Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b622389c5 | |||
| a820a95ebb | |||
| ea9c746852 | |||
| d60a51c9e7 | |||
| 9aabb01e2a | |||
| 2815adee03 | |||
| 9f51596e2f | |||
| 87d176153a | |||
| 7d4b4ad736 | |||
| 569991bb15 | |||
| c915115096 | |||
| 908d92a7e8 | |||
| 43f2c37538 | |||
| 6d3f5b5b01 | |||
| eda1112f3b | |||
| 71041029e2 | |||
| 35018226ef | |||
| 8833a9c76b | |||
| 6c07409452 | |||
| 1c2541f7d6 | |||
| 9e25f18a3e | |||
| 197897516e | |||
| 767748720a | |||
| 58051b5af1 | |||
| f9b2391a8b | |||
| f229795cea | |||
| 6e5364a0ed | |||
| 86817d6d06 | |||
| 0fc2e3a18a | |||
| 453919db20 | |||
| ad60e10e95 | |||
| 1528697287 | |||
| dbdab2d570 | |||
| b9371dcac6 | |||
| 62c2e92ec0 | |||
| aec94eb2e8 | |||
| 4dfe106fe3 | |||
| 2e0e2fd0bb | |||
| f3fa6b353a | |||
| 6645f64c3e | |||
| f10e0068dd | |||
| 9b124d9194 | |||
| 12530c8a95 | |||
| 51256c4c9a | |||
| 76481c2736 | |||
| bcc2305cd0 | |||
| 0ceeac8df4 | |||
| 4fae13af75 | |||
| 774217199e | |||
| 2db59d52a7 | |||
| 92d5fd580c | |||
| edeef19ff0 | |||
| 018f7a6f47 | |||
| eca41798bd | |||
| cc423567e7 | |||
| 8088ef9e00 | |||
| 666b924d29 | |||
| e52c616592 | |||
| 2b97bac51e | |||
| ab42db2b87 | |||
| 94d553570d | |||
| 2e97b905b4 | |||
| fbcca449be | |||
| 2076e4a788 | |||
| 30eb6add1b | |||
| dc266056d1 | |||
| 1c786b7156 | |||
| a3af10a830 | |||
| c0de473382 | |||
| 3e534340bf | |||
| 1a704d704d | |||
| e57adcb001 | |||
| bec7362b7b | |||
| a3ec746a01 | |||
| af0eec250e | |||
| 20aa2d59c9 | |||
| 4bad90dedb | |||
| 2b8d0f74fa | |||
| af9d2133dc | |||
| a1fdfccd61 | |||
| 5c05163266 | |||
| 92d2629001 | |||
| bdcfccce77 | |||
| f4deccacc9 | |||
| 8aaac01de6 | |||
| feb6f2c03d | |||
| 99bb3526db | |||
| bb8cb8d014 | |||
| 5e66aa8f22 | |||
| e332f167b2 | |||
| 322401b9af | |||
| 4f34a232d4 | |||
| 93987f2dfc |
@@ -9,6 +9,7 @@
|
|||||||
/mavwaked
|
/mavwaked
|
||||||
/mavmaild
|
/mavmaild
|
||||||
/mavupdate
|
/mavupdate
|
||||||
|
/mavgpud
|
||||||
|
|
||||||
# Certs (private keys, don't commit)
|
# Certs (private keys, don't commit)
|
||||||
certs/
|
certs/
|
||||||
@@ -40,6 +41,9 @@ deploy/telegram.env
|
|||||||
deploy/zenmoney.token
|
deploy/zenmoney.token
|
||||||
# IMAP password, read by mavmaild (never in argv, never committed)
|
# IMAP password, read by mavmaild (never in argv, never committed)
|
||||||
deploy/imap.password
|
deploy/imap.password
|
||||||
|
# Compose interpolation secrets — MAVEN_AMBIENT_TOKEN today. docker compose
|
||||||
|
# reads this file itself; it is not an env_file on any service.
|
||||||
|
/.env
|
||||||
|
|
||||||
# Temp files
|
# Temp files
|
||||||
/tmp/
|
/tmp/
|
||||||
@@ -63,3 +67,6 @@ coverage.out
|
|||||||
/HANDOFF.md
|
/HANDOFF.md
|
||||||
/models/stt
|
/models/stt
|
||||||
/models/tts
|
/models/tts
|
||||||
|
|
||||||
|
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
|
||||||
|
.env
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
beyond the model and tts work, the useful additions are mostly around **reliability, context, and reach**, not more intelligence.
|
|
||||||
|
|
||||||
## highest-value additions
|
|
||||||
|
|
||||||
### 1. unified event intake
|
|
||||||
|
|
||||||
maven should receive normalized events from:
|
|
||||||
|
|
||||||
* praxis
|
|
||||||
* calendar
|
|
||||||
* telegram
|
|
||||||
* local notifications
|
|
||||||
* system/service health
|
|
||||||
* manual checklists
|
|
||||||
* eventually email bridges
|
|
||||||
|
|
||||||
one internal envelope:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Event struct {
|
|
||||||
Source string
|
|
||||||
Kind string
|
|
||||||
EntityIDs []string
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
Priority string
|
|
||||||
OccurredAt time.Time
|
|
||||||
Payload json.RawMessage
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
this gives digestion one stable input instead of source-specific logic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. explicit morning routine engine — **core engine done (2026-07-20)**
|
|
||||||
|
|
||||||
`internal/morning` — pure checklist engine, mirrors `internal/loop`/
|
|
||||||
`internal/routine`'s no-I/O contract. `Evaluate(routine, facts, now)` answers
|
|
||||||
"what's still missing" any time (order-independent — checks facts, not
|
|
||||||
sequence); `Due(routines, facts, last, now)` fires the once-per-day nag only
|
|
||||||
at `NudgeAt` (defaults to window end) and only when something's unevidenced,
|
|
||||||
with a `last`-map dedupe identical in shape to `routine.Due`'s cold-start/
|
|
||||||
last-fire tracking. Evidence is just a fact timestamped inside today's
|
|
||||||
window — manual (voice-tapped) and inferred (another daemon writing the same
|
|
||||||
key) are indistinguishable, satisfying the manual/inferred requirement for
|
|
||||||
free. Weekday/weekend variants are two `Routine`s with different `Weekdays`
|
|
||||||
sets under different names. Wired into `config.MorningRoutineConfig` +
|
|
||||||
`cmd/mavend/tick.go`'s `fireMorningRoutines` (reads only the fact keys the
|
|
||||||
configured items reference, dispatches through the normal severity/presence
|
|
||||||
routing table, body is literal joined item labels — not LLM-phrased, same
|
|
||||||
no-hallucination rationale as cron routines). 13 unit tests in
|
|
||||||
`internal/morning/morning_test.go`.
|
|
||||||
|
|
||||||
Added since (2026-07-20, same day): a read-only `/morning` page in mavweb —
|
|
||||||
`ipc.CoreAPI.MorningStatus` (new wire method, mirrors `TickTrace`'s
|
|
||||||
daemon-cache-only shape: the store adapter errors, `daemonAPI` serves it from
|
|
||||||
a `tickLoop.morningStatus` closure) returns each routine's active/window/
|
|
||||||
per-item done state, server-rendered same as `/trace` (no live-update loop —
|
|
||||||
checklist state moves on minutes, not seconds).
|
|
||||||
|
|
||||||
Not yet done: no config wired in `deploy/mavend.json` (no morning routines
|
|
||||||
configured on homesrv yet — add items there when the medicine/water/pets
|
|
||||||
fact keys the phone/desktop write are settled), no voice query path for
|
|
||||||
"what did I miss this morning" (Evaluate supports it; nothing calls it yet),
|
|
||||||
no way to create/edit routines from the web UI — construction still means
|
|
||||||
hand-editing config, deliberately deferred: routines are operator-declared
|
|
||||||
config (like cron routines), and a CRUD editor would mean moving them to a
|
|
||||||
DB table + hot-reload, a bigger change than this pass.
|
|
||||||
|
|
||||||
not ordinary reminders.
|
|
||||||
|
|
||||||
support:
|
|
||||||
|
|
||||||
* required morning items
|
|
||||||
* order-independent completion
|
|
||||||
* soft time windows
|
|
||||||
* skipped-step detection
|
|
||||||
* one nudge, not repeated spam
|
|
||||||
* manual and inferred completion evidence
|
|
||||||
* weekend/weekday variants
|
|
||||||
|
|
||||||
example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
08:00–11:00
|
|
||||||
- medicine
|
|
||||||
- water
|
|
||||||
- pets
|
|
||||||
- check praxis attention
|
|
||||||
```
|
|
||||||
|
|
||||||
maven should know what is still missing, not merely fire four timers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. cross-device presence
|
|
||||||
|
|
||||||
**status (2026-07-20):** the hysteresis engine and 3 of the listed signals are
|
|
||||||
already built and wired live: `internal/store/presence.go` (noisy-OR combiner
|
|
||||||
+ Schmitt-trigger bucket resolve), fed by `desk_active` (workstation, via
|
|
||||||
`scripts/desk-active.sh` posting to `/api/signal`), `page_heartbeat` (mavweb
|
|
||||||
tab, `app.js`), and `wg_handshake` (`mavpoll` polling `wg show`) — threaded
|
|
||||||
into the tick loop via `internal/loop/gather.go`. Not done: phone-reachable,
|
|
||||||
homesrv-available, audio-output, and active-maven-client signals from the
|
|
||||||
list below are still missing.
|
|
||||||
|
|
||||||
a small presence daemon on each trusted device:
|
|
||||||
|
|
||||||
* workstation active/idle
|
|
||||||
* phone reachable
|
|
||||||
* homesrv available
|
|
||||||
* last keyboard/mouse activity
|
|
||||||
* wireguard presence
|
|
||||||
* current audio output
|
|
||||||
* active maven client
|
|
||||||
|
|
||||||
mavend receives only compact state, not raw activity logs.
|
|
||||||
|
|
||||||
useful for:
|
|
||||||
|
|
||||||
* choosing delivery channel
|
|
||||||
* suppressing voice while away
|
|
||||||
* surfacing reminders when you return
|
|
||||||
* knowing whether an agent result should be spoken or sent as text
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. interruption policy — **done (2026-07-20), turned out to already be built**
|
|
||||||
|
|
||||||
audited the existing code before writing anything new: `internal/loop.Gate`
|
|
||||||
already answers deliver_now vs. drop (quiet-hours/cooldown/snooze/presence/
|
|
||||||
calendar-busy), and `cmd/mavend/tick.go`'s `digestQ` + `config.DigestConfig`
|
|
||||||
already implement queue/digest (low-severity nudges batch into one
|
|
||||||
notification, flushed on window elapsed or max-items reached). The four
|
|
||||||
outcomes below were already covered by these two mechanisms; nothing new to
|
|
||||||
build for the core policy.
|
|
||||||
|
|
||||||
Gap that *was* real: `deploy/mavend.json` had no `digest` block, so batching
|
|
||||||
was disabled in prod despite being fully implemented. Fixed — see the config
|
|
||||||
change alongside this note.
|
|
||||||
|
|
||||||
before delivering anything, evaluate:
|
|
||||||
|
|
||||||
```text
|
|
||||||
urgency
|
|
||||||
current activity
|
|
||||||
quiet hours
|
|
||||||
recent nudges
|
|
||||||
available channels
|
|
||||||
whether already surfaced
|
|
||||||
```
|
|
||||||
|
|
||||||
result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
deliver_now
|
|
||||||
queue
|
|
||||||
digest
|
|
||||||
drop
|
|
||||||
```
|
|
||||||
|
|
||||||
this prevents maven from becoming annoying once praxis and other sources start producing more data.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. entity-aware memory — **done (2026-07-20)**
|
|
||||||
|
|
||||||
`03fa52d`/`9876187` (Vikunja #279): facts gain `Subject`/`EntityID`/
|
|
||||||
`ResolutionState`; an async enrichment worker resolves free-text subjects to
|
|
||||||
canonical Nexus entity_ids (mirrors Praxis's enrichment pattern). Ambiguous
|
|
||||||
or unreachable Nexus never guesses — the fact stays `pending` or terminal
|
|
||||||
`ambiguous`. Voice-tapped facts (`IntentFact`) now flow into the enrichment
|
|
||||||
queue automatically via an optional `Subject` field on `WriteFactReq` (old
|
|
||||||
callers unaffected).
|
|
||||||
|
|
||||||
Landed alongside this in the same session (not originally on this list, but
|
|
||||||
closes the plumbing gaps the last brief flagged for Nexus/Praxis maturity):
|
|
||||||
a typed Praxis lifecycle client (`398997f` — surface/acknowledge/resolve/
|
|
||||||
ignore/pin; fixes the surfaced≠acknowledged gap where reading an item aloud
|
|
||||||
left no trace), correlation-ID/version headers on the Nexus/Praxis clients
|
|
||||||
(`b743860`), entity-scoped Praxis attention queries (`0579ef9`), a durable
|
|
||||||
delivery outbox with begin-before-send/complete-after semantics
|
|
||||||
(`29f23e3`+`9ff726e` — closes a duplicate-send-on-crash bug), fail-closed
|
|
||||||
handling on ambiguous IPC mutation outcomes and Nexus/Hexis dependency
|
|
||||||
errors (`838fde1`+`d9fa4d6`), and a reusable fake-ecosystem test harness
|
|
||||||
with fault injection (`c932cd8`).
|
|
||||||
|
|
||||||
connect maven memory to nexus ids.
|
|
||||||
|
|
||||||
instead of:
|
|
||||||
|
|
||||||
```text
|
|
||||||
key = "кошачий фонтан"
|
|
||||||
```
|
|
||||||
|
|
||||||
store:
|
|
||||||
|
|
||||||
```text
|
|
||||||
entity_id = ent_pet_water_fountain
|
|
||||||
predicate = refilled_at
|
|
||||||
value = 2026-07-19T...
|
|
||||||
```
|
|
||||||
|
|
||||||
benefits:
|
|
||||||
|
|
||||||
* stable russian/english aliases
|
|
||||||
* fewer duplicate facts
|
|
||||||
* better “when did i last…” queries
|
|
||||||
* easier routine detection
|
|
||||||
* cleaner praxis correlation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. bounded follow-up state
|
|
||||||
|
|
||||||
for short continuations:
|
|
||||||
|
|
||||||
* “yes”
|
|
||||||
* “tomorrow”
|
|
||||||
* “the second one”
|
|
||||||
* “not that project”
|
|
||||||
* “do it later”
|
|
||||||
|
|
||||||
store explicit pending state instead of relying on chat history:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type PendingInteraction struct {
|
|
||||||
Kind string
|
|
||||||
Candidates []string
|
|
||||||
Args json.RawMessage
|
|
||||||
ExpiresAt time.Time
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
this matters a lot for a 1.7b model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. evaluation lab — **skipped for now (2026-07-20)**
|
|
||||||
|
|
||||||
runs on a different machine (GPU box), and CPT is currently in progress
|
|
||||||
there — deprioritized until the training pipeline has a checkpoint to gate.
|
|
||||||
Not abandoned, just off the immediate list.
|
|
||||||
|
|
||||||
before every new checkpoint or lora deploy:
|
|
||||||
|
|
||||||
* routing accuracy
|
|
||||||
* slot accuracy
|
|
||||||
* malformed json rate
|
|
||||||
* russian/english mixed input
|
|
||||||
* ambiguous entity handling
|
|
||||||
* reminder vs note vs fact
|
|
||||||
* direct answer vs tool call
|
|
||||||
* confirmation safety
|
|
||||||
* phrasing quality
|
|
||||||
* latency and ram
|
|
||||||
|
|
||||||
also replay real anonymized traces against old and new checkpoints.
|
|
||||||
|
|
||||||
this should be a hard deployment gate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. replayable full-system simulator
|
|
||||||
|
|
||||||
fake:
|
|
||||||
|
|
||||||
* clock
|
|
||||||
* presence
|
|
||||||
* caldav
|
|
||||||
* telegram
|
|
||||||
* praxis
|
|
||||||
* nexus
|
|
||||||
* hexis
|
|
||||||
* stt
|
|
||||||
* tts
|
|
||||||
* llama-server
|
|
||||||
|
|
||||||
scenario:
|
|
||||||
|
|
||||||
```text
|
|
||||||
08:30 user appears
|
|
||||||
08:35 medicine not completed
|
|
||||||
08:40 correx agent waits
|
|
||||||
08:45 calendar sync stale
|
|
||||||
08:50 user says “what did i miss?”
|
|
||||||
```
|
|
||||||
|
|
||||||
assert:
|
|
||||||
|
|
||||||
* what tools were called
|
|
||||||
* what was surfaced
|
|
||||||
* what stayed unresolved
|
|
||||||
* what maven said
|
|
||||||
* what was not executed
|
|
||||||
|
|
||||||
this will save more time than another feature daemon.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## useful second-wave additions
|
|
||||||
|
|
||||||
### voice session quality
|
|
||||||
|
|
||||||
* barge-in
|
|
||||||
* interrupt tts on wake word
|
|
||||||
* partial stt display
|
|
||||||
* confidence-aware clarification
|
|
||||||
* retry only failed stt segment
|
|
||||||
* per-room microphone profiles
|
|
||||||
* noise-floor calibration
|
|
||||||
* short response mode when speaking
|
|
||||||
|
|
||||||
### notification bridge framework
|
|
||||||
|
|
||||||
small adapters for:
|
|
||||||
|
|
||||||
* ntfy
|
|
||||||
* telegram
|
|
||||||
* matrix
|
|
||||||
* web push
|
|
||||||
* android notification forwarding
|
|
||||||
* local dbus notifications
|
|
||||||
|
|
||||||
normalize into maven/praxis events instead of treating each as a separate feature.
|
|
||||||
|
|
||||||
### local knowledge ingestion
|
|
||||||
|
|
||||||
* markdown/docs ingestion
|
|
||||||
* git repo summaries
|
|
||||||
* project decision records
|
|
||||||
* conversation exports
|
|
||||||
* provenance and source links
|
|
||||||
* incremental reindexing
|
|
||||||
|
|
||||||
keep this read-only and separate from personal fact memory.
|
|
||||||
|
|
||||||
### service self-diagnostics
|
|
||||||
|
|
||||||
`maven doctor`:
|
|
||||||
|
|
||||||
* socket reachability
|
|
||||||
* model health
|
|
||||||
* stt/tts readiness
|
|
||||||
* embedder availability
|
|
||||||
* caldav freshness
|
|
||||||
* telegram poll state
|
|
||||||
* praxis/nexus/hexis reachability
|
|
||||||
* db integrity
|
|
||||||
* disk usage
|
|
||||||
* recent failures
|
|
||||||
|
|
||||||
### config and secret management
|
|
||||||
|
|
||||||
* schema-validated config
|
|
||||||
* config migration
|
|
||||||
* secret references instead of inline values
|
|
||||||
* dry-run validation
|
|
||||||
* redacted config dump
|
|
||||||
* per-daemon health config
|
|
||||||
* startup dependency report
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## things i would not build yet
|
|
||||||
|
|
||||||
* autonomous multi-step planning
|
|
||||||
* large external reasoner
|
|
||||||
* generic workflow engine
|
|
||||||
* self-editing memory
|
|
||||||
* automatic hexis actions from praxis
|
|
||||||
* emotion simulation beyond phrasing
|
|
||||||
* full home-assistant replacement
|
|
||||||
* more model layers before routing is stable
|
|
||||||
|
|
||||||
## recommended order
|
|
||||||
|
|
||||||
**status as of 2026-07-20:**
|
|
||||||
|
|
||||||
1. ~~evaluation lab~~ — **skipped, GPU-box work, deprioritized while CPT is in progress**
|
|
||||||
2. ~~entity-aware memory~~ — **done** (`03fa52d`/`9876187`, plus adjacent
|
|
||||||
Nexus/Praxis plumbing hardening — see item 5 above)
|
|
||||||
3. ~~morning routine engine~~ — **core engine done** (`internal/morning` +
|
|
||||||
`cmd/mavend` wiring — see item 2 above; not yet configured on homesrv,
|
|
||||||
no voice query, no web UI)
|
|
||||||
4. interruption/delivery policy
|
|
||||||
5. presence agents
|
|
||||||
6. unified event intake
|
|
||||||
7. full-system simulator
|
|
||||||
8. notification bridges
|
|
||||||
9. knowledge ingestion
|
|
||||||
10. voice-session polish
|
|
||||||
|
|
||||||
the main goal should be: **maven reliably knows what is happening, knows what you meant, and chooses the least annoying correct response**. everything else can wait.
|
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ live in sibling repos next to this one.
|
|||||||
|
|
||||||
Division of labour: Nexus identifies, Praxis observes, Hexis acts, Maven understands
|
Division of labour: Nexus identifies, Praxis observes, Hexis acts, Maven understands
|
||||||
and coordinates. Maven is not the source of truth for any of the three. The full
|
and coordinates. Maven is not the source of truth for any of the three. The full
|
||||||
contract is `MAVEN_ECOSYSTEM_ARCHITECTURE.md`, and the constraints that bite during
|
contract is `docs/ecosystem.md`, and the constraints that bite during
|
||||||
implementation are summarised in `CLAUDE.md`.
|
implementation are summarised in `CLAUDE.md`.
|
||||||
|
|
||||||
Where things are in this repo:
|
Where things are in this repo:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B
|
|||||||
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
||||||
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
||||||
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
|
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
|
||||||
talk fixture. See `MODEL-BAKEOFF-31-07-2026.md`. It is a Thinking variant, so `n_ctx` is 4096
|
talk fixture. See `docs/evals/2026-07-31-model-bakeoff.md`. It is a Thinking variant, so `n_ctx` is 4096
|
||||||
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
|
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
|
||||||
|
|
||||||
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
||||||
@@ -25,9 +25,24 @@ Spanish. Their strong published IFEval/BFCL numbers are English-only. Model file
|
|||||||
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
||||||
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
||||||
|
|
||||||
See `REARCH.md` for the target architecture, `DESIGN.md` for the folded design spec, and
|
See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and
|
||||||
`AGENTS.md` for local-preview + model-download recipes.
|
`AGENTS.md` for local-preview + model-download recipes.
|
||||||
|
|
||||||
|
**Model work is moving to the workstation** (owner's call, 2026-08-02). homesrv cannot grow a
|
||||||
|
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
|
||||||
|
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
|
||||||
|
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
|
||||||
|
stays on homesrv permanently, because it backs that floor. Read `docs/offload.md` before
|
||||||
|
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||||
|
are the work.
|
||||||
|
|
||||||
|
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
|
||||||
|
through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A
|
||||||
|
world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not
|
||||||
|
free — `worldGap` in `cmd/mavend/worldmodel.go`, which he hears instead of an invented
|
||||||
|
answer. A box with no `workstation` block behaves exactly as it did before the seam: naming
|
||||||
|
a gap requires a gap. The offload table in `docs/offload.md` says which caller is which.
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
|
|
||||||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
||||||
@@ -72,7 +87,7 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
|
|||||||
|
|
||||||
Maven is one of four services. It owns conversation and personal memory. It does not
|
Maven is one of four services. It owns conversation and personal memory. It does not
|
||||||
own identity, operational state, or execution. Full contract in
|
own identity, operational state, or execution. Full contract in
|
||||||
`MAVEN_ECOSYSTEM_ARCHITECTURE.md`.
|
`docs/ecosystem.md`.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordinates.
|
Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordinates.
|
||||||
@@ -113,7 +128,7 @@ Every cross-service call carries a correlation id minted once per action
|
|||||||
on in deploy** — this section used to say it was wired `nil`, which stopped being true on
|
on in deploy** — this section used to say it was wired `nil`, which stopped being true on
|
||||||
2026-07-31.
|
2026-07-31.
|
||||||
|
|
||||||
- **LLM router (the intended design, REARCH.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
- **LLM router (the intended design, docs/rearchitecture.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||||||
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
||||||
demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via
|
demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via
|
||||||
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router`
|
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router`
|
||||||
@@ -127,13 +142,23 @@ on in deploy** — this section used to say it was wired `nil`, which stopped be
|
|||||||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||||||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||||||
|
|
||||||
Measured on the 77-case RU fixture (`MODEL-BAKEOFF-31-07-2026.md`): the classifier scores
|
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
|
||||||
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
|
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
|
||||||
cascade at p50 ≈825ms. Accuracy roughly doubled, latency is ~27× worse, and that trade was
|
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
|
||||||
accepted deliberately. **The ≈2.7s figure that stood here until 2026-08-02 was contention,
|
seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores
|
||||||
not the model.** See `ROUTING-EVAL-31-07-2026.md` line 61, which measures the LLM router at
|
77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy,
|
||||||
|
not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure
|
||||||
|
that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
|
||||||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
||||||
work off the bakeoff table. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
work off the bakeoff table.
|
||||||
|
|
||||||
|
**The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing
|
||||||
|
completes through `llm.Pair` against gemma-4-12b and scores **84.4% full / 93.5% intent-only at
|
||||||
|
p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
|
||||||
|
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
|
||||||
|
routing change against the classifier and the resident model, since those are what always answer.
|
||||||
|
|
||||||
|
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||||||
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
|||||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||||
|
|
||||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update
|
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update build-gpud
|
||||||
|
|
||||||
build-stt:
|
build-stt:
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
@@ -59,6 +59,12 @@ build-mail:
|
|||||||
build-update:
|
build-update:
|
||||||
$(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/
|
$(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/
|
||||||
|
|
||||||
|
# mavgpud runs on the workstation, not here. It is built with the rest so a
|
||||||
|
# broken supervisor is caught by `make build` on homesrv rather than by the
|
||||||
|
# workstation refusing to serve. Copy the binary over, do not `make deploy` it.
|
||||||
|
build-gpud:
|
||||||
|
$(GO) build $(GOFLAGS) -o mavgpud ./cmd/mavgpud/
|
||||||
|
|
||||||
run-web: build-web
|
run-web: build-web
|
||||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||||
|
|
||||||
@@ -78,7 +84,7 @@ deps-go:
|
|||||||
done
|
done
|
||||||
$(GO) version
|
$(GO) version
|
||||||
|
|
||||||
# fmt-check fails if any file needs gofmt. DESIGN.md has always said `make
|
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
||||||
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
||||||
# Run `gofmt -w` on whatever this prints.
|
# Run `gofmt -w` on whatever this prints.
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@@ -191,7 +197,7 @@ deps-piper:
|
|||||||
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
||||||
# a short question against a longer passage, which is what note recall is.
|
# a short question against a longer passage, which is what note recall is.
|
||||||
# The quantized file is the one we download, deploy and measure — see
|
# The quantized file is the one we download, deploy and measure — see
|
||||||
# RECALL-EVAL-31-07-2026.md.
|
# docs/evals/2026-07-31-recall.md.
|
||||||
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
||||||
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
||||||
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
||||||
|
|||||||
-468
@@ -1,468 +0,0 @@
|
|||||||
## Maven — current state (updated 2026-07-20)
|
|
||||||
|
|
||||||
### Session 2026-07-20 — ecosystem hardening + entity-aware facts
|
|
||||||
|
|
||||||
Ten commits, focused on closing the Nexus/Praxis integration gaps flagged
|
|
||||||
as "wired but immature" in the prior review, plus the entity-aware-memory
|
|
||||||
backlog item (`20-07-2026-BACKLOG.md` item 5).
|
|
||||||
|
|
||||||
- **Entity-aware fact resolution (Vikunja #279)** — facts gain
|
|
||||||
`Subject`/`EntityID`/`ResolutionState`; an async worker resolves
|
|
||||||
free-text subjects to canonical Nexus entity_ids (mirrors Praxis's own
|
|
||||||
enrichment pattern). Ambiguous/unreachable Nexus never guesses — stays
|
|
||||||
`pending` or terminal `ambiguous`. Voice-tapped facts (`IntentFact`) flow
|
|
||||||
into the queue automatically via an optional `Subject` field on
|
|
||||||
`WriteFactReq` (old callers unaffected, no signature break).
|
|
||||||
- **Typed Praxis lifecycle client (Vikunja #271)** — `GetItem`/`Search`/
|
|
||||||
`Surface`/`Acknowledge`/`Resolve`/`Ignore`/`Pin`, routed through new RU/EN
|
|
||||||
dialogue verbs. Fixes a real lifecycle-invariant bug: reading an
|
|
||||||
attention item aloud now calls `Surface` — previously the digest path
|
|
||||||
read items without recording that they'd been surfaced, so "Maven
|
|
||||||
mentioned it" was indistinguishable from "never came up."
|
|
||||||
- **Durable delivery outbox (Vikunja #270)** — `BeginDeliveryAttempt`
|
|
||||||
before `Send`, `CompleteDeliveryAttempt` after; a stale `pending` row
|
|
||||||
found at startup reconciles to `unknown` (never silently resent or
|
|
||||||
dropped — same rule as Hexis's execution-timeout handling). Closes a
|
|
||||||
crash-window duplicate-send bug. Wired into `DispatchNudge`,
|
|
||||||
`DispatchReminder`, `RepeatUnacked`; reconciliation runs once at boot
|
|
||||||
before the tick loop resumes.
|
|
||||||
- **Fail-closed IPC/dependency handling (Vikunja #269, #272/#273)** —
|
|
||||||
ambiguous mutation outcomes (frame sent, reply lost) no longer blindly
|
|
||||||
retry; Nexus/Hexis dependency errors fail closed instead of guessing.
|
|
||||||
- **Correlation IDs + version headers (Vikunja #273)** — the hand-rolled
|
|
||||||
Nexus/Praxis HTTP clients now send `X-Nexus-Version`/`X-Praxis-Version`
|
|
||||||
and thread the same correlation ID already generated in
|
|
||||||
`executeCapability` through the whole call chain, matching the Hexis
|
|
||||||
client's existing behavior.
|
|
||||||
- **Entity-scoped Praxis attention queries** — callers holding a resolved
|
|
||||||
entity_id can ask "what needs attention for this entity" directly
|
|
||||||
instead of filtering the unscoped list client-side.
|
|
||||||
- **Fake-ecosystem test harness with fault injection** — a reusable
|
|
||||||
`fakeServer` (Nexus/Praxis/Hexis fixtures, runtime-toggleable
|
|
||||||
`SetFault`, fake clock) replacing ad-hoc per-test `httptest` servers;
|
|
||||||
covers a gap that had zero test coverage (`handlePraxisAct`) and adds a
|
|
||||||
fault-then-recovery regression test for the fail-closed fixes above.
|
|
||||||
- **Ops fix** — `deploy/mavend.json`'s phraser was pointed at a 4B model
|
|
||||||
with `n_gpu_layers=99`, which OOM'd under memory pressure and left a
|
|
||||||
zombie `llama-server` child; swapped to the 2B Qwen model matching the
|
|
||||||
intended resident-model size.
|
|
||||||
|
|
||||||
Net effect: the Nexus/Praxis wiring described as "plumbing exists, thin
|
|
||||||
compared to Maven's test depth" in the prior review is now materially
|
|
||||||
hardened — typed clients, fail-closed error handling, durable delivery,
|
|
||||||
and a proper fault-injection test harness are all in place. Evaluation lab
|
|
||||||
(`20-07-2026-BACKLOG.md` item 7) is explicitly skipped for now — it runs
|
|
||||||
on the GPU box, which is occupied by CPT. Morning routine engine (backlog
|
|
||||||
item 3) is next up, not started.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **Resolved 2026-07-30 (task #318).** The resident checkpoint is
|
|
||||||
> **Qwen3.5-0.8B** (`Q4_K_M`), set in `deploy/mavend.json`; the **target** is
|
|
||||||
> the locally CPT'd **Qwen3-1.7B**, still training (#122). Older model claims
|
|
||||||
> below — the LFM references, the pipeline line, and the "swapped to the 2B
|
|
||||||
> Qwen model" ops entry above — are historical. Read them as a log of what was
|
|
||||||
> true at the time, not as current fact. Note also that `/mnt/hdd1/llms` is
|
|
||||||
> bind-mounted over `models/llm/`, so the LFM2.5 gguf in the repo tree is
|
|
||||||
> never loaded.
|
|
||||||
|
|
||||||
Architecture decision (as written on 2026-07-20): the target resident
|
|
||||||
router/phraser is the locally trained Qwen3-1.7B model — still the target as
|
|
||||||
of 2026-07-30. Older LFM references below describe the then-deployed
|
|
||||||
historical stack, not the target checkpoint. RU CPT has a successful
|
|
||||||
full-weight checkpoint at step 1000/8077; evaluation and Qwen3 SFT tooling are
|
|
||||||
tracked in `docs/plans/2026-07-18-qwen3-resident-training-eval.md`.
|
|
||||||
|
|
||||||
Consolidated status. The reactive↔proactive core is closed and testable through
|
|
||||||
the web PWA. The former SPEC's open items 1–7 (now `DESIGN.md` § execution ledger) are landed (protocol doc, away-channel
|
|
||||||
fallthrough, CalDAV poller, quiet-hours schedule, tools enable/disable, note RAG,
|
|
||||||
passkey step-up); item 8 (multi-user) is deliberately deferred — see the tail.
|
|
||||||
The two big infra gaps from the jul5 revision are closed on `overnight-jul5`:
|
|
||||||
**at-rest encryption** (AES-256-GCM, tmpfs working copy — not sqlcipher, see
|
|
||||||
`internal/store/crypt.go`) and **Docker deployment** (one image, six daemon
|
|
||||||
containers). The `overnight-jul6` session (now on `master`) closed the biggest
|
|
||||||
*query-surface* gaps — **calendar querying, general-knowledge answers, and
|
|
||||||
weather** — plus a populated homelab act allowlist and two pure scaffolds
|
|
||||||
(dialogue state, long-term-memory vector store). ~15.2k LOC + ~8.5k test, 303
|
|
||||||
tests, `-race` in `make test`.
|
|
||||||
|
|
||||||
### Access model
|
|
||||||
|
|
||||||
- **Phone** → needs the wg tunnel to reach homesrv (no homesrv DNS otherwise;
|
|
||||||
raw IP or a DNS tweak can bypass, not the default).
|
|
||||||
- **PC** → uses homesrv DNS, resolves the domains over local-net, **no wg needed**.
|
|
||||||
- nginx + ufw both scope to `10.42.0.0/24` (wg) + `192.168.1.0/24` (LAN), deny all else.
|
|
||||||
- **Surface in use now: the web PWA (`mavweb`).** Voice PTT + in-app nudges both ride it.
|
|
||||||
|
|
||||||
### Works end-to-end (tested)
|
|
||||||
|
|
||||||
- **Reactive voice:** PWA record → Whisper STT (`mavsttd`) → ONNX classifier →
|
|
||||||
resident phraser (llama-server subprocess; Qwen3.5-0.8B as of 2026-07-30 —
|
|
||||||
this line historically named "LFM 2.5-1.2B") → Piper TTS
|
|
||||||
(`mavttsd`) → reply.
|
|
||||||
HTTP POST path (mobile-Chrome drops WS for the audio).
|
|
||||||
- **Capture:** `fact` (EN **and RU** — root-substring recognizers) + `reminder`
|
|
||||||
persist through CoreAPI (`source=tap:voice`). This is the substrate the care
|
|
||||||
rules read.
|
|
||||||
- **Notes / query (semantic recall, sqlite — no chroma):** `note` → embed (the
|
|
||||||
classifier's ONNX embedder) → `notes` table. `query` → embed → brute-force
|
|
||||||
cosine top-k → confidence-gated (below `queryMinScore` 0.55 ⇒ "no note", not a
|
|
||||||
guess). **Note RAG (SPEC item 6):** the gated top-k feed the phraser
|
|
||||||
(`PhraseQuery`) to compose a natural answer ("вот что я нашла: …") instead of
|
|
||||||
a verbatim dump; raw-notes fallback on any LLM error. Stub is deterministic.
|
|
||||||
- **Monitoring (`/dash`):** mavweb server-renders presence + recent nudges (by
|
|
||||||
outcome) + recent facts from the append-only store via CoreAPI. Read-only,
|
|
||||||
meta-refresh, no JS.
|
|
||||||
- **Proactive loop:** 60s dumb ticker, pure predicates over a State snapshot,
|
|
||||||
universal gate (quiet-hours/presence/cooldown/snooze/calendar), one-nudge-per-
|
|
||||||
tick max-severity, reminders (gate-bypassing), sev4 repeat-til-ack, feedback
|
|
||||||
auto-tuner (outcome ratio → bounded cooldown, persisted as `source=feedback`).
|
|
||||||
- **Rules:** water/meal/break (sev1–2 care), service_down (sev4, `poll:uptimekuma`),
|
|
||||||
netdata_critical (sev3, `poll:netdata`).
|
|
||||||
- **Routines (`internal/routine`):** operator-declared clockwork — the third
|
|
||||||
proactive class beside reminders (user-stated) and care rules (world-state).
|
|
||||||
Config `routines[]` (cron + literal RU body + severity) fire through the normal
|
|
||||||
dispatcher on schedule (an 08:00 briefing, a 22:00 wind-down). Bodies are
|
|
||||||
literal (not LLM-phrased ⇒ can't hallucinate); rule name `routine:<name>` so
|
|
||||||
they don't pollute the care autotuner; cold-start guard seeds on first sight so
|
|
||||||
a restart never replays a missed schedule. Pure `routine.Due`, unit-tested; the
|
|
||||||
tick driver holds the last-fired map.
|
|
||||||
- **Env facts (`mavpoll`):** netdata alarms → `netdata_alarm` (fires immediately
|
|
||||||
on a real CRITICAL); kuma monitor_status → `service_down`. Writes only on
|
|
||||||
value-change (no append-only churn).
|
|
||||||
- **Presence:** noisy-OR decay + Schmitt hysteresis. Live via `page_heartbeat`
|
|
||||||
(PWA auto-pings `/api/signal` every 30s → present when a tab's open).
|
|
||||||
- **Delivery:** ntfy / telegram / voice by `f(severity, presence)`; minimal body
|
|
||||||
on away channels. PWA subscribes to ntfy over **WebSocket** for in-app nudges.
|
|
||||||
- **Away-channel fallthrough (SPEC item 2):** when the router picks voice but no
|
|
||||||
live session exists at push time (presence guess was wrong), the dispatcher
|
|
||||||
reroutes through the AWAY table — sev3→ntfy, sev4→telegram-repeat-til-ack,
|
|
||||||
sev≤2→drop — instead of silently dropping. Covers nudges + reminders.
|
|
||||||
- **Calendar busy (SPEC item 3, `mavcaldav`):** new poller queries a self-hosted
|
|
||||||
**Radicale** CalDAV server on an interval, writes `calendar_busy` + event facts
|
|
||||||
through CoreAPI (value-change only). The loop gate already consumes `calendar_busy`.
|
|
||||||
- **Quiet-hours schedule (SPEC item 4):** the gate reads `quiet_hours`; a config
|
|
||||||
time window (`voice.quiet_hours`, HH:MM, midnight-crossing handled) now sets it
|
|
||||||
on each tick — in addition to the "тихий режим" voice toggle. Both activate quiet.
|
|
||||||
- **Client protocol (SPEC item 1):** the voice wire format (length-prefixed JSON
|
|
||||||
frames) is published in `PROTOCOL.md`, generated from `internal/voice/wire.go`
|
|
||||||
so third-party clients don't need the Go source.
|
|
||||||
- **Passkey step-up (SPEC item 7):** `internal/webauthn` does real WebAuthn —
|
|
||||||
ES256/P-256 register + assert, ecdsa signature verification, rpIdHash + UP/UV
|
|
||||||
flag binding (UV = the gesture), sign-count regression check. `PasskeySession`
|
|
||||||
bumps the auth session L2→L3 for a TTL on assert. mavweb serves `/auth/passkey`
|
|
||||||
(enroll + step-up) + the begin/finish endpoints. Crypto is round-trip tested
|
|
||||||
(incl. tampered-sig / missing-UV / wrong-origin negatives).
|
|
||||||
- **Stability:** llama-server orphan leak fixed (`Pdeathsig` kills the child on
|
|
||||||
any mavend death); `kill-maven.sh` reaps strays (matches the model, not a
|
|
||||||
bogus `llama-server.*maven` pattern); `start-maven.sh` wires `-core` + poller.
|
|
||||||
|
|
||||||
### Wired but needs a deploy action (not code)
|
|
||||||
|
|
||||||
- **`desk_active`** (strongest presence signal) — `scripts/desk-active.sh` runs
|
|
||||||
on the **desk PC** (hypridle-gated systemd timer), posts over wg to mavweb.
|
|
||||||
- **`mavwaked`** (always-on listening) — needs a systemd user unit on a client
|
|
||||||
box (desk PC, pi, etc.) where the mic is attached. Connects to mavend over wg
|
|
||||||
or local net via `-addr`. Deferred until a client box is wired with a mic.
|
|
||||||
|
|
||||||
Caveats / gotchas:
|
|
||||||
- **desk_active is a workstation deploy, not code** — 0 facts ever written; presence
|
|
||||||
runs on page_heartbeat alone (dash reads "away"/"never at desk"). `scripts/desk-active.sh`
|
|
||||||
+ a hypridle-gated `maven-desk` timer must be installed on the desk PC (not homesrv).
|
|
||||||
- **Notes recall needs the ONNX embedder** — under the HashEmbedder floor, cosine is
|
|
||||||
lexical (token overlap), not semantic; scores are low, so most RU commands sit under
|
|
||||||
the 0.35 route threshold and clarify. Configure `voice.embedder` for confident recall+routing.
|
|
||||||
(The floor now at least tokenizes Cyrillic — see below — so it ranks correctly, just weakly.)
|
|
||||||
- **Switching the embedder model silently breaks old notes** — different dim ⇒
|
|
||||||
cosine 0 ⇒ they stop matching; brute-force can't re-embed. Re-embed on a model change.
|
|
||||||
- **`wg_handshake` is OFF and should stay off** — in this topology the phone only
|
|
||||||
runs wg when *outside*, so a fresh handshake means AWAY, not here. The `mavpoll
|
|
||||||
-wg` flag exists (defaults `""`) and could later back the spec's "away override"
|
|
||||||
by flipping the sign; as a presence-*here* signal it's inverted. desk_active +
|
|
||||||
page_heartbeat cover home presence.
|
|
||||||
- **Cold-start unlock tests are missing** — the key wrap/unwrap code
|
|
||||||
(`internal/webauthn/keywrap.go`) and locked-mode IPC gating (`cmd/mavend/main.go`)
|
|
||||||
are correct but have **zero test coverage**. The roadmap (item 2.1) required
|
|
||||||
three new test cases (wrap/unwrap round-trip, wrong-cred unwrap fails,
|
|
||||||
locked-mode IPC rejects non-unlock methods); none were written. `make test`
|
|
||||||
is green by omission. Write these before relying on the cold-start path with
|
|
||||||
real keys.
|
|
||||||
|
|
||||||
### Done since last revision (overnight-jul6, 2026-07-06)
|
|
||||||
|
|
||||||
Seven tasks (session board `SESSION-06-07-2026.md`, deleted 2026-07-30 — see git history), one commit each, merged to `master`.
|
|
||||||
This session was run through **opencode**, not Claude Code (co-author trailer).
|
|
||||||
|
|
||||||
Since then (**2026-07-06, second session**):
|
|
||||||
|
|
||||||
- **Always-on listening (gap 1, MVP)** — `cmd/mavwaked/`: 825 lines, 10 `-race`
|
|
||||||
tests. Energy-based VAD over 30ms windows (same RMS threshold as mavsttd's
|
|
||||||
`gateReason`), adaptive noise floor, speech→silence state machine. Captures
|
|
||||||
PCM from arecord(1) subprocess, sends `PushToTalk` with `Surface=SurfaceVoice`
|
|
||||||
(L0 — no destructive acts). Reply plays through aplay(1). No wake word yet
|
|
||||||
(pure VAD trigger); the 30ms frame shape matches silero-vad ONNX input 1:1,
|
|
||||||
so swapping energy-threshold for ONNX inference is a local change in vad.go.
|
|
||||||
`Makefile` `build-waked` target. Runs on client boxes (not docker/homesrv)
|
|
||||||
via systemd user unit; connects to mavend over wg or local net.
|
|
||||||
|
|
||||||
Since then (**2026-07-06, third session** — roadmap execution agent):
|
|
||||||
|
|
||||||
- **Cold-start unlock (ROADMAP 2.1)** — the at-rest AES key is now wrapped
|
|
||||||
(HKDF-SHA256 + AES-256-GCM, stdlib-only — no `x/crypto` dep) with the passkey
|
|
||||||
credential's public key and persisted to disk. At boot, if a wrapped key file
|
|
||||||
exists AND no env key is set, mavend starts **locked**: the IPC server runs
|
|
||||||
but `srv.Check` rejects everything except `MethodAssertStepUp` +
|
|
||||||
`MethodUnlock`. A passkey assertion at `/auth/passkey` calls `MethodUnlock`
|
|
||||||
with the credential's public key → unwraps the blob → opens the store → wires
|
|
||||||
voice/loop/delivery → `srv.SetAPI` swaps the locked stub for the real
|
|
||||||
CoreAPI. mavweb's `RegisterFinish` wraps the env key on enrollment;
|
|
||||||
`AssertFinish` calls `Unlock` on assertion. Env-key fallback preserved
|
|
||||||
(dev/CI path unchanged). **Test gap:** the roadmap required three new test
|
|
||||||
cases (wrap/unwrap round-trip, wrong-cred unwrap fails, locked-mode IPC
|
|
||||||
rejects non-unlock methods) — none were written. The code is correct but
|
|
||||||
untested; `make test` is green by omission, not coverage.
|
|
||||||
- **Conversation depth (ROADMAP 3.2)** — cross-intent anaphora + fact-by-key
|
|
||||||
lookup. `AnaphoraResolver` in `router/slots.go` detects RU pronouns
|
|
||||||
(это/он/она/оно/тот/мой + inflected forms). `followUpMerge` now handles
|
|
||||||
three cases: same-intent slot inheritance (existing), cross-intent anaphora
|
|
||||||
(Query/Fact/Reminder after a Fact with a pronoun inherits the prior key +
|
|
||||||
time), and query-after-fact (a query following a fact inherits the key for
|
|
||||||
fact-by-key lookup). `Session.History []Turn` added as the multi-turn
|
|
||||||
scaffold (capped at 4). 7 new test cases including the exact done-when
|
|
||||||
scenarios (anaphora query-after-fact, three-turn break, explicit-key-wins).
|
|
||||||
- **Routing quality + persona (ROADMAP 4.1/4.4)** — `QueryMinScore` is now a
|
|
||||||
config knob (`voice.query_min_score`, default 0.55) instead of a hardcoded
|
|
||||||
const. `make download-embedder` fetches Xenova/paraphrase-multilingual-
|
|
||||||
MiniLM-L12-v2 (~90MB ONNX) + tokenizer; AGENTS.md documents the embedder +
|
|
||||||
libonnxruntime setup. `Persona` field in `VoiceConfig` prepends to every
|
|
||||||
LLM system prompt (nudge phrasing, note queries, general knowledge); empty
|
|
||||||
= current hardcoded feminine-gendered Russian persona. Also fixed two
|
|
||||||
pre-existing data races found by `-race`: `voice/server.go` wg.Add vs
|
|
||||||
wg.Wait (accept mutex), `mavweb/server.go` s.api field (atomic.Value).
|
|
||||||
|
|
||||||
- **Calendar querying (task 3)** — "что у меня завтра?" now answers from the
|
|
||||||
CalDAV facts the poller already writes. Added `store.CalendarEvents(from,to)`,
|
|
||||||
a RU date-scope parser («сегодня»/«завтра») in `router/slots.go`, and an
|
|
||||||
IPC `CalendarEvents` RPC (api/client/server/wire) feeding the `IntentQuery`
|
|
||||||
handler. Empty day → «на сегодня ничего нет». Previously calendar only *gated*
|
|
||||||
nudges; it's now queryable.
|
|
||||||
- **General-knowledge routing (task 4)** — when notes-RAG misses `queryMinScore`,
|
|
||||||
the query now falls through to the phraser with an anti-hallucination system
|
|
||||||
prompt (`router.KnowledgePrompt`, single tested source) instead of giving up.
|
|
||||||
Empty/errored/Stub phraser → «не знаю.», never a fabrication.
|
|
||||||
- **Weather (task 5)** — new `internal/weather/`: `Provider` interface, a stub
|
|
||||||
(«погода не настроена»), and a real **keyless Open-Meteo** provider (geocode +
|
|
||||||
current_weather, injectable `*http.Client`, mocked in tests — no live network).
|
|
||||||
Wired into `IntentQuery` (keywords погода/градус/температура) with a ~5s
|
|
||||||
context timeout; selected by `voice.weather.provider` ("open-meteo" | "" → stub).
|
|
||||||
- **Homelab act allowlist (task 2)** — `voice.tools` seeded with read-only acts
|
|
||||||
(`systemctl status`, `docker ps`, `uptime`, `df`, `free`, `journalctl` reads)
|
|
||||||
as `destructive:false` and mutating ones (restart/stop/start/reboot,
|
|
||||||
docker-restart/stop) as `destructive:true`. Guardrail verified: no dangerous
|
|
||||||
verb is `destructive:false`. RU phrasings seeded in `act.txt`.
|
|
||||||
- **Embedder config validation (task 1)** — a partially-filled `voice.embedder`
|
|
||||||
block (some of model/tokenizer/lib paths missing) is now a load error instead
|
|
||||||
of a silent fall-through to the Hash floor; the floor fallback logs explicitly.
|
|
||||||
- **Dialogue state scaffold (task 6)** — `internal/dialogue/`: `Session` +
|
|
||||||
TTL `SessionStore` + pure `InheritSlots`. **Now wired** (post-merge follow-up):
|
|
||||||
the voice handler carries slots across same-intent turns within a 2-min window
|
|
||||||
(`followUpMerge`, unit-tested) — bounded gap-filling, not full multi-turn yet.
|
|
||||||
- **Long-term memory interface (task 7)** — `internal/memory/`: `Store` interface
|
|
||||||
+ `InMemoryStore` (cosine). Wired into `IntentNote` (best-effort insert) and,
|
|
||||||
post-merge, into `IntentFact` (facts indexed) + `IntentQuery` (read-back after
|
|
||||||
notes-RAG misses). In-memory only — no persistent backend yet (gap #8).
|
|
||||||
|
|
||||||
Follow-ups (Claude Code, post-merge): gofmt'd `handlers_test.go` (the jul6
|
|
||||||
verification commit left it misaligned, so `gofmt -l` still flagged it despite the
|
|
||||||
"all gates green" claim); deduped the task-4 knowledge prompt to the single tested
|
|
||||||
`router.KnowledgePrompt()`. Tree is now genuinely green (gofmt/vet/303 tests).
|
|
||||||
|
|
||||||
### Done since the jul5 revision (overnight-jul5, 2026-07-05)
|
|
||||||
|
|
||||||
The overnight session (`SESSION-05-07-2026.md`, deleted 2026-07-30 — see git history; 25 tasks) closed the previous
|
|
||||||
"not built yet" items 1–3 and added feature depth:
|
|
||||||
|
|
||||||
- **At-rest encryption** — the on-disk db is AES-256-GCM ciphertext; the daemon
|
|
||||||
works on a tmpfs (RAM) plaintext copy, sealed back atomically on close. Wrong
|
|
||||||
key / tamper ⇒ fail closed, never a plaintext fallback. Legacy plaintext dbs
|
|
||||||
upgrade on first clean shutdown. Key via config/env (`db_key_env`); no KDF —
|
|
||||||
raw 32-byte key, base64. The passkey cold-start unlock plugs into the same
|
|
||||||
`store.OpenEncrypted` seam later.
|
|
||||||
- **Docker deployment** — single image, one container per daemon
|
|
||||||
(`docker-compose.yml`); only mavend mounts the key + db volume; IPC over a
|
|
||||||
shared socket volume. `ipc.DialWait` (boot-order tolerance) + redial-on-drop
|
|
||||||
(core restarts don't kill modules). `deploy/README.md` has the runbook.
|
|
||||||
- **Tests** — mavcaldav, mavttsd, voicesink, mavweb main/handlers covered;
|
|
||||||
`make test` runs `-race -coverprofile`.
|
|
||||||
- **Recurring reminders** — `cron` + `next_fire_ts` on reminders; recurring ones
|
|
||||||
reschedule (instead of mark-fired) after successful delivery.
|
|
||||||
- **Notification digest/batching** — low-severity nudges queue and flush as one
|
|
||||||
digest per window/max-items (`digest` config block); stale-reminder bursts on
|
|
||||||
boot collapse into a single digest reminder, completed only after delivery.
|
|
||||||
- **Rule trace engine** — `ExplainTick`/`ExplainGate` record per-rule
|
|
||||||
predicate/gate/selection results each tick; served over IPC (`tick_trace`)
|
|
||||||
and rendered at mavweb `/trace` ("why didn't she nudge me").
|
|
||||||
- **Web UI** — new `/history` (facts + revert buttons), `/notifications` (nudge
|
|
||||||
history), `/trace` pages; nav links on `/dash`; RU/EN cheatsheet toggle in the
|
|
||||||
PWA; manifest icons (`icon.svg`). POST `/tools` now requires an in-process
|
|
||||||
passkey step-up when WebAuthn is configured.
|
|
||||||
- **Revert/undo** — `RevertFact` voids the latest fact for a key (append-only
|
|
||||||
void-marker, audit trail intact); exposed at `/api/revert` from `/history`.
|
|
||||||
- **Tool scopes** — `scope` column on tools, threaded through propose/enable/UI.
|
|
||||||
`DisableTool` raised to AuthStepUp alongside Enable.
|
|
||||||
- **Passkey persistence** — mavweb credentials in a JSON file (`-passkey-file`),
|
|
||||||
surviving restarts; rollback-on-persist-failure keeps memory and disk in sync.
|
|
||||||
- **STT silence gate** — min-duration + RMS floor drop non-speech before whisper
|
|
||||||
hallucinates on it (`-min-ms`, `-silence-rms` flags on mavsttd).
|
|
||||||
- **Housekeeping** — `db_key.env` gitignored (+`.env.example`), `build-caldav`
|
|
||||||
target, zero-timestamp "never" fix on /dash.
|
|
||||||
|
|
||||||
### Not built yet (ranked by ROI)
|
|
||||||
|
|
||||||
1. **Multi-user (SPEC item 8)** — deliberately deferred, see the tail.
|
|
||||||
|
|
||||||
Closed (jul6 follow-ups): `/api/revert` now sits behind the same passkey
|
|
||||||
step-up as POST `/tools`; `go.mod` direct deps (`onnxruntime_go`,
|
|
||||||
`coder/websocket`, `robfig/cron`) are labeled correctly — `go mod tidy` can't
|
|
||||||
run here because it walks the vendored `deps/go` toolchain tree.
|
|
||||||
Purge+rotate leaked db key (#12) — investigated and closed: the key was
|
|
||||||
**never committed** to git history (gitignored at introduction, no commit
|
|
||||||
ever tracked `deploy/db_key.env`), so nothing to scrub. File stays on disk
|
|
||||||
and in deploy env by design — at-rest encryption needs it at boot.
|
|
||||||
|
|
||||||
Done earlier (2026-07-03): **act tool executor, store-backed, full flow**
|
|
||||||
(`internal/tool` + `internal/store/tools.go` + `tools` CoreAPI methods).
|
|
||||||
- **Execution:** IntentAct runs the matched fn against the store's ENABLED
|
|
||||||
allowlist. argv, no shell → STT text can't inject. Live store read, so a
|
|
||||||
newly-enabled tool runs without a daemon restart.
|
|
||||||
- **proposed→enabled→disabled (SPEC item 5):** an act whose verb isn't enabled is
|
|
||||||
scaffolded as a `proposed` tool (maven suggests). A human enables it (fills argv
|
|
||||||
+ destructive) on the authed **`mavweb /tools`** page — never voice — and can
|
|
||||||
disable it back to `proposed` (kept in the store, won't run). `EnableTool`/
|
|
||||||
`DisableTool` sit at `AuthStepUp`; the gate is now **live** via `PasskeySession`,
|
|
||||||
so /tools enable requires a passkey assertion at `/auth/passkey` first.
|
|
||||||
- **Confirm turn:** a destructive enabled tool replies "выполнить X? да/нет" and
|
|
||||||
parks; the next utterance (ru/en yes-no) confirms or cancels (90s TTL).
|
|
||||||
- **Config:** `voice.tools` seeds enabled tools at boot (editing mavend.json =
|
|
||||||
the human enable act); mavweb enables ad-hoc ones on top.
|
|
||||||
- **Russian:** fixed grammar in reply strings + seed files; maven's self-
|
|
||||||
reference is feminine ("she") — [[maven-persona-gender]].
|
|
||||||
|
|
||||||
Also fixed:
|
|
||||||
- **HashEmbedder was blind to Cyrillic** (`tokenize` iterated bytes, kept only
|
|
||||||
`a-z0-9`) → every RU utterance embedded to the zero vector → cosine 0 across
|
|
||||||
all intents → misrouted to `act` (alphabetical tie-break). Now rune-based
|
|
||||||
(`unicode.IsLetter`). This was the real cause of "Найди заметку" (a query)
|
|
||||||
landing in `notes`; added note-retrieval query seeds too.
|
|
||||||
- **Notes are now browsable on `/dash`** — `RecentNotes` plumbed through the
|
|
||||||
store + CoreAPI; voice-captured notes were previously only reachable via
|
|
||||||
semantic `query`.
|
|
||||||
Earlier: notes/query recall, `/dash` monitoring, `wg_handshake` poller (NO-OP).
|
|
||||||
|
|
||||||
### Gaps — why "voice assistant" is still aspirational (2026-07-06)
|
|
||||||
|
|
||||||
What separates Maven today from the thing the spec describes. Dealbreakers
|
|
||||||
first — these define the category:
|
|
||||||
|
|
||||||
1. **Always-on listening is code-complete (MVP).** `cmd/mavwaked` captures
|
|
||||||
PCM from arecord → energy-based VAD → PushToTalk with `Surface=SurfaceVoice`
|
|
||||||
(L0). Gap narrowed: no wake word yet (pure voice-activity trigger; every
|
|
||||||
utterance fires). The 30ms frame shape and 16kHz PCM match silero-vad's
|
|
||||||
ONNX input exactly, so a wake-word model swap is a local change in vad.go.
|
|
||||||
Hardware: the mic lives on a client box (desk PC, pi, etc.) — never the
|
|
||||||
homesrv. Deploy action: systemd user unit on whichever box has the mic,
|
|
||||||
connects to mavend over wg or local net.
|
|
||||||
2. **Conversation is deeper now, still not full dialogue.** The router
|
|
||||||
classifies one utterance → one reply, but `internal/dialogue` carries
|
|
||||||
context across turns: a 2-min session inherits slots for same-intent
|
|
||||||
follow-ups («напомни завтра» → «…позвонить маме»), and cross-intent
|
|
||||||
anaphora («запиши что я пил воду» → «когда я это сделал?») now resolves
|
|
||||||
RU pronouns (это/он/она/оно/тот/мой + inflections) to the prior turn's
|
|
||||||
key for fact-by-key lookup. `Session.History []Turn` is the scaffold for
|
|
||||||
real multi-turn. Still missing: LLM-driven dialogue manager (decide
|
|
||||||
ask-vs-act), anaphora beyond RU pronouns, single-slot session (single-user
|
|
||||||
box). The sub-1B phraser only words replies.
|
|
||||||
3. **Latency/shape of a turn.** Clip-based STT (record → upload → whisper →
|
|
||||||
route → phrase → piper → play). No streaming either direction, no barge-in;
|
|
||||||
every exchange is a full round trip.
|
|
||||||
|
|
||||||
Capability-class gaps — built but thin:
|
|
||||||
|
|
||||||
4. **Act surface is a small argv allowlist.** propose→enable works and the
|
|
||||||
allowlist now ships a homelab starter set (jul6 task 2 — status/ps/uptime/
|
|
||||||
df/free/logs read-only, restart/stop/reboot gated). Still bounded to what's
|
|
||||||
seeded; broadening it is config, not code.
|
|
||||||
5. **Query answers now cover notes + calendar + weather + general knowledge**
|
|
||||||
(jul6 tasks 3/4/5). Calendar querying, keyless Open-Meteo weather, and a
|
|
||||||
phraser knowledge-fallback all landed; caveat — general-knowledge quality is
|
|
||||||
only as good as the sub-1B phraser, and weather needs `voice.weather.provider`
|
|
||||||
set. The cheatsheet and router are now roughly aligned.
|
|
||||||
6. **Routing quality depends on the ONNX embedder being configured** — the
|
|
||||||
HashEmbedder floor makes RU recall lexical/weak; many commands fall to
|
|
||||||
"clarify". `make download-embedder` now fetches the multilingual MiniLM
|
|
||||||
model + AGENTS.md documents libonnxruntime setup; `voice.query_min_score`
|
|
||||||
is a config knob (default 0.55) so the floor can be tuned without recompile.
|
|
||||||
7. **Presence is effectively one signal** (page_heartbeat); desk_active is
|
|
||||||
still an undeployed script — "voice when near" routing runs on a guess.
|
|
||||||
8. **Long-term memory is now persistent (store-backed), not the spec's chroma.**
|
|
||||||
`internal/memory` has a `Store` interface; the daemon now wires
|
|
||||||
`store.MemoryStore` (`internal/store/memory.go`) — a **persistent** backend
|
|
||||||
in the **same encrypted sqlite db** (survives restarts; recall text inherits
|
|
||||||
at-rest encryption, so no plaintext sidecar). Vectors are float32 blobs,
|
|
||||||
search is brute-force cosine (fine at single-user scale; ANN is the later
|
|
||||||
swap behind the same interface). Notes **and facts** are indexed on capture;
|
|
||||||
`IntentQuery` reads it back (after notes-RAG misses, before general-knowledge)
|
|
||||||
— fact recall («когда я пил воду?») is its distinct payoff. The in-memory
|
|
||||||
impl remains the test/no-store floor. Remaining: an ANN/external index is
|
|
||||||
optional-scale, not a gap. Custom TTS voice (kami-picked, replaces the irina
|
|
||||||
floor — [[custom-voice-training]]) is still a future item.
|
|
||||||
|
|
||||||
Ops footnote: voice-over-web verified 2026-07-06 — mavend binds 0.0.0.0:9100
|
|
||||||
and mavweb reaches it cross-container at mavend:9100 (nc -z confirmed).
|
|
||||||
mavpoll uses network_mode=host to reach localhost services (netdata, kuma).
|
|
||||||
|
|
||||||
### Future / logged, not now
|
|
||||||
|
|
||||||
Custom TTS voice training (kami-picked voice, replaces irina floor); listening
|
|
||||||
modes 2–3 (meeting-record, ambient-derive).
|
|
||||||
|
|
||||||
### Services & layout
|
|
||||||
|
|
||||||
- `mavend` (core, IPC unix socket) — store + loop + phraser; the only key-holder.
|
|
||||||
- `mavsttd` / `mavttsd` — STT/TTS worker modules (unix sockets).
|
|
||||||
- `mavweb` — PWA bridge (HTTP), `/api/ptt` voice, `/api/signal` presence ingest,
|
|
||||||
`/api/ntfy` WS-subscribe config, `/dash` read-only monitoring.
|
|
||||||
- `mavpoll` — env poller (netdata/kuma → facts via CoreAPI).
|
|
||||||
- `mavcaldav` — CalDAV poller (Radicale → `calendar_busy` + events via CoreAPI).
|
|
||||||
- All behind wg + nginx deny-all; no phone-home. CGo only in `mavsttd`.
|
|
||||||
- Start/stop: `./start-maven.sh [build]`, `./kill-maven.sh`.
|
|
||||||
- Config: `~/.config/maven/mavend.json` (or `mavend.json` in repo root).
|
|
||||||
|
|
||||||
### Key files
|
|
||||||
|
|
||||||
- `cmd/mavend/{main,tick,voice}.go` — daemon wiring, loop driver, voice handler
|
|
||||||
- `internal/loop/{loop,rules,gather,feedback}.go` — proactive engine
|
|
||||||
- `internal/store/` — append-only facts/reminders/nudges/presence/notes
|
|
||||||
- `cmd/mavweb/{main.go,dash.html}` — PWA bridge + `/dash` monitoring
|
|
||||||
- `internal/router/{classifier,slots,stage0}.go` — reactive routing + slot parse
|
|
||||||
- `internal/delivery/` — dispatcher + ntfy/telegram/voice sinks
|
|
||||||
- `internal/auth/` — scope/gate/policy; `FloorEnrollment` (same-uid = device
|
|
||||||
trust) + `webauthn.PasskeySession` (real step-up for L3)
|
|
||||||
- `internal/webauthn/`, `cmd/mavweb/webauthn.go` — passkey register/assert
|
|
||||||
- `cmd/mavcaldav/`, `cmd/mavpoll/`, `scripts/desk-active.sh` — env producers
|
|
||||||
|
|
||||||
### Why multi-user (SPEC item 8) is deferred
|
|
||||||
|
|
||||||
Not neglect — the one item where doing nothing now beats doing something:
|
|
||||||
|
|
||||||
- **No second user exists yet** (the "gf phase"). Building per-user partitioning
|
|
||||||
now means code exercised by zero users and validated by nobody — YAGNI.
|
|
||||||
- **The append-only schema makes it a migration, not a rewrite.** No row is ever
|
|
||||||
mutated, so adding `facts/notes/reminders.user_id` later is add-columns +
|
|
||||||
backfill-to-"kami" — no reshaping, no dual-write window. Deferral is cheap.
|
|
||||||
- **The hard part is speaker attribution, and it needs the second voice.** A
|
|
||||||
voice-print discriminator (kami vs gf vs unknown) can't be trained or tuned
|
|
||||||
with one voice in the house. Plumbing before the model is pipe with no water.
|
|
||||||
- **It's fenced deliberately** (`DO NOT TOUCH THIS PHASE` in `DESIGN.md` § Users) so an
|
|
||||||
autonomous agent doesn't add `user_id` columns while touching the store and
|
|
||||||
commit us to a schema before the constraints that shape it exist.
|
|
||||||
-184
@@ -1,184 +0,0 @@
|
|||||||
# QA plan: checking Maven properly
|
|
||||||
|
|
||||||
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
|
||||||
|
|
||||||
44 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
|
|
||||||
build work. Most sat unverifiable while Maven was down for 11 days. That
|
|
||||||
blocker is gone.
|
|
||||||
|
|
||||||
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
|
|
||||||
downstream assumes the voice loop works, and nobody has confirmed that since
|
|
||||||
the redeploy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Before you start
|
|
||||||
|
|
||||||
Two things bite anyone running these checks on homesrv.
|
|
||||||
|
|
||||||
**curl needs `--noproxy '*'`.** The shell exports `http_proxy=http://127.0.0.1:18080`.
|
|
||||||
Without the flag, every local check returns 503 from the proxy and looks like a
|
|
||||||
dead service. This cost me a false regression report today.
|
|
||||||
|
|
||||||
**The database is not readable with sqlite3.** Four older QA steps say
|
|
||||||
`docker compose exec mavend sqlite3 /data/maven.db "select ..."`. That cannot
|
|
||||||
work: the container has no `sqlite3` binary, and the store is AES-256-GCM at
|
|
||||||
rest with a tmpfs working copy. Read state through mavweb instead, at
|
|
||||||
`/history`, `/trace`, `/routines` and `/dash`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Session 1: the voice loop (half a day)
|
|
||||||
|
|
||||||
Nothing here has been confirmed since the redeploy, and everything else assumes
|
|
||||||
it works. Do this first.
|
|
||||||
|
|
||||||
Closes or advances: **44** (conversation), **45** (text chat), **287** (voice
|
|
||||||
session quality), **321** steps 3-5 (quiet mode), **288** (STT fixtures).
|
|
||||||
|
|
||||||
1. Open `http://127.0.0.1:9201/chat` and hold a short conversation in Russian.
|
|
||||||
Watch for three things: she answers in feminine forms (`рада`, `поняла`), she
|
|
||||||
says `ты` and never `вы`, and no pet names appear.
|
|
||||||
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
|
|
||||||
back. This is the only check that covers mic to STT to core to TTS to
|
|
||||||
speaker as one path. It is also the path the eleven-day outage most likely
|
|
||||||
broke.
|
|
||||||
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.`
|
|
||||||
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win.
|
|
||||||
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
|
||||||
`quiet_hours` fact was written.
|
|
||||||
6. Say `включи режим тишины`, then `сделай потише`. Both must flip quiet mode
|
|
||||||
on. These are the noun form and the comparative, added 01-08-2026.
|
|
||||||
7. Wait for a nudge, then say `потом` within twenty minutes. Expect `хорошо,
|
|
||||||
вернусь к этому позже.` and the nudge row on `/notifications` reading
|
|
||||||
`snoozed`. Say `потом` again with nothing pending: it must route as an
|
|
||||||
ordinary utterance, not be swallowed.
|
|
||||||
8. Wait for the water nudge, then say `выпил воды`. Expect the ordinary fact
|
|
||||||
reply and nothing extra — she must not congratulate you. Check
|
|
||||||
`/notifications`: the row reads `acted`. Then trigger another nudge and say
|
|
||||||
`готово`; expect `отлично, отметила.` and the same outcome.
|
|
||||||
9. Note anything where she is slow, cuts off, or talks over herself. That is
|
|
||||||
287's whole content and it has no written acceptance criteria yet.
|
|
||||||
|
|
||||||
**319 is fixed** (01-08-2026). Single-word Russian utterances no longer come
|
|
||||||
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
|
|
||||||
both pass now: `thinSingleToken` spares social singles and any token carrying a
|
|
||||||
verb ending, and only thins a bare nominal like `вода`. If a one-word utterance
|
|
||||||
still gets clarified during the smoke test, that is a new case for the lexicon,
|
|
||||||
not the old bug.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Session 2: measurement (half a day, mostly waiting)
|
|
||||||
|
|
||||||
Closes or advances: **320** items 2-4, **278** (make the eval lab routine),
|
|
||||||
**319** (gate recalibration).
|
|
||||||
|
|
||||||
The resident llama-server cannot be reached by the eval harness. It binds
|
|
||||||
`--host 127.0.0.1 --port 0` inside the container, so the port is kernel-assigned
|
|
||||||
and never published. Start a second one on a fixed port instead:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
llama-server -m /mnt/hdd1/llms/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf \
|
|
||||||
--host 127.0.0.1 --port 18100 -c 4096 -ngl 99 --no-webui
|
|
||||||
```
|
|
||||||
|
|
||||||
`-c 4096` matters. The recorded numbers were measured at that context size, and
|
|
||||||
a mismatch invalidates the comparison.
|
|
||||||
|
|
||||||
Then:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make eval-models MAVEN_LLM_URL=http://127.0.0.1:18100 # want ~72.7% cascade
|
|
||||||
make eval-router # classifier baseline
|
|
||||||
MAVEN_LLM_URL=http://127.0.0.1:18100 make eval-phrasing # persona checks, slow
|
|
||||||
make eval-recall
|
|
||||||
```
|
|
||||||
|
|
||||||
A large miss against 72.7% means the deploy differs from the bench harness.
|
|
||||||
|
|
||||||
Two things to decide while the numbers are in front of you:
|
|
||||||
|
|
||||||
- **319's gate recalibration.** The single-token rule needs narrowing or
|
|
||||||
dropping. This needs your judgement, not a threshold sweep. The fixture and the
|
|
||||||
daemon disagree about what is correct on two of the three false clarifies.
|
|
||||||
- **278's real ask** is making the eval lab routine rather than building it. It
|
|
||||||
is built. Decide whether it runs on a timer, on every merge, or on demand, and
|
|
||||||
the task can close.
|
|
||||||
|
|
||||||
Item 4 of **320** needs a permission I do not have. Kill the `llama-server`
|
|
||||||
pid under `maven-mavend-1`, post a turn, and confirm it still completes
|
|
||||||
through the classifier. Either grant it or run it yourself. It is the only
|
|
||||||
check that the failure floor catches a mid-session model death.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Session 3: the interaction batch (a day, or three sittings)
|
|
||||||
|
|
||||||
These need real use rather than a command, grouped by what one sitting covers.
|
|
||||||
|
|
||||||
**Morning and delivery** (**280**, **281**, **128**, **282**): open `/morning`,
|
|
||||||
walk the seven required behaviours, then check the four interruption outcomes
|
|
||||||
and the digest gap. **282** needs the `desk_active` script enabled on the desk
|
|
||||||
PC first, which is **15** and needs you at that machine.
|
|
||||||
|
|
||||||
**Tasks and calendar** (**129**, **130**, **127**, **126**, **246**): capture a
|
|
||||||
task by voice, confirm it lands, check prioritisation ordering is not nonsense.
|
|
||||||
**246** (mail reader) also exercises the `IngestMail` rung that moved to
|
|
||||||
`AuthWrite` this morning.
|
|
||||||
|
|
||||||
**Routines and patterns** (**43**, **46**, **247**, **254**): these need history
|
|
||||||
to detect against. If the database is thin after the outage, they may have
|
|
||||||
nothing to propose, which is not a failure. Check `/routines` before
|
|
||||||
concluding anything.
|
|
||||||
|
|
||||||
**Ecosystem** (**272**, **273**, **276**): nexus, hexis and praxis are wired and
|
|
||||||
logged clean at boot. **276** is the degraded-mode suite, which means taking
|
|
||||||
siblings down on purpose. Worth doing while you are already in there.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Housekeeping (one sitting, no box needed)
|
|
||||||
|
|
||||||
Four QA tasks will not close no matter how long they sit, because they are
|
|
||||||
gated on something that does not exist:
|
|
||||||
|
|
||||||
- **125** zenmoney: needs a token you have not minted.
|
|
||||||
- **256** Home Assistant: needs HA configured.
|
|
||||||
- **257** Bluetooth: BLOCKED, no bluez on the box. Says so in the title.
|
|
||||||
- **288** STT golden audio: needs fixtures generated.
|
|
||||||
|
|
||||||
Relabel these so they stop reading as backlog. They are not verification work
|
|
||||||
that is pending, they are work that has not started.
|
|
||||||
|
|
||||||
Same treatment for the five plan-only tasks (**251** MCP, **252** vision,
|
|
||||||
**253** hearing, **255** speaker recognition, **259** crawler). A `QA:` prefix on
|
|
||||||
a plan is misleading.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Needs you specifically
|
|
||||||
|
|
||||||
Not QA. These are blocked on a decision or a credential only you have.
|
|
||||||
|
|
||||||
| # | what |
|
|
||||||
|---|---|
|
|
||||||
| 16 | Create the Kuma API key. `-kuma-key uk5_mavpoll-key` in `docker-compose.yml` is still the placeholder. |
|
|
||||||
| 15 | Deploy `desk_active` on the desk PC. Blocks **282**. |
|
|
||||||
| 122 | Finish the CPT run for Qwen3-1.7B. The persona fix depends on it. |
|
|
||||||
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
|
|
||||||
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
|
|
||||||
| 275 | Hexis native API and MCP parity. |
|
|
||||||
| — | Decide on `-require-stepup`. Making it the default needs WebAuthn configured first, or it locks you out of your own admin surfaces. See **317**. |
|
|
||||||
| — | Three nginx sites bind wildcard `:80` (`acme.conf`, `matrix`, `panel`), so the ecosystem's bind-level protection is not in effect and `allow`/`deny` is carrying it alone. See **354**. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Suggested order
|
|
||||||
|
|
||||||
1. Session 1. If the voice loop is broken, nothing else matters.
|
|
||||||
2. The `-require-stepup` and Kuma decisions. Five minutes, unblocks **317** fully
|
|
||||||
and **16**.
|
|
||||||
3. Session 2. The numbers tell you whether the router is worth its 90x latency.
|
|
||||||
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
|
|
||||||
5. Session 3, split whichever way suits you.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package main is mavenclient — maven's reference client.
|
// Package main is mavenclient — maven's reference client.
|
||||||
//
|
//
|
||||||
// Per DESIGN.md § Voice pipeline (STT / TTS): capture lives on the client;
|
// Per docs/design.md § Voice pipeline (STT / TTS): capture lives on the client;
|
||||||
// the server transcribes + synthesises on demand. The PC client runs the
|
// the server transcribes + synthesises on demand. The PC client runs the
|
||||||
// wake-word / VAD gate (cmd/mavwaked) and ships ONE clean audio blob per
|
// wake-word / VAD gate (cmd/mavwaked) and ships ONE clean audio blob per
|
||||||
// utterance on activation. The server never owns a mic.
|
// utterance on activation. The server never owns a mic.
|
||||||
|
|||||||
+50
-14
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
||||||
@@ -15,14 +16,41 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
if !dec.Slots.HasKey {
|
if !dec.Slots.HasKey {
|
||||||
return "не разобрала, что записать — попробуй иначе."
|
return "не разобрала, что записать — попробуй иначе."
|
||||||
}
|
}
|
||||||
|
// A question is never a fact about him (#470). "какая последняя версия
|
||||||
|
// языка Go?" used to land here, and the value stored was whatever the
|
||||||
|
// model invented for it, at confidence 1.00, indexed for recall under the
|
||||||
|
// question's own text. Two such rows then claimed seven unrelated world
|
||||||
|
// questions through recall and silently disabled world answering.
|
||||||
|
//
|
||||||
|
// The routing error itself is not fixed here — the answer is to answer.
|
||||||
|
// Sending the turn down the query chain is what he asked for anyway, and
|
||||||
|
// it costs a mis-routed capture nothing: an explicit "запиши ..." is not
|
||||||
|
// question-shaped, so it never takes this branch.
|
||||||
|
if router.IsQuestionShaped(dec.Utterance) {
|
||||||
|
log.Printf("voice: fact write refused, utterance is a question: %q (key %q) — answering as a query",
|
||||||
|
dec.Utterance, dec.Slots.Key)
|
||||||
|
q := dec
|
||||||
|
q.Intent = router.IntentQuery
|
||||||
|
// The key the model extracted is its guess at what to store, not a
|
||||||
|
// fact he has. Left in place, queryFactByKey would read it back and
|
||||||
|
// claim the turn before any real source ran.
|
||||||
|
q.Slots.Key, q.Slots.HasKey = "", false
|
||||||
|
q.Slots.Value = ""
|
||||||
|
return h.actionQuery(ctx, q)
|
||||||
|
}
|
||||||
now := h.now()
|
now := h.now()
|
||||||
req := ipc.WriteFactReq{
|
req := ipc.WriteFactReq{
|
||||||
Ts: now,
|
Ts: now,
|
||||||
Kind: "self",
|
Kind: "self",
|
||||||
Key: dec.Slots.Key,
|
Key: dec.Slots.Key,
|
||||||
Value: dec.Slots.Value,
|
Value: dec.Slots.Value,
|
||||||
Source: "tap:voice",
|
Source: "tap:voice",
|
||||||
Confidence: 1.0,
|
// Not 1.00 unconditionally any more (#470). A value he said is
|
||||||
|
// evidence; a value the model supplied for words he never said is a
|
||||||
|
// guess, and writing a guess at full confidence is the same mistake
|
||||||
|
// the act path already refuses under "LLM output is not
|
||||||
|
// authorization".
|
||||||
|
Confidence: factConfidence(dec.Utterance, dec.Slots.Value),
|
||||||
// Subject: the key doubles as the entity-resolution candidate —
|
// Subject: the key doubles as the entity-resolution candidate —
|
||||||
// a voice-tapped fact's key is usually the thing/person it's
|
// a voice-tapped fact's key is usually the thing/person it's
|
||||||
// about ("espresso_machine", "kate"), so queueing it for Nexus
|
// about ("espresso_machine", "kate"), so queueing it for Nexus
|
||||||
@@ -36,17 +64,25 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
log.Printf("voice: write fact: %v", err)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return "не получилось сохранить факт."
|
return "не получилось сохранить факт."
|
||||||
}
|
}
|
||||||
// Index the fact utterance in long-term memory (best-effort, must not
|
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||||
// fail the fact write). Facts aren't in the notes table, so this is the
|
// write). Facts aren't in the notes table, so this is the only recall path
|
||||||
// only recall path for them — "когда я пил воду?" reads back from here.
|
// for them — "когда я пил воду?" reads back from here.
|
||||||
|
//
|
||||||
|
// The indexed text is the fact, not the utterance (#493). queryMemory
|
||||||
|
// returns a fact's stored text verbatim, so what goes in here is what he
|
||||||
|
// hears; storing the utterance meant recall answered with his own sentence
|
||||||
|
// rather than the value. The utterance stays alongside as provenance —
|
||||||
|
// readable on /trace, never the answer and never embedded.
|
||||||
if h.memStore != nil {
|
if h.memStore != nil {
|
||||||
if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil {
|
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||||
|
if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil {
|
||||||
log.Printf("voice: embed fact for memory: %v", err)
|
log.Printf("voice: embed fact for memory: %v", err)
|
||||||
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "fact",
|
"type": "fact",
|
||||||
"text": dec.Utterance,
|
"text": text,
|
||||||
"ts": strconv.FormatInt(now.Unix(), 10),
|
"utterance": dec.Utterance,
|
||||||
|
"ts": strconv.FormatInt(now.Unix(), 10),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: memory insert fact: %v", err)
|
log.Printf("voice: memory insert fact: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-26
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/rss"
|
"github.com/kami/maven/internal/rss"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -389,6 +390,11 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
|||||||
if errors.Is(err, weather.ErrNotConfigured) {
|
if errors.Is(err, weather.ErrNotConfigured) {
|
||||||
return "погода не настроена.", true
|
return "погода не настроена.", true
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, weather.ErrLocationUnknown) {
|
||||||
|
// He named a place and the geocoder does not have it. Saying so beats
|
||||||
|
// reading out the default city's temperature (Vikunja #421).
|
||||||
|
return "не знаю такого города — " + loc + ".", true
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: weather: %v", err)
|
log.Printf("voice: weather: %v", err)
|
||||||
return "не получилось узнать погоду.", true
|
return "не получилось узнать погоду.", true
|
||||||
@@ -433,6 +439,14 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
text := hit.Meta["text"]
|
text := hit.Meta["text"]
|
||||||
|
// The score cleared the gate and the topic still has to match (#470). A
|
||||||
|
// note about his slow network scored high enough to answer "почему небо
|
||||||
|
// синее?", because the right-note and must-be-silent score ranges overlap
|
||||||
|
// and no threshold sits between them.
|
||||||
|
if !memory.RecallAllowed(t.dec.Utterance, text) {
|
||||||
|
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||||
// stored.
|
// stored.
|
||||||
if hit.Meta["type"] == "note" {
|
if hit.Meta["type"] == "note" {
|
||||||
@@ -468,6 +482,12 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
// Same topic veto as queryMemory above: the best note must be about what
|
||||||
|
// he asked, not merely the nearest vector in the index.
|
||||||
|
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
|
||||||
|
log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
texts := make([]string, len(notes))
|
texts := make([]string, len(notes))
|
||||||
for i, n := range notes {
|
for i, n := range notes {
|
||||||
texts[i] = n.Text
|
texts[i] = n.Text
|
||||||
@@ -523,10 +543,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
// the question he actually asked. She answers the question, she does not
|
// the question he actually asked. She answers the question, she does not
|
||||||
// recite the page.
|
// recite the page.
|
||||||
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||||||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
reply := h.phraseSource(ctx, "web", t.dec.Utterance, []string{snippet})
|
||||||
if perr != nil {
|
|
||||||
log.Printf("voice: web: phrase: %v", perr)
|
|
||||||
}
|
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser (or it failed): read back the top of the page rather than
|
// No phraser (or it failed): read back the top of the page rather than
|
||||||
// pretend the fetch did not happen.
|
// pretend the fetch did not happen.
|
||||||
@@ -592,14 +609,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
|||||||
// question he asked, not something to recite. The trim is one budget over the
|
// question he asked, not something to recite. The trim is one budget over the
|
||||||
// joined block, so a long first snippet cannot crowd out the rest.
|
// joined block, so a long first snippet cannot crowd out the rest.
|
||||||
evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes)
|
evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes)
|
||||||
var reply string
|
reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence})
|
||||||
if h.phraser != nil {
|
|
||||||
var perr error
|
|
||||||
reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{evidence})
|
|
||||||
if perr != nil {
|
|
||||||
log.Printf("voice: search: phrase: %v", perr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser, or it failed. Read back the best evidence rather than
|
// No phraser, or it failed. Read back the best evidence rather than
|
||||||
// pretend the search did not happen.
|
// pretend the search did not happen.
|
||||||
@@ -680,14 +690,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
// Handed over the same way a note or a page is: context for the question he
|
// Handed over the same way a note or a page is: context for the question he
|
||||||
// asked, not something to recite.
|
// asked, not something to recite.
|
||||||
snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes)
|
snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes)
|
||||||
var reply string
|
reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
|
||||||
if h.phraser != nil {
|
|
||||||
var perr error
|
|
||||||
reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
|
||||||
if perr != nil {
|
|
||||||
log.Printf("voice: kiwix: phrase: %v", perr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||||
// the search did not happen.
|
// the search did not happen.
|
||||||
@@ -717,7 +720,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
// not be sent to an upstream engine at all. The guard closes both holes with
|
// not be sent to an upstream engine at all. The guard closes both holes with
|
||||||
// the same test.
|
// the same test.
|
||||||
func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isPersonalQuery(t.dec.Utterance) {
|
if !h.isPersonalTurn(ctx, t) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
||||||
@@ -742,7 +745,9 @@ var personalMarkers = []*regexp.Regexp{
|
|||||||
regexp.MustCompile(`(?i)\bdid\s+i\b`),
|
regexp.MustCompile(`(?i)\bdid\s+i\b`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPersonalQuery reports whether the utterance asks about something of his.
|
// isPersonalQuery — the offline floor under the boundary. Possession only, and
|
||||||
|
// deliberately still narrow: it answers when there is no embedder to ask, and a
|
||||||
|
// broad guess made blind is worse than a narrow one.
|
||||||
func isPersonalQuery(utterance string) bool {
|
func isPersonalQuery(utterance string) bool {
|
||||||
if utterance == "" {
|
if utterance == "" {
|
||||||
return false
|
return false
|
||||||
@@ -755,11 +760,45 @@ func isPersonalQuery(utterance string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryGeneral — general knowledge from the phraser, the last source before
|
// isPersonalTurn — the boundary test. The seeds decide when the embedder is
|
||||||
// giving up. It always claims: either the model answers or Maven says she
|
// there, which is every deployed box; the possession markers are the floor
|
||||||
// doesn't know.
|
// underneath, for a handler with no embedder or a turn whose vector never got
|
||||||
|
// computed. Same shape as the cascade: the better test leads, the offline one
|
||||||
|
// always answers.
|
||||||
|
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
|
||||||
|
h.boundary.load(ctx, h.embedder)
|
||||||
|
if personal, world, ok := h.boundary.score(t.vec); ok {
|
||||||
|
if personal > world {
|
||||||
|
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isPersonalQuery(t.dec.Utterance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryGeneral — general knowledge, the last source before giving up. It always
|
||||||
|
// claims: either a model answers, or Maven names the gap, or she says she does
|
||||||
|
// not know.
|
||||||
|
//
|
||||||
|
// This is the sharpest case for the naming half. Nothing has been fetched, so
|
||||||
|
// there is no passage to fall back on and no floor under the answer except the
|
||||||
|
// model's weights — and a 1.7B's weights are where the invented answers come
|
||||||
|
// from. With a workstation configured and asleep he is told that, rather than
|
||||||
|
// told something false in a confident voice. With no workstation configured at
|
||||||
|
// all the resident model answers exactly as it does today: naming a gap requires
|
||||||
|
// a gap, and on that box the 1.7B is the whole product.
|
||||||
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil)
|
if h.phraser == nil {
|
||||||
|
// No model of any size. That is not the workstation being asleep, so it
|
||||||
|
// is not that gap: it is simply not knowing.
|
||||||
|
return "не знаю.", true
|
||||||
|
}
|
||||||
|
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||||||
|
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||||
|
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
||||||
|
return worldGap, true
|
||||||
|
}
|
||||||
if err != nil || reply == "" {
|
if err != nil || reply == "" {
|
||||||
return "не знаю.", true
|
return "не знаю.", true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ func TestIsPersonalQuery(t *testing.T) {
|
|||||||
"when is my meeting",
|
"when is my meeting",
|
||||||
"do i have anything today",
|
"do i have anything today",
|
||||||
"did i take my vitamins",
|
"did i take my vitamins",
|
||||||
|
// Speech, but only the forms possession already covers ("did i").
|
||||||
|
// The verb forms the floor cannot see are the seeds' job, scored in
|
||||||
|
// TestONNXPersonalBoundary.
|
||||||
|
"what did i say about backups",
|
||||||
} {
|
} {
|
||||||
if !isPersonalQuery(s) {
|
if !isPersonalQuery(s) {
|
||||||
t.Errorf("isPersonalQuery(%q) = false, want true", s)
|
t.Errorf("isPersonalQuery(%q) = false, want true", s)
|
||||||
@@ -33,6 +37,10 @@ func TestIsPersonalQuery(t *testing.T) {
|
|||||||
"почему небо синее",
|
"почему небо синее",
|
||||||
"столица франции",
|
"столица франции",
|
||||||
"how do i boil an egg",
|
"how do i boil an egg",
|
||||||
|
// The floor is possession-only by design: a speech verb it cannot see
|
||||||
|
// passes here and is caught by the seeds instead.
|
||||||
|
"что я говорил про бэкапы?",
|
||||||
|
"как я говорил, почему небо синее",
|
||||||
"",
|
"",
|
||||||
} {
|
} {
|
||||||
if isPersonalQuery(s) {
|
if isPersonalQuery(s) {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func (l llmCompleter) Complete(ctx context.Context, system, user string) (string
|
|||||||
// grammar, or a llama-server too old to honour one, gets the plain text it used
|
// grammar, or a llama-server too old to honour one, gets the plain text it used
|
||||||
// to get rather than an empty meeting summary.
|
// to get rather than an empty meeting summary.
|
||||||
func unwrapSummary(raw string) string {
|
func unwrapSummary(raw string) string {
|
||||||
s := stripThink(strings.TrimSpace(raw))
|
s := phraser.StripThink(strings.TrimSpace(raw))
|
||||||
start := strings.Index(s, "{")
|
start := strings.Index(s, "{")
|
||||||
end := strings.LastIndex(s, "}")
|
end := strings.LastIndex(s, "}")
|
||||||
if start < 0 || end <= start {
|
if start < 0 || end <= start {
|
||||||
|
|||||||
+13
-13
@@ -106,11 +106,11 @@ func trimClarifyExpired(s string) string {
|
|||||||
// out, and "" when nothing was parked. Call it right after
|
// out, and "" when nothing was parked. Call it right after
|
||||||
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
||||||
// only reported here — the words themselves still go on to be routed fresh.
|
// only reported here — the words themselves still go on to be routed fresh.
|
||||||
func (h *reactiveHandler) clarifyExpiredNotice() string {
|
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) {
|
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||||
@@ -157,7 +157,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
|||||||
// askClarify parks the request and returns the question to ask instead of the
|
// askClarify parks the request and returns the question to ask instead of the
|
||||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||||
// the caller falls back to the canned reply.
|
// the caller falls back to the canned reply.
|
||||||
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
|
||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||||
Intent: dialogue.Intent(dec.Intent),
|
Intent: dialogue.Intent(dec.Intent),
|
||||||
Slots: toDialogueSlots(dec.Slots),
|
Slots: toDialogueSlots(dec.Slots),
|
||||||
Missing: []dialogue.Slot{slot},
|
Missing: []dialogue.Slot{slot},
|
||||||
@@ -192,7 +192,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -206,9 +206,9 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
// would fire at 11:00 saying "напомни" and nothing else.
|
// would fire at 11:00 saying "напомни" and nothing else.
|
||||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||||
return h.reaskOrGiveUp(q, merged, text), true
|
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||||
}
|
}
|
||||||
h.clarifyStore.Delete(voiceDialogueID)
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
|
|
||||||
// One gap filled is not the same as a complete request. askClarify parks
|
// One gap filled is not the same as a complete request. askClarify parks
|
||||||
// only the first gap, because one question per turn is the rule, but a
|
// only the first gap, because one question per turn is the rule, but a
|
||||||
@@ -217,7 +217,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
// a reminder with no time, which answered "не получилось разобрать время
|
// a reminder with no time, which answered "не получилось разобрать время
|
||||||
// напоминания." — an error for a request she never finished asking about.
|
// напоминания." — an error for a request she never finished asking about.
|
||||||
// Re-enter the loop instead, one question at a time as before.
|
// Re-enter the loop instead, one question at a time as before.
|
||||||
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
|
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
|||||||
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
||||||
// costs a question exactly like a second try at the first one does, so the cap
|
// costs a question exactly like a second try at the first one does, so the cap
|
||||||
// still bounds how many times she can speak before acting or letting go.
|
// still bounds how many times she can speak before acting or letting go.
|
||||||
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||||
if len(remaining) == 0 {
|
if len(remaining) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -270,7 +270,7 @@ func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent ro
|
|||||||
if !ok || !q.CanAsk() {
|
if !ok || !q.CanAsk() {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||||
Intent: q.Intent,
|
Intent: q.Intent,
|
||||||
Slots: merged,
|
Slots: merged,
|
||||||
Missing: []dialogue.Slot{remaining[0]},
|
Missing: []dialogue.Slot{remaining[0]},
|
||||||
@@ -287,13 +287,13 @@ func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent ro
|
|||||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||||
// again while she has attempts left, otherwise say she did not understand and
|
// again while she has attempts left, otherwise say she did not understand and
|
||||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||||
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||||
question := ""
|
question := ""
|
||||||
if len(q.Missing) > 0 {
|
if len(q.Missing) > 0 {
|
||||||
question = clarifyQuestions[q.Missing[0]]
|
question = clarifyQuestions[q.Missing[0]]
|
||||||
}
|
}
|
||||||
if question == "" || !q.CanAsk() {
|
if question == "" || !q.CanAsk() {
|
||||||
h.clarifyStore.Delete(voiceDialogueID)
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||||
return clarifyGaveUp
|
return clarifyGaveUp
|
||||||
}
|
}
|
||||||
@@ -302,7 +302,7 @@ func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dial
|
|||||||
q.Slots = merged
|
q.Slots = merged
|
||||||
q.Attempts++
|
q.Attempts++
|
||||||
q.Asked = h.now()
|
q.Asked = h.now()
|
||||||
h.clarifyStore.Put(voiceDialogueID, q)
|
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||||
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||||||
return question
|
return question
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-20
@@ -81,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||||
if !asked || question != "Когда?" {
|
if !asked || question != "Когда?" {
|
||||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||||
t.Fatal("a fact with no key should be asked about")
|
t.Fatal("a fact with no key should be asked about")
|
||||||
}
|
}
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||||||
@@ -128,7 +128,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, now := newClarifyHandler(t)
|
h, st, now := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
@@ -147,7 +147,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a first question")
|
t.Fatal("expected a first question")
|
||||||
}
|
}
|
||||||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||||||
@@ -185,7 +185,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
|||||||
h, _, _ := newClarifyHandler(t)
|
h, _, _ := newClarifyHandler(t)
|
||||||
h.clarifyMaxAttempts = 1
|
h.clarifyMaxAttempts = 1
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||||||
@@ -199,7 +199,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
// First answer parses, but re-park it by hand as if she had asked again:
|
// First answer parses, but re-park it by hand as if she had asked again:
|
||||||
@@ -232,7 +232,7 @@ func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
|||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||||
t.Fatal("an act with no fn should be asked about")
|
t.Fatal("an act with no fn should be asked about")
|
||||||
}
|
}
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||||||
@@ -260,7 +260,7 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||||||
@@ -284,7 +284,7 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
|||||||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
||||||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
||||||
} {
|
} {
|
||||||
if question, asked := h.askClarify(dec); asked {
|
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||||||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,29 +296,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
|||||||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||||||
// TTL: she must say the old request is gone AND still answer the new words.
|
// TTL: she must say the old request is gone AND still answer the new words.
|
||||||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h.embedder = emb
|
h.embedder = emb
|
||||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
|
|
||||||
reply := h.handleText(ctx, "как дела")
|
reply := h.handleText(ctx, "", "как дела")
|
||||||
if !isClarifyExpired(reply) {
|
if !isClarifyExpired(reply) {
|
||||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||||
}
|
}
|
||||||
if trimClarifyExpired(reply) == "" {
|
if trimClarifyExpired(reply) == "" {
|
||||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
// The notice is said once, not on every later utterance.
|
// The notice is said once, not on every later utterance.
|
||||||
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
|
if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
|
||||||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +340,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||||
if !asked || question != "О чём напомнить?" {
|
if !asked || question != "О чём напомнить?" {
|
||||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,7 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
|||||||
h, _, _ := newClarifyHandler(t)
|
h, _, _ := newClarifyHandler(t)
|
||||||
h.clarifyMaxAttempts = 1
|
h.clarifyMaxAttempts = 1
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||||
t.Fatal("expected the subject question")
|
t.Fatal("expected the subject question")
|
||||||
}
|
}
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||||
@@ -440,10 +440,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
|||||||
// The confirm turn used to return before the notice was even computed, so he
|
// The confirm turn used to return before the notice was even computed, so he
|
||||||
// answered the confirm and never heard that the older request was let go.
|
// answered the confirm and never heard that the older request was let go.
|
||||||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
// A confirm parked with a longer life than the question, so only the
|
// A confirm parked with a longer life than the question, so only the
|
||||||
@@ -451,7 +451,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
|||||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
|
|
||||||
reply := h.handleText(ctx, "нет")
|
reply := h.handleText(ctx, "", "нет")
|
||||||
if !isClarifyExpired(reply) {
|
if !isClarifyExpired(reply) {
|
||||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||||
}
|
}
|
||||||
@@ -461,7 +461,92 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
|||||||
if h.pending != nil {
|
if h.pending != nil {
|
||||||
t.Fatal("the confirm must still have been consumed")
|
t.Fatal("the confirm must still have been consumed")
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The other half of the subject question: his answer must fill the empty slot,
|
||||||
|
// not replace the request. Slots.Text used to be the whole raw utterance for
|
||||||
|
// every intent, so the branch that fills a text slot could only ever overwrite
|
||||||
|
// (Vikunja #383). Here the parked request holds the hour and the answer holds
|
||||||
|
// what to say at it, and the reminder that lands has both.
|
||||||
|
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
at := h.now().Add(2 * time.Hour)
|
||||||
|
|
||||||
|
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||||
|
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||||||
|
if !asked || question != "О чём напомнить?" {
|
||||||
|
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||||
|
}
|
||||||
|
if reply == clarifyGaveUp {
|
||||||
|
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||||
|
if err != nil || len(reminders) != 1 {
|
||||||
|
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||||
|
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reminders[0].Payload, "11") {
|
||||||
|
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClarifyIsPerConversation — the parked question belongs to the reach that
|
||||||
|
// was asked. Before this the clarify store had one global key, so a question
|
||||||
|
// asked in the web chat and never answered captured the next utterance from
|
||||||
|
// telegram, or from the mic, and answered it against a request the speaker had
|
||||||
|
// never made (Vikunja #466).
|
||||||
|
func TestClarifyIsPerConversation(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||||
|
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
|
||||||
|
|
||||||
|
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question on the web conversation")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question asked on the web must not eat a telegram utterance")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
|
||||||
|
t.Fatal("a question asked on the web must not eat what he says at the mic")
|
||||||
|
}
|
||||||
|
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||||
|
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceCtx — the mic's conversation, which carries no id of its own.
|
||||||
|
func voiceCtx() context.Context {
|
||||||
|
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||||||
|
// question dies with the process, and she does not claim to have let it go —
|
||||||
|
// the words that follow are routed as a fresh request. Restarting is modelled
|
||||||
|
// the way the daemon does it, by building a second handler over the same store.
|
||||||
|
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
ctx := voiceCtx()
|
||||||
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question before the restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
restarted, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||||||
|
}
|
||||||
|
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||||||
|
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-8
@@ -107,14 +107,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
|||||||
return pr != nil && !h.now().After(pr.expiry)
|
return pr != nil && !h.now().After(pr.expiry)
|
||||||
},
|
},
|
||||||
yes: func() string {
|
yes: func() string {
|
||||||
// Only record the acceptance. The tick loop reads accepted
|
// Voice does NOT accept (Vikunja #367). Accepting hands the
|
||||||
// routines and nudges on their own interval. Building a
|
// tick loop a standing new reason to speak, which is the same
|
||||||
// reminder here made a routine fire exactly once (Vikunja #366).
|
// tier as enabling a tool — and DESIGN.md § "surface caps
|
||||||
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil {
|
// authority" says a room mic, reachable by anyone present, is
|
||||||
log.Printf("voice: accept proposed routine: %v", err)
|
// structurally incapable of layer 3. So a spoken "да" leaves
|
||||||
return "не получилось запомнить рутину."
|
// the row 'proposed' and points at the authed page, where the
|
||||||
}
|
// accept button is gated at step-up. The convenience of
|
||||||
return "буду напоминать."
|
// answering out loud stays; the authority does not move.
|
||||||
|
//
|
||||||
|
// Acceptance itself is recorded by /routines, and the tick
|
||||||
|
// loop nudges on the interval from there (Vikunja #366).
|
||||||
|
return "поняла — подтверди на странице рутин, и начну напоминать."
|
||||||
},
|
},
|
||||||
no: func() string {
|
no: func() string {
|
||||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ungroundedConfidence — what a self fact is worth when its value appears
|
||||||
|
// nowhere in what he said. Below `query_min_score` is not the point (recall
|
||||||
|
// gates on vector distance, not on this number); the point is that
|
||||||
|
// `/history` and every future reader can tell a value he said from a value
|
||||||
|
// the model supplied.
|
||||||
|
const ungroundedConfidence = 0.6
|
||||||
|
|
||||||
|
// factConfidence scores a self fact by whether its value is grounded in the
|
||||||
|
// utterance it came from. Grounded stays 1.00, which is what a tapped fact
|
||||||
|
// has always been worth. Ungrounded drops, and says so in the log.
|
||||||
|
//
|
||||||
|
// An empty value is grounded by definition: the key alone carries the fact
|
||||||
|
// ("поужинал"), and there is nothing for the model to have invented.
|
||||||
|
func factConfidence(utterance, value string) float64 {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
if valueGrounded(utterance, value) {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
log.Printf("voice: fact value %q is not in %q — writing at confidence %.2f",
|
||||||
|
value, utterance, ungroundedConfidence)
|
||||||
|
return ungroundedConfidence
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueGrounded reports whether every word of value traces back to a word he
|
||||||
|
// actually said. The comparison is on a 4-rune prefix, so the model's
|
||||||
|
// normalization survives ("пил воду" → "вода") while an invented value
|
||||||
|
// ("1.20" for a question about Go) does not.
|
||||||
|
func valueGrounded(utterance, value string) bool {
|
||||||
|
said := factTokens(utterance)
|
||||||
|
words := factTokens(value)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, w := range words {
|
||||||
|
if !anyTokenMatches(said, w) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func anyTokenMatches(said []string, w string) bool {
|
||||||
|
for _, s := range said {
|
||||||
|
if s == w || sameStem(s, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameStem is inflection tolerance and nothing more: it compares all but the
|
||||||
|
// last rune of the shorter word, and never fewer than three. Russian marks
|
||||||
|
// case on the ending, so "пил воду" and the stored "вода" are the same word he
|
||||||
|
// said, while "1.20" and "версия" are not. A word of three runes or fewer must
|
||||||
|
// match outright, where a shorter prefix would match half the language.
|
||||||
|
func sameStem(a, b string) bool {
|
||||||
|
ar, br := []rune(a), []rune(b)
|
||||||
|
shorter := min(len(ar), len(br))
|
||||||
|
n := shorter - 1
|
||||||
|
if n < 3 || len(ar) < n || len(br) < n {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(ar[:n]) == string(br[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// factTokens lowercases and splits on everything that is not a letter or a
|
||||||
|
// digit, the same shape planTokens uses in the router.
|
||||||
|
func factTokens(s string) []string {
|
||||||
|
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/memory"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/tool"
|
||||||
|
"github.com/kami/maven/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.CoreAPI) {
|
||||||
|
t.Helper()
|
||||||
|
st := newTestStore(t)
|
||||||
|
api := ipc.NewStoreAPI(st)
|
||||||
|
emb := router.NewHashEmbedder(1024)
|
||||||
|
h := &reactiveHandler{
|
||||||
|
api: api,
|
||||||
|
embedder: emb,
|
||||||
|
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
memStore: memory.NewInMemoryStore(),
|
||||||
|
dataStore: st,
|
||||||
|
}
|
||||||
|
return h, api
|
||||||
|
}
|
||||||
|
|
||||||
|
// The write half of #470: a question routed to IntentFact must not become a
|
||||||
|
// fact about him, and must not leave a vector behind for recall to serve.
|
||||||
|
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, api := newFactGateHandler(t, time.Now())
|
||||||
|
|
||||||
|
reply := h.actionFact(ctx, router.Decision{
|
||||||
|
Intent: router.IntentFact,
|
||||||
|
Utterance: "какая последняя версия языка Go?",
|
||||||
|
Slots: router.Slots{Key: "go_version", HasKey: true, Value: `"1.20"`},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
||||||
|
t.Fatal("a question was stored as a fact about him")
|
||||||
|
}
|
||||||
|
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memory search: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) != 0 {
|
||||||
|
t.Fatalf("the question was indexed for recall: %+v", hits)
|
||||||
|
}
|
||||||
|
// It went down the query chain instead. Nothing is configured to answer a
|
||||||
|
// world question in this harness, so "не знаю." is the honest outcome —
|
||||||
|
// what matters is that the turn was answered, not stored.
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("the turn was neither stored nor answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The capture that must survive the gate: an explicit instruction to record,
|
||||||
|
// even though it contains an interrogative.
|
||||||
|
func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, api := newFactGateHandler(t, time.Now())
|
||||||
|
|
||||||
|
h.actionFact(ctx, router.Decision{
|
||||||
|
Intent: router.IntentFact,
|
||||||
|
Utterance: "запиши что я пил воду",
|
||||||
|
Slots: router.Slots{Key: "water", HasKey: true, Value: `"вода"`},
|
||||||
|
})
|
||||||
|
|
||||||
|
f, err := api.LatestFact(ctx, "water")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an explicit capture was refused: %v", err)
|
||||||
|
}
|
||||||
|
if f.Confidence != 1.0 {
|
||||||
|
t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence)
|
||||||
|
}
|
||||||
|
// #493: what recall reads back is the fact, not the sentence he said.
|
||||||
|
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
||||||
|
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
||||||
|
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memory search: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) != 1 {
|
||||||
|
t.Fatalf("the fact was not indexed once: %+v", hits)
|
||||||
|
}
|
||||||
|
if got := hits[0].Meta["text"]; got != "water — вода" {
|
||||||
|
t.Errorf("indexed text = %q, want the fact", got)
|
||||||
|
}
|
||||||
|
if got := hits[0].Meta["utterance"]; got != "запиши что я пил воду" {
|
||||||
|
t.Errorf("utterance provenance = %q, want it kept alongside", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactConfidence(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance, value string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"запиши что я пил воду", `"вода"`, 1.0},
|
||||||
|
{"я выпил кофе", `"кофе"`, 1.0},
|
||||||
|
{"поужинал", "", 1.0},
|
||||||
|
{"отметь что я полил кактус", `"полил кактус"`, 1.0},
|
||||||
|
{"какая последняя версия языка Go", `"1.20"`, ungroundedConfidence},
|
||||||
|
{"кто премьер Японии", `"Тонио Озаки"`, ungroundedConfidence},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := factConfidence(c.utterance, c.value); got != c.want {
|
||||||
|
t.Errorf("factConfidence(%q, %q) = %v, want %v", c.utterance, c.value, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||||
|
t.Helper()
|
||||||
|
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("embed %q: %v", text, err)
|
||||||
|
}
|
||||||
|
return vec
|
||||||
|
}
|
||||||
+62
-8
@@ -1,24 +1,79 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
// voiceDialogueID — the single dialogue-session key. This is a single-user box
|
// voiceDialogueID — the dialogue-session key for the microphone, and the
|
||||||
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids,
|
// clarify key for it too. This is a single-user box (ponytail), so one slot
|
||||||
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
|
// suffices; a second speaker would need per-speaker ids, which waits on
|
||||||
|
// voice-print attribution (see PROGRESS multi-user deferral).
|
||||||
const voiceDialogueID = "voice"
|
const voiceDialogueID = "voice"
|
||||||
|
|
||||||
// toDialogueSlots projects the router's slots onto the dialogue layer's subset
|
// textDialogueID — the clarify key for a text turn that named no conversation.
|
||||||
// (everything except the fact Value, which the dialogue layer doesn't carry).
|
// Separate from the mic: an old client that sends no id still must not answer
|
||||||
|
// a question she asked out loud.
|
||||||
|
const textDialogueID = "text"
|
||||||
|
|
||||||
|
// dialogueKey — the context key carrying the id of the conversation this turn
|
||||||
|
// belongs to. It rides the context rather than a parameter for the same reason
|
||||||
|
// the correlation id does: every step of the turn needs it, most of them only
|
||||||
|
// to hand to the next one, and threading it by hand would put it in six
|
||||||
|
// clarify signatures that have nothing else to say about it.
|
||||||
|
type dialogueKey struct{}
|
||||||
|
|
||||||
|
// dialogueIDFor builds the id a turn is held under: the conversation the reach
|
||||||
|
// named, qualified by the tap it arrived on, or the tap's own fallback when it
|
||||||
|
// named none.
|
||||||
|
//
|
||||||
|
// A parked clarifying question used to be held under voiceDialogueID no matter
|
||||||
|
// where the turn came from, so one unanswerable question captured the next
|
||||||
|
// three utterances from anywhere. Three independent curl sessions fed a
|
||||||
|
// capture attempt that had already failed, and a reminder among them was lost
|
||||||
|
// (Vikunja #466).
|
||||||
|
func dialogueIDFor(src turnSource, conversation string) string {
|
||||||
|
if conversation != "" {
|
||||||
|
return string(src) + ":" + conversation
|
||||||
|
}
|
||||||
|
if src == sourceVoice {
|
||||||
|
return voiceDialogueID
|
||||||
|
}
|
||||||
|
return textDialogueID
|
||||||
|
}
|
||||||
|
|
||||||
|
// withDialogueID tags a turn with that id.
|
||||||
|
func withDialogueID(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, dialogueKey{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogueIDOf reads it back. Falls back to the microphone's slot, which is
|
||||||
|
// what an unthreaded caller — a test, an internal replay — gets.
|
||||||
|
func dialogueIDOf(ctx context.Context) string {
|
||||||
|
if id, ok := ctx.Value(dialogueKey{}).(string); ok && id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return voiceDialogueID
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDialogueSlots and applyDialogueSlots are the only bridge between
|
||||||
|
// router.Slots and dialogue.Slots. dialogue must not import router (import
|
||||||
|
// cycle), so the two structs are hand-kept copies and every field has to be
|
||||||
|
// carried by hand here. Adding a field to either struct without adding it to
|
||||||
|
// BOTH functions loses a slot silently — nothing fails to build. The tests in
|
||||||
|
// slotsparity_test.go fail when the field sets or the converters stop matching;
|
||||||
|
// when they do, fix these two functions, not the tests.
|
||||||
|
|
||||||
|
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
|
||||||
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
||||||
return dialogue.Slots{
|
return dialogue.Slots{
|
||||||
Time: s.Time,
|
Time: s.Time,
|
||||||
HasTime: s.HasTime,
|
HasTime: s.HasTime,
|
||||||
Key: s.Key,
|
Key: s.Key,
|
||||||
|
Value: s.Value,
|
||||||
HasKey: s.HasKey,
|
HasKey: s.HasKey,
|
||||||
Text: s.Text,
|
Text: s.Text,
|
||||||
Fn: s.Fn,
|
Fn: s.Fn,
|
||||||
@@ -27,11 +82,10 @@ func toDialogueSlots(s router.Slots) dialogue.Slots {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyDialogueSlots writes inherited dialogue slots back onto router slots,
|
// applyDialogueSlots writes dialogue slots back onto router slots.
|
||||||
// preserving router-only fields (Value) the dialogue layer never touched.
|
|
||||||
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
||||||
base.Time, base.HasTime = d.Time, d.HasTime
|
base.Time, base.HasTime = d.Time, d.HasTime
|
||||||
base.Key, base.HasKey = d.Key, d.HasKey
|
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
|
||||||
base.Text = d.Text
|
base.Text = d.Text
|
||||||
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
|
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func run(args []string) error {
|
|||||||
Listen: cfg.Phraser.Listen,
|
Listen: cfg.Phraser.Listen,
|
||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
|
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
LLMNudges: cfg.Phraser.LLMNudges,
|
LLMNudges: cfg.Phraser.LLMNudges,
|
||||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
@@ -525,6 +526,7 @@ func run(args []string) error {
|
|||||||
Listen: cfg.Phraser.Listen,
|
Listen: cfg.Phraser.Listen,
|
||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
|
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
LLMNudges: cfg.Phraser.LLMNudges,
|
LLMNudges: cfg.Phraser.LLMNudges,
|
||||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
@@ -788,6 +790,22 @@ func personaFacts(cfg *config.Config) persona.Facts {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cacheRAMMiB resolves phraser.cache_ram_mib into the phraser's field. Unset
|
||||||
|
// means 512 MiB and not "whatever the server does", because the server's own
|
||||||
|
// default is 8 GiB of prompt cache and that is what put 7.9 GB of RSS and half
|
||||||
|
// a gigabyte of swap on homesrv for a 1.1 GB model. A negative value is the
|
||||||
|
// deliberate opt-out: no flag is passed, the server's default applies, and the
|
||||||
|
// operator owns the consequence.
|
||||||
|
func cacheRAMMiB(configured int) int {
|
||||||
|
if configured == 0 {
|
||||||
|
return 512
|
||||||
|
}
|
||||||
|
if configured < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
|
||||||
// contextBlockFn returns the per-turn renderer of the shared context block.
|
// contextBlockFn returns the per-turn renderer of the shared context block.
|
||||||
// Per turn, not once at startup, because the block states the current time.
|
// Per turn, not once at startup, because the block states the current time.
|
||||||
func contextBlockFn(cfg *config.Config, now func() time.Time) func() string {
|
func contextBlockFn(cfg *config.Config, now func() time.Time) func() string {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// No `workstation` block is the shipping deploy. The seam must then be the
|
||||||
|
// resident client itself, with nothing probing anything.
|
||||||
|
func TestModelSeamUnconfiguredIsResidentOnly(t *testing.T) {
|
||||||
|
resident := llm.New("http://127.0.0.1:1", time.Second)
|
||||||
|
hot, pair := modelSeam(&config.Config{}, resident)
|
||||||
|
if pair != nil {
|
||||||
|
t.Error("built a pair with no workstation configured")
|
||||||
|
}
|
||||||
|
if hot == nil {
|
||||||
|
t.Fatal("no seam at all, so the cascade would route with the classifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A workstation with no resident model behind it has no floor, and a Pair with
|
||||||
|
// no floor is a configuration mistake rather than a degraded mode.
|
||||||
|
func TestModelSeamWithoutResidentIsNil(t *testing.T) {
|
||||||
|
cfg := &config.Config{Workstation: &config.WorkstationConfig{URL: "http://127.0.0.1:1"}}
|
||||||
|
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||||
|
hot, pair := modelSeam(cfg, nil)
|
||||||
|
if hot != nil || pair != nil {
|
||||||
|
t.Errorf("built a seam with no floor: hot=%v pair=%v", hot, pair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configured case: the seam is the pair, and the pair notices a workstation
|
||||||
|
// that answers /health.
|
||||||
|
func TestModelSeamPrefersAnAnsweringWorkstation(t *testing.T) {
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer up.Close()
|
||||||
|
|
||||||
|
cfg := &config.Config{Workstation: &config.WorkstationConfig{
|
||||||
|
URL: up.URL,
|
||||||
|
Probe: config.Duration(10 * time.Millisecond),
|
||||||
|
}}
|
||||||
|
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||||
|
|
||||||
|
hot, pair := modelSeam(cfg, llm.New("http://127.0.0.1:1", time.Second))
|
||||||
|
if pair == nil || hot == nil {
|
||||||
|
t.Fatal("no pair built for a configured workstation")
|
||||||
|
}
|
||||||
|
defer pair.Stop()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for !pair.Available() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !pair.Available() {
|
||||||
|
t.Fatal("the pair never saw a workstation that answers /health")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card held by a CPT run answers 503, and that must read as unavailable
|
||||||
|
// rather than as an error a turn has to handle.
|
||||||
|
func TestModelSeamHeldCardIsUnavailable(t *testing.T) {
|
||||||
|
busy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer busy.Close()
|
||||||
|
|
||||||
|
cfg := &config.Config{Workstation: &config.WorkstationConfig{
|
||||||
|
URL: busy.URL,
|
||||||
|
Probe: config.Duration(10 * time.Millisecond),
|
||||||
|
}}
|
||||||
|
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||||
|
|
||||||
|
_, pair := modelSeam(cfg, llm.New("http://127.0.0.1:1", time.Second))
|
||||||
|
if pair == nil {
|
||||||
|
t.Fatal("no pair built for a configured workstation")
|
||||||
|
}
|
||||||
|
defer pair.Stop()
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if pair.Available() {
|
||||||
|
t.Error("a 503 from the supervisor read as available")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMorningNudgeBodySeparatesOptional — the one message a routine is allowed
|
||||||
|
// per day says what was not done, then what he could still do (Vikunja #473).
|
||||||
|
func TestMorningNudgeBodySeparatesOptional(t *testing.T) {
|
||||||
|
cand := morning.Candidate{
|
||||||
|
Routine: morning.Routine{Name: "утро"},
|
||||||
|
Missing: []morning.Item{
|
||||||
|
{Key: "meds", Label: "таблетки"},
|
||||||
|
{Key: "stretch", Label: "растяжка", Optional: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := morningNudgeBody(cand)
|
||||||
|
if !strings.Contains(body, "не сделано — таблетки") {
|
||||||
|
t.Fatalf("the required item must be named as not done: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "если будет время — растяжка") {
|
||||||
|
t.Fatalf("the optional item must read softer: %q", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "не сделано — таблетки, растяжка") {
|
||||||
|
t.Fatalf("optional must not be folded into the required list: %q", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing optional missing: the sentence is what it always was.
|
||||||
|
only := morning.Candidate{
|
||||||
|
Routine: morning.Routine{Name: "утро"},
|
||||||
|
Missing: []morning.Item{{Key: "meds", Label: "таблетки"}},
|
||||||
|
}
|
||||||
|
if got, want := morningNudgeBody(only), "утро: не сделано — таблетки"; got != want {
|
||||||
|
t.Fatalf("morningNudgeBody = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -283,3 +284,81 @@ func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVoiceYesDoesNotAcceptRoutine — Vikunja #367. Accepting a routine hands
|
||||||
|
// the tick loop a standing new reason to speak, which DESIGN.md puts at layer
|
||||||
|
// 3, and voice is structurally incapable of layer 3. A spoken "да" must park
|
||||||
|
// the decision for the authed page, not flip the row itself.
|
||||||
|
func TestVoiceYesDoesNotAcceptRoutine(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1)
|
||||||
|
|
||||||
|
h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
// The MinEvents'th event is the one that makes the pattern detectable, and
|
||||||
|
// it goes through the voice path so the proposal is parked for a y/n.
|
||||||
|
last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour)
|
||||||
|
factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write fact: %v", err)
|
||||||
|
}
|
||||||
|
if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" {
|
||||||
|
t.Fatal("expected a parked routine proposal")
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, handled := h.resolveConfirm(ctx, "да")
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("the spoken yes should be consumed by the routine confirm")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "рутин") {
|
||||||
|
t.Fatalf("reply should send him to the routines page, got %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineAccepted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list accepted: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("voice accepted a routine: %+v", rows)
|
||||||
|
}
|
||||||
|
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list proposed: %v", err)
|
||||||
|
}
|
||||||
|
if len(proposed) != 1 {
|
||||||
|
t.Fatalf("proposed routines = %d, want 1 (still waiting for the page)", len(proposed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVoiceNoStillDismissesRoutine — declining does not move the boundary
|
||||||
|
// outward, so voice keeps it. Only acceptance is gated.
|
||||||
|
func TestVoiceNoStillDismissesRoutine(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1)
|
||||||
|
|
||||||
|
h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour)
|
||||||
|
factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write fact: %v", err)
|
||||||
|
}
|
||||||
|
if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" {
|
||||||
|
t.Fatal("expected a parked routine proposal")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, handled := h.resolveConfirm(ctx, "нет"); !handled {
|
||||||
|
t.Fatal("the spoken no should be consumed by the routine confirm")
|
||||||
|
}
|
||||||
|
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineDismissed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list dismissed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("dismissed routines = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The personal boundary decides one thing: is this question about him. It used
|
||||||
|
// to decide it by matching possession words, and that was the whole defect
|
||||||
|
// behind Vikunja #495. "что я говорил про бэкапы?" is his data by definition —
|
||||||
|
// nothing outside the box has ever heard him say anything — and it carried no
|
||||||
|
// possession word, so it walked past the boundary into SearXNG and came back
|
||||||
|
// answered out of a Habr article about somebody else's backups.
|
||||||
|
//
|
||||||
|
// The first fix was one more marker class, `я говорил|сказал|писал|…`, plus a
|
||||||
|
// carve-out so "как я говорил, почему небо синее" stayed a world question. Both
|
||||||
|
// halves are a lexicon, and a lexicon is the wrong instrument here: Russian
|
||||||
|
// gives every verb a dozen surface forms, the preamble list has no end, and
|
||||||
|
// every utterance the list misses is one that reaches the world. It also drifts
|
||||||
|
// silently — a missing verb looks exactly like no bug.
|
||||||
|
//
|
||||||
|
// So the boundary asks the embedder instead. Two frozen seed sets — questions
|
||||||
|
// about him, questions about the world — are embedded once, and the turn's own
|
||||||
|
// query vector, already computed by queryEmbed upstream, is scored against
|
||||||
|
// both. Nearest side wins. Word order, verb form and unseen phrasing stop
|
||||||
|
// mattering, which is exactly what a lexicon could not do.
|
||||||
|
//
|
||||||
|
// Measured 03-08-2026 against multilingual-e5-small on 19 held-out utterances,
|
||||||
|
// none of them a seed: 19 right (TestONNXPersonalBoundary). A 20th, "as i said,
|
||||||
|
// what is the population of india", missed by +0.008 during the first pass and
|
||||||
|
// is a world seed now, which is why it is not in the held-out set. True
|
||||||
|
// positives clear the world side by +0.014 to +0.089 and the nearest true
|
||||||
|
// negative sits at -0.005, so the gate is the sign of the difference and
|
||||||
|
// nothing tighter: the margins are too thin to justify a threshold, and the
|
||||||
|
// asymmetry favours claiming anyway. A false claim costs one honest "не знаю";
|
||||||
|
// a false pass sends his life to an upstream engine.
|
||||||
|
//
|
||||||
|
// The embedder is the one model CLAUDE.md pins to homesrv permanently, and it
|
||||||
|
// is what makes this affordable: no llama-server call, no network, one cosine
|
||||||
|
// per seed against a vector the turn already has.
|
||||||
|
|
||||||
|
// personalSeeds — questions about him. Frozen: they are scoring data, so
|
||||||
|
// editing one moves the boundary and must be re-measured, not eyeballed. Cover
|
||||||
|
// both classes the boundary owns, possession and first-person speech, in both
|
||||||
|
// languages.
|
||||||
|
var personalSeeds = []string{
|
||||||
|
"что я говорил про это",
|
||||||
|
"я тебе рассказывал об этом?",
|
||||||
|
"что я записал про врача",
|
||||||
|
"я упоминал эту тему?",
|
||||||
|
"что у меня сегодня",
|
||||||
|
"когда моя встреча",
|
||||||
|
"what did i say about this",
|
||||||
|
"did i mention this to you",
|
||||||
|
}
|
||||||
|
|
||||||
|
// worldSeeds — questions the world can answer, including the two shapes that
|
||||||
|
// look personal and are not: a first-person preamble on a world question ("как
|
||||||
|
// я говорил, ..."), and first person without possession ("что я могу
|
||||||
|
// посмотреть вечером"). Refusing those is the opposite mistake and the older
|
||||||
|
// comment on personalMarkers already named it.
|
||||||
|
var worldSeeds = []string{
|
||||||
|
"почему небо синее",
|
||||||
|
"какая столица франции",
|
||||||
|
"как сварить борщ",
|
||||||
|
"кто написал эту книгу",
|
||||||
|
"what is the capital of france",
|
||||||
|
"how do i boil an egg",
|
||||||
|
"как я говорил, почему небо синее",
|
||||||
|
"as i said, why is the sky blue",
|
||||||
|
"as i said, what is the population of india",
|
||||||
|
"что я могу посмотреть вечером",
|
||||||
|
"что мне почитать про историю",
|
||||||
|
"что я должен знать про питон",
|
||||||
|
"what can i watch tonight",
|
||||||
|
}
|
||||||
|
|
||||||
|
// personalBoundary holds the embedded seeds. Zero value is usable and means
|
||||||
|
// "not loaded yet"; a handler built without an embedder never loads and the
|
||||||
|
// boundary falls back to personalMarkers.
|
||||||
|
type personalBoundary struct {
|
||||||
|
once sync.Once
|
||||||
|
personal [][]float32
|
||||||
|
world [][]float32
|
||||||
|
loaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// load embeds both seed sets, once per process. Seeds are embedded on the QUERY
|
||||||
|
// side, like the utterance they are compared with — a question against a
|
||||||
|
// question. Mixing sides would measure the e5 prefix, not the meaning.
|
||||||
|
func (b *personalBoundary) load(ctx context.Context, emb router.Embedder) {
|
||||||
|
b.once.Do(func() {
|
||||||
|
if emb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
embedAll := func(ss []string) [][]float32 {
|
||||||
|
out := make([][]float32, 0, len(ss))
|
||||||
|
for _, s := range ss {
|
||||||
|
v, err := router.EmbedQuery(ctx, emb, s)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: personal boundary seeds unavailable (%v); falling back to possession markers", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
p, w := embedAll(personalSeeds), embedAll(worldSeeds)
|
||||||
|
if p == nil || w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.personal, b.world, b.loaded = p, w, true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// score returns the best similarity to each side. ok is false when the seeds
|
||||||
|
// are not loaded, which is the caller's signal to use the markers instead.
|
||||||
|
func (b *personalBoundary) score(vec []float32) (personal, world float64, ok bool) {
|
||||||
|
if !b.loaded || len(vec) == 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
best := func(seeds [][]float32) float64 {
|
||||||
|
m := -1.0
|
||||||
|
for _, s := range seeds {
|
||||||
|
if c := cosine(vec, s); c > m {
|
||||||
|
m = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return best(b.personal), best(b.world), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cosine — same math as internal/router and internal/memory, small enough that
|
||||||
|
// importing one of them for it would be the larger coupling.
|
||||||
|
func cosine(a, b []float32) float64 {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var dot, na, nb float64
|
||||||
|
for i := range a {
|
||||||
|
dot += float64(a[i]) * float64(b[i])
|
||||||
|
na += float64(a[i]) * float64(a[i])
|
||||||
|
nb += float64(b[i]) * float64(b[i])
|
||||||
|
}
|
||||||
|
if na == 0 || nb == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A handler with no embedder never loads the seeds, so the boundary falls back
|
||||||
|
// to the possession markers. That is the offline floor and it must keep working
|
||||||
|
// — an embedder that fails to load must not open the boundary.
|
||||||
|
func TestBoundaryFallsBackToMarkersWithNoEmbedder(t *testing.T) {
|
||||||
|
h := personalHandler()
|
||||||
|
if !h.isPersonalTurn(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "во сколько у меня встреча"},
|
||||||
|
}) {
|
||||||
|
t.Error("no embedder: a possession question must still be personal")
|
||||||
|
}
|
||||||
|
if h.isPersonalTurn(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "почему небо синее"},
|
||||||
|
}) {
|
||||||
|
t.Error("no embedder: a world question must still pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestONNXPersonalBoundary — the number that matters, scored against the
|
||||||
|
// embedder homesrv actually runs. Opt-in via MAVEN_ONNX_LIB, exactly like
|
||||||
|
// TestONNXRecall in internal/memory/recalleval.
|
||||||
|
//
|
||||||
|
// Every case here is held out: none of these strings is a seed. The #495
|
||||||
|
// regression is the first row — "что я говорил про бэкапы?" reached SearXNG and
|
||||||
|
// was answered from a Habr article, and no possession word appears in it.
|
||||||
|
func TestONNXPersonalBoundary(t *testing.T) {
|
||||||
|
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||||
|
if lib == "" {
|
||||||
|
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||||
|
}
|
||||||
|
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||||
|
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("onnx embedder unavailable: %v", err)
|
||||||
|
}
|
||||||
|
defer emb.Close()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
personal bool
|
||||||
|
}{
|
||||||
|
{"что я говорил про бэкапы?", true},
|
||||||
|
{"что я сказал вчера про отпуск", true},
|
||||||
|
{"я писал что-нибудь про сервер", true},
|
||||||
|
{"я упоминал про конференцию?", true},
|
||||||
|
{"что я отмечал по поводу переезда", true},
|
||||||
|
{"я рассказывал тебе про новую работу?", true},
|
||||||
|
{"во сколько у меня встреча", true},
|
||||||
|
{"когда мой следующий отпуск", true},
|
||||||
|
{"what did i say about backups", true},
|
||||||
|
{"did i tell you about the doctor", true},
|
||||||
|
{"как я говорил, почему небо синее", false},
|
||||||
|
{"как уже я говорил, какая столица франции", false},
|
||||||
|
{"почему трава зелёная", false},
|
||||||
|
{"столица франции", false},
|
||||||
|
{"как мне сварить борщ", false},
|
||||||
|
{"что мне посмотреть вечером", false},
|
||||||
|
{"я хочу узнать про рим", false},
|
||||||
|
{"кто такой гагарин", false},
|
||||||
|
{"how do i boil an egg", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &reactiveHandler{embedder: emb}
|
||||||
|
ctx := context.Background()
|
||||||
|
wrong := 0
|
||||||
|
for _, c := range cases {
|
||||||
|
vec, err := router.EmbedQuery(ctx, emb, c.utterance)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("embed %q: %v", c.utterance, err)
|
||||||
|
}
|
||||||
|
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
|
||||||
|
got := h.isPersonalTurn(ctx, turn)
|
||||||
|
p, w, ok := h.boundary.score(vec)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("seeds did not load with a working embedder")
|
||||||
|
}
|
||||||
|
if got != c.personal {
|
||||||
|
wrong++
|
||||||
|
t.Errorf("%q: personal=%v want %v (personal %.4f world %.4f)", c.utterance, got, c.personal, p, w)
|
||||||
|
}
|
||||||
|
t.Logf("personal=%-5v personal %.4f world %.4f delta %+.4f %s", got, p, w, p-w, c.utterance)
|
||||||
|
}
|
||||||
|
t.Logf("personal boundary: %d/%d held-out utterances correct", len(cases)-wrong, len(cases))
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
@@ -85,3 +87,38 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
|
||||||
|
// task table. It went dead when the router started claiming the marker as an
|
||||||
|
// act: capture rides the note intent, so nothing below actionNote was ever
|
||||||
|
// reached and every capture answered "Что сделать?" (Vikunja #467).
|
||||||
|
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
api := ipc.NewStoreAPI(st)
|
||||||
|
now := time.Now()
|
||||||
|
emb := router.NewHashEmbedder(1024)
|
||||||
|
matcher := tool.NewMatcher(api)
|
||||||
|
h := &reactiveHandler{
|
||||||
|
api: api,
|
||||||
|
embedder: emb,
|
||||||
|
router: buildRouter(emb, matcher, 0.55, nil),
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
memStore: memory.NewInMemoryStore(),
|
||||||
|
dataStore: st,
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
|
||||||
|
if !strings.Contains(reply, "купить молоко") {
|
||||||
|
t.Fatalf("capture did not claim the turn: %q", reply)
|
||||||
|
}
|
||||||
|
open, err := st.ListTasks(ctx, store.TaskOpen)
|
||||||
|
if err != nil || len(open) != 1 {
|
||||||
|
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
|
||||||
|
}
|
||||||
|
// The words he said, not the model's rewrite of them.
|
||||||
|
if open[0].Text != "купить молоко" {
|
||||||
|
t.Fatalf("task text was rewritten: %q", open[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-100
@@ -2,122 +2,33 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/llm"
|
|
||||||
"github.com/kami/maven/internal/persona"
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
// completer is the LLM seam for the replier (subset of router.Completer).
|
// llmReplier is the daemon-side wiring around phraser.Replier: it owns the
|
||||||
// *llm.Client satisfies it.
|
// deterministic floor, and nothing else. The phrasing itself, the prompt and the
|
||||||
type completer interface {
|
// output parsing live in internal/phraser so the eval can score them (#396).
|
||||||
Complete(ctx context.Context, r llm.Req) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// llmReplier phrases reactive confirmations with the resident model
|
|
||||||
// (Qwen3-1.7B). Stub is the
|
|
||||||
// floor on any error (offline-safe). Maven speaks as "she", feminine RU.
|
|
||||||
type llmReplier struct {
|
type llmReplier struct {
|
||||||
c completer
|
p *phraser.Replier
|
||||||
stub *voice.StubReplier
|
stub *voice.StubReplier
|
||||||
|
|
||||||
// block renders the shared context block per turn (who he is, the time).
|
|
||||||
// nil ⇒ the prompt stands alone.
|
|
||||||
block func() string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLLMReplier(c completer, block func() string) *llmReplier {
|
func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
||||||
return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block}
|
return &llmReplier{p: phraser.NewReplier(c, block), stub: voice.NewStubReplier()}
|
||||||
}
|
}
|
||||||
|
|
||||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
// Reply never fails: a clarify, a model error and an unusable generation all
|
||||||
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
|
// answer from the stub, which is what keeps a turn from breaking on the model.
|
||||||
Никогда не пиши "..." в поле response.`
|
|
||||||
|
|
||||||
func (r *llmReplier) Reply(d router.Decision) string {
|
func (r *llmReplier) Reply(d router.Decision) string {
|
||||||
if d.Clarify {
|
if d.Clarify {
|
||||||
return r.stub.Reply(d)
|
return r.stub.Reply(d)
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
out, err := r.p.PhraseReply(context.Background(), d)
|
||||||
defer cancel()
|
if err != nil || out == "" {
|
||||||
out, err := r.c.Complete(ctx, llm.Req{
|
|
||||||
System: persona.Prepend(r.block, replySystem),
|
|
||||||
User: replyContext(d),
|
|
||||||
Grammar: phraser.ResponseGrammar,
|
|
||||||
MaxTokens: 512,
|
|
||||||
RepeatPenalty: 1.3,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return r.stub.Reply(d)
|
return r.stub.Reply(d)
|
||||||
}
|
}
|
||||||
out = stripThink(out)
|
return out
|
||||||
if response, _ := parseResponseMood(out); response != "" {
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
// fallback: try plain-text parsing
|
|
||||||
if out = firstSentence(out); out != "" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
return r.stub.Reply(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstSentence trims the model's output to a single clean confirmation: first
|
|
||||||
// line, first sentence, whitespace-normalized — the last-line defense against a
|
|
||||||
// small model that rambles past the first period despite the prompt + stop.
|
|
||||||
// stripThink removes the <think> block that Thinking-variant models emit.
|
|
||||||
func stripThink(s string) string {
|
|
||||||
if i := strings.LastIndex(s, "</think>"); i >= 0 {
|
|
||||||
s = strings.TrimSpace(s[i+8:])
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstSentence(s string) string {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
||||||
s = s[:i]
|
|
||||||
}
|
|
||||||
// keep up to and including the first sentence-ending punctuation.
|
|
||||||
if i := strings.IndexAny(s, ".!?"); i >= 0 {
|
|
||||||
s = s[:i+1]
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
|
||||||
// of thinking tokens and extra text before/after the JSON block.
|
|
||||||
func parseResponseMood(raw string) (response, mood string) {
|
|
||||||
cleaned := strings.TrimSpace(raw)
|
|
||||||
start := strings.Index(cleaned, "{")
|
|
||||||
end := strings.LastIndex(cleaned, "}")
|
|
||||||
if start < 0 || end < 0 || end <= start {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
var parsed struct {
|
|
||||||
Response string `json:"response"`
|
|
||||||
Mood string `json:"mood"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
return parsed.Response, parsed.Mood
|
|
||||||
}
|
|
||||||
|
|
||||||
// replyContext renders the decision into a compact RU description for the model.
|
|
||||||
func replyContext(d router.Decision) string {
|
|
||||||
switch d.Intent {
|
|
||||||
case router.IntentFact:
|
|
||||||
return "записала факт: " + d.Slots.Key + " " + d.Slots.Value
|
|
||||||
case router.IntentNote:
|
|
||||||
return "сохранила заметку: " + d.Slots.Text
|
|
||||||
case router.IntentReminder:
|
|
||||||
return "поставила напоминание: " + d.Slots.Text
|
|
||||||
default:
|
|
||||||
return string(d.Intent) + ": " + d.Slots.Text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,22 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/llm"
|
"github.com/kami/maven/internal/llm"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockCompleter struct {
|
// The phrasing itself is tested in internal/phraser. What is left here is the
|
||||||
|
// only thing the daemon adds: the stub floor, on the three ways a reply can
|
||||||
|
// fail to arrive.
|
||||||
|
type stubCompleter struct {
|
||||||
out string
|
out string
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return s.out, s.err }
|
||||||
|
|
||||||
func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||||
r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
||||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
|
||||||
if got != "записала, кофе закончился" {
|
|
||||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
|
||||||
r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"}, nil)
|
|
||||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||||
if got != "записала, кофе закончился" {
|
if got != "записала, кофе закончился" {
|
||||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||||
@@ -34,54 +28,30 @@ func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||||
r := newLLMReplier(mockCompleter{err: errTestLLMDown}, nil)
|
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||||
noteDec := router.Decision{Intent: router.IntentNote}
|
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
|
||||||
got := r.Reply(noteDec)
|
|
||||||
want := voice.NewStubReplier().Reply(noteDec)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("on llm error: got %q, want stub %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||||
r := newLLMReplier(mockCompleter{out: ""}, nil)
|
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||||
noteDec := router.Decision{Intent: router.IntentNote}
|
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
||||||
got := r.Reply(noteDec)
|
|
||||||
want := voice.NewStubReplier().Reply(noteDec)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("on empty llm: got %q, want stub %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||||
r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil)
|
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||||
clarifyDec := router.Decision{Clarify: true}
|
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
||||||
got := r.Reply(clarifyDec)
|
}
|
||||||
want := voice.NewStubReplier().Reply(clarifyDec)
|
|
||||||
|
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||||
|
t.Helper()
|
||||||
|
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("on clarify: got %q, want stub %q", got, want)
|
t.Errorf("on %s: got %q, want stub %q", what, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var errTestLLMDown = errTest("llm down")
|
var errReplierTest = errTest("llm down")
|
||||||
|
|
||||||
type errTest string
|
type errTest string
|
||||||
|
|
||||||
func (e errTest) Error() string { return string(e) }
|
func (e errTest) Error() string { return string(e) }
|
||||||
|
|
||||||
// grammarRecorder captures the request so the grammar can be asserted on.
|
|
||||||
type grammarRecorder struct{ req llm.Req }
|
|
||||||
|
|
||||||
func (g *grammarRecorder) Complete(_ context.Context, r llm.Req) (string, error) {
|
|
||||||
g.req = r
|
|
||||||
return `{"response":"записала","mood":"neutral"}`, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLLMReplierCarriesTheResponseGrammar(t *testing.T) {
|
|
||||||
rec := &grammarRecorder{}
|
|
||||||
r := newLLMReplier(rec, nil)
|
|
||||||
r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
|
||||||
if rec.req.Grammar != phraser.ResponseGrammar {
|
|
||||||
t.Errorf("grammar = %q, want phraser.ResponseGrammar", rec.req.Grammar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// mavend/simulator_test.go — the replayable full-system simulator
|
// mavend/simulator_test.go — the replayable full-system simulator
|
||||||
// (Vikunja #284, 20-07-2026-BACKLOG.md item 7).
|
// (Vikunja #284).
|
||||||
//
|
//
|
||||||
// # What it is
|
// # What it is
|
||||||
//
|
//
|
||||||
@@ -1043,3 +1043,26 @@ func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
|
|||||||
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
|
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSimulatorRoutesWithTheDeployedSeeds — the scenarios must replay against
|
||||||
|
// the classifier the deploy runs, not an empty one.
|
||||||
|
//
|
||||||
|
// They did not. The seed path was relative to the working directory, which is
|
||||||
|
// cmd/mavend under `go test`, so every file failed to open and the whole
|
||||||
|
// simulator scored three green scenarios with zero examples loaded (Vikunja
|
||||||
|
// #465). The count is asserted rather than logged, because a silent zero is
|
||||||
|
// exactly the failure that hid here for as long as it did.
|
||||||
|
func TestSimulatorRoutesWithTheDeployedSeeds(t *testing.T) {
|
||||||
|
cls := router.NewClassifier(router.NewHashEmbedder(1024))
|
||||||
|
seedClassifier(cls)
|
||||||
|
total := 0
|
||||||
|
for _, intent := range cls.Intents() {
|
||||||
|
total += len(cls.Examples(intent))
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
t.Fatalf("no seed examples loaded from %s — the simulator would route on nothing", seedPath())
|
||||||
|
}
|
||||||
|
if len(cls.Intents()) != 7 {
|
||||||
|
t.Fatalf("seeded %d intents, want all 7", len(cls.Intents()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSlotsParity — dialogue.Slots is a hand-kept copy of router.Slots
|
||||||
|
// (dialogue must not import router: import cycle). Drift is silent, so this
|
||||||
|
// test compares the two field sets by name and type. If it fails, add the new
|
||||||
|
// field to both structs AND to toDialogueSlots/applyDialogueSlots in
|
||||||
|
// followup.go — do not relax the test.
|
||||||
|
func TestSlotsParity(t *testing.T) {
|
||||||
|
fields := func(v any) map[string]string {
|
||||||
|
rt := reflect.TypeOf(v)
|
||||||
|
out := make(map[string]string, rt.NumField())
|
||||||
|
for i := 0; i < rt.NumField(); i++ {
|
||||||
|
f := rt.Field(i)
|
||||||
|
out[f.Name] = f.Type.String()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
rf, df := fields(router.Slots{}), fields(dialogue.Slots{})
|
||||||
|
for name, typ := range rf {
|
||||||
|
dt, ok := df[name]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("router.Slots.%s (%s) missing from dialogue.Slots", name, typ)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dt != typ {
|
||||||
|
t.Errorf("field %s: router has %s, dialogue has %s", name, typ, dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, typ := range df {
|
||||||
|
if _, ok := rf[name]; !ok {
|
||||||
|
t.Errorf("dialogue.Slots.%s (%s) missing from router.Slots", name, typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlotsRoundTrip — the converters carry every field. A field the parity
|
||||||
|
// test accepts can still be dropped in transit, so round-trip a fully
|
||||||
|
// populated value and compare.
|
||||||
|
func TestSlotsRoundTrip(t *testing.T) {
|
||||||
|
full := router.Slots{
|
||||||
|
Time: time.Date(2026, 8, 2, 11, 0, 0, 0, time.UTC),
|
||||||
|
HasTime: true,
|
||||||
|
Fn: "restart",
|
||||||
|
Args: []string{"nginx"},
|
||||||
|
HasFn: true,
|
||||||
|
Key: "water",
|
||||||
|
Value: `"drank"`,
|
||||||
|
HasKey: true,
|
||||||
|
Text: "выпил воды",
|
||||||
|
}
|
||||||
|
// Every field must be non-zero, or the round-trip proves nothing.
|
||||||
|
rv := reflect.ValueOf(full)
|
||||||
|
for i := 0; i < rv.NumField(); i++ {
|
||||||
|
if rv.Field(i).IsZero() {
|
||||||
|
t.Fatalf("field %s is zero: extend this fixture so the round-trip covers it",
|
||||||
|
rv.Type().Field(i).Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := applyDialogueSlots(router.Slots{}, toDialogueSlots(full)); !reflect.DeepEqual(got, full) {
|
||||||
|
t.Errorf("round-trip lost a slot:\n got %+v\nwant %+v", got, full)
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-8
@@ -761,11 +761,7 @@ func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state
|
|||||||
facts := t.gatherMorningFacts(ctx)
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
|
||||||
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||||
labels := make([]string, len(cand.Missing))
|
body := morningNudgeBody(cand)
|
||||||
for i, it := range cand.Missing {
|
|
||||||
labels[i] = it.Label
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", "))
|
|
||||||
pn := delivery.PhrasedNudge{
|
pn := delivery.PhrasedNudge{
|
||||||
Candidate: loop.Candidate{
|
Candidate: loop.Candidate{
|
||||||
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||||
@@ -781,6 +777,26 @@ func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// morningNudgeBody words the one message a routine gets per day. Required
|
||||||
|
// items are what she says was not done; optional ones follow, worded as
|
||||||
|
// something he could still do rather than something he owes (Vikunja #473).
|
||||||
|
// Operator text, not phrased by the model, for the same reason it always was:
|
||||||
|
// a checklist item must not be invented.
|
||||||
|
func morningNudgeBody(cand morning.Candidate) string {
|
||||||
|
labels := func(items []morning.Item) string {
|
||||||
|
out := make([]string, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
out[i] = it.Label
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
|
||||||
|
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
|
||||||
|
body += fmt.Sprintf(". если будет время — %s", labels(opt))
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
||||||
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||||
// decision) and morningStatus (read-only query) so the two paths can never
|
// decision) and morningStatus (read-only query) so the two paths can never
|
||||||
@@ -986,7 +1002,7 @@ type daemonAPI struct {
|
|||||||
getTrace func() *loop.TickTrace
|
getTrace func() *loop.TickTrace
|
||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
chatFn func(ctx context.Context, text string) string
|
chatFn func(ctx context.Context, conversation, text string) string
|
||||||
getMCPServers func() []ipc.MCPServerStatus
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
getEvents func(n int) []ipc.IntakeEvent
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
}
|
}
|
||||||
@@ -1002,11 +1018,11 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
|
|||||||
return d.getEvents(n), nil
|
return d.getEvents(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
if d.chatFn == nil {
|
if d.chatFn == nil {
|
||||||
return "", errors.New("mavend: chat not available")
|
return "", errors.New("mavend: chat not available")
|
||||||
}
|
}
|
||||||
return d.chatFn(ctx, text), nil
|
return d.chatFn(ctx, conversation, text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||||
|
|||||||
+8
-4
@@ -76,6 +76,10 @@ type reactiveHandler struct {
|
|||||||
tts tts.Synthesizer
|
tts tts.Synthesizer
|
||||||
router *router.Router
|
router *router.Router
|
||||||
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
||||||
|
// boundary — the embedded seed sets behind the personal boundary
|
||||||
|
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||||
|
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||||
|
boundary personalBoundary
|
||||||
// api — the CoreAPI the handler reads and writes through. Wired with the
|
// api — the CoreAPI the handler reads and writes through. Wired with the
|
||||||
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
|
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
|
||||||
// upgradeAPI.
|
// upgradeAPI.
|
||||||
@@ -216,9 +220,9 @@ func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
|
|||||||
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
|
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
|
||||||
// endpoint (and eventually by telegram). Splits out the audio bookends from
|
// endpoint (and eventually by telegram). Splits out the audio bookends from
|
||||||
// HandlePushToTalk so text channels share the same routing logic.
|
// HandlePushToTalk so text channels share the same routing logic.
|
||||||
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
|
||||||
log.Printf("voice: handleText: %q", text)
|
log.Printf("voice: handleText: %q", text)
|
||||||
return h.runTurn(ctx, text, sourceText)
|
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText)
|
||||||
}
|
}
|
||||||
|
|
||||||
// turnSource — which channel this utterance arrived on, in the same provenance
|
// turnSource — which channel this utterance arrived on, in the same provenance
|
||||||
@@ -251,7 +255,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
// early. He can be asked a question, walk off, come back and say "да" to a
|
// early. He can be asked a question, walk off, come back and say "да" to a
|
||||||
// confirm that is still parked; computing the notice after that return meant
|
// confirm that is still parked; computing the notice after that return meant
|
||||||
// he answered the confirm and never heard that the older request was let go.
|
// he answered the confirm and never heard that the older request was let go.
|
||||||
expiredNotice := h.clarifyExpiredNotice()
|
expiredNotice := h.clarifyExpiredNotice(ctx)
|
||||||
|
|
||||||
// 2. confirm turn — if a destructive act is parked, this utterance is its
|
// 2. confirm turn — if a destructive act is parked, this utterance is its
|
||||||
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
||||||
@@ -347,7 +351,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
// and park the request (clarify.go); otherwise the replier's canned reply
|
||||||
// stands.
|
// stands.
|
||||||
if dec.Clarify {
|
if dec.Clarify {
|
||||||
if question, asked := h.askClarify(dec); asked {
|
if question, asked := h.askClarify(ctx, dec); asked {
|
||||||
return withNotice(expiredNotice, question)
|
return withNotice(expiredNotice, question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-11
@@ -48,7 +48,11 @@ type voiceWiring struct {
|
|||||||
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
|
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
|
||||||
// server (Vikunja #251). Its tools land in the same allowlist as every
|
// server (Vikunja #251). Its tools land in the same allowlist as every
|
||||||
// other act, so nothing else here has to know about it.
|
// other act, so nothing else here has to know about it.
|
||||||
mcp *mcpWiring
|
// pair — the workstation model with the resident one as the floor, nil
|
||||||
|
// unless a `workstation` block names an address. Held here only so the
|
||||||
|
// prober is stopped on shutdown; callers were handed it at build time.
|
||||||
|
pair *llm.Pair
|
||||||
|
mcp *mcpWiring
|
||||||
// home — the Home Assistant client, nil unless the `smarthome` block is
|
// home — the Home Assistant client, nil unless the `smarthome` block is
|
||||||
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
||||||
// other act, so nothing else here has to know about it.
|
// other act, so nothing else here has to know about it.
|
||||||
@@ -76,6 +80,9 @@ func (w *voiceWiring) close() {
|
|||||||
if w.ttsClient != nil {
|
if w.ttsClient != nil {
|
||||||
_ = w.ttsClient.Close()
|
_ = w.ttsClient.Close()
|
||||||
}
|
}
|
||||||
|
if w.pair != nil {
|
||||||
|
w.pair.Stop()
|
||||||
|
}
|
||||||
w.mcp.close()
|
w.mcp.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +146,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
emb = router.NewHashEmbedder(1024)
|
emb = router.NewHashEmbedder(1024)
|
||||||
}
|
}
|
||||||
w.embedder = emb
|
w.embedder = emb
|
||||||
|
repairFactVectors(dataStore, emb)
|
||||||
checkStoredEmbedder(dataStore, emb)
|
checkStoredEmbedder(dataStore, emb)
|
||||||
|
|
||||||
// ----- tool executor (the enabled act allowlist, store-backed) -----
|
// ----- tool executor (the enabled act allowlist, store-backed) -----
|
||||||
@@ -188,6 +196,18 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// the new llama-server when the resident model is swapped (Vikunja #250).
|
// the new llama-server when the resident model is swapped (Vikunja #250).
|
||||||
llmClient = llmClientFor(lp, 60*time.Second)
|
llmClient = llmClientFor(lp, 60*time.Second)
|
||||||
}
|
}
|
||||||
|
// The workstation model sits above that one when it is configured and its
|
||||||
|
// card is free. hot is what the router and the replier complete through:
|
||||||
|
// either the pair, or the resident client alone, or nothing at all.
|
||||||
|
hot, pair := modelSeam(cfg, llmClient)
|
||||||
|
w.pair = pair
|
||||||
|
// The phraser gets the same pair, which is what carries the workstation model
|
||||||
|
// into the paths that do not go through `hot`: world questions (the naming
|
||||||
|
// half), and the digestion worker's nudge and reminder phrasing (the silent
|
||||||
|
// half). Wiring, so it happens once and before the voice server listens.
|
||||||
|
if lp, ok := phr.(*phraser.LLMPhraser); ok && pair != nil {
|
||||||
|
lp.UseRemote(pair)
|
||||||
|
}
|
||||||
// ----- router (the cascade; floor examples seed the classifier) -----
|
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||||
// router only matches acts the executor can run (one source of truth).
|
// router only matches acts the executor can run (one source of truth).
|
||||||
@@ -199,7 +219,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// against the classifier's 50.0%, at about 1s a turn instead of 30ms (see
|
// against the classifier's 50.0%, at about 1s a turn instead of 30ms (see
|
||||||
// config.VoiceConfig.LLMRouter). The classifier always stays wired as the
|
// config.VoiceConfig.LLMRouter). The classifier always stays wired as the
|
||||||
// fallback, so a model error never breaks a turn.
|
// fallback, so a model error never breaks a turn.
|
||||||
rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient))
|
rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), hot))
|
||||||
|
|
||||||
// ----- sessions registry (shared with voicesink) -----
|
// ----- sessions registry (shared with voicesink) -----
|
||||||
sessions := voice.NewSessions()
|
sessions := voice.NewSessions()
|
||||||
@@ -218,7 +238,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||||
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
||||||
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
||||||
// load, never revived. Clarify's parked question stays in memory only.
|
// load, never revived. Clarify's parked question stays in memory only, and
|
||||||
|
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
|
||||||
|
// a restart expires it, so the thread comes back and the open question does
|
||||||
|
// not.
|
||||||
var dialogueSessions *dialogue.SessionStore
|
var dialogueSessions *dialogue.SessionStore
|
||||||
if dataStore != nil {
|
if dataStore != nil {
|
||||||
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
||||||
@@ -233,8 +256,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
|
|
||||||
// ----- replier (LLM-backed when the engine is on, Stub floor otherwise) -----
|
// ----- replier (LLM-backed when the engine is on, Stub floor otherwise) -----
|
||||||
replier := voice.Replier(voice.NewStubReplier())
|
replier := voice.Replier(voice.NewStubReplier())
|
||||||
if llmClient != nil {
|
if hot != nil {
|
||||||
replier = newLLMReplier(llmClient, contextBlockFn(cfg, time.Now))
|
replier = newLLMReplier(hot, contextBlockFn(cfg, time.Now))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||||
@@ -291,7 +314,42 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// pickLLMRouter returns the LLM router when the operator asked for it and there
|
// pickLLMRouter returns the LLM router when the operator asked for it and there
|
||||||
// is a llama-server to talk to, and nil otherwise. nil is safe: the cascade then
|
// is a llama-server to talk to, and nil otherwise. nil is safe: the cascade then
|
||||||
// routes with the classifier, so an unusable setting costs accuracy, not turns.
|
// routes with the classifier, so an unusable setting costs accuracy, not turns.
|
||||||
func pickLLMRouter(enabled bool, c *llm.Client) *router.LLMRouter {
|
// modelSeam builds the completion seam the hot paths use: routing and replies.
|
||||||
|
//
|
||||||
|
// With no `workstation` block it is the resident client and nothing probes
|
||||||
|
// anything, which is today's deploy exactly. With one, it is an llm.Pair that
|
||||||
|
// prefers the workstation and falls back to the resident model silently — the
|
||||||
|
// silent half of the degradation rule (docs/offload.md), because the big model
|
||||||
|
// is only better here and the 1.7B is today's shipping quality. He is never
|
||||||
|
// told which of the two phrased his reply.
|
||||||
|
//
|
||||||
|
// A nil resident client means the phraser is not an LLM phraser. There is then
|
||||||
|
// no floor, and a Pair with no floor is a configuration mistake rather than a
|
||||||
|
// degraded mode, so the seam is nil and the cascade routes with the classifier.
|
||||||
|
func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm.Pair) {
|
||||||
|
if resident == nil {
|
||||||
|
if cfg.Workstation != nil {
|
||||||
|
log.Printf("voice: a workstation is configured but there is no resident model to floor it with — ignoring the block")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if cfg.Workstation == nil {
|
||||||
|
return resident, nil
|
||||||
|
}
|
||||||
|
ws := cfg.Workstation
|
||||||
|
pair := llm.NewPair(
|
||||||
|
llm.New(ws.URL, time.Duration(ws.Timeout)),
|
||||||
|
resident,
|
||||||
|
ws.Health,
|
||||||
|
time.Duration(ws.Probe),
|
||||||
|
)
|
||||||
|
pair.Start(context.Background())
|
||||||
|
log.Printf("voice: workstation model at %s, probed every %s, resident model as the floor",
|
||||||
|
ws.URL, time.Duration(ws.Probe))
|
||||||
|
return pair, pair
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -325,6 +383,11 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
// is an agenda question and must not.
|
// is an agenda question and must not.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
|
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||||
|
// explicit capture marker beats the model, which called it an act and
|
||||||
|
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||||
|
// marker never collides with a clock or agenda question.
|
||||||
|
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
Classifier: cls,
|
Classifier: cls,
|
||||||
@@ -338,11 +401,34 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedDir is the directory containing intent seed files. Each file is named
|
// seedDir is the directory containing intent seed files, relative to the repo
|
||||||
// <intent>.txt and contains one training example per line (blank lines and
|
// root. Each file is named <intent>.txt and holds one training example per
|
||||||
// lines starting with # are ignored). Relative to the working directory.
|
// line (blank lines and lines starting with # are ignored).
|
||||||
const seedDir = "models/seeds"
|
const seedDir = "models/seeds"
|
||||||
|
|
||||||
|
// seedPath resolves seedDir against the working directory, walking up until it
|
||||||
|
// finds it. The daemon runs from the repo root and the first candidate hits.
|
||||||
|
//
|
||||||
|
// A test does not: `go test ./cmd/mavend/` runs with the working directory at
|
||||||
|
// cmd/mavend, so every open failed and the simulator scenarios replayed a whole
|
||||||
|
// scripted day against a classifier holding zero examples (Vikunja #465). They
|
||||||
|
// passed, which is the part that matters — a green simulator was not exercising
|
||||||
|
// the routing the deploy runs, and a regression in the seed set could not have
|
||||||
|
// shown up there.
|
||||||
|
//
|
||||||
|
// Bounded at five levels, so a daemon started somewhere without the seeds logs
|
||||||
|
// the same failure it always did rather than walking to the filesystem root.
|
||||||
|
func seedPath() string {
|
||||||
|
dir := seedDir
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
dir = filepath.Join("..", dir)
|
||||||
|
}
|
||||||
|
return seedDir
|
||||||
|
}
|
||||||
|
|
||||||
// seedClassifier floors the embedded examples so the cold-boot path
|
// seedClassifier floors the embedded examples so the cold-boot path
|
||||||
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
|
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
|
||||||
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
|
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
|
||||||
@@ -367,11 +453,11 @@ func seedClassifier(c *router.Classifier) {
|
|||||||
}
|
}
|
||||||
total += n
|
total += n
|
||||||
}
|
}
|
||||||
log.Printf("voice: loaded %d seed examples from %s", total, seedDir)
|
log.Printf("voice: loaded %d seed examples from %s", total, seedPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
|
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
|
||||||
path := filepath.Join(seedDir, string(intent)+".txt")
|
path := filepath.Join(seedPath(), string(intent)+".txt")
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("open %s: %w", path, err)
|
return 0, fmt.Errorf("open %s: %w", path, err)
|
||||||
@@ -419,6 +505,36 @@ func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) {
|
|||||||
log.Printf("voice: seeded %d act tools from config", n)
|
log.Printf("voice: seeded %d act tools from config", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// repairFactVectors brings stored fact vectors in line with the facts they name
|
||||||
|
// (#493), once per box, before the embedder marker is even looked at.
|
||||||
|
//
|
||||||
|
// Automatic and not a flag, unlike -reembed: only voice-tapped facts are in
|
||||||
|
// this index, so the work is tens of embeddings rather than the thousands of
|
||||||
|
// notes that made the backfill a deliberate act. And the box that needs it is
|
||||||
|
// broken in a way nobody can see — recall answers with the wrong text and
|
||||||
|
// nothing logs an error — so waiting for an operator to know to run it is how
|
||||||
|
// the defect survived four restarts in the first place.
|
||||||
|
func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
|
||||||
|
if dataStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := dataStore.RepairFactVectors(context.Background(),
|
||||||
|
// EmbedPassage, the stored side, same as every other writer of these
|
||||||
|
// vectors.
|
||||||
|
func(ctx context.Context, text string) ([]float32, error) {
|
||||||
|
return router.EmbedPassage(ctx, emb, text)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: fact vector repair failed, no marker written and nothing half-done — retried next start: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.Skipped || res.Rewritten+res.Dropped == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("voice: fact vector repair — %d re-embedded from the fact they name, %d dropped as voided or superseded, %d already right, took %s (#493)",
|
||||||
|
res.Rewritten, res.Dropped, res.Kept, res.Took.Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
// reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see
|
// reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see
|
||||||
// runReembed.
|
// runReembed.
|
||||||
var reembedOnStart bool
|
var reembedOnStart bool
|
||||||
|
|||||||
+45
-33
@@ -1,10 +1,13 @@
|
|||||||
// Package main — weatherq.go holds the weather-query keyword helpers: does
|
// Package main — weatherq.go holds the weather-query keyword helpers: does
|
||||||
// this utterance ask about weather at all, and which city (if any) did it
|
// this utterance ask about weather at all, and which place (if any) did he
|
||||||
// name. Both are plain substring/lookup matching, not NLU — extend this file
|
// name. Both are plain keyword matching, not NLU — extend this file rather
|
||||||
// rather than voice.go for anything in that shape.
|
// than voice.go for anything in that shape.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// isWeatherQuery returns true if the utterance is about weather.
|
// isWeatherQuery returns true if the utterance is about weather.
|
||||||
func isWeatherQuery(u string) bool {
|
func isWeatherQuery(u string) bool {
|
||||||
@@ -19,40 +22,49 @@ func isWeatherQuery(u string) bool {
|
|||||||
strings.Contains(lower, "temperature")
|
strings.Contains(lower, "temperature")
|
||||||
}
|
}
|
||||||
|
|
||||||
// weatherCities — the city names an utterance may name explicitly, as
|
// weatherPlace — the place he named, after "в"/"во"/"in". One or two words,
|
||||||
// lowercase substrings mapped to the provider's spelling. This is a
|
// letters and dashes only, so "в Нижнем Новгороде" and "in New York" both
|
||||||
// convenience for "какая погода в Лондоне", NOT a source of default truth:
|
// come through whole and "в 5 утра" does not.
|
||||||
// nothing here is used unless he actually said it.
|
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
|
||||||
var weatherCities = map[string]string{
|
|
||||||
"москв": "Moscow",
|
// weatherNonPlaces — words that follow "в" in a weather question and are not
|
||||||
"moscow": "Moscow",
|
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and
|
||||||
"питер": "Saint Petersburg",
|
// "тепло в комнате" is the same question about the same room.
|
||||||
"spb": "Saint Petersburg",
|
var weatherNonPlaces = map[string]bool{
|
||||||
"петербур": "Saint Petersburg",
|
"доме": true, "квартире": true, "комнате": true, "спальне": true,
|
||||||
"лондон": "London",
|
"гостиной": true, "кухне": true, "гараже": true, "офисе": true,
|
||||||
"london": "London",
|
"выходные": true, "субботу": true, "воскресенье": true, "понедельник": true,
|
||||||
"париж": "Paris",
|
"вторник": true, "среду": true, "четверг": true, "пятницу": true,
|
||||||
"paris": "Paris",
|
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
|
||||||
"берлин": "Berlin",
|
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true,
|
||||||
"berlin": "Berlin",
|
|
||||||
"нью-йорк": "New York",
|
|
||||||
"new york": "New York",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractWeatherLocation returns the city he named, or the configured default
|
// extractWeatherLocation returns the place he named, or the configured default
|
||||||
// when he named none. It returns "" when he named none AND no default is
|
// when he named none. It returns "" when he named none AND no default is
|
||||||
// configured — the caller must then say it does not know.
|
// configured — the caller must then say it does not know.
|
||||||
//
|
//
|
||||||
// It used to return "Moscow" in that case. That is a made-up answer presented
|
// It used to be a hand-written table of six cities in two spellings each
|
||||||
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
|
// (Vikunja #421). Anything outside it — Kazan, Tbilisi — was dropped silently
|
||||||
// wrong in exactly the way maven must never be wrong. voice.weather
|
// and answered for the default location, which reads as a correct answer about
|
||||||
// .default_location is the only source of an unstated location.
|
// the wrong place. There is a geocoder behind this now: internal/weather
|
||||||
|
// already calls Open-Meteo's geocoding endpoint for every lookup, so any place
|
||||||
|
// it knows is a place he can ask about, and the table bought nothing.
|
||||||
|
//
|
||||||
|
// A named place that the geocoder cannot resolve is the caller's problem to
|
||||||
|
// report, not this function's to hide.
|
||||||
|
//
|
||||||
|
// It used to return "Moscow" when he named nothing. That is a made-up answer
|
||||||
|
// presented as fact. voice.weather.default_location is the only source of an
|
||||||
|
// unstated location.
|
||||||
func extractWeatherLocation(u, defaultLoc string) string {
|
func extractWeatherLocation(u, defaultLoc string) string {
|
||||||
lower := strings.ToLower(u)
|
m := weatherPlace.FindStringSubmatch(u)
|
||||||
for substr, name := range weatherCities {
|
if m == nil {
|
||||||
if strings.Contains(lower, substr) {
|
return defaultLoc
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return defaultLoc
|
place := strings.TrimSpace(m[1])
|
||||||
|
first := strings.ToLower(strings.Fields(place)[0])
|
||||||
|
if weatherNonPlaces[first] {
|
||||||
|
return defaultLoc
|
||||||
|
}
|
||||||
|
return place
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestExtractWeatherLocation — any place he names comes through, not just the
|
||||||
|
// six that used to be in a table (Vikunja #421).
|
||||||
|
func TestExtractWeatherLocation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
def string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// The cities the table had, and the ones it silently dropped.
|
||||||
|
{"какая погода в Москве", "Berlin", "Москве"},
|
||||||
|
{"какая погода в Казани", "Berlin", "Казани"},
|
||||||
|
{"погода в Тбилиси?", "Berlin", "Тбилиси"},
|
||||||
|
{"what's the weather in New York", "Berlin", "New York"},
|
||||||
|
{"тепло в Нижнем Новгороде?", "Berlin", "Нижнем Новгороде"},
|
||||||
|
// He named nothing: the configured default, and nothing at all when
|
||||||
|
// there is no default.
|
||||||
|
{"какая сегодня погода", "Berlin", "Berlin"},
|
||||||
|
{"какая сегодня погода", "", ""},
|
||||||
|
// "в" followed by something that is not a place stays the default —
|
||||||
|
// the house sensors and the day words answer elsewhere.
|
||||||
|
{"тепло в комнате?", "Berlin", "Berlin"},
|
||||||
|
{"какая погода в выходные", "Berlin", "Berlin"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := extractWeatherLocation(c.utterance, c.def); got != c.want {
|
||||||
|
t.Errorf("extractWeatherLocation(%q, %q) = %q, want %q", c.utterance, c.def, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// worldPhraser — the naming half of the degradation rule (docs/offload.md), as
|
||||||
|
// the query sources see it. Only *phraser.LLMPhraser implements it, so the
|
||||||
|
// Stub and every test double stay exactly as they are.
|
||||||
|
type worldPhraser interface {
|
||||||
|
PhraseWorld(ctx context.Context, utterance string, sources []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// worldGap — what he hears when the question is about the world, the workstation
|
||||||
|
// model is the one configured to answer it, and that machine is not answering.
|
||||||
|
//
|
||||||
|
// It says the true thing. The resident 1.7B is not a worse answer here, it is an
|
||||||
|
// invented one: "Война и мир" came back with Левитан as its author, and a
|
||||||
|
// question about his meeting came back as a swimming competition in Nottingham.
|
||||||
|
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
|
||||||
|
// being down.
|
||||||
|
const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||||
|
|
||||||
|
// phraseWorld asks the world model, or reports the gap.
|
||||||
|
//
|
||||||
|
// The three outcomes come straight from LLMPhraser.PhraseWorld: no workstation
|
||||||
|
// configured means the resident model answers as it always has, a workstation
|
||||||
|
// that is up answers, and a workstation that is down returns
|
||||||
|
// phraser.ErrNoWorldModel. A phraser that has no world seam at all — the Stub,
|
||||||
|
// and the doubles in the tests — is the first of those three.
|
||||||
|
func (h *reactiveHandler) phraseWorld(ctx context.Context, utterance string, sources []string) (string, error) {
|
||||||
|
if h.phraser == nil {
|
||||||
|
return "", phraser.ErrNoWorldModel
|
||||||
|
}
|
||||||
|
if w, ok := h.phraser.(worldPhraser); ok {
|
||||||
|
return w.PhraseWorld(ctx, utterance, sources)
|
||||||
|
}
|
||||||
|
return h.phraser.PhraseQuery(ctx, utterance, sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
// phraseSource asks the world model to answer from a passage someone already
|
||||||
|
// fetched — a live search result, a ZIM article, a page he named. It returns ""
|
||||||
|
// rather than the gap phrase, because these callers hold something better than a
|
||||||
|
// gap: the passage itself, which their own floor reads back to him. Nothing is
|
||||||
|
// invented either way, and a real quote beats "не могу сейчас".
|
||||||
|
func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance string, sources []string) string {
|
||||||
|
reply, err := h.phraseWorld(ctx, utterance, sources)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, phraser.ErrNoWorldModel):
|
||||||
|
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
||||||
|
return ""
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("voice: %s: phrase: %v", name, err)
|
||||||
|
}
|
||||||
|
return reply
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gapPhraser — a phraser whose world model is configured and asleep, which is
|
||||||
|
// the state the naming half exists for.
|
||||||
|
type gapPhraser struct {
|
||||||
|
*phraser.Stub
|
||||||
|
worldCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gapPhraser) PhraseWorld(context.Context, string, []string) (string, error) {
|
||||||
|
g.worldCalls++
|
||||||
|
return "", phraser.ErrNoWorldModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func worldTurn(utterance string) *queryTurn {
|
||||||
|
return &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: utterance}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A world question with the workstation asleep says so. The resident model is
|
||||||
|
// not asked, because what it produces here is an invention with no signal that
|
||||||
|
// it is one.
|
||||||
|
func TestQueryGeneralNamesTheGap(t *testing.T) {
|
||||||
|
g := &gapPhraser{Stub: phraser.NewStub()}
|
||||||
|
h := &reactiveHandler{phraser: g}
|
||||||
|
reply, ok := h.queryGeneral(context.Background(), worldTurn("почему небо голубое"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||||
|
}
|
||||||
|
if reply != worldGap {
|
||||||
|
t.Fatalf("reply = %q, want the named gap", reply)
|
||||||
|
}
|
||||||
|
if g.worldCalls != 1 {
|
||||||
|
t.Fatalf("PhraseWorld called %d times, want 1", g.worldCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phraser with no world seam at all — the Stub, and every box with no
|
||||||
|
// `workstation` block — answers exactly as it did before this seam existed.
|
||||||
|
func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
||||||
|
h := &reactiveHandler{phraser: phraser.NewStub()}
|
||||||
|
reply, ok := h.queryGeneral(context.Background(), worldTurn("почему небо голубое"))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||||
|
}
|
||||||
|
if reply != "не знаю." {
|
||||||
|
t.Fatalf("reply = %q, want the Stub's answer", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gap is spoken aloud by a Russian voice, so it is Russian, feminine and
|
||||||
|
// informal. "не хочу" and "не могу" are her own verbs; there is no "вы" and no
|
||||||
|
// English in it.
|
||||||
|
func TestWorldGapIsInPersona(t *testing.T) {
|
||||||
|
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
|
||||||
|
if strings.Contains(worldGap, bad) {
|
||||||
|
t.Errorf("the gap phrase contains %q: %s", bad, worldGap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") {
|
||||||
|
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sources that hold a passage read it back rather than name a gap. He gets a
|
||||||
|
// real quote instead of "не могу сейчас", and nothing is invented either way.
|
||||||
|
func TestASourceWithAPassageReadsItBackInsteadOfNamingTheGap(t *testing.T) {
|
||||||
|
g := &gapPhraser{Stub: phraser.NewStub()}
|
||||||
|
h := &reactiveHandler{phraser: g}
|
||||||
|
if got := h.phraseSource(context.Background(), "search", "почему небо голубое",
|
||||||
|
[]string{"Рэлеевское рассеяние."}); got != "" {
|
||||||
|
t.Fatalf("phraseSource = %q, want \"\" so the caller's own floor reads the passage back", got)
|
||||||
|
}
|
||||||
|
if g.worldCalls != 1 {
|
||||||
|
t.Fatalf("PhraseWorld called %d times, want 1", g.worldCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The card is an AMD 7900 GRE with 16GB, driven by amdgpu and ROCm. Everything
|
||||||
|
// here reads sysfs and forks nothing: rocm-smi is not even installed on the
|
||||||
|
// workstation, and a poll that costs a subprocess every second is a poll that
|
||||||
|
// gets tuned down until it is useless.
|
||||||
|
|
||||||
|
// gpuProc — one process holding the compute engine.
|
||||||
|
type gpuProc struct {
|
||||||
|
PID int
|
||||||
|
Comm string
|
||||||
|
VRAM int64 // bytes, as the kernel accounts them to this process
|
||||||
|
}
|
||||||
|
|
||||||
|
// probe reads the two sysfs trees the supervisor decides from.
|
||||||
|
//
|
||||||
|
// kfdRoot is /sys/class/kfd/kfd/proc, one directory per ROCm process. The
|
||||||
|
// directory appears when the process initialises HIP, which is well before it
|
||||||
|
// allocates anything large. That is the whole reason this works: the job that
|
||||||
|
// is about to want the card announces itself while it is still starting up,
|
||||||
|
// so we see the contender rather than only the winner of an allocation race.
|
||||||
|
//
|
||||||
|
// drmDev is /sys/class/drm/cardN/device, which reports total and used VRAM for
|
||||||
|
// the card as a whole.
|
||||||
|
type probe struct {
|
||||||
|
kfdRoot string
|
||||||
|
drmDev string
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreign lists every ROCm process that is not ours. selfPID is the supervisor's
|
||||||
|
// llama-server child, or 0 when it is not running.
|
||||||
|
//
|
||||||
|
// An unreadable kfd tree returns no processes and no error. That is deliberate
|
||||||
|
// and it is the safe direction only because startVRAM also has to agree before
|
||||||
|
// anything launches: a supervisor that cannot see the KFD never sees free VRAM
|
||||||
|
// either, because the CPT run holding the card shows up in the drm totals.
|
||||||
|
func (p probe) foreign(selfPID int) []gpuProc {
|
||||||
|
entries, err := os.ReadDir(p.kfdRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []gpuProc
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || pid == selfPID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, gpuProc{
|
||||||
|
PID: pid,
|
||||||
|
Comm: readComm(pid),
|
||||||
|
VRAM: p.procVRAM(e.Name()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// procVRAM sums the per-node vram_* files under one process directory. The
|
||||||
|
// suffix is the KFD topology node id (vram_35881 on this card), so it is
|
||||||
|
// globbed rather than named, and a machine with two cards sums both.
|
||||||
|
func (p probe) procVRAM(pid string) int64 {
|
||||||
|
matches, err := filepath.Glob(filepath.Join(p.kfdRoot, pid, "vram_*"))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
for _, m := range matches {
|
||||||
|
total += readInt(m)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeVRAM reports the bytes the card has left. Used only to decide whether to
|
||||||
|
// start: a shortfall here means llama-server would refuse to load anyway. It is
|
||||||
|
// never used to decide to stop, because by the time free VRAM has dropped the
|
||||||
|
// other job has already failed its allocation, which is exactly the outcome
|
||||||
|
// yielding exists to prevent.
|
||||||
|
func (p probe) freeVRAM() int64 {
|
||||||
|
total := readInt(filepath.Join(p.drmDev, "mem_info_vram_total"))
|
||||||
|
used := readInt(filepath.Join(p.drmDev, "mem_info_vram_used"))
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if free := total - used; free > 0 {
|
||||||
|
return free
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInt(path string) int64 {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// readComm names the contender for the log. The log is the instrument for the
|
||||||
|
// open question in Vikunja #488: whether a process can want this card without
|
||||||
|
// ever registering on the KFD, which a Vulkan or video-decode job would.
|
||||||
|
func readComm(pid int) string {
|
||||||
|
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "comm"))
|
||||||
|
if err != nil {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeKFD builds the sysfs shape the workstation actually has: one directory
|
||||||
|
// per ROCm process, each holding a vram_<node> file. Sampled from the live box
|
||||||
|
// on 02-08-2026, where the CPT run appeared as proc/478104/vram_35881.
|
||||||
|
func fakeKFD(t *testing.T, vramByPID map[int]int64) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for pid, vram := range vramByPID {
|
||||||
|
dir := filepath.Join(root, strconv.Itoa(pid))
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := filepath.Join(dir, "vram_35881")
|
||||||
|
if err := os.WriteFile(f, []byte(strconv.FormatInt(vram, 10)+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForeignExcludesOurChild(t *testing.T) {
|
||||||
|
root := fakeKFD(t, map[int]int64{478104: 12791693312, 999: 4096})
|
||||||
|
p := probe{kfdRoot: root}
|
||||||
|
|
||||||
|
all := p.foreign(0)
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("with no child running, both processes are foreign, got %d", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
ours := p.foreign(999)
|
||||||
|
if len(ours) != 1 || ours[0].PID != 478104 {
|
||||||
|
t.Fatalf("our own llama-server must not count as a contender, got %+v", ours)
|
||||||
|
}
|
||||||
|
if ours[0].VRAM != 12791693312 {
|
||||||
|
t.Errorf("per-process VRAM = %d, want the value from vram_35881", ours[0].VRAM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty KFD tree is the state that permits a start, so it must read as empty
|
||||||
|
// rather than as an error the caller has to interpret.
|
||||||
|
func TestForeignEmptyAndMissing(t *testing.T) {
|
||||||
|
if got := (probe{kfdRoot: t.TempDir()}).foreign(0); len(got) != 0 {
|
||||||
|
t.Errorf("empty kfd tree: got %d processes, want 0", len(got))
|
||||||
|
}
|
||||||
|
if got := (probe{kfdRoot: "/nonexistent"}).foreign(0); got != nil {
|
||||||
|
t.Errorf("missing kfd tree: got %+v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreeVRAM(t *testing.T) {
|
||||||
|
dev := t.TempDir()
|
||||||
|
write := func(name, v string) {
|
||||||
|
if err := os.WriteFile(filepath.Join(dev, name), []byte(v), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The live numbers from the workstation while the CPT run held the card.
|
||||||
|
write("mem_info_vram_total", "17163091968\n")
|
||||||
|
write("mem_info_vram_used", "13396389888\n")
|
||||||
|
p := probe{drmDev: dev}
|
||||||
|
if got, want := p.freeVRAM(), int64(3766702080); got != want {
|
||||||
|
t.Errorf("freeVRAM = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got := (probe{drmDev: "/nonexistent"}).freeVRAM(); got != 0 {
|
||||||
|
t.Errorf("unreadable card reports %d free, want 0 so nothing starts", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no model loaded the supervisor must still answer, and it must answer 503
|
||||||
|
// rather than hanging or proxying into a closed port. Maven reads this endpoint
|
||||||
|
// on a timer forever, including while the workstation is busy.
|
||||||
|
func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
|
||||||
|
s := &supervisor{run: newRunner("/bin/true", nil, "")}
|
||||||
|
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
|
||||||
|
|
||||||
|
for _, path := range []string{"/health", "/v1/chat/completions"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("%s with no model: got %d, want 503", path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustURL(t *testing.T, s string) *url.URL {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// mavgpud — the workstation's GPU supervisor.
|
||||||
|
//
|
||||||
|
// It runs on the workstation (an AMD 7900 GRE, 16GB), not on homesrv, and it is
|
||||||
|
// deployed separately from the Maven daemons. Maven does not participate in any
|
||||||
|
// of this and never asks for a start: it reads /health through internal/llm.Pair
|
||||||
|
// and either gets the big model or falls back to the resident 1.7B.
|
||||||
|
//
|
||||||
|
// The rule, from Vikunja #488: keep llama-server loaded whenever the card is
|
||||||
|
// free, unload it when it has been idle too long or when another process needs
|
||||||
|
// the card. Not on demand, because a 7-14B takes tens of seconds to load and a
|
||||||
|
// world question would be answered by a gap every time the card had been quiet.
|
||||||
|
// Not always on, because that holds 16GB against the owner's own jobs.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Listen string `json:"listen"` // what Maven talks to
|
||||||
|
LlamaAddr string `json:"llama_addr"` // where llama-server binds
|
||||||
|
LlamaBin string `json:"llama_bin"`
|
||||||
|
// LlamaArgs must include the flags that bind LlamaAddr. They are passed
|
||||||
|
// through untouched so the model, context size and layer count stay the
|
||||||
|
// owner's business and not this daemon's schema.
|
||||||
|
LlamaArgs []string `json:"llama_args"`
|
||||||
|
|
||||||
|
KFDRoot string `json:"kfd_root"`
|
||||||
|
DRMDevice string `json:"drm_device"`
|
||||||
|
|
||||||
|
Poll duration `json:"poll"`
|
||||||
|
IdleTimeout duration `json:"idle_timeout"`
|
||||||
|
StopGrace duration `json:"stop_grace"`
|
||||||
|
MinFreeVRAM int64 `json:"min_free_vram_bytes"`
|
||||||
|
// EvictAfter and StartAfter are counted in polls, not seconds. Both exist
|
||||||
|
// to damp flapping: a one-tick blip from a short-lived rocm process must
|
||||||
|
// not evict the model, and a card that has just been released must not be
|
||||||
|
// grabbed before the previous job has finished unmapping.
|
||||||
|
EvictAfter int `json:"evict_after_polls"`
|
||||||
|
StartAfter int `json:"start_after_polls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaults() config {
|
||||||
|
return config{
|
||||||
|
Listen: ":8080",
|
||||||
|
LlamaAddr: "127.0.0.1:8081",
|
||||||
|
KFDRoot: "/sys/class/kfd/kfd/proc",
|
||||||
|
DRMDevice: "/sys/class/drm/card1/device",
|
||||||
|
Poll: duration(time.Second),
|
||||||
|
IdleTimeout: duration(15 * time.Minute),
|
||||||
|
StopGrace: duration(20 * time.Second),
|
||||||
|
MinFreeVRAM: 15 << 30,
|
||||||
|
EvictAfter: 2,
|
||||||
|
StartAfter: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration lets the config file say "15m" instead of counting nanoseconds.
|
||||||
|
type duration time.Duration
|
||||||
|
|
||||||
|
func (d *duration) UnmarshalJSON(b []byte) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(b, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := time.ParseDuration(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*d = duration(v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
path := flag.String("config", "/etc/mavgpud.json", "config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg := defaults()
|
||||||
|
b, err := os.ReadFile(*path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("mavgpud: read config: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||||
|
log.Fatalf("mavgpud: parse config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.LlamaBin == "" {
|
||||||
|
log.Fatal("mavgpud: llama_bin is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := "http://" + cfg.LlamaAddr
|
||||||
|
run := newRunner(cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
|
||||||
|
sup := &supervisor{
|
||||||
|
cfg: cfg,
|
||||||
|
probe: probe{kfdRoot: cfg.KFDRoot, drmDev: cfg.DRMDevice},
|
||||||
|
run: run,
|
||||||
|
}
|
||||||
|
sup.touch()
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
target, err := url.Parse(base)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("mavgpud: llama_addr: %v", err)
|
||||||
|
}
|
||||||
|
srv := &http.Server{Addr: cfg.Listen, Handler: sup.handler(target)}
|
||||||
|
go func() {
|
||||||
|
log.Printf("mavgpud: listening on %s, model %s", cfg.Listen, cfg.LlamaBin)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("mavgpud: listen: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sup.loop(ctx)
|
||||||
|
|
||||||
|
// The card must come back before we do. A supervisor that exits leaving
|
||||||
|
// llama-server holding 14GB is worse than one that never ran.
|
||||||
|
shut, done := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer done()
|
||||||
|
_ = srv.Shutdown(shut)
|
||||||
|
run.stop(time.Duration(cfg.StopGrace))
|
||||||
|
}
|
||||||
|
|
||||||
|
type supervisor struct {
|
||||||
|
cfg config
|
||||||
|
probe probe
|
||||||
|
run *runner
|
||||||
|
|
||||||
|
lastReq atomic.Int64 // unix nanos of the last request Maven sent
|
||||||
|
|
||||||
|
foreignStreak int
|
||||||
|
clearStreak int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *supervisor) touch() { s.lastReq.Store(time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
func (s *supervisor) idle() time.Duration {
|
||||||
|
return time.Since(time.Unix(0, s.lastReq.Load()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handler serves the two things the workstation exposes.
|
||||||
|
//
|
||||||
|
// /health is answered locally and always, with no GPU cost and no round trip,
|
||||||
|
// because it is the only thing Maven reads and Maven reads it on a timer
|
||||||
|
// forever. Everything else is llama-server's API, reverse-proxied. Proxying
|
||||||
|
// rather than pointing Maven straight at llama-server is what makes the idle
|
||||||
|
// window measurable: the supervisor cannot otherwise know when the model was
|
||||||
|
// last used.
|
||||||
|
func (s *supervisor) handler(target *url.URL) http.Handler {
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.run.isReady() {
|
||||||
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.run.isReady() {
|
||||||
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.touch()
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *supervisor) loop(ctx context.Context) {
|
||||||
|
t := time.NewTicker(time.Duration(s.cfg.Poll))
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
s.tick(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tick is the whole decision. Yielding is checked before starting, and presence
|
||||||
|
// on the KFD is what triggers it — not a VRAM threshold. A ROCm process
|
||||||
|
// registers under /sys/class/kfd/kfd/proc when it initialises HIP, before it
|
||||||
|
// allocates, so we see a contender during its startup rather than after it has
|
||||||
|
// already failed to get the memory it wanted.
|
||||||
|
func (s *supervisor) tick(ctx context.Context) {
|
||||||
|
others := s.probe.foreign(s.run.pid())
|
||||||
|
if len(others) > 0 {
|
||||||
|
s.foreignStreak++
|
||||||
|
s.clearStreak = 0
|
||||||
|
} else {
|
||||||
|
s.foreignStreak = 0
|
||||||
|
s.clearStreak++
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.run.running() {
|
||||||
|
s.run.refreshReady(ctx)
|
||||||
|
switch {
|
||||||
|
case s.foreignStreak >= s.cfg.EvictAfter:
|
||||||
|
log.Printf("mavgpud: yielding the card to %s", describe(others))
|
||||||
|
s.run.stop(time.Duration(s.cfg.StopGrace))
|
||||||
|
case s.idle() > time.Duration(s.cfg.IdleTimeout):
|
||||||
|
log.Printf("mavgpud: idle for %s, unloading", s.idle().Round(time.Second))
|
||||||
|
s.run.stop(time.Duration(s.cfg.StopGrace))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.clearStreak < s.cfg.StartAfter {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if free := s.probe.freeVRAM(); free < s.cfg.MinFreeVRAM {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.touch() // the idle clock starts at load, not at the last request before it
|
||||||
|
if err := s.run.start(); err != nil {
|
||||||
|
log.Printf("mavgpud: start llama-server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// describe names the contenders in the log. This log is the instrument for the
|
||||||
|
// open question in #488: whether polling the KFD misses a job that wants the
|
||||||
|
// card without registering there.
|
||||||
|
func describe(procs []gpuProc) string {
|
||||||
|
out := ""
|
||||||
|
for i, p := range procs {
|
||||||
|
if i > 0 {
|
||||||
|
out += ", "
|
||||||
|
}
|
||||||
|
out += p.Comm
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runner owns one llama-server process. Owning it is the point of the daemon:
|
||||||
|
// the workstation cannot keep a 7-14B resident, because that holds 16GB against
|
||||||
|
// the owner's CPT runs, Correx and the manga-recap pipeline. So the thing that
|
||||||
|
// stays up is this, which costs no VRAM, and the model comes and goes under it.
|
||||||
|
type runner struct {
|
||||||
|
bin string
|
||||||
|
args []string
|
||||||
|
// ready is llama-server's own /health, which answers "is a model loaded".
|
||||||
|
// Loading a 7-14B takes tens of seconds, so started is not ready.
|
||||||
|
readyURL string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cmd *exec.Cmd
|
||||||
|
ready bool
|
||||||
|
// yielding — stop() has sent the signal and the exit that follows is ours.
|
||||||
|
// llama-server aborts on SIGTERM (its static teardown throws, upstream
|
||||||
|
// ggml-org/llama.cpp), so a routine yield and a real crash produce the same
|
||||||
|
// "signal: aborted" and used to log identically (Vikunja #491).
|
||||||
|
yielding bool
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunner(bin string, args []string, readyURL string) *runner {
|
||||||
|
return &runner{
|
||||||
|
bin: bin, args: args, readyURL: readyURL,
|
||||||
|
http: &http.Client{Timeout: 2 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pid is the child's, or 0. The GPU probe needs it to tell our own model apart
|
||||||
|
// from a contender.
|
||||||
|
func (r *runner) pid() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.cmd == nil || r.cmd.Process == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return r.cmd.Process.Pid
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runner) running() bool { return r.pid() != 0 }
|
||||||
|
|
||||||
|
// isReady reports the cached readiness. The supervisor loop refreshes it; the
|
||||||
|
// health handler only reads, so answering /health never costs a round trip.
|
||||||
|
func (r *runner) isReady() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.ready
|
||||||
|
}
|
||||||
|
|
||||||
|
// start launches llama-server. It returns as soon as the process exists, not
|
||||||
|
// when the model is loaded.
|
||||||
|
func (r *runner) start() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.cmd != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd := exec.Command(r.bin, r.args...)
|
||||||
|
// Own process group, so stop kills anything llama-server spawned rather
|
||||||
|
// than leaving it holding VRAM after we have declared the card yielded.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.cmd, r.ready, r.yielding = cmd, false, false
|
||||||
|
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
|
||||||
|
go func() {
|
||||||
|
err := cmd.Wait()
|
||||||
|
r.mu.Lock()
|
||||||
|
yielded := r.yielding
|
||||||
|
r.cmd, r.ready, r.yielding = nil, false, false
|
||||||
|
r.mu.Unlock()
|
||||||
|
if yielded {
|
||||||
|
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("mavgpud: llama-server exited: %v", err)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop ends llama-server and waits for the VRAM to come back. SIGTERM first so
|
||||||
|
// it unmaps cleanly, SIGKILL after the grace window. Returning before the
|
||||||
|
// process is gone would let the supervisor report a free card while 14GB is
|
||||||
|
// still mapped, which is the one lie that would make yielding useless.
|
||||||
|
func (r *runner) stop(grace time.Duration) {
|
||||||
|
r.mu.Lock()
|
||||||
|
cmd := r.cmd
|
||||||
|
r.ready = false
|
||||||
|
if cmd != nil && cmd.Process != nil {
|
||||||
|
// The exit that follows is ours, not a crash.
|
||||||
|
r.yielding = true
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pgid := -cmd.Process.Pid
|
||||||
|
_ = syscall.Kill(pgid, syscall.SIGTERM)
|
||||||
|
deadline := time.Now().Add(grace)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !r.running() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
log.Printf("mavgpud: llama-server did not exit in %s, killing", grace)
|
||||||
|
_ = syscall.Kill(pgid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshReady asks llama-server whether the model is loaded. Called once per
|
||||||
|
// supervisor tick, never per request.
|
||||||
|
func (r *runner) refreshReady(ctx context.Context) {
|
||||||
|
if !r.running() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok := false
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.readyURL, nil)
|
||||||
|
if err == nil {
|
||||||
|
resp, err := r.http.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
ok = resp.StatusCode == http.StatusOK
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
was := r.ready
|
||||||
|
r.ready = ok
|
||||||
|
r.mu.Unlock()
|
||||||
|
if ok && !was {
|
||||||
|
log.Printf("mavgpud: model ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeServer writes an executable standing in for llama-server: it ignores
|
||||||
|
// SIGTERM the way the real one effectively does — by dying messily rather than
|
||||||
|
// cleanly — and reports a non-zero status.
|
||||||
|
func fakeServer(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "fake-llama-server")
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deliberate stop is a yield, and the log has to say so.
|
||||||
|
//
|
||||||
|
// llama-server aborts inside its own static teardown on SIGTERM, so the exit
|
||||||
|
// status of a routine yield is identical to that of a real crash. Reading the
|
||||||
|
// mavgpud log, the two were indistinguishable (Vikunja #491).
|
||||||
|
func TestStopMarksTheExitAsAYield(t *testing.T) {
|
||||||
|
r := newRunner(fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
|
||||||
|
if err := r.start(); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.yielding {
|
||||||
|
t.Error("a freshly started server is already marked as yielding")
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
r.stop(2 * time.Second)
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !r.running() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("the child outlived stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping when nothing is running must not arm the flag for the next child.
|
||||||
|
// The next exit after that would be a real crash logged as a yield.
|
||||||
|
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
|
||||||
|
r := newRunner("/nonexistent", nil, "")
|
||||||
|
r.stop(10 * time.Millisecond)
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.yielding {
|
||||||
|
t.Error("stop armed the yield flag with no child running")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,9 @@ type fakeCore struct {
|
|||||||
revertErr error
|
revertErr error
|
||||||
|
|
||||||
// for handleNotifications tests
|
// for handleNotifications tests
|
||||||
nudgesErr error
|
nudgesErr error
|
||||||
|
attempts []ipc.DeliveryAttempt
|
||||||
|
attemptStatus string
|
||||||
|
|
||||||
// for handleHistory tests
|
// for handleHistory tests
|
||||||
historyFacts []ipc.Fact
|
historyFacts []ipc.Fact
|
||||||
@@ -77,7 +79,7 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
|||||||
return f.mcpServers, f.mcpErr
|
return f.mcpServers, f.mcpErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
func (f *fakeCore) Chat(_ context.Context, _, text string) (string, error) {
|
||||||
f.chatText = text
|
f.chatText = text
|
||||||
if f.chatErr != nil {
|
if f.chatErr != nil {
|
||||||
return "", f.chatErr
|
return "", f.chatErr
|
||||||
@@ -1251,3 +1253,35 @@ func TestHandleWS_AssertedSession_PassesGate(t *testing.T) {
|
|||||||
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
|
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeCore) DeliveryAttempts(_ context.Context, status string, _ int) ([]ipc.DeliveryAttempt, error) {
|
||||||
|
f.attemptStatus = status
|
||||||
|
return f.attempts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleNotifications_ShowsTheOutbox — the outbox was written and never
|
||||||
|
// read, so a dropped or failed send was invisible (Vikunja #390).
|
||||||
|
func TestHandleNotifications_ShowsTheOutbox(t *testing.T) {
|
||||||
|
done := time.Date(2026, 8, 4, 9, 0, 30, 0, time.UTC)
|
||||||
|
core := &fakeCore{
|
||||||
|
attempts: []ipc.DeliveryAttempt{
|
||||||
|
{Kind: "nudge", Rule: "care-check", Channel: "telegram", Status: "dropped",
|
||||||
|
Created: done.Add(-30 * time.Second), Completed: &done},
|
||||||
|
{Kind: "reminder", ReminderID: 7, Channel: "voice", Status: "pending", Created: done},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications?status=dropped", nil), core)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.attemptStatus != "dropped" {
|
||||||
|
t.Errorf("status filter = %q, want it passed through", core.attemptStatus)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, want := range []string{"care-check", "dropped", "reminder #7", "Delivery outbox"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("rendered outbox missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+60
-10
@@ -512,7 +512,7 @@ func main() {
|
|||||||
|
|
||||||
// /tools — the authed enable surface. maven proposes acts she can't run;
|
// /tools — the authed enable surface. maven proposes acts she can't run;
|
||||||
// this page is where a human reviews and enables them (proposed→enabled).
|
// this page is where a human reviews and enables them (proposed→enabled).
|
||||||
// Enabling is the boundary-moving act (DESIGN.md § Tool registration —
|
// Enabling is the boundary-moving act (docs/design.md § Tool registration —
|
||||||
// drafting is suggest, enabling is act), so it lives ONLY here,
|
// drafting is suggest, enabling is act), so it lives ONLY here,
|
||||||
// behind wg+nginx+auth — never the voice/chat path.
|
// behind wg+nginx+auth — never the voice/chat path.
|
||||||
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -893,12 +893,59 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP
|
|||||||
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The outbox, on the page that already answers "what did she send".
|
||||||
|
// A failed or dropped attempt is why she went quiet, and until now it was
|
||||||
|
// recorded and unreadable (Vikunja #390). Filter with ?status=dropped.
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
attempts, err := core.DeliveryAttempts(ctx, status, 50)
|
||||||
|
if err != nil {
|
||||||
|
// The nudge list is still worth showing, so this is a note on the page
|
||||||
|
// rather than a dead page.
|
||||||
|
log.Printf("notifications: delivery attempts: %v", err)
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil {
|
if err := notificationsTmpl.Execute(w, map[string]any{
|
||||||
|
"Nudges": nudges,
|
||||||
|
"Attempts": deliveryRows(attempts),
|
||||||
|
"Status": status,
|
||||||
|
}); err != nil {
|
||||||
log.Printf("notifications template: %v", err)
|
log.Printf("notifications template: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliveryRow is one outbox line, with every timestamp already formatted so
|
||||||
|
// the template holds no date logic — same shape as taskRow.
|
||||||
|
type deliveryRow struct {
|
||||||
|
Kind string
|
||||||
|
Target string
|
||||||
|
Channel string
|
||||||
|
Status string
|
||||||
|
Created string
|
||||||
|
Completed string
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
||||||
|
out := make([]deliveryRow, 0, len(as))
|
||||||
|
for _, a := range as {
|
||||||
|
target := a.Rule
|
||||||
|
if target == "" && a.ReminderID != 0 {
|
||||||
|
target = "reminder #" + strconv.FormatInt(a.ReminderID, 10)
|
||||||
|
}
|
||||||
|
row := deliveryRow{
|
||||||
|
Kind: a.Kind,
|
||||||
|
Target: target,
|
||||||
|
Channel: a.Channel,
|
||||||
|
Status: a.Status,
|
||||||
|
Created: a.Created.Format("02.01 15:04"),
|
||||||
|
}
|
||||||
|
if a.Completed != nil {
|
||||||
|
row.Completed = a.Completed.Format("15:04")
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
@@ -1210,13 +1257,10 @@ func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// acceptRoutine creates the recurring reminder for a proposal, then marks the
|
// acceptRoutine marks a proposal accepted. This page is the ONLY surface that
|
||||||
// proposal accepted and links the reminder to it. Weekly patterns get a cron
|
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
||||||
// expression; any other interval fires once.
|
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
||||||
//
|
// behind step-up. Voice can park the question and dismiss, never accept.
|
||||||
// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go.
|
|
||||||
// When the tick loop learns to read accepted proposals directly, both callers
|
|
||||||
// should hand off to one place in core instead of each building a reminder.
|
|
||||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||||
proposed, err := core.ListProposedRoutines(ctx)
|
proposed, err := core.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1681,7 +1725,13 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
|||||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reply, err := core.Chat(r.Context(), text)
|
// One conversation id for the whole web chat, and a different one from
|
||||||
|
// telegram or the mic. A parked question belongs to the reach that was
|
||||||
|
// asked; before this, a clarify nobody answered on the web ate the next
|
||||||
|
// utterance spoken at the mic (Vikunja #466). This server has no
|
||||||
|
// per-browser session, so every browser tab is the same conversation —
|
||||||
|
// which is right for a single-owner box.
|
||||||
|
reply, err := core.Chat(r.Context(), "web", text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("chat api: %v", err)
|
log.Printf("chat api: %v", err)
|
||||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -14,5 +14,27 @@
|
|||||||
<div>no notifications yet</div>
|
<div>no notifications yet</div>
|
||||||
<div class=hint>check back later or ask maven a question</div>
|
<div class=hint>check back later or ask maven a question</div>
|
||||||
</div>{{end}}
|
</div>{{end}}
|
||||||
|
<h2>Delivery outbox</h2>
|
||||||
|
<p class=hint>
|
||||||
|
every send is recorded before it leaves, so a failure is visible rather than silent.
|
||||||
|
<a href="/notifications">all</a> ·
|
||||||
|
<a href="/notifications?status=dropped">dropped</a> ·
|
||||||
|
<a href="/notifications?status=failed">failed</a> ·
|
||||||
|
<a href="/notifications?status=pending">pending</a> ·
|
||||||
|
<a href="/notifications?status=unknown">unknown</a>
|
||||||
|
</p>
|
||||||
|
{{if .Attempts}}<div class=scroll><table>
|
||||||
|
<tr><th>started</th><th>kind</th><th>rule</th><th>channel</th><th>status</th><th>finished</th></tr>
|
||||||
|
{{range .Attempts}}<tr>
|
||||||
|
<td class=hint>{{.Created}}</td>
|
||||||
|
<td>{{.Kind}}</td>
|
||||||
|
<td class=key>{{.Target}}</td>
|
||||||
|
<td><span class=badge>{{.Channel}}</span></td>
|
||||||
|
<td class={{.Status}}>{{.Status}}</td>
|
||||||
|
<td class=hint>{{.Completed}}</td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
{{else}}<div class=empty>
|
||||||
|
<div>no delivery attempts{{if .Status}} with status {{.Status}}{{end}}</div>
|
||||||
|
</div>{{end}}
|
||||||
{{template "shellBottom"}}
|
{{template "shellBottom"}}
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+69
-1
@@ -20,6 +20,7 @@
|
|||||||
"bin_path": "llama-server",
|
"bin_path": "llama-server",
|
||||||
"n_gpu_layers": 99,
|
"n_gpu_layers": 99,
|
||||||
"n_ctx": 4096,
|
"n_ctx": 4096,
|
||||||
|
"cache_ram_mib": 512,
|
||||||
"timeout": "60s",
|
"timeout": "60s",
|
||||||
"llm_nudges": false
|
"llm_nudges": false
|
||||||
},
|
},
|
||||||
@@ -39,6 +40,23 @@
|
|||||||
"proxy": "socks5://192.168.240.1:10808"
|
"proxy": "socks5://192.168.240.1:10808"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"//workstation": [
|
||||||
|
"The big model on the desk PC (workpc, 7900 GRE 16GB), fronted by",
|
||||||
|
"mavgpud on port 8080. It runs gemma-4-12b and it is preferred over the",
|
||||||
|
"resident Qwen3-1.7B for routing and replies whenever the card is free.",
|
||||||
|
"The machine is never assumed up: it sleeps, and the card is often held by",
|
||||||
|
"a CPT run, in which case mavgpud answers 503 and Maven falls back to the",
|
||||||
|
"resident model without saying so. Deleting this block restores exactly",
|
||||||
|
"the behaviour homesrv had before it existed.",
|
||||||
|
"Addressed by LAN address, not container name: mavgpud runs on another",
|
||||||
|
"machine and there is no shared docker network to name it on."
|
||||||
|
],
|
||||||
|
"workstation": {
|
||||||
|
"url": "http://192.168.1.105:8080",
|
||||||
|
"probe": "15s",
|
||||||
|
"timeout": "90s"
|
||||||
|
},
|
||||||
|
|
||||||
"//search": [
|
"//search": [
|
||||||
"The live web, searched after his own notes and before Kiwix. Only the",
|
"The live web, searched after his own notes and before Kiwix. Only the",
|
||||||
"query string leaves the box — never a note, a fact, the persona block or",
|
"query string leaves the box — never a note, a fact, the persona block or",
|
||||||
@@ -76,6 +94,56 @@
|
|||||||
"snippet_runes": 1500
|
"snippet_runes": 1500
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"//morning_routines": [
|
||||||
|
"The daily checklist (Vikunja #280). Each item is done when its fact_key",
|
||||||
|
"gets a non-voided fact inside the window, so 'выпил воды' closes water and",
|
||||||
|
"nothing has to be ticked by hand. nudge_at fires once, at the end of the",
|
||||||
|
"window, and only for what is still open. Weekdays empty = every day."
|
||||||
|
],
|
||||||
|
"morning_routines": [
|
||||||
|
{
|
||||||
|
"name": "утро",
|
||||||
|
"window_start": "08:00",
|
||||||
|
"window_end": "11:00",
|
||||||
|
"nudge_at": "10:30",
|
||||||
|
"severity": 1,
|
||||||
|
"items": [
|
||||||
|
{ "key": "medicine", "fact_key": "medicine", "label": "лекарство" },
|
||||||
|
{ "key": "water", "fact_key": "water", "label": "вода" },
|
||||||
|
{ "key": "pets", "fact_key": "pets", "label": "покормить кота" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"//feeds": [
|
||||||
|
"RSS reading (Vikunja #258). Every item lands as a note with source",
|
||||||
|
"rss:<name>, which is also what puts entries in the intake journal that",
|
||||||
|
"/events reads. Only the feed URL leaves the box.",
|
||||||
|
"This is a starting pair, not a curated set — trim or extend it."
|
||||||
|
],
|
||||||
|
"feeds": {
|
||||||
|
"poll_interval": "30m",
|
||||||
|
"max_items": 5,
|
||||||
|
"max_age": "24h",
|
||||||
|
"sources": [
|
||||||
|
{ "name": "lwn", "url": "https://lwn.net/headlines/newrss", "category": "технологии" },
|
||||||
|
{ "name": "archlinux", "url": "https://archlinux.org/feeds/news/", "category": "технологии" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"//crawl": [
|
||||||
|
"Reading a web page (Vikunja #259). on_demand answers 'посмотри <URL>'.",
|
||||||
|
"No allow_hosts, so any public host he names is readable; private",
|
||||||
|
"addresses are refused unconditionally by internal/webfetch and do not",
|
||||||
|
"need listing. Setting allow_hosts here would also narrow on-demand,",
|
||||||
|
"which is the point of leaving it empty."
|
||||||
|
],
|
||||||
|
"crawl": {
|
||||||
|
"on_demand": true,
|
||||||
|
"timeout": "10s",
|
||||||
|
"max_runes": 4000
|
||||||
|
},
|
||||||
|
|
||||||
"digest": {
|
"digest": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"window": "30m",
|
"window": "30m",
|
||||||
@@ -119,7 +187,7 @@
|
|||||||
"timeout": "400ms",
|
"timeout": "400ms",
|
||||||
"rate": 100,
|
"rate": 100,
|
||||||
"max_hosts": 256,
|
"max_hosts": 256,
|
||||||
"enabled": false
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"nexus": { "url": "http://nexus:9740" },
|
"nexus": { "url": "http://nexus:9740" },
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"listen": ":8080",
|
||||||
|
"llama_addr": "127.0.0.1:10000",
|
||||||
|
"llama_bin": "llama-server",
|
||||||
|
"llama_args": [
|
||||||
|
"-m", "/mnt/D/AI/gemma4/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
|
||||||
|
"-md", "/mnt/D/AI/gemma4/mtp-gemma-4-12B-it-BF16.gguf",
|
||||||
|
"-ngl", "99",
|
||||||
|
"-fa", "on",
|
||||||
|
"-np", "1",
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
"--port", "10000",
|
||||||
|
"--ctx-size", "32768",
|
||||||
|
"--threads", "6",
|
||||||
|
"--batch-size", "2048",
|
||||||
|
"--ubatch-size", "512",
|
||||||
|
"--jinja",
|
||||||
|
"--chat-template-kwargs", "{\"enable_thinking\":false}",
|
||||||
|
"--spec-type", "draft-mtp",
|
||||||
|
"--spec-draft-n-max", "2"
|
||||||
|
],
|
||||||
|
|
||||||
|
"kfd_root": "/sys/class/kfd/kfd/proc",
|
||||||
|
"drm_device": "/sys/class/drm/card1/device",
|
||||||
|
|
||||||
|
"poll": "1s",
|
||||||
|
"idle_timeout": "15m",
|
||||||
|
"stop_grace": "20s",
|
||||||
|
"min_free_vram_bytes": 10737418240,
|
||||||
|
"evict_after_polls": 2,
|
||||||
|
"start_after_polls": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[Unit]
|
||||||
|
# Runs on the workstation (bugmachine), not on homesrv. Install as a systemd
|
||||||
|
# user unit and turn on lingering, so the card is supervised after a reboot
|
||||||
|
# with nobody logged in:
|
||||||
|
#
|
||||||
|
# scp mavgpud workpc:~/.local/bin/mavgpud
|
||||||
|
# scp deploy/mavgpud.json workpc:~/.config/mavgpud.json
|
||||||
|
# scp deploy/mavgpud.service workpc:~/.config/systemd/user/mavgpud.service
|
||||||
|
# ssh workpc 'systemctl --user daemon-reload && systemctl --user enable --now mavgpud'
|
||||||
|
# sudo loginctl enable-linger kami
|
||||||
|
Description=Maven GPU supervisor (holds llama-server while the card is free)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=%h/.local/bin/mavgpud -config %h/.config/mavgpud.json
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
# The card must come back when the supervisor goes down. mavgpud stops
|
||||||
|
# llama-server on SIGTERM, so give it longer than stop_grace to do that.
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=60
|
||||||
|
# llama-server aborts inside its own static teardown on SIGTERM, so every
|
||||||
|
# routine yield used to write a multi-gigabyte core into systemd-coredump
|
||||||
|
# (Vikunja #491). Yielding is meant to happen several times a day.
|
||||||
|
LimitCORE=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -79,7 +79,16 @@ services:
|
|||||||
<<: *image
|
<<: *image
|
||||||
# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it
|
# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it
|
||||||
# cross-container. Verified 2026-07-06.
|
# cross-container. Verified 2026-07-06.
|
||||||
|
# -ambient-token turns on POST /api/ambient (Vikunja #126): the phone posts
|
||||||
|
# notification text, mavweb keeps only a meeting time. Empty ⇒ no route at
|
||||||
|
# all, which is what a missing MAVEN_AMBIENT_TOKEN gives. The value comes
|
||||||
|
# from the gitignored .env docker compose reads for interpolation, NOT from
|
||||||
|
# an env_file — flags are interpolated before any service env exists.
|
||||||
|
# Weakness worth naming: mavweb takes this as a flag, so it is visible in
|
||||||
|
# `ps` inside this container, unlike the zenmoney and IMAP secrets which are
|
||||||
|
# read from files.
|
||||||
command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock",
|
command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock",
|
||||||
|
"-ambient-token", "${MAVEN_AMBIENT_TOKEN:-}",
|
||||||
"-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"]
|
"-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"]
|
||||||
depends_on: [mavend]
|
depends_on: [mavend]
|
||||||
# loopback-only on purpose: /tools defines+executes arbitrary argv and
|
# loopback-only on purpose: /tools defines+executes arbitrary argv and
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
# maven — feature ranking
|
# maven — feature ranking
|
||||||
|
|
||||||
> dated 2026-07-03. companion to `DESIGN.md` (folded from the former `maven.md`). ranks everything discussed post-repo-state against the infra blockers, not a replacement for the build order.
|
> **Archived 2026-08-02 (V-447).** The mandatory and easy tiers are now Vikunja tasks
|
||||||
|
> 449-458. Two of those closed immediately, because the ranking was stale. Quiet hours
|
||||||
|
> (V-450) ship as `QuietHoursConfig` plus the care gate in `internal/loop/loop.go`.
|
||||||
|
> Schema migrations (V-451) ship as `internal/store/migrations.go` on `PRAGMA
|
||||||
|
> user_version`. The doable and epic tiers stay here because they are reasoning. Some of
|
||||||
|
> them exist only to record why something is not worth doing yet. Read this for the why,
|
||||||
|
> not as a work queue, and check the code before believing a gap.
|
||||||
|
|
||||||
|
> dated 2026-07-03. companion to `docs/design.md` (folded from the former `maven.md`). ranks everything discussed post-repo-state against the infra blockers, not a replacement for the build order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## what already shipped (checked against the code, 2026-08-02)
|
||||||
|
|
||||||
|
One month old and already wrong in ten places. Everything below is marked
|
||||||
|
built after reading the code, not the board. Read the tiers underneath with this list in
|
||||||
|
hand.
|
||||||
|
|
||||||
|
Infra 1 and 2, the two the ranking says block every feature, are both done. sqlcipher
|
||||||
|
at-rest ships as `Store.enc` plus `OpenEncrypted`, a tmpfs working copy re-encrypted on
|
||||||
|
`Close`, keyed from `db_key_env` in `deploy/mavend.json`. mavweb and mavcaldav are no
|
||||||
|
longer at zero coverage: seven test files under `cmd/mavweb`, including
|
||||||
|
`credentials_test.go` and `passkey_prf_test.go`, and two under `cmd/mavcaldav`. Infra 3
|
||||||
|
is stale in the other direction. There are still no systemd units, but the deploy is
|
||||||
|
`deploy/ecosystem/docker-compose.yml`, not scripts and tmux.
|
||||||
|
|
||||||
|
Doable tier, built: rule trace and explanation as `/trace` plus `internal/loop/explain.go`.
|
||||||
|
Recurring reminders as the cron column, `NextFireTs` and `RescheduleReminder`
|
||||||
|
(`internal/store/reminders.go:206`), so "fires once right now" is wrong. Stale-reminder
|
||||||
|
burst collapse as `collapseReminders` (`internal/loop/gather.go:209`). Revert as
|
||||||
|
`VoidLatestFact` (`internal/store/facts.go:300`). Digest mode as `internal/store/digest.go`.
|
||||||
|
Testing infra as the simulator and the eval lab (V-284, V-278). Passkey persistence as the
|
||||||
|
JSON-backed `credentialStore` in `cmd/mavweb/credentials.go`. Most integrations shipped as
|
||||||
|
their own QA tasks (V-246 mail, V-256 smarthome, V-258 rss, V-259 crawler).
|
||||||
|
|
||||||
|
Doable tier, still open: correx, systemd units, memory decay and duplicate detection
|
||||||
|
(nothing in `internal/memory` touches it), backup automation, import and export, barge-in.
|
||||||
|
|
||||||
|
Epic tier, unbuilt as ranked. `event.Bus` exists (`cmd/mavend/intake.go:57`) but it is the
|
||||||
|
intake journal from V-283, not the rewrite of facts into projections that this tier means.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,7 +59,7 @@ nothing feature-level below should land before 1–2 are done. 3–4 can interle
|
|||||||
### mandatory
|
### mandatory
|
||||||
things that block correctness or safety of stuff already shipped — not new capability, just closing gaps in existing design.
|
things that block correctness or safety of stuff already shipped — not new capability, just closing gaps in existing design.
|
||||||
|
|
||||||
- **destructive-confirm policy** — open question in `DESIGN.md` § open questions, blocks correx and any new tool domain from having a coherent risk tier
|
- **destructive-confirm policy** — open question in `docs/design.md` § open questions, blocks correx and any new tool domain from having a coherent risk tier
|
||||||
- **quiet-hours definition** — open question, blocks proactive delivery being trustworthy
|
- **quiet-hours definition** — open question, blocks proactive delivery being trustworthy
|
||||||
- **schema migrations** — sqlcipher rollout alone forces a schema touch. want this mechanism before that, not after.
|
- **schema migrations** — sqlcipher rollout alone forces a schema touch. want this mechanism before that, not after.
|
||||||
|
|
||||||
@@ -30,7 +69,7 @@ cheap, no dependencies, no new invariants.
|
|||||||
- **grocery / `list_items` table** — fourth append-only shape (item, status, list-tag), no predicate touches it, multi-adder just works for free
|
- **grocery / `list_items` table** — fourth append-only shape (item, status, list-tag), no predicate touches it, multi-adder just works for free
|
||||||
- **go.mod tidy**
|
- **go.mod tidy**
|
||||||
- **capability model** (deepseek) — `homelab.docker.restart` instead of flat `tool→enabled`. cheap now, expensive to retrofit once tools surface passes ~15 entries. time-sensitive, not urgent.
|
- **capability model** (deepseek) — `homelab.docker.restart` instead of flat `tool→enabled`. cheap now, expensive to retrofit once tools surface passes ~15 entries. time-sensitive, not urgent.
|
||||||
- **conversation repair** — already free: `DESIGN.md` has "misroute correction = new centroid example," this is just naming the existing mechanism as a feature
|
- **conversation repair** — already free: `docs/design.md` has "misroute correction = new centroid example," this is just naming the existing mechanism as a feature
|
||||||
- **command history** — read-only query over existing facts, no new mechanism
|
- **command history** — read-only query over existing facts, no new mechanism
|
||||||
- **clarification templates** — canned phrasing for the router's existing confidence-gate fallback, phraser-lane only
|
- **clarification templates** — canned phrasing for the router's existing confidence-gate fallback, phraser-lane only
|
||||||
- **pronunciation dictionary** — tts config, no architecture
|
- **pronunciation dictionary** — tts config, no architecture
|
||||||
@@ -30,7 +30,7 @@ critical workflows: voice turn (mic→STT→route→tool/reply→TTS); proactive
|
|||||||
(/dash /chat /tools /ecosystem)
|
(/dash /chat /tools /ecosystem)
|
||||||
current state: all 34 test packages pass; vet clean; `make test` still exits 1
|
current state: all 34 test packages pass; vet clean; `make test` still exits 1
|
||||||
(finding 2)
|
(finding 2)
|
||||||
known failures: weak RU query routing — REARCH.md names the cause; the named fix
|
known failures: weak RU query routing — docs/rearchitecture.md names the cause; the named fix
|
||||||
is wired `nil`
|
is wired `nil`
|
||||||
maintenance burden: 4,518 lines of root markdown vs 33,319 lines of Go; 15 top-level
|
maintenance burden: 4,518 lines of root markdown vs 33,319 lines of Go; 15 top-level
|
||||||
.md files, 3 of them dated session logs; several contradict
|
.md files, 3 of them dated session logs; several contradict
|
||||||
@@ -47,11 +47,11 @@ what's obsolete: llmrouter.go (built, tested, never wired); classifier seed-p
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Classification: healthy + misaligned.** Not fragile, not overbuilt, not abandoned. The
|
**Classification: healthy + misaligned.** Not fragile, not overbuilt, not abandoned. The
|
||||||
architecture in `REARCH.md` is sound and mostly *built* — it just is not *connected*.
|
architecture in `docs/rearchitecture.md` is sound and mostly *built* — it just is not *connected*.
|
||||||
|
|
||||||
## what it should become
|
## what it should become
|
||||||
|
|
||||||
The thing `REARCH.md` already describes, with the switch flipped and the drift removed:
|
The thing `docs/rearchitecture.md` already describes, with the switch flipped and the drift removed:
|
||||||
one resident small model doing both routing and phrasing, classifier demoted from the live
|
one resident small model doing both routing and phrasing, classifier demoted from the live
|
||||||
path to the failure floor, embedder demoted to RAG hint. No new architecture is needed.
|
path to the failure floor, embedder demoted to RAG hint. No new architecture is needed.
|
||||||
**The gap is a config/wiring decision plus doc convergence, not a redesign.**
|
**The gap is a config/wiring decision plus doc convergence, not a redesign.**
|
||||||
@@ -65,19 +65,19 @@ path to the failure floor, embedder demoted to RAG hint. No new architecture is
|
|||||||
`architecture` / `repair`
|
`architecture` / `repair`
|
||||||
|
|
||||||
**problem:** The most load-bearing design decision in the project is stated four different,
|
**problem:** The most load-bearing design decision in the project is stated four different,
|
||||||
incompatible ways, and the code path `REARCH.md` calls "the linchpin" is disabled.
|
incompatible ways, and the code path `docs/rearchitecture.md` calls "the linchpin" is disabled.
|
||||||
|
|
||||||
**evidence** (all confirmed):
|
**evidence** (all confirmed):
|
||||||
|
|
||||||
- `cmd/mavend/voice.go:211` — `rtr := buildRouter(emb, matcher, threshold, nil) // LLM router disabled`,
|
- `cmd/mavend/voice.go:211` — `rtr := buildRouter(emb, matcher, threshold, nil) // LLM router disabled`,
|
||||||
with comment *"the classifier handles routing reliably."*
|
with comment *"the classifier handles routing reliably."*
|
||||||
- `REARCH.md:11` says the same classifier is *"the structural cause of 'she messes up
|
- `docs/rearchitecture.md:11` says the same classifier is *"the structural cause of 'she messes up
|
||||||
queries.'"* **The code comment and the design doc make opposite claims about the same
|
queries.'"* **The code comment and the design doc make opposite claims about the same
|
||||||
component.**
|
component.**
|
||||||
- `internal/router/llmrouter.go` (139 lines) + `llmrouter_test.go` — fully built and
|
- `internal/router/llmrouter.go` (139 lines) + `llmrouter_test.go` — fully built and
|
||||||
tested, zero non-test callers.
|
tested, zero non-test callers.
|
||||||
- Model identity, four ways: docs say **Qwen3-1.7B** (`CLAUDE.md:6`, `REARCH.md:15`,
|
- Model identity, four ways: docs say **Qwen3-1.7B** (`CLAUDE.md:6`, `docs/rearchitecture.md:15`,
|
||||||
`SPEC.md:46`, `AGENTS.md:79`, `MAVEN_ECOSYSTEM_ARCHITECTURE.md:72`);
|
`SPEC.md:46`, `AGENTS.md:79`, `docs/ecosystem.md:72`);
|
||||||
`deploy/mavend.json:9` says **Qwen3.5-2B-UD-Q4_K_XL**; `models/llm/` on disk holds
|
`deploy/mavend.json:9` says **Qwen3.5-2B-UD-Q4_K_XL**; `models/llm/` on disk holds
|
||||||
**LFM2.5-1.2B-Thinking**; code comments in 5 files still say **LFM**.
|
**LFM2.5-1.2B-Thinking**; code comments in 5 files still say **LFM**.
|
||||||
- `deploy/mavend.json:11` sets `"n_gpu_layers": 99` while `CLAUDE.md:4` states the target
|
- `deploy/mavend.json:11` sets `"n_gpu_layers": 99` while `CLAUDE.md:4` states the target
|
||||||
@@ -100,7 +100,7 @@ match. Delete nothing from `internal/router` yet — the classifier is the fallb
|
|||||||
reconciliation, not a refactor. **Do not rewrite the router.**
|
reconciliation, not a refactor. **Do not rewrite the router.**
|
||||||
|
|
||||||
**alternatives:** Delete `llmrouter.go` and commit to the classifier — only defensible if
|
**alternatives:** Delete `llmrouter.go` and commit to the classifier — only defensible if
|
||||||
the eval harness shows the classifier is actually adequate, which contradicts `REARCH.md`.
|
the eval harness shows the classifier is actually adequate, which contradicts `docs/rearchitecture.md`.
|
||||||
|
|
||||||
**risk:** Low-moderate. LLM route failures already fall through to the classifier
|
**risk:** Low-moderate. LLM route failures already fall through to the classifier
|
||||||
(`router.go:88-96`), so a bad model cannot break a turn. The real risk is CPU latency.
|
(`router.go:88-96`), so a bad model cannot break a turn. The real risk is CPU latency.
|
||||||
@@ -228,21 +228,21 @@ after finding 1, not before** — and skip it if it stays purely cosmetic.
|
|||||||
**problem:** 15 root markdown files, 4,518 lines, several stale or superseded, at least
|
**problem:** 15 root markdown files, 4,518 lines, several stale or superseded, at least
|
||||||
three pairs contradicting each other.
|
three pairs contradicting each other.
|
||||||
|
|
||||||
**evidence:** `ROADMAP.md` (759) + `MAVEN_ECOSYSTEM_ARCHITECTURE.md` (884) +
|
**evidence:** `ROADMAP.md` (759) + `docs/ecosystem.md` (884) +
|
||||||
`PROGRESS.md` (456) + `maven.md` (413) + `20-07-2026-BACKLOG.md` (396) +
|
`PROGRESS.md` (456) + `maven.md` (413) + `20-07-2026-BACKLOG.md` (396) +
|
||||||
`SESSION-05-07-2026.md` + `SESSION-06-07-2026.md` (477 combined) + `PLANS.md` (25) +
|
`SESSION-05-07-2026.md` + `SESSION-06-07-2026.md` (477 combined) + `PLANS.md` (25) +
|
||||||
`START.md` + `SPEC.md` + `PROTOCOL.md`. `PROGRESS.md:61` annotates its own staleness:
|
`docs/operations.md` + `SPEC.md` + `docs/protocol.md`. `PROGRESS.md:61` annotates its own staleness:
|
||||||
*"Older LFM references below describe the currently deployed..."*. `REARCH.md` announces it
|
*"Older LFM references below describe the currently deployed..."*. `docs/rearchitecture.md` announces it
|
||||||
"supersedes" a model still described as current elsewhere.
|
"supersedes" a model still described as current elsewhere.
|
||||||
|
|
||||||
**impact:** The doc set is the reason finding 1 exists. When five documents describe the
|
**impact:** The doc set is the reason finding 1 exists. When five documents describe the
|
||||||
architecture, the code becomes the only trustworthy one — which defeats the purpose of
|
architecture, the code becomes the only trustworthy one — which defeats the purpose of
|
||||||
having them.
|
having them.
|
||||||
|
|
||||||
**recommended action:** Keep `CLAUDE.md` (agent contract), `REARCH.md` (target
|
**recommended action:** Keep `CLAUDE.md` (agent contract), `docs/rearchitecture.md` (target
|
||||||
architecture), `AGENTS.md` (recipes), `PROTOCOL.md` (wire format),
|
architecture), `AGENTS.md` (recipes), `docs/protocol.md` (wire format),
|
||||||
`20-07-2026-BACKLOG.md` (live queue). Delete the two `SESSION-*.md` and `PLANS.md` — git
|
`20-07-2026-BACKLOG.md` (live queue). Delete the two `SESSION-*.md` and `PLANS.md` — git
|
||||||
history holds them. Fold `SPEC.md` + `maven.md` + `ROADMAP.md` into one `DESIGN.md` and
|
history holds them. Fold `SPEC.md` + `maven.md` + `ROADMAP.md` into one `docs/design.md` and
|
||||||
mark superseded sections instead of leaving them to read as current. Target ~1,500 lines.
|
mark superseded sections instead of leaving them to read as current. Target ~1,500 lines.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -320,7 +320,7 @@ expected maintenance gain: none over the incremental path
|
|||||||
suggesting Vulkan offload is intended and working — but `CLAUDE.md` says CPU-only. Likely
|
suggesting Vulkan offload is intended and working — but `CLAUDE.md` says CPU-only. Likely
|
||||||
the doc is stale, not the config; unverified.
|
the doc is stale, not the config; unverified.
|
||||||
- **Whether the classifier is genuinely adequate.** `voice.go:211` asserts it is;
|
- **Whether the classifier is genuinely adequate.** `voice.go:211` asserts it is;
|
||||||
`REARCH.md` asserts it is not. Both are claims, neither is measured. The uncommitted eval
|
`docs/rearchitecture.md` asserts it is not. Both are claims, neither is measured. The uncommitted eval
|
||||||
harness is the instrument to settle it — resolve before flipping the router, not after.
|
harness is the instrument to settle it — resolve before flipping the router, not after.
|
||||||
- **Whether wg+nginx+auth actually fronts 9201 in production.** Not in this repo. If it
|
- **Whether wg+nginx+auth actually fronts 9201 in production.** Not in this repo. If it
|
||||||
does, finding 3 drops from "unauthenticated RCE" to "the control is not reproducible from
|
does, finding 3 drops from "unauthenticated RCE" to "the control is not reproducible from
|
||||||
@@ -350,7 +350,7 @@ Key claims independently re-verified against the working tree; the verdict stand
|
|||||||
over WireGuard on homesrv, this is hygiene, not an emergency — but the loopback bind
|
over WireGuard on homesrv, this is hygiene, not an emergency — but the loopback bind
|
||||||
and startup warning are cheap insurance either way, so do them regardless.
|
and startup warning are cheap insurance either way, so do them regardless.
|
||||||
- Finding 1's "flip the router" step should be gated harder on measurement.
|
- Finding 1's "flip the router" step should be gated harder on measurement.
|
||||||
`REARCH.md`'s claim that the classifier causes weak RU queries is itself unmeasured —
|
`docs/rearchitecture.md`'s claim that the classifier causes weak RU queries is itself unmeasured —
|
||||||
the review admits this under uncertainties, but the "repair now" ordering buries it.
|
the review admits this under uncertainties, but the "repair now" ordering buries it.
|
||||||
Run `eval_scenarios_test.go` against both paths **before** deciding to flip, not
|
Run `eval_scenarios_test.go` against both paths **before** deciding to flip, not
|
||||||
after. A 2B model on CPU may add enough latency that the classifier wins in practice
|
after. A 2B model on CPU may add enough latency that the classifier wins in practice
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
# Maven — Design
|
# Maven — Design
|
||||||
|
|
||||||
|
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
> Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md`
|
> Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md`
|
||||||
> (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan,
|
> (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan,
|
||||||
> 2026-07-06). Those three files are gone; git history holds them.
|
> 2026-07-06). Those three files are gone; git history holds them.
|
||||||
> This is the single design document: principles, target state, and the
|
> This is the single design document: principles, target state, and the
|
||||||
> execution ledger. `REARCH.md` remains authoritative wherever it disagrees
|
> execution ledger. `docs/rearchitecture.md` remains authoritative wherever it disagrees
|
||||||
> with anything here. Everything the three sources asserted that is no longer
|
> with anything here. Everything the three sources asserted that is no longer
|
||||||
> the intended design is preserved under **§ Superseded** — do not read that
|
> the intended design is preserved under **§ Superseded** — do not read that
|
||||||
> section as current.
|
> section as current.
|
||||||
@@ -160,7 +162,7 @@ presence_state ( last_bucket, last_score, updated_ts )
|
|||||||
```
|
```
|
||||||
|
|
||||||
Facts additionally carry `Subject`/`EntityID`/`ResolutionState` for
|
Facts additionally carry `Subject`/`EntityID`/`ResolutionState` for
|
||||||
entity-aware resolution against Nexus (see `MAVEN_ECOSYSTEM_ARCHITECTURE.md`).
|
entity-aware resolution against Nexus (see `docs/ecosystem.md`).
|
||||||
|
|
||||||
### Trigger model
|
### Trigger model
|
||||||
|
|
||||||
@@ -196,7 +198,7 @@ INTO the gate as an env predicate, not the LLM's job.
|
|||||||
|
|
||||||
## Reactive path — routing
|
## Reactive path — routing
|
||||||
|
|
||||||
**Target design: LLM-as-router** (see `REARCH.md` and `CLAUDE.md`). One
|
**Target design: LLM-as-router** (see `docs/rearchitecture.md` and `CLAUDE.md`). One
|
||||||
resident model emits GBNF-constrained structured JSON, and the same model
|
resident model emits GBNF-constrained structured JSON, and the same model
|
||||||
phrases replies; the embedder is a RAG hint, not a routing gate. The
|
phrases replies; the embedder is a RAG hint, not a routing gate. The
|
||||||
committed default today is the classifier/embedder cascade, which is an
|
committed default today is the classifier/embedder cascade, which is an
|
||||||
@@ -221,6 +223,30 @@ Not alternatives — layers:
|
|||||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||||
|
|
||||||
|
#### A restart expires a parked question
|
||||||
|
|
||||||
|
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||||
|
restart; the clarify question parked behind it does not, and neither do the
|
||||||
|
three yes/no confirms in `voice.go`. `ClarifyStore` stays in memory.
|
||||||
|
|
||||||
|
Three reasons, in the order they settle it:
|
||||||
|
|
||||||
|
- The clock stops meaning anything. A parked question carries a 90s TTL and an
|
||||||
|
attempt count. A restart is a gap of unknown length, so a restored question is
|
||||||
|
either already dead or pretending to be young.
|
||||||
|
- Restoring the question restores the request behind it. He asked for something,
|
||||||
|
she asked back, and then the daemon went away. Acting on that minutes later,
|
||||||
|
against words he has probably given up on, is the misroute the stage 3 gate
|
||||||
|
exists to avoid.
|
||||||
|
- She does not announce it either. The expiry notice needs to know a question
|
||||||
|
was parked, and knowing that across a restart means storing it. One sentence,
|
||||||
|
in the rare window where he speaks within 90s of a restart, does not pay for a
|
||||||
|
marker that outlives the thing it describes. His next words route fresh, which
|
||||||
|
is the correct answer with or without the notice.
|
||||||
|
|
||||||
|
So the notice stays what it is: the in-process TTL case, where she really did
|
||||||
|
wait and really did let go.
|
||||||
|
|
||||||
### save-where — the two-memory routing axis
|
### save-where — the two-memory routing axis
|
||||||
|
|
||||||
One discriminator: **does the loop evaluate a predicate against it?**
|
One discriminator: **does the loop evaluate a predicate against it?**
|
||||||
@@ -655,7 +681,7 @@ daemon.
|
|||||||
The voice wire protocol (length-prefixed JSON frames over TCP) is designed for
|
The voice wire protocol (length-prefixed JSON frames over TCP) is designed for
|
||||||
**multiple client implementations**. The reference PWA at `cmd/mavweb` is one
|
**multiple client implementations**. The reference PWA at `cmd/mavweb` is one
|
||||||
client; any app (phone, desktop CLI, smartwatch) can implement the same frame
|
client; any app (phone, desktop CLI, smartwatch) can implement the same frame
|
||||||
protocol. The published spec is `PROTOCOL.md` — **generated from
|
protocol. The published spec is `docs/protocol.md` — **generated from
|
||||||
`internal/voice/wire.go`**, not composed freehand, so it can't drift from
|
`internal/voice/wire.go`**, not composed freehand, so it can't drift from
|
||||||
code. It covers transport (4-byte big-endian length prefix), methods
|
code. It covers transport (4-byte big-endian length prefix), methods
|
||||||
(`PushToTalk`, `Pong`), push kinds (`AudioNudge`), surface identity
|
(`PushToTalk`, `Pong`), push kinds (`AudioNudge`), surface identity
|
||||||
@@ -670,8 +696,8 @@ Broadening to home automation, media or comms is JSON, not code.
|
|||||||
|
|
||||||
## Execution ledger
|
## Execution ledger
|
||||||
|
|
||||||
Condensed from `ROADMAP.md` (2026-07-06). The live queue is
|
Condensed from `ROADMAP.md` (2026-07-06). The live queue is the Vikunja board
|
||||||
`20-07-2026-BACKLOG.md`; current state is `PROGRESS.md`.
|
(project Maven, ID 2); this table is history, not a work list.
|
||||||
|
|
||||||
| # | Item | Prio | Status |
|
| # | Item | Prio | Status |
|
||||||
|---|------|------|--------|
|
|---|------|------|--------|
|
||||||
@@ -755,7 +781,7 @@ Kept for provenance. **None of this is the current or intended design.**
|
|||||||
stay deterministic — "classifier owns the route, the SLM stays in its
|
stay deterministic — "classifier owns the route, the SLM stays in its
|
||||||
phrasing lane" — with an embedding + nearest-centroid stage 1 over ~10
|
phrasing lane" — with an embedding + nearest-centroid stage 1 over ~10
|
||||||
examples per intent, and misroutes appended as new centroid examples.
|
examples per intent, and misroutes appended as new centroid examples.
|
||||||
*Replaced by* LLM-as-router (`REARCH.md`): one resident model emits
|
*Replaced by* LLM-as-router (`docs/rearchitecture.md`): one resident model emits
|
||||||
GBNF-constrained JSON and also phrases replies; the embedder is demoted to
|
GBNF-constrained JSON and also phrases replies; the embedder is demoted to
|
||||||
a RAG hint. *Landed 2026-07-31:* the LLM router is on by default and set
|
a RAG hint. *Landed 2026-07-31:* the LLM router is on by default and set
|
||||||
`true` in `deploy/mavend.json`. The classifier cascade stays as the failure
|
`true` in `deploy/mavend.json`. The classifier cascade stays as the failure
|
||||||
@@ -774,7 +800,7 @@ Kept for provenance. **None of this is the current or intended design.**
|
|||||||
*Resolved 2026-07-30 (#318), revised 2026-07-31:* the resident checkpoint is
|
*Resolved 2026-07-30 (#318), revised 2026-07-31:* the resident checkpoint is
|
||||||
stock **Qwen3-1.7B** (`UD-Q4_K_XL`, `n_ctx` 4096), which replaced
|
stock **Qwen3-1.7B** (`UD-Q4_K_XL`, `n_ctx` 4096), which replaced
|
||||||
Qwen3.5-0.8B after measuring better on both fixtures
|
Qwen3.5-0.8B after measuring better on both fixtures
|
||||||
(`MODEL-BAKEOFF-31-07-2026.md`). The CPT'd **Qwen3-1.7B** remains the target
|
(`docs/evals/2026-07-31-model-bakeoff.md`). The CPT'd **Qwen3-1.7B** remains the target
|
||||||
(#122); what stock gets wrong is the persona, not the Russian. Note the resident
|
(#122); what stock gets wrong is the persona, not the Russian. Note the resident
|
||||||
model is no longer described as untrained — the target is trained
|
model is no longer described as untrained — the target is trained
|
||||||
end-to-end, which is the substantive change from the old claim.
|
end-to-end, which is the substantive change from the old claim.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Deterministic logic around a small model
|
# Deterministic logic around a small model
|
||||||
|
|
||||||
|
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
Written 2026-08-02. Branch `fix/integrated`.
|
Written 2026-08-02. Branch `fix/integrated`.
|
||||||
|
|
||||||
## The question
|
## The question
|
||||||
@@ -268,7 +270,7 @@ rebuilt `mavend` wires it. "кто написал войну и мир?" now rou
|
|||||||
that turn left unsettled.
|
that turn left unsettled.
|
||||||
|
|
||||||
- **The turn was slow, and nobody knows yet whether that is real.** Route 7s,
|
- **The turn was slow, and nobody knows yet whether that is real.** Route 7s,
|
||||||
search 1s, phrasing 15s. The p50 in `ROUTING-EVAL-31-07-2026.md` is 825ms. It
|
search 1s, phrasing 15s. The p50 in `docs/evals/2026-07-31-routing.md` is 825ms. It
|
||||||
was the first turn after a cold start with the model still warming, so it
|
was the first turn after a cold start with the model still warming, so it
|
||||||
proves nothing either way. Re-run the same question warm before treating it as
|
proves nothing either way. Re-run the same question warm before treating it as
|
||||||
a regression. Do not plan latency work off this number.
|
a regression. Do not plan latency work off this number.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Maven Ecosystem Architecture
|
# Maven Ecosystem Architecture
|
||||||
|
|
||||||
|
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
|
|
||||||
This document defines Maven's role in the local ecosystem formed by:
|
This document defines Maven's role in the local ecosystem formed by:
|
||||||
@@ -16,7 +16,7 @@ with Qwen3-1.7B.
|
|||||||
|
|
||||||
Settles Vikunja **#278 / #250**.
|
Settles Vikunja **#278 / #250**.
|
||||||
|
|
||||||
- Same fixture and scorer as `ROUTING-EVAL-31-07-2026.md`: `internal/router/eval/`
|
- Same fixture and scorer as `docs/evals/2026-07-31-routing.md`: `internal/router/eval/`
|
||||||
(`ru_routing_v1.json`, 76 held-out cases).
|
(`ru_routing_v1.json`, 76 held-out cases).
|
||||||
- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1:<port> make eval-router`
|
- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1:<port> make eval-router`
|
||||||
(`TestLLMRouterBaseline`). (This line used to say there is no `make eval-models` target.
|
(`TestLLMRouterBaseline`). (This line used to say there is no `make eval-models` target.
|
||||||
@@ -148,7 +148,7 @@ Qwen3-1.7B wins every column, including against a model 20% larger than it.
|
|||||||
| ontopic | 16, 19, 19 | **22, 23, 23** |
|
| ontopic | 16, 19, 19 | **22, 23, 23** |
|
||||||
| canned fallbacks | 8, 5, 6 | **0, 2, 0** |
|
| canned fallbacks | 8, 5, 6 | **0, 2, 0** |
|
||||||
|
|
||||||
This also fills the row `TALK-EVAL-31-07-2026.md` had to void for contamination:
|
This also fills the row `docs/evals/2026-07-31-talk.md` had to void for contamination:
|
||||||
**600ch/1024tok on Qwen3.5-0.8B scores 13, 11, 8.**
|
**600ch/1024tok on Qwen3.5-0.8B scores 13, 11, 8.**
|
||||||
|
|
||||||
`address` is the headline. It sat at 18-22 of 27 on the 0.8B no matter how the prompt
|
`address` is the headline. It sat at 18-22 of 27 on the 0.8B no matter how the prompt
|
||||||
@@ -164,7 +164,7 @@ The 1.7B does that 0-2 times.
|
|||||||
|
|
||||||
> **Stale, corrected 2026-08-02.** The p50 figures in this table are contention on a
|
> **Stale, corrected 2026-08-02.** The p50 figures in this table are contention on a
|
||||||
> shared llama-server, not the model's cost. The router measures p50 825ms / p95 1.2s /
|
> shared llama-server, not the model's cost. The router measures p50 825ms / p95 1.2s /
|
||||||
> max 3.0s in `ROUTING-EVAL-31-07-2026.md`, which says so at line 61. Read this table for
|
> max 3.0s in `docs/evals/2026-07-31-routing.md`, which says so at line 61. Read this table for
|
||||||
> the shape of the tail only. Take absolute latency from the routing eval.
|
> the shape of the tail only. Take absolute latency from the routing eval.
|
||||||
|
|
||||||
| | p50 | p95 |
|
| | p50 | p95 |
|
||||||
@@ -220,11 +220,11 @@ swapped again when the CPT lands.
|
|||||||
behind `voice.llm_router`, the default is on, and `deploy/mavend.json` sets it `true`.
|
behind `voice.llm_router`, the default is on, and `deploy/mavend.json` sets it `true`.
|
||||||
These numbers are the production path now. **Corrected 2026-08-02: the p50 ≈2.7s in the
|
These numbers are the production path now. **Corrected 2026-08-02: the p50 ≈2.7s in the
|
||||||
latency table above WAS a bench artifact.** It is contention on the shared llama-server,
|
latency table above WAS a bench artifact.** It is contention on the shared llama-server,
|
||||||
not the model. `ROUTING-EVAL-31-07-2026.md` line 61 says so, and measures the router at
|
not the model. `docs/evals/2026-07-31-routing.md` line 61 says so, and measures the router at
|
||||||
p50 825ms / p95 1.2s / max 3.0s. Cite that file for latency, not this one.
|
p50 825ms / p95 1.2s / max 3.0s. Cite that file for latency, not this one.
|
||||||
- ~~`/mnt/hdd1/llms/LFM2.5/Qwen3-1.7B-UD-Q4_K_XL.gguf` is a 293 MB truncated download
|
- ~~`/mnt/hdd1/llms/LFM2.5/Qwen3-1.7B-UD-Q4_K_XL.gguf` is a 293 MB truncated download
|
||||||
in the wrong directory.~~ **Deleted 2026-07-31.** The good 1.13 GB copy in `qwen3/` is
|
in the wrong directory.~~ **Deleted 2026-07-31.** The good 1.13 GB copy in `qwen3/` is
|
||||||
what `deploy/mavend.json` loads.
|
what `deploy/mavend.json` loads.
|
||||||
- Harness: `scratchpad/bakeoff.sh`, one server at a time, health-checked before each
|
- Harness: `scratchpad/bakeoff.sh`, one server at a time, health-checked before each
|
||||||
run, `/v1/models` recorded per run. Never run two LLM consumers at once — see the
|
run, `/v1/models` recorded per run. Never run two LLM consumers at once — see the
|
||||||
contamination note in `TALK-EVAL-31-07-2026.md`.
|
contamination note in `docs/evals/2026-07-31-talk.md`.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Phrasing evaluation — 31-07-2026
|
# Phrasing evaluation — 31-07-2026
|
||||||
|
|
||||||
How Maven words a nudge, measured instead of argued. Counterpart to
|
How Maven words a nudge, measured instead of argued. Counterpart to
|
||||||
`ROUTING-EVAL-31-07-2026.md`.
|
`docs/evals/2026-07-31-routing.md`.
|
||||||
|
|
||||||
- Fixture + scorer: `internal/phraser/eval/` (`nudges_v1.json`, 15 cases; `eval.go`, `checks.go`)
|
- Fixture + scorer: `internal/phraser/eval/` (`nudges_v1.json`, 15 cases; `eval.go`, `checks.go`)
|
||||||
- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing`
|
- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing`
|
||||||
@@ -65,7 +65,7 @@ prefixes) is the targeted fix, and it would move findings 1 and 2 together. Sepa
|
|||||||
`model_quantized.onnx` — not the same file.
|
`model_quantized.onnx` — not the same file.
|
||||||
|
|
||||||
`hard` cases score **2/11**: every one is a query where the operator did not reuse his own words.
|
`hard` cases score **2/11**: every one is a query where the operator did not reuse his own words.
|
||||||
That is the normal case weeks later, and exactly what DESIGN.md's "recall when relevant" promises.
|
That is the normal case weeks later, and exactly what docs/design.md's "recall when relevant" promises.
|
||||||
|
|
||||||
### 4. The memory-store recall branch is dead for notes
|
### 4. The memory-store recall branch is dead for notes
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ was silent ("не знаю" to "который час") while the one it introdu
|
|||||||
|
|
||||||
### 1. The resident model does route better — 50.0% vs 36.8%
|
### 1. The resident model does route better — 50.0% vs 36.8%
|
||||||
|
|
||||||
REARCH.md's premise holds; `voice.go:211`'s comment does not. **But the classifier is only
|
docs/rearchitecture.md's premise holds; `voice.go:211`'s comment does not. **But the classifier is only
|
||||||
~37% correct on held-out utterances, and the model only ~50%.** Neither is "reliable". The
|
~37% correct on held-out utterances, and the model only ~50%.** Neither is "reliable". The
|
||||||
gap between them is real but both are far from a system you would describe as working.
|
gap between them is real but both are far from a system you would describe as working.
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ a model check, which catches a dead server but not a loaded one.
|
|||||||
measured) on this fixture and the router fixture. Not the 4B — too big for
|
measured) on this fixture and the router fixture. Not the 4B — too big for
|
||||||
this box, owner's call.
|
this box, owner's call.
|
||||||
- Newer sub-500M candidates (LFM2.5 200M/300M) are worth a run for routing.
|
- Newer sub-500M candidates (LFM2.5 200M/300M) are worth a run for routing.
|
||||||
Note `MODEL-BAKEOFF-31-07-2026.md` found LFM2.5-**1.2B** worse than
|
Note `docs/evals/2026-07-31-model-bakeoff.md` found LFM2.5-**1.2B** worse than
|
||||||
Qwen3.5-0.8B at Russian routing and 2.4× slower — but those are a different,
|
Qwen3.5-0.8B at Russian routing and 2.4× slower — but those are a different,
|
||||||
older generation, so that result does not predict the small ones.
|
older generation, so that result does not predict the small ones.
|
||||||
- Fix `chat-how-are-you`'s `want_any`, and re-baseline once, so `ontopic`
|
- Fix `chat-how-are-you`'s `want_any`, and re-baseline once, so `ontopic`
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# gemma-4-12b on the workstation, against the resident Qwen3-1.7B
|
||||||
|
|
||||||
|
Measured 2026-08-02 on the fixtures as they stand. Dated file: it is not edited
|
||||||
|
after today, and a newer number is a new file.
|
||||||
|
|
||||||
|
Vikunja #485's first assumption was that a 7-14B measurably beats Qwen3-1.7B on
|
||||||
|
the 77-case RU routing fixture and the 27-case talk fixture. It does, on both,
|
||||||
|
and it is also faster.
|
||||||
|
|
||||||
|
## The setup
|
||||||
|
|
||||||
|
`gemma-4-12B-it-qat-UD-Q4_K_XL` with the `mtp-gemma-4-12B-it-BF16` draft model,
|
||||||
|
served by `llama-server` b10220 on bugmachine (AMD 7900 GRE, 16GB), fronted by
|
||||||
|
`mavgpud` on `192.168.1.105:8080`. Thinking is off through
|
||||||
|
`--chat-template-kwargs '{"enable_thinking":false}'`, speculative decoding is
|
||||||
|
`--spec-type draft-mtp --spec-draft-n-max 2`, context 32768. The exact line is
|
||||||
|
`deploy/mavgpud.json`.
|
||||||
|
|
||||||
|
Every number below crossed the LAN from homesrv. Note the trap: homesrv's shell
|
||||||
|
exports `HTTP_PROXY`, Go honours it, and the runs need
|
||||||
|
`env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy`.
|
||||||
|
|
||||||
|
## Routing, 77-case RU fixture
|
||||||
|
|
||||||
|
| | full | intent-only | p50 | p95 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| classifier alone (02-08) | 68.8% | — | 16.6µs | — |
|
||||||
|
| Qwen3-1.7B through the cascade (31-07, 02-08) | 72.7% | 77.9% | 0.80-1.04s | — |
|
||||||
|
| **gemma-4-12b through the cascade** | **84.4%** | **93.5%** | **329ms** | 429ms |
|
||||||
|
| gemma-4-12b alone, no cascade | 55.8% | 85.7% | 335ms | 436ms |
|
||||||
|
|
||||||
|
The workstation buys 11.7 points of full accuracy over the resident model. It
|
||||||
|
buys 15.6 points of intent-only, at a third of the latency. The router's p50 was
|
||||||
|
never the model's fault, which the 02-08 contention finding already said. A 12B
|
||||||
|
on a free 16GB card answers a routing turn in a third of a second.
|
||||||
|
|
||||||
|
Two things the table hides.
|
||||||
|
|
||||||
|
The alone-versus-cascade gap is slots, not intents. gemma reads the intent right
|
||||||
|
85.7% of the time on its own. It loses full accuracy on seven fact keys
|
||||||
|
(`вода` instead of `water`, `ужин` instead of `meal`) and on six reminder times
|
||||||
|
with no time slot. Stage 0 and the daemon's own extractor repair
|
||||||
|
both, which is why the cascade is 28 points higher. The lesson is that the
|
||||||
|
cascade earns its keep even under a much better model, not that it is scaffolding
|
||||||
|
to remove.
|
||||||
|
|
||||||
|
`errors: 6` in the alone row are declines on single-token and ambiguous
|
||||||
|
utterances, all of which the cascade caught. The remaining defects through the
|
||||||
|
cascade are three `query→fact` confusions, one `chat→query`, and one false
|
||||||
|
clarify.
|
||||||
|
|
||||||
|
## Talk, 27-case conversational fixture
|
||||||
|
|
||||||
|
| | pass | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Qwen3-1.7B (31-07) | 20/27 | 11-17/27 for the 0.8B before it |
|
||||||
|
| **gemma-4-12b** | **25/27 (92.6%)** | chat 8/9, knowledge 9/9, query 8/9 |
|
||||||
|
|
||||||
|
Knowledge is the interesting column: 9/9, in Russian, with real answers about
|
||||||
|
Rayleigh scattering, SSD versus HDD and thunder delay. That is the case the
|
||||||
|
1.7B cannot do at all and the reason the naming half of the degradation rule
|
||||||
|
exists.
|
||||||
|
|
||||||
|
Two failures, and one of them is the persona defect the CPT (#122) targets:
|
||||||
|
`query-notes-do-not-answer` wrote `заплатил` where Maven needs the feminine
|
||||||
|
form. The other is `chat-joke`, where the model told a joke without using any of
|
||||||
|
the words the check looks for. Run-to-run variance is about one case: a second
|
||||||
|
run scored 24/27 with `chat-followup-server` also off-topic.
|
||||||
|
|
||||||
|
## Nudge phrasing, 15-case fixture
|
||||||
|
|
||||||
|
15/15, every check, no errors. `mood`, `lang`, `length`, `feminine`,
|
||||||
|
`hisgender`, `address`, `cringe` and `ontopic` all clean.
|
||||||
|
|
||||||
|
## What this settles and what it does not
|
||||||
|
|
||||||
|
Settled: the size question. A 12B on the workstation beats the resident model on
|
||||||
|
every fixture we have, and it is faster. The offload argument holds.
|
||||||
|
|
||||||
|
Not settled: how often the card is free. That is #485's second assumption and
|
||||||
|
only the `mavgpud` log answers it, after a week of the owner's normal work. A
|
||||||
|
model that is better whenever it is up is worth little if it is never up.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Where the resident model's 7.9GB of RSS goes (2026-08-03, homesrv)
|
||||||
|
|
||||||
|
Measured for Vikunja #499. The deployed llama-server held 7.9GB RSS for a 1.1GB
|
||||||
|
model file. Half a gigabyte of it was in swap, on a box that also runs
|
||||||
|
whisper.cpp, piper and the embedder.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
`maven-mavend-1` was stopped for the measurement, with the owner's approval.
|
||||||
|
Its own binary then ran on the host with the exact deployed command line. That
|
||||||
|
binary is `/opt/maven/bin/llama-server`, version `1 (4c65955)`, a Vulkan build.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
llama-server -m /mnt/hdd1/llms/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf \
|
||||||
|
--host 127.0.0.1 --port 18099 -c 4096 -ngl 99 --no-webui
|
||||||
|
```
|
||||||
|
|
||||||
|
RSS was read from `/proc/<pid>/status` after load and after each of 8 distinct
|
||||||
|
1521-token prompts. `smaps` of the deployed process was read first, from inside
|
||||||
|
the container, since the host user cannot read another user's maps.
|
||||||
|
|
||||||
|
## The cause: the prompt cache, not the weights and not the offload
|
||||||
|
|
||||||
|
The startup log says it outright:
|
||||||
|
|
||||||
|
```text
|
||||||
|
srv load_model: prompt cache is enabled, size limit: 8192 MiB
|
||||||
|
srv llama_server: n_parallel is set to auto, using n_parallel = 4 and kv_unified = true
|
||||||
|
```
|
||||||
|
|
||||||
|
The server saves the full KV state of every idle slot it evicts. It keeps up to
|
||||||
|
8GiB of those states in host RAM (llama.cpp PR 16391). One saved prompt of 1521
|
||||||
|
tokens costs 166.377 MiB. That is 112 kiB per token, exactly Qwen3-1.7B's KV
|
||||||
|
footprint (28 layers x 2 x 1024 dims x 2 bytes).
|
||||||
|
|
||||||
|
RSS at rest, and per distinct prompt:
|
||||||
|
|
||||||
|
| Prompts served | RSS, default | RSS, `--cache-ram 512` |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 (just loaded) | 443 MB | 411 MB |
|
||||||
|
| 1 | 445 MB | 411 MB |
|
||||||
|
| 4 | 958 MB | 929 MB |
|
||||||
|
| 8 | 1641 MB | 932 MB |
|
||||||
|
|
||||||
|
Uncapped, RSS climbs about 170MB per distinct prompt and does not stop until
|
||||||
|
the 8GiB limit. Capped at 512 MiB it plateaus at 932MB from the fourth prompt
|
||||||
|
on, with the cache holding steady at `3 prompts, 499.132 MiB` and evicting.
|
||||||
|
|
||||||
|
The 7.9GB on the running daemon was that climb, weeks of it. Its `smaps` showed
|
||||||
|
one 6.03GB anonymous mapping at 5.32GB resident plus a 1.45GB mapping at 1.27GB
|
||||||
|
resident, and only 30MB of file-backed RSS.
|
||||||
|
|
||||||
|
## The task's leading guess was wrong
|
||||||
|
|
||||||
|
`-ngl 99` on the Vega iGPU costs almost no process RSS. A freshly loaded server
|
||||||
|
has 95MB of anonymous RSS in total. RADV allocates device memory through the
|
||||||
|
kernel, outside the process, and the log sees 8202 MiB free on `Vulkan0`. The
|
||||||
|
weights are mmapped and file-backed, so they are evictable and do not pin RSS. The logit buffer is not visible in the numbers above at all.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`--cache-ram 512` is now the default, wired as `phraser.cache_ram_mib` and set
|
||||||
|
in `deploy/mavend.json`. 512 MiB caps total RSS near 1GB, an eighth of what the
|
||||||
|
box carried. It still holds three of the 1521-token probes above. Maven's real
|
||||||
|
routing and phrasing prompts are much shorter, so it holds more of those than
|
||||||
|
the table suggests. `-c 4096` is untouched, as #499
|
||||||
|
required. A negative `cache_ram_mib` passes no flag, for a llama-server too old
|
||||||
|
to know it.
|
||||||
|
|
||||||
|
Not changed: `n_parallel = 4`. With `kv_unified = true` the four slots share one
|
||||||
|
4096-token KV cache, so they do not multiply it.
|
||||||
|
|
||||||
|
The other half of #499 was that none of these lines were reachable. mavend
|
||||||
|
scraped llama-server's stderr for the listen line and discarded it, and never
|
||||||
|
piped stdout at all. Both streams now go to mavend's log with a `llama:` prefix.
|
||||||
|
The last 12 startup lines go into the error when the server dies before it
|
||||||
|
listens.
|
||||||
|
|
||||||
|
## Deployed
|
||||||
|
|
||||||
|
The `mavenai:latest` image was rebuilt and `maven-mavend-1` recreated the same
|
||||||
|
day. The daemon's own log now carries the child's startup, it reads
|
||||||
|
`prompt cache is enabled, size limit: 512 MiB`, and the resident server sat at
|
||||||
|
439MB RSS after load and 613MB after one served turn.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Personal boundary, seed scoring vs possession markers, 2026-08-03
|
||||||
|
|
||||||
|
Vikunja #495. `что я говорил про бэкапы?` walked past the personal boundary into
|
||||||
|
SearXNG and came back answered from a Habr article. The boundary matched
|
||||||
|
possession words only, so a first-person speech verb was not a personal
|
||||||
|
question.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The boundary now scores the turn's query vector against two frozen seed sets.
|
||||||
|
It claims the turn when the personal side is nearer than the world side. Seeds
|
||||||
|
and code are in `cmd/mavend/personalboundary.go`. The possession markers stay as
|
||||||
|
the offline floor for a handler with no embedder.
|
||||||
|
|
||||||
|
A regex speech class was written first and dropped. Russian gives every verb a
|
||||||
|
dozen surface forms, and the "как я говорил, ..." preamble list has no end. Each
|
||||||
|
form the lexicon missed was one more question reaching the world.
|
||||||
|
|
||||||
|
## Measurement
|
||||||
|
|
||||||
|
Embedder: multilingual-e5-small int8, the one homesrv runs. Both sides are
|
||||||
|
embedded on the query side. Cases are held out, none of them a seed. `make test`
|
||||||
|
runs the offline part. The scored part is opt-in through `MAVEN_ONNX_LIB`, like
|
||||||
|
`TestONNXRecall`.
|
||||||
|
|
||||||
|
19/19 held-out utterances correct (TestONNXPersonalBoundary)
|
||||||
|
|
||||||
|
true positive margins +0.014 to +0.089
|
||||||
|
nearest true negative -0.005 ("кто такой гагарин")
|
||||||
|
|
||||||
|
One case missed during the first pass and is not held out any more: `as i said,
|
||||||
|
what is the population of india`, +0.008 to the personal side. It is a world seed
|
||||||
|
now.
|
||||||
|
|
||||||
|
The gate is the sign of the difference and nothing tighter. The margins are too
|
||||||
|
thin for a threshold. The asymmetry favours claiming: a false claim costs one
|
||||||
|
honest "не знаю", a false pass sends his life to an upstream engine.
|
||||||
|
|
||||||
|
`make eval-recall` unchanged, 18/27 answered at gate 0.55. Recall does not touch
|
||||||
|
this path.
|
||||||
|
|
||||||
|
## Not verified
|
||||||
|
|
||||||
|
The live probe on the deployed box. The daemon was not rebuilt in this session.
|
||||||
|
The reply to `что я говорил про бэкапы?` with no matching note is still untested
|
||||||
|
against a real SearXNG.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Recall topic veto, what it costs and what it buys, 2026-08-03
|
||||||
|
|
||||||
|
Vikunja #496. The task asked for a cross-language fix. Skip the topic veto in
|
||||||
|
`memory.RecallAllowed` when the question and the hit are in different scripts.
|
||||||
|
An English question would then stop losing a Russian note.
|
||||||
|
|
||||||
|
No such case exists. No fixture case puts the question and its wanted note in
|
||||||
|
different scripts. The case the task named is not one either.
|
||||||
|
|
||||||
|
en-hard-024
|
||||||
|
query "what fixed the screen problem"
|
||||||
|
note "the flicker went away once i swapped the display cable"
|
||||||
|
|
||||||
|
Both are English. It is a paraphrase failure, not a language failure. A script
|
||||||
|
test would not have changed a single case, and neither would a bilingual stem
|
||||||
|
map.
|
||||||
|
|
||||||
|
## What the veto is worth today
|
||||||
|
|
||||||
|
Measured with the real embedder, multilingual-e5-small int8, gate 0.55, margin
|
||||||
|
0.008. The first row is the veto as it ships. The second is `RecallAllowed`
|
||||||
|
forced to true.
|
||||||
|
|
||||||
|
| | cases passing | answered | false recall | silenced by gate |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| veto on | 22/32 | 17/27 | 0/5 | 2 |
|
||||||
|
| veto off | 22/32 | 18/27 | 1/5 | 1 |
|
||||||
|
|
||||||
|
The pass count does not move. The veto trades one true recall for one false one.
|
||||||
|
It costs `en-hard-024` and it buys `ru-silent-029`:
|
||||||
|
|
||||||
|
ru-silent-029
|
||||||
|
query "во сколько отходит поезд"
|
||||||
|
note "погулял вдоль реки" 0.835, margin 0.019
|
||||||
|
|
||||||
|
The second case counted as silenced by the gate is `ru-home-026` at margin
|
||||||
|
0.001, which the margin gate stops. The veto has nothing to do with it.
|
||||||
|
|
||||||
|
## Why no lexical rule separates the two
|
||||||
|
|
||||||
|
`en-hard-024` and `ru-silent-029` are in the same lexical class. Both questions
|
||||||
|
share zero content words with their hit, and neither carries a first-person
|
||||||
|
marker. The scores sit on top of each other, 0.826 against 0.835, and so do the
|
||||||
|
margins, 0.023 against 0.019. Only one thing separates them. A screen problem
|
||||||
|
and a swapped display cable are the same event. A train and a river walk are
|
||||||
|
not. The embedder scores that difference at nine thousandths.
|
||||||
|
|
||||||
|
So the signal is semantic and the gate is lexical. Any rule cheap enough to sit
|
||||||
|
in `RecallAllowed` and strong enough to recover `en-hard-024` also re-admits
|
||||||
|
`ru-silent-029`, which puts false recall back to 1/5.
|
||||||
|
|
||||||
|
One near-miss rule was tried on paper and rejected: let the veto pass when the
|
||||||
|
hit itself is first person. It works on these two, because the English note says
|
||||||
|
"i swapped" and the Russian note says only "погулял". It is backwards as a
|
||||||
|
principle. A first-person note is exactly the personal note the veto keeps away
|
||||||
|
from a world question. The rule would weaken the veto where it was designed to
|
||||||
|
bite. It survives here only because Russian drops the pronoun.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Accept the loss. `en-hard-024` stays silenced and false recall stays 0/5.
|
||||||
|
|
||||||
|
The way out is a reranker, not a longer word list. Recall@3 is 85.2% against
|
||||||
|
recall@1 at 70.4%, so the right note is usually in the returned set and ranked
|
||||||
|
wrong. That is where the remaining points are, and it is not this task.
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
# Offloading model work to the workstation
|
||||||
|
|
||||||
|
*Last verified: 2026-08-03 @ 12530c8. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
|
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
|
||||||
|
work, and this file holds the shape and the rules all four must obey.
|
||||||
|
|
||||||
|
## The goal
|
||||||
|
|
||||||
|
homesrv cannot grow a GPU. The workstation has 16GB of VRAM. Move the model work
|
||||||
|
to the workstation and leave homesrv running the logic that must be always-on,
|
||||||
|
deterministic and cheap.
|
||||||
|
|
||||||
|
## Why this is tractable
|
||||||
|
|
||||||
|
The split already exists structurally. `mavsttd` and `mavttsd` are separate
|
||||||
|
daemons that core reaches over a socket, not linked libraries. Moving them off-box
|
||||||
|
is a transport change, not a redesign.
|
||||||
|
|
||||||
|
The microphone is at the workstation, because that is where the owner sits and
|
||||||
|
homesrv is headless. So speech-to-text and the wake word are already on the
|
||||||
|
workstation side by construction. Audio never has to cross the LAN. Only the core
|
||||||
|
turn does.
|
||||||
|
|
||||||
|
## The constraint that shapes everything
|
||||||
|
|
||||||
|
The workstation's GPU is often busy: CPT runs, experiments, Correx, the manga-recap
|
||||||
|
pipeline. It also sleeps. homesrv does not.
|
||||||
|
|
||||||
|
So an offloaded model is never *the* model. It is the preferred one, with a floor
|
||||||
|
on homesrv. That is the shape the cascade already has, where a router error falls
|
||||||
|
through to the classifier.
|
||||||
|
|
||||||
|
## The degradation rule
|
||||||
|
|
||||||
|
Two cases, and the line between them is sharp.
|
||||||
|
|
||||||
|
**Fall back silently** when the workstation model would only do the job *better*:
|
||||||
|
routing, phrasing, a nudge. Falling back costs nothing that exists today, because
|
||||||
|
the resident Qwen3-1.7B is today's production quality. The owner should not be told
|
||||||
|
that his reply was phrased by the smaller model.
|
||||||
|
|
||||||
|
**Name the gap** when the resident model cannot do the job *at all*. A world
|
||||||
|
question that a 1.7B answers by inventing is the case. A wrong answer is worse
|
||||||
|
than "не могу сейчас". This is the rule CLAUDE.md already states for a sibling
|
||||||
|
service being down.
|
||||||
|
|
||||||
|
Nothing in between. A turn never breaks on the workstation being asleep.
|
||||||
|
|
||||||
|
Both halves are wired, 03-08-2026. `LLMPhraser.PhraseWorld`
|
||||||
|
(`internal/phraser/world.go`) is the naming half and has three outcomes, not two:
|
||||||
|
|
||||||
|
| State | What he hears |
|
||||||
|
|---|---|
|
||||||
|
| no `workstation` block | the resident model answers, exactly as before the seam existed |
|
||||||
|
| configured, card free | the workstation answers |
|
||||||
|
| configured, asleep or busy | the gap, `worldGap` in `cmd/mavend/worldmodel.go` |
|
||||||
|
|
||||||
|
The first row is the one worth stating. Naming a gap requires a gap. On a box with
|
||||||
|
no second model the 1.7B is the whole product. Refusing every world question there
|
||||||
|
would remove a capability the owner has today.
|
||||||
|
|
||||||
|
A source holding a passage is on the naming half too: a live search, a ZIM
|
||||||
|
article, a page he named. None of them says "не могу сейчас". They read the
|
||||||
|
passage back, which is what `phraseSource` returning `""` selects. A real quote
|
||||||
|
beats a gap, and neither path invents.
|
||||||
|
|
||||||
|
## Admission control, not a scheduler
|
||||||
|
|
||||||
|
There is no GPU arbiter. That is a service with its own failure modes, and nothing
|
||||||
|
here needs work *distributed*. It needs admission control. The workstation
|
||||||
|
advertises free VRAM over a health endpoint, and Maven treats it as one more query
|
||||||
|
source that claims a turn or passes. llama-server also refuses to load when VRAM is
|
||||||
|
short, so the failure is detectable without cooperation from the owner's other
|
||||||
|
jobs.
|
||||||
|
|
||||||
|
The caller must be able to ask "is this peer usable right now" without a turn
|
||||||
|
hanging on a timeout. A dead remote is a normal state, not an error state.
|
||||||
|
`internal/llm.Pair` is that check on the Maven side. A prober caches the answer,
|
||||||
|
so `Available()` is an atomic read and no turn pays for a health check.
|
||||||
|
|
||||||
|
llama-server does not stay up on the workstation. It cannot: a resident 7-14B
|
||||||
|
would hold 16GB against the owner's CPT runs. So a supervisor there owns its
|
||||||
|
lifecycle, keeps it loaded while the card is free, and unloads it on idle or
|
||||||
|
when another process needs the card (owner's call, 2026-08-02, Vikunja #488).
|
||||||
|
|
||||||
|
That supervisor is still not a scheduler, and the distinction is worth holding.
|
||||||
|
It arbitrates nothing between callers. It reports whether it can take work and
|
||||||
|
manages one process to back that answer. Maven never asks it to start anything
|
||||||
|
and never learns that it did.
|
||||||
|
|
||||||
|
Contention is decided by presence under `/sys/class/kfd/kfd/proc`, not by a VRAM
|
||||||
|
threshold. A ROCm process registers there when it initialises HIP, before it
|
||||||
|
allocates anything. So the supervisor sees a contender during that job's startup,
|
||||||
|
and yields before the job loses the memory it asked for. A
|
||||||
|
threshold reads the card too late. By the time free VRAM has dropped, the other
|
||||||
|
job has already lost the allocation race. Free VRAM is still read, but only as a
|
||||||
|
precondition for loading, never as the eviction signal. One blind spot is known.
|
||||||
|
A job can take the card without registering on the KFD, as a Vulkan or a
|
||||||
|
video-decode job would. `describe()` logs every contender's comm, and that log is
|
||||||
|
how we find out whether the blind spot is real.
|
||||||
|
|
||||||
|
`mavgpud` runs from a systemd unit on the workstation with
|
||||||
|
`deploy/mavgpud.json` as its config, and `llama_args` is passed to llama-server
|
||||||
|
untouched. The model, the context size, the layer count and the MTP flags are the
|
||||||
|
owner's business and not this daemon's schema.
|
||||||
|
|
||||||
|
## What stays on homesrv, permanently
|
||||||
|
|
||||||
|
The **embedder** (multilingual-e5-small, ONNX, CPU). It backs the classifier, which
|
||||||
|
must answer while the GPU is saturated. It is also cheap enough on CPU that moving
|
||||||
|
it buys nothing. Four callers:
|
||||||
|
|
||||||
|
| Caller | What for |
|
||||||
|
|---|---|
|
||||||
|
| `internal/router/classifier.go` | the routing floor |
|
||||||
|
| `cmd/mavend/actions_query.go` (`queryEmbed`) | memory recall |
|
||||||
|
| `cmd/mavend/feeds.go` | ingest embedding for every RSS item |
|
||||||
|
| `internal/crawl/watch.go` | ingest embedding for every crawled page |
|
||||||
|
|
||||||
|
`internal/speaker` becomes a fifth once it lands.
|
||||||
|
|
||||||
|
## Inventory: what runs a model on homesrv today
|
||||||
|
|
||||||
|
The **resident model** is one llama-server with seven callers, and 03-08-2026 is
|
||||||
|
the date each of them stopped or did not stop being resident-only:
|
||||||
|
|
||||||
|
| Caller | What for | Offloaded |
|
||||||
|
|---|---|---|
|
||||||
|
| `cmd/mavend/voicewire.go` | routing | silently, through `hot` |
|
||||||
|
| `cmd/mavend/replier_llm.go` | replies | silently, through `hot` |
|
||||||
|
| `cmd/mavend/tick.go` | digestion worker: `PhraseNudge`, `PhraseReminder` | silently, inside the phraser |
|
||||||
|
| `cmd/mavend/actions_query.go` | world questions, and any fetched passage | names the gap |
|
||||||
|
| `cmd/mavend/capture.go` | capture summarisation (unreachable, see #480) | no, holds its own client |
|
||||||
|
| `cmd/mavend/mail.go` | mail extraction (off, no IMAP) | no, holds its own client |
|
||||||
|
| `memoryeval.go`, `modelswap.go` | admin and evals | no, and deliberately |
|
||||||
|
|
||||||
|
The last three rows are resident-only on purpose. `memoryeval.go` and
|
||||||
|
`modelswap.go` measure and swap the resident model, so sending their work
|
||||||
|
elsewhere would measure the wrong thing. `capture.go` and `mail.go` are
|
||||||
|
background jobs that hold a gated background client (`llmBackgroundClientFor`),
|
||||||
|
and that priority has no equivalent on the remote yet. Both are also unreachable
|
||||||
|
on this deploy, so wiring them would ship an untestable path.
|
||||||
|
|
||||||
|
The `tick.go` row needs one caveat. `phraser.llm_nudges` is `false` in deploy, so
|
||||||
|
nudges come from templates and the seam under them changes nothing until that
|
||||||
|
flips. It is wired anyway: `PhraseReminder` is on the same transport and is on.
|
||||||
|
|
||||||
|
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
|
||||||
|
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
|
||||||
|
|
||||||
|
## Order
|
||||||
|
|
||||||
|
1. **Transport** (#484). Nothing else is possible until a seam can cross a host.
|
||||||
|
`internal/netaddr` landed in PR #92. A seam address now carries its own scheme,
|
||||||
|
and a scheme-less one is still unix. A tcp seam requires a shared token, because
|
||||||
|
the filesystem permission that authenticated the unix socket is gone.
|
||||||
|
2. **The resident model** (#485, #490). Wired. A `workstation` block builds an
|
||||||
|
`llm.Pair` in `modelSeam` (`cmd/mavend/voicewire.go`), routing and replies
|
||||||
|
complete through it, and the phraser holds the same pair (`UseRemote`). Both
|
||||||
|
halves of the rule are live: see the table above for which caller gets which.
|
||||||
|
Measured, `docs/evals/2026-08-02-workstation-gemma4-12b.md`: gemma-4-12b
|
||||||
|
through the cascade scores 84.4% full accuracy at p50 329ms. The resident
|
||||||
|
model scores 72.7% at p50 0.80-1.04s. On the talk fixture it is 25/27
|
||||||
|
against 20/27. Biggest quality delta. A 16GB card runs a 7-14B,
|
||||||
|
which fixes what the 1.7B gets wrong: world knowledge, and the persona the CPT
|
||||||
|
targets. The degradation path is already written and measured, since the
|
||||||
|
classifier scores 68.8% full accuracy at p50 16.6µs on its own.
|
||||||
|
3. **Speech-to-text and text-to-speech** (#486). They gain a real margin, but on
|
||||||
|
quality alone, and both already work.
|
||||||
|
4. **The wake word** (#487). Independent of all of the above.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The LAN is trusted enough that wireguard is supported but not required (owner's
|
||||||
|
call). What crosses the wire is still his utterances. That is why the tcp seam
|
||||||
|
carries its own token instead of assuming a network boundary.
|
||||||
|
- The workstation is not expected to be up. Every child task must still serve a
|
||||||
|
turn while it is down.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Start Commands
|
# Start Commands
|
||||||
|
|
||||||
|
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`.
|
All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
> use the embedded LFM model paths or old single-object examples as current ops
|
> use the embedded LFM model paths or old single-object examples as current ops
|
||||||
> guidance; see `2026-07-18-qwen3-resident-training-eval.md`.
|
> guidance; see `2026-07-18-qwen3-resident-training-eval.md`.
|
||||||
|
|
||||||
> Scope from `REARCH.md`. Make Maven trustworthy: the LFM becomes the router
|
> Scope from `docs/rearchitecture.md`. Make Maven trustworthy: the LFM becomes the router
|
||||||
> (fixes "messes up queries" / "doesn't take notes"), the engine actually runs
|
> (fixes "messes up queries" / "doesn't take notes"), the engine actually runs
|
||||||
> (fixes stub replies), dates stop being read as "number dot number dot number",
|
> (fixes stub replies), dates stop being read as "number dot number dot number",
|
||||||
> and telegram becomes a reach channel. NOT in scope: on-demand 4B reasoner,
|
> and telegram becomes a reach channel. NOT in scope: on-demand 4B reasoner,
|
||||||
@@ -693,7 +693,7 @@ ssh kami@192.168.1.104 'curl -s localhost:9201/api/chat -d "{\"text\":\"запо
|
|||||||
4. `docker compose up -d mavend && docker logs -f maven-mavend-1` — confirm the
|
4. `docker compose up -d mavend && docker logs -f maven-mavend-1` — confirm the
|
||||||
phraser spawns and no `phraser: NewStub` path. Run the two verify curls.
|
phraser spawns and no `phraser: NewStub` path. Run the two verify curls.
|
||||||
5. Update `AGENTS.md`: LFM model download + note that routing is now LFM-first
|
5. Update `AGENTS.md`: LFM model download + note that routing is now LFM-first
|
||||||
with classifier fallback (`REARCH.md` is the design of record).
|
with classifier fallback (`docs/rearchitecture.md` is the design of record).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Maven Voice Protocol
|
# Maven Voice Protocol
|
||||||
|
|
||||||
|
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
> Auto-generated from `internal/voice/wire.go`, `internal/voice/errors.go`,
|
> Auto-generated from `internal/voice/wire.go`, `internal/voice/errors.go`,
|
||||||
> `internal/voice/frame.go`, `internal/voice/client.go`. If this file and
|
> `internal/voice/frame.go`, `internal/voice/client.go`. If this file and
|
||||||
> those files disagree, the code wins.
|
> those files disagree, the code wins.
|
||||||
+566
@@ -0,0 +1,566 @@
|
|||||||
|
# QA plan: checking Maven properly
|
||||||
|
|
||||||
|
*Last verified: 2026-08-02 @ 20aa2d5. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
|
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
||||||
|
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
|
||||||
|
|
||||||
|
42 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
|
||||||
|
build work. Most sat unverifiable while Maven was down for 11 days. That
|
||||||
|
blocker is gone.
|
||||||
|
|
||||||
|
The plan as written on 2026-08-01 named 40 task numbers. Ten open `QA:` tasks were
|
||||||
|
missing and two of the named ones had closed. Every open task now appears below,
|
||||||
|
the eight non-QA ones in the last two sections.
|
||||||
|
|
||||||
|
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
|
||||||
|
downstream assumes the voice loop works, and nobody has confirmed that since
|
||||||
|
the redeploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the 02-08-2026 run found
|
||||||
|
|
||||||
|
Sessions 1, 2 and 3 all ran. Read these five before picking anything up.
|
||||||
|
|
||||||
|
- **470: a question writes invented knowledge into memory.** Recall then serves
|
||||||
|
it back. `что дальше?` lands on `IntentFact` and stores the model's answer as a
|
||||||
|
`self` fact at confidence 1.00. Two junk rows then claimed seven unrelated
|
||||||
|
world questions through recall, outranking the search leg. A question about the
|
||||||
|
capital of Australia was answered `какая последняя версия языка Go?`. Two bad
|
||||||
|
writes silently disabled world answering, with nothing logged.
|
||||||
|
- **466: a pending clarify is global.** One unanswerable clarify swallowed the
|
||||||
|
next three utterances from three separate sessions. With ntfy, telegram and
|
||||||
|
voice all live, a clarify raised on web chat eats the next telegram message.
|
||||||
|
- **467: spoken task capture is dead.** The router calls the capture marker an
|
||||||
|
`act`, and capture is reachable only from the `note` intent.
|
||||||
|
- **The classifier baseline in this repo was wrong**, and it flattered the
|
||||||
|
router. See session 2 and **464**.
|
||||||
|
- **477: the model swap and the self-update cannot be triggered on this box.**
|
||||||
|
Both are built and both are correct in test. The swap needs a passkey and
|
||||||
|
WebAuthn is unconfigured. `mavupdate` needs to reach a socket that only an
|
||||||
|
in-container uid can open.
|
||||||
|
|
||||||
|
- **479: an unconfigured capability lets the question escape to web search.**
|
||||||
|
Netscan off, asked `какие устройства в сети?`. She answered from the live web
|
||||||
|
with a general article about network hardware. A question about his LAN went to
|
||||||
|
an upstream engine. The crawler fails the same way.
|
||||||
|
|
||||||
|
Twenty-one defects were filed on 02-08-2026: 462 through 482. Six tasks this plan
|
||||||
|
had written off as blocked turned out to be ready to check. All six ran. Every
|
||||||
|
one of them is code-correct and stops at the deploy.
|
||||||
|
|
||||||
|
Three of the five config blockers in **472** were then cleared. The morning
|
||||||
|
routine, ambient ingest, feeds, the crawler and netscan are all live. Two remain,
|
||||||
|
and both are the owner's call: a token for each ecosystem sibling, and seed data
|
||||||
|
in Nexus and Praxis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
Two things bite anyone running these checks on homesrv.
|
||||||
|
|
||||||
|
**curl needs `--noproxy '*'`.** The shell exports `http_proxy=http://127.0.0.1:18080`.
|
||||||
|
Without the flag, every local check returns 503 from the proxy and looks like a
|
||||||
|
dead service. This cost me a false regression report today.
|
||||||
|
|
||||||
|
**The database is not readable with sqlite3.** Four older QA steps say
|
||||||
|
`docker compose exec mavend sqlite3 /data/maven.db "select ..."`. That cannot
|
||||||
|
work: the container has no `sqlite3` binary, and the store is AES-256-GCM at
|
||||||
|
rest with a tmpfs working copy. Read state through mavweb instead, at
|
||||||
|
`/history`, `/trace`, `/routines` and `/dash`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 1: the voice loop (half a day)
|
||||||
|
|
||||||
|
Nothing here has been confirmed since the redeploy, and everything else assumes
|
||||||
|
it works. Do this first.
|
||||||
|
|
||||||
|
Closes or advances: **44** (conversation), **45** (text chat), **287** (voice
|
||||||
|
session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
|
||||||
|
|
||||||
|
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
|
||||||
|
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
|
||||||
|
|
||||||
|
Steps 1 and 3-6 were run on 02-08-2026 and pass. Steps 2 and 7-9 still need a
|
||||||
|
person at the box, because they need a microphone or a nudge to arrive.
|
||||||
|
|
||||||
|
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
|
||||||
|
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s --noproxy '*' -c jar -b jar -L -X POST \
|
||||||
|
http://127.0.0.1:9201/api/chat --data-urlencode 'text=привет'
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse the whole page, not the last text node. The page carries nav and footer
|
||||||
|
text. A naive tail of the Cyrillic nodes returns the wrong string, which makes
|
||||||
|
turns look misaligned when they are not.
|
||||||
|
|
||||||
|
1. Open `http://127.0.0.1:9201/chat` and hold a short conversation in Russian.
|
||||||
|
Watch for three things: she answers in feminine forms (`рада`, `поняла`), she
|
||||||
|
says `ты` and never `вы`, and no pet names appear.
|
||||||
|
**Passes** (02-08-2026, five turns): `я рада`, `поняла`, `помогла`,
|
||||||
|
`проверила`, `записала`, `грустна`, `ты` throughout, no pet names.
|
||||||
|
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
|
||||||
|
back. This is the only check that covers mic to STT to core to TTS to
|
||||||
|
speaker as one path. It is also the path the eleven-day outage most likely
|
||||||
|
broke.
|
||||||
|
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
|
||||||
|
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
|
||||||
|
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
||||||
|
`quiet_hours` fact was written. **Passes**: no row written. She answers `пока
|
||||||
|
не умею отвечать на этот вопрос.`, so it lands on `IntentSystem` with no arm.
|
||||||
|
6. Say `включи режим тишины`, then `сделай потише`. Both must flip quiet mode
|
||||||
|
on. These are the noun form and the comparative, added 01-08-2026. **Both pass.**
|
||||||
|
7. Wait for a nudge, then say `потом` within twenty minutes. Expect `хорошо,
|
||||||
|
вернусь к этому позже.` and the nudge row on `/notifications` reading
|
||||||
|
`snoozed`. Say `потом` again with nothing pending: it must route as an
|
||||||
|
ordinary utterance, not be swallowed.
|
||||||
|
8. Wait for the water nudge, then say `выпил воды`. Expect the ordinary fact
|
||||||
|
reply and nothing extra. She must not congratulate you. Check
|
||||||
|
`/notifications`: the row reads `acted`. Then trigger another nudge and say
|
||||||
|
`готово`. Expect `отлично, отметила.` and the same outcome.
|
||||||
|
9. Note anything where she is slow, cuts off, or talks over herself. That is
|
||||||
|
287's whole content and it has no written acceptance criteria yet.
|
||||||
|
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
|
||||||
|
and stitch unrelated topics. Asked whether he should move flats, she opened
|
||||||
|
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
|
||||||
|
|
||||||
|
**The wake path cannot be checked as deployed.** `mavwaked` and `mavenclient`
|
||||||
|
appear in no compose file and run as no host process. Step 2 covers only
|
||||||
|
push-to-talk, from `/dash` through mavsttd and mavttsd. Wake word and VAD
|
||||||
|
are untested by construction. Decide whether they belong in compose or on a
|
||||||
|
client machine, and say which in the deploy docs. Tracked as **463**.
|
||||||
|
|
||||||
|
**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
|
||||||
|
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
|
||||||
|
both pass now: `thinSingleToken` spares social singles and any token carrying a
|
||||||
|
verb ending, and only thins a bare nominal like `вода`. A one-word utterance that
|
||||||
|
still gets clarified in this session is a new case for the lexicon, not the old bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 2: measurement (half a day, mostly waiting)
|
||||||
|
|
||||||
|
Closes or advances: **320** items 2-4, **278** (make the eval lab routine).
|
||||||
|
Also **248** (memory evaluation), **319** (the margin gate) and **323** (the
|
||||||
|
startup timeout arm).
|
||||||
|
|
||||||
|
The resident llama-server cannot be reached by the eval harness. It binds
|
||||||
|
`--host 127.0.0.1 --port 0` inside the container, so the port is kernel-assigned
|
||||||
|
and never published. Start a second one on a fixed port instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
llama-server -m /mnt/hdd1/llms/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf \
|
||||||
|
--host 127.0.0.1 --port 18100 -c 4096 -ngl 99 --no-webui
|
||||||
|
```
|
||||||
|
|
||||||
|
`-c 4096` matters. The recorded numbers were measured at that context size, and
|
||||||
|
a mismatch invalidates the comparison.
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make eval-models MAVEN_LLM_URL=http://127.0.0.1:18100 # want ~72.7% cascade
|
||||||
|
make eval-router # classifier baseline
|
||||||
|
MAVEN_LLM_URL=http://127.0.0.1:18100 make eval-phrasing # persona checks, slow
|
||||||
|
make eval-recall
|
||||||
|
```
|
||||||
|
|
||||||
|
A large miss against 72.7% means the deploy differs from the bench harness.
|
||||||
|
|
||||||
|
**Run on 02-08-2026 @ af9d213. The deploy matches the bench.** `eval-models`
|
||||||
|
scored 56 of 77: 72.7% full, 77.9% intent-only, 2 false clarifies and 1 missed.
|
||||||
|
That is the recorded figure to the decimal, and calendar sat at 2 of 2, so the
|
||||||
|
stage 0 agenda rules hold. `eval-phrasing` scored 21 of 27 on the talk fixture
|
||||||
|
against a recorded 20, and the 15 nudge templates passed every check.
|
||||||
|
|
||||||
|
Two numbers in this repo were wrong, and both flattered the resident model.
|
||||||
|
|
||||||
|
- **The classifier is not 36.8% and not 31ms.** `make eval-router` reports
|
||||||
|
`classifier+onnx: 53/77 (68.8% full)` at p50 16.6µs. The figure repeated here
|
||||||
|
and in `CLAUDE.md` predates the stage 0 rules and the seed additions. Both now
|
||||||
|
score inside that baseline. The accuracy gap the router buys is
|
||||||
|
roughly 4 points, not 36. Re-argue the trade on the real numbers: **464**.
|
||||||
|
- **Router latency was measured under contention again.** p50 1.126s, p95 1.58s,
|
||||||
|
max 3.24s, against a recorded p50 825ms. The resident model was serving the
|
||||||
|
daemon on the same iGPU throughout. Do not record this as a regression, and do
|
||||||
|
not record it as a measurement either. Stop the stack before timing the router.
|
||||||
|
|
||||||
|
`classifier+hash` scores 19.5%, which is the no-ONNX degraded path and is not the
|
||||||
|
failure floor the deploy uses. Do not quote it as the classifier baseline.
|
||||||
|
|
||||||
|
Then three things to decide while the numbers are in front of you:
|
||||||
|
|
||||||
|
- **319 is done.** 359 gave the LLM path a real confidence signal.
|
||||||
|
`thinSingleToken` was narrowed on 01-08-2026, and agenda questions moved to
|
||||||
|
stage 0. Missed clarify sits at 1 of 6 and false clarifies at 2. Item 2 point 2
|
||||||
|
closed on 02-08-2026: the `make eval-recall` margin sweep is the distribution
|
||||||
|
that was asked for, and `0.008` sits at the knee.
|
||||||
|
|
||||||
|
| delta | answered | false recall |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.005 | 18/27 | 2/5 |
|
||||||
|
| **0.008** | **18/27** | **1/5** |
|
||||||
|
| 0.010 | 16/27 | 1/5 |
|
||||||
|
|
||||||
|
It removes four of five false recalls at no cost in answers, and the next step
|
||||||
|
costs two answers for nothing. The hand-picked value survives on evidence.
|
||||||
|
- **278's real ask** is making the eval lab routine rather than building it. It
|
||||||
|
is built. Decide whether it runs on a timer, on every merge, or on demand, and
|
||||||
|
the task can close.
|
||||||
|
- **248** is the memory evaluation loop. It ships, it writes notes, and it cannot
|
||||||
|
speak. `make eval-recall` covers the retrieval half. The open question is whether
|
||||||
|
a written evaluation nobody reads is worth the tick.
|
||||||
|
|
||||||
|
**323 is down to one check.** PR #90 covered the spawn path and took phraser
|
||||||
|
coverage to 76.9%. Only the 60s startup timeout arm is untested, because testing it
|
||||||
|
needs a `StartupTimeout` field on `Config` rather than a test-only hack. While you
|
||||||
|
are on the box, time a cold 1.7B load off spinning disk. If it runs near 60s, the
|
||||||
|
default is too tight and the field earns itself twice.
|
||||||
|
|
||||||
|
Warm, it is nowhere near. A second llama-server answered `/health` 1.8s after
|
||||||
|
launch at `n_ctx 4096` on 02-08-2026. That is page cache, so it does not settle
|
||||||
|
the question. A cold read needs a cache drop, which needs root.
|
||||||
|
|
||||||
|
**`CheckFeminine` has a false positive.** On 02-08-2026 it failed
|
||||||
|
`query-notes-do-not-answer` for `ты заплатил`, calling it masculine
|
||||||
|
self-reference. Masculine second person is correct, because the owner is male.
|
||||||
|
The check matches a masculine
|
||||||
|
past-tense verb before `за` without confirming the subject is `я`. Fix it in
|
||||||
|
`internal/phraser/eval/checks.go` before trusting a phrasing score to the case.
|
||||||
|
The real talk-fixture score on that run is 22 of 27, not 21. Tracked as **462**.
|
||||||
|
|
||||||
|
Item 4 of **320** needs a permission I do not have. Kill the `llama-server`
|
||||||
|
pid under `maven-mavend-1`, post a turn, and confirm it still completes
|
||||||
|
through the classifier. Either grant it or run it yourself. It is the only
|
||||||
|
check that the failure floor catches a mid-session model death.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 3: the interaction batch (a day, or five sittings)
|
||||||
|
|
||||||
|
These need real use rather than a command, grouped by what one sitting covers.
|
||||||
|
|
||||||
|
**Morning and delivery** (**280**, **281**, **128**, **282**, **283**, **285**):
|
||||||
|
open `/morning`, walk the seven required behaviours, then check the four
|
||||||
|
interruption outcomes and the digest gap. **282** needs the `desk_active` script
|
||||||
|
enabled on the desk PC first, which is **15** and needs you at that machine.
|
||||||
|
**283** is the event intake envelope every reach shares, so a delivery check
|
||||||
|
exercises it whether you name it or not. **285** is not verification: the bridge
|
||||||
|
framework works and the remaining ask is more adapters. Decide which reach comes
|
||||||
|
next, or park it.
|
||||||
|
|
||||||
|
Run 02-08-2026. **280 is blocked.** No morning routine is configured (**472**).
|
||||||
|
`morning.Item` also has no required-versus-optional field, so behaviour 1 cannot
|
||||||
|
hold whatever you configure (**473**). **281's digest gap is closed**, and
|
||||||
|
its presence rule passes on inspection. Three of its five items need traffic the
|
||||||
|
box has not had. **283 is blocked**: nothing feeds the intake journal. **128
|
||||||
|
found the worst defect of the whole session, see below.**
|
||||||
|
|
||||||
|
Three of 472's five blockers were cleared the same day, in `deploy/mavend.json`
|
||||||
|
and `docker-compose.yml`.
|
||||||
|
|
||||||
|
- A `morning_routines` block, one routine `утро` 08:00-11:00 with medicine,
|
||||||
|
water and pets. It is live: the dispatcher logged `dropped morning:утро (sev1,
|
||||||
|
presence=away)`, so the plan builds and the nudge is proposed. 280's
|
||||||
|
behaviours and 128 step 11 are checkable now. 473 still stands.
|
||||||
|
- `-ambient-token` on mavweb, value in a gitignored `/.env` that docker compose
|
||||||
|
reads for interpolation. `/api/ambient` answers 401 without the token and 201
|
||||||
|
with it, storing `calendar_event_20260802_Standup`. 283 step 5 and 128 step 8
|
||||||
|
are unblocked. The token is a flag, so it shows in `ps` inside that container.
|
||||||
|
The zenmoney and IMAP secrets are read from files instead. Ingest also
|
||||||
|
reads the notification's wall clock as UTC and stores a 14:30 meeting at 18:30
|
||||||
|
(**482**).
|
||||||
|
- `feeds` (two sources), `crawl.on_demand` and `netscan.enabled`. The intake
|
||||||
|
journal now fills: `/events` holds `scan:lan` and `ambient:notif` rows.
|
||||||
|
|
||||||
|
Two are not mine to clear. No sibling has a `token` in `deploy/mavend.json`, so
|
||||||
|
273 steps 6 and 8 need a credential decision. Nexus has no entities and Praxis no
|
||||||
|
attention items, so 272 step 3 needs seed data whose content is the owner's call.
|
||||||
|
|
||||||
|
For **285**, two facts bear on the choice. Synapse is already running on this box
|
||||||
|
and healthy, so a Matrix reach has a live target and needs no new service. And
|
||||||
|
mavweb is already a PWA with a service worker, which 285 itself calls the highest
|
||||||
|
value adapter left. Today's reaches are ntfy, telegram and voice.
|
||||||
|
|
||||||
|
**Query sources** (**258**, **286**): ask her something the RSS feeds answer and
|
||||||
|
something only a ZIM answers, with the search block on. Live search leads and the
|
||||||
|
ZIMs are the fallback since 02-08-2026. **286**'s remaining half is doc and
|
||||||
|
git ingestion, which is build work, not a check.
|
||||||
|
|
||||||
|
**Do not read `/trace` for this.** `/trace` is the nudge-rule trace: rule,
|
||||||
|
severity, predicate, gate, selected. No query-source field exists anywhere in the
|
||||||
|
codebase. The only evidence of which query source claimed a turn is the
|
||||||
|
`voice: search:` and `voice: kiwix:` lines in `docker compose logs mavend`
|
||||||
|
(`actions_query.go:589` and `:660`).
|
||||||
|
|
||||||
|
Run 02-08-2026, 20 turns. **Search leads and the personal boundary holds.** Every
|
||||||
|
world question that reached the boundary was claimed by search. All three
|
||||||
|
personal questions produced no search and no kiwix line at all.
|
||||||
|
|
||||||
|
The rest of this sitting went badly. **Kiwix has zero live coverage.** SearXNG
|
||||||
|
returns four results for everything, including two invented nonsense terms. So
|
||||||
|
`querySearch` always claims, and Kiwix is unreachable code as deployed. The ZIM
|
||||||
|
half of the 02-08-2026 decision is unverified. A ZIM answer cannot signal a
|
||||||
|
silent search failure, because a ZIM answer cannot happen.
|
||||||
|
**Ordering defects** in feeds and calendar, plus 258 step 1's utterance not
|
||||||
|
working: **474**. And the sitting independently found stage 2 of **470**.
|
||||||
|
|
||||||
|
**Tasks and calendar** (**129**, **130**, **127**, **126**, **246**): capture a
|
||||||
|
task by voice, confirm it lands, check prioritisation ordering is not nonsense.
|
||||||
|
**246** (mail reader) also exercises the `IngestMail` rung that moved to
|
||||||
|
`AuthWrite` this morning.
|
||||||
|
|
||||||
|
Run 02-08-2026. **129 passes.** The page and the spoken answer agree on ordering.
|
||||||
|
The undistinguished task carries no invented reason on either surface, which is
|
||||||
|
the thing 129 asks for. **130 fails outright** and **127 half fails**:
|
||||||
|
**467**, **469**. **246 cannot be run**: `mavmaild` is commented out in
|
||||||
|
`docker-compose.yml` and there is no `email` block, so nothing in steps 4-13 is
|
||||||
|
reachable. The `IngestMail` rung does sit at `AuthWrite`
|
||||||
|
(`internal/auth/policy.go:96`, asserted in `auth_test.go:421`), verified by
|
||||||
|
reading only.
|
||||||
|
|
||||||
|
**Routines and patterns** (**43**, **46**, **247**, **254**): these need history
|
||||||
|
to detect against. If the database is thin after the outage, they may have
|
||||||
|
nothing to propose, which is not a failure. Check `/routines` before
|
||||||
|
concluding anything.
|
||||||
|
|
||||||
|
Run 02-08-2026. The answer is the middle case: **the detector ran and found
|
||||||
|
nothing.** The tick loop is live, and `detectPatterns` is called unconditionally
|
||||||
|
at `cmd/mavend/tick.go:227`. It has run about 25 times since the restart. It
|
||||||
|
finds nothing because the events table is empty upstream of it. Rows land there
|
||||||
|
only from `pattern.Extract` at fact-write time, and `Extract` requires the fact
|
||||||
|
value to match a closed 7-action lexicon. All 200 facts on `/history` are
|
||||||
|
`page_heartbeat`, `netdata_alarm`, `quiet_hours`, `name`, `service_down` and
|
||||||
|
`рост`. Not one lexicon hit, so no event can exist, let alone the four one pair
|
||||||
|
needs. **46 step 5 passes**: `/routines` renders `noticed 0` with the empty state
|
||||||
|
and the hint string.
|
||||||
|
|
||||||
|
Two things block this sitting, and both are build work. The seeding recipe on
|
||||||
|
**43** goes through `sqlite3` and cannot work. And `pattern.Detect` has no
|
||||||
|
minimum-interval floor, so seeding by hand mints a permanent false routine
|
||||||
|
(**468**). Do not try to seed a pattern with four fast chat turns.
|
||||||
|
|
||||||
|
**Ecosystem** (**272**, **273**, **276**): nexus, hexis and praxis are wired and
|
||||||
|
logged clean at boot.
|
||||||
|
|
||||||
|
Run 02-08-2026, read-only half. All three answer `/health` 200 and `/ecosystem`
|
||||||
|
lists 18 Hexis capabilities with correct read-only and mutating badges. **272 and
|
||||||
|
273 are blocked on empty data**, not on code. Nexus holds no entities, Praxis
|
||||||
|
holds no attention items, and the Calls panel has never recorded a call. See
|
||||||
|
**472**, and read its warning first. 273's trace fix has never been validated
|
||||||
|
here. An empty Calls panel is exactly what the old bug looked like. The page is
|
||||||
|
`/ecosystem`, not `/siblings`.
|
||||||
|
|
||||||
|
**276 ran 02-08-2026 and the suite is sound.** 17 `TestEcosystem_` cases pass
|
||||||
|
under `-race`, not the 10 the task describes. The mutation check bites: patching
|
||||||
|
the Nexus-error branch of `handleHexisAct` to `return ""` fails
|
||||||
|
`TestEcosystem_MalformedNexusResponseFailsClosed` on the expected line.
|
||||||
|
|
||||||
|
Steps 4 and 6 could not be checked through chat, because no utterance reaches
|
||||||
|
Praxis (**475**). «что требует внимания» routes to `intent=query` and is answered
|
||||||
|
by the search leg, identically whether `ecosystem-praxis-1` is up or stopped. The
|
||||||
|
degraded string never appears because its branch is never entered. Step 5 is
|
||||||
|
blocked the same way: `перезапусти muzick indexer` clarifies on
|
||||||
|
`HasFn:false`, and the router had already rewritten the entity name to
|
||||||
|
`музик индексер` (**476**).
|
||||||
|
|
||||||
|
Both steps were checked on `/ecosystem` instead, which reads Praxis directly.
|
||||||
|
With Praxis stopped the card reads `praxis — unreachable` while Nexus and Hexis
|
||||||
|
keep rendering. On `docker start` the card returns to `nothing needs attention.`
|
||||||
|
with no mavend restart. Independent degradation and recovery both hold.
|
||||||
|
|
||||||
|
**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
|
||||||
|
neither lever can be pulled on this box. See **477**.
|
||||||
|
|
||||||
|
**250** passes steps 1, 2, 3, 9 and 10 on the deploy. The capability announces
|
||||||
|
itself. `/models` names the model llama-server reports, not the config filename.
|
||||||
|
Asking her to switch models does nothing. Removing `swap_models` renders `swap
|
||||||
|
not configured`. Step 4's refusal half passes at HTTP 403, and the 403 comes from
|
||||||
|
mavend rather than mavweb. WebAuthn is unconfigured, so the web gate fails open
|
||||||
|
and the wire gate fails closed. Steps 5 to 8 need a passkey assertion nothing on
|
||||||
|
this box can produce. They pass in test: 13 swap cases and 7 page cases covering
|
||||||
|
drain, mid-swap refusal, rollback, failed rollback and the not-owned refusal.
|
||||||
|
|
||||||
|
**249** passes steps 1 and 2. Step 3 stops it. `mavupdate` health-checks over
|
||||||
|
`/run/maven/mavend.sock`, which is `srw------- 1 10001 999` inside a docker
|
||||||
|
volume. The host owner cannot traverse `/var/lib/docker/volumes` and cannot
|
||||||
|
connect to a socket owned by an in-container uid. `mavupdate` assumes a
|
||||||
|
host-installed daemon and the deploy is containers. Do not sudo around this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Housekeeping (done 02-08-2026, and this section was mostly wrong)
|
||||||
|
|
||||||
|
This section claimed eleven tasks were not verification work. **Three were not.
|
||||||
|
The other eight are.** Every one of the eight has shipped, tested code behind it.
|
||||||
|
The error ran one way: it wrote off work that is ready to check. Do not trust a
|
||||||
|
"nothing is built" line in this plan without grepping for the package first.
|
||||||
|
|
||||||
|
Relabelled to `Blocked:`, claim verified:
|
||||||
|
|
||||||
|
- **125** zenmoney. `internal/zenmoney/` ships and is tested against a fixture.
|
||||||
|
`deploy/zenmoney.token` does not exist and the compose mount is commented out.
|
||||||
|
One token unblocks it.
|
||||||
|
- **256** Home Assistant. `internal/smarthome/` ships, the `smarthome` block sits
|
||||||
|
in `deploy/mavend.json` at `enabled: false`, and 8123 and 1883 are closed.
|
||||||
|
- **14** cold-start unlock. The seam is real at `cmd/mavend/main.go:128` and
|
||||||
|
`internal/webauthn/prf.go` is in place. `lockedAPI` is gone, replaced by
|
||||||
|
`Server.Check` in `internal/ipc/server.go`. Gated on an authenticator that
|
||||||
|
implements the WebAuthn PRF extension, which is hardware, not code.
|
||||||
|
|
||||||
|
Left alone, because the claim here was false:
|
||||||
|
|
||||||
|
- **284** simulator. `cmd/mavend/simulator_test.go`, three scenarios under
|
||||||
|
`cmd/mavend/testdata/scenarios/`, and a `simulate` target at `Makefile:98`.
|
||||||
|
**Run 02-08-2026: all three scenarios pass**, plus the determinism and
|
||||||
|
backwards-step guards. One defect found, see below.
|
||||||
|
- **288** STT golden audio. Four WAVs and `golden_v1.json` are committed under
|
||||||
|
`cmd/mavsttd/testdata/`, the make targets exist, and `models/stt/ggml-small.bin`
|
||||||
|
is on the box. Session 1 lists 288 as blocked on fixtures, which is wrong.
|
||||||
|
**Run 02-08-2026: all four pass**, WER at or under ceiling with no drift.
|
||||||
|
|
||||||
|
| fixture | transcript | WER | ceiling |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ru_reminder | `Напомни мне через час позвонить маме.` | 0.00 | 0.10 |
|
||||||
|
| ru_fact | `А отметь, что я выпил воды.` | 0.20 | 0.25 |
|
||||||
|
| ru_query | `Что у меня сегодня по календарю?` | 0.00 | 0.10 |
|
||||||
|
| en_act | `Restart the web server and check the disk space.` | 0.00 | 0.10 |
|
||||||
|
|
||||||
|
That also settles a session 1 worry indirectly: whisper.cpp works on Vulkan
|
||||||
|
after the redeploy. Only the mic and the wake path remain unproven.
|
||||||
|
|
||||||
|
**The simulator routes with an empty seed set.** Every `make simulate` run logs
|
||||||
|
`loaded 0 seed examples from models/seeds`, seven times per scenario. The test
|
||||||
|
runs from `cmd/mavend`, and the seed path is relative to the repo root. The
|
||||||
|
scenarios still pass, which means they pass without the classifier having any
|
||||||
|
seeds to match against. Whatever 284 is proving, it is not proving the routing
|
||||||
|
the deploy runs. Fix the path before trusting a green simulator.
|
||||||
|
- **257** Bluetooth. The bluez half is genuinely absent. The LAN-scan half shipped
|
||||||
|
(`internal/netscan/`), and steps 1-9 run today. Only step 10 is Bluetooth, so
|
||||||
|
relabelling the whole task would bury real pending work.
|
||||||
|
- **251** MCP, **253** hearing, **259** crawler. All three ship
|
||||||
|
(`internal/mcp/`, `internal/capture/`, `internal/crawl/`) with no external gate.
|
||||||
|
Fully checkable. `259`'s step 1 wants no `crawl` block in `deploy/mavend.json`,
|
||||||
|
and there is none, so it is already set up correctly.
|
||||||
|
- **252** vision and **255** speaker recognition. Both ship. Each is blocked only
|
||||||
|
on a model download: a vision gguf with mmproj, and a speaker embedding model.
|
||||||
|
Neither is present under `/mnt/hdd1`. Their refusal-path steps run today.
|
||||||
|
|
||||||
|
So the honest split is three blocked on a credential or hardware, two blocked on
|
||||||
|
a download, and six ready to check. That is roughly a session of real QA this
|
||||||
|
plan had written off as backlog.
|
||||||
|
|
||||||
|
**All six ran on 02-08-2026.** Every one of them is code-correct and stops at the
|
||||||
|
deploy. The pattern repeats often enough to be the headline: the packages pass,
|
||||||
|
and the box cannot reach them.
|
||||||
|
|
||||||
|
**251, MCP.** Steps 1, 2, 3, 4 and 13 pass. Package tests green under `-race`.
|
||||||
|
Off-by-default is clean, and the SSRF refusal is exact: without `allow_private`
|
||||||
|
the log reads `refusing to connect to a private address: 127.0.0.1` and `/tools`
|
||||||
|
shows the server down with zero proposals. Steps 5 to 12 are blocked. `ss -lntp`
|
||||||
|
shows the Vikunja MCP server on `127.0.0.1:9100` only, so no container reaches it
|
||||||
|
at any address (**478**). `allow_private` does work, measured both ways.
|
||||||
|
|
||||||
|
**253, hearing.** Steps 1, 2 and 17 pass. `internal/capture` covers 90.3%. Steps
|
||||||
|
7 to 16 are blocked on something nobody can work around: no shipped client calls
|
||||||
|
`CaptureStart`. There is no `cmd/mavheard`, no mavweb route, and `mavenclient`
|
||||||
|
never calls it (**480**). Two of its QA steps are also stale.
|
||||||
|
|
||||||
|
**257, netscan.** Steps 2, 3 and 9 pass at unit level. Step 1 fails. Steps 4 to 8
|
||||||
|
need the block enabled. Step 10 is Bluetooth and stays skipped.
|
||||||
|
|
||||||
|
**259, crawler.** Steps 1 and 15 pass. Step 2 fails. Steps 3 to 14 need a `crawl`
|
||||||
|
block that nobody has written.
|
||||||
|
|
||||||
|
Both were configured later the same day, and both work. `netscan.enabled: true`
|
||||||
|
answers `какие устройства в сети?` with `нашла 3 устройства, из них 2 с вебом, 2 с
|
||||||
|
ssh. список записала.` and the scan lands in the intake journal as `scan:lan`.
|
||||||
|
`crawl.on_demand: true` answers `посмотри https://lwn.net — что там пишут?` from
|
||||||
|
the real page. So **479** is one defect, not the routing defect it was filed as.
|
||||||
|
An unconfigured capability declines its own turn instead of naming the gap.
|
||||||
|
Nothing is wrong with the routing.
|
||||||
|
|
||||||
|
257 step 1 and 259 step 2 fail the same way and share a task (**479**). An
|
||||||
|
unconfigured capability does not name the gap, so the question escapes to web
|
||||||
|
search. `какие устройства в сети?` was answered with a general article about
|
||||||
|
network hardware. That is his LAN going to an upstream engine.
|
||||||
|
|
||||||
|
**252 vision and 255 speaker.** Both confirmed blocked. The disk claim was
|
||||||
|
re-verified rather than taken on trust: 16 text-only ggufs under `/mnt/hdd1`, no
|
||||||
|
mmproj and no speaker embedding model. Everything not needing the model passes,
|
||||||
|
including the two refusals that matter. `TestNewLocalRefusesNonPrivateEndpoints`
|
||||||
|
rejects `https://api.openai.com`, and forget really deletes
|
||||||
|
(`internal/store/memory.go:145` is a real `DELETE`, not a tombstone). Vision is
|
||||||
|
19/19, speaker 22/22, media 16/16.
|
||||||
|
|
||||||
|
**470 got worse, then closed.** Both poisoned facts showed `voided` on
|
||||||
|
`/history` and the defect survived. Re-measured at 15:42, after four restarts:
|
||||||
|
`почему небо синее?` still answered `какая последняя версия языка Go?` with no
|
||||||
|
`search:` line. What came back was the question he typed, not the value the fact
|
||||||
|
held. So the poison was a vector in the memory index, and `revert` did not
|
||||||
|
remove it.
|
||||||
|
|
||||||
|
Repaired in two parts. 470 stopped the writes: a question is never a fact, and a
|
||||||
|
void drops the key's vectors. 493 fixed what the index holds. A fact is indexed
|
||||||
|
as the fact and not as the utterance, and a correction drops its superseded
|
||||||
|
vector too.
|
||||||
|
|
||||||
|
A poisoned box now repairs itself on the next start. `RepairFactVectors`
|
||||||
|
re-embeds every fact vector from the fact it names, and deletes the voided and
|
||||||
|
superseded ones. It runs once, guarded by a marker, and logs what it did.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Needs you specifically
|
||||||
|
|
||||||
|
Not QA. These are blocked on a decision or a credential only you have.
|
||||||
|
|
||||||
|
| # | what |
|
||||||
|
|---|---|
|
||||||
|
| 16 | Create the Kuma API key. `-kuma-key uk5_mavpoll-key` in `docker-compose.yml` is still the placeholder. |
|
||||||
|
| 15 | Deploy `desk_active` on the desk PC. Blocks **282**. |
|
||||||
|
| 122 | Finish the CPT run for Qwen3-1.7B. The persona fix depends on it. |
|
||||||
|
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
|
||||||
|
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
|
||||||
|
| 275 | Hexis native API and MCP parity. |
|
||||||
|
| — | Decide on `-require-stepup`. Making it the default needs WebAuthn configured first, or it locks you out of your own admin surfaces. |
|
||||||
|
|
||||||
|
317 and 354 closed on 01-08-2026. The step-up gate now covers `POST /api/chat` and
|
||||||
|
`/routines`, and the nginx template is locked down with a `maven.<domain>` block for
|
||||||
|
mavweb. The `-require-stepup` default is still your call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not this repo
|
||||||
|
|
||||||
|
Two open tasks sit on the Maven board and are not Maven work. Move them or note
|
||||||
|
where they land, so the board stops reading as 50 things Maven owes.
|
||||||
|
|
||||||
|
- **358** replace the rowid execution cursor with a real seq column. This is Hexis,
|
||||||
|
and it must land before any execution retention or pruning does.
|
||||||
|
- **362** mirror the router prompt reorder into the relabelling prompt. This is the
|
||||||
|
training workspace, enforced by `llm/check_prompt_parity.py` there, not here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. Session 1. If the voice loop is broken, nothing else matters.
|
||||||
|
2. The `-require-stepup` and Kuma decisions. Five minutes, and it unblocks **16**.
|
||||||
|
3. Session 2. **Run on 02-08-2026.** The numbers came back worse for the router
|
||||||
|
than the docs claimed. The classifier is 68.8%, not 36.8%, and 16.6µs, not
|
||||||
|
31ms. The router buys about 4 points of accuracy for four orders of magnitude
|
||||||
|
of latency. Whether that still earns its place is now an open question.
|
||||||
|
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
|
||||||
|
5. Session 3, split whichever way suits you. All five sittings ran on
|
||||||
|
02-08-2026. Read the per-sitting notes before repeating any of them.
|
||||||
|
|
||||||
|
The next thing to fix is not in this plan. Four defects say the same sentence:
|
||||||
|
a capability is built and no utterance reaches it. **466** (a clarify is global),
|
||||||
|
**467** (capture is act-routed), **475** (attention is act-routed), **476** (the
|
||||||
|
router rewrites entity names). Routing is where the work is.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Maven — Re-architecture (Qwen3 resident model, revised 2026-07-18)
|
# Maven — Re-architecture (Qwen3 resident model, revised 2026-07-18)
|
||||||
|
|
||||||
|
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
> Supersedes the classifier-first routing model. Agreed in a design session
|
> Supersedes the classifier-first routing model. Agreed in a design session
|
||||||
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
|
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
|
||||||
> running) and an embedder-classifier that routes by nearest-neighbor between
|
> running) and an embedder-classifier that routes by nearest-neighbor between
|
||||||
@@ -311,7 +311,7 @@ func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
|
|||||||
if fake.writes != 0 {
|
if fake.writes != 0 {
|
||||||
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
|
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
|
||||||
}
|
}
|
||||||
_, err = cli.Chat(context.Background(), "привет")
|
_, err = cli.Chat(context.Background(), "web", "привет")
|
||||||
if !errors.Is(err, ipc.ErrForbidden) {
|
if !errors.Is(err, ipc.ErrForbidden) {
|
||||||
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
|
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
|
|||||||
t.Fatalf("dial: %v", err)
|
t.Fatalf("dial: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = cli.Close() })
|
t.Cleanup(func() { _ = cli.Close() })
|
||||||
reply, err := cli.Chat(context.Background(), "привет")
|
reply, err := cli.Chat(context.Background(), "web", "привет")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat: %v", err)
|
t.Fatalf("Chat: %v", err)
|
||||||
}
|
}
|
||||||
@@ -373,7 +373,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
|
|||||||
return int64(r.writes), nil
|
return int64(r.writes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) {
|
func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) {
|
||||||
r.chats++
|
r.chats++
|
||||||
return "echo: " + text, nil
|
return "echo: " + text, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package auth is maven's authority layer — the 4-layer cascade and the
|
// Package auth is maven's authority layer — the 4-layer cascade and the
|
||||||
// "surface caps authority" invariant.
|
// "surface caps authority" invariant.
|
||||||
//
|
//
|
||||||
// Spec contract (from DESIGN.md § Auth):
|
// Spec contract (from docs/design.md § Auth):
|
||||||
//
|
//
|
||||||
// a cascade, not a pick-one — each layer answers a different question:
|
// a cascade, not a pick-one — each layer answers a different question:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fact sources. A calendar event reaches the store as a
|
// Fact sources. A calendar event reaches the store as a
|
||||||
@@ -153,14 +154,20 @@ func Overlapping(events []Event, from, to time.Time) []Event {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
// safeKey makes a summary safe to use inside a fact key: letters and digits in
|
||||||
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
// any script, plus dashes, with space and underscore folded to a dash.
|
||||||
// the day prefix carries the identity and this only disambiguates within a day.
|
//
|
||||||
|
// It kept ASCII only until 04-08-2026, and dropped everything else. His
|
||||||
|
// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to
|
||||||
|
// "--" and produced the same key on the same day — the second event of the day
|
||||||
|
// silently overwrote the first (Vikunja #443). Letting the letters through is
|
||||||
|
// what makes the key identify the event. Migration #18 drops the keys written
|
||||||
|
// under the old rule; they are re-derived on the next poll.
|
||||||
func safeKey(s string) string {
|
func safeKey(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
switch {
|
switch {
|
||||||
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
case r == ' ' || r == '_':
|
case r == ' ' || r == '_':
|
||||||
b.WriteRune('-')
|
b.WriteRune('-')
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ func TestSafeKey(t *testing.T) {
|
|||||||
{"Hello_World", "Hello-World"},
|
{"Hello_World", "Hello-World"},
|
||||||
{"special@#$chars!!", "specialchars"},
|
{"special@#$chars!!", "specialchars"},
|
||||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||||
|
// His calendar is Russian. These reduced to "--" and "--" (Vikunja #443).
|
||||||
|
{"Встреча с Аней", "Встреча-с-Аней"},
|
||||||
|
{"Обед с мамой", "Обед-с-мамой"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
if got := safeKey(tt.in); got != tt.want {
|
if got := safeKey(tt.in); got != tt.want {
|
||||||
@@ -263,3 +266,19 @@ func TestSourceTrust(t *testing.T) {
|
|||||||
t.Errorf("Sources() = %v", Sources())
|
t.Errorf("Sources() = %v", Sources())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two Russian events on one day must not share a key. They did: safeKey kept
|
||||||
|
// ASCII only, so both summaries collapsed to their spaces and the second event
|
||||||
|
// overwrote the first in the store (Vikunja #443).
|
||||||
|
func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) {
|
||||||
|
day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||||
|
a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)}
|
||||||
|
b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)}
|
||||||
|
if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) {
|
||||||
|
t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC))
|
||||||
|
}
|
||||||
|
// The day prefix still has to survive, because the store range-scans on it.
|
||||||
|
if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) {
|
||||||
|
t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ type Config struct {
|
|||||||
// See SearchConfig.
|
// See SearchConfig.
|
||||||
Search *SearchConfig `json:"search,omitempty"`
|
Search *SearchConfig `json:"search,omitempty"`
|
||||||
|
|
||||||
|
// Workstation — the big model on the owner's desktop, preferred over the
|
||||||
|
// resident one when its GPU is free. nil / absent / url empty ⇒ homesrv
|
||||||
|
// behaves exactly as it does today. See WorkstationConfig.
|
||||||
|
Workstation *WorkstationConfig `json:"workstation,omitempty"`
|
||||||
|
|
||||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||||
@@ -596,6 +601,9 @@ type MorningRoutineItemConfig struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
FactKey string `json:"fact_key"`
|
FactKey string `json:"fact_key"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
// Optional — this one being skipped does not earn a nudge. Default false,
|
||||||
|
// so a routine written before 04-08-2026 keeps behaving as it did.
|
||||||
|
Optional bool `json:"optional,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
||||||
@@ -649,7 +657,7 @@ type VoiceConfig struct {
|
|||||||
// LLMRouter — route with the resident model instead of the embedding
|
// LLMRouter — route with the resident model instead of the embedding
|
||||||
// classifier. On by default since Vikunja #320.
|
// classifier. On by default since Vikunja #320.
|
||||||
//
|
//
|
||||||
// Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md): 63.2% of
|
// Measured on the held-out fixture (docs/evals/2026-07-31-routing.md): 63.2% of
|
||||||
// intents right against the classifier's 50.0%, and no route errors. It
|
// intents right against the classifier's 50.0%, and no route errors. It
|
||||||
// costs about 1s per turn instead of 30ms.
|
// costs about 1s per turn instead of 30ms.
|
||||||
//
|
//
|
||||||
@@ -1105,6 +1113,44 @@ const (
|
|||||||
DefaultKiwixSnippetRunes = 1500
|
DefaultKiwixSnippetRunes = 1500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WorkstationConfig — the big model on the owner's desktop (workpc, a
|
||||||
|
// 7900 GRE with 16GB), fronted by mavgpud.
|
||||||
|
//
|
||||||
|
// homesrv cannot grow a GPU, so the resident Qwen3-1.7B is the floor and this
|
||||||
|
// is the preferred model above it (owner's call, 2026-08-02, docs/offload.md).
|
||||||
|
// The workstation is never assumed up: its card is often held by a CPT run and
|
||||||
|
// the machine sleeps. No block, or an empty URL, and homesrv behaves exactly as
|
||||||
|
// it does today.
|
||||||
|
//
|
||||||
|
// Only the prompt crosses the LAN, and the workstation is not "the box". The
|
||||||
|
// rules in CLAUDE.md about what may leave still apply.
|
||||||
|
type WorkstationConfig struct {
|
||||||
|
// URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒
|
||||||
|
// the whole block is normalised to nil and nothing probes anything.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
|
||||||
|
// Health — the admission endpoint. Empty ⇒ URL + "/health", which is what
|
||||||
|
// mavgpud serves. It answers 503 while the card is held, and that is the
|
||||||
|
// signal, so it must be the supervisor's endpoint and not llama-server's.
|
||||||
|
Health string `json:"health,omitempty"`
|
||||||
|
|
||||||
|
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||||
|
// Nothing on the hot path waits for it: the answer is cached and read
|
||||||
|
// atomically, so this only sets how late Maven notices the card came back.
|
||||||
|
Probe Duration `json:"probe,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — the per-request budget for a completion on the workstation.
|
||||||
|
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
|
||||||
|
// the resident one, and a request that overruns falls back to the floor.
|
||||||
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workstation defaults, applied in Normalise.
|
||||||
|
const (
|
||||||
|
DefaultWorkstationProbe = 15 * time.Second
|
||||||
|
DefaultWorkstationTimeout = 90 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// SearchConfig — the self-hosted SearXNG instance she searches with.
|
// SearchConfig — the self-hosted SearXNG instance she searches with.
|
||||||
//
|
//
|
||||||
// External search is allowed and off unless configured (CLAUDE.md). Configuring
|
// External search is allowed and off unless configured (CLAUDE.md). Configuring
|
||||||
@@ -1233,6 +1279,12 @@ type PhraserConfig struct {
|
|||||||
NCtx int `json:"n_ctx,omitempty"`
|
NCtx int `json:"n_ctx,omitempty"`
|
||||||
Timeout Duration `json:"timeout,omitempty"`
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// CacheRAMMiB bounds llama-server's prompt cache. Omitted ⇒ 512 MiB, which
|
||||||
|
// is what keeps the resident model near 1 GB of RSS instead of the 7.9 GB
|
||||||
|
// measured on 2026-08-03. Set it to -1 to pass no flag at all and let the
|
||||||
|
// server apply its own 8 GiB default. See phraser.Config.CacheRAMMiB.
|
||||||
|
CacheRAMMiB int `json:"cache_ram_mib,omitempty"`
|
||||||
|
|
||||||
// LLMNudges — let the model word nudges again. Off by default: nudges are
|
// LLMNudges — let the model word nudges again. Off by default: nudges are
|
||||||
// worded from hand-written Russian templates now (the model broke the
|
// worded from hand-written Russian templates now (the model broke the
|
||||||
// persona and invented units). Chat, query and reminder phrasing always go
|
// persona and invented units). Chat, query and reminder phrasing always go
|
||||||
@@ -1500,6 +1552,24 @@ func (c *Config) applyDefaults() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No address, no preferred model. An unconfigured workstation is the
|
||||||
|
// default deploy and must be indistinguishable from today.
|
||||||
|
if c.Workstation != nil && strings.TrimSpace(c.Workstation.URL) == "" {
|
||||||
|
c.Workstation = nil
|
||||||
|
}
|
||||||
|
if c.Workstation != nil {
|
||||||
|
w := c.Workstation
|
||||||
|
if strings.TrimSpace(w.Health) == "" {
|
||||||
|
w.Health = strings.TrimRight(w.URL, "/") + "/health"
|
||||||
|
}
|
||||||
|
if w.Probe <= 0 {
|
||||||
|
w.Probe = Duration(DefaultWorkstationProbe)
|
||||||
|
}
|
||||||
|
if w.Timeout <= 0 {
|
||||||
|
w.Timeout = Duration(DefaultWorkstationTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if c.Voice != nil {
|
if c.Voice != nil {
|
||||||
if c.Voice.RouterThreshold <= 0 {
|
if c.Voice.RouterThreshold <= 0 {
|
||||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||||
@@ -1667,7 +1737,7 @@ func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
|||||||
for i, r := range mc {
|
for i, r := range mc {
|
||||||
items := make([]morning.Item, len(r.Items))
|
items := make([]morning.Item, len(r.Items))
|
||||||
for j, it := range r.Items {
|
for j, it := range r.Items {
|
||||||
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label}
|
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional}
|
||||||
}
|
}
|
||||||
weekdays := make([]time.Weekday, len(r.Weekdays))
|
weekdays := make([]time.Weekday, len(r.Weekdays))
|
||||||
for j, w := range r.Weekdays {
|
for j, w := range r.Weekdays {
|
||||||
|
|||||||
@@ -413,3 +413,56 @@ func TestNormaliseFillsKiwixDefaults(t *testing.T) {
|
|||||||
t.Error("rewrite: false was not honoured")
|
t.Error("rewrite: false was not honoured")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A workstation with no address is not a workstation. The unconfigured deploy
|
||||||
|
// must be indistinguishable from today, so the block is dropped rather than
|
||||||
|
// left to fail one probe at a time.
|
||||||
|
func TestNormaliseDropsAddresslessWorkstation(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
in *WorkstationConfig
|
||||||
|
}{
|
||||||
|
{"no url", &WorkstationConfig{Probe: Duration(time.Second)}},
|
||||||
|
{"blank url", &WorkstationConfig{URL: " "}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := &Config{Workstation: tc.in}
|
||||||
|
c.applyDefaults()
|
||||||
|
if c.Workstation != nil {
|
||||||
|
t.Errorf("kept an unusable workstation block: %+v", c.Workstation)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The health endpoint defaults to the supervisor's, not llama-server's: mavgpud
|
||||||
|
// answers 503 while the card is held, and that refusal is the whole signal.
|
||||||
|
func TestNormaliseFillsWorkstationDefaults(t *testing.T) {
|
||||||
|
c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/"}}
|
||||||
|
c.applyDefaults()
|
||||||
|
if c.Workstation == nil {
|
||||||
|
t.Fatal("dropped a usable workstation block")
|
||||||
|
}
|
||||||
|
if got, want := c.Workstation.Health, "http://192.168.1.105:8080/health"; got != want {
|
||||||
|
t.Errorf("Health = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if time.Duration(c.Workstation.Probe) != DefaultWorkstationProbe {
|
||||||
|
t.Errorf("Probe = %s, want %s", time.Duration(c.Workstation.Probe), DefaultWorkstationProbe)
|
||||||
|
}
|
||||||
|
if time.Duration(c.Workstation.Timeout) != DefaultWorkstationTimeout {
|
||||||
|
t.Errorf("Timeout = %s, want %s", time.Duration(c.Workstation.Timeout), DefaultWorkstationTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit health URL is left alone: the supervisor may sit behind something
|
||||||
|
// that does not put /health at the root.
|
||||||
|
func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
||||||
|
c := &Config{Workstation: &WorkstationConfig{
|
||||||
|
URL: "http://192.168.1.105:8080",
|
||||||
|
Health: "http://192.168.1.105:9000/ready",
|
||||||
|
}}
|
||||||
|
c.applyDefaults()
|
||||||
|
if got, want := c.Workstation.Health, "http://192.168.1.105:9000/ready"; got != want {
|
||||||
|
t.Errorf("Health = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package delivery is maven's channel-routing + dispatch layer.
|
// Package delivery is maven's channel-routing + dispatch layer.
|
||||||
//
|
//
|
||||||
// Spec contract (from DESIGN.md § Delivery / channel routing):
|
// Spec contract (from docs/design.md § Delivery / channel routing):
|
||||||
//
|
//
|
||||||
// - routing = f(severity, presence). presence decides REACHABILITY; severity
|
// - routing = f(severity, presence). presence decides REACHABILITY; severity
|
||||||
// decides INSISTENCE. need both.
|
// decides INSISTENCE. need both.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// This file walks every cell of the DESIGN.md § "Delivery / channel routing"
|
// This file walks every cell of the docs/design.md § "Delivery / channel routing"
|
||||||
// table, once as the pure table and once through the dispatcher, so a change
|
// table, once as the pure table and once through the dispatcher, so a change
|
||||||
// to either side has to break a named cell.
|
// to either side has to break a named cell.
|
||||||
//
|
//
|
||||||
@@ -205,7 +205,7 @@ func TestAwayChannelsGetMinimalBody(t *testing.T) {
|
|||||||
// An empty Summary no longer means "send the whole body" — it means a short
|
// An empty Summary no longer means "send the whole body" — it means a short
|
||||||
// generic line — so the old expectation here was wrong as well as duplicated.
|
// generic line — so the old expectation here was wrong as well as duplicated.
|
||||||
|
|
||||||
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
// TestCareAwayDropIsRecorded — docs/design.md's drop is a decision ("a missed water
|
||||||
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
||||||
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
||||||
// attempt, no log — nothing an operator can see afterwards. now it leaves a
|
// attempt, no log — nothing an operator can see afterwards. now it leaves a
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
// 3. if no live session exists, Send returns voice.ErrNoSession
|
// 3. if no live session exists, Send returns voice.ErrNoSession
|
||||||
// (wrapped). The daemon logs the partial dispatch; an OPEN deferred
|
// (wrapped). The daemon logs the partial dispatch; an OPEN deferred
|
||||||
// question is whether the dispatcher should reroute to away-channels
|
// question is whether the dispatcher should reroute to away-channels
|
||||||
// instead of returning partial — listed in PROGRESS.md.
|
// instead of returning partial.
|
||||||
//
|
//
|
||||||
// Import direction: voicesink imports internal/tts (synth seam) and
|
// Import direction: voicesink imports internal/tts (synth seam) and
|
||||||
// internal/voice (Sessions registry). Both are siblings of delivery; the
|
// internal/voice (Sessions registry). Both are siblings of delivery; the
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ func (q *PendingQuestion) CanAsk() bool {
|
|||||||
|
|
||||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
// ClarifyStore holds the parked questions. Same shape and locking as
|
||||||
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
||||||
|
//
|
||||||
|
// Memory only, deliberately, unlike SessionStore — a restart expires every
|
||||||
|
// parked question and she does not announce that it happened (Vikunja #385,
|
||||||
|
// written down in docs/design.md). The 90s TTL and the attempt count measure a
|
||||||
|
// pause in one conversation, and a restart is a gap of unknown length, so a
|
||||||
|
// restored question would either be dead already or lying about its age. His
|
||||||
|
// next words route fresh, which is the right answer with or without a notice.
|
||||||
|
// Do not give this store a persister without re-arguing that.
|
||||||
type ClarifyStore struct {
|
type ClarifyStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
questions map[string]*PendingQuestion
|
questions map[string]*PendingQuestion
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Package event is the unified intake envelope (Vikunja #283,
|
// Package event is the unified intake envelope (Vikunja #283).
|
||||||
// 20-07-2026-BACKLOG.md item 1).
|
|
||||||
//
|
//
|
||||||
// # The problem it solves
|
// # The problem it solves
|
||||||
//
|
//
|
||||||
|
|||||||
+35
-2
@@ -60,6 +60,19 @@ type Nudge struct {
|
|||||||
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
|
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeliveryAttempt — one row of the delivery outbox. Times are formatted by the
|
||||||
|
// reader; Completed is nil while the attempt is still pending.
|
||||||
|
type DeliveryAttempt struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Rule string `json:"rule,omitempty"`
|
||||||
|
ReminderID int64 `json:"reminder_id,omitempty"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Completed *time.Time `json:"completed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Note — a recall/preference item; ranked by embedding cosine on query.
|
// Note — a recall/preference item; ranked by embedding cosine on query.
|
||||||
// Score is set by QueryNotes (0 on the write path).
|
// Score is set by QueryNotes (0 on the write path).
|
||||||
type Note struct {
|
type Note struct {
|
||||||
@@ -521,6 +534,12 @@ type outcomesReq struct {
|
|||||||
type nReq struct {
|
type nReq struct {
|
||||||
N int `json:"n"`
|
N int `json:"n"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliveryAttemptsReq — the outbox read. Status is empty for every status.
|
||||||
|
type deliveryAttemptsReq struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
N int `json:"n"`
|
||||||
|
}
|
||||||
type kindNReq struct {
|
type kindNReq struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
N int `json:"n"`
|
N int `json:"n"`
|
||||||
@@ -593,8 +612,14 @@ type MCPServerStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
|
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
|
||||||
|
//
|
||||||
|
// Conversation names the thread this utterance belongs to: a mavweb session, a
|
||||||
|
// telegram chat. It is opaque to the daemon and only has to be stable for one
|
||||||
|
// conversation and distinct across them. Empty is allowed and means "the
|
||||||
|
// unattributed text tap", which is what an old client sends.
|
||||||
type chatReq struct {
|
type chatReq struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
Conversation string `json:"conversation,omitempty"`
|
||||||
}
|
}
|
||||||
type chatResp struct {
|
type chatResp struct {
|
||||||
Reply string `json:"reply"`
|
Reply string `json:"reply"`
|
||||||
@@ -679,6 +704,9 @@ type CoreAPI interface {
|
|||||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||||
|
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||||
|
// every status (Vikunja #390).
|
||||||
|
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||||
|
|
||||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||||
// own table so machine-rate traces never crowd out human-rate facts.
|
// own table so machine-rate traces never crowd out human-rate facts.
|
||||||
@@ -759,7 +787,12 @@ type CoreAPI interface {
|
|||||||
// Chat routes a text utterance through the reactive handler's core path
|
// Chat routes a text utterance through the reactive handler's core path
|
||||||
// (router → dialogue → action → replier) and returns the reply text.
|
// (router → dialogue → action → replier) and returns the reply text.
|
||||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||||
Chat(ctx context.Context, text string) (string, error)
|
//
|
||||||
|
// conversation names the thread. A parked clarifying question is held per
|
||||||
|
// conversation, so an unanswered question on one reach cannot eat the next
|
||||||
|
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||||
|
// tap and is still one conversation of its own, separate from the mic.
|
||||||
|
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||||
|
|
||||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||||
|
|||||||
+35
-15
@@ -8,13 +8,15 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/netaddr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client — the module side of the boundary. Wraps a unix-socket connection
|
// Client — the module side of the boundary. Wraps a connection to core and
|
||||||
// and satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is
|
// satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is
|
||||||
// agnostic to whether it's been wired in-process (tests / daemon-embedded)
|
// agnostic to whether it's been wired in-process (tests / daemon-embedded),
|
||||||
// or over this socket (full topology). The swappability is the seam auth
|
// over a local unix socket, or over tcp to another host. The swappability is
|
||||||
// will insert into without touching module code.
|
// the seam auth will insert into without touching module code.
|
||||||
//
|
//
|
||||||
// One Client ⇒ one conn ⇒ one concurrent request at a time. A module that
|
// One Client ⇒ one conn ⇒ one concurrent request at a time. A module that
|
||||||
// wants parallel requests opens one Client per goroutine; the store is the
|
// wants parallel requests opens one Client per goroutine; the store is the
|
||||||
@@ -22,7 +24,8 @@ import (
|
|||||||
// per-Client lock keeps frame interleaving impossible by construction.
|
// per-Client lock keeps frame interleaving impossible by construction.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
path string // kept so a dropped conn can be re-dialed (core restart)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ var readOnlyMethods = map[Method]bool{
|
|||||||
MethodRecentActiveFacts: true,
|
MethodRecentActiveFacts: true,
|
||||||
MethodCalendarEvents: true,
|
MethodCalendarEvents: true,
|
||||||
MethodRecentNudges: true,
|
MethodRecentNudges: true,
|
||||||
|
MethodDeliveryAttempts: true,
|
||||||
MethodRecentEcoTraces: true,
|
MethodRecentEcoTraces: true,
|
||||||
MethodQueryNotes: true,
|
MethodQueryNotes: true,
|
||||||
MethodRecentNotes: true,
|
MethodRecentNotes: true,
|
||||||
@@ -81,14 +85,22 @@ var readOnlyMethods = map[Method]bool{
|
|||||||
MethodPing: true,
|
MethodPing: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dial connects to a core socket at path and returns a Client. The module
|
// Dial connects to core at path and returns a Client. The module owns its
|
||||||
// owns its Client lifecycle; Close on shutdown.
|
// Client lifecycle; Close on shutdown.
|
||||||
|
//
|
||||||
|
// path is a netaddr seam address: a bare path is the unix socket it has
|
||||||
|
// always been, and "tcp://host:port?token=..." reaches a core on another
|
||||||
|
// host. See internal/netaddr.
|
||||||
func Dial(path string) (*Client, error) {
|
func Dial(path string) (*Client, error) {
|
||||||
c, err := net.Dial("unix", path)
|
addr, err := netaddr.Parse(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ipc: dial %s: %w", path, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Client{conn: c, path: path}, nil
|
c, err := netaddr.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ipc: dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
return &Client{conn: c, path: path, addr: addr}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
@@ -189,9 +201,9 @@ func (c *Client) call(ctx context.Context, m Method, params, result any) error {
|
|||||||
// re-dials clean. Caller holds c.mu.
|
// re-dials clean. Caller holds c.mu.
|
||||||
func (c *Client) roundtrip(m Method, raw json.RawMessage, resp *Response) error {
|
func (c *Client) roundtrip(m Method, raw json.RawMessage, resp *Response) error {
|
||||||
if c.conn == nil {
|
if c.conn == nil {
|
||||||
conn, err := net.Dial("unix", c.path)
|
conn, err := netaddr.Dial(c.addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.path, err)
|
return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.addr, err)
|
||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
}
|
}
|
||||||
@@ -362,6 +374,14 @@ func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemT
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||||
|
var out []DeliveryAttempt
|
||||||
|
if err := c.call(ctx, MethodDeliveryAttempts, deliveryAttemptsReq{Status: status, N: n}, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||||
var out []Nudge
|
var out []Nudge
|
||||||
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
|
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
|
||||||
@@ -612,9 +632,9 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
|||||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
var r chatResp
|
var r chatResp
|
||||||
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return r.Reply, nil
|
return r.Reply, nil
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ func TestChatViaClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = cli.Close() })
|
t.Cleanup(func() { _ = cli.Close() })
|
||||||
|
|
||||||
reply, err := cli.Chat(context.Background(), "привет")
|
reply, err := cli.Chat(context.Background(), "web", "привет")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat: %v", err)
|
t.Fatalf("Chat: %v", err)
|
||||||
}
|
}
|
||||||
@@ -417,7 +417,7 @@ type chatTestAPI struct {
|
|||||||
UnimplementedCoreAPI
|
UnimplementedCoreAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) {
|
||||||
if text == "привет" {
|
if text == "привет" {
|
||||||
return "и тебе привет!", nil
|
return "и тебе привет!", nil
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-42
@@ -8,11 +8,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/netaddr"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
@@ -173,6 +173,25 @@ func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||||
|
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]DeliveryAttempt, len(as))
|
||||||
|
for i, at := range as {
|
||||||
|
out[i] = DeliveryAttempt{
|
||||||
|
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
||||||
|
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
||||||
|
}
|
||||||
|
if at.HasComplete {
|
||||||
|
t := at.Completed
|
||||||
|
out[i].Completed = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||||
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
||||||
return id, mapErr(err)
|
return id, mapErr(err)
|
||||||
@@ -240,7 +259,7 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
|||||||
return newID, mapErr(err)
|
return newID, mapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *storeAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
return "", errors.New("store: chat not available via direct store API")
|
return "", errors.New("store: chat not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,6 +465,7 @@ func mapErr(err error) error {
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
api atomic.Value // stores CoreAPI
|
api atomic.Value // stores CoreAPI
|
||||||
path string
|
path string
|
||||||
|
addr netaddr.Addr
|
||||||
|
|
||||||
ln net.Listener
|
ln net.Listener
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -610,31 +630,29 @@ type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error
|
|||||||
// MethodAssertStepUp dispatch calls this instead of going through CoreAPI.
|
// MethodAssertStepUp dispatch calls this instead of going through CoreAPI.
|
||||||
type StepUpFunc func(ctx context.Context) error
|
type StepUpFunc func(ctx context.Context) error
|
||||||
|
|
||||||
// Listen creates a Server bound to path. path's parent dir must exist and be
|
// Listen creates a Server bound to path.
|
||||||
// 0700 (we chmod it if we own it); the socket file itself is created 0600 so
|
//
|
||||||
// only the same unix user can connect — the current "auth floor", same radius
|
// A bare path is a unix socket, unchanged: its parent dir is 0700 and the
|
||||||
// as wg at the network boundary. Removing a stale socket at path first lets
|
// socket file itself is 0600, so only the same unix user can connect — the
|
||||||
// the daemon restart cleanly.
|
// current "auth floor", same radius as wg at the network boundary. A stale
|
||||||
|
// socket is removed first so the daemon restarts cleanly.
|
||||||
|
//
|
||||||
|
// A "tcp://host:port?token=..." address binds a network listener instead, for
|
||||||
|
// a module that lives on another host. There is no filesystem there to be the
|
||||||
|
// auth floor, so netaddr checks the shared token before this package sees the
|
||||||
|
// connection and a token is mandatory. See internal/netaddr.
|
||||||
func Listen(path string, api CoreAPI) (*Server, error) {
|
func Listen(path string, api CoreAPI) (*Server, error) {
|
||||||
_ = os.Remove(path) // stale socket from a crashed daemon; ignore missing
|
addr, err := netaddr.Parse(path)
|
||||||
if err := os.MkdirAll(parentDir(path), 0o700); err != nil {
|
|
||||||
return nil, fmt.Errorf("ipc: mkdir socket dir: %w", err)
|
|
||||||
}
|
|
||||||
// umask could widen the perms on socket creation; tighten then chmod to
|
|
||||||
// be explicit. 0600 ⇒ read+write by owner only.
|
|
||||||
oldMask := unix.Umask(0o077)
|
|
||||||
ln, err := net.Listen("unix", path)
|
|
||||||
unix.Umask(oldMask)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ipc: listen %s: %w", path, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := os.Chmod(path, 0o600); err != nil {
|
ln, err := netaddr.Listen(addr)
|
||||||
_ = ln.Close()
|
if err != nil {
|
||||||
_ = os.Remove(path)
|
return nil, err
|
||||||
return nil, fmt.Errorf("ipc: chmod socket: %w", err)
|
|
||||||
}
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
path: path,
|
path: path,
|
||||||
|
addr: addr,
|
||||||
ln: ln,
|
ln: ln,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
@@ -864,6 +882,16 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}),
|
}),
|
||||||
|
MethodDeliveryAttempts: withParams(func(ctx context.Context, api CoreAPI, p deliveryAttemptsReq) ([]DeliveryAttempt, error) {
|
||||||
|
out, err := api.DeliveryAttempts(ctx, p.Status, p.N)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []DeliveryAttempt{}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}),
|
||||||
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
||||||
out, err := api.RecentNudges(ctx, p.N)
|
out, err := api.RecentNudges(ctx, p.N)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -975,7 +1003,7 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
return map[string]int64{"new_id": newID}, nil
|
return map[string]int64{"new_id": newID}, nil
|
||||||
}),
|
}),
|
||||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||||
reply, err := api.Chat(ctx, p.Text)
|
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||||
return chatResp{Reply: reply}, err
|
return chatResp{Reply: reply}, err
|
||||||
}),
|
}),
|
||||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||||
@@ -1268,7 +1296,7 @@ func (s *Server) Close() error {
|
|||||||
// missing the seal costs every write since the last clean shutdown.
|
// missing the seal costs every write since the last clean shutdown.
|
||||||
log.Printf("ipc: %d connection(s) still busy after %s, closing anyway", s.liveConns(), closeGrace)
|
log.Printf("ipc: %d connection(s) still busy after %s, closing anyway", s.liveConns(), closeGrace)
|
||||||
}
|
}
|
||||||
_ = os.Remove(s.path)
|
netaddr.Cleanup(s.addr)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,25 +1366,6 @@ func (s *Server) Path() string { return s.path }
|
|||||||
// while the server is serving (dispatch loads api once per request via atomic).
|
// while the server is serving (dispatch loads api once per request via atomic).
|
||||||
func (s *Server) SetAPI(api CoreAPI) { s.api.Store(api) }
|
func (s *Server) SetAPI(api CoreAPI) { s.api.Store(api) }
|
||||||
|
|
||||||
func parentDir(p string) string {
|
|
||||||
if i := lastIndexByte(p, '/'); i >= 0 {
|
|
||||||
if i == 0 {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return p[:i]
|
|
||||||
}
|
|
||||||
return "."
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastIndexByte(s string, b byte) int {
|
|
||||||
for i := len(s) - 1; i >= 0; i-- {
|
|
||||||
if s[i] == b {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// peerCaller — read SO_PEERCRED off a unix conn to identify the connecting
|
// peerCaller — read SO_PEERCRED off a unix conn to identify the connecting
|
||||||
// process. Returns ok=false on a non-unix conn or a platform without
|
// process. Returns ok=false on a non-unix conn or a platform without
|
||||||
// SO_PEERCRED; the caller then proceeds without a Caller (the socket perms
|
// SO_PEERCRED; the caller then proceeds without a Caller (the socket perms
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind st
|
|||||||
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCoreAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||||
|
return nil, ErrNotImplemented
|
||||||
|
}
|
||||||
func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus,
|
|||||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||||
return DayPlan{}, ErrNotImplemented
|
return DayPlan{}, ErrNotImplemented
|
||||||
}
|
}
|
||||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
return "", ErrNotImplemented
|
return "", ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const (
|
|||||||
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
|
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
|
||||||
MethodCalendarEvents Method = "calendar_events"
|
MethodCalendarEvents Method = "calendar_events"
|
||||||
MethodRecentNudges Method = "recent_nudges"
|
MethodRecentNudges Method = "recent_nudges"
|
||||||
|
MethodDeliveryAttempts Method = "delivery_attempts"
|
||||||
MethodRecentEcoTraces Method = "recent_ecosystem_traces"
|
MethodRecentEcoTraces Method = "recent_ecosystem_traces"
|
||||||
MethodWriteNote Method = "write_note"
|
MethodWriteNote Method = "write_note"
|
||||||
MethodQueryNotes Method = "query_notes"
|
MethodQueryNotes Method = "query_notes"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type Completer interface {
|
|||||||
// or reply in Russian.
|
// or reply in Russian.
|
||||||
//
|
//
|
||||||
// Why the JSON wrapper: this model always thinks out loud and this llama-server
|
// Why the JSON wrapper: this model always thinks out loud and this llama-server
|
||||||
// build ignores the thinking switch (see ROUTING-EVAL-31-07-2026.md). A bare
|
// build ignores the thinking switch (see docs/evals/2026-07-31-routing.md). A bare
|
||||||
// word-list grammar just captured the reasoning — every case came back as
|
// word-list grammar just captured the reasoning — every case came back as
|
||||||
// "Let me analyze this request carefully". Demanding JSON, like routeGrammar and
|
// "Let me analyze this request carefully". Demanding JSON, like routeGrammar and
|
||||||
// responseGrammar already do, gives the reasoning nowhere to go.
|
// responseGrammar already do, gives the reasoning nowhere to go.
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ type Req struct {
|
|||||||
RepeatPenalty float64
|
RepeatPenalty float64
|
||||||
// Stop — sequences that end generation early (e.g. newline for a one-liner).
|
// Stop — sequences that end generation early (e.g. newline for a one-liner).
|
||||||
Stop []string
|
Stop []string
|
||||||
|
// Temperature — 0 (the zero value) is greedy decoding, and greedy is what
|
||||||
|
// every caller here wanted before this field existed. It is set only by the
|
||||||
|
// phraser, whose own transport has always sampled at 0.7: routing a phrasing
|
||||||
|
// call through this client must not quietly change how it decodes.
|
||||||
|
Temperature float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type msg struct {
|
type msg struct {
|
||||||
@@ -176,7 +181,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
|||||||
Messages: []msg{{Role: "system", Content: r.System}, {Role: "user", Content: r.User}},
|
Messages: []msg{{Role: "system", Content: r.System}, {Role: "user", Content: r.User}},
|
||||||
MaxTokens: r.MaxTokens,
|
MaxTokens: r.MaxTokens,
|
||||||
Grammar: r.Grammar,
|
Grammar: r.Grammar,
|
||||||
Temp: 0,
|
Temp: r.Temperature,
|
||||||
RepeatPenalty: r.RepeatPenalty,
|
RepeatPenalty: r.RepeatPenalty,
|
||||||
Stop: r.Stop,
|
Stop: r.Stop,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pair — a preferred model on another host, with the resident one as the floor.
|
||||||
|
//
|
||||||
|
// homesrv cannot grow a GPU and the workstation has 16GB of VRAM, so the big
|
||||||
|
// model runs there and the resident Qwen3-1.7B stays here. See docs/offload.md.
|
||||||
|
// The workstation is never assumed up: its GPU is often busy with CPT runs and
|
||||||
|
// the manga-recap pipeline, and the machine sleeps. So the remote is preferred,
|
||||||
|
// never required, and Pair is what makes "preferred" mean something precise.
|
||||||
|
//
|
||||||
|
// This is admission control, not a scheduler. There is no arbiter deciding who
|
||||||
|
// gets the card. A prober asks the remote whether it will take work, caches the
|
||||||
|
// answer, and every request reads that cached answer in nanoseconds. Routing
|
||||||
|
// sits on the hot path at p50 825ms and must never wait on a machine that may
|
||||||
|
// be asleep, so no request ever pays for a health check itself.
|
||||||
|
//
|
||||||
|
// Pair satisfies nothing by itself. Callers pick a method by which half of the
|
||||||
|
// degradation rule they live under:
|
||||||
|
//
|
||||||
|
// - Complete falls back silently. For routing, replies, and nudge phrasing,
|
||||||
|
// where the big model is only better and the 1.7B is today's shipping
|
||||||
|
// quality. He is not told which model phrased his reply.
|
||||||
|
// - CompleteRemote returns ErrRemoteUnavailable instead of falling back. For
|
||||||
|
// a world question, or a long Kiwix or search passage, where a 1.7B
|
||||||
|
// confabulates rather than summarises. A named gap beats an invented
|
||||||
|
// answer.
|
||||||
|
type Pair struct {
|
||||||
|
remote *Client
|
||||||
|
floor *Client
|
||||||
|
|
||||||
|
// up — the cached admission answer, written only by the prober goroutine
|
||||||
|
// and read by every request. Atomic so the read costs nanoseconds and no
|
||||||
|
// request ever contends with the prober.
|
||||||
|
up atomic.Bool
|
||||||
|
|
||||||
|
health string
|
||||||
|
interval time.Duration
|
||||||
|
http *http.Client
|
||||||
|
stop chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrRemoteUnavailable — the workstation model was required and is not
|
||||||
|
// answering. Callers on the naming half of the degradation rule turn this into
|
||||||
|
// a gap in the reply ("не могу сейчас"), never into a guess from the floor.
|
||||||
|
var ErrRemoteUnavailable = errors.New("llm: workstation model unavailable")
|
||||||
|
|
||||||
|
// ErrNoFloor — a Pair was built with no resident model to fall back to. A
|
||||||
|
// configuration mistake: the floor is the whole point.
|
||||||
|
var ErrNoFloor = errors.New("llm: no floor client")
|
||||||
|
|
||||||
|
// NewPair builds the two-model arrangement. remote may be nil, which is the
|
||||||
|
// unconfigured deploy and must behave exactly as the box behaves today: every
|
||||||
|
// call goes to the floor and nothing probes anything.
|
||||||
|
//
|
||||||
|
// health is the URL the prober asks. llama-server's /health answers "is a model
|
||||||
|
// loaded and ready", which is the useful signal here, because llama-server
|
||||||
|
// refuses to load at all when VRAM is short. That makes a busy card detectable
|
||||||
|
// without any cooperation from the owner's other jobs.
|
||||||
|
func NewPair(remote, floor *Client, health string, interval time.Duration) *Pair {
|
||||||
|
p := &Pair{
|
||||||
|
remote: remote,
|
||||||
|
floor: floor,
|
||||||
|
health: health,
|
||||||
|
interval: interval,
|
||||||
|
http: &http.Client{Timeout: probeTimeout},
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeTimeout — a remote that cannot answer /health this fast is not going to
|
||||||
|
// serve a turn either. Short on purpose: the prober runs on its own goroutine,
|
||||||
|
// but a slow probe still delays the moment Maven notices the card came back.
|
||||||
|
const probeTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
// Start begins probing. It returns immediately, and the first probe runs before
|
||||||
|
// the first tick so a remote that is already up is used on the first turn
|
||||||
|
// rather than after one interval of falling back. Safe to call with a nil
|
||||||
|
// remote; it does nothing.
|
||||||
|
func (p *Pair) Start(ctx context.Context) {
|
||||||
|
if p.remote == nil || p.health == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
p.probe(ctx)
|
||||||
|
t := time.NewTicker(p.interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-p.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
p.probe(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ends the prober. Idempotent.
|
||||||
|
func (p *Pair) Stop() {
|
||||||
|
select {
|
||||||
|
case <-p.stop:
|
||||||
|
default:
|
||||||
|
close(p.stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available reports whether the workstation will take work right now. It reads
|
||||||
|
// a cached flag, so it is safe to call per turn on the hot path. A false answer
|
||||||
|
// is never stale in the direction that matters: the worst case is that Maven
|
||||||
|
// falls back for up to one probe interval after the card frees up.
|
||||||
|
func (p *Pair) Available() bool {
|
||||||
|
return p.remote != nil && p.up.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pair) probe(ctx context.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.health, nil)
|
||||||
|
if err != nil {
|
||||||
|
p.set(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := p.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
p.set(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
p.set(resp.StatusCode == http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set records the admission answer and logs only the transitions. A machine
|
||||||
|
// that sleeps every night would otherwise write one line per interval forever.
|
||||||
|
func (p *Pair) set(up bool) {
|
||||||
|
if p.up.Swap(up) == up {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if up {
|
||||||
|
log.Printf("llm: workstation model available at %s", p.health)
|
||||||
|
} else {
|
||||||
|
log.Printf("llm: workstation model unavailable, falling back to the resident model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete runs r on the workstation when it will take work, and on the
|
||||||
|
// resident model otherwise. A remote that fails mid-request falls back too: the
|
||||||
|
// admission answer is a cache and can be one interval out of date, so an error
|
||||||
|
// here is expected rather than exceptional.
|
||||||
|
//
|
||||||
|
// This is the silent half of the degradation rule. It must be indistinguishable
|
||||||
|
// from today's behaviour when the workstation is down.
|
||||||
|
func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
|
||||||
|
if p.floor == nil {
|
||||||
|
return "", ErrNoFloor
|
||||||
|
}
|
||||||
|
if p.Available() {
|
||||||
|
out, err := p.remote.Complete(ctx, r)
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
// The cached answer was wrong. Correct it now rather than sending the
|
||||||
|
// next request into the same hole, then fall back.
|
||||||
|
p.set(false)
|
||||||
|
}
|
||||||
|
return p.floor.Complete(ctx, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteRemote runs r on the workstation or refuses. It never falls back,
|
||||||
|
// because for a world question the resident 1.7B does not answer worse, it
|
||||||
|
// invents. Callers turn ErrRemoteUnavailable into a named gap.
|
||||||
|
func (p *Pair) CompleteRemote(ctx context.Context, r Req) (string, error) {
|
||||||
|
if !p.Available() {
|
||||||
|
return "", ErrRemoteUnavailable
|
||||||
|
}
|
||||||
|
out, err := p.remote.Complete(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
p.set(false)
|
||||||
|
return "", errors.Join(ErrRemoteUnavailable, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// completionServer stands in for a llama-server. It counts what reached it, so
|
||||||
|
// a test can say which of the two models answered.
|
||||||
|
func completionServer(t *testing.T, reply string, hits *atomic.Int64) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"` + reply + `"}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthServer(t *testing.T, ok *atomic.Bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !ok.Load() {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor polls until cond holds or the deadline passes. The prober runs on its
|
||||||
|
// own goroutine, so a test has to wait for it rather than assume it has run.
|
||||||
|
func waitFor(t *testing.T, cond func() bool) bool {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if cond() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unconfigured deploy. No remote, no probing, every call to the floor —
|
||||||
|
// exactly what the box does today.
|
||||||
|
func TestNoRemoteGoesToTheFloor(t *testing.T) {
|
||||||
|
var floorHits atomic.Int64
|
||||||
|
floor := completionServer(t, "floor", &floorHits)
|
||||||
|
|
||||||
|
p := NewPair(nil, New(floor.URL, time.Second), "", time.Second)
|
||||||
|
p.Start(context.Background())
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
if p.Available() {
|
||||||
|
t.Fatal("a Pair with no remote reports available")
|
||||||
|
}
|
||||||
|
out, err := p.Complete(context.Background(), Req{User: "привет"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if out != "floor" || floorHits.Load() != 1 {
|
||||||
|
t.Fatalf("out = %q, floor hits = %d", out, floorHits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The workstation is up, so it answers and the resident model is not touched.
|
||||||
|
func TestAvailableRemoteAnswers(t *testing.T) {
|
||||||
|
var remoteHits, floorHits atomic.Int64
|
||||||
|
remote := completionServer(t, "remote", &remoteHits)
|
||||||
|
floor := completionServer(t, "floor", &floorHits)
|
||||||
|
up := &atomic.Bool{}
|
||||||
|
up.Store(true)
|
||||||
|
health := healthServer(t, up)
|
||||||
|
|
||||||
|
p := NewPair(New(remote.URL, time.Second), New(floor.URL, time.Second), health.URL, 20*time.Millisecond)
|
||||||
|
p.Start(context.Background())
|
||||||
|
defer p.Stop()
|
||||||
|
if !waitFor(t, p.Available) {
|
||||||
|
t.Fatal("prober never saw the remote come up")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := p.Complete(context.Background(), Req{User: "привет"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if out != "remote" || floorHits.Load() != 0 {
|
||||||
|
t.Fatalf("out = %q, floor hits = %d", out, floorHits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card is busy, so /health refuses and Complete degrades silently. This is
|
||||||
|
// the constraint from 483: the workstation being down is indistinguishable from
|
||||||
|
// today's behaviour.
|
||||||
|
func TestBusyCardFallsBackSilently(t *testing.T) {
|
||||||
|
var remoteHits, floorHits atomic.Int64
|
||||||
|
remote := completionServer(t, "remote", &remoteHits)
|
||||||
|
floor := completionServer(t, "floor", &floorHits)
|
||||||
|
health := healthServer(t, &atomic.Bool{}) // never ok
|
||||||
|
|
||||||
|
p := NewPair(New(remote.URL, time.Second), New(floor.URL, time.Second), health.URL, 20*time.Millisecond)
|
||||||
|
p.Start(context.Background())
|
||||||
|
defer p.Stop()
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
|
||||||
|
out, err := p.Complete(context.Background(), Req{User: "привет"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if out != "floor" || remoteHits.Load() != 0 {
|
||||||
|
t.Fatalf("out = %q, remote hits = %d", out, remoteHits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cached admission answer can be one interval out of date, so a remote that
|
||||||
|
// dies between probes must still not break the turn.
|
||||||
|
func TestRemoteErrorMidRequestFallsBack(t *testing.T) {
|
||||||
|
var floorHits atomic.Int64
|
||||||
|
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer dead.Close()
|
||||||
|
floor := completionServer(t, "floor", &floorHits)
|
||||||
|
up := &atomic.Bool{}
|
||||||
|
up.Store(true)
|
||||||
|
health := healthServer(t, up)
|
||||||
|
|
||||||
|
p := NewPair(New(dead.URL, time.Second), New(floor.URL, time.Second), health.URL, time.Hour)
|
||||||
|
p.Start(context.Background())
|
||||||
|
defer p.Stop()
|
||||||
|
if !waitFor(t, p.Available) {
|
||||||
|
t.Fatal("prober never saw the remote come up")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := p.Complete(context.Background(), Req{User: "привет"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if out != "floor" || floorHits.Load() != 1 {
|
||||||
|
t.Fatalf("out = %q, floor hits = %d", out, floorHits.Load())
|
||||||
|
}
|
||||||
|
// The failed request must have corrected the cached answer, so the next
|
||||||
|
// one does not walk into the same hole.
|
||||||
|
if p.Available() {
|
||||||
|
t.Fatal("a failed remote request left the admission answer up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The naming half of the degradation rule. A world question must not be handed
|
||||||
|
// to the resident model, because it answers by inventing.
|
||||||
|
func TestCompleteRemoteNamesTheGap(t *testing.T) {
|
||||||
|
var floorHits atomic.Int64
|
||||||
|
floor := completionServer(t, "floor", &floorHits)
|
||||||
|
health := healthServer(t, &atomic.Bool{}) // never ok
|
||||||
|
|
||||||
|
p := NewPair(New("http://127.0.0.1:1", time.Second), New(floor.URL, time.Second), health.URL, 20*time.Millisecond)
|
||||||
|
p.Start(context.Background())
|
||||||
|
defer p.Stop()
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := p.CompleteRemote(context.Background(), Req{User: "почему небо голубое"}); !errors.Is(err, ErrRemoteUnavailable) {
|
||||||
|
t.Fatalf("err = %v, want ErrRemoteUnavailable", err)
|
||||||
|
}
|
||||||
|
if floorHits.Load() != 0 {
|
||||||
|
t.Fatalf("CompleteRemote fell back to the floor %d times", floorHits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routing sits on the hot path and must never pay for a health check. Available
|
||||||
|
// reads a cached flag, so it costs no network at all.
|
||||||
|
func TestAvailableDoesNotProbe(t *testing.T) {
|
||||||
|
var probes atomic.Int64
|
||||||
|
health := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
probes.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer health.Close()
|
||||||
|
|
||||||
|
p := NewPair(New("http://127.0.0.1:1", time.Second), New("http://127.0.0.1:1", time.Second), health.URL, time.Hour)
|
||||||
|
p.Start(context.Background())
|
||||||
|
defer p.Stop()
|
||||||
|
if !waitFor(t, p.Available) {
|
||||||
|
t.Fatal("prober never ran")
|
||||||
|
}
|
||||||
|
|
||||||
|
before := probes.Load()
|
||||||
|
for range 1000 {
|
||||||
|
p.Available()
|
||||||
|
}
|
||||||
|
if got := probes.Load(); got != before {
|
||||||
|
t.Fatalf("1000 Available calls made %d probes", got-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Pair with no floor is a configuration mistake, and it must say so rather
|
||||||
|
// than silently having nowhere to degrade to.
|
||||||
|
func TestNoFloorIsAnError(t *testing.T) {
|
||||||
|
p := NewPair(nil, nil, "", time.Second)
|
||||||
|
if _, err := p.Complete(context.Background(), Req{User: "привет"}); !errors.Is(err, ErrNoFloor) {
|
||||||
|
t.Fatalf("err = %v, want ErrNoFloor", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
|
|
||||||
// Tests for the universal restraint gate.
|
// Tests for the universal restraint gate.
|
||||||
//
|
//
|
||||||
// DESIGN.md § Trigger model: "the gate is universal, applied by the loop, never
|
// docs/design.md § Trigger model: "the gate is universal, applied by the loop, never
|
||||||
// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in
|
// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in
|
||||||
// one fires()." These tests pin the CONSERVATIVE side of that: the cases where
|
// one fires()." These tests pin the CONSERVATIVE side of that: the cases where
|
||||||
// Maven must stay quiet. They exist so nobody loosens the gate by accident.
|
// Maven must stay quiet. They exist so nobody loosens the gate by accident.
|
||||||
//
|
//
|
||||||
// Where the code does not yet do what DESIGN.md promises, the test is written to
|
// Where the code does not yet do what docs/design.md promises, the test is written to
|
||||||
// show the gap and then skipped, with the file and line to fix. Behaviour is not
|
// show the gap and then skipped, with the file and line to fix. Behaviour is not
|
||||||
// changed to make a test pass.
|
// changed to make a test pass.
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ func TestGateQuietHoursSuppressesCareOnly(t *testing.T) {
|
|||||||
|
|
||||||
// ---------------------------- presence ---------------------------------------
|
// ---------------------------- presence ---------------------------------------
|
||||||
|
|
||||||
// DESIGN.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water
|
// docs/design.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water
|
||||||
// nudge is noise, a missed backup failure isn't."
|
// nudge is noise, a missed backup failure isn't."
|
||||||
func TestGateAwayDropsCareHoldsOps(t *testing.T) {
|
func TestGateAwayDropsCareHoldsOps(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -250,7 +250,7 @@ func TestTickOrderOfRulesDoesNotMatter(t *testing.T) {
|
|||||||
|
|
||||||
// ---------------------------- reminders bypass the gate ----------------------
|
// ---------------------------- reminders bypass the gate ----------------------
|
||||||
|
|
||||||
// DESIGN.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires
|
// docs/design.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires
|
||||||
// in quiet hours; that's the point." Every suppressor set at once, and the
|
// in quiet hours; that's the point." Every suppressor set at once, and the
|
||||||
// reminder still comes through.
|
// reminder still comes through.
|
||||||
func TestRemindersBypassEverySuppressor(t *testing.T) {
|
func TestRemindersBypassEverySuppressor(t *testing.T) {
|
||||||
@@ -269,7 +269,7 @@ func TestRemindersBypassEverySuppressor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GAP — DESIGN.md § User reminders ends "Snooze still applies." RemindDecisions
|
// GAP — docs/design.md § User reminders ends "Snooze still applies." RemindDecisions
|
||||||
// passes every due reminder straight through with no snooze check, so a snoozed
|
// passes every due reminder straight through with no snooze check, so a snoozed
|
||||||
// reminder fires anyway. The test below is what the contract asks for.
|
// reminder fires anyway. The test below is what the contract asks for.
|
||||||
func TestRemindersStillHonourSnooze(t *testing.T) {
|
func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
// - does it stay quiet when it should?
|
// - does it stay quiet when it should?
|
||||||
// - is it silent when the key it needs has no data at all?
|
// - is it silent when the key it needs has no data at all?
|
||||||
//
|
//
|
||||||
// The last one is load-bearing. DESIGN.md: "since(key)==null → don't fire.
|
// The last one is load-bearing. docs/design.md: "since(key)==null → don't fire.
|
||||||
// Silence on no-data is 'shuts up when uncertain'."
|
// Silence on no-data is 'shuts up when uncertain'."
|
||||||
|
|
||||||
// stateWith builds a snapshot at refTime() holding just the given facts.
|
// stateWith builds a snapshot at refTime() holding just the given facts.
|
||||||
@@ -179,7 +179,7 @@ func TestCareRulePredicates(t *testing.T) {
|
|||||||
|
|
||||||
// ---------------------------- ops rules --------------------------------------
|
// ---------------------------- ops rules --------------------------------------
|
||||||
|
|
||||||
// The two ops rules match on a value AND on which poller wrote it. DESIGN.md:
|
// The two ops rules match on a value AND on which poller wrote it. docs/design.md:
|
||||||
// "a compromised poller must not be able to forge a trigger." Half of this
|
// "a compromised poller must not be able to forge a trigger." Half of this
|
||||||
// table is forgery attempts; all of them must be refused.
|
// table is forgery attempts; all of them must be refused.
|
||||||
func TestOpsRulePredicates(t *testing.T) {
|
func TestOpsRulePredicates(t *testing.T) {
|
||||||
@@ -331,7 +331,7 @@ func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Severities are the delivery contract (DESIGN.md § Delivery / channel
|
// Severities are the delivery contract (docs/design.md § Delivery / channel
|
||||||
// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin
|
// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin
|
||||||
// them so a change to a rule's insistence has to be deliberate.
|
// them so a change to a rule's insistence has to be deliberate.
|
||||||
func TestDefaultRuleSeverities(t *testing.T) {
|
func TestDefaultRuleSeverities(t *testing.T) {
|
||||||
@@ -356,7 +356,7 @@ func TestDefaultRuleSeverities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooldown bounds keep the feedback tuner honest — DESIGN.md wants
|
// Cooldown bounds keep the feedback tuner honest — docs/design.md wants
|
||||||
// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or
|
// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or
|
||||||
// stalker". A base outside its own envelope would make that meaningless.
|
// stalker". A base outside its own envelope would make that meaningless.
|
||||||
func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stopwords — words that carry no topic. A question and a note that share only
|
||||||
|
// these share nothing: "почему небо синее" and "сеть какая-то медленная" both
|
||||||
|
// contain "какая"-shaped filler and are about different worlds.
|
||||||
|
var stopwords = map[string]bool{
|
||||||
|
// interrogatives and demonstratives
|
||||||
|
"что": true, "чего": true, "какой": true, "какая": true, "какое": true,
|
||||||
|
"какие": true, "каких": true, "кто": true, "кого": true, "кому": true,
|
||||||
|
"почему": true, "зачем": true, "где": true, "куда": true, "откуда": true,
|
||||||
|
"когда": true, "сколько": true, "как": true, "то": true, "это": true,
|
||||||
|
"этот": true, "тот": true, "там": true, "тут": true, "такой": true,
|
||||||
|
// pronouns — every sentence he says is about him, so "я" is not a topic
|
||||||
|
"я": true, "меня": true, "мне": true, "мой": true, "моя": true, "мои": true,
|
||||||
|
"ты": true, "тебя": true, "тебе": true, "твой": true, "он": true, "она": true,
|
||||||
|
"они": true, "мы": true, "себя": true, "свой": true,
|
||||||
|
// prepositions, conjunctions, particles, copulas
|
||||||
|
"в": true, "во": true, "на": true, "с": true, "со": true, "у": true,
|
||||||
|
"о": true, "об": true, "про": true, "за": true, "из": true, "по": true,
|
||||||
|
"до": true, "от": true, "для": true, "над": true, "под": true, "при": true,
|
||||||
|
"и": true, "а": true, "но": true, "или": true, "же": true, "ли": true,
|
||||||
|
"не": true, "ни": true, "бы": true, "был": true, "была": true, "было": true,
|
||||||
|
"быть": true, "есть": true, "был-ли": true, "уже": true, "ещё": true,
|
||||||
|
"еще": true, "так": true, "вот": true, "там-же": true,
|
||||||
|
// English filler, for the mixed utterances he does say
|
||||||
|
"the": true, "a": true, "an": true, "is": true, "are": true, "was": true,
|
||||||
|
"were": true, "be": true, "of": true, "in": true, "on": true, "at": true,
|
||||||
|
"to": true, "for": true, "about": true, "and": true, "or": true, "not": true,
|
||||||
|
"what": true, "who": true, "why": true, "when": true, "where": true,
|
||||||
|
"which": true, "how": true, "i": true, "my": true, "me": true, "it": true,
|
||||||
|
"this": true, "that": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstPerson — the words that make an utterance a question about his own
|
||||||
|
// life. Not possession only: "как я восстановил конфиги" owns nothing and is
|
||||||
|
// still about him.
|
||||||
|
var firstPerson = map[string]bool{
|
||||||
|
"я": true, "меня": true, "мне": true, "мной": true, "мой": true,
|
||||||
|
"моя": true, "моё": true, "мое": true, "мои": true, "моего": true,
|
||||||
|
"моей": true, "моих": true, "моим": true, "себя": true, "свой": true,
|
||||||
|
"своя": true, "свои": true, "своего": true, "мною": true,
|
||||||
|
"i": true, "me": true, "my": true, "mine": true, "myself": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecallAllowed is the second half of the recall gate (#470). A hit that
|
||||||
|
// cleared the score and margin gate may still be about something else
|
||||||
|
// entirely: the held-out fixture puts the right note at 0.791-0.890 and the
|
||||||
|
// must-be-silent cases at 0.795-0.835, so no threshold sits between them, and
|
||||||
|
// a note about his slow network answered "почему небо синее?".
|
||||||
|
//
|
||||||
|
// The veto applies only to a question that mentions nothing of his. That
|
||||||
|
// restriction is what keeps the fix from costing more than it saves: recall
|
||||||
|
// exists to find the note whose words he no longer remembers, and demanding a
|
||||||
|
// shared word of every recall silenced four true recalls on the fixture to
|
||||||
|
// kill one false one. A question about his own life keeps the embedder alone
|
||||||
|
// as its judge. A question about the world has to name something the memory
|
||||||
|
// actually mentions.
|
||||||
|
//
|
||||||
|
// The veto's price was re-measured on 2026-08-03 (#496,
|
||||||
|
// docs/evals/2026-08-03-recall-topic-veto.md). It costs one true recall and
|
||||||
|
// buys one false one, and the fixture pass count is the same either way. The
|
||||||
|
// lost case is an English paraphrase, not the cross-language loss it was
|
||||||
|
// reported as, and the fixture has no cross-language case at all. Do not add a
|
||||||
|
// script test or a bilingual stem map for it — both are no-ops here. The
|
||||||
|
// separating signal is semantic and belongs in a reranker, not in this file.
|
||||||
|
func RecallAllowed(query, text string) bool {
|
||||||
|
if mentionsHim(query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return SharesContentWord(query, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mentionsHim(query string) bool {
|
||||||
|
for _, w := range strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
}) {
|
||||||
|
if firstPerson[w] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharesContentWord reports whether query and text have at least one topic
|
||||||
|
// word in common, after dropping the words that carry no topic. Stems are
|
||||||
|
// compared, so the note and the question do not have to inflect alike.
|
||||||
|
func SharesContentWord(query, text string) bool {
|
||||||
|
q := contentWords(query)
|
||||||
|
if len(q) == 0 {
|
||||||
|
// Nothing to compare — a question made entirely of filler. The score
|
||||||
|
// gate is then the only judge it can have.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
t := contentWords(text)
|
||||||
|
for _, a := range q {
|
||||||
|
for _, b := range t {
|
||||||
|
if a == b || sameStem(a, b) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentWords(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
}) {
|
||||||
|
if !stopwords[w] {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameStem is inflection and derivation tolerance: Russian marks case and
|
||||||
|
// tense on the ending, and the note and the question rarely use the same form.
|
||||||
|
// "воду" and "вода" are the same water, "кормить" and "корм" the same feeding.
|
||||||
|
// All but the last rune of the shorter word must match, and never fewer than
|
||||||
|
// three, which is what keeps "сеть" clear of "сеанс".
|
||||||
|
func sameStem(a, b string) bool {
|
||||||
|
ar, br := []rune(a), []rune(b)
|
||||||
|
n := min(len(ar), len(br)) - 1
|
||||||
|
if n < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(ar[:n]) == string(br[:n])
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRecallAllowed(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
query, text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// The #470 shape: a world question and a note about his box.
|
||||||
|
{"world question, unrelated note", "почему небо синее", "сеть какая-то медленная", false},
|
||||||
|
{"world question, unrelated fact", "какая столица Франции", "какая последняя версия языка Go", false},
|
||||||
|
{"silent fixture case", "во сколько отходит поезд", "бэкап запускается в три ночи", false},
|
||||||
|
|
||||||
|
// A world question that does name the topic keeps its answer.
|
||||||
|
{"world question, same topic", "какой поезд идёт в Минск", "поезда в Минск ходят утром", true},
|
||||||
|
|
||||||
|
// A question about his own life is judged by the embedder alone,
|
||||||
|
// because recall exists for words he no longer remembers.
|
||||||
|
{"about him, no shared word", "во сколько я обычно засыпаю", "ложусь около одиннадцати", true},
|
||||||
|
{"about him, english", "which colour scheme do i like", "тёмная тема везде", true},
|
||||||
|
|
||||||
|
// Inflection must not break a match.
|
||||||
|
{"inflected", "чем кормить кота", "корм для кота в шкафу", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := RecallAllowed(c.query, c.text); got != c.want {
|
||||||
|
t.Errorf("%s: RecallAllowed(%q, %q) = %v, want %v", c.name, c.query, c.text, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The known cost of the veto and the thing that pays for it, both measured on
|
||||||
|
// the held-out fixture with the real embedder (#496,
|
||||||
|
// docs/evals/2026-08-03-recall-topic-veto.md). The two are one lexical class:
|
||||||
|
// zero shared content words, no first-person marker, scores 0.826 against 0.835
|
||||||
|
// and margins 0.023 against 0.019. Recovering the first re-admits the second,
|
||||||
|
// which puts false recall back to 1/5. Anyone loosening the veto has to move
|
||||||
|
// the first line without moving the second.
|
||||||
|
func TestRecallVetoTradeIsPinned(t *testing.T) {
|
||||||
|
if RecallAllowed("what fixed the screen problem", "the flicker went away once i swapped the display cable") {
|
||||||
|
t.Error("en-hard-024 is expected to stay vetoed — if this passes now, re-measure false recall before celebrating")
|
||||||
|
}
|
||||||
|
if RecallAllowed("во сколько отходит поезд", "погулял вдоль реки") {
|
||||||
|
t.Error("ru-silent-029 must stay vetoed — this is the false recall the veto exists to stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A question made only of filler has no topic word to match on, and the score
|
||||||
|
// gate is then the only judge it can have.
|
||||||
|
func TestRecallAllowedFallsBackWhenNothingToCompare(t *testing.T) {
|
||||||
|
if !RecallAllowed("что это", "сеть какая-то медленная") {
|
||||||
|
t.Error("a question with no content word must not be vetoed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,9 +90,44 @@ func Load() (Fixture, error) {
|
|||||||
if len(f.Cases) == 0 {
|
if len(f.Cases) == 0 {
|
||||||
return Fixture{}, fmt.Errorf("fixture has no cases")
|
return Fixture{}, fmt.Errorf("fixture has no cases")
|
||||||
}
|
}
|
||||||
|
if err := checkIDs(f); err != nil {
|
||||||
|
return Fixture{}, err
|
||||||
|
}
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkIDs refuses a fixture where a case note and a filler note share an id.
|
||||||
|
//
|
||||||
|
// Every case is scored over its own notes plus the whole filler set, and the
|
||||||
|
// two stores disagree about what a repeated id means: the sqlite store upserts
|
||||||
|
// on it, the in-memory store appends. So one collision makes a case score
|
||||||
|
// differently on the two backends, and it reads as an embedder or gate
|
||||||
|
// difference, which is the one thing this harness exists to measure (Vikunja
|
||||||
|
// #386). It was dodged once by hand during #373 by renaming two ids.
|
||||||
|
//
|
||||||
|
// Checked in Load rather than in the test, so every caller of the fixture is
|
||||||
|
// covered and not only the one that remembers to look.
|
||||||
|
func checkIDs(f Fixture) error {
|
||||||
|
filler := make(map[string]bool, len(f.Filler))
|
||||||
|
for _, n := range f.Filler {
|
||||||
|
if n.ID == "" {
|
||||||
|
return fmt.Errorf("filler note with an empty id")
|
||||||
|
}
|
||||||
|
if filler[n.ID] {
|
||||||
|
return fmt.Errorf("duplicate filler note id %q", n.ID)
|
||||||
|
}
|
||||||
|
filler[n.ID] = true
|
||||||
|
}
|
||||||
|
for _, c := range f.Cases {
|
||||||
|
for _, n := range c.Notes {
|
||||||
|
if filler[n.ID] {
|
||||||
|
return fmt.Errorf("case %s: note id %q collides with a filler note", c.ID, n.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewStore builds an empty store for one case, plus a function to release it.
|
// NewStore builds an empty store for one case, plus a function to release it.
|
||||||
// A factory rather than a store because every case needs a clean index — notes
|
// A factory rather than a store because every case needs a clean index — notes
|
||||||
// from case A must not be visible to case B's query.
|
// from case A must not be visible to case B's query.
|
||||||
@@ -378,7 +413,7 @@ func scoreCase(ctx context.Context, emb router.Embedder, newStore NewStore, minS
|
|||||||
if len(hits) > 1 {
|
if len(hits) > 1 {
|
||||||
o.Margin = hits[0].Score - hits[1].Score
|
o.Margin = hits[0].Score - hits[1].Score
|
||||||
}
|
}
|
||||||
o.Recalled = bestRecall(hits, minScore, minMargin)
|
o.Recalled = bestRecall(c.Query, hits, minScore, minMargin)
|
||||||
}
|
}
|
||||||
for i, h := range hits {
|
for i, h := range hits {
|
||||||
if h.ID != c.Want {
|
if h.ID != c.Want {
|
||||||
@@ -424,11 +459,19 @@ func rankNote(inTop3 bool) string {
|
|||||||
// is not importable; recalleval_test.go asserts the two agree in behaviour.
|
// is not importable; recalleval_test.go asserts the two agree in behaviour.
|
||||||
// The daemon returns the whole hit (a note and a fact are said differently);
|
// The daemon returns the whole hit (a note and a fact are said differently);
|
||||||
// the harness only scores what came back, so it keeps returning the text.
|
// the harness only scores what came back, so it keeps returning the text.
|
||||||
func bestRecall(results []memory.Result, minScore, minMargin float64) string {
|
// bestRecall mirrors the daemon's gate in cmd/mavend/recall.go, including the
|
||||||
|
// topic veto added for #470: a score that clears the gate still has to be
|
||||||
|
// about what he asked. Keep the two in step — a fixture that measures a
|
||||||
|
// weaker gate than the daemon runs flatters it.
|
||||||
|
func bestRecall(query string, results []memory.Result, minScore, minMargin float64) string {
|
||||||
if !memory.Confident(results, minScore, minMargin) {
|
if !memory.Confident(results, minScore, minMargin) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return results[0].Meta["text"]
|
text := results[0].Meta["text"]
|
||||||
|
if !memory.RecallAllowed(query, text) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
func bump(m map[string]TagStat, key string, pass bool) {
|
func bump(m map[string]TagStat, key string, pass bool) {
|
||||||
|
|||||||
@@ -140,21 +140,22 @@ func words(s string) []string {
|
|||||||
|
|
||||||
// TestBestRecallMatchesDaemon — the harness duplicates bestRecall from
|
// TestBestRecallMatchesDaemon — the harness duplicates bestRecall from
|
||||||
// cmd/mavend/recall.go (package main is not importable). This pins the copy to
|
// cmd/mavend/recall.go (package main is not importable). This pins the copy to
|
||||||
// the original's three rules: no hits, below the gate, or no text ⇒ silence.
|
// the original's rules: no hits, below the gate, no text, or no shared topic
|
||||||
|
// word ⇒ silence.
|
||||||
func TestBestRecallMatchesDaemon(t *testing.T) {
|
func TestBestRecallMatchesDaemon(t *testing.T) {
|
||||||
if got := bestRecall(nil, 0.55, 0); got != "" {
|
if got := bestRecall("чай", nil, 0.55, 0); got != "" {
|
||||||
t.Errorf("no hits: got %q, want silence", got)
|
t.Errorf("no hits: got %q, want silence", got)
|
||||||
}
|
}
|
||||||
low := []memory.Result{{ID: "a", Score: 0.4, Meta: map[string]string{"text": "чай"}}}
|
low := []memory.Result{{ID: "a", Score: 0.4, Meta: map[string]string{"text": "чай"}}}
|
||||||
if got := bestRecall(low, 0.55, 0); got != "" {
|
if got := bestRecall("чай", low, 0.55, 0); got != "" {
|
||||||
t.Errorf("below gate: got %q, want silence", got)
|
t.Errorf("below gate: got %q, want silence", got)
|
||||||
}
|
}
|
||||||
noText := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{}}}
|
noText := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{}}}
|
||||||
if got := bestRecall(noText, 0.55, 0); got != "" {
|
if got := bestRecall("чай", noText, 0.55, 0); got != "" {
|
||||||
t.Errorf("no text: got %q, want silence", got)
|
t.Errorf("no text: got %q, want silence", got)
|
||||||
}
|
}
|
||||||
ok := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "чай"}}}
|
ok := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "чай"}}}
|
||||||
if got := bestRecall(ok, 0.55, 0); got != "чай" {
|
if got := bestRecall("чай", ok, 0.55, 0); got != "чай" {
|
||||||
t.Errorf("above gate: got %q, want %q", got, "чай")
|
t.Errorf("above gate: got %q, want %q", got, "чай")
|
||||||
}
|
}
|
||||||
// Margin: a close runner-up means the embedder cannot tell the two apart,
|
// Margin: a close runner-up means the embedder cannot tell the two apart,
|
||||||
@@ -163,17 +164,23 @@ func TestBestRecallMatchesDaemon(t *testing.T) {
|
|||||||
{ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}},
|
{ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}},
|
||||||
{ID: "b", Score: 0.85, Meta: map[string]string{"text": "кофе"}},
|
{ID: "b", Score: 0.85, Meta: map[string]string{"text": "кофе"}},
|
||||||
}
|
}
|
||||||
if got := bestRecall(close, 0.55, 0.03); got != "" {
|
if got := bestRecall("чай", close, 0.55, 0.03); got != "" {
|
||||||
t.Errorf("thin margin: got %q, want silence", got)
|
t.Errorf("thin margin: got %q, want silence", got)
|
||||||
}
|
}
|
||||||
if got := bestRecall(close, 0.55, 0); got != "чай" {
|
if got := bestRecall("чай", close, 0.55, 0); got != "чай" {
|
||||||
t.Errorf("margin off: got %q, want %q", got, "чай")
|
t.Errorf("margin off: got %q, want %q", got, "чай")
|
||||||
}
|
}
|
||||||
|
// The topic veto (#470): the score is fine and the note is about
|
||||||
|
// something else.
|
||||||
|
offTopic := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "сеть какая-то медленная"}}}
|
||||||
|
if got := bestRecall("почему небо синее", offTopic, 0.55, 0); got != "" {
|
||||||
|
t.Errorf("off topic: got %q, want silence", got)
|
||||||
|
}
|
||||||
clear := []memory.Result{
|
clear := []memory.Result{
|
||||||
{ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}},
|
{ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}},
|
||||||
{ID: "b", Score: 0.70, Meta: map[string]string{"text": "кофе"}},
|
{ID: "b", Score: 0.70, Meta: map[string]string{"text": "кофе"}},
|
||||||
}
|
}
|
||||||
if got := bestRecall(clear, 0.55, 0.03); got != "чай" {
|
if got := bestRecall("чай", clear, 0.55, 0.03); got != "чай" {
|
||||||
t.Errorf("wide margin: got %q, want %q", got, "чай")
|
t.Errorf("wide margin: got %q, want %q", got, "чай")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,3 +337,28 @@ func marginSweep(t *testing.T, emb router.Embedder, f Fixture) string {
|
|||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFillerIDCollisionIsRefused — the guard that keeps a fixture edit from
|
||||||
|
// looking like a backend difference (Vikunja #386).
|
||||||
|
func TestFillerIDCollisionIsRefused(t *testing.T) {
|
||||||
|
f := Fixture{
|
||||||
|
SchemaVersion: SchemaVersion,
|
||||||
|
Cases: []Case{{ID: "ru-001", Notes: []StoredNote{{ID: "f1", Text: "..."}}}},
|
||||||
|
Filler: []StoredNote{{ID: "f1", Text: "..."}},
|
||||||
|
}
|
||||||
|
if err := checkIDs(f); err == nil {
|
||||||
|
t.Fatal("a case note reusing a filler id must be refused")
|
||||||
|
}
|
||||||
|
f.Filler = append(f.Filler, StoredNote{ID: "f1", Text: "..."})
|
||||||
|
if err := checkIDs(Fixture{SchemaVersion: SchemaVersion, Filler: f.Filler}); err == nil {
|
||||||
|
t.Fatal("a duplicate filler id must be refused")
|
||||||
|
}
|
||||||
|
ok := Fixture{
|
||||||
|
SchemaVersion: SchemaVersion,
|
||||||
|
Cases: []Case{{ID: "ru-001", Notes: []StoredNote{{ID: "n1", Text: "..."}}}},
|
||||||
|
Filler: []StoredNote{{ID: "f1", Text: "..."}},
|
||||||
|
}
|
||||||
|
if err := checkIDs(ok); err != nil {
|
||||||
|
t.Fatalf("a clean fixture must pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"tags": ["preference", "homelab", "paraphrase", "hard"],
|
"tags": ["preference", "homelab", "paraphrase", "hard"],
|
||||||
"query": "когда запускать резервное копирование",
|
"query": "когда запускать резервное копирование",
|
||||||
"want": "n1",
|
"want": "n1",
|
||||||
"note": "The DESIGN.md preference-seam example, phrased as the operator would ask it later.",
|
"note": "The docs/design.md preference-seam example, phrased as the operator would ask it later.",
|
||||||
"notes": [
|
"notes": [
|
||||||
{"id": "n1", "text": "бэкапы лучше делать ночью в три часа", "kind": "note"},
|
{"id": "n1", "text": "бэкапы лучше делать ночью в три часа", "kind": "note"},
|
||||||
{"id": "n2", "text": "обновления ставлю по субботам", "kind": "note"},
|
{"id": "n2", "text": "обновления ставлю по субботам", "kind": "note"},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package morning is maven's morning routine engine — item #3 off the
|
// Package morning is maven's morning routine engine — item #3 off the
|
||||||
// 2026-07-20 backlog (see Maven/20-07-2026-BACKLOG.md).
|
// 2026-07-20 backlog (Vikunja #280).
|
||||||
//
|
//
|
||||||
// A Routine is NOT four independent reminder timers. It's a checklist for a
|
// A Routine is NOT four independent reminder timers. It's a checklist for a
|
||||||
// daily window: several Items, each evidenced by a fact key, completed in
|
// daily window: several Items, each evidenced by a fact key, completed in
|
||||||
@@ -30,6 +30,18 @@ type Item struct {
|
|||||||
Key string
|
Key string
|
||||||
FactKey string
|
FactKey string
|
||||||
Label string // RU text surfaced when this item is still missing.
|
Label string // RU text surfaced when this item is still missing.
|
||||||
|
// Optional — a missing one is not worth a nudge on its own.
|
||||||
|
//
|
||||||
|
// Every item was implicitly required until 04-08-2026, because there was
|
||||||
|
// no field, so a skipped stretch read exactly like skipped medication and
|
||||||
|
// #280's first behaviour could not hold (Vikunja #473). A checklist where
|
||||||
|
// everything is mandatory is a checklist he learns to ignore.
|
||||||
|
//
|
||||||
|
// It changes two things and nothing else: an all-optional routine never
|
||||||
|
// nudges, and a nudge that does fire names the optional stragglers after
|
||||||
|
// the required ones, in softer words. Evidence, the window and the day
|
||||||
|
// plan treat both kinds alike — a missing optional item is still missing.
|
||||||
|
Optional bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local
|
// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local
|
||||||
@@ -60,12 +72,37 @@ type Status struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Candidate — a routine that's due for its one-per-day nag: the window has
|
// Candidate — a routine that's due for its one-per-day nag: the window has
|
||||||
// reached NudgeAt and at least one item is still unevidenced.
|
// reached NudgeAt and at least one REQUIRED item is still unevidenced. Missing
|
||||||
|
// carries the optional stragglers too, so the one message she is allowed per
|
||||||
|
// day per routine can mention them; they never cause it.
|
||||||
type Candidate struct {
|
type Candidate struct {
|
||||||
Routine Routine
|
Routine Routine
|
||||||
Missing []Item
|
Missing []Item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Required reports the missing items that are not optional. The nudge fires on
|
||||||
|
// these; the rest ride along.
|
||||||
|
func Required(missing []Item) []Item {
|
||||||
|
var out []Item
|
||||||
|
for _, it := range missing {
|
||||||
|
if !it.Optional {
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalOnly is the other half of Required.
|
||||||
|
func OptionalOnly(missing []Item) []Item {
|
||||||
|
var out []Item
|
||||||
|
for _, it := range missing {
|
||||||
|
if it.Optional {
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Validate reports the first structural problem with a routine set: missing
|
// Validate reports the first structural problem with a routine set: missing
|
||||||
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
|
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
|
||||||
// within a routine, or an out-of-range weekday. Called at config load so a
|
// within a routine, or an out-of-range weekday. Called at config load so a
|
||||||
@@ -191,7 +228,11 @@ func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.T
|
|||||||
missing = append(missing, it)
|
missing = append(missing, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(missing) == 0 {
|
// A day where only the optional items were skipped is a fine day, and
|
||||||
|
// nagging about it is what teaches him to stop listening (Vikunja
|
||||||
|
// #473). The optional ones still travel in Missing so the message can
|
||||||
|
// mention them when it is being sent anyway.
|
||||||
|
if len(Required(missing)) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
|
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
|
||||||
|
|||||||
@@ -182,3 +182,40 @@ func TestDueRespectsExplicitNudgeAt(t *testing.T) {
|
|||||||
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
|
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOptionalItemsDoNotEarnANudge — behaviour 1 of #280, which could not hold
|
||||||
|
// while every item was implicitly required (Vikunja #473).
|
||||||
|
func TestOptionalItemsDoNotEarnANudge(t *testing.T) {
|
||||||
|
r := Routine{
|
||||||
|
Name: "утро",
|
||||||
|
WindowStart: "07:00",
|
||||||
|
WindowEnd: "10:00",
|
||||||
|
Items: []Item{
|
||||||
|
{Key: "meds", FactKey: "meds", Label: "таблетки"},
|
||||||
|
{Key: "stretch", FactKey: "stretch", Label: "растяжка", Optional: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 8, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
took := map[string]store.Fact{"meds": {Key: "meds", Ts: now.Add(-2 * time.Hour)}}
|
||||||
|
|
||||||
|
// Only the stretch was skipped: nothing to say.
|
||||||
|
if due := Due([]Routine{r}, took, map[string]time.Time{}, now); len(due) != 0 {
|
||||||
|
t.Fatalf("an optional item alone must not nudge, got %+v", due)
|
||||||
|
}
|
||||||
|
// The medication was skipped: she says so, and mentions the stretch too.
|
||||||
|
due := Due([]Routine{r}, map[string]store.Fact{}, map[string]time.Time{}, now)
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Fatalf("a missing required item must nudge, got %+v", due)
|
||||||
|
}
|
||||||
|
if got := Required(due[0].Missing); len(got) != 1 || got[0].Key != "meds" {
|
||||||
|
t.Fatalf("Required = %+v, want the meds item alone", got)
|
||||||
|
}
|
||||||
|
if got := OptionalOnly(due[0].Missing); len(got) != 1 || got[0].Key != "stretch" {
|
||||||
|
t.Fatalf("OptionalOnly = %+v, want the stretch item alone", got)
|
||||||
|
}
|
||||||
|
// The window still reports it as missing — optional is not invisible.
|
||||||
|
st := Evaluate(r, map[string]store.Fact{}, now.Add(-time.Hour))
|
||||||
|
if len(st.Missing) != 2 {
|
||||||
|
t.Fatalf("Evaluate must still list both, got %+v", st.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// Package netaddr parses a daemon seam address and dials or binds it.
|
||||||
|
//
|
||||||
|
// Every seam between Maven's daemons used to be a unix socket with the
|
||||||
|
// network hardcoded at the call site — two dials in internal/ipc, one listen,
|
||||||
|
// and the same pair again in internal/worker. That is correct for co-located
|
||||||
|
// daemons and it is the reason a module cannot live on another host. This
|
||||||
|
// package moves the choice into the address string so a deploy picks the
|
||||||
|
// transport, not a recompile:
|
||||||
|
//
|
||||||
|
// /run/maven/stt.sock unix (the default, unchanged)
|
||||||
|
// unix:///run/maven/stt.sock unix (explicit, same thing)
|
||||||
|
// tcp://workstation:9310?token=hunter2 tcp
|
||||||
|
//
|
||||||
|
// A scheme-less address is unix and behaves exactly as it did before this
|
||||||
|
// package existed: same 0700 parent dir, same 0600 socket, same bytes on the
|
||||||
|
// wire with no handshake in front of them.
|
||||||
|
//
|
||||||
|
// Over TCP the filesystem permission that authenticated the unix socket is
|
||||||
|
// gone, and what crosses this seam is audio of the owner speaking and the
|
||||||
|
// text of his turns. So a TCP seam carries a shared token, checked before the
|
||||||
|
// first protocol frame is read. Wireguard is supported underneath and is not
|
||||||
|
// required.
|
||||||
|
package netaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrUnauthorized — the peer presented a token the listener does not accept,
|
||||||
|
// or presented none when one is required.
|
||||||
|
var ErrUnauthorized = errors.New("netaddr: unauthorized")
|
||||||
|
|
||||||
|
// Addr is a parsed seam endpoint.
|
||||||
|
type Addr struct {
|
||||||
|
// Network is "unix" or "tcp".
|
||||||
|
Network string
|
||||||
|
// Address is the socket path (unix) or host:port (tcp).
|
||||||
|
Address string
|
||||||
|
// Token is the shared secret for a tcp seam. Empty for unix, where the
|
||||||
|
// filesystem does the same job.
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the address for logs and errors. The token is never included.
|
||||||
|
func (a Addr) String() string {
|
||||||
|
if a.Network == "unix" {
|
||||||
|
return a.Address
|
||||||
|
}
|
||||||
|
return a.Network + "://" + a.Address
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUnix reports whether this seam is a unix socket, and so is local, is
|
||||||
|
// authenticated by file permissions, and needs no handshake.
|
||||||
|
func (a Addr) IsUnix() bool { return a.Network == "unix" }
|
||||||
|
|
||||||
|
// Parse reads a seam address. Anything without a "scheme://" prefix is a unix
|
||||||
|
// socket path, which keeps every existing config and every default working
|
||||||
|
// untouched.
|
||||||
|
func Parse(s string) (Addr, error) {
|
||||||
|
if !strings.Contains(s, "://") {
|
||||||
|
return Addr{Network: "unix", Address: s}, nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return Addr{}, fmt.Errorf("netaddr: parse %q: %w", s, err)
|
||||||
|
}
|
||||||
|
switch u.Scheme {
|
||||||
|
case "unix":
|
||||||
|
return Addr{Network: "unix", Address: u.Path}, nil
|
||||||
|
case "tcp":
|
||||||
|
if u.Host == "" {
|
||||||
|
return Addr{}, fmt.Errorf("netaddr: %q has no host:port", s)
|
||||||
|
}
|
||||||
|
return Addr{Network: "tcp", Address: u.Host, Token: u.Query().Get("token")}, nil
|
||||||
|
default:
|
||||||
|
return Addr{}, fmt.Errorf("netaddr: unsupported scheme %q", u.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustParse is Parse for a literal known good at compile time. It panics on a
|
||||||
|
// bad address, so use it in tests and constants, never on config input.
|
||||||
|
func MustParse(s string) Addr {
|
||||||
|
a, err := Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// handshakeTimeout bounds the token exchange. A peer that cannot write one
|
||||||
|
// short line in this long is not going to serve a turn either.
|
||||||
|
const handshakeTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// greeting prefixes the token line. Versioned so a later mTLS seam can be
|
||||||
|
// told apart from this one on the wire.
|
||||||
|
const greeting = "MAVEN1 "
|
||||||
|
|
||||||
|
// Dial connects to a. On a tcp seam it sends the token and waits for the
|
||||||
|
// listener to accept it, so a returned conn is already authorized and the
|
||||||
|
// caller can write its first protocol frame.
|
||||||
|
func Dial(a Addr) (net.Conn, error) {
|
||||||
|
return DialTimeout(a, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialTimeout is Dial with a bound on the connect. Zero means the operating
|
||||||
|
// system default. The token exchange gets its own timeout either way.
|
||||||
|
func DialTimeout(a Addr, timeout time.Duration) (net.Conn, error) {
|
||||||
|
var c net.Conn
|
||||||
|
var err error
|
||||||
|
if timeout > 0 {
|
||||||
|
c, err = net.DialTimeout(a.Network, a.Address, timeout)
|
||||||
|
} else {
|
||||||
|
c, err = net.Dial(a.Network, a.Address)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.IsUnix() {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if err := clientHandshake(c, a.Token); err != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientHandshake(c net.Conn, token string) error {
|
||||||
|
_ = c.SetDeadline(time.Now().Add(handshakeTimeout))
|
||||||
|
defer c.SetDeadline(time.Time{})
|
||||||
|
if _, err := c.Write([]byte(greeting + token + "\n")); err != nil {
|
||||||
|
return fmt.Errorf("netaddr: send token: %w", err)
|
||||||
|
}
|
||||||
|
var reply [3]byte
|
||||||
|
if _, err := readFull(c, reply[:]); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrUnauthorized, err)
|
||||||
|
}
|
||||||
|
if string(reply[:]) != "ok\n" {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listener wraps a net.Listener so Accept performs the token check for a tcp
|
||||||
|
// seam. A connection that fails the check is closed and never surfaces, so
|
||||||
|
// the protocol above this layer only ever sees authorized peers.
|
||||||
|
type Listener struct {
|
||||||
|
net.Listener
|
||||||
|
addr Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept returns the next authorized connection. Unauthorized peers are
|
||||||
|
// dropped and Accept keeps waiting: a bad token is a rejected stranger, not a
|
||||||
|
// reason to stop serving.
|
||||||
|
func (l *Listener) Accept() (net.Conn, error) {
|
||||||
|
for {
|
||||||
|
c, err := l.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if l.addr.IsUnix() {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if err := serverHandshake(c, l.addr.Token); err != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr reports the parsed seam address this listener was built from.
|
||||||
|
func (l *Listener) SeamAddr() Addr { return l.addr }
|
||||||
|
|
||||||
|
func serverHandshake(c net.Conn, want string) error {
|
||||||
|
_ = c.SetDeadline(time.Now().Add(handshakeTimeout))
|
||||||
|
defer c.SetDeadline(time.Time{})
|
||||||
|
// The line is bounded: greeting, token, newline. Read a byte at a time so
|
||||||
|
// nothing of the first protocol frame is consumed when the token is short.
|
||||||
|
line := make([]byte, 0, 128)
|
||||||
|
var b [1]byte
|
||||||
|
for {
|
||||||
|
if _, err := readFull(c, b[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b[0] == '\n' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line = append(line, b[0])
|
||||||
|
if len(line) > 512 {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, ok := strings.CutPrefix(string(line), greeting)
|
||||||
|
if !ok {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
if _, err := c.Write([]byte("ok\n")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFull(c net.Conn, p []byte) (int, error) {
|
||||||
|
n := 0
|
||||||
|
for n < len(p) {
|
||||||
|
m, err := c.Read(p[n:])
|
||||||
|
n += m
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen binds a. A unix seam gets the perms it has always had: parent dir
|
||||||
|
// 0700, socket 0600, and any stale socket from a crashed daemon removed
|
||||||
|
// first. A tcp seam must carry a token, because there is no filesystem to
|
||||||
|
// stand in for one.
|
||||||
|
func Listen(a Addr) (*Listener, error) {
|
||||||
|
if a.IsUnix() {
|
||||||
|
ln, err := listenUnix(a.Address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Listener{Listener: ln, addr: a}, nil
|
||||||
|
}
|
||||||
|
if a.Token == "" {
|
||||||
|
return nil, fmt.Errorf("netaddr: listen %s: tcp seam requires a token", a)
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", a.Address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("netaddr: listen %s: %w", a, err)
|
||||||
|
}
|
||||||
|
return &Listener{Listener: ln, addr: a}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenUnix(path string) (net.Listener, error) {
|
||||||
|
_ = os.Remove(path) // stale socket from a crashed daemon; ignore missing
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("netaddr: mkdir socket dir: %w", err)
|
||||||
|
}
|
||||||
|
// umask could widen the perms on socket creation; tighten then chmod to
|
||||||
|
// be explicit. 0600 ⇒ read+write by owner only.
|
||||||
|
oldMask := unix.Umask(0o077)
|
||||||
|
ln, err := net.Listen("unix", path)
|
||||||
|
unix.Umask(oldMask)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("netaddr: listen %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
_ = ln.Close()
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return nil, fmt.Errorf("netaddr: chmod socket: %w", err)
|
||||||
|
}
|
||||||
|
return ln, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes the socket file behind a unix seam. It is a no-op for tcp.
|
||||||
|
func Cleanup(a Addr) {
|
||||||
|
if a.IsUnix() && a.Address != "" {
|
||||||
|
_ = os.Remove(a.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package netaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A scheme-less address must stay unix. Every deploy in the tree writes a bare
|
||||||
|
// path, so this is the test that says the transport change costs them nothing.
|
||||||
|
func TestParseSchemelessIsUnix(t *testing.T) {
|
||||||
|
a, err := Parse("/run/maven/stt.sock")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !a.IsUnix() {
|
||||||
|
t.Fatalf("want unix, got %q", a.Network)
|
||||||
|
}
|
||||||
|
if a.Address != "/run/maven/stt.sock" {
|
||||||
|
t.Fatalf("address = %q", a.Address)
|
||||||
|
}
|
||||||
|
if a.Token != "" {
|
||||||
|
t.Fatalf("unix seam carries a token: %q", a.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
net, addr, tk string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{in: "", net: "unix", addr: ""},
|
||||||
|
{in: "unix:///run/maven/core.sock", net: "unix", addr: "/run/maven/core.sock"},
|
||||||
|
{in: "tcp://workstation:9310", net: "tcp", addr: "workstation:9310"},
|
||||||
|
{in: "tcp://workstation:9310?token=hunter2", net: "tcp", addr: "workstation:9310", tk: "hunter2"},
|
||||||
|
{in: "tcp://", wantErr: true},
|
||||||
|
{in: "udp://workstation:9310", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
a, err := Parse(c.in)
|
||||||
|
if c.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Parse(%q) = %v, want error", c.in, a)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Parse(%q): %v", c.in, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.Network != c.net || a.Address != c.addr || a.Token != c.tk {
|
||||||
|
t.Errorf("Parse(%q) = %+v, want %s/%s/%s", c.in, a, c.net, c.addr, c.tk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token must never reach a log line.
|
||||||
|
func TestStringHidesToken(t *testing.T) {
|
||||||
|
a := MustParse("tcp://workstation:9310?token=hunter2")
|
||||||
|
if got := a.String(); got != "tcp://workstation:9310" {
|
||||||
|
t.Fatalf("String() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A unix seam must round-trip with no handshake in front of the payload: the
|
||||||
|
// first bytes the listener sees are the caller's, exactly as before.
|
||||||
|
func TestUnixRoundTripHasNoHandshake(t *testing.T) {
|
||||||
|
a := MustParse(filepath.Join(t.TempDir(), "s.sock"))
|
||||||
|
ln, err := Listen(a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
go echoOnce(ln)
|
||||||
|
|
||||||
|
c, err := Dial(a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if got := roundTrip(t, c, "hello"); got != "hello" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPRoundTripWithToken(t *testing.T) {
|
||||||
|
ln, addr := listenLoopback(t, "s3cret")
|
||||||
|
defer ln.Close()
|
||||||
|
go echoOnce(ln)
|
||||||
|
|
||||||
|
c, err := Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if got := roundTrip(t, c, "hello"); got != "hello" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPWrongTokenIsRejected(t *testing.T) {
|
||||||
|
ln, addr := listenLoopback(t, "s3cret")
|
||||||
|
defer ln.Close()
|
||||||
|
// Accept keeps waiting past the bad peer, so nothing here should ever
|
||||||
|
// reach the echo. A conn that does means the token was not checked.
|
||||||
|
go echoOnce(ln)
|
||||||
|
|
||||||
|
bad := addr
|
||||||
|
bad.Token = "wrong"
|
||||||
|
if _, err := Dial(bad); !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Fatalf("dial with wrong token: err = %v, want ErrUnauthorized", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stranger that speaks the protocol instead of the greeting is dropped, and
|
||||||
|
// the listener stays up for the peer that follows it.
|
||||||
|
func TestTCPUngreetedPeerDoesNotKillTheListener(t *testing.T) {
|
||||||
|
ln, addr := listenLoopback(t, "s3cret")
|
||||||
|
defer ln.Close()
|
||||||
|
go echoOnce(ln)
|
||||||
|
|
||||||
|
raw, err := net.Dial("tcp", addr.Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("raw dial: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := raw.Write([]byte("GET / HTTP/1.1\n")); err != nil {
|
||||||
|
t.Fatalf("raw write: %v", err)
|
||||||
|
}
|
||||||
|
raw.Close()
|
||||||
|
|
||||||
|
c, err := Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial after stranger: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if got := roundTrip(t, c, "still here"); got != "still here" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tcp seam with no token is a misconfiguration, and it must fail at bind
|
||||||
|
// rather than serve the owner's turns to anyone who connects.
|
||||||
|
func TestTCPListenRequiresToken(t *testing.T) {
|
||||||
|
if _, err := Listen(MustParse("tcp://127.0.0.1:0")); err == nil {
|
||||||
|
t.Fatal("listen on a tokenless tcp seam succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenLoopback(t *testing.T, token string) (*Listener, Addr) {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := Listen(Addr{Network: "tcp", Address: "127.0.0.1:0", Token: token})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
return ln, Addr{Network: "tcp", Address: ln.Addr().String(), Token: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
func echoOnce(ln *Listener) {
|
||||||
|
c, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
n, err := c.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Write(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTrip(t *testing.T, c net.Conn, msg string) string {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := c.Write([]byte(msg)); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
n, err := c.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user