Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4425ba112b | |||
| a4d5155029 | |||
| c62c7034fa | |||
| 58b546a27e | |||
| 997f92f5c4 | |||
| d9ef9ecef2 | |||
| be3e5cea25 | |||
| 1da3aa39e8 | |||
| bee3ef80b4 | |||
| fc9538d07f | |||
| bb8bd608da | |||
| db0752223f | |||
| 97fc786acf | |||
| 54f13466c9 | |||
| 3bbffa4f37 | |||
| 8b76dc50d3 | |||
| a1324e679f | |||
| 82ef1b0110 | |||
| 897dcf847a | |||
| 8b9e8e9f4e | |||
| b338d9bb40 | |||
| 6878e12d37 | |||
| 8d46ee39e0 | |||
| 6eba79b332 | |||
| 3f60ec3994 | |||
| 14ea06712e | |||
| 42d3feadd2 | |||
| 2a2f706b74 | |||
| e082e06868 | |||
| 88dc4e1383 | |||
| 60759a991e | |||
| 9537346441 | |||
| c6be818f13 | |||
| b5575a9402 | |||
| fcda5e3d2c | |||
| b1420acb94 | |||
| 70fb7c030b | |||
| fe489dff6d | |||
| d8da529be0 | |||
| 0258a40b0d | |||
| f6a8752d00 | |||
| 5b7480ddaf | |||
| 8aa790cc48 | |||
| 0ade0ec734 | |||
| d960e211d3 | |||
| 6fba4d6931 | |||
| 12c18dcf65 | |||
| a286865fe5 | |||
| 28c0ff73bd | |||
| bdafc82e35 | |||
| c69023c310 | |||
| 761cf9f3e0 | |||
| 6a9d8a4dd5 | |||
| 4c95b200e4 | |||
| 6ae1312ff1 | |||
| 765ed36340 | |||
| feca776077 | |||
| d79b30a1a6 | |||
| c5e993fc55 | |||
| a2081d8227 | |||
| 82bd160c0d | |||
| 1fe03f7a51 | |||
| d773f1f72b | |||
| 80ac7579fb | |||
| bb6cb6d185 | |||
| a9067a5754 | |||
| 787cc56522 | |||
| 86dcd99de2 | |||
| 554181ccbd | |||
| 58635f1a69 | |||
| fd3d063e02 | |||
| ed491e23fc | |||
| 246db4e609 | |||
| b6abb19090 | |||
| 1f7fd476ec | |||
| 08512ad58b | |||
| ff71d981ef | |||
| 4d83f8c785 | |||
| a439117995 | |||
| 2689715c2d | |||
| 05f47aef4b | |||
| 45a5e37963 | |||
| 6d8a95095a | |||
| 7e21cd06b3 | |||
| 09c648b934 | |||
| 4f516657da | |||
| 8a21478f36 | |||
| 958d2a2fc8 | |||
| 7db139b83e | |||
| 0987dabfc4 | |||
| 947506c7b8 | |||
| 6c67e61962 | |||
| 0990f32808 | |||
| d41878c2b1 | |||
| e023638135 | |||
| 0d52344d27 | |||
| 5bd303788b | |||
| afac8fb670 | |||
| 77206f298e | |||
| a1f811d4c8 | |||
| 7ab38cd7f7 | |||
| 6a85e71077 | |||
| bf6c2bf1a6 | |||
| 1558233665 | |||
| 5afa2dfb38 | |||
| 0f361d2034 | |||
| 19242ef73b | |||
| 26d6d71588 | |||
| df58710141 | |||
| 442d3ec08e | |||
| e481ad4930 | |||
| 3d8224fb04 | |||
| 990a4a99e9 | |||
| 5b622389c5 | |||
| a820a95ebb | |||
| ea9c746852 | |||
| d60a51c9e7 | |||
| 9aabb01e2a | |||
| 2815adee03 | |||
| 9f51596e2f | |||
| 87d176153a | |||
| 7d4b4ad736 | |||
| 569991bb15 | |||
| c915115096 | |||
| 908d92a7e8 | |||
| 43f2c37538 | |||
| 6d3f5b5b01 | |||
| eda1112f3b | |||
| 1c8a32c3fd | |||
| 96b474223d | |||
| 42d7a39c49 | |||
| bad3fa4035 | |||
| d819fc09f0 | |||
| c35979d9f9 | |||
| f3c0540b42 | |||
| 5b4192acb5 | |||
| 16d94894b7 | |||
| ae8d38fc31 | |||
| b2521988e1 | |||
| dae123adac | |||
| 1c9ddbbea2 | |||
| 3f2782f5b7 | |||
| 865623ef3e | |||
| 4fdce3ca2c | |||
| c47881106e | |||
| 9a70f7378b | |||
| b18f608594 | |||
| d1f8a734c5 | |||
| 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,28 @@ 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. It is multilingual-e5-small,
|
||||||
|
quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:`
|
||||||
|
prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced
|
||||||
|
MiniLM and bought ten points of recall@1 and 2.5× the speed; see
|
||||||
|
`docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before
|
||||||
|
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||||
|
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
|
||||||
@@ -58,8 +77,8 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
|||||||
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
||||||
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
||||||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||||||
| `mavwaked` | Wake-word / VAD gate. |
|
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
|
||||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
|
||||||
| `mavpoll` | Telegram long-poll reach. |
|
| `mavpoll` | Telegram long-poll reach. |
|
||||||
| `mavcaldav` | CalDAV calendar sync. |
|
| `mavcaldav` | CalDAV calendar sync. |
|
||||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||||
@@ -68,11 +87,20 @@ Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/ser
|
|||||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||||
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
||||||
|
|
||||||
|
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
||||||
|
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
||||||
|
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
|
||||||
|
daemon there listens to nobody. They belong on a client machine where he is standing.
|
||||||
|
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
|
||||||
|
needs building to allow it, but no such machine exists yet. **The consequence: the wake
|
||||||
|
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
|
||||||
|
sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers.
|
||||||
|
|
||||||
## The ecosystem: Nexus, Praxis, Hexis
|
## The ecosystem: Nexus, Praxis, Hexis
|
||||||
|
|
||||||
Maven is one of four services. It owns conversation and personal memory. It does not
|
Maven is one of four services. It owns conversation and personal memory. It does not
|
||||||
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 +141,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 +155,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
|
||||||
@@ -164,6 +202,18 @@ fixture had said `query` since ru-query-019 was written. Measured: **full accura
|
|||||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||||||
explicit `(\s|[?!.]|$)`.
|
explicit `(\s|[?!.]|$)`.
|
||||||
|
|
||||||
|
Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside
|
||||||
|
`AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar`
|
||||||
|
(`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про
|
||||||
|
X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model
|
||||||
|
called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a
|
||||||
|
latency and fixture defect, not a correctness one. The narrative rule reads the same
|
||||||
|
`narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a
|
||||||
|
joke, a bedtime story, herself — because the query chain has no source that answers those.
|
||||||
|
New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) →
|
||||||
|
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
|
||||||
|
llama-server in that run), so judge it again before quoting a cascade number.
|
||||||
|
|
||||||
## LLM output contract
|
## LLM output contract
|
||||||
|
|
||||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||||
@@ -173,6 +223,34 @@ All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_ll
|
|||||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||||
workspace enforces that the Go and relabelling prompts remain identical.
|
workspace enforces that the Go and relabelling prompts remain identical.
|
||||||
|
|
||||||
|
## Russian patterns — three mechanisms, no fourth
|
||||||
|
|
||||||
|
Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a
|
||||||
|
pattern, and the resident model cannot be asked per turn either). A regex whose output is a
|
||||||
|
fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an
|
||||||
|
argv list — is not. Before writing a Russian word list, pick one of these:
|
||||||
|
|
||||||
|
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
||||||
|
capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months,
|
||||||
|
spoken hours. Editing a word is a data change, and there is exactly one copy: months used
|
||||||
|
to live in three files. Cardinals carry the oblique forms, because a spoken time declines
|
||||||
|
and `в семь` / `к семи` are one hour.
|
||||||
|
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
|
||||||
|
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
|
||||||
|
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
||||||
|
lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`).
|
||||||
|
- **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a
|
||||||
|
turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the
|
||||||
|
turn's own query vector. Same shape as the personal boundary in `personalboundary.go`,
|
||||||
|
with one difference: a topic must clear the runner-up by `topicMargin`, because a false
|
||||||
|
claim here spends a network scan rather than one honest "не знаю". The old keyword tests
|
||||||
|
stay as the offline floor and may remain narrow, since they are no longer the only answer.
|
||||||
|
- **The ecosystem trio** — when the answer is not in the utterance at all. Identity is
|
||||||
|
Nexus's, never a local pattern.
|
||||||
|
|
||||||
|
Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the
|
||||||
|
`TestONNX*` tests, not eyeballed.
|
||||||
|
|
||||||
## Non-goals (hard constraints)
|
## Non-goals (hard constraints)
|
||||||
|
|
||||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||||
@@ -204,8 +282,11 @@ world questions, so she needs to read external sources. What replaces it:
|
|||||||
|
|
||||||
## Web UI conventions
|
## Web UI conventions
|
||||||
|
|
||||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
|
||||||
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
|
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
|
||||||
|
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
|
||||||
|
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
|
||||||
|
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
|
||||||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||||||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 deps-sentinel tidy eval-router eval-reach 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
|
||||||
|
|
||||||
@@ -68,7 +74,7 @@ run-web: build-web
|
|||||||
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
||||||
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
||||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||||
deps-go:
|
deps-go: deps-sentinel
|
||||||
@mkdir -p deps/go
|
@mkdir -p deps/go
|
||||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||||
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
||||||
@@ -78,7 +84,26 @@ deps-go:
|
|||||||
done
|
done
|
||||||
$(GO) version
|
$(GO) version
|
||||||
|
|
||||||
# fmt-check fails if any file needs gofmt. DESIGN.md has always said `make
|
# deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454).
|
||||||
|
# The vendored toolchain lives inside the module tree, so `go mod tidy` walked
|
||||||
|
# Go's own compiler-error fixtures and died on files that are malformed on
|
||||||
|
# purpose ("unicode//utf8": double slash). A nested module is not part of the
|
||||||
|
# parent, so one three-line file ends the walk. deps/ is gitignored, so it is
|
||||||
|
# generated here rather than committed, and every target that populates deps/
|
||||||
|
# writes it.
|
||||||
|
deps-sentinel:
|
||||||
|
@mkdir -p deps
|
||||||
|
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
|
||||||
|
|
||||||
|
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
|
||||||
|
# go.mod, and a build target that edits the module file is a surprise.
|
||||||
|
# vendor/ is committed, so a tidy that drops a requirement must be followed by
|
||||||
|
# a re-vendor or the next build fails on "inconsistent vendoring".
|
||||||
|
tidy: deps-sentinel
|
||||||
|
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy
|
||||||
|
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor
|
||||||
|
|
||||||
|
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
||||||
# 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:
|
||||||
@@ -113,6 +138,14 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt
|
|||||||
eval-router:
|
eval-router:
|
||||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
|
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
|
||||||
|
|
||||||
|
# eval-reach — score the held-out ecosystem reach fixture (internal/router/eval,
|
||||||
|
# ru_ecosystem_v1.json). Answers "does a real Russian utterance actually arrive
|
||||||
|
# at Praxis or Hexis", which routing accuracy alone does not say. Same
|
||||||
|
# MAVEN_ONNX_LIB deal as eval-router; without it only the deterministic hash
|
||||||
|
# ratchet runs. Vikunja #405.
|
||||||
|
eval-reach:
|
||||||
|
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 -run 'Reach|Praxis' ./internal/router/eval/
|
||||||
|
|
||||||
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
|
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
|
||||||
# Answers "can she find the note again when it matters": recall@1, recall@3,
|
# Answers "can she find the note again when it matters": recall@1, recall@3,
|
||||||
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
|
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
|
||||||
@@ -172,7 +205,7 @@ run-tts: build-tts
|
|||||||
./mavttsd -socket /tmp/maven/tts.sock \
|
./mavttsd -socket /tmp/maven/tts.sock \
|
||||||
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
||||||
|
|
||||||
deps: deps-whisper deps-piper
|
deps: deps-sentinel deps-whisper deps-piper
|
||||||
|
|
||||||
deps-whisper:
|
deps-whisper:
|
||||||
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||||
@@ -191,7 +224,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-08-04-recall-e5-small.md for what the swap bought.
|
||||||
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
||||||
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
||||||
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
||||||
|
|||||||
-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.
|
||||||
|
|||||||
+3
-2
@@ -16,6 +16,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
|
|||||||
}
|
}
|
||||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
||||||
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||||
return "не получилось отметить.", true
|
return phraser.Ack(phraser.FailAck, nil), true
|
||||||
}
|
}
|
||||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||||
return "отлично, отметила.", true
|
return phraser.Ack(phraser.AckNudge, nil), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,11 +58,18 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r
|
|||||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||||
// Conversational: build history from dialogue session (prior user turns)
|
// Conversational: build history from dialogue session (prior user turns)
|
||||||
// and let the LLM respond from general knowledge + context.
|
// and let the LLM respond from general knowledge + context.
|
||||||
|
if h.phraser == nil {
|
||||||
|
return "поговорили."
|
||||||
|
}
|
||||||
history := h.chatHistory()
|
history := h.chatHistory()
|
||||||
|
// The phraser hands back its own fallback text alongside the error, so the
|
||||||
|
// turn survives a dead server and the failure still reaches the log.
|
||||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: chat: %v", err)
|
log.Printf("voice: chat: %v", err)
|
||||||
return "поговорили."
|
}
|
||||||
|
if reply == "" {
|
||||||
|
return phraser.ChatFallback()
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/mcp"
|
"github.com/kami/maven/internal/mcp"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
)
|
)
|
||||||
@@ -50,31 +51,37 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
|||||||
// destructive: park it and ask. The next utterance answers.
|
// destructive: park it and ask. The next utterance answers.
|
||||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
||||||
|
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
||||||
|
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||||
|
// help: everything that proposed this act — the STT, the router,
|
||||||
|
// the fuzzy allowlist match — is a guess, and a spoken "да" checks
|
||||||
|
// none of it. She names the gap instead.
|
||||||
|
return phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
||||||
case errors.Is(err, tool.ErrNotEnabled):
|
case errors.Is(err, tool.ErrNotEnabled):
|
||||||
return h.proposeGap(ctx, dec)
|
return h.proposeGap(ctx, dec)
|
||||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
||||||
// The row is enabled and the backend is gone. Drafting a proposal
|
// The row is enabled and the backend is gone. Drafting a proposal
|
||||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||||
// question.
|
// question.
|
||||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
return phraser.A(phraser.ActServerDown, nil)
|
||||||
case errors.Is(err, mcp.ErrToolGone):
|
case errors.Is(err, mcp.ErrToolGone):
|
||||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
return phraser.A(phraser.ActWithdrawn, nil)
|
||||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||||
// An MCP tool that wants named arguments a spoken verb cannot
|
// An MCP tool that wants named arguments a spoken verb cannot
|
||||||
// supply. Guessing them would be a wrong act, so she says so
|
// supply. Guessing them would be a wrong act, so she says so
|
||||||
// instead — the tool is still runnable from the authed surface,
|
// instead — the tool is still runnable from the authed surface,
|
||||||
// where a human types them.
|
// where a human types them.
|
||||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
return phraser.A(phraser.ActNeedsArgs, nil)
|
||||||
}
|
}
|
||||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||||
if out != "" {
|
if out != "" {
|
||||||
return "не получилось выполнить команду: " + firstLine(out)
|
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||||
}
|
}
|
||||||
return "не получилось выполнить команду."
|
return phraser.A(phraser.ActFail, nil)
|
||||||
}
|
}
|
||||||
if out != "" {
|
if out != "" {
|
||||||
return "готово: " + firstLine(out)
|
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||||
}
|
}
|
||||||
return "готово."
|
return phraser.A(phraser.ActDone, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The act path speaks each tier (Vikunja #449): a safe row runs, a destructive
|
||||||
|
// one costs a confirm turn, an irreversible one is refused with the reason.
|
||||||
|
func TestActPathSpeaksTheTiers(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := h.now()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cmd []string
|
||||||
|
destructive bool
|
||||||
|
}{
|
||||||
|
{"status", []string{"true"}, false},
|
||||||
|
{"restart", []string{"true"}, true},
|
||||||
|
{"wipe", []string{"rm", "-rf"}, true},
|
||||||
|
} {
|
||||||
|
if _, err := st.ProposeTool(ctx, tc.name, "test", "homelab", now); err != nil {
|
||||||
|
t.Fatalf("propose %s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if err := st.EnableTool(ctx, tc.name, tc.cmd, tc.destructive, "homelab", now); err != nil {
|
||||||
|
t.Fatalf("enable %s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
act := func(fn string) string {
|
||||||
|
return h.actionAct(ctx, router.Decision{
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Utterance: fn,
|
||||||
|
Slots: router.Slots{Fn: fn, HasFn: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply := act("status"); !strings.HasPrefix(reply, "готово") {
|
||||||
|
t.Errorf("safe act replied %q; want it to have run", reply)
|
||||||
|
}
|
||||||
|
// PR 112's review cut «скажи «да» или «нет».» — he knows how to answer a
|
||||||
|
// yes/no question — so the confirm turn is recognised by the question.
|
||||||
|
if reply := act("restart"); !strings.Contains(reply, "да или нет") {
|
||||||
|
t.Errorf("destructive act replied %q; want a confirm turn", reply)
|
||||||
|
}
|
||||||
|
// Clear the confirm the destructive act parked, so what is pending after
|
||||||
|
// the irreversible one is only what the irreversible one parked.
|
||||||
|
h.mu.Lock()
|
||||||
|
h.pending = nil
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
reply := act("wipe")
|
||||||
|
if strings.Contains(reply, "да или нет") {
|
||||||
|
t.Fatalf("irreversible act asked for a confirm: %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не вернуть") {
|
||||||
|
t.Errorf("irreversible act replied %q; want it to name the reason", reply)
|
||||||
|
}
|
||||||
|
// Nothing was parked, so a later "да" cannot pick it up.
|
||||||
|
h.mu.Lock()
|
||||||
|
pending := h.pending
|
||||||
|
h.mu.Unlock()
|
||||||
|
if pending != nil {
|
||||||
|
t.Errorf("an irreversible act parked %+v", pending)
|
||||||
|
}
|
||||||
|
// And it is still an enabled row — refusing to run it from voice is not
|
||||||
|
// the same as taking it off the allowlist.
|
||||||
|
if got, err := st.LookupTool(ctx, "wipe"); err != nil || got.Status != "enabled" {
|
||||||
|
t.Errorf("wipe is %+v, %v; want it still enabled", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-18
@@ -6,23 +6,67 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
||||||
// it for recall, and let pattern detection propose a routine.
|
// it for recall, and let pattern detection propose a routine.
|
||||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||||
if !dec.Slots.HasKey {
|
if !dec.Slots.HasKey {
|
||||||
return "не разобрала, что записать — попробуй иначе."
|
return phraser.Ack(phraser.FailFactUnparsed, nil)
|
||||||
|
}
|
||||||
|
// A question is never a fact about him (#470). "какая последняя версия
|
||||||
|
// языка 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)
|
||||||
|
}
|
||||||
|
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
||||||
|
// "интернет не работает" were stored as `self` rows at confidence 1.00, and
|
||||||
|
// recall reads a self row back later as if it were still true — the same
|
||||||
|
// class of row that outranked live search in #470. The sentence describes a
|
||||||
|
// moment, so she answers it and stores nothing. An explicit "запомни ..."
|
||||||
|
// and anything about him are both left alone by the test.
|
||||||
|
if router.IsTransientComplaint(dec.Utterance) {
|
||||||
|
log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat",
|
||||||
|
dec.Utterance, dec.Slots.Key)
|
||||||
|
c := dec
|
||||||
|
c.Intent = router.IntentChat
|
||||||
|
c.Slots.Key, c.Slots.HasKey = "", false
|
||||||
|
c.Slots.Value = ""
|
||||||
|
return h.actionChat(ctx, c)
|
||||||
}
|
}
|
||||||
now := h.now()
|
now := h.now()
|
||||||
req := ipc.WriteFactReq{
|
req := ipc.WriteFactReq{
|
||||||
Ts: now,
|
Ts: now,
|
||||||
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
|
||||||
@@ -34,19 +78,27 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
factID, err := h.api.WriteFact(ctx, req)
|
factID, err := h.api.WriteFact(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: write fact: %v", err)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return "не получилось сохранить факт."
|
return phraser.Ack(phraser.FailFact, nil)
|
||||||
}
|
}
|
||||||
// Index the fact 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.
|
||||||
if h.memStore != nil {
|
//
|
||||||
if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil {
|
// 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.recall.memStore != nil {
|
||||||
|
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||||
|
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||||
log.Printf("voice: embed fact for memory: %v", err)
|
log.Printf("voice: embed fact for memory: %v", err)
|
||||||
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
} else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "fact",
|
"type": "fact",
|
||||||
"text": 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standing lists on the voice path (Vikunja #453).
|
||||||
|
//
|
||||||
|
// Three halves, mirroring what task capture already does: an add that runs at
|
||||||
|
// the top of actionNote, a read-back query source, and a crossing-off that runs
|
||||||
|
// on the same note path because "всё купил" is note-shaped.
|
||||||
|
//
|
||||||
|
// These read h.dataStore rather than the CoreAPI. A list is local to the core
|
||||||
|
// and nothing outside it writes one: the web UI has no list page, no reach
|
||||||
|
// files groceries, and the digestion worker does not read the table. When
|
||||||
|
// something outside mavend needs to add to a list, the ipc seam is what it
|
||||||
|
// grows through — the intake rules that CaptureTaskReq documents are about
|
||||||
|
// shared intake, and there is none here yet.
|
||||||
|
//
|
||||||
|
// Nothing here speaks unprompted. A list is answered when asked about.
|
||||||
|
|
||||||
|
// captureListFromNote claims the turn when the utterance adds to, clears, or
|
||||||
|
// crosses one item off a list. ("", false) hands the turn back to the note path.
|
||||||
|
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||||
|
if h.dataStore == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// Clearing is read before removing on purpose: "всё купил" and "купил
|
||||||
|
// молоко" start with the same word, and only the second one names an item.
|
||||||
|
if list, ok := router.ParseListClear(dec.Utterance); ok {
|
||||||
|
n, err := h.dataStore.ClearList(ctx, list, h.now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: clear list: %v", err)
|
||||||
|
return "не получилось обновить список.", true
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "в списке и так ничего не было.", true
|
||||||
|
}
|
||||||
|
return "вычеркнула всё, список пустой.", true
|
||||||
|
}
|
||||||
|
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
|
||||||
|
if reply, ok := h.removeListItem(ctx, cap); ok {
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
// Nothing on the list by that name. "купил новый ноутбук" is a note and
|
||||||
|
// must stay one, so the turn goes back rather than claiming a removal
|
||||||
|
// that removed nothing.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
cap, ok := router.ParseListCapture(dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
|
||||||
|
List: cap.List,
|
||||||
|
Item: cap.Item,
|
||||||
|
Source: "tap:voice",
|
||||||
|
CreatedTs: h.now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: add list item: %v", err)
|
||||||
|
return "не получилось добавить в список.", true
|
||||||
|
}
|
||||||
|
if !res.Created {
|
||||||
|
return cap.Item + " уже в списке.", true
|
||||||
|
}
|
||||||
|
return "добавила в список: " + cap.Item + ".", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeListItem crosses one named item off. It reports false when the list
|
||||||
|
// holds nothing by that name, which is what keeps the marker words from
|
||||||
|
// swallowing ordinary notes.
|
||||||
|
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
|
||||||
|
items, err := h.dataStore.ListItems(ctx, cap.List, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: list items: %v", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
want := store.NormalizeTaskText(cap.Item)
|
||||||
|
for _, li := range items {
|
||||||
|
if store.NormalizeTaskText(li.Item) != want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
|
||||||
|
log.Printf("voice: cross off list item: %v", err)
|
||||||
|
return "не получилось обновить список.", true
|
||||||
|
}
|
||||||
|
return "вычеркнула: " + li.Item + ".", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryList — "что в списке покупок?", "что мне купить?".
|
||||||
|
//
|
||||||
|
// A query source, so it sits in querySources and either claims the turn or
|
||||||
|
// passes it on. It is before the recall sources for the reason every specific
|
||||||
|
// source is: the notes pass would otherwise answer a list question with
|
||||||
|
// whatever note is nearest.
|
||||||
|
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
list, ok := router.ParseListQuery(t.dec.Utterance)
|
||||||
|
if !ok || h.dataStore == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
items, err := h.dataStore.ListItems(ctx, list, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: list items: %v", err)
|
||||||
|
return "не получилось посмотреть список.", true
|
||||||
|
}
|
||||||
|
return formatListRU(list, items), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatListRU reads a list aloud. One sentence, comma-separated, because a
|
||||||
|
// shopping list is heard in a shop and a numbered recital is unusable there.
|
||||||
|
func formatListRU(list string, items []store.ListItem) string {
|
||||||
|
name := "списке " + listGenitive(list)
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "в " + name + " пусто."
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(items))
|
||||||
|
for _, li := range items {
|
||||||
|
names = append(names, li.Item)
|
||||||
|
}
|
||||||
|
return "в " + name + ": " + strings.Join(names, ", ") + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
// listGenitive puts a list tag into the case "список <…>" needs. Russian
|
||||||
|
// declines the noun and she must not say "в списке покупки".
|
||||||
|
func listGenitive(list string) string {
|
||||||
|
switch list {
|
||||||
|
case "покупки":
|
||||||
|
return "покупок"
|
||||||
|
case "аптека":
|
||||||
|
return "аптеки"
|
||||||
|
case "хозяйство":
|
||||||
|
return "хозяйства"
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func listHandler(t *testing.T) *reactiveHandler {
|
||||||
|
t.Helper()
|
||||||
|
return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
|
||||||
|
}
|
||||||
|
|
||||||
|
func askList(t *testing.T, h *reactiveHandler, utterance string) (string, bool) {
|
||||||
|
t.Helper()
|
||||||
|
return h.captureListFromNote(context.Background(), router.Decision{
|
||||||
|
Intent: router.IntentNote, Utterance: utterance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCaptureAddsAndReadsBack(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} {
|
||||||
|
if reply, ok := askList(t, h, u); !ok {
|
||||||
|
t.Fatalf("%q was not claimed (reply %q)", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reply, ok := askList(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") {
|
||||||
|
t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok)
|
||||||
|
}
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the list question was not claimed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") {
|
||||||
|
t.Errorf("answer %q; want both items", answer)
|
||||||
|
}
|
||||||
|
if strings.Contains(answer, "списке покупки") {
|
||||||
|
t.Errorf("answer %q declines the list name wrong", answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An utterance with no list marker is a note and must stay one, whichever half
|
||||||
|
// of the parser it brushes against.
|
||||||
|
func TestListCapturePassesOrdinaryNotes(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"молоко закончилось",
|
||||||
|
"надо бы съездить в магазин",
|
||||||
|
"купил новый ноутбук",
|
||||||
|
"добавь в список покупок",
|
||||||
|
} {
|
||||||
|
if reply, ok := askList(t, h, u); ok {
|
||||||
|
t.Errorf("%q was claimed as a list turn: %q", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCrossOffOneItemAndThenAll(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"добавь в список покупок молоко",
|
||||||
|
"добавь в список покупок хлеб",
|
||||||
|
"добавь в список аптеки бинт",
|
||||||
|
} {
|
||||||
|
if _, ok := askList(t, h, u); !ok {
|
||||||
|
t.Fatalf("%q was not claimed", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply, ok := askList(t, h, "вычеркни молоко")
|
||||||
|
if !ok || !strings.Contains(reply, "молоко") {
|
||||||
|
t.Fatalf("cross off replied %q, %v", reply, ok)
|
||||||
|
}
|
||||||
|
open, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(open) != 1 || open[0].Item != "хлеб" {
|
||||||
|
t.Fatalf("open list %+v; want only хлеб", open)
|
||||||
|
}
|
||||||
|
if reply, ok := askList(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") {
|
||||||
|
t.Errorf("clear replied %q, %v", reply, ok)
|
||||||
|
}
|
||||||
|
open, err = h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(open) != 0 {
|
||||||
|
t.Errorf("%d items still open after всё купил", len(open))
|
||||||
|
}
|
||||||
|
// The other list is untouched, and it is read back on its own.
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"},
|
||||||
|
})
|
||||||
|
if !ok || !strings.Contains(answer, "бинт") {
|
||||||
|
t.Errorf("аптека answer %q, %v; want бинт", answer, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryListSaysWhenItIsEmpty(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the list question was not claimed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(answer, "пусто") {
|
||||||
|
t.Errorf("empty answer %q; want it to say so", answer)
|
||||||
|
}
|
||||||
|
if _, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||||
|
}); ok {
|
||||||
|
t.Error("the list source claimed a task question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 0 answers a list turn without the model: the grammars route it, and the
|
||||||
|
// action handlers re-parse what the grammar matched.
|
||||||
|
func TestListGrammarsRouteWithoutTheModel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
}{
|
||||||
|
{"добавь в список покупок молоко", router.IntentNote},
|
||||||
|
{"что в списке покупок?", router.IntentQuery},
|
||||||
|
{"всё купил", router.IntentNote},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
var got router.Intent
|
||||||
|
claimed := false
|
||||||
|
for _, g := range router.ListGrammars() {
|
||||||
|
m := g.Pattern.FindStringSubmatch(c.utterance)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dec, ok := g.Build(m); ok {
|
||||||
|
got, claimed = dec.Intent, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
t.Errorf("no list grammar claimed %q", c.utterance)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range router.ListGrammars() {
|
||||||
|
m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра")
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := g.Build(m); ok {
|
||||||
|
t.Errorf("grammar %s claimed a reminder", g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListStoreSourceIsVoice(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
if _, ok := askList(t, h, "добавь в список покупок молоко"); !ok {
|
||||||
|
t.Fatal("not claimed")
|
||||||
|
}
|
||||||
|
items, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Source != "tap:voice" {
|
||||||
|
t.Errorf("stored %+v; want one row from tap:voice", items)
|
||||||
|
}
|
||||||
|
if items[0].Status != store.ListItemOpen {
|
||||||
|
t.Errorf("status %q; want open", items[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,24 +18,30 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
|||||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
// A standing list is neither work nor recall (Vikunja #453). Checked here
|
||||||
|
// for the same reason and at the same cost: before the embedding is paid
|
||||||
|
// for, and it passes the turn straight back when no marker matches.
|
||||||
|
if reply, ok := h.captureListFromNote(ctx, dec); ok {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
// embed the note text with the same model the classifier uses, persist
|
// embed the note text with the same model the classifier uses, persist
|
||||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||||
// facts — no predicate reads it (spec's two-memory split).
|
// facts — no predicate reads it (spec's two-memory split).
|
||||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: embed note: %v", err)
|
log.Printf("voice: embed note: %v", err)
|
||||||
return "не получилось сохранить заметку."
|
return phraser.Ack(phraser.FailNote, nil)
|
||||||
}
|
}
|
||||||
noteTs := h.now()
|
noteTs := h.now()
|
||||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: write note: %v", err)
|
log.Printf("voice: write note: %v", err)
|
||||||
return "не получилось сохранить заметку."
|
return phraser.Ack(phraser.FailNote, nil)
|
||||||
}
|
}
|
||||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||||
if h.memStore != nil {
|
if h.recall.memStore != nil {
|
||||||
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"text": dec.Utterance,
|
"text": dec.Utterance,
|
||||||
|
|||||||
+166
-74
@@ -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"
|
||||||
@@ -80,11 +81,27 @@ var querySources = []querySource{
|
|||||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||||
// date-bearing question still reaches the calendar.
|
// date-bearing question still reaches the calendar.
|
||||||
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
||||||
|
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||||||
|
// question about the operational state Praxis holds, and it used to fall
|
||||||
|
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||||
|
// an attention marker, and it falls through when Praxis is not configured.
|
||||||
|
{name: "attention", answer: (*reactiveHandler).queryAttention},
|
||||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||||
// the money facts the poller wrote, and the notes pass would otherwise
|
// the money facts the poller wrote, and the notes pass would otherwise
|
||||||
// answer it from whatever he once said about spending. Its matcher needs a
|
// answer it from whatever he once said about spending. Its matcher needs a
|
||||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||||
|
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||||
|
// about the shopping list, and the recall pass would otherwise answer it
|
||||||
|
// from an old note about the shop. Its matcher needs an explicit list
|
||||||
|
// marker, so "надо бы съездить в магазин" is untouched.
|
||||||
|
{name: "list", answer: (*reactiveHandler).queryList},
|
||||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||||
|
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||||
|
// the facts he tapped in, and the notes pass would answer it with whatever
|
||||||
|
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||||||
|
// history phrase and bails out when he names a topic, so "что я говорил
|
||||||
|
// про сервер" is still recall.
|
||||||
|
{name: "history", answer: (*reactiveHandler).queryHistory},
|
||||||
// Before the recall sources and before general knowledge: "что нового?" is
|
// Before the recall sources and before general knowledge: "что нового?" is
|
||||||
// a question about the feeds she reads, and general knowledge would answer
|
// a question about the feeds she reads, and general knowledge would answer
|
||||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||||
@@ -139,6 +156,13 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if reply, ok := src.answer(h, ctx, t); ok {
|
if reply, ok := src.answer(h, ctx, t); ok {
|
||||||
|
// Which source claimed is the one thing about a query turn that was
|
||||||
|
// invisible from outside: /trace is the nudge-rule trace and carries
|
||||||
|
// no query-source field, so a wrong answer could not be told from a
|
||||||
|
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
|
||||||
|
// the utterance and the answer are already on the voice lines above
|
||||||
|
// and below this one.
|
||||||
|
log.Printf("voice: query claimed by source %q", src.name)
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,9 +170,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
|||||||
// The previous question cannot be re-asked for another day. Saying so
|
// The previous question cannot be re-asked for another day. Saying so
|
||||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||||
// truth is that she never looked.
|
// truth is that she never looked.
|
||||||
return "про другой день так не отвечу — спроси целиком."
|
return phraser.Q(phraser.QueryOtherDay, nil)
|
||||||
}
|
}
|
||||||
return "не знаю."
|
return phraser.Q(phraser.QueryUnknown, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||||
@@ -166,11 +190,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
|||||||
if dec.Slots.HasTime {
|
if dec.Slots.HasTime {
|
||||||
// The query asks about timing — the fact's own timestamp is the
|
// The query asks about timing — the fact's own timestamp is the
|
||||||
// answer it's looking for. Format as a natural reply.
|
// answer it's looking for. Format as a natural reply.
|
||||||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
||||||
}
|
}
|
||||||
// General fact reference: describe what we know.
|
// General fact reference: describe what we know.
|
||||||
if dec.Utterance == "" {
|
if dec.Utterance == "" {
|
||||||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
|
||||||
}
|
}
|
||||||
// The utterance still carries the question; fall through to normal RAG
|
// The utterance still carries the question; fall through to normal RAG
|
||||||
// with the resolved key in context.
|
// with the resolved key in context.
|
||||||
@@ -196,7 +220,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
|||||||
plan, err := h.api.DayPlan(ctx)
|
plan, err := h.api.DayPlan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: day plan: %v", err)
|
log.Printf("voice: day plan: %v", err)
|
||||||
return "не получилось собрать план.", true
|
return phraser.Q(phraser.QueryFailPlan, nil), true
|
||||||
}
|
}
|
||||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||||
return plan.Spoken, true
|
return plan.Spoken, true
|
||||||
@@ -241,7 +265,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
|||||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: habits: recent facts: %v", err)
|
log.Printf("voice: habits: recent facts: %v", err)
|
||||||
return "не получилось посмотреть записи.", true
|
return phraser.Q(phraser.QueryFailNotes, nil), true
|
||||||
}
|
}
|
||||||
obs := make([]memory.Observation, 0, len(facts))
|
obs := make([]memory.Observation, 0, len(facts))
|
||||||
for _, f := range facts {
|
for _, f := range facts {
|
||||||
@@ -277,10 +301,18 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if !h.feedsOn {
|
if !h.feedsOn {
|
||||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
// Claim only when nothing below can read the world. The reason this
|
||||||
// letting general knowledge answer "что нового?" would be an invented
|
// source used to claim unconditionally was that general knowledge would
|
||||||
// news bulletin.
|
// answer "что нового?" with an invented news bulletin — true, and it
|
||||||
return "я пока не читаю ленты — они не настроены.", true
|
// stopped being the only alternative on 2026-08-02, when live search
|
||||||
|
// took the lead. With SearXNG or the ZIMs configured, "что происходит
|
||||||
|
// в новостях про искусственный интеллект?" has a real answer below,
|
||||||
|
// and a configuration status is the wrong thing to say instead
|
||||||
|
// (Vikunja #474).
|
||||||
|
if h.search != nil || h.kiwix != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
||||||
}
|
}
|
||||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||||
// used to push the newest headline out of the window, and she answered "в
|
// used to push the newest headline out of the window, and she answered "в
|
||||||
@@ -288,7 +320,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: feeds: recent notes: %v", err)
|
log.Printf("voice: feeds: recent notes: %v", err)
|
||||||
return "не получилось посмотреть ленты.", true
|
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
||||||
}
|
}
|
||||||
var picked []string
|
var picked []string
|
||||||
for _, n := range notes {
|
for _, n := range notes {
|
||||||
@@ -305,17 +337,26 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
}
|
}
|
||||||
if len(picked) == 0 {
|
if len(picked) == 0 {
|
||||||
if q.Category != "" {
|
if q.Category != "" {
|
||||||
return "по этой теме в лентах пока ничего.", true
|
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||||
}
|
}
|
||||||
return "в лентах пока ничего нового.", true
|
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||||
}
|
}
|
||||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||||
// source can be tested at a fixed time like the rest.
|
// source can be tested at a fixed time like the rest.
|
||||||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
// A day word is all this source matches on, so any question that merely
|
||||||
|
// names a day reached it first. "какая сегодня погода в Москве?" answered
|
||||||
|
// "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day
|
||||||
|
// far more often than the calendar is, and the weather source sits right
|
||||||
|
// below, so the calendar steps aside on weather wording — the same bail-out
|
||||||
|
// queryHome already does for the same reason.
|
||||||
|
if isWeatherQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -323,7 +364,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: calendar events: %v", err)
|
log.Printf("voice: calendar events: %v", err)
|
||||||
return "не получилось проверить календарь.", true
|
return phraser.Q(phraser.QueryFailCalendar, nil), true
|
||||||
}
|
}
|
||||||
// Provenance travels with each event. A work meeting relayed off a phone
|
// Provenance travels with each event. A work meeting relayed off a phone
|
||||||
// notification (source ambient:notif, #126) is stored below full confidence
|
// notification (source ambient:notif, #126) is stored below full confidence
|
||||||
@@ -340,7 +381,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
// calls States and nothing else, so there is no confirm turn here — the only
|
// calls States and nothing else, so there is no confirm turn here — the only
|
||||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isHomeQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.home == nil {
|
if h.home == nil {
|
||||||
@@ -362,48 +403,61 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
|||||||
// because Scan takes no target — the utterance selects the question, never the
|
// because Scan takes no target — the utterance selects the question, never the
|
||||||
// subnet.
|
// subnet.
|
||||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isNetworkQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.netscan == nil {
|
if h.netscan == nil {
|
||||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
// The recogniser already matched, so this is a question about HIS LAN
|
||||||
// swallow "сколько устройств в сети?" before recall has looked.
|
// and there is no scanner to answer it. Falling through sent it to the
|
||||||
return "", false
|
// search leg, which answered with a paragraph about routers in general
|
||||||
|
// and put his network question on an upstream engine (Vikunja #479).
|
||||||
|
// A missing capability names itself.
|
||||||
|
return phraser.Q(phraser.QueryNetOff, nil), true
|
||||||
}
|
}
|
||||||
return h.netscan.scanSummary(ctx)
|
return h.netscan.scanSummary(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isWeatherQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||||
if loc == "" {
|
if loc == "" {
|
||||||
// He named no city and voice.weather.default_location is unset. Saying
|
// He named no city and voice.weather.default_location is unset. Saying
|
||||||
// so is the only honest answer; picking a city would be inventing one.
|
// so is the only honest answer; picking a city would be inventing one.
|
||||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||||
}
|
}
|
||||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||||
if errors.Is(err, weather.ErrNotConfigured) {
|
if errors.Is(err, weather.ErrNotConfigured) {
|
||||||
return "погода не настроена.", true
|
return phraser.Q(phraser.QueryWeatherOff, nil), true
|
||||||
|
}
|
||||||
|
if errors.Is(err, weather.ErrLocationUnknown) {
|
||||||
|
// He named a place and the geocoder does not have it. Saying so beats
|
||||||
|
// reading out the default city's temperature (Vikunja #421).
|
||||||
|
return "не знаю такого города — " + loc + ".", true
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: weather: %v", err)
|
log.Printf("voice: weather: %v", err)
|
||||||
return "не получилось узнать погоду.", true
|
return phraser.Q(phraser.QueryFailWeather, nil), true
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||||||
|
"location": w.Location,
|
||||||
|
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||||||
|
"word": phraser.Degrees(w.Temperature),
|
||||||
|
"condition": w.Condition,
|
||||||
|
}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||||
// sources below both need, run once, in the position it always ran in. It
|
// sources below both need, run once, in the position it always ran in. It
|
||||||
// only claims the turn when the embedder fails.
|
// only claims the turn when the embedder fails.
|
||||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: embed query: %v", err)
|
log.Printf("voice: embed query: %v", err)
|
||||||
return "не получилось найти ответ.", true
|
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||||
}
|
}
|
||||||
t.vec = vec
|
t.vec = vec
|
||||||
return "", false
|
return "", false
|
||||||
@@ -420,23 +474,37 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
|||||||
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
||||||
// which memory gets to answer them.
|
// which memory gets to answer them.
|
||||||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if h.memStore == nil {
|
if h.recall.memStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
log.Printf("voice: memory search: %v", herr)
|
log.Printf("voice: memory search: %v", herr)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
text := hit.Meta["text"]
|
text := hit.Meta["text"]
|
||||||
|
// The score cleared the gate and the topic still has to match (#470). A
|
||||||
|
// 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" {
|
||||||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
|
||||||
|
switch {
|
||||||
|
case perr != nil:
|
||||||
|
// Reading the note back verbatim beats the phraser's own fallback,
|
||||||
|
// which only wraps the same text in "вот что я нашла:".
|
||||||
|
log.Printf("voice: recall phrase: %v", perr)
|
||||||
|
case reply != "":
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,14 +526,20 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: query notes: %v", err)
|
log.Printf("voice: query notes: %v", err)
|
||||||
return "не получилось найти ответ.", true
|
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||||
}
|
}
|
||||||
t.notes = notes
|
t.notes = notes
|
||||||
noteScores := make([]float64, len(notes))
|
noteScores := make([]float64, len(notes))
|
||||||
for i, n := range notes {
|
for i, n := range notes {
|
||||||
noteScores[i] = n.Score
|
noteScores[i] = n.Score
|
||||||
}
|
}
|
||||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// 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
|
return "", false
|
||||||
}
|
}
|
||||||
texts := make([]string, len(notes))
|
texts := make([]string, len(notes))
|
||||||
@@ -477,7 +551,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
log.Printf("voice: phrase query: %v", err)
|
log.Printf("voice: phrase query: %v", err)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
reply = "вот что я нашла: " + texts[0]
|
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -499,38 +573,34 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.crawler == nil {
|
if h.crawler == nil {
|
||||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
// He named a URL, so the question is about that page and nothing else
|
||||||
// where it was never turned on the older behaviour is right: the model
|
// can answer it. The older comment here argued for falling through and
|
||||||
// answers the question as if the URL had not been said. Announcing a
|
// letting the model answer as if the URL had not been said; that is a
|
||||||
// configuration status is for a capability that exists and failed, not
|
// guess dressed as an answer (Vikunja #479).
|
||||||
// for one he never asked for.
|
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
page, err := h.crawler.Page(ctxFetch, link)
|
page, err := h.crawler.Page(ctxFetch, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, crawl.ErrRobots) {
|
if errors.Is(err, crawl.ErrRobots) {
|
||||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
||||||
}
|
}
|
||||||
log.Printf("voice: web: %v", err)
|
log.Printf("voice: web: %v", err)
|
||||||
return "не получилось прочитать страницу.", true
|
return phraser.Q(phraser.QueryFailPage, nil), true
|
||||||
}
|
}
|
||||||
if page.Text == "" {
|
if page.Text == "" {
|
||||||
return "страница открылась, но читать там нечего.", true
|
return phraser.Q(phraser.QueryPageEmpty, nil), true
|
||||||
}
|
}
|
||||||
// The page is handed to the phraser the same way a note is: as context for
|
// The page is handed to the phraser the same way a note is: as context for
|
||||||
// the question he actually asked. She answers the question, she does not
|
// the question he actually asked. She answers the question, she does not
|
||||||
// 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.
|
||||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -592,18 +662,11 @@ 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.
|
||||||
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -680,18 +743,11 @@ 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.
|
||||||
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -717,11 +773,11 @@ 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)
|
||||||
return "не знаю — не нашла у тебя такой записи.", true
|
return phraser.Q(phraser.QueryPersonalNone, nil), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||||
@@ -742,7 +798,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,13 +813,47 @@ 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.recall.boundary.load(ctx, h.recall.embedder)
|
||||||
|
if personal, world, ok := h.recall.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 phraser.Q(phraser.QueryUnknown, nil), 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 phraser.Q(phraser.QueryUnknown, nil), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, 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) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,13 +22,16 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !dec.Slots.HasTime {
|
if !dec.Slots.HasTime {
|
||||||
return "не получилось разобрать время напоминания."
|
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
// The body is what she says at the hour, so the marker and the time come
|
||||||
|
// out of it: the fire time is already a column, and "напомни" is an
|
||||||
|
// instruction that has been carried out (Vikunja #469).
|
||||||
|
payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}`
|
||||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||||
log.Printf("voice: create reminder: %v", err)
|
log.Printf("voice: create reminder: %v", err)
|
||||||
return "не получилось поставить напоминание."
|
return phraser.Ack(phraser.FailReminder, nil)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/tasks"
|
"github.com/kami/maven/internal/tasks"
|
||||||
@@ -40,18 +42,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: capture task: %v", err)
|
log.Printf("voice: capture task: %v", err)
|
||||||
return "не получилось записать задачу.", true
|
return phraser.Ack(phraser.FailTask, nil), true
|
||||||
}
|
}
|
||||||
if resp.Promoted {
|
if resp.Promoted {
|
||||||
// It was a candidate Maven derived from something she read, and he has
|
// It was a candidate Maven derived from something she read, and he has
|
||||||
// now said it himself. Saying "уже в списке" here would be answering a
|
// now said it himself. Saying "уже в списке" here would be answering a
|
||||||
// confirmation with a shrug.
|
// confirmation with a shrug.
|
||||||
return "поняла, беру в работу: " + cap.Text, true
|
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
||||||
}
|
}
|
||||||
if !resp.Created {
|
if !resp.Created {
|
||||||
return "это уже в списке.", true
|
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
|
||||||
}
|
}
|
||||||
return "записала: " + cap.Text, true
|
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||||
@@ -70,7 +72,16 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
|||||||
log.Printf("voice: list tasks: %v", err)
|
log.Printf("voice: list tasks: %v", err)
|
||||||
return "не получилось посмотреть задачи.", true
|
return "не получилось посмотреть задачи.", true
|
||||||
}
|
}
|
||||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
ranked := tasks.Rank(taskItems(live), h.now())
|
||||||
|
// Bind what she is about to say, in the order she says it, so "второй"
|
||||||
|
// means the second task he heard (ordinal.go).
|
||||||
|
spoken := tasks.Spoken(ranked)
|
||||||
|
cands := make([]dialogue.Candidate, 0, len(spoken))
|
||||||
|
for _, r := range spoken {
|
||||||
|
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||||
|
}
|
||||||
|
h.offerCandidates(cands)
|
||||||
|
return tasks.FormatRU(ranked), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не получилось") {
|
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
||||||
t.Errorf("reply = %q, want an honest failure", reply)
|
t.Errorf("reply = %q, want an honest failure", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
|||||||
if !(openIdx < candIdx) {
|
if !(openIdx < candIdx) {
|
||||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не подтвердил") {
|
if !strings.Contains(reply, "не подтверждал") {
|
||||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attentionMarkers — the offline floor under the attention topic (topics.go).
|
||||||
|
// The seeds decide when the embedder is there; this answers when it is not, and
|
||||||
|
// it stays a substring list on purpose for the reason the other floors do: a
|
||||||
|
// narrow test made blind beats a broad guess made blind.
|
||||||
|
//
|
||||||
|
// "что нового" is deliberately absent: the feeds source claims it, and it
|
||||||
|
// still should — a question about news is a question about the feeds she
|
||||||
|
// reads. This list is about the operational state of his things.
|
||||||
|
var attentionMarkers = []string{
|
||||||
|
"внимани", "что требует", "что не так", "что важн", "что срочн",
|
||||||
|
"needs attention", "what needs looking",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAttentionQuery reports whether the utterance asks what needs looking at.
|
||||||
|
// Called through turnIsAbout, never directly.
|
||||||
|
func isAttentionQuery(u string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, m := range attentionMarkers {
|
||||||
|
if strings.Contains(s, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAttention answers "что требует внимания?" from Praxis.
|
||||||
|
//
|
||||||
|
// The capability was already built and already degraded correctly, and no
|
||||||
|
// utterance could reach it (Vikunja #475). Its aliases live on the act
|
||||||
|
// dispatch, and the question routes to IntentQuery, so it fell through every
|
||||||
|
// source to the web search and came back with an encyclopedia article about
|
||||||
|
// the concept of attention — worse than silence, because it reads as an
|
||||||
|
// answer.
|
||||||
|
//
|
||||||
|
// Placed above the recall sources and well above the personal boundary: this
|
||||||
|
// is operational state about his things, and a notes pass would otherwise
|
||||||
|
// answer it from whatever he once wrote about a server. An unconfigured or
|
||||||
|
// absent Praxis falls through rather than claiming the turn, the same
|
||||||
|
// convention queryHome and queryNetwork follow. A Praxis that is configured
|
||||||
|
// and down does claim it, and says it cannot reach the service — that is the
|
||||||
|
// degradation the ecosystem contract asks for, and it comes from the same
|
||||||
|
// handler the act path uses.
|
||||||
|
func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !h.turnIsAbout(ctx, t, topicAttend, isAttentionQuery) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||||
|
Utterance: t.dec.Utterance,
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Fn: "list_attention", HasFn: true},
|
||||||
|
})
|
||||||
|
if reply == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsAttentionQuery(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"что требует внимания", true},
|
||||||
|
{"на что обратить внимание?", true},
|
||||||
|
{"что не так?", true},
|
||||||
|
{"что важного?", true},
|
||||||
|
// The feeds source owns this one, and should keep owning it.
|
||||||
|
{"что нового?", false},
|
||||||
|
{"какая погода?", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if got := isAttentionQuery(tc.text); got != tc.want {
|
||||||
|
t.Errorf("isAttentionQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAttentionQuestionReachesPraxis — the defect (Vikunja #475). The question
|
||||||
|
// routes to IntentQuery, and every source used to pass, so a web search about
|
||||||
|
// the concept of attention answered it.
|
||||||
|
func TestAttentionQuestionReachesPraxis(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||||
|
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||||
|
}))
|
||||||
|
h := ecoHandler(t, nil, praxis, nil)
|
||||||
|
|
||||||
|
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the attention question must be claimed before the world sources")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "disk almost full") {
|
||||||
|
t.Fatalf("reply = %q, want the praxis item", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A configured Praxis that is down claims the turn and says so. Falling
|
||||||
|
// through here would answer an outage with an encyclopedia article.
|
||||||
|
func TestAttentionQuestionSaysWhenPraxisIsDown(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||||
|
h := ecoHandler(t, nil, praxis, nil)
|
||||||
|
praxis.SetFault(503)
|
||||||
|
|
||||||
|
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}})
|
||||||
|
if !ok || !strings.Contains(reply, "не могу") {
|
||||||
|
t.Fatalf("an outage must name the gap, got ok=%v reply=%q", ok, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Praxis configured means no claim: the rest of the chain still runs.
|
||||||
|
func TestAttentionQuestionFallsThroughWithoutPraxis(t *testing.T) {
|
||||||
|
h, _ := newFactGateHandler(t, time.Now())
|
||||||
|
if _, ok := h.queryAttention(context.Background(), &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}}); ok {
|
||||||
|
t.Fatal("an unconfigured praxis must not claim the turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
+22
-29
@@ -33,19 +33,10 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
|||||||
router.IntentAct: {dialogue.SlotFn},
|
router.IntentAct: {dialogue.SlotFn},
|
||||||
}
|
}
|
||||||
|
|
||||||
// clarifyQuestions — one short question per missing slot.
|
// The questions themselves live in clarifytemplates.go, one list per slot,
|
||||||
//
|
// picked by attempt (Vikunja #457). The first ask is the short one this map
|
||||||
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
// used to hold; a re-ask says it differently, because a question he already
|
||||||
// would wander, and a question whose wording changes every time is harder to
|
// failed to answer is the worst one to repeat unchanged.
|
||||||
// answer than a blunt one that always reads the same. They are infinitive
|
|
||||||
// questions, so there is no gender agreement to get wrong; the feminine
|
|
||||||
// self-reference lives in the reply she gives when she drops the request.
|
|
||||||
var clarifyQuestions = map[dialogue.Slot]string{
|
|
||||||
dialogue.SlotTime: "Когда?",
|
|
||||||
dialogue.SlotText: "О чём напомнить?",
|
|
||||||
dialogue.SlotKey: "Что записать?",
|
|
||||||
dialogue.SlotFn: "Что сделать?",
|
|
||||||
}
|
|
||||||
|
|
||||||
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
||||||
// says so out loud: dropping the request in silence would leave him thinking it
|
// says so out loud: dropping the request in silence would leave him thinking it
|
||||||
@@ -106,11 +97,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")
|
||||||
@@ -147,7 +138,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
|||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
q, ok := clarifyQuestions[missing[0]]
|
q, ok := clarifyQuestionFor(missing[0], 1)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -157,7 +148,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 +156,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 +183,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 +197,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 +208,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,16 +252,18 @@ 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
|
||||||
}
|
}
|
||||||
question, ok := clarifyQuestions[remaining[0]]
|
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||||
|
// with the re-ask path, so the second gap is worded like a second try.
|
||||||
|
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||||||
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 +280,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, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||||||
}
|
}
|
||||||
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 +295,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
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-27
@@ -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).
|
||||||
@@ -156,8 +156,14 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||||
}
|
}
|
||||||
if reply != "Когда?" {
|
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
// question he already failed to answer is the worst way to ask it.
|
||||||
|
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||||
|
}
|
||||||
|
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||||||
|
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||||
t.Fatalf("attempt %d must leave the question armed", i)
|
t.Fatalf("attempt %d must leave the question armed", i)
|
||||||
@@ -185,7 +191,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 +205,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 +238,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 +266,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 +290,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 +302,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
|||||||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||||||
// TTL: she must say the old request is gone AND still answer the new words.
|
// TTL: she must say the old request is gone AND still answer the new words.
|
||||||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h.embedder = emb
|
h.recall.embedder = emb
|
||||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
|
|
||||||
reply := h.handleText(ctx, "как дела")
|
reply := h.handleText(ctx, "", "как дела")
|
||||||
if !isClarifyExpired(reply) {
|
if !isClarifyExpired(reply) {
|
||||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||||
}
|
}
|
||||||
if trimClarifyExpired(reply) == "" {
|
if trimClarifyExpired(reply) == "" {
|
||||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
// The notice is said once, not on every later utterance.
|
// The notice is said once, not on every later utterance.
|
||||||
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
|
if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
|
||||||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +346,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||||
if !asked || question != "О чём напомнить?" {
|
if !asked || question != "О чём напомнить?" {
|
||||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
@@ -349,8 +355,11 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatal("the answer must be consumed as an answer")
|
t.Fatal("the answer must be consumed as an answer")
|
||||||
}
|
}
|
||||||
if reply != "Когда?" {
|
// Second gap, second attempt, so it is the second wording of the time
|
||||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
// question — the attempt budget is shared between the two paths.
|
||||||
|
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||||
}
|
}
|
||||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||||
if q == nil {
|
if q == nil {
|
||||||
@@ -380,7 +389,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, "позвонить маме")
|
||||||
@@ -411,8 +420,9 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
|||||||
eval.CheckCringe: true,
|
eval.CheckCringe: true,
|
||||||
}
|
}
|
||||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||||
for _, q := range clarifyQuestions {
|
lines = append(lines, clarifyMissedVariants...)
|
||||||
lines = append(lines, q)
|
for _, variants := range clarifyQuestionVariants {
|
||||||
|
lines = append(lines, variants...)
|
||||||
}
|
}
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||||
@@ -440,10 +450,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 +461,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 +471,96 @@ 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)
|
||||||
|
}
|
||||||
|
// The hour is the fire time, not a word in the body: the body is what she
|
||||||
|
// says at the hour, and the time expression is stripped out of it
|
||||||
|
// (Vikunja #469). Clobbering the parked request would show up here as a
|
||||||
|
// reminder that fires at some other time than the one he asked for.
|
||||||
|
if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
|
||||||
|
t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClarifyIsPerConversation — the parked question belongs to the reach that
|
||||||
|
// was asked. Before this the clarify store had one global key, so a question
|
||||||
|
// asked in the web chat and never answered captured the next utterance from
|
||||||
|
// telegram, or from the mic, and answered it against a request the speaker had
|
||||||
|
// never made (Vikunja #466).
|
||||||
|
func TestClarifyIsPerConversation(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||||
|
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
|
||||||
|
|
||||||
|
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question on the web conversation")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question asked on the web must not eat a telegram utterance")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
|
||||||
|
t.Fatal("a question asked on the web must not eat what he says at the mic")
|
||||||
|
}
|
||||||
|
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||||
|
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceCtx — the mic's conversation, which carries no id of its own.
|
||||||
|
func voiceCtx() context.Context {
|
||||||
|
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||||||
|
// question dies with the process, and she does not claim to have let it go —
|
||||||
|
// the words that follow are routed as a fresh request. Restarting is modelled
|
||||||
|
// the way the daemon does it, by building a second handler over the same store.
|
||||||
|
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
ctx := voiceCtx()
|
||||||
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question before the restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
restarted, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||||||
|
}
|
||||||
|
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||||||
|
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The clarify copy deck (Vikunja #457).
|
||||||
|
//
|
||||||
|
// Every clarify turn used to say one sentence per gap, and a re-ask repeated
|
||||||
|
// that sentence word for word. A question he already failed to answer is the
|
||||||
|
// worst one to ask again unchanged: the second wording is the one that tells
|
||||||
|
// him which part she missed.
|
||||||
|
//
|
||||||
|
// Fixed templates, not model output, for the reason clarifyQuestions has always
|
||||||
|
// given: the resident model would wander, and a question whose wording changes
|
||||||
|
// at random is harder to answer than a blunt one. What changes here is that the
|
||||||
|
// wording varies with the attempt rather than with a die roll — the first ask is
|
||||||
|
// short, the second names the gap, the third spells it out.
|
||||||
|
//
|
||||||
|
// No schema_version, unlike internal/phraser/nudge_templates.go. These are Go
|
||||||
|
// constants compiled into the daemon, so there is no file that can drift out of
|
||||||
|
// step with the code that reads it.
|
||||||
|
//
|
||||||
|
// Persona holds: infinitive and imperative questions, so there is no gender
|
||||||
|
// agreement to get wrong, "ты" throughout, and no pet names.
|
||||||
|
var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||||
|
dialogue.SlotTime: {
|
||||||
|
"Когда?",
|
||||||
|
"Во сколько напомнить?",
|
||||||
|
"Скажи время — например, «в семь вечера» или «через час».",
|
||||||
|
},
|
||||||
|
dialogue.SlotText: {
|
||||||
|
"О чём напомнить?",
|
||||||
|
"Что сказать тебе в это время?",
|
||||||
|
"Скажи одной фразой, о чём напомнить.",
|
||||||
|
},
|
||||||
|
dialogue.SlotKey: {
|
||||||
|
"Что записать?",
|
||||||
|
"Что именно отметить?",
|
||||||
|
"Назови, что записать — например, «выпил воды».",
|
||||||
|
},
|
||||||
|
dialogue.SlotFn: {
|
||||||
|
"Что сделать?",
|
||||||
|
"Какое действие выполнить?",
|
||||||
|
"Назови действие — я умею только то, что ты мне разрешил.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as
|
||||||
|
// PendingQuestion.Attempts counts it; anything past the list uses the last and
|
||||||
|
// most explicit phrasing rather than wrapping round to the short one, because
|
||||||
|
// wrapping would ask the same short question he has already not answered.
|
||||||
|
//
|
||||||
|
// Deterministic on purpose, unlike clarifyExpiredLine: an expiry notice is the
|
||||||
|
// same statement however it is worded, and a re-ask is not.
|
||||||
|
func clarifyQuestionFor(slot dialogue.Slot, attempt int) (string, bool) {
|
||||||
|
variants, ok := clarifyQuestionVariants[slot]
|
||||||
|
if !ok || len(variants) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
i := attempt - 1
|
||||||
|
if i < 0 {
|
||||||
|
i = 0
|
||||||
|
}
|
||||||
|
if i >= len(variants) {
|
||||||
|
i = len(variants) - 1
|
||||||
|
}
|
||||||
|
return variants[i], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedVariants — she is asking for the whole utterance again, because
|
||||||
|
// the gate fired on an intent with nothing identifiable to ask about (note,
|
||||||
|
// query, chat, system are not in wantedSlots).
|
||||||
|
//
|
||||||
|
// Rotated like the expiry lines and for the same reason: this is the line he
|
||||||
|
// hears whenever she misses him completely, so it is a line that repeats, and
|
||||||
|
// the same sentence every time is what makes a house assistant sound like a
|
||||||
|
// kiosk. All of them say the same two things — she did not catch it, and he
|
||||||
|
// should say it again — because the wording may vary and the meaning may not.
|
||||||
|
var clarifyMissedVariants = []string{
|
||||||
|
"Не совсем поняла — скажи, пожалуйста, ещё раз.",
|
||||||
|
"Я тебя не разобрала. Повтори, пожалуйста.",
|
||||||
|
"Не уловила. Скажи это по-другому?",
|
||||||
|
"Прости, не поняла — попробуй сказать иначе.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedFor picks a wording by the utterance itself, so the same words
|
||||||
|
// asked twice get the same answer and two different misses sound different.
|
||||||
|
//
|
||||||
|
// A hash, not rand: a test that drives an utterance twice must not depend on a
|
||||||
|
// die roll, and the point of rotating is only that consecutive misses differ.
|
||||||
|
func clarifyMissedFor(utterance string) string {
|
||||||
|
var sum int
|
||||||
|
for _, r := range utterance {
|
||||||
|
sum += int(r)
|
||||||
|
}
|
||||||
|
return clarifyMissedVariants[sum%len(clarifyMissedVariants)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedLine is the canned reply for a clarify decision she cannot turn
|
||||||
|
// into a question. Returns "" for a decision that is not a clarify, so the
|
||||||
|
// caller keeps its own reply.
|
||||||
|
func clarifyMissedLine(dec router.Decision) string {
|
||||||
|
if !dec.Clarify {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return clarifyMissedFor(dec.Utterance)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every slot she can ask about has a wording for every attempt she is allowed,
|
||||||
|
// and no two attempts on one slot read the same. A deck with a repeated line is
|
||||||
|
// the defect this deck exists to fix (Vikunja #457).
|
||||||
|
func TestClarifyQuestionsVaryByAttempt(t *testing.T) {
|
||||||
|
for slot, variants := range clarifyQuestionVariants {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range variants {
|
||||||
|
if v == "" {
|
||||||
|
t.Errorf("%s: empty wording in the deck", slot)
|
||||||
|
}
|
||||||
|
if seen[v] {
|
||||||
|
t.Errorf("%s: repeated wording %q", slot, v)
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
for attempt := 1; attempt <= len(variants); attempt++ {
|
||||||
|
got, ok := clarifyQuestionFor(slot, attempt)
|
||||||
|
if !ok || got != variants[attempt-1] {
|
||||||
|
t.Errorf("%s attempt %d = %q ok=%v, want %q", slot, attempt, got, ok, variants[attempt-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the end she keeps the most explicit wording. Wrapping round would ask
|
||||||
|
// the short question he has already not answered twice.
|
||||||
|
func TestClarifyQuestionPastTheEndKeepsTheLastWording(t *testing.T) {
|
||||||
|
last := clarifyQuestionVariants[dialogue.SlotTime][len(clarifyQuestionVariants[dialogue.SlotTime])-1]
|
||||||
|
for _, attempt := range []int{0, 4, 9} {
|
||||||
|
if got, _ := clarifyQuestionFor(dialogue.SlotTime, attempt); attempt > 1 && got != last {
|
||||||
|
t.Errorf("attempt %d = %q, want the last wording %q", attempt, got, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := clarifyQuestionFor("nonesuch", 1); ok {
|
||||||
|
t.Error("an unknown slot must have no question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The missed line is stable for one utterance and absent for a decision that is
|
||||||
|
// not a clarify.
|
||||||
|
func TestClarifyMissedLine(t *testing.T) {
|
||||||
|
d := router.Decision{Clarify: true, Utterance: "мгм"}
|
||||||
|
first := clarifyMissedLine(d)
|
||||||
|
if first == "" || first != clarifyMissedLine(d) {
|
||||||
|
t.Fatalf("the missed line must be stable for one utterance, got %q", first)
|
||||||
|
}
|
||||||
|
if got := clarifyMissedLine(router.Decision{Intent: router.IntentNote}); got != "" {
|
||||||
|
t.Errorf("a decision that is not a clarify got %q", got)
|
||||||
|
}
|
||||||
|
// The empty utterance still gets a line: she has to say something.
|
||||||
|
if got := clarifyMissedLine(router.Decision{Clarify: true}); got == "" {
|
||||||
|
t.Error("an empty utterance must still be answered out loud")
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 {
|
||||||
|
|||||||
+16
-11
@@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A daemon where page reading was never turned on — the default — answers the
|
// A daemon where page reading was never turned on names the gap. He asked
|
||||||
// question the way it did before the capability existed. Claiming the turn to
|
// about one page, nothing else on the box can read it, and the old behaviour
|
||||||
// report a configuration status is for something that exists and failed.
|
// here was to answer as though the URL had not been said (Vikunja #479).
|
||||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||||
h := buildWebHandler(nil)
|
h := buildWebHandler(nil)
|
||||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
if !ok {
|
||||||
|
t.Fatal("an unconfigured crawler let the page question fall through")
|
||||||
|
}
|
||||||
|
if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
|
||||||
|
t.Errorf("got %q, want the gap named", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,21 +160,22 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the web source did not claim a question with a URL")
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не получилось") {
|
if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
|
||||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// robots.txt is honoured on the answer path too, and she says so instead of
|
// robots.txt is honoured on the answer path too, and she says the page is
|
||||||
// reporting a generic failure.
|
// closed instead of reporting a generic failure.
|
||||||
func TestQueryWebObeysRobots(t *testing.T) {
|
func TestQueryWebObeysRobots(t *testing.T) {
|
||||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the web source did not claim a question with a URL")
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "robots.txt") {
|
// She names the cause without reading a filename out loud.
|
||||||
t.Errorf("reply = %q, want the robots answer", reply)
|
if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") {
|
||||||
|
t.Errorf("reply = %q, want the closed-page answer with no filename", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/calendar"
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||||
}
|
}
|
||||||
if reply != "не получилось собрать план." {
|
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
||||||
t.Errorf("reply = %q", reply)
|
t.Errorf("reply = %q, want the honest failure", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+183
-32
@@ -9,10 +9,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/tool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The three services, spelled the way she says them out loud. A service that is
|
||||||
|
// down or refusing has to be named: they degrade independently, so "не
|
||||||
|
// отвечает" on its own tells him nothing he can act on, and each call site
|
||||||
|
// already knows which one it was talking to — it records the same name in the
|
||||||
|
// trace (Vikunja #521).
|
||||||
|
const (
|
||||||
|
serviceNexus = "Nexus"
|
||||||
|
serviceHexis = "Hexis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceVars — the one-key map the eco_down and eco_denied lines take.
|
||||||
|
func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
|
||||||
|
|
||||||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||||||
// rather than a map[string]func because each arm carries its own state: the
|
// rather than a map[string]func because each arm carries its own state: the
|
||||||
// verb aliases it answers to, the trace name it records, and its own reply
|
// verb aliases it answers to, the trace name it records, and its own reply
|
||||||
@@ -144,10 +159,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
log.Printf("ecosystem: praxis attention: %v", err)
|
log.Printf("ecosystem: praxis attention: %v", err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||||
started, traceErrorFields(err))
|
started, traceErrorFields(err))
|
||||||
return "не могу сейчас узнать, что требует внимания."
|
return phraser.A(phraser.AttentionFail, nil)
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return "ничего не требует внимания."
|
return phraser.A(phraser.AttentionNone, nil)
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||||
var parts []string
|
var parts []string
|
||||||
@@ -157,6 +172,11 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
importance, _ := item["importance"].(float64)
|
importance, _ := item["importance"].(float64)
|
||||||
rule, _ := item["rule"].(string)
|
rule, _ := item["rule"].(string)
|
||||||
s := title
|
s := title
|
||||||
|
if s == "" {
|
||||||
|
// An item Praxis returned without a title is not an item she can
|
||||||
|
// read out. Counting it would put an empty slot in the list.
|
||||||
|
continue
|
||||||
|
}
|
||||||
if importance > 0 {
|
if importance > 0 {
|
||||||
s += fmt.Sprintf(" (важность %d", int(importance))
|
s += fmt.Sprintf(" (важность %d", int(importance))
|
||||||
if rule != "" {
|
if rule != "" {
|
||||||
@@ -175,7 +195,13 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "требует внимания: " + strings.Join(parts, "; ")
|
if len(parts) == 0 {
|
||||||
|
// Praxis returned items and not one of them could be said. "ничего не
|
||||||
|
// требует внимания" is the honest answer; the list line would render as
|
||||||
|
// its own label and a colon (Vikunja #521).
|
||||||
|
return phraser.A(phraser.AttentionNone, nil)
|
||||||
|
}
|
||||||
|
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||||
}
|
}
|
||||||
|
|
||||||
// listChangesCapability reads the recent-changes feed.
|
// listChangesCapability reads the recent-changes feed.
|
||||||
@@ -192,19 +218,29 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
|||||||
log.Printf("ecosystem: praxis changes: %v", err)
|
log.Printf("ecosystem: praxis changes: %v", err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||||
started, traceErrorFields(err))
|
started, traceErrorFields(err))
|
||||||
return "не могу сейчас узнать об изменениях."
|
return phraser.A(phraser.ChangesFail, nil)
|
||||||
}
|
}
|
||||||
if len(changes) == 0 {
|
if len(changes) == 0 {
|
||||||
return "нет изменений."
|
return phraser.A(phraser.ChangesNone, nil)
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, c := range changes {
|
for _, c := range changes {
|
||||||
title, _ := c["title"].(string)
|
title, _ := c["title"].(string)
|
||||||
|
if title == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
typ, _ := c["change_type"].(string)
|
typ, _ := c["change_type"].(string)
|
||||||
|
if typ == "" {
|
||||||
|
parts = append(parts, title)
|
||||||
|
continue
|
||||||
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
||||||
}
|
}
|
||||||
return "изменения: " + strings.Join(parts, "; ")
|
if len(parts) == 0 {
|
||||||
|
return phraser.A(phraser.ChangesNone, nil)
|
||||||
|
}
|
||||||
|
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||||
}
|
}
|
||||||
|
|
||||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||||
@@ -230,12 +266,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
subject = dec.Slots.Text
|
subject = dec.Slots.Text
|
||||||
}
|
}
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
return "про что именно спросить?"
|
return phraser.A(phraser.EcoAboutWhat, nil)
|
||||||
}
|
}
|
||||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||||
// than quietly answering about something else.
|
// than quietly answering about something else.
|
||||||
return "не могу связать это с сущностью — Nexus не настроен."
|
return phraser.A(phraser.EcoNoNexus, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
started := h.now()
|
started := h.now()
|
||||||
@@ -248,15 +284,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||||
}
|
}
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||||
}
|
}
|
||||||
if len(ambiguous) > 0 {
|
if len(ambiguous) > 0 {
|
||||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
return "не знаю такой сущности."
|
return phraser.A(phraser.EcoUnknownEntity, nil)
|
||||||
}
|
}
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = subject
|
displayName = subject
|
||||||
@@ -268,7 +304,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||||
}
|
}
|
||||||
items, scoped := scopedToEntity(items, entityID)
|
items, scoped := scopedToEntity(items, entityID)
|
||||||
if !scoped {
|
if !scoped {
|
||||||
@@ -279,7 +315,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
||||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||||
"entity_id": entityID, "count": len(items),
|
"entity_id": entityID, "count": len(items),
|
||||||
@@ -303,9 +339,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
parts = append(parts, known)
|
parts = append(parts, known)
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "по «" + displayName + "» ничего нет."
|
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
||||||
}
|
}
|
||||||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||||
}
|
}
|
||||||
|
|
||||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||||
@@ -370,7 +406,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
|
|||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
out := "я помню: " + strings.Join(parts, ", ")
|
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
|
||||||
if more {
|
if more {
|
||||||
out += ", и это не всё"
|
out += ", и это не всё"
|
||||||
}
|
}
|
||||||
@@ -497,6 +533,64 @@ func traceErrorFields(err error) map[string]any {
|
|||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entityResolution — what asking Nexus about a turn's candidate names came to.
|
||||||
|
// One shape rather than five return values, because the caller needs the
|
||||||
|
// reference that answered as well as the answer: it goes in the trace.
|
||||||
|
type entityResolution struct {
|
||||||
|
subject string // the reference Nexus answered about
|
||||||
|
entityID string // set when exactly one name resolved
|
||||||
|
displayName string // that entity's name as Nexus spells it
|
||||||
|
ambiguous []string // candidate display names to ask between
|
||||||
|
err error // a dependency failure, not a miss
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveEntityCandidates asks Nexus about each name the turn offered and
|
||||||
|
// reports what it knows, stopping early where the answer is already decided.
|
||||||
|
//
|
||||||
|
// The rules, in the order they apply:
|
||||||
|
//
|
||||||
|
// - A dependency failure ends it. Nexus being down is not "no such entity",
|
||||||
|
// and asking about the next name would report the outage as a miss.
|
||||||
|
// - Nexus calling one name ambiguous ends it. It has the candidates and it is
|
||||||
|
// telling us to ask.
|
||||||
|
// - Two names resolving to different entities is a clarify too, this time ours:
|
||||||
|
// "перезапусти nginx на muzick-indexer" names both a service and its host,
|
||||||
|
// and picking either would be inventing an intent he did not state.
|
||||||
|
// - Nothing resolving returns the first name as the subject, so the trace says
|
||||||
|
// what was actually looked for.
|
||||||
|
func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []string) entityResolution {
|
||||||
|
var out entityResolution
|
||||||
|
for _, ref := range refs {
|
||||||
|
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, ref, nil)
|
||||||
|
if err != nil {
|
||||||
|
return entityResolution{subject: ref, err: err}
|
||||||
|
}
|
||||||
|
if len(ambiguous) > 0 {
|
||||||
|
return entityResolution{subject: ref, ambiguous: ambiguous}
|
||||||
|
}
|
||||||
|
if entityID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out.entityID == "" {
|
||||||
|
out = entityResolution{subject: ref, entityID: entityID, displayName: displayName}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entityID == out.entityID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Both are real and they are not the same thing. Hand back the names
|
||||||
|
// Nexus spells, not the words he happened to say.
|
||||||
|
return entityResolution{
|
||||||
|
subject: out.subject,
|
||||||
|
ambiguous: []string{out.displayName, displayName},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.entityID == "" && len(refs) > 0 {
|
||||||
|
out.subject = refs[0]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// handleHexisAct — resolves entity references through Nexus and executes
|
// handleHexisAct — resolves entity references through Nexus and executes
|
||||||
// matching capabilities through Hexis. Returns a reply string when handled,
|
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||||
// or "" to fall through to the system command executor.
|
// or "" to fall through to the system command executor.
|
||||||
@@ -514,28 +608,31 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
|
|
||||||
// Resolve the utterance text as an entity reference through Nexus. An
|
// Resolve the utterance text as an entity reference through Nexus. An
|
||||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||||
|
// The names come from entityReferences, not straight from the Text slot: the
|
||||||
|
// model transliterates Latin names as it routes (Vikunja #476, #524).
|
||||||
started := h.now()
|
started := h.now()
|
||||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
res := h.resolveEntityCandidates(ctx, entityReferences(dec))
|
||||||
|
subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||||
}
|
}
|
||||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||||
// and report degradation rather than silently falling through to the
|
// and report degradation rather than silently falling through to the
|
||||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||||
// independently, never a silent all-clear).
|
// independently, never a silent all-clear).
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||||
}
|
}
|
||||||
if len(ambiguous) > 0 {
|
if len(ambiguous) > 0 {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||||
map[string]any{"candidates": len(ambiguous)})
|
map[string]any{"candidates": len(ambiguous)})
|
||||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
map[string]any{"subject": redactSubject(subject)})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||||
@@ -550,9 +647,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
|
||||||
}
|
}
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||||
@@ -569,10 +666,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
verbLower := strings.ToLower(verb)
|
verbLower := strings.ToLower(verb)
|
||||||
|
|
||||||
|
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
|
||||||
|
// indexer"), which no capability name ever contains. Read it the other way
|
||||||
|
// round then: the phrase is the haystack and the capability name is what we
|
||||||
|
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
|
||||||
|
// fn is a single verb and containment already means what it says.
|
||||||
|
loose := !dec.Slots.HasFn
|
||||||
var matches []*hexisclient.Capability
|
var matches []*hexisclient.Capability
|
||||||
for i, c := range caps {
|
for i, c := range caps {
|
||||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
name := strings.ToLower(c.Name)
|
||||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
hit := strings.Contains(name, verbLower) ||
|
||||||
|
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower))
|
||||||
|
if loose && name != "" && strings.Contains(verbLower, name) {
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
matches = append(matches, &caps[i])
|
matches = append(matches, &caps[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,13 +692,30 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
names = append(names, m.Name)
|
names = append(names, m.Name)
|
||||||
}
|
}
|
||||||
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
|
||||||
}
|
}
|
||||||
matched := matches[0]
|
matched := matches[0]
|
||||||
|
|
||||||
// Read-only capabilities run immediately; mutating ones are parked for an
|
// Read-only capabilities run immediately; mutating ones are parked for an
|
||||||
// explicit spoken confirm bound to this capability + target.
|
// explicit spoken confirm bound to this capability + target.
|
||||||
if !matched.ReadOnly {
|
// The tier decides, and Hexis owns the tier (Vikunja #523). read_only alone
|
||||||
|
// used to decide it here, which flattened three answers into two: a
|
||||||
|
// capability that wipes the thing it names got the same single spoken "да"
|
||||||
|
// as one that restarts a service, and requires_confirmation — which the
|
||||||
|
// Hexis contract calls server-derived and not settable by a caller — was
|
||||||
|
// read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk.
|
||||||
|
tier := tool.RiskOfCapability(matched.Risk, matched.ReadOnly, matched.RequiresConfirmation)
|
||||||
|
policy := tool.PolicyFor(tier)
|
||||||
|
if !policy.VoiceMayRun {
|
||||||
|
// Irreversible. A confirm turn would not help, for the same reason it
|
||||||
|
// does not help a local row: the STT heard it, the model routed it and
|
||||||
|
// a substring matched the capability, and a spoken "да" checks none of
|
||||||
|
// those. She names the gap and he runs it himself.
|
||||||
|
h.recordEcosystemTrace(ctx, "hexis", "confirmation", traceRefused, started,
|
||||||
|
map[string]any{"entity_id": entityID, "capability": matched.Name, "risk": string(tier)})
|
||||||
|
return phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
||||||
|
}
|
||||||
|
if policy.Confirm {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.pendingHexis = &pendingHexisExec{
|
h.pendingHexis = &pendingHexisExec{
|
||||||
capabilityID: matched.ID,
|
capabilityID: matched.ID,
|
||||||
@@ -602,7 +727,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName})
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||||
@@ -622,7 +747,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
mergeFields(traceErrorFields(err), map[string]any{
|
mergeFields(traceErrorFields(err), map[string]any{
|
||||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||||
}))
|
}))
|
||||||
return "не получилось выполнить команду для " + displayName + "."
|
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||||
}
|
}
|
||||||
// One record per hop: the second write this used to make said the same
|
// One record per hop: the second write this used to make said the same
|
||||||
// thing under a different key, in a different shape.
|
// thing under a different key, in a different shape.
|
||||||
@@ -630,5 +755,31 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
"entity_id": entityID, "entity_name": displayName,
|
"entity_id": entityID, "entity_name": displayName,
|
||||||
"capability": capName, "causation_id": causationID,
|
"capability": capName, "causation_id": causationID,
|
||||||
})
|
})
|
||||||
return "команда выполнена для " + displayName + "."
|
return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
|
||||||
|
// asks what to do.
|
||||||
|
//
|
||||||
|
// The stage-3 gate thins an act that never matched an allowlisted fn, so
|
||||||
|
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
|
||||||
|
// path was never entered — the capability existed and no utterance could reach
|
||||||
|
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
|
||||||
|
// the verb is matched against the capabilities Hexis registers for the entity,
|
||||||
|
// not against the allowlist.
|
||||||
|
//
|
||||||
|
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
|
||||||
|
// only when Hexis is wired — a box with no ecosystem asks the question it
|
||||||
|
// always asked. A "" back means Nexus knew no such entity or Hexis had no
|
||||||
|
// matching capability, and then she asks after all. Authority is unchanged:
|
||||||
|
// resolution stops on ambiguity and a mutating capability still goes through
|
||||||
|
// the spoken confirm in handleHexisAct.
|
||||||
|
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
|
||||||
|
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.handleHexisAct(ctx, dec)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
|||||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
||||||
// store is the one thing the Praxis path could inherit it through.
|
// store is the one thing the Praxis path could inherit it through.
|
||||||
nexus.SetFault(503)
|
nexus.SetFault(503)
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
|
||||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||||
@@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
|||||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
||||||
}
|
}
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
|||||||
|
|
||||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
if reply == "" || actRan(reply) {
|
||||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
|||||||
nexus := newFakeNexus(t, body)
|
nexus := newFakeNexus(t, body)
|
||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
if reply == "" || actRan(reply) {
|
||||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
|||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
exec := tracesFor(t, h, "hexis", "execute")
|
exec := tracesFor(t, h, "hexis", "execute")
|
||||||
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
if len(traces(t, h)) == 0 {
|
if len(traces(t, h)) == 0 {
|
||||||
@@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Errorf("%s: total outage must not answer with silence", name)
|
t.Errorf("%s: total outage must not answer with silence", name)
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
|||||||
|
|
||||||
nexus.SetFault(503)
|
nexus.SetFault(503)
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
nexus.SetFault(0)
|
nexus.SetFault(0)
|
||||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if !strings.Contains(reply, "выполнена") {
|
if !actRan(reply) {
|
||||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
|||||||
|
|
||||||
// The follow-up "да" turn executes exactly the parked capability.
|
// The follow-up "да" turn executes exactly the parked capability.
|
||||||
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
||||||
if !handled || !strings.Contains(confirmReply, "выполнена") {
|
if !handled || !actRan(confirmReply) {
|
||||||
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
||||||
}
|
}
|
||||||
if !*executed {
|
if !*executed {
|
||||||
@@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
|||||||
if h.pendingHexis != nil {
|
if h.pendingHexis != nil {
|
||||||
t.Fatal("read-only cap should not park a confirmation")
|
t.Fatal("read-only cap should not park a confirmation")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "выполнена") {
|
if !actRan(reply) {
|
||||||
t.Fatalf("unexpected reply %q", reply)
|
t.Fatalf("unexpected reply %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,3 +242,73 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
|||||||
t.Fatal("not_found resolution must never execute a hexis capability")
|
t.Fatal("not_found resolution must never execute a hexis capability")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actRan — the reply is the line she says when a capability ran against an
|
||||||
|
// entity. The tests used to look for the substring "выполнена", which was a
|
||||||
|
// literal out of the act file: the review reworded that line to "готово: {name}"
|
||||||
|
// and seventeen assertions went with it (Vikunja #521).
|
||||||
|
func actRan(reply string) bool {
|
||||||
|
return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// muzickIndexer — the display name every ecosystem fixture resolves to.
|
||||||
|
const muzickIndexer = "Muzick indexer"
|
||||||
|
|
||||||
|
// read_only used to be the whole decision on this path, which meant a
|
||||||
|
// capability that destroys what it names got the same single spoken "да" as one
|
||||||
|
// that restarts a service. Hexis declares the tier and the voice path is not an
|
||||||
|
// authorised surface for the top one (Vikunja #523).
|
||||||
|
func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]`
|
||||||
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if *executed {
|
||||||
|
t.Fatal("an irreversible capability ran from the voice path")
|
||||||
|
}
|
||||||
|
if h.pendingHexis != nil {
|
||||||
|
t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не вернуть") {
|
||||||
|
t.Errorf("reply = %q; want it to name why she will not run it", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half: Hexis calling a capability safe is enough to run it, even
|
||||||
|
// though read_only is the field that used to decide. Nothing here re-derives.
|
||||||
|
func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]`
|
||||||
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if !*executed {
|
||||||
|
t.Fatal("a capability Hexis calls safe should run")
|
||||||
|
}
|
||||||
|
if !actRan(reply) {
|
||||||
|
t.Fatalf("unexpected reply %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mutating capability with no declared tier keeps the confirm turn it has
|
||||||
|
// always had, so the split does not quietly loosen an existing box.
|
||||||
|
func TestHexisUndeclaredTierStillConfirms(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
||||||
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if *executed {
|
||||||
|
t.Fatal("a mutating capability ran without a confirm")
|
||||||
|
}
|
||||||
|
if h.pendingHexis == nil {
|
||||||
|
t.Fatal("a mutating capability did not park a confirm")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "да или нет") {
|
||||||
|
t.Errorf("reply = %q; want the confirm question", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// latinRun matches a run of Latin-script words — the shape a service, host or
|
||||||
|
// project name takes in a Russian sentence. Digits, dot, dash and underscore
|
||||||
|
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
|
||||||
|
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
|
||||||
|
|
||||||
|
// maxEntityReferences caps how many names one utterance may send to Nexus. The
|
||||||
|
// cap is not about correctness, it is about one turn not fanning out into a
|
||||||
|
// dozen HTTP calls when the utterance is a paragraph of English.
|
||||||
|
const maxEntityReferences = 4
|
||||||
|
|
||||||
|
// hasLatin reports whether s carries a Latin letter.
|
||||||
|
func hasLatin(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.In(r, unicode.Latin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityReferences returns the names Nexus is asked to resolve, in the order
|
||||||
|
// they were said.
|
||||||
|
//
|
||||||
|
// Normally there is one, and it is the router's Text slot — the verb phrase the
|
||||||
|
// model wrote. But the resident model rewrites a Russian utterance as it routes,
|
||||||
|
// and on the way it transliterates: "перезапусти muzick indexer" came back as
|
||||||
|
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
|
||||||
|
// service nobody has ever named, so the act cannot resolve its target even with
|
||||||
|
// every gate open.
|
||||||
|
//
|
||||||
|
// The recovery is deliberately narrow. Only when the utterance holds a Latin run
|
||||||
|
// and the model's Text holds none has a name certainly been rewritten. Anything
|
||||||
|
// else keeps the Text slot, so an English utterance and a Russian entity name
|
||||||
|
// are both untouched. Un-transliterating the Cyrillic back is not attempted: the
|
||||||
|
// surface form he said is right there, and guessing at a reverse mapping would
|
||||||
|
// invent a second name to be wrong about.
|
||||||
|
//
|
||||||
|
// What this does NOT do is pick. It used to return the longest run, and length
|
||||||
|
// is a guess: "перезапусти nginx на muzick-indexer" has two names in it and the
|
||||||
|
// longer one is not reliably the target. Nexus owns which names it knows
|
||||||
|
// (docs/ecosystem.md — ambiguous resolution asks the owner, it does not pick),
|
||||||
|
// so every run goes over and Nexus answers. Two runs that both resolve are a
|
||||||
|
// clarify, not a coin toss.
|
||||||
|
func entityReferences(dec router.Decision) []string {
|
||||||
|
text := dec.Slots.Text
|
||||||
|
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
var refs []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
||||||
|
m = strings.TrimSpace(m)
|
||||||
|
// A single stray letter is not a name.
|
||||||
|
if len(m) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(m)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
refs = append(refs, m)
|
||||||
|
if len(refs) == maxEntityReferences {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(refs) == 0 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntityReferences pins when his own words win over the model's, and that
|
||||||
|
// every name he said goes over rather than one of them being picked.
|
||||||
|
func TestEntityReferences(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
utterance string
|
||||||
|
text string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "the model transliterated the name",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить музик индексер",
|
||||||
|
want: []string{"muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "it kept the name, so nothing to repair",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить muzick indexer",
|
||||||
|
want: []string{"перезагрузить muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an all-Russian entity name is not a rewrite",
|
||||||
|
utterance: "перезапусти домашний сервер",
|
||||||
|
text: "перезагрузить домашний сервер",
|
||||||
|
want: []string{"перезагрузить домашний сервер"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an English turn never enters the recovery",
|
||||||
|
utterance: "restart muzick indexer",
|
||||||
|
text: "restart muzick indexer",
|
||||||
|
want: []string{"restart muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both names go over, in the order he said them",
|
||||||
|
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
||||||
|
text: "перезагрузить нгинкс",
|
||||||
|
want: []string{"nginx", "muzick-indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one stray letter is not a name",
|
||||||
|
utterance: "перезапусти сервер a",
|
||||||
|
text: "перезагрузить сервер",
|
||||||
|
want: []string{"перезагрузить сервер"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the same name twice is one question",
|
||||||
|
utterance: "перезапусти nginx, ну правда, nginx",
|
||||||
|
text: "перезагрузить нгинкс",
|
||||||
|
want: []string{"nginx"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
||||||
|
got := entityReferences(dec)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
|
||||||
|
// router hands over a transliterated Text, and Nexus must still be asked about
|
||||||
|
// the service that exists.
|
||||||
|
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
h.handleHexisAct(ctx, dec)
|
||||||
|
|
||||||
|
reqs := nexus.Requests()
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
t.Fatal("nexus was never asked")
|
||||||
|
}
|
||||||
|
body := string(reqs[0].Body)
|
||||||
|
if !strings.Contains(body, "muzick indexer") {
|
||||||
|
t.Fatalf("nexus resolve body = %s, want the name he said", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
|
||||||
|
// stage-3 gate thins an act with no allowlisted fn, and that question used to
|
||||||
|
// be the whole turn, so the Hexis path was unreachable from voice or chat.
|
||||||
|
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "restart status muzick indexer"},
|
||||||
|
}
|
||||||
|
reply := h.hexisBeforeClarify(ctx, dec)
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
|
||||||
|
}
|
||||||
|
if hexis.Count("", "/api/v1") == 0 {
|
||||||
|
t.Fatal("hexis was never contacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
|
||||||
|
// she asks exactly what she asked before.
|
||||||
|
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер"},
|
||||||
|
}
|
||||||
|
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
|
||||||
|
t.Fatalf("no hexis must mean no reply, got %q", reply)
|
||||||
|
}
|
||||||
|
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
|
||||||
|
t.Fatal("she must still ask what to do")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nexusInOrder serves one resolve answer per call, in order, so a test can say
|
||||||
|
// what Nexus knows about the first name and what it knows about the second. The
|
||||||
|
// last body repeats once the list runs out.
|
||||||
|
func nexusInOrder(t *testing.T, bodies ...string) *fakeServer {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
n := 0
|
||||||
|
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||||
|
"POST /api/v1/resolve": func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
body := bodies[min(n, len(bodies)-1)]
|
||||||
|
n++
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoResolvedNamesAsk — «перезапусти nginx на muzick-indexer» names a
|
||||||
|
// service and the host it runs on. Both are real, and which one he meant is not
|
||||||
|
// in the utterance, so she asks. Picking one by length was the old behaviour and
|
||||||
|
// length is not evidence (Vikunja #524).
|
||||||
|
func TestTwoResolvedNamesAsk(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := nexusInOrder(t,
|
||||||
|
fixtureNexusResolved("ent_nginx", "nginx", "service"),
|
||||||
|
fixtureNexusResolved("ent_host", "Muzick indexer", "device"),
|
||||||
|
)
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти nginx на muzick-indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
reply := h.handleHexisAct(ctx, dec)
|
||||||
|
if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") {
|
||||||
|
t.Fatalf("reply = %q, want both names she found", reply)
|
||||||
|
}
|
||||||
|
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||||
|
t.Fatal("she must not execute against a target she is still asking about")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheNameNexusKnowsWins — the other half. Two names go over and only one is
|
||||||
|
// an entity, so there is nothing to ask about and the act runs.
|
||||||
|
func TestTheNameNexusKnowsWins(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := nexusInOrder(t,
|
||||||
|
fixtureNexusNotFound(),
|
||||||
|
fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"),
|
||||||
|
)
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти nginx на muzick-indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
reply := h.handleHexisAct(ctx, dec)
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("the resolvable name must carry the act")
|
||||||
|
}
|
||||||
|
if len(nexus.Requests()) != 2 {
|
||||||
|
t.Fatalf("nexus asked %d times, want both names", len(nexus.Requests()))
|
||||||
|
}
|
||||||
|
if hexis.Count("POST", "/api/v1/execute") == 0 {
|
||||||
|
t.Fatal("hexis was never asked to run it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,10 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
|||||||
|
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,169 @@
|
|||||||
|
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,
|
||||||
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
|
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
dataStore: st,
|
||||||
|
}
|
||||||
|
return h, 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.recall.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.recall.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.recall.embedder, text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("embed %q: %v", text, err)
|
||||||
|
}
|
||||||
|
return vec
|
||||||
|
}
|
||||||
|
|
||||||
|
// The write half of #481: a complaint about a thing is a state of the
|
||||||
|
// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00
|
||||||
|
// it comes back on recall as if the network were still down.
|
||||||
|
func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, api := newFactGateHandler(t, time.Now())
|
||||||
|
|
||||||
|
reply := h.actionFact(ctx, router.Decision{
|
||||||
|
Intent: router.IntentFact,
|
||||||
|
Utterance: "сеть какая-то медленная",
|
||||||
|
Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
|
||||||
|
t.Fatal("a passing complaint was stored as a fact about him")
|
||||||
|
}
|
||||||
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memory search: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) != 0 {
|
||||||
|
t.Fatalf("the complaint was indexed for recall: %+v", hits)
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("the turn was neither stored nor answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the complaint he asked her to keep: the capture verb wins, as it does
|
||||||
|
// over the question gate.
|
||||||
|
func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, api := newFactGateHandler(t, time.Now())
|
||||||
|
|
||||||
|
h.actionFact(ctx, router.Decision{
|
||||||
|
Intent: router.IntentFact,
|
||||||
|
Utterance: "запомни что интернет не работает",
|
||||||
|
Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := api.LatestFact(ctx, "internet"); err != nil {
|
||||||
|
t.Fatalf("an explicit capture was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,12 +29,12 @@ func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &reactiveHandler{
|
return &reactiveHandler{
|
||||||
api: ipc.NewStoreAPI(st),
|
api: ipc.NewStoreAPI(st),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
phraser: phraser.NewStub(),
|
phraser: phraser.NewStub(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
feedsOn: feedsOn,
|
feedsOn: feedsOn,
|
||||||
embedder: nil,
|
recall: recallWiring{embedder: nil},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
|||||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||||
}
|
}
|
||||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||||
if !strings.Contains(reply, "ничего") {
|
if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
|
||||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
|||||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||||
// answered by the model inventing a bulletin.
|
// answered by the model inventing a bulletin.
|
||||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||||
|
// Against the entries, not against a substring: both of these have several
|
||||||
|
// wordings, so "ничего нового" passed only on the turns the picker happened
|
||||||
|
// to choose the first one.
|
||||||
off := buildFeedHandler(t, false)
|
off := buildFeedHandler(t, false)
|
||||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||||
if !ok || !strings.Contains(reply, "не настроены") {
|
if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||||
}
|
}
|
||||||
on := buildFeedHandler(t, true)
|
on := buildFeedHandler(t, true)
|
||||||
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
||||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
|
||||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||||
}
|
}
|
||||||
|
if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||||
|
t.Fatalf("an empty feed answered as an unconfigured one: %q", reply)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||||
@@ -194,3 +200,22 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
|||||||
t.Fatal("the poller fetched a private address")
|
t.Fatal("the poller fetched a private address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The
|
||||||
|
// deployed box has no feeds block and does have SearXNG, and "что происходит
|
||||||
|
// сейчас в новостях про искусственный интеллект?" got a configuration status
|
||||||
|
// instead of the live answer sitting one source below.
|
||||||
|
func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) {
|
||||||
|
h := buildFeedHandler(t, false)
|
||||||
|
h.search = &searchWiring{max: 3, runes: 1500}
|
||||||
|
|
||||||
|
if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok {
|
||||||
|
t.Fatalf("feeds off with a search configured must fall through, got %q", reply)
|
||||||
|
}
|
||||||
|
// With nothing below that reads the world, the honest status is still said:
|
||||||
|
// general knowledge would otherwise answer with an invented bulletin.
|
||||||
|
h.search = nil
|
||||||
|
if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") {
|
||||||
|
t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
|
||||||
|
// (Vikunja #456).
|
||||||
|
//
|
||||||
|
// Read-only over the facts that already exist. No new mechanism and no new
|
||||||
|
// storage: everything he tapped in is already a row with a source and a
|
||||||
|
// timestamp, and this only reads them back.
|
||||||
|
|
||||||
|
// A history question needs three things in one utterance: the interrogative,
|
||||||
|
// whose turn is being asked about, and a verb of saying or recording. Any two of
|
||||||
|
// them are a different question. "что я говорил про сервер" names a topic and
|
||||||
|
// the notes pass answers it better; "записал молоко" is a capture.
|
||||||
|
//
|
||||||
|
// The verbs are matched by lemma through internal/morph, not by a truncated
|
||||||
|
// prefix (Vikunja #530). The pairs here used to hold "рассказ" and "записал",
|
||||||
|
// which is the defect V-528 fixed in complaint.go: "рассказ" is also the noun,
|
||||||
|
// so "что я рассказал ей" and "что я читал рассказ" were the same string test.
|
||||||
|
// Aspect pairs are separate lemmas in the dictionary, so both members are listed.
|
||||||
|
var (
|
||||||
|
// historySpokenVerbs — what HE did. "что я тебе говорил".
|
||||||
|
historySpokenVerbs = []string{"говорить", "сказать", "рассказать", "рассказывать", "отметить", "отмечать"}
|
||||||
|
|
||||||
|
// historyRecordedVerbs — what SHE did with it. "что ты записала сегодня".
|
||||||
|
historyRecordedVerbs = []string{"записать", "запомнить", "отметить", "отмечать"}
|
||||||
|
|
||||||
|
// firstPersonSubjects and secondPersonSubjects — whose turn the question is
|
||||||
|
// about. Only the subject forms: "что я тебе говорил" is his turn, and the
|
||||||
|
// dative "тебе" in it is not the subject.
|
||||||
|
firstPersonSubjects = []string{"я"}
|
||||||
|
secondPersonSubjects = []string{"ты"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// historyMarkersEn — the English pairs, kept as substrings because the
|
||||||
|
// dictionary is Russian. Each half alone is a different question, the same way
|
||||||
|
// the Russian test needs all three parts.
|
||||||
|
var historyMarkersEn = [][2]string{
|
||||||
|
{"what did i", "tell"},
|
||||||
|
{"what did you", "record"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyRecall — the word that turns a history question into a recall
|
||||||
|
// question. "что я говорил про сервер" names a topic, and the notes pass
|
||||||
|
// answers a topic far better than a list of the last five facts does.
|
||||||
|
var historyRecall = []string{" про ", " об ", " о ", " about "}
|
||||||
|
|
||||||
|
// isHistoryQuery reports whether he is asking what he told her.
|
||||||
|
func isHistoryQuery(u string) bool {
|
||||||
|
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
|
||||||
|
if s == " " {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range historyRecall {
|
||||||
|
if strings.Contains(s, r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pair := range historyMarkersEn {
|
||||||
|
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toks := historyTokens(s)
|
||||||
|
if !hasAny(toks, "что", "чего") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyTokens splits an utterance into bare words. The punctuation goes
|
||||||
|
// because "говорил?" is the same word as "говорил".
|
||||||
|
func historyTokens(s string) []string {
|
||||||
|
toks := strings.Fields(s)
|
||||||
|
out := make([]string, 0, len(toks))
|
||||||
|
for _, t := range toks {
|
||||||
|
if t = strings.Trim(t, ".,!?;:—–-()\"'«»"); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAny(toks []string, want ...string) bool {
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, w := range want {
|
||||||
|
if t == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasVerbForm reports whether any token is a form of any of the lemmas. Both
|
||||||
|
// sides go through the dictionary, so a caller may name the infinitive and he
|
||||||
|
// may say the past tense.
|
||||||
|
func hasVerbForm(toks []string, lemmas []string) bool {
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, l := range lemmas {
|
||||||
|
if morph.SameWord(t, l) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyScan — how many recent facts are read before filtering. Deliberately
|
||||||
|
// larger than historyReadOut: a poller writing every few minutes would
|
||||||
|
// otherwise push everything he said out of the window, the same way his own
|
||||||
|
// notes used to crowd out the feed headlines.
|
||||||
|
const historyScan = 100
|
||||||
|
|
||||||
|
// historyReadOut — how many she says out loud. Five is what fits in one spoken
|
||||||
|
// breath; the rest are on /history, which is the surface for reading a list.
|
||||||
|
const historyReadOut = 5
|
||||||
|
|
||||||
|
// historyWindow — how far back "recently" reaches. A day, because the question
|
||||||
|
// is about this conversation and not about the archive.
|
||||||
|
const historyWindow = 24 * time.Hour
|
||||||
|
|
||||||
|
// queryHistory answers what he told her, from the facts he tapped in.
|
||||||
|
//
|
||||||
|
// Only "tap:" sources. A fact written by a poller, an inference or the ambient
|
||||||
|
// relay is a thing she learned, not a thing he said, and reading those back
|
||||||
|
// under "что я тебе говорил?" would put words in his mouth.
|
||||||
|
//
|
||||||
|
// Placed with the other sources that read his own rows and above the recall
|
||||||
|
// pass: the notes pass would otherwise answer this from whatever note happens
|
||||||
|
// to be nearest, which reads as an answer and is not one.
|
||||||
|
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !isHistoryQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
facts, err := h.api.RecentFacts(ctx, historyScan)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: history: recent facts: %v", err)
|
||||||
|
return "не получилось посмотреть, что ты говорил.", true
|
||||||
|
}
|
||||||
|
cutoff := h.now().Add(-historyWindow)
|
||||||
|
var said []string
|
||||||
|
for _, f := range facts {
|
||||||
|
if !strings.HasPrefix(f.Source, "tap:") || f.Ts.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
said = append(said, historyLine(f.Key, f.Value, f.Ts))
|
||||||
|
if len(said) == historyReadOut {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(said) == 0 {
|
||||||
|
// Claim the turn rather than fall through. "ничего не говорил" is the
|
||||||
|
// true answer, and recall would answer it with an old note instead.
|
||||||
|
return "за последние сутки ты мне ничего такого не говорил.", true
|
||||||
|
}
|
||||||
|
return "ты говорил: " + strings.Join(said, "; "), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyLine — one fact as she says it. The hour and minute, because the day
|
||||||
|
// is already bounded by historyWindow and a date would be noise.
|
||||||
|
func historyLine(key, value string, ts time.Time) string {
|
||||||
|
what := key
|
||||||
|
if value != "" {
|
||||||
|
what = key + " — " + value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s)", what, ts.Local().Format("15:04"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// historyAPI serves a fixed set of recent facts.
|
||||||
|
type historyAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
facts []ipc.Fact
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) {
|
||||||
|
a.calls++
|
||||||
|
return a.facts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) {
|
||||||
|
api := &historyAPI{facts: facts}
|
||||||
|
return &reactiveHandler{api: api, now: func() time.Time { return now }}, api
|
||||||
|
}
|
||||||
|
|
||||||
|
func askHistory(h *reactiveHandler, u string) (string, bool) {
|
||||||
|
return h.queryHistory(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHistoryQuery(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"что я тебе говорил?", true},
|
||||||
|
{"что ты записала сегодня?", true},
|
||||||
|
{"что я отмечал?", true},
|
||||||
|
// Forms the truncated prefixes did not reach. The dictionary answers
|
||||||
|
// these because it lemmatises both sides (V-530).
|
||||||
|
{"что я тебе рассказывал?", true},
|
||||||
|
{"что я сказала вчера", true},
|
||||||
|
{"что ты запомнила?", true},
|
||||||
|
// The noun, not the verb. "рассказ" was a prefix of the old pair, so
|
||||||
|
// this read as a history question — the same defect V-528 fixed in
|
||||||
|
// complaint.go, where "лаг" matched "лагерь".
|
||||||
|
{"что я читал рассказ", false},
|
||||||
|
// A verb of saying with nobody saying it.
|
||||||
|
{"что записать?", false},
|
||||||
|
// A named topic is a recall question, and the notes pass answers it
|
||||||
|
// better than a list of the last five facts does.
|
||||||
|
{"что я говорил про сервер?", false},
|
||||||
|
{"что у меня сегодня?", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if got := isHistoryQuery(tc.text); got != tc.want {
|
||||||
|
t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
h, api := historyHandler(now,
|
||||||
|
ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)},
|
||||||
|
// Learned, not said: a poller writing this back under "что я тебе
|
||||||
|
// говорил?" would put words in his mouth.
|
||||||
|
ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)},
|
||||||
|
// Older than the window.
|
||||||
|
ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)},
|
||||||
|
)
|
||||||
|
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the history question must be claimed before the recall sources")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "water") {
|
||||||
|
t.Errorf("reply = %q, want the fact he tapped in", reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") {
|
||||||
|
t.Errorf("reply = %q, want only what he said inside the window", reply)
|
||||||
|
}
|
||||||
|
if api.calls != 1 {
|
||||||
|
t.Errorf("RecentFacts called %d times, want 1", api.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing said is an answer of its own. Falling through would hand the question
|
||||||
|
// to recall, which answers it with an old note.
|
||||||
|
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
h, _ := historyHandler(now)
|
||||||
|
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||||
|
if !ok || !strings.Contains(reply, "ничего") {
|
||||||
|
t.Fatalf("reply = %q, ok = %v", reply, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Five is what fits in one spoken breath; the rest are on /history.
|
||||||
|
func TestHistoryStopsAtFive(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
var facts []ipc.Fact
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)})
|
||||||
|
}
|
||||||
|
h, _ := historyHandler(now, facts...)
|
||||||
|
reply, _ := askHistory(h, "что ты записала?")
|
||||||
|
if got := strings.Count(reply, ";"); got != historyReadOut-1 {
|
||||||
|
t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-20
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/netscan"
|
"github.com/kami/maven/internal/netscan"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||||
@@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
|||||||
res, err := w.scan(ctx)
|
res, err := w.scan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("netscan: scan: %v", err)
|
log.Printf("netscan: scan: %v", err)
|
||||||
return "не получилось просканировать сеть.", true
|
return phraser.Q(phraser.QueryFailNetscan, nil), true
|
||||||
}
|
}
|
||||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||||
// устройств" after stopping two thirds of the way through the range is a
|
// устройств" after stopping two thirds of the way through the range is a
|
||||||
@@ -116,9 +117,9 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
|||||||
tail = ", но успела посмотреть не всю сеть"
|
tail = ", но успела посмотреть не всю сеть"
|
||||||
}
|
}
|
||||||
if len(res.Hosts) == 0 {
|
if len(res.Hosts) == 0 {
|
||||||
return "в сети никого не нашла" + tail + ".", true
|
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
||||||
}
|
}
|
||||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||||
if shape := scanShape(res.Hosts); shape != "" {
|
if shape := scanShape(res.Hosts); shape != "" {
|
||||||
out += ", " + shape
|
out += ", " + shape
|
||||||
}
|
}
|
||||||
@@ -179,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
|||||||
if w.api == nil {
|
if w.api == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||||
if res.Truncated {
|
if res.Truncated {
|
||||||
head += " (не вся сеть)"
|
head += " (не вся сеть)"
|
||||||
}
|
}
|
||||||
@@ -210,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
|
||||||
// 5+ устройств, and the teens are all the last form.
|
|
||||||
func hostWord(n int) string {
|
|
||||||
if n%100 >= 11 && n%100 <= 14 {
|
|
||||||
return "устройств"
|
|
||||||
}
|
|
||||||
switch n % 10 {
|
|
||||||
case 1:
|
|
||||||
return "устройство"
|
|
||||||
case 2, 3, 4:
|
|
||||||
return "устройства"
|
|
||||||
default:
|
|
||||||
return "устройств"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||||
|
|||||||
+21
-12
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||||
@@ -74,18 +76,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
|
||||||
for n, want := range map[int]string{
|
|
||||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
|
||||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
|
||||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
|
||||||
} {
|
|
||||||
if got := hostWord(n); got != want {
|
|
||||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsNetworkQuery(t *testing.T) {
|
func TestIsNetworkQuery(t *testing.T) {
|
||||||
yes := []string{
|
yes := []string{
|
||||||
"какие устройства в сети?",
|
"какие устройства в сети?",
|
||||||
@@ -169,3 +159,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
|||||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unconfigured scanner names the gap instead of declining the turn.
|
||||||
|
//
|
||||||
|
// Falling through sent "какие устройства в сети?" to the search leg, which
|
||||||
|
// answered with a paragraph about routers in general — and put a question about
|
||||||
|
// his own LAN on an upstream engine, which the personal boundary exists to
|
||||||
|
// prevent (Vikunja #479).
|
||||||
|
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||||
|
h := &reactiveHandler{}
|
||||||
|
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "какие устройства в сети?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("an unconfigured scanner let the question fall through to search")
|
||||||
|
}
|
||||||
|
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
|
||||||
|
t.Errorf("got %q, want the gap named", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ordinal selection over a list she just read (Vikunja #448).
|
||||||
|
//
|
||||||
|
// The dialogue session already carried the intent, the slots and the history.
|
||||||
|
// What it did not carry was the list: she recited five tasks, he said "второй",
|
||||||
|
// and there was nothing for that word to point at, so it routed as a fresh
|
||||||
|
// utterance and meant nothing.
|
||||||
|
//
|
||||||
|
// Candidates are bound when she speaks the list, in the order she spoke it (see
|
||||||
|
// tasks.Spoken). Binding afterwards would resolve "второй" against a fresh
|
||||||
|
// query, and the list can change between two turns.
|
||||||
|
//
|
||||||
|
// An ordinal with no verb is read back, not acted on: "второй" names a task, it
|
||||||
|
// does not say what to do with it. Acting on the bare word would guess, and a
|
||||||
|
// wrong guess here closes work he never finished.
|
||||||
|
|
||||||
|
// candidateOrdinals — the words that pick a position, by index. Prefix match,
|
||||||
|
// because Russian declines them: "первый", "первую", "первое".
|
||||||
|
var candidateOrdinals = []struct {
|
||||||
|
word string
|
||||||
|
nth int
|
||||||
|
}{
|
||||||
|
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
|
||||||
|
{"first", 1}, {"second", 2}, {"third", 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
|
||||||
|
// "15" starts with "1" and is a time, not a position.
|
||||||
|
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
|
||||||
|
|
||||||
|
// candidateLast — "последний" picks the end of the list whatever its length.
|
||||||
|
var candidateLast = []string{"последн", "last"}
|
||||||
|
|
||||||
|
// parseOrdinal reads which position he named. 0 and false when he named none.
|
||||||
|
// A negative result means the last one.
|
||||||
|
func parseOrdinal(text string) (int, bool) {
|
||||||
|
// Token by token, not substring: " 1" would otherwise match inside
|
||||||
|
// "напомни в 15:00" and turn a reminder into a selection.
|
||||||
|
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
}) {
|
||||||
|
for _, w := range candidateLast {
|
||||||
|
if strings.HasPrefix(tok, w) {
|
||||||
|
return -1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, ok := candidateDigits[tok]; ok {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
for _, o := range candidateOrdinals {
|
||||||
|
// Prefix, because Russian declines them: "первый", "первую".
|
||||||
|
if strings.HasPrefix(tok, o.word) {
|
||||||
|
return o.nth, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateVerbs — what he wants done with the one he picked. Nothing here is
|
||||||
|
// destructive: a task moves forward or is dropped, and both are recorded with a
|
||||||
|
// provenance the /tasks page shows.
|
||||||
|
var candidateVerbs = []struct {
|
||||||
|
words []string
|
||||||
|
status string
|
||||||
|
say string
|
||||||
|
}{
|
||||||
|
{[]string{"готов", "сделал", "выполнил", "закрыл", "done"}, store.TaskDone, "закрыла"},
|
||||||
|
{[]string{"не надо", "убери", "отмени", "не буду", "drop"}, store.TaskDropped, "убрала"},
|
||||||
|
{[]string{"подтвержда", "беру", "да,", "буду делать"}, store.TaskOpen, "взяла в работу"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCandidateVerb(text string) (status, say string, ok bool) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
for _, v := range candidateVerbs {
|
||||||
|
for _, w := range v.words {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return v.status, v.say, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerCandidates records the list she just read, so his next words can pick
|
||||||
|
// from it. Best effort: no session store, or a session that expired between the
|
||||||
|
// question and the answer, means the words route normally.
|
||||||
|
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
|
||||||
|
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
||||||
|
// the list she just read.
|
||||||
|
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
|
||||||
|
if h.dialogueSessions == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
|
||||||
|
if sess == nil || len(sess.Candidates) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
nth, ok := parseOrdinal(text)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if nth < 0 {
|
||||||
|
nth = len(sess.Candidates)
|
||||||
|
}
|
||||||
|
if nth > len(sess.Candidates) {
|
||||||
|
// Claim the turn: he is picking from her list and named a position she
|
||||||
|
// did not read. Routing it fresh would answer something else entirely.
|
||||||
|
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
|
||||||
|
}
|
||||||
|
pick := sess.Candidates[nth-1]
|
||||||
|
status, say, hasVerb := parseCandidateVerb(text)
|
||||||
|
if !hasVerb || pick.Kind != "task" {
|
||||||
|
// Read it back and keep the list: naming one is often the first half of
|
||||||
|
// a sentence, and the second half is the next turn.
|
||||||
|
return pick.Label, true
|
||||||
|
}
|
||||||
|
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
|
||||||
|
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
|
||||||
|
return "не получилось изменить задачу.", true
|
||||||
|
}
|
||||||
|
// Spent: the list she read is no longer the list, and a second ordinal
|
||||||
|
// against it would close the wrong task.
|
||||||
|
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
|
||||||
|
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||||
|
return say + ": " + pick.Label, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"второй", 2, true},
|
||||||
|
{"вторую сделал", 2, true},
|
||||||
|
{"первую убери", 1, true},
|
||||||
|
{"последнюю не надо", -1, true},
|
||||||
|
{"3", 3, true},
|
||||||
|
{"the second one", 2, true},
|
||||||
|
// No position named.
|
||||||
|
{"какие у меня задачи", 0, false},
|
||||||
|
{"", 0, false},
|
||||||
|
// A digit inside a time is not a position.
|
||||||
|
{"напомни в 15:00", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseOrdinal(c.text)
|
||||||
|
if ok != c.ok || (ok && got != c.want) {
|
||||||
|
t.Errorf("parseOrdinal(%q) = %d,%v; want %d,%v", c.text, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
|
||||||
|
t.Error("an ordinal with no list behind it was claimed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||||
|
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||||
|
|
||||||
|
reply, handled := h.resolveCandidate(ctx, "второй")
|
||||||
|
if !handled || !strings.Contains(reply, "позвонить маме") {
|
||||||
|
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
|
||||||
|
}
|
||||||
|
// Still live: naming one is often the first half of a sentence.
|
||||||
|
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
|
||||||
|
t.Error("the list was spent by a read-back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||||
|
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||||
|
|
||||||
|
reply, handled := h.resolveCandidate(ctx, "первую сделал")
|
||||||
|
if !handled || !strings.Contains(reply, "купить хлеб") {
|
||||||
|
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
|
||||||
|
}
|
||||||
|
live, err := st.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list tasks: %v", err)
|
||||||
|
}
|
||||||
|
for _, task := range live {
|
||||||
|
if task.ID == ids[0] {
|
||||||
|
t.Fatalf("task %d is still live after he closed it", task.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Spent: a second ordinal against a list that no longer holds would close
|
||||||
|
// the wrong task.
|
||||||
|
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
|
||||||
|
t.Error("the list survived the pick it was spent on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ids := seedTasks(t, st, "купить хлеб")
|
||||||
|
putCandidates(h, ids, "купить хлеб")
|
||||||
|
|
||||||
|
reply, handled := h.resolveCandidate(context.Background(), "третий")
|
||||||
|
if !handled || !strings.Contains(reply, "1") {
|
||||||
|
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ordinalNow — a fixed capture time; the ranker only needs the rows to exist.
|
||||||
|
var ordinalNow = time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var ids []int64
|
||||||
|
for _, text := range texts {
|
||||||
|
res, err := st.CaptureTask(ctx, store.Task{
|
||||||
|
Text: text,
|
||||||
|
Source: "tap:voice",
|
||||||
|
Status: store.TaskOpen,
|
||||||
|
CreatedTs: ordinalNow,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("capture task: %v", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, res.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
|
||||||
|
cands := make([]dialogue.Candidate, 0, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
|
||||||
|
}
|
||||||
|
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
|
||||||
|
h.offerCandidates(cands)
|
||||||
|
}
|
||||||
@@ -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,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/phraser/eval"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The persona checks, run before she speaks (Vikunja #399).
|
||||||
|
//
|
||||||
|
// RunChecks and RunTalkChecks only ever ran from the eval package, so
|
||||||
|
// everything the fixtures measured was offline knowledge: we could say "about
|
||||||
|
// one reply in three is broken" and still ship every one of them. This runs the
|
||||||
|
// cheap half of that on the live path, and replaces a failing message with the
|
||||||
|
// deterministic floor.
|
||||||
|
//
|
||||||
|
// Which checks: the unambiguous string tests only — feminine self-reference,
|
||||||
|
// how she addresses him, and a leaked-reasoning test. Not length, which is
|
||||||
|
// path-specific, and not ontopic, which compares against fragments the fixture
|
||||||
|
// supplies and runtime does not have. Not hisgender either — see guardSpoken.
|
||||||
|
//
|
||||||
|
// No retry. A retry doubles the latency on the exact turn that is already going
|
||||||
|
// badly, and on the nudge path the moment has passed.
|
||||||
|
//
|
||||||
|
// The known cost, written down because it is real: a wrongly flagged good reply
|
||||||
|
// is replaced by a flatter stub one. That is the right trade — a stub sentence
|
||||||
|
// is dull, a leaked reasoning trace is broken — but it means these checks can
|
||||||
|
// no longer be tuned for sensitivity alone.
|
||||||
|
|
||||||
|
// checkLeak — the name reported when the model's scaffolding reaches the text.
|
||||||
|
const checkLeak = "leak"
|
||||||
|
|
||||||
|
// leakPatterns — reasoning and protocol that belongs to the model, not to him.
|
||||||
|
// The resident model is a Thinking variant, so an unclosed reasoning block is
|
||||||
|
// the failure mode, not a hypothetical (Vikunja #398).
|
||||||
|
var leakPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)<\s*/?\s*think`),
|
||||||
|
regexp.MustCompile(`(?i)thinking\s*(process|:)`),
|
||||||
|
regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
|
||||||
|
// Raw contract JSON: the parser already unwraps a good one, so a body that
|
||||||
|
// still carries the keys is one it could not read.
|
||||||
|
regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
|
||||||
|
// The persona block quoted back at him.
|
||||||
|
regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPersonaLeak reports whether the model's own scaffolding is in the text.
|
||||||
|
func checkPersonaLeak(body string) (string, bool) {
|
||||||
|
for _, re := range leakPatterns {
|
||||||
|
if m := re.FindString(body); m != "" {
|
||||||
|
return "leaked " + strings.TrimSpace(m), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// personaRejects counts what the guard caught, by check name, so the real
|
||||||
|
// production rate is knowable rather than inferred from the fixture.
|
||||||
|
var personaRejects = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
by map[string]int
|
||||||
|
}{by: map[string]int{}}
|
||||||
|
|
||||||
|
func personaRejectCounts() map[string]int {
|
||||||
|
personaRejects.mu.Lock()
|
||||||
|
defer personaRejects.mu.Unlock()
|
||||||
|
out := make(map[string]int, len(personaRejects.by))
|
||||||
|
for k, v := range personaRejects.by {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardSpoken checks a phrased message. It returns the failed check and false
|
||||||
|
// when the message must not be said; path names the caller, for the log.
|
||||||
|
//
|
||||||
|
// An empty message passes: the caller already treats that as a failure and
|
||||||
|
// falls back on its own, and reporting it as a persona breach would put a
|
||||||
|
// misleading line in the count.
|
||||||
|
func guardSpoken(path, body string) (string, bool) {
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
if detail, ok := checkPersonaLeak(body); !ok {
|
||||||
|
return rejectSpoken(path, checkLeak, detail, body), false
|
||||||
|
}
|
||||||
|
// Feminine and address only. HisGender is not run here: it reads a
|
||||||
|
// sentence-initial feminine verb with no pronoun — "записала, что ты выпил
|
||||||
|
// воды" — as a woman being addressed, when it is her own correct
|
||||||
|
// self-reference. Offline that is a point of score; on this path it would
|
||||||
|
// replace a good reply with a stub one on every fact she confirms.
|
||||||
|
for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
|
||||||
|
if !r.Pass {
|
||||||
|
return rejectSpoken(path, r.Name, r.Detail, body), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectSpoken logs what she nearly said and counts it. The whole text, not a
|
||||||
|
// prefix: the point of the log line is that the failure can be read back later
|
||||||
|
// and argued with.
|
||||||
|
func rejectSpoken(path, check, detail, body string) string {
|
||||||
|
personaRejects.mu.Lock()
|
||||||
|
personaRejects.by[check]++
|
||||||
|
personaRejects.mu.Unlock()
|
||||||
|
log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardNudge checks a phrased nudge and falls back to the deterministic floor
|
||||||
|
// when it fails. The nudge path, unlike the reply path, cannot ask again: the
|
||||||
|
// tick has already decided she speaks, so the choice is the floor's wording or
|
||||||
|
// a broken sentence.
|
||||||
|
func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
|
||||||
|
if _, ok := guardSpoken("nudge", pn.Body); ok {
|
||||||
|
return pn
|
||||||
|
}
|
||||||
|
stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
|
||||||
|
if err != nil {
|
||||||
|
// The Stub is templates over the candidate and does not fail. If it
|
||||||
|
// somehow does, the model's text is still what the rule decided to
|
||||||
|
// say, and saying nothing is the worse outcome.
|
||||||
|
return pn
|
||||||
|
}
|
||||||
|
return stub
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGuardPassesWhatSheShouldSay(t *testing.T) {
|
||||||
|
good := []string{
|
||||||
|
"записала: купить хлеб.",
|
||||||
|
"поняла, напомню в 11:00.",
|
||||||
|
"ты не пил воду с утра.",
|
||||||
|
"я рада, что получилось.",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
for _, body := range good {
|
||||||
|
if check, ok := guardSpoken("test", body); !ok {
|
||||||
|
t.Errorf("guardSpoken(%q) rejected on %s", body, check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardStopsWhatSheShouldNot(t *testing.T) {
|
||||||
|
bad := []struct {
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"<think>он просил воду</think> попей воды.", checkLeak},
|
||||||
|
{"Thinking Process: он давно не пил.", checkLeak},
|
||||||
|
{`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
|
||||||
|
{"я напомнил тебе про воду.", "feminine"},
|
||||||
|
{"вы давно не пили воду.", "address"},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
check, ok := guardSpoken("test", c.body)
|
||||||
|
if ok {
|
||||||
|
t.Errorf("guardSpoken(%q) let it through", c.body)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if check != c.want {
|
||||||
|
t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardCountsWhatItCaught(t *testing.T) {
|
||||||
|
before := personaRejectCounts()[checkLeak]
|
||||||
|
if _, ok := guardSpoken("test", "<think>…"); ok {
|
||||||
|
t.Fatal("a leaked reasoning block was let through")
|
||||||
|
}
|
||||||
|
if after := personaRejectCounts()[checkLeak]; after != before+1 {
|
||||||
|
t.Errorf("leak count %d; want %d", after, before+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
|
||||||
|
// deterministic wording, not dropped and not retried.
|
||||||
|
func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
|
||||||
|
cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
|
||||||
|
bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
|
||||||
|
got := guardNudge(bad, cand)
|
||||||
|
if got.Body == bad.Body {
|
||||||
|
t.Fatal("the broken nudge was delivered unchanged")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(got.Body) == "" {
|
||||||
|
t.Fatal("the nudge was dropped rather than re-worded")
|
||||||
|
}
|
||||||
|
good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
|
||||||
|
if guardNudge(good, cand).Body != good.Body {
|
||||||
|
t.Error("a good nudge was replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{recall: recallWiring{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.recall.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))
|
||||||
|
}
|
||||||
@@ -85,15 +85,17 @@ func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reac
|
|||||||
|
|
||||||
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: ipc.NewStoreAPI(st),
|
api: ipc.NewStoreAPI(st),
|
||||||
embedder: emb,
|
recall: recallWiring{
|
||||||
|
embedder: emb,
|
||||||
|
memStore: mem,
|
||||||
|
minScore: 0.55,
|
||||||
|
minMargin: 0.008,
|
||||||
|
},
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
phraser: phr,
|
phraser: phr,
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: mem,
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
queryMinScore: 0.55,
|
|
||||||
queryMinMargin: 0.008,
|
|
||||||
weatherProvider: nil,
|
weatherProvider: nil,
|
||||||
}
|
}
|
||||||
return h, phr
|
return h, phr
|
||||||
@@ -121,8 +123,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
|||||||
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
||||||
})
|
})
|
||||||
reply := askQuery(t, h, q)
|
reply := askQuery(t, h, q)
|
||||||
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
|
if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
|
||||||
t.Errorf("reply %q, want %q", reply, want)
|
t.Errorf("reply %q, want the note read back", reply)
|
||||||
}
|
}
|
||||||
// One text, the winning memory's — the answer came from the memory
|
// One text, the winning memory's — the answer came from the memory
|
||||||
// pass, not from handing the phraser every note in the table.
|
// pass, not from handing the phraser every note in the table.
|
||||||
@@ -151,7 +153,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
|||||||
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
||||||
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
||||||
})
|
})
|
||||||
if reply := askQuery(t, h, q); reply != "не знаю." {
|
if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) {
|
||||||
t.Errorf("reply %q, want silence", reply)
|
t.Errorf("reply %q, want silence", reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+60
-39
@@ -11,6 +11,8 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||||
@@ -32,10 +34,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
val := "false"
|
val := "false"
|
||||||
reply := "тихий режим выключен."
|
reply := phraser.Ack(phraser.AckQuietOff, nil)
|
||||||
if on {
|
if on {
|
||||||
val = "true"
|
val = "true"
|
||||||
reply = "тихий режим включён. буду реже напоминать."
|
reply = phraser.Ack(phraser.AckQuietOn, nil)
|
||||||
}
|
}
|
||||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
Ts: h.now(),
|
Ts: h.now(),
|
||||||
@@ -46,34 +48,44 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
|||||||
Confidence: 1.0,
|
Confidence: 1.0,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: write quiet_hours: %v", err)
|
log.Printf("voice: write quiet_hours: %v", err)
|
||||||
return "не получилось переключить тихий режим.", true
|
return phraser.Ack(phraser.FailQuiet, nil), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietInflections — the inflectional endings a stem may carry and still be
|
// quietStem reports whether tok is one of the words a vocabulary slot accepts.
|
||||||
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
// A slot is written as alternatives joined by "|", and an alternative comes in
|
||||||
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
// two flavours:
|
||||||
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
//
|
||||||
// ending, and "потихоньку" doesn't start with the stem at all.
|
// - a dictionary form, matched through the dictionary, so every case and
|
||||||
var quietInflections = []string{
|
// gender of it counts. This is what the nouns and adjectives want: "тихий",
|
||||||
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
// "тихом", "тихо" and "тише" are one word.
|
||||||
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
// - a form prefixed with "=", matched as the exact token. This is what the
|
||||||
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
// VERBS want, and it is not a shortcut. A command is an imperative, and the
|
||||||
}
|
// dictionary quite correctly files "говори" and "говорил" under one lemma —
|
||||||
|
// so lemma-matching a verb slot read "он говорил тихим голосом весь вечер",
|
||||||
// quietStem reports whether tok is the given stem carrying at most one
|
// a remark about his evening, as an order to go quiet. Aspect pairs are two
|
||||||
// inflectional ending. Word boundaries come from tokenisation (see
|
// separate verbs, which is why several imperatives are listed by hand.
|
||||||
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
//
|
||||||
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
// Word boundaries come from tokenisation (see quietTokens), not from a regexp —
|
||||||
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
// Go's \b is ASCII-oriented and treats every Cyrillic letter as a non-word
|
||||||
func quietStem(tok, stem string) bool {
|
// character, so `\bтих\b` would happily match inside "тихонько". Comparing whole
|
||||||
if !strings.HasPrefix(tok, stem) {
|
// tokens sidesteps that entirely.
|
||||||
return false
|
//
|
||||||
}
|
// The comparison is a dictionary lookup, not a stem plus a list of 36 endings
|
||||||
suffix := tok[len(stem):]
|
// (Vikunja #526). The distinction the old comment described is exactly the one a
|
||||||
for _, e := range quietInflections {
|
// dictionary makes: "тихий", "тихом", "тихо" and "тише" are one word inflected,
|
||||||
if suffix == e {
|
// while "тихонько" and "потихоньку" are different words — and the dictionary
|
||||||
|
// knows that without anybody deciding that "онько" is not an ending.
|
||||||
|
func quietStem(tok, slot string) bool {
|
||||||
|
for _, form := range strings.Split(slot, "|") {
|
||||||
|
if exact, ok := strings.CutPrefix(form, "="); ok {
|
||||||
|
if tok == exact {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if morph.SameWord(tok, form) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +128,10 @@ func quietPhrase(tokens, pattern []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as sequences of
|
||||||
|
// dictionary forms. They used to be truncated stems ("тих", "выключ"), which is
|
||||||
|
// what the ending list existed to complete; a dictionary form needs no
|
||||||
|
// completing (Vikunja #526).
|
||||||
//
|
//
|
||||||
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
||||||
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
||||||
@@ -127,36 +142,42 @@ func quietPhrase(tokens, pattern []string) bool {
|
|||||||
var (
|
var (
|
||||||
quietOffPhrases = [][]string{
|
quietOffPhrases = [][]string{
|
||||||
{"quiet", "off"}, {"quiet", "end"},
|
{"quiet", "off"}, {"quiet", "end"},
|
||||||
{"громк", "режим"}, {"шумн", "режим"},
|
{"громкий", "режим"}, {"шумный", "режим"},
|
||||||
{"отмен", "тих"}, {"выключ", "тих"},
|
{"=отмени|=отменяй|=отменить", "тихий"},
|
||||||
|
{"=выключи|=выключай|=выключить", "тихий"},
|
||||||
}
|
}
|
||||||
quietOnPhrases = [][]string{
|
quietOnPhrases = [][]string{
|
||||||
{"quiet", "on"}, {"quiet", "mode"},
|
{"quiet", "on"}, {"quiet", "mode"},
|
||||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
{"тихий", "режим"}, {"не", "=шуми|=шумите"}, {"не", "=беспокой|=беспокоить"},
|
||||||
// The noun form and the comparative. "режим тишины" is how the
|
// The noun form and the comparative. "режим тишины" is how the
|
||||||
// setting is named half the time, and "сделай потише" is how it is
|
// setting is named half the time, and "сделай потише" is how it is
|
||||||
// actually asked for out loud. Both used to fall through to the
|
// actually asked for out loud. Both used to fall through to the
|
||||||
// router, which has no quiet intent, so the command did nothing.
|
// router, which has no quiet intent, so the command did nothing.
|
||||||
{"режим", "тишин"}, {"сделай", "тише"}, {"сделай", "потише"},
|
{"режим", "тишина"}, {"=сделай", "тихий"}, {"=сделай", "потише"},
|
||||||
{"говори", "тише"}, {"будь", "потише"},
|
{"=говори", "тихий"}, {"=будь", "потише"},
|
||||||
{"тих"}, {"потише"},
|
{"тихий"}, {"потише"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// quietWordStems — every stem that names the setting. Used by the
|
// quietWordStems — every word that names the setting. Used by the
|
||||||
// negated-but-unmatched fallback in classifyQuietToggle, which has to
|
// negated-but-unmatched fallback in classifyQuietToggle, which has to
|
||||||
// recognise "хватит тишины" without an ON phrase having matched.
|
// recognise "хватит тишины" without an ON phrase having matched.
|
||||||
var quietWordStems = []string{"тих", "тишин", "потише"}
|
var quietWordStems = []string{"тихий", "тишина", "потише"}
|
||||||
|
|
||||||
// quietNegatorWords — negators that are whole words with no useful stem.
|
// quietNegatorWords — negators that are whole words with no useful stem.
|
||||||
var quietNegatorWords = map[string]bool{
|
var quietNegatorWords = map[string]bool{
|
||||||
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietNegatorStems — negators that inflect. Matched through quietStem, the
|
// quietNegatorStems — negators that inflect. Imperatives, matched exactly for
|
||||||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
// the reason quietStem gives: "выключи" is a command and "выключил" is a report
|
||||||
// and "выключай" all count and "выключатель" does not.
|
// about earlier, and one lemma covers both. "выключатель" was never a negator
|
||||||
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
|
// and is not one now.
|
||||||
|
var quietNegatorStems = []string{
|
||||||
|
"=выключи|=выключай|=выключить", "=отмени|=отменяй|=отменить",
|
||||||
|
"=прекрати|=прекращай|=прекратить", "=убери|=убирай|=убрать",
|
||||||
|
"stop", "cancel", "disable",
|
||||||
|
}
|
||||||
|
|
||||||
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
||||||
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
@@ -24,11 +26,10 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
|
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,3 +86,37 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
|
||||||
|
// task table. It went dead when the router started claiming the marker as an
|
||||||
|
// act: capture rides the note intent, so nothing below actionNote was ever
|
||||||
|
// reached and every capture answered "Что сделать?" (Vikunja #467).
|
||||||
|
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
api := ipc.NewStoreAPI(st)
|
||||||
|
now := time.Now()
|
||||||
|
emb := router.NewHashEmbedder(1024)
|
||||||
|
matcher := tool.NewMatcher(api)
|
||||||
|
h := &reactiveHandler{
|
||||||
|
api: api,
|
||||||
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
|
router: buildRouter(emb, matcher, 0.55, nil),
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
dataStore: st,
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
|
||||||
|
if !strings.Contains(reply, "купить молоко") {
|
||||||
|
t.Fatalf("capture did not claim the turn: %q", reply)
|
||||||
|
}
|
||||||
|
open, err := st.ListTasks(ctx, store.TaskOpen)
|
||||||
|
if err != nil || len(open) != 1 {
|
||||||
|
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
|
||||||
|
}
|
||||||
|
// The words he said, not the model's rewrite of them.
|
||||||
|
if open[0].Text != "купить молоко" {
|
||||||
|
t.Fatalf("task text was rewritten: %q", open[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+41
-1
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/kami/maven/internal/memory"
|
import (
|
||||||
|
"github.com/kami/maven/internal/memory"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
// bestRecall is the read side of the long-term memory store: the top hit when
|
// bestRecall is the read side of the long-term memory store: the top hit when
|
||||||
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
||||||
@@ -23,3 +26,40 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re
|
|||||||
}
|
}
|
||||||
return results[0], true
|
return results[0], true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recallWiring — the recall subsystem's dependencies, held as one group on
|
||||||
|
// reactiveHandler (Vikunja #433). It is the worked example for the wiring
|
||||||
|
// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty
|
||||||
|
// loose ones, so a handler names what it needs and the package can be split
|
||||||
|
// later without exporting the whole struct.
|
||||||
|
//
|
||||||
|
// The zero value is usable and means "no recall": no embedder, no vector
|
||||||
|
// store, and a gate that is never consulted because nothing is ever searched.
|
||||||
|
type recallWiring struct {
|
||||||
|
// embedder — reused for note write/query (same model as the classifier).
|
||||||
|
embedder router.Embedder
|
||||||
|
|
||||||
|
// memStore — the vector index over notes and facts.
|
||||||
|
memStore memory.Store
|
||||||
|
|
||||||
|
// topics — the embedded seed sets behind the weather, house and LAN
|
||||||
|
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
|
||||||
|
// usable, loads on first query, and with no embedder it never loads and
|
||||||
|
// each source falls back to its own keyword test.
|
||||||
|
topics topicIndex
|
||||||
|
|
||||||
|
// boundary — the embedded seed sets behind the personal boundary
|
||||||
|
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||||
|
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||||
|
boundary personalBoundary
|
||||||
|
|
||||||
|
// minScore — the note-recall confidence gate. Top cosine below this ⇒
|
||||||
|
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob,
|
||||||
|
// not load-bearing math (same posture as the presence thresholds). Set by
|
||||||
|
// wireVoice from VoiceConfig; default 0.55.
|
||||||
|
minScore float64
|
||||||
|
|
||||||
|
// minMargin — the second half of that gate: how far the top hit must beat
|
||||||
|
// the runner-up. 0 ⇒ margin off.
|
||||||
|
minMargin float64
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reminderMarker — the words that open a reminder. Stripped because they are
|
||||||
|
// the instruction, not the thing to say at the hour.
|
||||||
|
//
|
||||||
|
// The verbs come from the lexicon (Vikunja #530). They are a closed set of the
|
||||||
|
// commands she answers to, exactly like capture_verbs, and the literal that
|
||||||
|
// stood here knew four of them.
|
||||||
|
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:` + alternation(lexicon.ReminderVerbs()) +
|
||||||
|
`)\s*(?:мне|me)?[\s,:—-]*`)
|
||||||
|
|
||||||
|
// reminderTimeWords — the time expressions a reminder carries, removed from
|
||||||
|
// the body because the fire time is already a column. Ordered longest-first
|
||||||
|
// where two could match the same words, so "через полтора часа" does not leave
|
||||||
|
// "полтора" behind.
|
||||||
|
//
|
||||||
|
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
|
||||||
|
// boundaries here are written out as whitespace or an end of string — the same
|
||||||
|
// trap the agenda grammars hit.
|
||||||
|
//
|
||||||
|
// The Russian word lists are gone (Vikunja #530). The day words are
|
||||||
|
// lexicon.DayOffsetWords, which is why "вчера" and "позавчера" are stripped now
|
||||||
|
// and were not before, and the times of day are lexicon.PartsOfDay. What is
|
||||||
|
// still written out here is the shape of a clock reading — a preposition, digits,
|
||||||
|
// a colon — which is structured input rather than a claim about Russian.
|
||||||
|
var reminderTimeWords = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.DayOffsetWords()) + `)(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.PartsOfDay()) + `)(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// alternation folds a lexicon set into one regexp branch, longest member first
|
||||||
|
// so "послезавтра" is not matched as "завтра" with a tail left behind. Sorted
|
||||||
|
// rather than taken as given, because two members of equal length must still
|
||||||
|
// produce the same pattern on every build.
|
||||||
|
func alternation(set []string) string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for _, w := range set {
|
||||||
|
out = append(out, regexp.QuoteMeta(w))
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if len(out[i]) != len(out[j]) {
|
||||||
|
return len(out[i]) > len(out[j])
|
||||||
|
}
|
||||||
|
return out[i] < out[j]
|
||||||
|
})
|
||||||
|
return strings.Join(out, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminderBody is what she says at the hour.
|
||||||
|
//
|
||||||
|
// The whole utterance used to be stored, so /reminders read "напомни завтра в
|
||||||
|
// 9 утра выпить таблетки" where it should read "выпить таблетки", and the
|
||||||
|
// agenda recited the marker back at him (Vikunja #469). The fire time is
|
||||||
|
// already a column, and the marker is an instruction that was carried out.
|
||||||
|
//
|
||||||
|
// Falls back to the fuller text whenever stripping would leave nothing: an
|
||||||
|
// empty body is a reminder that fires and says nothing, which is worse than a
|
||||||
|
// wordy one.
|
||||||
|
func reminderBody(utterance, text string) string {
|
||||||
|
body := strings.TrimSpace(text)
|
||||||
|
if body == "" {
|
||||||
|
body = strings.TrimSpace(utterance)
|
||||||
|
}
|
||||||
|
stripped := reminderMarker.ReplaceAllString(body, "")
|
||||||
|
for _, re := range reminderTimeWords {
|
||||||
|
stripped = re.ReplaceAllString(stripped, " ")
|
||||||
|
}
|
||||||
|
stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " "))
|
||||||
|
stripped = strings.Trim(stripped, " ,;:—-")
|
||||||
|
if stripped == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReminderBody(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ utterance, text, want string }{
|
||||||
|
// The row from the QA sitting: the whole utterance was the body.
|
||||||
|
{"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"},
|
||||||
|
{"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"},
|
||||||
|
{"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"},
|
||||||
|
{"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"},
|
||||||
|
// Nothing left after stripping ⇒ keep what there was. A reminder that
|
||||||
|
// fires and says nothing is worse than a wordy one.
|
||||||
|
{"напомни завтра", "завтра", "завтра"},
|
||||||
|
{"", "", ""},
|
||||||
|
} {
|
||||||
|
if got := reminderBody(tc.utterance, tc.text); got != tc.want {
|
||||||
|
t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conversation repair (Vikunja #455).
|
||||||
|
//
|
||||||
|
// The classifier has been able to learn from a correction since it was
|
||||||
|
// written — CorrectMisroute appends the utterance as a new example for the
|
||||||
|
// intent he names, append-only, no retrain. Nothing in the daemon could reach
|
||||||
|
// it: the only caller was a test. So the mechanism existed and the behaviour
|
||||||
|
// did not.
|
||||||
|
//
|
||||||
|
// This is the reachable half. He says she got it wrong and names what it
|
||||||
|
// should have been, she redoes the previous utterance under that intent, and
|
||||||
|
// she says out loud that the correction landed — because a correction he
|
||||||
|
// cannot see is indistinguishable from one that was dropped.
|
||||||
|
//
|
||||||
|
// Taken before routing, like the confirm and clarify turns: "нет, это была
|
||||||
|
// заметка" is an answer to the previous turn, not a fresh command, and routing
|
||||||
|
// it as one files the correction itself as a note.
|
||||||
|
|
||||||
|
// routedTurn — the previous utterance and where it went, which is all a
|
||||||
|
// correction needs to point at.
|
||||||
|
type routedTurn struct {
|
||||||
|
utterance string
|
||||||
|
intent router.Intent
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||||
|
// hear the wrong answer, think, and say so; short enough that "это заметка"
|
||||||
|
// half an hour later is a fresh sentence and not a verdict on something he has
|
||||||
|
// forgotten.
|
||||||
|
const repairWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// repairMarkers — the ways he says she got it wrong. One of these must appear:
|
||||||
|
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
|
||||||
|
// treating it as a correction would rewrite the last turn every time he used
|
||||||
|
// the word.
|
||||||
|
//
|
||||||
|
// From the lexicon, and staying a list rather than becoming seeds (Vikunja
|
||||||
|
// #528). This runs pre-route, before the turn vector exists, and a correction
|
||||||
|
// redoes the previous request — so a near-miss would act on something he never
|
||||||
|
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
|
||||||
|
var repairMarkers = lexicon.RepairMarkers()
|
||||||
|
|
||||||
|
// repairIntents — the words he uses for each intent, as dictionary forms. They
|
||||||
|
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
|
||||||
|
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
|
||||||
|
// compares the words themselves (Vikunja #528).
|
||||||
|
var repairIntents = []struct {
|
||||||
|
word string
|
||||||
|
intent router.Intent
|
||||||
|
say string
|
||||||
|
}{
|
||||||
|
{"заметка", router.IntentNote, "заметка"},
|
||||||
|
{"напоминание", router.IntentReminder, "напоминание"},
|
||||||
|
{"напомнить", router.IntentReminder, "напоминание"},
|
||||||
|
{"факт", router.IntentFact, "факт"},
|
||||||
|
{"вопрос", router.IntentQuery, "вопрос"},
|
||||||
|
{"команда", router.IntentAct, "команда"},
|
||||||
|
{"note", router.IntentNote, "заметка"},
|
||||||
|
{"reminder", router.IntentReminder, "напоминание"},
|
||||||
|
{"fact", router.IntentFact, "факт"},
|
||||||
|
{"question", router.IntentQuery, "вопрос"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRepair reads a spoken correction: a marker saying she was wrong, plus
|
||||||
|
// the intent it should have been.
|
||||||
|
//
|
||||||
|
// The negated half is skipped. "это заметка, а не напоминание" names both
|
||||||
|
// intents, and the one he is correcting TO is the one he did not put "не" in
|
||||||
|
// front of.
|
||||||
|
func parseRepair(utterance string) (router.Intent, string, bool) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||||
|
if s == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// A leading "нет" is a marker on its own — "нет, это заметка" is the
|
||||||
|
// shortest correction he actually says. Only leading: "нет" in the middle
|
||||||
|
// of a sentence is an ordinary word.
|
||||||
|
marked := strings.HasPrefix(s, "нет") || strings.HasPrefix(s, "no,")
|
||||||
|
for _, m := range repairMarkers {
|
||||||
|
if marked || strings.Contains(s, m) {
|
||||||
|
marked = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !marked {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// Over tokens, not byte offsets. The negation test used to read the string
|
||||||
|
// immediately before a match, which meant it could only see "не" spelled
|
||||||
|
// exactly there; a token list makes the previous word plain to read.
|
||||||
|
toks := repairTokens(s)
|
||||||
|
best, say, at := router.Intent(""), "", -1
|
||||||
|
for i, tok := range toks {
|
||||||
|
if at >= 0 && i > at {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, w := range repairIntents {
|
||||||
|
if !morph.SameWord(tok, w.word) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i > 0 && (toks[i-1] == "не" || toks[i-1] == "not") {
|
||||||
|
// The one he is ruling out: "не напоминание, а заметка".
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Leftmost wins: "это заметка, а не напоминание" corrects to the
|
||||||
|
// first.
|
||||||
|
if at < 0 || i < at {
|
||||||
|
best, say, at = w.intent, w.say, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return best, say, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairTokens splits a correction into lowercase word tokens. Punctuation goes,
|
||||||
|
// because "это заметка, а не напоминание" glues a comma to the word the negation
|
||||||
|
// test has to look past.
|
||||||
|
func repairTokens(s string) []string {
|
||||||
|
return strings.FieldsFunc(s, func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordTurn keeps the utterance a correction would point at. Only turns she
|
||||||
|
// acted on: a clarify asked instead of acting, so there is nothing yet to be
|
||||||
|
// wrong about.
|
||||||
|
func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
last := h.lastRouted
|
||||||
|
// Taken, not read: one utterance is corrected once. Saying "нет, не так"
|
||||||
|
// twice would otherwise redo the same request twice.
|
||||||
|
h.lastRouted = nil
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRepair handles a spoken correction of the previous turn: teach the
|
||||||
|
// classifier, redo the request under the corrected intent, and say so.
|
||||||
|
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
||||||
|
corrected, say, ok := parseRepair(text)
|
||||||
|
if !ok || h.router == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
last := h.takeLastTurn()
|
||||||
|
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if last.intent == corrected {
|
||||||
|
// She already did what he is asking for. Correcting the classifier
|
||||||
|
// here would teach it the label it produced, and redoing the request
|
||||||
|
// would file it twice.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
learned := true
|
||||||
|
if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil {
|
||||||
|
// The redo is still worth doing: he asked for something and it did not
|
||||||
|
// happen. Only the learning half is lost, and he is told so.
|
||||||
|
log.Printf("voice: repair: could not learn %q as %s: %v", last.utterance, corrected, err)
|
||||||
|
learned = false
|
||||||
|
}
|
||||||
|
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: last.utterance,
|
||||||
|
Stage: 2,
|
||||||
|
Intent: corrected,
|
||||||
|
Slots: h.extractor.Extract(ctx, corrected, last.utterance, h.now()),
|
||||||
|
}
|
||||||
|
// A reminder's Text is what she says at the hour and stays empty when it
|
||||||
|
// was not spoken, so the gap is asked about rather than filled with the
|
||||||
|
// whole sentence. Everywhere else the utterance IS the payload.
|
||||||
|
if dec.Slots.Text == "" && corrected != router.IntentReminder {
|
||||||
|
dec.Slots.Text = last.utterance
|
||||||
|
}
|
||||||
|
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairLine — what she says before redoing it, so the correction is visible
|
||||||
|
// and not just filed. Feminine, informal, no apology: he corrected a routing
|
||||||
|
// call, he did not complain about her.
|
||||||
|
func repairLine(say string, learned bool) string {
|
||||||
|
if !learned {
|
||||||
|
return "поняла, это " + say + " — переделываю, но запомнить поправку не вышло."
|
||||||
|
}
|
||||||
|
return "поняла, это " + say + " — запомнила."
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"нет, ты не поняла, это заметка", router.IntentNote, true},
|
||||||
|
{"нет, это заметка", router.IntentNote, true},
|
||||||
|
{"это не напоминание, а заметка", router.IntentNote, true},
|
||||||
|
{"это заметка, а не напоминание", router.IntentNote, true},
|
||||||
|
{"ты не так поняла — это факт", router.IntentFact, true},
|
||||||
|
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
|
||||||
|
{"you got it wrong, that was a note", router.IntentNote, true},
|
||||||
|
// No marker: an ordinary request that happens to name an intent.
|
||||||
|
{"запиши заметку купить хлеб", "", false},
|
||||||
|
{"напомни мне про заметку", "", false},
|
||||||
|
// A marker with no intent named: nothing to correct to.
|
||||||
|
{"ты не так поняла", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, _, ok := parseRepair(c.utterance)
|
||||||
|
if ok != c.ok || (ok && got != c.want) {
|
||||||
|
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
|
||||||
|
// previous utterance is filed under the intent he named, the classifier keeps
|
||||||
|
// it as an example, and he hears that it landed.
|
||||||
|
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
|
||||||
|
h, st, now := newClarifyHandler(t)
|
||||||
|
emb := router.NewHashEmbedder(256)
|
||||||
|
cls := router.NewClassifier(emb)
|
||||||
|
h.recall.embedder = emb
|
||||||
|
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("a spoken correction was not handled")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "заметка") {
|
||||||
|
t.Errorf("the correction is not named out loud: %q", reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "не вышло") {
|
||||||
|
t.Errorf("learning failed unexpectedly: %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := cls.Examples(router.IntentNote)
|
||||||
|
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
|
||||||
|
t.Fatalf("the classifier did not learn the correction: %+v", ex)
|
||||||
|
}
|
||||||
|
notes, err := st.RecentNotes(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent notes: %v", err)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
|
||||||
|
t.Fatalf("the request was not redone as a note: %+v", notes)
|
||||||
|
}
|
||||||
|
_ = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
|
||||||
|
h, _, now := newClarifyHandler(t)
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Nothing said yet.
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction with no previous turn was handled")
|
||||||
|
}
|
||||||
|
// Said, but long ago.
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
*now = now.Add(repairWindow + time.Minute)
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction outside the window was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairIsSpentOnce(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
emb := router.NewHashEmbedder(256)
|
||||||
|
h.recall.embedder = emb
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||||
|
t.Fatal("the first correction was not handled")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("the same turn was corrected twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
|
||||||
|
// is nothing to teach and redoing it would file the request a second time.
|
||||||
|
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||||
|
h.recordTurn("купить хлеб", router.IntentNote)
|
||||||
|
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction to the intent she already used was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairIntentWordCollisions — the prefix list matched more than the word
|
||||||
|
// (Vikunja #528). "команд" is inside "командировка" and "факт" inside
|
||||||
|
// "фактически", and either one used to name an intent she would redo the turn
|
||||||
|
// under.
|
||||||
|
func TestRepairIntentWordCollisions(t *testing.T) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"нет, это про командировку",
|
||||||
|
"нет, фактически всё нормально",
|
||||||
|
} {
|
||||||
|
if _, _, ok := parseRepair(s); ok {
|
||||||
|
t.Errorf("parseRepair(%q) claimed a correction", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The declined forms the prefixes existed to cover still work, and the
|
||||||
|
// negated half is still skipped.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
}{
|
||||||
|
{"нет, это заметка", router.IntentNote},
|
||||||
|
{"ты не так поняла, это заметку надо было", router.IntentNote},
|
||||||
|
{"нет, это напоминание, а не заметка", router.IntentReminder},
|
||||||
|
{"нет, не напоминание, а заметка", router.IntentNote},
|
||||||
|
{"нет, это командой было", router.IntentAct},
|
||||||
|
} {
|
||||||
|
got, _, ok := parseRepair(tc.utterance)
|
||||||
|
if !ok || got != tc.want {
|
||||||
|
t.Errorf("parseRepair(%q) = %q, %v; want %q, true", tc.utterance, got, ok, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-100
@@ -2,122 +2,43 @@ 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 {
|
||||||
|
// The deck, not the stub's single sentence: a clarify she cannot turn
|
||||||
|
// into a question is the line he hears most often when she misses him,
|
||||||
|
// and it used to be the same words every time (Vikunja #457). Still no
|
||||||
|
// model call — this text has to be right every time, and it is not worth
|
||||||
|
// a generation to say something this small.
|
||||||
|
return clarifyMissedLine(d)
|
||||||
|
}
|
||||||
|
out, err := r.p.PhraseReply(context.Background(), d)
|
||||||
|
if err != nil || out == "" {
|
||||||
return r.stub.Reply(d)
|
return r.stub.Reply(d)
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
// The persona checks, on the live path (personaguard.go). A reply that
|
||||||
defer cancel()
|
// leaks reasoning or calls him "вы" is worse than a flat one.
|
||||||
out, err := r.c.Complete(ctx, llm.Req{
|
if _, ok := guardSpoken("reply", out); !ok {
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,23 +10,18 @@ import (
|
|||||||
"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 +29,52 @@ 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}
|
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "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}
|
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
||||||
got := r.Reply(noteDec)
|
}
|
||||||
want := voice.NewStubReplier().Reply(noteDec)
|
|
||||||
if got != want {
|
// A clarify never reaches the model, and since Vikunja #457 it is answered from
|
||||||
t.Errorf("on empty llm: got %q, want stub %q", got, want)
|
// the clarify deck rather than the stub's single sentence.
|
||||||
|
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||||
|
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||||
|
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
|
||||||
|
if got == "я всё поняла" {
|
||||||
|
t.Fatal("a clarify must not be phrased by the model")
|
||||||
|
}
|
||||||
|
if want := clarifyMissedFor("мгм"); got != want {
|
||||||
|
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// Two different misses do not sound identical.
|
||||||
|
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||||
|
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
// assertAck — the stub picks between variants now, so two calls to it are not
|
||||||
r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil)
|
// expected to match. What must hold is that the reply is a line that entry can
|
||||||
clarifyDec := router.Decision{Clarify: true}
|
// produce, which is the same claim without pinning one wording.
|
||||||
got := r.Reply(clarifyDec)
|
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
||||||
want := voice.NewStubReplier().Reply(clarifyDec)
|
t.Helper()
|
||||||
if got != want {
|
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
|
||||||
t.Errorf("on clarify: got %q, want stub %q", got, want)
|
t.Errorf("on %s: got %q, want a %q line", what, got, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var errTestLLMDown = errTest("llm down")
|
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 {
|
||||||
|
t.Errorf("on %s: got %q, want stub %q", what, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+26
-36
@@ -4,6 +4,10 @@
|
|||||||
// plural agreement, clock/date rendering, and the "do I actually know this
|
// plural agreement, clock/date rendering, and the "do I actually know this
|
||||||
// place/day" guards that pick an honest reply over a confidently wrong one.
|
// place/day" guards that pick an honest reply over a confidently wrong one.
|
||||||
// Extend this file rather than voice.go for anything in that shape.
|
// Extend this file rather than voice.go for anything in that shape.
|
||||||
|
//
|
||||||
|
// Count agreement is not here. It is say.CountWord, because there were four
|
||||||
|
// copies of the same three-way rule and two of the sites that needed it were
|
||||||
|
// spelling one form out (Vikunja #521).
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,17 +15,15 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ruWeekdays = []string{
|
// Weekday and month names are a closed class — the language has seven and
|
||||||
"воскресенье", "понедельник", "вторник", "среда",
|
// twelve — so they live complete in internal/lexicon, where internal/ttsnorm
|
||||||
"четверг", "пятница", "суббота",
|
// reads the same twelve month names instead of keeping a second copy
|
||||||
}
|
// (Vikunja #525).
|
||||||
|
|
||||||
var ruMonths = []string{
|
|
||||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
|
||||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
|
||||||
}
|
|
||||||
|
|
||||||
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
||||||
// other than here. She only keeps one clock, and saying so is better than
|
// other than here. She only keeps one clock, and saying so is better than
|
||||||
@@ -33,13 +35,15 @@ var ruMonths = []string{
|
|||||||
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
||||||
|
|
||||||
// notPlaceAfterV — words that follow "в" without naming a place, so
|
// notPlaceAfterV — words that follow "в" without naming a place, so
|
||||||
// mentionsUnknownPlace does not mistake them for a city.
|
// mentionsUnknownPlace does not mistake them for a city. Closed set, kept
|
||||||
var notPlaceAfterV = map[string]bool{
|
// complete in internal/lexicon.
|
||||||
"данный": true, "данную": true, "этот": true, "эту": true,
|
var notPlaceAfterV = func() map[string]bool {
|
||||||
"котором": true, "какое": true, "какой": true, "который": true,
|
m := map[string]bool{}
|
||||||
"общем": true, "точности": true, "курсе": true, "сутках": true,
|
for _, w := range lexicon.NotPlaceAfterV() {
|
||||||
"часах": true, "минутах": true, "секундах": true, "неделе": true,
|
m[w] = true
|
||||||
}
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
||||||
// that looks like a place we do not know ("который час в киеве"). Used only to
|
// that looks like a place we do not know ("который час в киеве"). Used only to
|
||||||
@@ -94,11 +98,11 @@ func mentionsUnknownDay(u string) bool {
|
|||||||
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
||||||
func ruClock(t time.Time) string {
|
func ruClock(t time.Time) string {
|
||||||
h, m := t.Hour(), t.Minute()
|
h, m := t.Hour(), t.Minute()
|
||||||
hourWord := ruPlural(h, "час", "часа", "часов")
|
hourWord := say.CountWord(h, "час", "часа", "часов")
|
||||||
if m == 0 {
|
if m == 0 {
|
||||||
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут"))
|
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(m, "минута", "минуты", "минут"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
||||||
@@ -118,22 +122,6 @@ func dayPrefix(now, day time.Time) string {
|
|||||||
return "это"
|
return "это"
|
||||||
}
|
}
|
||||||
|
|
||||||
func ruPlural(n int, one, two, many string) string {
|
|
||||||
n = n % 100
|
|
||||||
if n > 10 && n < 20 {
|
|
||||||
return many
|
|
||||||
}
|
|
||||||
n = n % 10
|
|
||||||
switch n {
|
|
||||||
case 1:
|
|
||||||
return one
|
|
||||||
case 2, 3, 4:
|
|
||||||
return two
|
|
||||||
default:
|
|
||||||
return many
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
||||||
// rather than the current clock — guards replySystem from replying "сейчас
|
// rather than the current clock — guards replySystem from replying "сейчас
|
||||||
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
||||||
@@ -169,11 +157,13 @@ func formatTime(t time.Time) string {
|
|||||||
case diff < 10*time.Minute:
|
case diff < 10*time.Minute:
|
||||||
return "несколько минут назад"
|
return "несколько минут назад"
|
||||||
case diff < 60*time.Minute:
|
case diff < 60*time.Minute:
|
||||||
return fmt.Sprintf("%d минут назад", int(diff.Minutes()))
|
n := int(diff.Minutes())
|
||||||
|
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут"))
|
||||||
case diff < 2*time.Hour:
|
case diff < 2*time.Hour:
|
||||||
return "час назад"
|
return "час назад"
|
||||||
case diff < 24*time.Hour:
|
case diff < 24*time.Hour:
|
||||||
return fmt.Sprintf("%d часа назад", int(diff.Hours()))
|
n := int(diff.Hours())
|
||||||
|
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||||
default:
|
default:
|
||||||
return t.Format("2 января 15:04")
|
return t.Format("2 января 15:04")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
//
|
//
|
||||||
@@ -428,20 +428,22 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
|||||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||||
|
|
||||||
w.handler = &reactiveHandler{
|
w.handler = &reactiveHandler{
|
||||||
stt: simTranscriber{},
|
stt: simTranscriber{},
|
||||||
tts: simSynthesizer{},
|
tts: simSynthesizer{},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
recall: recallWiring{
|
||||||
|
embedder: emb,
|
||||||
|
memStore: st.VectorMemory(),
|
||||||
|
minScore: config.DefaultQueryMinScore,
|
||||||
|
minMargin: config.DefaultQueryMinMargin,
|
||||||
|
},
|
||||||
api: api,
|
api: api,
|
||||||
matcher: matcher,
|
matcher: matcher,
|
||||||
tools: tool.NewExecutor(api, 5*time.Second),
|
tools: tool.NewExecutor(api, 5*time.Second),
|
||||||
phraser: phraser.NewStub(),
|
phraser: phraser.NewStub(),
|
||||||
replier: newLLMReplier(scripted, nil),
|
replier: newLLMReplier(scripted, nil),
|
||||||
now: clock.Now,
|
now: clock.Now,
|
||||||
memStore: st.VectorMemory(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
queryMinScore: config.DefaultQueryMinScore,
|
|
||||||
queryMinMargin: config.DefaultQueryMinMargin,
|
|
||||||
timeParser: router.StubDateTimeParser{},
|
timeParser: router.StubDateTimeParser{},
|
||||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||||
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||||
@@ -1043,3 +1045,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-5
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/smarthome"
|
"github.com/kami/maven/internal/smarthome"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -140,10 +142,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
ents, err := w.client.States(ctx)
|
ents, err := w.client.States(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("smarthome: summary: %v", err)
|
log.Printf("smarthome: summary: %v", err)
|
||||||
return "не смогла достучаться до дома.", true
|
return phraser.A(phraser.HomeUnreachable, nil), true
|
||||||
}
|
}
|
||||||
if len(ents) == 0 {
|
if len(ents) == 0 {
|
||||||
return "дом ничего не отдаёт.", true
|
return phraser.A(phraser.HomeEmpty, nil), true
|
||||||
}
|
}
|
||||||
var on []string
|
var on []string
|
||||||
var sensors []string
|
var sensors []string
|
||||||
@@ -177,7 +179,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
}
|
}
|
||||||
// Silent truncation on a status read is the same failure as the cap
|
// Silent truncation on a status read is the same failure as the cap
|
||||||
// one layer up: she has to say the list is not the whole list.
|
// one layer up: she has to say the list is not the whole list.
|
||||||
line := "включено: " + strings.Join(shown, ", ")
|
line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")})
|
||||||
if rest > 0 {
|
if rest > 0 {
|
||||||
line += fmt.Sprintf(" и ещё %d", rest)
|
line += fmt.Sprintf(" и ещё %d", rest)
|
||||||
}
|
}
|
||||||
@@ -185,7 +187,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
case dark > 0 && len(sensors) == 0:
|
case dark > 0 && len(sensors) == 0:
|
||||||
// Nothing is on and everything she can see is unreachable. "всё
|
// Nothing is on and everything she can see is unreachable. "всё
|
||||||
// выключено" would be a claim about the house she cannot make.
|
// выключено" would be a claim about the house she cannot make.
|
||||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
return phraser.A(phraser.HomeDark, map[string]string{
|
||||||
|
"count": strconv.Itoa(dark),
|
||||||
|
"word": phraser.Devices(dark),
|
||||||
|
}), true
|
||||||
default:
|
default:
|
||||||
parts = append(parts, "всё выключено")
|
parts = append(parts, "всё выключено")
|
||||||
}
|
}
|
||||||
@@ -193,7 +198,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
parts = append(parts, strings.Join(sensors, ", "))
|
parts = append(parts, strings.Join(sensors, ", "))
|
||||||
}
|
}
|
||||||
if dark > 0 {
|
if dark > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
|
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark)))
|
||||||
}
|
}
|
||||||
return strings.Join(parts, "; ") + ".", true
|
return strings.Join(parts, "; ") + ".", true
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-6
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
|
|||||||
}
|
}
|
||||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
||||||
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||||
return "не получилось отложить.", true
|
return phraser.Ack(phraser.FailSnooze, nil), true
|
||||||
}
|
}
|
||||||
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||||
return "хорошо, вернусь к этому позже.", true
|
return phraser.Ack(phraser.AckSnooze, nil), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
||||||
@@ -81,15 +82,23 @@ func (h *reactiveHandler) pendingNudge(ctx context.Context, now time.Time) (ipc.
|
|||||||
return ipc.Nudge{}, false
|
return ipc.Nudge{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// snoozePhrases — the deferral vocabulary, as stem sequences. Matched by
|
// snoozePhrases — the deferral vocabulary. Matched by quietPhrase and quietStem
|
||||||
// quietPhrase (quiet_toggle.go), which carries the rule that matters here:
|
// (quiet_toggle.go), so an adverb is matched through the dictionary and a verb
|
||||||
|
// exactly, prefixed with "=": "напомни" is a request and "напомнил" is a report
|
||||||
|
// about earlier, and the dictionary files both under напомнить (Vikunja #526).
|
||||||
|
// The verbs used to be truncated stems — "напомн", "отлож" — which is what the
|
||||||
|
// deleted ending list existed to complete.
|
||||||
|
//
|
||||||
|
// quietPhrase carries the rule that matters here:
|
||||||
// a single-word pattern matches only a single-word utterance. Bare "потом" is
|
// a single-word pattern matches only a single-word utterance. Bare "потом" is
|
||||||
// an answer; "потом схожу за водой" is a plan, and reporting a plan must not
|
// an answer; "потом схожу за водой" is a plan, and reporting a plan must not
|
||||||
// silence the rule that prompted it.
|
// silence the rule that prompted it.
|
||||||
var snoozePhrases = [][]string{
|
var snoozePhrases = [][]string{
|
||||||
{"не", "сейчас"}, {"не", "могу", "сейчас"}, {"не", "до", "этого"},
|
{"не", "сейчас"}, {"не", "могу", "сейчас"}, {"не", "до", "этого"},
|
||||||
{"напомн", "позже"}, {"напомн", "потом"}, {"спрос", "позже"},
|
{"=напомни|=напоминай", "позже"}, {"=напомни|=напоминай", "потом"},
|
||||||
{"отлож"}, {"позже"}, {"потом"}, {"попозже"}, {"погоди"},
|
{"=спроси|=спрашивай", "позже"},
|
||||||
|
{"=отложи|=отложим|=откладывай"}, {"позже"}, {"потом"}, {"попозже"},
|
||||||
|
{"=погоди|=погодите"},
|
||||||
{"not", "now"}, {"later"}, {"snooze"}, {"remind", "me", "later"},
|
{"not", "now"}, {"later"}, {"snooze"}, {"remind", "me", "later"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-694
@@ -10,22 +10,17 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/calendar"
|
|
||||||
"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/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
"github.com/kami/maven/internal/pattern"
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -176,6 +171,9 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
t.queueNudge(ctx, cand, state, now)
|
t.queueNudge(ctx, cand, state, now)
|
||||||
} else {
|
} else {
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||||
|
if err == nil {
|
||||||
|
pn = guardNudge(pn, *cand)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -315,597 +313,6 @@ func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
|
|||||||
return pn.Body, pn.Summary
|
return pn.Body, pn.Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldQueue — true when digest is enabled and the candidate's severity is
|
|
||||||
// at or below the configured ceiling.
|
|
||||||
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
|
|
||||||
return t.digestCfg != nil && t.digestCfg.Enabled &&
|
|
||||||
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
|
|
||||||
}
|
|
||||||
|
|
||||||
// queueNudge — phrases the candidate and appends it to the digest queue.
|
|
||||||
// Deduplicates by rule name: if the same rule is already queued, this is a
|
|
||||||
// no-op (the first fire within the window is the one that counts).
|
|
||||||
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
|
|
||||||
for _, q := range t.digestQ {
|
|
||||||
if q.Rule == cand.Rule.Name {
|
|
||||||
return // already queued
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.digestQ = append(t.digestQ, QueuedNudge{
|
|
||||||
Rule: cand.Rule.Name,
|
|
||||||
Severity: int(cand.Severity),
|
|
||||||
Body: pn.Body,
|
|
||||||
Key: cand.Rule.Name,
|
|
||||||
QueuedAt: now,
|
|
||||||
})
|
|
||||||
t.cachePhrase(pn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeFlush — flushes the digest queue if the window has elapsed since the
|
|
||||||
// first item or the queue reached MaxItems.
|
|
||||||
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
first := t.digestQ[0]
|
|
||||||
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
|
|
||||||
len(t.digestQ) >= t.digestCfg.MaxItems {
|
|
||||||
t.flushDigest(ctx, now, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flushDigest — concatenates queued nudge bodies into a single digest
|
|
||||||
// notification and dispatches it. Clears the queue after a successful send.
|
|
||||||
// The digest uses the max severity among queued items for routing.
|
|
||||||
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
if len(t.digestQ) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
maxSev := 0
|
|
||||||
for i, q := range t.digestQ {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(" · ")
|
|
||||||
}
|
|
||||||
b.WriteString(q.Body)
|
|
||||||
if q.Severity > maxSev {
|
|
||||||
maxSev = q.Severity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body := b.String()
|
|
||||||
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
|
|
||||||
|
|
||||||
cand := loop.Candidate{
|
|
||||||
Rule: loop.Rule{
|
|
||||||
Name: "digest",
|
|
||||||
Severity: loop.Severity(maxSev),
|
|
||||||
},
|
|
||||||
Severity: loop.Severity(maxSev),
|
|
||||||
State: state,
|
|
||||||
}
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: cand,
|
|
||||||
Body: body,
|
|
||||||
Summary: summary,
|
|
||||||
}
|
|
||||||
t.cachePhrase(pn)
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
// keep the queue — the next tick's maybeFlush re-attempts.
|
|
||||||
log.Printf("tick: dispatch digest: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.digestQ = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectPatterns runs the pattern detector proactively over every
|
|
||||||
// action+object pair that has ever produced an event, independent of
|
|
||||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
|
||||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
|
||||||
// own schedule reading accumulated history, not only as a side effect of a
|
|
||||||
// live voice turn.
|
|
||||||
//
|
|
||||||
// Idempotence and noise are handled by the store, not here — this function
|
|
||||||
// is safe to call every tick:
|
|
||||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
|
||||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
|
||||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
|
||||||
// already has a row — in ANY status — produces no second row and no log
|
|
||||||
// spam beyond the one line at genuine creation.
|
|
||||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
|
||||||
// status in place; the row is never deleted. So the same Lookup check
|
|
||||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
|
||||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
|
||||||
// calling the same shared path the voice route already used.
|
|
||||||
//
|
|
||||||
// By default this only creates a row for the /routines page to show: it does
|
|
||||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
|
||||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
|
||||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
|
||||||
// for the restraints that apply even then. A proposal only starts producing
|
|
||||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
|
||||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: distinct event pairs: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
announced := false
|
|
||||||
for _, p := range pairs {
|
|
||||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == nil {
|
|
||||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
|
||||||
}
|
|
||||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
|
||||||
// One announcement per tick at most, whatever the scan turned up. The
|
|
||||||
// rest are on /routines; they are not lost, they are just not shouted.
|
|
||||||
// Nor are they queued: the row now exists, so no later tick re-detects
|
|
||||||
// them and they are never announced. See announceProposal.
|
|
||||||
if announced {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
announced = t.announceProposal(ctx, r, now, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// announceProposal offers a freshly inferred routine through the ordinary
|
|
||||||
// care-delivery path, if announcing is switched on at all. Returns true when
|
|
||||||
// something was actually sent.
|
|
||||||
//
|
|
||||||
// Everything here is restraint. The feature is off unless configured; when on
|
|
||||||
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
|
||||||
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
|
||||||
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
|
||||||
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
|
||||||
// real send, but the proposal row already exists, so the next tick will not
|
|
||||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
|
||||||
// he reads it on /routines instead, which is the whole point of the page.
|
|
||||||
//
|
|
||||||
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
|
||||||
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
|
||||||
// that first proposes it. Combined with one announcement per tick, the first
|
|
||||||
// tick over a populated history announces one pattern and permanently silences
|
|
||||||
// every other pattern found in the same pass. That is the intent, not an
|
|
||||||
// oversight — an inferred routine is not worth a second attempt at his
|
|
||||||
// attention, and /routines lists all of them. So the cooldown does not drain a
|
|
||||||
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
|
||||||
// later ticks. If it should ever become "one per day until each is mentioned",
|
|
||||||
// that needs a queue rather than this counter.
|
|
||||||
//
|
|
||||||
// Cooldown gets its default here as well as in applyDefaults. That is
|
|
||||||
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
|
||||||
// and an unspaced announcer is not what those tests mean to exercise.
|
|
||||||
//
|
|
||||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
|
||||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
|
||||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
|
||||||
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
|
||||||
if !t.proposalCfg.AnnounceProposals() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
|
||||||
if cooldown <= 0 {
|
|
||||||
cooldown = config.DefaultProposalCooldown
|
|
||||||
}
|
|
||||||
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
|
||||||
if !loop.Gate(state, rule) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
body := pattern.PhraseRoutine(r)
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
|
||||||
Body: body,
|
|
||||||
Summary: body,
|
|
||||||
}
|
|
||||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(sent) == 0 {
|
|
||||||
return false // routing dropped it — /routines still has it.
|
|
||||||
}
|
|
||||||
t.lastProposalAt = now
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
|
||||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
|
||||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
|
||||||
// digest entry that outlives one full day is describing a day that's already
|
|
||||||
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
|
||||||
// news. Bounding at one day also means a digest can never silently span a
|
|
||||||
// weekend of quiet hours into an unbounded backlog.
|
|
||||||
const digestExpiry = 24 * time.Hour
|
|
||||||
|
|
||||||
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
|
||||||
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
|
||||||
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
|
||||||
// Anything beyond the cap is still marked drained (it did get its moment;
|
|
||||||
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
|
||||||
// count instead of being spoken in full.
|
|
||||||
const maxDigestSpokenItems = 3
|
|
||||||
|
|
||||||
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
|
||||||
// gate blocked for a genuine restraint reason and durably records the
|
|
||||||
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
|
||||||
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
|
||||||
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
|
||||||
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
|
||||||
// makes repeat calls here harmless, but skipping the phrase call entirely
|
|
||||||
// when a pending entry already exists avoids the LLM round-trip too).
|
|
||||||
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
|
||||||
if trace == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, tr := range trace.RuleTraces {
|
|
||||||
if !tr.PredicateResult || tr.GateResult {
|
|
||||||
continue // didn't want to fire, or wasn't suppressed
|
|
||||||
}
|
|
||||||
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
|
||||||
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
expires := now.Add(digestExpiry)
|
|
||||||
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
|
||||||
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
|
||||||
} else if deduped {
|
|
||||||
// same suppressed nudge already pending — nothing new to say.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
|
||||||
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
|
||||||
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
|
||||||
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: expire stale digest entries: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
|
||||||
// suppression reasons have actually cleared — quiet hours over, back from
|
|
||||||
// away, out of the meeting. Draining while still suppressed would just be a
|
|
||||||
// second way to nag through quiet hours; the bundle waits for the same "is
|
|
||||||
// it allowed right now" condition a live nudge already waits for.
|
|
||||||
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
|
||||||
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries, err := t.store.PendingDigestEntries(ctx, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: pending digest entries: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
spoken := entries
|
|
||||||
extra := 0
|
|
||||||
if len(spoken) > maxDigestSpokenItems {
|
|
||||||
spoken = entries[:maxDigestSpokenItems]
|
|
||||||
extra = len(entries) - maxDigestSpokenItems
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
maxSev := 0
|
|
||||||
for i, e := range spoken {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(" · ")
|
|
||||||
}
|
|
||||||
b.WriteString(e.Body)
|
|
||||||
if e.Severity > maxSev {
|
|
||||||
maxSev = e.Severity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if extra > 0 {
|
|
||||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
|
||||||
}
|
|
||||||
body := b.String()
|
|
||||||
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
|
|
||||||
|
|
||||||
cand := loop.Candidate{
|
|
||||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
|
||||||
Severity: loop.Severity(maxSev),
|
|
||||||
State: state,
|
|
||||||
}
|
|
||||||
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
|
||||||
t.cachePhrase(pn)
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
log.Printf("tick: dispatch digest bundle: %v", err)
|
|
||||||
return // leave entries pending; retried next tick
|
|
||||||
}
|
|
||||||
ids := make([]int64, len(entries))
|
|
||||||
for i, e := range entries {
|
|
||||||
ids[i] = e.ID
|
|
||||||
}
|
|
||||||
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
|
||||||
log.Printf("tick: drain digest entries: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// routinesFromConfig maps the config's routine blocks to the engine type.
|
|
||||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
|
||||||
// in config.Load, so this is a pure field copy.
|
|
||||||
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
|
||||||
if len(rc) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]routine.Routine, len(rc))
|
|
||||||
for i, r := range rc {
|
|
||||||
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
|
||||||
// last fire. Each is delivered as a nudge through the normal routing table
|
|
||||||
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
|
||||||
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
|
||||||
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
|
||||||
// already advanced the last-fire time so a transient failure drops that fire
|
|
||||||
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
|
||||||
// no repeat-til-ack).
|
|
||||||
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: loop.Candidate{
|
|
||||||
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
|
||||||
Severity: loop.Severity(r.Severity),
|
|
||||||
State: state,
|
|
||||||
},
|
|
||||||
Body: r.Body,
|
|
||||||
Summary: r.Body,
|
|
||||||
}
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fireAcceptedRoutines nudges about the routines the user accepted, once per
|
|
||||||
// interval (Vikunja #366). Accepting used to create a single reminder, so a
|
|
||||||
// non-weekly routine fired once and went quiet forever; the schedule lives in
|
|
||||||
// the proposed_routines row now and the loop re-reads it every tick.
|
|
||||||
//
|
|
||||||
// A routine is a care-class nudge and goes through the restraint gate like any
|
|
||||||
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
|
|
||||||
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
|
|
||||||
// goes out on the next tick that the gate allows — one nudge, held, not dropped
|
|
||||||
// and not repeated.
|
|
||||||
//
|
|
||||||
// The body is literal text built from the detected action and object, not
|
|
||||||
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
|
|
||||||
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
rows, err := t.store.ListAcceptedRoutines(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: list accepted routines: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
accepted := make([]routine.Accepted, 0, len(rows))
|
|
||||||
for _, r := range rows {
|
|
||||||
if r.AcceptedTs == nil {
|
|
||||||
continue // accepted before the schedule column existed — no clock to start from.
|
|
||||||
}
|
|
||||||
accepted = append(accepted, routine.Accepted{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Action + " " + r.Object,
|
|
||||||
IntervalDays: r.IntervalDays,
|
|
||||||
Accepted: *r.AcceptedTs,
|
|
||||||
LastFired: r.LastFiredTs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range routine.DueAccepted(accepted, now) {
|
|
||||||
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
|
|
||||||
if !loop.Gate(state, rule) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body := "пора: " + a.Name
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
|
||||||
Body: body,
|
|
||||||
Summary: body,
|
|
||||||
}
|
|
||||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(sent) == 0 {
|
|
||||||
continue // routing dropped it — leave it due.
|
|
||||||
}
|
|
||||||
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
|
|
||||||
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fireMorningRoutines checks each configured checklist against today's facts
|
|
||||||
// and dispatches a nag listing exactly what's still missing, at most once per
|
|
||||||
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
|
||||||
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
|
||||||
// concern — pulling it into the shared gather path would leak that mapping
|
|
||||||
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
|
||||||
// operator text (item labels joined), not LLM-phrased, same rationale as
|
|
||||||
// cron routines: deterministic, can't hallucinate a checklist item.
|
|
||||||
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
if len(t.morningRoutines) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
facts := t.gatherMorningFacts(ctx)
|
|
||||||
|
|
||||||
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
|
||||||
labels := make([]string, len(cand.Missing))
|
|
||||||
for i, it := range cand.Missing {
|
|
||||||
labels[i] = it.Label
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", "))
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: loop.Candidate{
|
|
||||||
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
|
||||||
Severity: loop.Severity(cand.Routine.Severity),
|
|
||||||
State: state,
|
|
||||||
},
|
|
||||||
Body: body,
|
|
||||||
Summary: body,
|
|
||||||
}
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
|
||||||
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
|
||||||
// decision) and morningStatus (read-only query) so the two paths can never
|
|
||||||
// disagree about what evidence exists.
|
|
||||||
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
|
||||||
keys := make(map[string]struct{})
|
|
||||||
for _, r := range t.morningRoutines {
|
|
||||||
for _, it := range r.Items {
|
|
||||||
keys[it.FactKey] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
facts := make(map[string]store.Fact, len(keys))
|
|
||||||
for k := range keys {
|
|
||||||
f, err := t.store.LatestFact(ctx, k)
|
|
||||||
if err == nil {
|
|
||||||
facts[k] = f
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != store.ErrNoFact {
|
|
||||||
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return facts
|
|
||||||
}
|
|
||||||
|
|
||||||
// morningStatus is the read-only "what's missing" query the web UI (and
|
|
||||||
// eventually a voice query) calls. Pure recompute over the current facts —
|
|
||||||
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
|
||||||
// "state right now," not "should we nag."
|
|
||||||
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
|
||||||
if len(t.morningRoutines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
facts := t.gatherMorningFacts(ctx)
|
|
||||||
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
|
||||||
for _, r := range t.morningRoutines {
|
|
||||||
st := morning.Evaluate(r, facts, now)
|
|
||||||
done := make(map[string]bool, len(st.Completed))
|
|
||||||
for _, it := range st.Completed {
|
|
||||||
done[it.Key] = true
|
|
||||||
}
|
|
||||||
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
|
||||||
for i, it := range r.Items {
|
|
||||||
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
|
||||||
}
|
|
||||||
out = append(out, ipc.MorningRoutineStatus{
|
|
||||||
Name: r.Name,
|
|
||||||
Active: st.Active,
|
|
||||||
WindowStart: r.WindowStart,
|
|
||||||
WindowEnd: r.WindowEnd,
|
|
||||||
Items: items,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
|
||||||
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
|
||||||
// pending reminders and the checklist facts, and the pure builder orders them.
|
|
||||||
//
|
|
||||||
// It never dispatches. Asking for the plan is a query like any other; the only
|
|
||||||
// unprompted delivery in maven stays with the morning nudge and the
|
|
||||||
// dispatcher's policy.
|
|
||||||
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
|
||||||
y, m, d := now.Date()
|
|
||||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
|
||||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
|
||||||
|
|
||||||
var events []morning.PlanEntry
|
|
||||||
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: day plan: calendar events: %v", err)
|
|
||||||
}
|
|
||||||
for _, f := range facts {
|
|
||||||
events = append(events, morning.PlanEntry{
|
|
||||||
At: f.Ts,
|
|
||||||
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
|
||||||
// fact value carries would say it twice.
|
|
||||||
Text: calendar.FactSummary(f.Value),
|
|
||||||
Kind: morning.PlanEvent,
|
|
||||||
// Provenance below a calendar read (an ambient relay, #126) is
|
|
||||||
// hedged rather than recited as fact.
|
|
||||||
Uncertain: f.Confidence < 1.0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var reminders []morning.PlanEntry
|
|
||||||
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: day plan: pending reminders: %v", err)
|
|
||||||
}
|
|
||||||
for _, r := range rems {
|
|
||||||
if r.Status != store.ReminderPending {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fire := r.NextFireTs
|
|
||||||
if fire.IsZero() {
|
|
||||||
fire = r.FireTs
|
|
||||||
}
|
|
||||||
reminders = append(reminders, morning.PlanEntry{
|
|
||||||
At: fire,
|
|
||||||
Text: r.Text(),
|
|
||||||
Kind: morning.PlanReminder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var checklistFacts map[string]store.Fact
|
|
||||||
if len(t.morningRoutines) > 0 {
|
|
||||||
checklistFacts = t.gatherMorningFacts(ctx)
|
|
||||||
}
|
|
||||||
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
|
||||||
|
|
||||||
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
|
||||||
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
|
||||||
for i, it := range plan.Items {
|
|
||||||
out.Items[i] = ipc.DayPlanItem{
|
|
||||||
At: it.At,
|
|
||||||
Text: it.Text,
|
|
||||||
Kind: string(it.Kind),
|
|
||||||
Uncertain: it.Uncertain,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||||
// rule:
|
// rule:
|
||||||
@@ -978,101 +385,3 @@ func (t *tickLoop) trace() *loop.TickTrace {
|
|||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
return t.lastTrace
|
return t.lastTrace
|
||||||
}
|
}
|
||||||
|
|
||||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
|
||||||
// daemon's in-memory tick trace cache.
|
|
||||||
type daemonAPI struct {
|
|
||||||
ipc.CoreAPI
|
|
||||||
getTrace func() *loop.TickTrace
|
|
||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
|
||||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
|
||||||
chatFn func(ctx context.Context, text string) string
|
|
||||||
getMCPServers func() []ipc.MCPServerStatus
|
|
||||||
getEvents func(n int) []ipc.IntakeEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
|
||||||
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
|
||||||
// look the same to a reader on purpose, because neither is a fault and the
|
|
||||||
// page renders both as an empty table.
|
|
||||||
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
|
||||||
if d.getEvents == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return d.getEvents(n), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
|
||||||
if d.chatFn == nil {
|
|
||||||
return "", errors.New("mavend: chat not available")
|
|
||||||
}
|
|
||||||
return d.chatFn(ctx, text), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
|
||||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
|
||||||
// default state and the web surface renders it as such.
|
|
||||||
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
|
||||||
if d.getMCPServers == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return d.getMCPServers(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
|
||||||
trace := d.getTrace()
|
|
||||||
if trace == nil {
|
|
||||||
return ipc.TickTrace{}, nil
|
|
||||||
}
|
|
||||||
return toIPCTickTrace(*trace), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
|
||||||
if d.getMorningStatus == nil {
|
|
||||||
return nil, errors.New("mavend: morning status not available")
|
|
||||||
}
|
|
||||||
return d.getMorningStatus(ctx), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
|
||||||
if d.getDayPlan == nil {
|
|
||||||
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
|
||||||
}
|
|
||||||
return d.getDayPlan(ctx), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
|
||||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
|
||||||
for i, r := range t.RuleTraces {
|
|
||||||
rules[i] = toIPCRuleTrace(r)
|
|
||||||
}
|
|
||||||
return ipc.TickTrace{
|
|
||||||
Now: t.Now,
|
|
||||||
Winner: t.Winner,
|
|
||||||
Rules: rules,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
|
|
||||||
return ipc.RuleTrace{
|
|
||||||
RuleName: r.RuleName,
|
|
||||||
Severity: int(r.Severity),
|
|
||||||
PredicateResult: r.PredicateResult,
|
|
||||||
GateResult: r.GateResult,
|
|
||||||
GateBlockedBy: r.GateBlockedBy,
|
|
||||||
GateDetail: toIPCGateDetail(r.GateDetail),
|
|
||||||
WasSelected: r.WasSelected,
|
|
||||||
LostTo: r.LostTo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
|
|
||||||
return ipc.GateDetail{
|
|
||||||
SnoozeUntil: d.SnoozeUntil,
|
|
||||||
CooldownUntil: d.CooldownUntil,
|
|
||||||
QuietHours: d.QuietHours,
|
|
||||||
CalendarBusy: d.CalendarBusy,
|
|
||||||
Presence: d.Presence,
|
|
||||||
InertKeysMissing: d.InertKeysMissing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// mavend/tick_api.go — the daemonAPI read surface over the tick loop.
|
||||||
|
//
|
||||||
|
// Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon
|
||||||
|
// for, and the loop-to-ipc conversions those answers need.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||||
|
// daemon's in-memory tick trace cache.
|
||||||
|
type daemonAPI struct {
|
||||||
|
ipc.CoreAPI
|
||||||
|
getTrace func() *loop.TickTrace
|
||||||
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
|
chatFn func(ctx context.Context, conversation, text string) string
|
||||||
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||||
|
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||||
|
// look the same to a reader on purpose, because neither is a fault and the
|
||||||
|
// page renders both as an empty table.
|
||||||
|
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||||
|
if d.getEvents == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getEvents(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
|
if d.chatFn == nil {
|
||||||
|
return "", errors.New("mavend: chat not available")
|
||||||
|
}
|
||||||
|
return d.chatFn(ctx, conversation, text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||||
|
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||||
|
// default state and the web surface renders it as such.
|
||||||
|
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||||
|
if d.getMCPServers == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getMCPServers(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||||
|
trace := d.getTrace()
|
||||||
|
if trace == nil {
|
||||||
|
return ipc.TickTrace{}, nil
|
||||||
|
}
|
||||||
|
return toIPCTickTrace(*trace), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||||
|
if d.getMorningStatus == nil {
|
||||||
|
return nil, errors.New("mavend: morning status not available")
|
||||||
|
}
|
||||||
|
return d.getMorningStatus(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||||
|
if d.getDayPlan == nil {
|
||||||
|
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||||
|
}
|
||||||
|
return d.getDayPlan(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||||
|
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||||
|
for i, r := range t.RuleTraces {
|
||||||
|
rules[i] = toIPCRuleTrace(r)
|
||||||
|
}
|
||||||
|
return ipc.TickTrace{
|
||||||
|
Now: t.Now,
|
||||||
|
Winner: t.Winner,
|
||||||
|
Rules: rules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
|
||||||
|
return ipc.RuleTrace{
|
||||||
|
RuleName: r.RuleName,
|
||||||
|
Severity: int(r.Severity),
|
||||||
|
PredicateResult: r.PredicateResult,
|
||||||
|
GateResult: r.GateResult,
|
||||||
|
GateBlockedBy: r.GateBlockedBy,
|
||||||
|
GateDetail: toIPCGateDetail(r.GateDetail),
|
||||||
|
WasSelected: r.WasSelected,
|
||||||
|
LostTo: r.LostTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
|
||||||
|
return ipc.GateDetail{
|
||||||
|
SnoozeUntil: d.SnoozeUntil,
|
||||||
|
CooldownUntil: d.CooldownUntil,
|
||||||
|
QuietHours: d.QuietHours,
|
||||||
|
CalendarBusy: d.CalendarBusy,
|
||||||
|
Presence: d.Presence,
|
||||||
|
InertKeysMissing: d.InertKeysMissing,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// mavend/tick_digest.go — the digest queue.
|
||||||
|
//
|
||||||
|
// Split out of tick.go, move-only (Vikunja #422). A nudge below the severity
|
||||||
|
// ceiling is held here instead of spoken, flushed as one batch on the window,
|
||||||
|
// and drained by hand when he asks. Everything about batching lives here.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shouldQueue — true when digest is enabled and the candidate's severity is
|
||||||
|
// at or below the configured ceiling.
|
||||||
|
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
|
||||||
|
return t.digestCfg != nil && t.digestCfg.Enabled &&
|
||||||
|
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueNudge — phrases the candidate and appends it to the digest queue.
|
||||||
|
// Deduplicates by rule name: if the same rule is already queued, this is a
|
||||||
|
// no-op (the first fire within the window is the one that counts).
|
||||||
|
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
|
||||||
|
for _, q := range t.digestQ {
|
||||||
|
if q.Rule == cand.Rule.Name {
|
||||||
|
return // already queued
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.digestQ = append(t.digestQ, QueuedNudge{
|
||||||
|
Rule: cand.Rule.Name,
|
||||||
|
Severity: int(cand.Severity),
|
||||||
|
Body: pn.Body,
|
||||||
|
Key: cand.Rule.Name,
|
||||||
|
QueuedAt: now,
|
||||||
|
})
|
||||||
|
t.cachePhrase(pn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeFlush — flushes the digest queue if the window has elapsed since the
|
||||||
|
// first item or the queue reached MaxItems.
|
||||||
|
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
first := t.digestQ[0]
|
||||||
|
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
|
||||||
|
len(t.digestQ) >= t.digestCfg.MaxItems {
|
||||||
|
t.flushDigest(ctx, now, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushDigest — concatenates queued nudge bodies into a single digest
|
||||||
|
// notification and dispatches it. Clears the queue after a successful send.
|
||||||
|
// The digest uses the max severity among queued items for routing.
|
||||||
|
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
if len(t.digestQ) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
maxSev := 0
|
||||||
|
for i, q := range t.digestQ {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(" · ")
|
||||||
|
}
|
||||||
|
b.WriteString(q.Body)
|
||||||
|
if q.Severity > maxSev {
|
||||||
|
maxSev = q.Severity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := b.String()
|
||||||
|
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
|
||||||
|
|
||||||
|
cand := loop.Candidate{
|
||||||
|
Rule: loop.Rule{
|
||||||
|
Name: "digest",
|
||||||
|
Severity: loop.Severity(maxSev),
|
||||||
|
},
|
||||||
|
Severity: loop.Severity(maxSev),
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: cand,
|
||||||
|
Body: body,
|
||||||
|
Summary: summary,
|
||||||
|
}
|
||||||
|
t.cachePhrase(pn)
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
// keep the queue — the next tick's maybeFlush re-attempts.
|
||||||
|
log.Printf("tick: dispatch digest: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.digestQ = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||||
|
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||||
|
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||||
|
// digest entry that outlives one full day is describing a day that's already
|
||||||
|
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
||||||
|
// news. Bounding at one day also means a digest can never silently span a
|
||||||
|
// weekend of quiet hours into an unbounded backlog.
|
||||||
|
const digestExpiry = 24 * time.Hour
|
||||||
|
|
||||||
|
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
||||||
|
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
||||||
|
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
||||||
|
// Anything beyond the cap is still marked drained (it did get its moment;
|
||||||
|
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
||||||
|
// count instead of being spoken in full.
|
||||||
|
const maxDigestSpokenItems = 3
|
||||||
|
|
||||||
|
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
||||||
|
// gate blocked for a genuine restraint reason and durably records the
|
||||||
|
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
||||||
|
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
||||||
|
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
||||||
|
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
||||||
|
// makes repeat calls here harmless, but skipping the phrase call entirely
|
||||||
|
// when a pending entry already exists avoids the LLM round-trip too).
|
||||||
|
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
||||||
|
if trace == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, tr := range trace.RuleTraces {
|
||||||
|
if !tr.PredicateResult || tr.GateResult {
|
||||||
|
continue // didn't want to fire, or wasn't suppressed
|
||||||
|
}
|
||||||
|
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
||||||
|
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
||||||
|
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expires := now.Add(digestExpiry)
|
||||||
|
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
||||||
|
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
||||||
|
} else if deduped {
|
||||||
|
// same suppressed nudge already pending — nothing new to say.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
||||||
|
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
||||||
|
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
||||||
|
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: expire stale digest entries: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
||||||
|
// suppression reasons have actually cleared — quiet hours over, back from
|
||||||
|
// away, out of the meeting. Draining while still suppressed would just be a
|
||||||
|
// second way to nag through quiet hours; the bundle waits for the same "is
|
||||||
|
// it allowed right now" condition a live nudge already waits for.
|
||||||
|
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
||||||
|
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := t.store.PendingDigestEntries(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: pending digest entries: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spoken := entries
|
||||||
|
extra := 0
|
||||||
|
if len(spoken) > maxDigestSpokenItems {
|
||||||
|
spoken = entries[:maxDigestSpokenItems]
|
||||||
|
extra = len(entries) - maxDigestSpokenItems
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
maxSev := 0
|
||||||
|
for i, e := range spoken {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(" · ")
|
||||||
|
}
|
||||||
|
b.WriteString(e.Body)
|
||||||
|
if e.Severity > maxSev {
|
||||||
|
maxSev = e.Severity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extra > 0 {
|
||||||
|
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||||
|
}
|
||||||
|
body := b.String()
|
||||||
|
// The adjective declines with the noun, so the count picks the whole
|
||||||
|
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
|
||||||
|
// отложенных уведомлений.
|
||||||
|
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
|
||||||
|
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
|
||||||
|
|
||||||
|
cand := loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||||
|
Severity: loop.Severity(maxSev),
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
||||||
|
t.cachePhrase(pn)
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch digest bundle: %v", err)
|
||||||
|
return // leave entries pending; retried next tick
|
||||||
|
}
|
||||||
|
ids := make([]int64, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
ids[i] = e.ID
|
||||||
|
}
|
||||||
|
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
||||||
|
log.Printf("tick: drain digest entries: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// mavend/tick_morning.go — the morning checklist and the day plan.
|
||||||
|
//
|
||||||
|
// Split out of tick.go, move-only (Vikunja #422). One window per configured
|
||||||
|
// routine, nudging once at the end for what is still open, plus the read
|
||||||
|
// surfaces /morning renders.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fireMorningRoutines checks each configured checklist against today's facts
|
||||||
|
// and dispatches a nag listing exactly what's still missing, at most once per
|
||||||
|
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
||||||
|
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
||||||
|
// concern — pulling it into the shared gather path would leak that mapping
|
||||||
|
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
||||||
|
// operator text (item labels joined), not LLM-phrased, same rationale as
|
||||||
|
// cron routines: deterministic, can't hallucinate a checklist item.
|
||||||
|
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
if len(t.morningRoutines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
|
||||||
|
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||||
|
body := morningNudgeBody(cand)
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||||
|
Severity: loop.Severity(cand.Routine.Severity),
|
||||||
|
State: state,
|
||||||
|
},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||||
|
// decision) and morningStatus (read-only query) so the two paths can never
|
||||||
|
// disagree about what evidence exists.
|
||||||
|
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
||||||
|
keys := make(map[string]struct{})
|
||||||
|
for _, r := range t.morningRoutines {
|
||||||
|
for _, it := range r.Items {
|
||||||
|
keys[it.FactKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
facts := make(map[string]store.Fact, len(keys))
|
||||||
|
for k := range keys {
|
||||||
|
f, err := t.store.LatestFact(ctx, k)
|
||||||
|
if err == nil {
|
||||||
|
facts[k] = f
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != store.ErrNoFact {
|
||||||
|
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return facts
|
||||||
|
}
|
||||||
|
|
||||||
|
// morningStatus is the read-only "what's missing" query the web UI (and
|
||||||
|
// eventually a voice query) calls. Pure recompute over the current facts —
|
||||||
|
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
||||||
|
// "state right now," not "should we nag."
|
||||||
|
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
||||||
|
if len(t.morningRoutines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
||||||
|
for _, r := range t.morningRoutines {
|
||||||
|
st := morning.Evaluate(r, facts, now)
|
||||||
|
done := make(map[string]bool, len(st.Completed))
|
||||||
|
for _, it := range st.Completed {
|
||||||
|
done[it.Key] = true
|
||||||
|
}
|
||||||
|
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
||||||
|
for i, it := range r.Items {
|
||||||
|
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
||||||
|
}
|
||||||
|
out = append(out, ipc.MorningRoutineStatus{
|
||||||
|
Name: r.Name,
|
||||||
|
Active: st.Active,
|
||||||
|
WindowStart: r.WindowStart,
|
||||||
|
WindowEnd: r.WindowEnd,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||||
|
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||||
|
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||||
|
//
|
||||||
|
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||||
|
// unprompted delivery in maven stays with the morning nudge and the
|
||||||
|
// dispatcher's policy.
|
||||||
|
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
var events []morning.PlanEntry
|
||||||
|
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: calendar events: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range facts {
|
||||||
|
events = append(events, morning.PlanEntry{
|
||||||
|
At: f.Ts,
|
||||||
|
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||||
|
// fact value carries would say it twice.
|
||||||
|
Text: calendar.FactSummary(f.Value),
|
||||||
|
Kind: morning.PlanEvent,
|
||||||
|
// Provenance below a calendar read (an ambient relay, #126) is
|
||||||
|
// hedged rather than recited as fact.
|
||||||
|
Uncertain: f.Confidence < 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminders []morning.PlanEntry
|
||||||
|
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range rems {
|
||||||
|
if r.Status != store.ReminderPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fire := r.NextFireTs
|
||||||
|
if fire.IsZero() {
|
||||||
|
fire = r.FireTs
|
||||||
|
}
|
||||||
|
reminders = append(reminders, morning.PlanEntry{
|
||||||
|
At: fire,
|
||||||
|
Text: r.Text(),
|
||||||
|
Kind: morning.PlanReminder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var checklistFacts map[string]store.Fact
|
||||||
|
if len(t.morningRoutines) > 0 {
|
||||||
|
checklistFacts = t.gatherMorningFacts(ctx)
|
||||||
|
}
|
||||||
|
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||||
|
|
||||||
|
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||||
|
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||||
|
for i, it := range plan.Items {
|
||||||
|
out.Items[i] = ipc.DayPlanItem{
|
||||||
|
At: it.At,
|
||||||
|
Text: it.Text,
|
||||||
|
Kind: string(it.Kind),
|
||||||
|
Uncertain: it.Uncertain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// mavend/tick_routines.go — pattern detection and the routines that fire.
|
||||||
|
//
|
||||||
|
// Split out of tick.go, move-only (Vikunja #422). Configured routines, the
|
||||||
|
// routines he accepted on /routines, and the tick-side detector that proposes
|
||||||
|
// new ones.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
|
"github.com/kami/maven/internal/routine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectPatterns runs the pattern detector proactively over every
|
||||||
|
// action+object pair that has ever produced an event, independent of
|
||||||
|
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||||
|
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||||
|
// own schedule reading accumulated history, not only as a side effect of a
|
||||||
|
// live voice turn.
|
||||||
|
//
|
||||||
|
// Idempotence and noise are handled by the store, not here — this function
|
||||||
|
// is safe to call every tick:
|
||||||
|
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||||
|
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||||
|
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||||
|
// already has a row — in ANY status — produces no second row and no log
|
||||||
|
// spam beyond the one line at genuine creation.
|
||||||
|
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||||
|
// status in place; the row is never deleted. So the same Lookup check
|
||||||
|
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||||
|
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||||
|
// calling the same shared path the voice route already used.
|
||||||
|
//
|
||||||
|
// By default this only creates a row for the /routines page to show: it does
|
||||||
|
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||||
|
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||||
|
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||||
|
// for the restraints that apply even then. A proposal only starts producing
|
||||||
|
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||||
|
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: distinct event pairs: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announced := false
|
||||||
|
for _, p := range pairs {
|
||||||
|
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||||
|
}
|
||||||
|
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||||
|
// One announcement per tick at most, whatever the scan turned up. The
|
||||||
|
// rest are on /routines; they are not lost, they are just not shouted.
|
||||||
|
// Nor are they queued: the row now exists, so no later tick re-detects
|
||||||
|
// them and they are never announced. See announceProposal.
|
||||||
|
if announced {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
announced = t.announceProposal(ctx, r, now, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceProposal offers a freshly inferred routine through the ordinary
|
||||||
|
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||||
|
// something was actually sent.
|
||||||
|
//
|
||||||
|
// Everything here is restraint. The feature is off unless configured; when on
|
||||||
|
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||||
|
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||||
|
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||||
|
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||||
|
// real send, but the proposal row already exists, so the next tick will not
|
||||||
|
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||||
|
// he reads it on /routines instead, which is the whole point of the page.
|
||||||
|
//
|
||||||
|
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
||||||
|
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
||||||
|
// that first proposes it. Combined with one announcement per tick, the first
|
||||||
|
// tick over a populated history announces one pattern and permanently silences
|
||||||
|
// every other pattern found in the same pass. That is the intent, not an
|
||||||
|
// oversight — an inferred routine is not worth a second attempt at his
|
||||||
|
// attention, and /routines lists all of them. So the cooldown does not drain a
|
||||||
|
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
||||||
|
// later ticks. If it should ever become "one per day until each is mentioned",
|
||||||
|
// that needs a queue rather than this counter.
|
||||||
|
//
|
||||||
|
// Cooldown gets its default here as well as in applyDefaults. That is
|
||||||
|
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
||||||
|
// and an unspaced announcer is not what those tests mean to exercise.
|
||||||
|
//
|
||||||
|
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||||
|
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||||
|
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||||
|
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||||
|
if !t.proposalCfg.AnnounceProposals() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||||
|
if cooldown <= 0 {
|
||||||
|
cooldown = config.DefaultProposalCooldown
|
||||||
|
}
|
||||||
|
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||||
|
if !loop.Gate(state, rule) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body := pattern.PhraseRoutine(r)
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(sent) == 0 {
|
||||||
|
return false // routing dropped it — /routines still has it.
|
||||||
|
}
|
||||||
|
t.lastProposalAt = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||||
|
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||||
|
// in config.Load, so this is a pure field copy.
|
||||||
|
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
||||||
|
if len(rc) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]routine.Routine, len(rc))
|
||||||
|
for i, r := range rc {
|
||||||
|
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
||||||
|
// last fire. Each is delivered as a nudge through the normal routing table
|
||||||
|
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
||||||
|
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
||||||
|
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
||||||
|
// already advanced the last-fire time so a transient failure drops that fire
|
||||||
|
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
||||||
|
// no repeat-til-ack).
|
||||||
|
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
||||||
|
Severity: loop.Severity(r.Severity),
|
||||||
|
State: state,
|
||||||
|
},
|
||||||
|
Body: r.Body,
|
||||||
|
Summary: r.Body,
|
||||||
|
}
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireAcceptedRoutines nudges about the routines the user accepted, once per
|
||||||
|
// interval (Vikunja #366). Accepting used to create a single reminder, so a
|
||||||
|
// non-weekly routine fired once and went quiet forever; the schedule lives in
|
||||||
|
// the proposed_routines row now and the loop re-reads it every tick.
|
||||||
|
//
|
||||||
|
// A routine is a care-class nudge and goes through the restraint gate like any
|
||||||
|
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
|
||||||
|
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
|
||||||
|
// goes out on the next tick that the gate allows — one nudge, held, not dropped
|
||||||
|
// and not repeated.
|
||||||
|
//
|
||||||
|
// The body is literal text built from the detected action and object, not
|
||||||
|
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
|
||||||
|
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
rows, err := t.store.ListAcceptedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: list accepted routines: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted := make([]routine.Accepted, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.AcceptedTs == nil {
|
||||||
|
continue // accepted before the schedule column existed — no clock to start from.
|
||||||
|
}
|
||||||
|
accepted = append(accepted, routine.Accepted{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Action + " " + r.Object,
|
||||||
|
IntervalDays: r.IntervalDays,
|
||||||
|
Accepted: *r.AcceptedTs,
|
||||||
|
LastFired: r.LastFiredTs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range routine.DueAccepted(accepted, now) {
|
||||||
|
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
|
||||||
|
if !loop.Gate(state, rule) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := "пора: " + a.Name
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(sent) == 0 {
|
||||||
|
continue // routing dropped it — leave it due.
|
||||||
|
}
|
||||||
|
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
|
||||||
|
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -584,7 +584,7 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
markPresent(t, st, ctx, now)
|
markPresent(t, st, ctx, now)
|
||||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
|
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
|
||||||
t.Fatalf("seed service_down: %v", err)
|
t.Fatalf("seed service_down: %v", err)
|
||||||
}
|
}
|
||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Which subject is this question about — the weather, the house, the LAN, what
|
||||||
|
// needs looking at, or none of them. Third of the three mechanisms replacing hand-written Russian
|
||||||
|
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
|
||||||
|
// sets that can be finished and internal/morph answers the grammar questions;
|
||||||
|
// this is for the sets that can never be finished, because "is this about the
|
||||||
|
// house" is a question about meaning and no word list closes it.
|
||||||
|
//
|
||||||
|
// The recognisers this replaces were each built the same way: a stem list,
|
||||||
|
// an ask test, a device-noun list, and a bail-out list for the neighbouring
|
||||||
|
// topic. Every one of their own comments admits the shape. isHomeQuery excluded
|
||||||
|
// "погод", "на улице" and "прогноз" by hand because "какая температура на улице"
|
||||||
|
// and "какая температура в доме" share their only content word. isNetworkQuery
|
||||||
|
// matched "сети" as a whole token because the substring lives inside "посетил",
|
||||||
|
// so "сколько машин я посетил" read as a request to scan the LAN. Those are not
|
||||||
|
// bugs in the lists, they are the lists being asked to do semantics.
|
||||||
|
//
|
||||||
|
// So the seeds decide, the same way the personal boundary does
|
||||||
|
// (personalboundary.go), against the same embedder and the same query vector the
|
||||||
|
// turn already carries. One difference in the gate, and it is deliberate. The
|
||||||
|
// boundary claims on the sign of the difference, because there a false claim
|
||||||
|
// costs one honest "не знаю". Here a false claim runs a network scan, or names a
|
||||||
|
// capability as off on a box where it is simply not the subject — so a topic has
|
||||||
|
// to win by a margin, and the losing side of a thin call falls through to the
|
||||||
|
// next query source, which is what the narrow regexes were achieving.
|
||||||
|
//
|
||||||
|
// A fourth subject joined on the same day: isAttentionQuery, "что требует
|
||||||
|
// внимания", which is Praxis's operational state and reached the web search
|
||||||
|
// before the source existed (Vikunja #475).
|
||||||
|
//
|
||||||
|
// The regexes stay as the offline floor, unchanged, for a handler with no
|
||||||
|
// embedder or a turn whose vector never got computed. They are allowed to remain
|
||||||
|
// narrow now precisely because they are no longer the only answer.
|
||||||
|
|
||||||
|
// topicLabel — the subjects worth telling apart, plus the one that means none of
|
||||||
|
// them. topicOther is a real class and not a threshold: a question needs
|
||||||
|
// somewhere to lose TO, and "интернет не работает" losing to a set that contains
|
||||||
|
// complaints is a better statement than it failing a number.
|
||||||
|
type topicLabel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
topicWeather topicLabel = "weather"
|
||||||
|
topicHome topicLabel = "home"
|
||||||
|
topicNetwork topicLabel = "network"
|
||||||
|
topicAttend topicLabel = "attention"
|
||||||
|
topicOther topicLabel = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
// topicMargin — how far a topic must clear the runner-up. Small, because the
|
||||||
|
// margins between neighbouring topics are small: measured on held-out
|
||||||
|
// utterances, a true weather question clears the home set by roughly 0.02 to
|
||||||
|
// 0.09 and the nearest wrong call sits under 0.01. It exists at all for the
|
||||||
|
// asymmetry named above — this gate spends a scan, so a coin-flip falls
|
||||||
|
// through rather than acts.
|
||||||
|
const topicMargin = 0.01
|
||||||
|
|
||||||
|
// topicSeedSets — frozen scoring data, like personalSeeds. Editing one moves a
|
||||||
|
// recogniser and has to be re-measured against TestONNXTopics, not eyeballed.
|
||||||
|
//
|
||||||
|
// Each set covers the phrasings its old regex covered, INCLUDING the ones it
|
||||||
|
// needed a bail-out list for: the weather set carries "какая температура на
|
||||||
|
// улице" and the home set "какая температура в доме", so the pair that forced
|
||||||
|
// isHomeQuery to exclude weather words by hand is now just two seeds sitting on
|
||||||
|
// their own sides.
|
||||||
|
var topicSeedSets = map[topicLabel][]string{
|
||||||
|
topicWeather: {
|
||||||
|
"какая сегодня погода",
|
||||||
|
"какая температура на улице",
|
||||||
|
"будет дождь сегодня",
|
||||||
|
"на улице холодно",
|
||||||
|
"прогноз погоды на завтра",
|
||||||
|
"сколько градусов сейчас",
|
||||||
|
"what is the weather like",
|
||||||
|
"is it going to rain today",
|
||||||
|
},
|
||||||
|
topicHome: {
|
||||||
|
"что включено в доме",
|
||||||
|
"какая температура в доме",
|
||||||
|
"свет в квартире горит",
|
||||||
|
"сколько лампочек включено дома",
|
||||||
|
"что у меня дома с датчиками",
|
||||||
|
"умный дом что сейчас работает",
|
||||||
|
"розетки в доме включены",
|
||||||
|
"what is on in the house",
|
||||||
|
},
|
||||||
|
topicNetwork: {
|
||||||
|
"какие устройства в сети",
|
||||||
|
"кто в сети сейчас",
|
||||||
|
"просканируй локальную сеть",
|
||||||
|
"сколько машин в сетке",
|
||||||
|
"покажи хосты в сети",
|
||||||
|
"какие адреса заняты в локальной сети",
|
||||||
|
"кто подключён к вайфаю",
|
||||||
|
"what devices are on the network",
|
||||||
|
},
|
||||||
|
topicAttend: {
|
||||||
|
"что требует внимания",
|
||||||
|
"что не так сейчас",
|
||||||
|
"на что мне посмотреть",
|
||||||
|
"что важное я пропустил",
|
||||||
|
"есть что-то срочное",
|
||||||
|
"что там висит нерешённое",
|
||||||
|
// With a thing named. Every other seed asks in the abstract, and the
|
||||||
|
// attention question he actually asks names his services or his
|
||||||
|
// projects.
|
||||||
|
"что не так с сервисами",
|
||||||
|
"что там с моими проектами",
|
||||||
|
"what needs attention",
|
||||||
|
"what needs looking at right now",
|
||||||
|
},
|
||||||
|
topicOther: {
|
||||||
|
// Complaints, which are not requests to scan or to read the house.
|
||||||
|
// isNetworkQuery's comment names this one: a scan she runs unasked is
|
||||||
|
// the noisy behaviour the bounds exist to prevent.
|
||||||
|
"интернет не работает",
|
||||||
|
"вайфай тормозит",
|
||||||
|
"свет погас",
|
||||||
|
// Statements. "я дома" was the reason isHomeQuery needed an ask test.
|
||||||
|
"я дома",
|
||||||
|
"я уже дома",
|
||||||
|
// The collision that made "сети" a whole-token match.
|
||||||
|
"сколько машин я посетил",
|
||||||
|
"сколько домов мы посмотрели",
|
||||||
|
// Ordinary questions, his and the world's, so a topic has something
|
||||||
|
// real to lose to rather than an arbitrary floor.
|
||||||
|
"почему небо синее",
|
||||||
|
"какая столица франции",
|
||||||
|
"что я говорил про бэкапы",
|
||||||
|
"что у меня сегодня по календарю",
|
||||||
|
"напомни мне позвонить маме",
|
||||||
|
// An attention question is about the state of his things; this is not.
|
||||||
|
"что ты умеешь",
|
||||||
|
"what did i say about backups",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicIndex holds the embedded seeds. Zero value is usable and means "not
|
||||||
|
// loaded yet"; a handler built without an embedder never loads and every caller
|
||||||
|
// uses its own floor instead.
|
||||||
|
type topicIndex struct {
|
||||||
|
once sync.Once
|
||||||
|
vecs map[topicLabel][][]float32
|
||||||
|
loaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// load embeds every set once per process, on the QUERY side — a question
|
||||||
|
// compared with a question, for the reason personalBoundary.load gives.
|
||||||
|
func (x *topicIndex) load(ctx context.Context, emb router.Embedder) {
|
||||||
|
x.once.Do(func() {
|
||||||
|
if emb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vecs := make(map[topicLabel][][]float32, len(topicSeedSets))
|
||||||
|
for label, seeds := range topicSeedSets {
|
||||||
|
out := make([][]float32, 0, len(seeds))
|
||||||
|
for _, s := range seeds {
|
||||||
|
v, err := router.EmbedQuery(ctx, emb, s)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: topic seeds unavailable (%v); falling back to keyword matching", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
vecs[label] = out
|
||||||
|
}
|
||||||
|
x.vecs, x.loaded = vecs, true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// best returns the nearest label, how far it cleared the runner-up, and whether
|
||||||
|
// the seeds answered at all. ok is false when they are not loaded, which is the
|
||||||
|
// caller's signal to use its floor.
|
||||||
|
func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok bool) {
|
||||||
|
if !x.loaded || len(vec) == 0 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
first, second := -1.0, -1.0
|
||||||
|
for l, seeds := range x.vecs {
|
||||||
|
top := -1.0
|
||||||
|
for _, s := range seeds {
|
||||||
|
if c := cosine(vec, s); c > top {
|
||||||
|
top = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case top > first:
|
||||||
|
label, first, second = l, top, first
|
||||||
|
case top > second:
|
||||||
|
second = top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return label, first - second, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// turnIsAbout — the recogniser every topic source calls. The seeds decide when
|
||||||
|
// the embedder is there, which is every deployed box; floor is the source's own
|
||||||
|
// keyword test, which answers when they are not.
|
||||||
|
//
|
||||||
|
// A topic that wins WITHOUT the margin is handed to the floor rather than
|
||||||
|
// claimed or dropped, and the near-miss is logged. That is the cascade shape
|
||||||
|
// again: the better test leads, the offline one always answers, and a thin call
|
||||||
|
// is exactly where the cheap high-precision test earns its place. Measured, the
|
||||||
|
// one held-out case that lands there is "вайфай опять отвалился", which reads as
|
||||||
|
// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is.
|
||||||
|
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
|
||||||
|
h.recall.topics.load(ctx, h.recall.embedder)
|
||||||
|
label, margin, ok := h.recall.topics.best(t.vec)
|
||||||
|
if !ok {
|
||||||
|
return floor(t.dec.Utterance)
|
||||||
|
}
|
||||||
|
if label != want {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if margin < topicMargin {
|
||||||
|
claimed := floor(t.dec.Utterance)
|
||||||
|
log.Printf("voice: %q reads as %s by only %.4f; the keyword floor says %v", t.dec.Utterance, want, margin, claimed)
|
||||||
|
return claimed
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTopicFloorAnswersWithoutSeeds — a handler with no embedder never loads the
|
||||||
|
// seeds, and every topic source has to keep working. This is the case that used
|
||||||
|
// to be the only one, so a regression here is all four recognisers going silent
|
||||||
|
// on a box with no embedder at all.
|
||||||
|
func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
|
||||||
|
h := &reactiveHandler{}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
utterance string
|
||||||
|
label topicLabel
|
||||||
|
floor func(string) bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"какая сегодня погода", topicWeather, isWeatherQuery, true},
|
||||||
|
{"что включено в доме?", topicHome, isHomeQuery, true},
|
||||||
|
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
|
||||||
|
{"что требует внимания?", topicAttend, isAttentionQuery, true},
|
||||||
|
{"почему небо синее", topicWeather, isWeatherQuery, false},
|
||||||
|
{"я дома", topicHome, isHomeQuery, false},
|
||||||
|
{"интернет не работает", topicNetwork, isNetworkQuery, false},
|
||||||
|
} {
|
||||||
|
turn := &queryTurn{dec: router.Decision{Utterance: tc.utterance}}
|
||||||
|
if got := h.turnIsAbout(context.Background(), turn, tc.label, tc.floor); got != tc.want {
|
||||||
|
t.Errorf("turnIsAbout(%q, %s) = %v, want %v", tc.utterance, tc.label, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestONNXTopics — the number that matters, scored against the embedder homesrv
|
||||||
|
// actually runs. Opt-in via MAVEN_ONNX_LIB, like TestONNXPersonalBoundary.
|
||||||
|
//
|
||||||
|
// Every case is held out: none of these strings is a seed. It asserts what the
|
||||||
|
// gate does, not what the raw scorer says — a label under topicMargin is handed
|
||||||
|
// to the keyword floor, and one case turns on exactly that.
|
||||||
|
//
|
||||||
|
// The first three rows are the collisions the old regexes needed hand-written
|
||||||
|
// bail-outs for: the temperature pair that made isHomeQuery exclude weather
|
||||||
|
// words, and the "посетил" substring that made isNetworkQuery match "сети" as a
|
||||||
|
// whole token.
|
||||||
|
func TestONNXTopics(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
|
||||||
|
want topicLabel
|
||||||
|
floor func(string) bool
|
||||||
|
}{
|
||||||
|
{"какая температура на улице?", topicWeather, isWeatherQuery},
|
||||||
|
{"какая температура в доме?", topicHome, isHomeQuery},
|
||||||
|
{"сколько машин я посетил?", topicOther, nil},
|
||||||
|
{"сколько сейчас градусов", topicWeather, isWeatherQuery},
|
||||||
|
{"дождь будет вечером?", topicWeather, isWeatherQuery},
|
||||||
|
{"тепло сегодня на улице?", topicWeather, isWeatherQuery},
|
||||||
|
{"свет на кухне включен?", topicHome, isHomeQuery},
|
||||||
|
{"что сейчас включено дома", topicHome, isHomeQuery},
|
||||||
|
{"датчики в квартире что показывают", topicHome, isHomeQuery},
|
||||||
|
{"просканируй сеть", topicNetwork, isNetworkQuery},
|
||||||
|
{"сколько устройств в локальной сети", topicNetwork, isNetworkQuery},
|
||||||
|
{"кто сейчас в сетке", topicNetwork, isNetworkQuery},
|
||||||
|
// The case the margin exists for. It reads as network by 0.0055, and
|
||||||
|
// isNetworkQuery says no, so it stays the complaint it is — a scan she
|
||||||
|
// runs unasked is the behaviour the bounds prevent.
|
||||||
|
{"вайфай опять отвалился", topicOther, isNetworkQuery},
|
||||||
|
{"я уже приехал домой", topicOther, nil},
|
||||||
|
{"что я говорил про погоду в москве", topicOther, nil},
|
||||||
|
{"напомни полить цветы", topicOther, nil},
|
||||||
|
{"что требует моего внимания сейчас", topicAttend, isAttentionQuery},
|
||||||
|
{"что не так с базой данных", topicAttend, isAttentionQuery},
|
||||||
|
{"есть что-то срочное на сегодня", topicAttend, isAttentionQuery},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||||
|
ctx := context.Background()
|
||||||
|
h.recall.topics.load(ctx, emb)
|
||||||
|
if !h.recall.topics.loaded {
|
||||||
|
t.Fatal("topic seeds did not load")
|
||||||
|
}
|
||||||
|
right := 0
|
||||||
|
for _, tc := range cases {
|
||||||
|
vec, err := router.EmbedQuery(ctx, emb, tc.utterance)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("embed %q: %v", tc.utterance, err)
|
||||||
|
}
|
||||||
|
label, margin, ok := h.recall.topics.best(vec)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("best(%q) not ok", tc.utterance)
|
||||||
|
}
|
||||||
|
// What the gate would do, which is the thing under test: a label that
|
||||||
|
// does not clear the margin is handed to that source's keyword floor.
|
||||||
|
got := label
|
||||||
|
if margin < topicMargin {
|
||||||
|
got = topicOther
|
||||||
|
if tc.floor != nil && tc.floor(tc.utterance) {
|
||||||
|
got = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == tc.want {
|
||||||
|
right++
|
||||||
|
} else {
|
||||||
|
t.Errorf("%q: %s by %.4f, want %s", tc.utterance, label, margin, tc.want)
|
||||||
|
}
|
||||||
|
t.Logf(" %-40s -> %-8s margin %.4f", tc.utterance, label, margin)
|
||||||
|
}
|
||||||
|
t.Logf("topics: %d/%d", right, len(cases))
|
||||||
|
}
|
||||||
+44
-21
@@ -55,7 +55,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/crawl"
|
"github.com/kami/maven/internal/crawl"
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/lexicon"
|
||||||
"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/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -72,10 +72,16 @@ import (
|
|||||||
// safe (the wired stt/tts/router/api all are); called from per-conn
|
// safe (the wired stt/tts/router/api all are); called from per-conn
|
||||||
// goroutines on the voice.Server.
|
// goroutines on the voice.Server.
|
||||||
type reactiveHandler struct {
|
type reactiveHandler struct {
|
||||||
stt stt.Transcriber
|
stt stt.Transcriber
|
||||||
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)
|
|
||||||
|
// recall — the note-and-fact recall subsystem: the embedder, the vector
|
||||||
|
// store it writes into, the personal boundary, and the two numbers that
|
||||||
|
// gate an answer. Grouped rather than spread across the handler because a
|
||||||
|
// handler that recalls needs all five and a handler that does not needs
|
||||||
|
// none of them (Vikunja #433, docs/handler-wiring.md).
|
||||||
|
recall recallWiring
|
||||||
// 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.
|
||||||
@@ -119,18 +125,8 @@ type reactiveHandler struct {
|
|||||||
weatherProvider weather.Provider
|
weatherProvider weather.Provider
|
||||||
weatherLocation string // default location for weather queries
|
weatherLocation string // default location for weather queries
|
||||||
|
|
||||||
memStore memory.Store
|
|
||||||
dataStore *store.Store // direct store access for event extraction + pattern detection
|
dataStore *store.Store // direct store access for event extraction + pattern detection
|
||||||
|
|
||||||
// queryMinScore — the note-recall confidence gate. Top cosine below this ⇒
|
|
||||||
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not
|
|
||||||
// load-bearing math (same posture as the presence thresholds). Set by
|
|
||||||
// wireVoice from VoiceConfig; default 0.55.
|
|
||||||
queryMinScore float64
|
|
||||||
// queryMinMargin — the second half of that gate: how far the top hit must
|
|
||||||
// beat the runner-up. 0 ⇒ margin off.
|
|
||||||
queryMinMargin float64
|
|
||||||
|
|
||||||
// timeParser — used as a fallback for stage-0 reminder grammar matches
|
// timeParser — used as a fallback for stage-0 reminder grammar matches
|
||||||
// (where the extractor didn't run). Shared with the router's extractor.
|
// (where the extractor didn't run). Shared with the router's extractor.
|
||||||
// The production dateparser will replace StubDateTimeParser here too.
|
// The production dateparser will replace StubDateTimeParser here too.
|
||||||
@@ -157,6 +153,7 @@ type reactiveHandler struct {
|
|||||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
|
||||||
pending *pendingAct
|
pending *pendingAct
|
||||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||||||
@@ -216,9 +213,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 +248,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
|
||||||
@@ -295,6 +292,22 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
return withNotice(expiredNotice, reply)
|
return withNotice(expiredNotice, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4d. spoken correction — "нет, это была заметка" points at the previous
|
||||||
|
// turn and names what it should have been (repair.go). Before routing,
|
||||||
|
// like the confirm and clarify turns: routing the correction as a fresh
|
||||||
|
// utterance files the correction itself instead of fixing anything.
|
||||||
|
if reply, handled := h.resolveRepair(ctx, text); handled {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
|
||||||
|
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||||
|
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||||
|
// and keeps routing.
|
||||||
|
if reply, handled := h.resolveCandidate(ctx, text); handled {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
|
|
||||||
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
||||||
// previous turn instead (continuation.go): the intent is the part it is
|
// previous turn instead (continuation.go): the intent is the part it is
|
||||||
// missing, so no amount of routing recovers it, and the model's guess
|
// missing, so no amount of routing recovers it, and the model's guess
|
||||||
@@ -347,11 +360,21 @@ 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 reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
|
if question, asked := h.askClarify(ctx, dec); asked {
|
||||||
return withNotice(expiredNotice, question)
|
return withNotice(expiredNotice, question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember what this turn was routed as, so the next utterance can correct
|
||||||
|
// it. Only turns she acts on: a clarify asked instead of acting, so there
|
||||||
|
// is nothing yet to be wrong about.
|
||||||
|
if !dec.Clarify {
|
||||||
|
h.recordTurn(text, dec.Intent)
|
||||||
|
}
|
||||||
|
|
||||||
// 8. action — execute the decision's intent. errors here surface as
|
// 8. action — execute the decision's intent. errors here surface as
|
||||||
// short reply text (the user wants to know the action didn't land);
|
// short reply text (the user wants to know the action didn't land);
|
||||||
// the round-trip stays alive.
|
// the round-trip stays alive.
|
||||||
@@ -445,8 +468,8 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
|||||||
// this arm was fixed for, so say what she can do instead.
|
// this arm was fixed for, so say what she can do instead.
|
||||||
return onlyNearDaysReply
|
return onlyNearDaysReply
|
||||||
}
|
}
|
||||||
dow := ruWeekdays[day.Weekday()]
|
dow := lexicon.Weekday(int(day.Weekday()))
|
||||||
month := ruMonths[day.Month()-1]
|
month := lexicon.MonthGenitive(int(day.Month()))
|
||||||
return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year())
|
return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year())
|
||||||
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
||||||
return "присутствие пока не подключено к голосовому запросу."
|
return "присутствие пока не подключено к голосовому запросу."
|
||||||
|
|||||||
+159
-30
@@ -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,25 +256,24 @@ 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) -----
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
stt: transcriber,
|
stt: transcriber,
|
||||||
tts: synthesizer,
|
tts: synthesizer,
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
api: coreAPI,
|
||||||
api: coreAPI,
|
tools: exec,
|
||||||
tools: exec,
|
matcher: matcher,
|
||||||
matcher: matcher,
|
replier: replier,
|
||||||
replier: replier,
|
phraser: phr,
|
||||||
phraser: phr,
|
now: time.Now,
|
||||||
now: time.Now,
|
feedsOn: cfg.Feeds != nil,
|
||||||
feedsOn: cfg.Feeds != nil,
|
home: w.home,
|
||||||
home: w.home,
|
netscan: w.netscan,
|
||||||
netscan: w.netscan,
|
|
||||||
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||||
// capability, and capabilities are off unless configured.
|
// capability, and capabilities are off unless configured.
|
||||||
crawler: onDemandCrawler(cfg),
|
crawler: onDemandCrawler(cfg),
|
||||||
@@ -260,18 +282,21 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
search: wireSearch(cfg),
|
search: wireSearch(cfg),
|
||||||
// nil unless a `kiwix` block names a server. Same swap-aware client the
|
// nil unless a `kiwix` block names a server. Same swap-aware client the
|
||||||
// router and replier use, so the rewriter follows a model swap.
|
// router and replier use, so the rewriter follows a model swap.
|
||||||
kiwix: wireKiwix(cfg, llmClient),
|
kiwix: wireKiwix(cfg, llmClient),
|
||||||
weatherProvider: weatherProvider,
|
weatherProvider: weatherProvider,
|
||||||
weatherLocation: weatherLocation,
|
weatherLocation: weatherLocation,
|
||||||
memStore: memStore,
|
recall: recallWiring{
|
||||||
|
embedder: emb,
|
||||||
|
memStore: memStore,
|
||||||
|
minScore: cfg.Voice.QueryMinScore,
|
||||||
|
minMargin: cfg.Voice.QueryMinMargin,
|
||||||
|
},
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
dialogueSessions: dialogueSessions,
|
dialogueSessions: dialogueSessions,
|
||||||
clarifyStore: clarifyStore,
|
clarifyStore: clarifyStore,
|
||||||
// 0 here (unset config) ⇒ the dialogue default.
|
// 0 here (unset config) ⇒ the dialogue default.
|
||||||
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
||||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||||
queryMinScore: cfg.Voice.QueryMinScore,
|
|
||||||
queryMinMargin: cfg.Voice.QueryMinMargin,
|
|
||||||
timeParser: timeParser,
|
timeParser: timeParser,
|
||||||
ecosystem: eco,
|
ecosystem: eco,
|
||||||
}
|
}
|
||||||
@@ -291,7 +316,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
|
||||||
}
|
}
|
||||||
@@ -324,7 +384,23 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||||
// is an agenda question and must not.
|
// is an agenda question and must not.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
|
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||||
|
// routed system and answered "пока не умею" (Vikunja #474).
|
||||||
|
grammars = append(grammars, router.FeedQueryGrammar())
|
||||||
|
// The list side of the same exposure: a phrasing with no possessive in it
|
||||||
|
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||||
|
grammars = append(grammars, router.TaskListGrammar())
|
||||||
|
grammars = append(grammars, router.ListGrammars()...)
|
||||||
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())
|
||||||
|
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||||
|
// last overall because it matches on the first word alone: "расскажи про
|
||||||
|
// X" is a world question the model called a fact (Vikunja #498).
|
||||||
|
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
Classifier: cls,
|
Classifier: cls,
|
||||||
@@ -338,11 +414,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 +466,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 +518,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,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalendarStepsAsideForWeather — the defect (Vikunja #474). "какая сегодня
|
||||||
|
// погода в Москве?" answered "на 02.08.2026 ничего нет.": the calendar matches
|
||||||
|
// on a day word alone, and it sits above the weather source.
|
||||||
|
func TestCalendarStepsAsideForWeather(t *testing.T) {
|
||||||
|
h, api := contQueryHandler()
|
||||||
|
for _, u := range []string{
|
||||||
|
"какая сегодня погода в Москве?",
|
||||||
|
"будет дождь завтра?",
|
||||||
|
"сколько градусов сегодня?",
|
||||||
|
} {
|
||||||
|
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||||
|
}); ok {
|
||||||
|
t.Errorf("the calendar claimed %q with %q", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api.events != 0 {
|
||||||
|
t.Errorf("CalendarEvents called %d times for weather questions, want 0", api.events)
|
||||||
|
}
|
||||||
|
// The agenda question it exists for still reaches it.
|
||||||
|
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что у меня сегодня?"},
|
||||||
|
}); !ok {
|
||||||
|
t.Fatal("the calendar stopped answering the agenda question")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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.
|
||||||
|
//
|
||||||
|
// The wording lives in fallbacks_ru_v1.json and is fixed there, not picked from
|
||||||
|
// variants: this sentence names one specific gap and must not drift into a
|
||||||
|
// general "I don't know".
|
||||||
|
func worldGap() string { return phraser.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:
|
||||||
|
// The resident phraser answers this call with its fallback text and the
|
||||||
|
// error together. Drop the text: these callers hold the passage itself
|
||||||
|
// and read it back better than "вот что я нашла: <passage>" does.
|
||||||
|
log.Printf("voice: %s: phrase: %v", name, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
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 !phraser.IsUnknownFallback(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-21
@@ -140,6 +140,10 @@ type poller struct {
|
|||||||
wgIface string
|
wgIface string
|
||||||
wgCmd string
|
wgCmd string
|
||||||
|
|
||||||
|
// kumaSeen — monitor name → state as of the last poll, so a monitor that
|
||||||
|
// disappears from the gauge can be marked unknown instead of staying down.
|
||||||
|
kumaSeen map[string]string
|
||||||
|
|
||||||
// zen is nil unless a token file was configured — money tracking is a
|
// zen is nil unless a token file was configured — money tracking is a
|
||||||
// capability, off by default like weather and telegram.
|
// capability, off by default like weather and telegram.
|
||||||
zen *zenmoney.Client
|
zen *zenmoney.Client
|
||||||
@@ -333,50 +337,96 @@ func maxSeverity(a netdataAlarms) string {
|
|||||||
return sev
|
return sev
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
|
// ---- kuma: monitor_status gauge → one fact per monitor ---------------------
|
||||||
|
|
||||||
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
||||||
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
|
// V is 1=up 0=down 2=pending 3=maintenance. We write one fact per monitor,
|
||||||
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
|
// keyed `service_down:<monitor name>`, because the nudge has to say WHICH
|
||||||
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
|
// service is down. The aggregate this used to write could not, which is why
|
||||||
// only needs "something is down".
|
// the rule shipped disabled.
|
||||||
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
var (
|
||||||
|
kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
||||||
|
kumaName = regexp.MustCompile(`monitor_name="([^"]*)"`)
|
||||||
|
)
|
||||||
|
|
||||||
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
||||||
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
down, seen := kumaAnyDown(body)
|
states := kumaMonitors(body)
|
||||||
if !seen {
|
if len(states) == 0 {
|
||||||
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
||||||
}
|
}
|
||||||
val := "up"
|
var firstErr error
|
||||||
if down {
|
for name, val := range states {
|
||||||
val = "down"
|
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, val, now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err // one bad monitor must not blind the rest
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
|
// A monitor deleted in kuma stops appearing in the gauge, and its last fact
|
||||||
|
// would otherwise read "down" forever. Mark it unknown, which no rule fires
|
||||||
|
// on. The seen-set is in memory, so a restart forgets it — harmless, since
|
||||||
|
// the next poll that still lacks the monitor says nothing new either.
|
||||||
|
for name := range p.kumaSeen {
|
||||||
|
if _, still := states[name]; !still {
|
||||||
|
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, "unknown", now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.kumaSeen = states
|
||||||
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
|
// kumaSource — the provenance the loop rule requires. Written here, checked in
|
||||||
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
|
// loop.ServiceDownRule; a poller under any other source cannot fire it.
|
||||||
// lines matched at all (wrong endpoint or auth rejected before the body).
|
const kumaSource = "poll:uptimekuma"
|
||||||
func kumaAnyDown(body []byte) (down, seen bool) {
|
|
||||||
|
// kumaFactKey — the fact key for one monitor. The suffix is the name he hears,
|
||||||
|
// so it stays as kuma spells it rather than being slugged into something else.
|
||||||
|
func kumaFactKey(name string) string { return "service_down:" + name }
|
||||||
|
|
||||||
|
// kumaMonitors parses kuma's Prometheus text into monitor name → state
|
||||||
|
// ("up"/"down"/"pending"/"maintenance"). An empty map means no monitor_status
|
||||||
|
// line matched at all (wrong endpoint, or auth rejected before the body).
|
||||||
|
// A line with no monitor_name label is skipped: a fact nobody can name is
|
||||||
|
// exactly the thing this replaced.
|
||||||
|
func kumaMonitors(body []byte) map[string]string {
|
||||||
|
out := make(map[string]string)
|
||||||
for _, line := range strings.Split(string(body), "\n") {
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||||
if m == nil {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen = true
|
nm := kumaName.FindStringSubmatch(m[1])
|
||||||
|
if nm == nil || strings.TrimSpace(nm[1]) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
v, err := strconv.ParseFloat(m[2], 64)
|
v, err := strconv.ParseFloat(m[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if v == 0 {
|
out[strings.TrimSpace(nm[1])] = kumaState(v)
|
||||||
down = true
|
}
|
||||||
}
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// kumaState — the gauge's four values. pending and maintenance are not "down":
|
||||||
|
// a monitor paused in kuma should silence that monitor, not page him.
|
||||||
|
func kumaState(v float64) string {
|
||||||
|
switch v {
|
||||||
|
case 0:
|
||||||
|
return "down"
|
||||||
|
case 1:
|
||||||
|
return "up"
|
||||||
|
case 2:
|
||||||
|
return "pending"
|
||||||
|
case 3:
|
||||||
|
return "maintenance"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
}
|
}
|
||||||
return down, seen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ---------------------------------------------------------------
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|||||||
+36
-12
@@ -35,22 +35,46 @@ func TestMaxSeverity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKumaAnyDown(t *testing.T) {
|
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
body string
|
name string
|
||||||
down, seen bool
|
body string
|
||||||
|
want map[string]string
|
||||||
}{
|
}{
|
||||||
{"", false, false},
|
{"empty body", "", map[string]string{}},
|
||||||
{`monitor_status{monitor_name="web"} 1`, false, true},
|
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
|
||||||
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
|
{
|
||||||
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
|
"one up one down",
|
||||||
{`# HELP monitor_status ...`, false, false},
|
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
|
||||||
|
map[string]string{"web": "up", "db": "down"},
|
||||||
|
},
|
||||||
|
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
|
||||||
|
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
|
||||||
|
{
|
||||||
|
"other labels do not hide the name",
|
||||||
|
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
|
||||||
|
map[string]string{"ci": "down"},
|
||||||
|
},
|
||||||
|
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
down, seen := kumaAnyDown([]byte(c.body))
|
t.Run(c.name, func(t *testing.T) {
|
||||||
if down != c.down || seen != c.seen {
|
got := kumaMonitors([]byte(c.body))
|
||||||
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
|
if len(got) != len(c.want) {
|
||||||
}
|
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
for k, v := range c.want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKumaFactKeyCarriesTheName(t *testing.T) {
|
||||||
|
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
|
||||||
|
t.Errorf("kumaFactKey = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -23,6 +23,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,15 +40,27 @@ func run(args []string) error {
|
|||||||
model := flag.String("model", "", "path to piper onnx model file")
|
model := flag.String("model", "", "path to piper onnx model file")
|
||||||
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
|
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
|
||||||
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
|
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
|
||||||
|
lexiconPath := flag.String("lexicon", "", "path to the pronunciation dictionary (json, name to spelling)")
|
||||||
|
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Read before the handler is built: a dictionary he asked for and that
|
||||||
|
// cannot be read is a startup failure, not a warning. Saying names wrong
|
||||||
|
// in silence is the thing it exists to stop.
|
||||||
|
lex, err := tts.LoadLexicon(*lexiconPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lex.Size() > 0 {
|
||||||
|
log.Printf("mavttsd: pronunciation dictionary: %d names from %s", lex.Size(), *lexiconPath)
|
||||||
|
}
|
||||||
|
|
||||||
var s worker.Synthesizer
|
var s worker.Synthesizer
|
||||||
if *piperBin != "" && *model != "" {
|
if *piperBin != "" && *model != "" {
|
||||||
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel)
|
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel, lex)
|
||||||
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
|
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("mavttsd: no piper/model specified, using stub handler")
|
log.Printf("mavttsd: no piper/model specified, using stub handler")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,3 +118,18 @@ func abs(n int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dictionary that ships in deploy/ must parse and must be non-empty. It is
|
||||||
|
// data, so nothing else would catch a trailing comma before the voice did.
|
||||||
|
func TestShippedLexiconLoads(t *testing.T) {
|
||||||
|
lex, err := tts.LoadLexicon(filepath.Join("..", "..", "deploy", "tts-lexicon.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy/tts-lexicon.json: %v", err)
|
||||||
|
}
|
||||||
|
if lex.Size() < 10 {
|
||||||
|
t.Errorf("shipped dictionary holds %d names, want the full list", lex.Size())
|
||||||
|
}
|
||||||
|
if got := lex.Apply("задача в Vikunja"); got == "задача в Vikunja" {
|
||||||
|
t.Error("the shipped dictionary did not rewrite a name it lists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,15 +19,20 @@ type piperHandler struct {
|
|||||||
configPath string
|
configPath string
|
||||||
espeakData string
|
espeakData string
|
||||||
tashkeelModel string
|
tashkeelModel string
|
||||||
|
// lexicon rewrites service ids and Latin names into the spelling the
|
||||||
|
// Russian voice reads correctly (Vikunja #458). Nil-safe: an unconfigured
|
||||||
|
// dictionary rewrites nothing.
|
||||||
|
lexicon *tts.Lexicon
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string) *piperHandler {
|
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string, lexicon *tts.Lexicon) *piperHandler {
|
||||||
return &piperHandler{
|
return &piperHandler{
|
||||||
piperPath: piperPath,
|
piperPath: piperPath,
|
||||||
modelPath: modelPath,
|
modelPath: modelPath,
|
||||||
configPath: modelPath + ".json",
|
configPath: modelPath + ".json",
|
||||||
espeakData: espeakData,
|
espeakData: espeakData,
|
||||||
tashkeelModel: tashkeelModel,
|
tashkeelModel: tashkeelModel,
|
||||||
|
lexicon: lexicon,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +72,14 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
|
|||||||
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
|
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := io.WriteString(stdin, req.Text); err != nil {
|
// The dictionary is applied here, at the last edge before the voice: every
|
||||||
|
// caller's text passes through this one point, and nothing upstream has to
|
||||||
|
// know how a name is spelled out loud.
|
||||||
|
text := req.Text
|
||||||
|
if h.lexicon != nil {
|
||||||
|
text = h.lexicon.Apply(text)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(stdin, text); err != nil {
|
||||||
stdin.Close()
|
stdin.Close()
|
||||||
stdout.Close()
|
stdout.Close()
|
||||||
_ = cmd.Wait()
|
_ = cmd.Wait()
|
||||||
|
|||||||
@@ -139,7 +139,10 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleAmbientAuth(t *testing.T) {
|
func TestHandleAmbientAuth(t *testing.T) {
|
||||||
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
// "завтра" so the meeting is in the future in every zone: the clock in the
|
||||||
|
// text is a local wall clock and posted_at is a Z instant, so a bare "10:00"
|
||||||
|
// is already stale on a box east of UTC and stores nothing (Vikunja #482).
|
||||||
|
body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||||
|
|
||||||
newReq := func(hdr, val string) *http.Request {
|
newReq := func(hdr, val string) *http.Request {
|
||||||
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||||
@@ -245,3 +248,31 @@ func TestHandleAmbientBadInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and
|
||||||
|
// the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup
|
||||||
|
// used to be stored at 18:30, and the size of the error was the deploy's offset.
|
||||||
|
func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) {
|
||||||
|
// No zone juggling: the assertion is that the stored wall clock is the one
|
||||||
|
// he read, whatever zone the box is in. That is false under the old code
|
||||||
|
// on every box except a UTC one.
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
|
||||||
|
Package: "com.slack",
|
||||||
|
Title: "Standup",
|
||||||
|
Text: "созвон завтра в 14:30",
|
||||||
|
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(resp.Key, "_Standup") {
|
||||||
|
t.Errorf("key = %q, want a key naming the meeting", resp.Key)
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
|
||||||
|
}
|
||||||
|
if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" {
|
||||||
|
t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{template "shellTop" "chat"}}
|
||||||
|
<h1>Chat</h1>
|
||||||
|
<section class=card>
|
||||||
|
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
||||||
|
<div class="scroll chat-scroll" id=chatHistory>
|
||||||
|
{{range .Messages}}
|
||||||
|
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
||||||
|
{{else}}
|
||||||
|
<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||||
|
<div>start a conversation</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<form method=post action=/api/chat class=chat-form>
|
||||||
|
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
||||||
|
<button class=btn>send</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
var ch = document.getElementById('chatHistory');
|
||||||
|
if(ch) ch.scrollTop = ch.scrollHeight;
|
||||||
|
</script>
|
||||||
|
{{template "shellBottom"}}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+181
-255
@@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/tasks"
|
"github.com/kami/maven/internal/tasks"
|
||||||
|
"github.com/kami/maven/internal/tool"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,23 @@ var morningHTML string
|
|||||||
//go:embed events.html
|
//go:embed events.html
|
||||||
var eventsHTML string
|
var eventsHTML string
|
||||||
|
|
||||||
|
//go:embed tools.html
|
||||||
|
var toolsHTML string
|
||||||
|
|
||||||
|
//go:embed routines.html
|
||||||
|
var routinesHTML string
|
||||||
|
|
||||||
|
//go:embed chat.html
|
||||||
|
var chatPageHTML string
|
||||||
|
|
||||||
|
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
|
||||||
|
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
|
||||||
|
// with the sidebar assembled by a strings.Builder, which is the one piece of
|
||||||
|
// markup that was still concatenated in Go.
|
||||||
|
//
|
||||||
|
//go:embed shell.html
|
||||||
|
var shellHTML string
|
||||||
|
|
||||||
// ── Ethos Workstation Shell ──
|
// ── Ethos Workstation Shell ──
|
||||||
//
|
//
|
||||||
// Two template pieces that wrap every page:
|
// Two template pieces that wrap every page:
|
||||||
@@ -135,74 +153,40 @@ var sidebarSections = []struct {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func sidebarActive(url, key string, activeKey string) string {
|
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
|
||||||
if key == activeKey {
|
// sidebar template wraps it in the <use> reference.
|
||||||
return `class="active"`
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// sidebarHTML renders the sidebar navigation given the active page key.
|
|
||||||
func sidebarHTML(active string) template.HTML {
|
|
||||||
var b strings.Builder
|
|
||||||
for _, sec := range sidebarSections {
|
|
||||||
b.WriteString(`<div class=sidebar-section>`)
|
|
||||||
b.WriteString(`<div class=sidebar-label>`)
|
|
||||||
b.WriteString(sec.Label)
|
|
||||||
b.WriteString(`</div>`)
|
|
||||||
for _, p := range sec.Pages {
|
|
||||||
cls := ""
|
|
||||||
if p.Key == active {
|
|
||||||
cls = ` class="active"`
|
|
||||||
}
|
|
||||||
b.WriteString(`<a href="`)
|
|
||||||
b.WriteString(p.URL)
|
|
||||||
b.WriteString(`"`)
|
|
||||||
b.WriteString(cls)
|
|
||||||
b.WriteString(`><span class=icon>`)
|
|
||||||
b.WriteString(pageIcon(p.Key))
|
|
||||||
b.WriteString(`</span><span>`)
|
|
||||||
b.WriteString(p.Label)
|
|
||||||
b.WriteString(`</span></a>`)
|
|
||||||
}
|
|
||||||
b.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
return template.HTML(b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
|
|
||||||
func pageIcon(key string) string {
|
func pageIcon(key string) string {
|
||||||
switch key {
|
switch key {
|
||||||
case "dash":
|
case "dash":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
return "i-grid"
|
||||||
case "history":
|
case "history":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
|
return "i-clock"
|
||||||
case "trace":
|
case "trace":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
return "i-wave"
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
return "i-bell"
|
||||||
case "tasks":
|
case "tasks":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
return "i-grid"
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
return "i-calendar"
|
||||||
case "routines":
|
case "routines":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
return "i-repeat"
|
||||||
case "morning":
|
case "morning":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
return "i-calendar"
|
||||||
case "chat":
|
case "chat":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
return "i-message"
|
||||||
case "voice":
|
case "voice":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
|
return "i-mic"
|
||||||
case "ecosystem":
|
case "ecosystem":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
return "i-grid"
|
||||||
case "tools":
|
case "tools":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
return "i-settings"
|
||||||
case "models":
|
case "models":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
return "i-wave"
|
||||||
case "passkey":
|
case "passkey":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
return "i-lock"
|
||||||
default:
|
default:
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
|
return "i-search"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,63 +226,12 @@ func pageTitle(key string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shellTopHTML opens the shell and renders the top bar + sidebar.
|
|
||||||
// Usage: {{template "shellTop" "<page-key>"}}
|
|
||||||
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
|
|
||||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
||||||
<meta name=theme-color content="#14110D">
|
|
||||||
<link rel=manifest href=/manifest.json>
|
|
||||||
<title>maven · {{pageTitle .}}</title>
|
|
||||||
<link rel=stylesheet href=/ui.css>
|
|
||||||
<div class=shell data-app=maven>
|
|
||||||
<header class=topbar>
|
|
||||||
<div class=breadcrumbs>
|
|
||||||
<span class=current>{{pageTitle .}}</span>
|
|
||||||
</div>
|
|
||||||
<div class=topbar-actions>
|
|
||||||
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
|
|
||||||
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
|
|
||||||
Search
|
|
||||||
<span class=kbd-hint>Ctrl+/</span>
|
|
||||||
</div>
|
|
||||||
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
|
|
||||||
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
|
|
||||||
</button>
|
|
||||||
<span class=conn-status>
|
|
||||||
<span class="dot online" id=connDot></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class=shell-body>
|
|
||||||
<aside class=sidebar>
|
|
||||||
{{sidebarHTML .}}
|
|
||||||
</aside>
|
|
||||||
<main class=content>
|
|
||||||
{{end}}`
|
|
||||||
|
|
||||||
// shellBottomHTML closes the content area, inspector, and shell.
|
|
||||||
// Usage: {{template "shellBottom"}}
|
|
||||||
const shellBottomHTML = `{{define "shellBottom"}}
|
|
||||||
</main>
|
|
||||||
<aside class=inspector id=inspector>
|
|
||||||
<div class=inspector-inner>
|
|
||||||
<div class=inspector-header>
|
|
||||||
<span id=inspectorTitle>Details</span>
|
|
||||||
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">×</button>
|
|
||||||
</div>
|
|
||||||
<div class=inspector-body id=inspectorBody></div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src=/mavweb.js></script>
|
|
||||||
{{end}}`
|
|
||||||
|
|
||||||
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
||||||
func shellFuncs() template.FuncMap {
|
func shellFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"pageTitle": pageTitle,
|
"pageTitle": pageTitle,
|
||||||
"sidebarHTML": sidebarHTML,
|
"pageIcon": pageIcon,
|
||||||
|
"sidebarSections": func() any { return sidebarSections },
|
||||||
"ago": func(t time.Time) string {
|
"ago": func(t time.Time) string {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return "never"
|
return "never"
|
||||||
@@ -313,21 +246,21 @@ func shellFuncs() template.FuncMap {
|
|||||||
// a small fetch loop refreshes the tables in place. html/template escapes the
|
// a small fetch loop refreshes the tables in place. html/template escapes the
|
||||||
// user text in facts/nudges. Read-only: browses the append-only store via
|
// user text in facts/nudges. Read-only: browses the append-only store via
|
||||||
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
||||||
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
|
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellHTML + dashHTML))
|
||||||
|
|
||||||
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
||||||
// human surface is here (they ship no web UI of their own).
|
// human surface is here (they ship no web UI of their own).
|
||||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML))
|
||||||
|
|
||||||
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||||
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||||
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
|
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML))
|
||||||
|
|
||||||
// morningTmpl — read-only view of today's checklist state per configured
|
// morningTmpl — read-only view of today's checklist state per configured
|
||||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||||
// checklist state changes on the scale of minutes, not seconds.
|
// checklist state changes on the scale of minutes, not seconds.
|
||||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML))
|
||||||
|
|
||||||
func noCache(h http.Handler) http.Handler {
|
func noCache(h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -512,7 +445,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) {
|
||||||
@@ -746,108 +679,26 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap {
|
var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap {
|
||||||
m := shellFuncs()
|
m := shellFuncs()
|
||||||
m["join"] = strings.Join
|
m["join"] = strings.Join
|
||||||
|
m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }
|
||||||
|
m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) }
|
||||||
return m
|
return m
|
||||||
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
|
}()).Parse(shellHTML + toolsHTML))
|
||||||
|
|
||||||
const toolsHTML = `{{template "shellTop" "tools"}}
|
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML))
|
||||||
<h1>Tools</h1>
|
|
||||||
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
|
|
||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
|
||||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
|
||||||
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
|
||||||
{{range .Proposed}}<tr>
|
|
||||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
|
||||||
<td><form method=post action=/tools>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
|
||||||
<input type=hidden name=action value=enable>
|
|
||||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
|
||||||
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
|
||||||
<button class=btn>enable</button></form>
|
|
||||||
<form method=post action=/tools class=inline-form>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=action value=dismiss>
|
|
||||||
<button class="btn btn-muted">dismiss</button></form></td>
|
|
||||||
</tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
|
|
||||||
<div>no proposed tools</div>
|
|
||||||
<div class=hint>maven will propose tools here when she needs help running an action</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
|
||||||
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
|
||||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
|
||||||
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
|
||||||
<td><form method=post action=/tools class=inline-form>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
|
||||||
<input type=hidden name=action value=disable>
|
|
||||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
|
||||||
<div>no tools enabled</div>
|
|
||||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
|
||||||
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
|
||||||
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
|
||||||
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
|
||||||
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
|
||||||
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
|
||||||
<div>no MCP servers configured</div>
|
|
||||||
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
{{template "shellBottom"}}`
|
|
||||||
|
|
||||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML))
|
||||||
|
|
||||||
|
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML))
|
||||||
|
|
||||||
|
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML))
|
||||||
|
|
||||||
|
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML))
|
||||||
|
|
||||||
|
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML))
|
||||||
|
|
||||||
|
// routinesTmpl — the proposed-routine review surface. One row per thing maven
|
||||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
// noticed, in her words, with at most two actions: accept or dismiss.
|
||||||
const routinesHTML = `{{template "shellTop" "routines"}}
|
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML))
|
||||||
<h1>Routines</h1>
|
|
||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
|
|
||||||
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
|
|
||||||
{{range .Proposed}}<tr>
|
|
||||||
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
|
|
||||||
<td><form method=post action=/routines class=inline-form>
|
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
|
||||||
<input type=hidden name=action value=accept>
|
|
||||||
<button class=btn>accept</button></form></td>
|
|
||||||
<td><form method=post action=/routines class=inline-form>
|
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
|
||||||
<input type=hidden name=action value=dismiss>
|
|
||||||
<button class="btn btn-muted">dismiss</button></form></td>
|
|
||||||
</tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
|
||||||
<div>no proposed routines</div>
|
|
||||||
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
{{template "shellBottom"}}`
|
|
||||||
|
|
||||||
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||||
m := shellFuncs()
|
m := shellFuncs()
|
||||||
@@ -859,7 +710,7 @@ var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMa
|
|||||||
}
|
}
|
||||||
m["join"] = strings.Join
|
m["join"] = strings.Join
|
||||||
return m
|
return m
|
||||||
}()).Parse(shellTopHTML + traceHTML + shellBottomHTML))
|
}()).Parse(shellHTML + traceHTML))
|
||||||
|
|
||||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
@@ -893,12 +744,104 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminderRow is one line on /reminders, with the payload unwrapped and both
|
||||||
|
// timestamps already in his clock.
|
||||||
|
//
|
||||||
|
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
|
||||||
|
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
|
||||||
|
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
|
||||||
|
// shape he never chose, and a time on a page he reads is the time on his wall.
|
||||||
|
type reminderRow struct {
|
||||||
|
Created string
|
||||||
|
Fires string
|
||||||
|
Status string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminderText unwraps the {"text":...} payload the router writes.
|
||||||
|
//
|
||||||
|
// A copy of store.ReminderText rather than a call to it, because mavweb is one
|
||||||
|
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
|
||||||
|
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
|
||||||
|
// whoever renders it. Payload that is not that shape is shown as he said it.
|
||||||
|
func reminderText(payload string) string {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
||||||
|
if t, ok := m["text"]; ok {
|
||||||
|
if s, isStr := t.(string); isStr && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||||
|
out := make([]reminderRow, 0, len(rs))
|
||||||
|
for _, r := range rs {
|
||||||
|
out = append(out, reminderRow{
|
||||||
|
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
|
||||||
|
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
|
||||||
|
Status: r.Status,
|
||||||
|
Text: reminderText(r.Payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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)
|
||||||
@@ -912,7 +855,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
|
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
|
||||||
log.Printf("reminders template: %v", err)
|
log.Printf("reminders template: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1125,9 +1068,10 @@ func fmtTaskDate(t *time.Time) string {
|
|||||||
return t.Local().Format("02 Jan")
|
return t.Local().Format("02 Jan")
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
// routineView is one line on the page: what maven noticed, in her words, and
|
||||||
// how long ago she noticed it.
|
// how long ago she noticed it. A view model, not a database row — the template
|
||||||
type routineRow struct {
|
// never formats an interval or a timestamp itself.
|
||||||
|
type routineView struct {
|
||||||
ID int64
|
ID int64
|
||||||
Phrase string
|
Phrase string
|
||||||
Noticed string
|
Noticed string
|
||||||
@@ -1189,34 +1133,31 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := routinesTmpl.Execute(w, struct {
|
if err := routinesTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []routineRow
|
Proposed []routineView
|
||||||
}{msg, routineRows(proposed)}); err != nil {
|
}{msg, toRoutineViews(proposed)}); err != nil {
|
||||||
log.Printf("routines render: %v", err)
|
log.Printf("routines render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineRows turns the wire rows into display rows. The phrase comes from
|
// toRoutineViews turns the wire rows into view models. The phrase comes from
|
||||||
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
||||||
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
||||||
out := make([]routineRow, 0, len(rs))
|
out := make([]routineView, 0, len(rs))
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
||||||
noticed := "just now"
|
noticed := "just now"
|
||||||
if r.CreatedTs > 0 {
|
if r.CreatedTs > 0 {
|
||||||
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
||||||
}
|
}
|
||||||
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -1462,12 +1403,16 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
|||||||
servers = nil
|
servers = nil
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
|
||||||
|
// flat list stops answering "what can she do to the house" somewhere
|
||||||
|
// around fifteen rows, and that is the question this page exists for.
|
||||||
if err := toolsTmpl.Execute(w, struct {
|
if err := toolsTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []ipc.Tool
|
Proposed []ipc.Tool
|
||||||
Enabled []ipc.Tool
|
Enabled []ipc.Tool
|
||||||
|
Groups []tool.CapabilityGroup
|
||||||
MCP []ipc.MCPServerStatus
|
MCP []ipc.MCPServerStatus
|
||||||
}{msg, proposed, enabled, servers}); err != nil {
|
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil {
|
||||||
log.Printf("tools render: %v", err)
|
log.Printf("tools render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1605,32 +1550,7 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
|
|||||||
|
|
||||||
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
||||||
// and the handler redirects back to /chat with the response.
|
// and the handler redirects back to /chat with the response.
|
||||||
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellTopHTML + chatPageHTML + shellBottomHTML))
|
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML))
|
||||||
|
|
||||||
const chatPageHTML = `{{template "shellTop" "chat"}}
|
|
||||||
<h1>Chat</h1>
|
|
||||||
<section class=card>
|
|
||||||
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
|
||||||
<div class="scroll chat-scroll" id=chatHistory>
|
|
||||||
{{range .Messages}}
|
|
||||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
|
||||||
{{else}}
|
|
||||||
<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
|
||||||
<div>start a conversation</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<form method=post action=/api/chat class=chat-form>
|
|
||||||
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
|
||||||
<button class=btn>send</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
<script>
|
|
||||||
var ch = document.getElementById('chatHistory');
|
|
||||||
if(ch) ch.scrollTop = ch.scrollHeight;
|
|
||||||
</script>
|
|
||||||
{{template "shellBottom"}}`
|
|
||||||
|
|
||||||
// handleChatPage renders the chat conversation page.
|
// handleChatPage renders the chat conversation page.
|
||||||
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
@@ -1681,7 +1601,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)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user