Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87bc30204c | |||
| 54cde17e73 | |||
| 4706f78a98 | |||
| 9ead1db047 |
@@ -11,18 +11,14 @@ started with an agent that inferred the goal instead of stating it back.
|
||||
## 0. Get on the branch
|
||||
|
||||
```sh
|
||||
task start <vikunja-id> # with an id
|
||||
git checkout -b task/<slug> # without one
|
||||
task start <vikunja-id>
|
||||
```
|
||||
|
||||
An id is optional (owner's call, 2026-08-25). With one, `~/.local/bin/task` owns
|
||||
the branch, the identity and the PR. It cuts `task/<id>-<slug>` off
|
||||
`origin/master` and sets the commit author to the `claude` gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting
|
||||
`~/.local/bin/task` owns the branch, the identity and the PR. It cuts
|
||||
`task/<id>-<slug>` off `origin/master` and sets the commit author to the `claude`
|
||||
gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting
|
||||
review comments into `.task/review-comments.md`. Do not hand-roll any of that.
|
||||
|
||||
Without an id, branch by hand and skip `TASK.md`. The user's own brief is then
|
||||
the goal, and step 4 restates it back to him instead.
|
||||
|
||||
`TASK.md` is the brief and it is immutable. If it says a PR already exists, this
|
||||
is a review-fix session and not new work. Read the comments first.
|
||||
|
||||
@@ -38,12 +34,9 @@ If there is no handoff, that is normal. It means the last session closed clean.
|
||||
|
||||
In this order, and stop as soon as you have enough:
|
||||
|
||||
- The Vikunja task, if there is one. Project Maven is ID 2, MCP at
|
||||
`http://localhost:9100/mcp`, reachable from workpc only through
|
||||
`ssh -N -f -L 9100:127.0.0.1:9100 kami@192.168.1.104`. A refused connection is
|
||||
the missing tunnel, not an outage. The task description and its comments hold
|
||||
the goal, the constraints, and the assumption ledger. This outranks the handoff
|
||||
on every conflict.
|
||||
- The Vikunja task, by id. Project Maven is ID 2, MCP at `http://localhost:9100/mcp`.
|
||||
The task description and its comments hold the goal, the constraints, and the
|
||||
assumption ledger. This outranks the handoff on every conflict.
|
||||
- `CLAUDE.md`, the section that covers the area you are about to touch.
|
||||
- The one file under `docs/` that owns the area. Check its `Last verified` line.
|
||||
If the sha is behind the code you are reading, say so in step 4 and trust the code.
|
||||
@@ -51,8 +44,8 @@ In this order, and stop as soon as you have enough:
|
||||
Do not read the dated files under `docs/evals/`. They are measurements from one day,
|
||||
never updated. Read one only when you need the number it recorded.
|
||||
|
||||
With no task id, do not ask for one and do not stall. State it in step 4 as
|
||||
`Task: unfiled` and carry on.
|
||||
If no task id is known, ask for one before doing anything else. Work without a task
|
||||
is work nobody can resume.
|
||||
|
||||
## 3. Look at the ground
|
||||
|
||||
@@ -65,7 +58,7 @@ Write at most five bullets and stop. Do not write code, do not open files to "ch
|
||||
one thing first", do not start with a small safe change.
|
||||
|
||||
```
|
||||
Task: V-359, one line. `unfiled` when there is no id.
|
||||
Task: V-359, one line.
|
||||
Done: what is already on the branch.
|
||||
Next: the one thing this session does.
|
||||
Constraints: what would make this wrong.
|
||||
@@ -74,9 +67,8 @@ Assuming: the beliefs that, if false, waste the session.
|
||||
|
||||
Then ask: is this right? Wait for the answer.
|
||||
|
||||
A corrected assumption goes into the Vikunja task as a comment where there is a
|
||||
task, because the handoff dies tonight and the task does not. Unfiled, it goes
|
||||
into the handoff and nowhere else.
|
||||
A corrected assumption goes into the Vikunja task as a comment, not into the handoff.
|
||||
The handoff dies tonight. The task does not.
|
||||
|
||||
## 5. Then begin
|
||||
|
||||
|
||||
@@ -36,13 +36,11 @@ a commit message, not into a comment in the code.
|
||||
Under 300 changed lines per commit in non-markdown files, enforced by `.githooks/pre-commit`.
|
||||
Markdown is exempt and may land as one batch.
|
||||
|
||||
Each commit is one idea, subject in the repo's voice, lowercase area prefix. A
|
||||
Vikunja ref is welcome where a task exists and is required nowhere: the
|
||||
`commit-msg` hook that demanded it was deleted on 2026-08-25.
|
||||
Each commit is one idea, subject in the repo's voice, lowercase area prefix, and it
|
||||
ends with the Vikunja ref:
|
||||
|
||||
```
|
||||
router: narrow the single-token rule (V-359)
|
||||
router: narrow the single-token rule
|
||||
```
|
||||
|
||||
If a change genuinely cannot split under 300 lines, say why in the commit body before
|
||||
@@ -58,17 +56,13 @@ It refuses a dirty tree, pushes, opens or refreshes the PR against the repo defa
|
||||
branch, labels the Vikunja task in-review, comments the PR url on it, and pushes an
|
||||
ntfy. Do not push by hand and do not call `tea` yourself.
|
||||
|
||||
`task pr` needs an id. On a hand-cut branch with no task, push the branch and open
|
||||
the PR by hand, and skip step 5.
|
||||
|
||||
## 5. Record what `task pr` cannot know
|
||||
|
||||
Comment on the Vikunja task: what you measured, what is still open. List every
|
||||
assumption that turned out to be wrong. If the session found new work, create a task
|
||||
for it now rather than describing it in prose.
|
||||
|
||||
This step is what makes the handoff disposable. With no task, it cannot run, so the
|
||||
handoff carries that content instead and stops being disposable. Say so in it.
|
||||
This step is what makes the handoff disposable.
|
||||
|
||||
## 6. Leave the handoff, or leave none
|
||||
|
||||
@@ -81,7 +75,7 @@ resume, and no history:
|
||||
```markdown
|
||||
# Handoff — <date>
|
||||
|
||||
Task: V-359 <one line>, or `unfiled`
|
||||
Task: V-359 <one line>
|
||||
Branch: task/359-<slug>, cut from master
|
||||
|
||||
## Where I stopped
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Every commit names the Vikunja task it belongs to.
|
||||
#
|
||||
# router: narrow the single-token rule (V-359)
|
||||
#
|
||||
# V- and not #, because Gitea autolinks #359 to a Gitea issue, which is a
|
||||
# different tracker and a wrong link.
|
||||
#
|
||||
# Exempt: merges, reverts, fixup/squash, and the initial commit.
|
||||
|
||||
msg_file=$1
|
||||
subject=$(sed -n '1p' "$msg_file")
|
||||
|
||||
case "$subject" in
|
||||
Merge\ *|Revert\ *|fixup!\ *|squash!\ *|amend!\ *) exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ -f "$(git rev-parse --git-dir)/MERGE_HEAD" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$subject" | grep -qE '\(V-[0-9]+\)$'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "commit-msg: subject must end with a Vikunja task ref." >&2
|
||||
echo " got: $subject" >&2
|
||||
echo " want: router: narrow the single-token rule (V-359)" >&2
|
||||
echo " No task yet? Create one. Work without a task is work nobody can resume." >&2
|
||||
exit 1
|
||||
-25
@@ -9,7 +9,6 @@
|
||||
/mavwaked
|
||||
/mavmaild
|
||||
/mavupdate
|
||||
/mavgpud
|
||||
|
||||
# Certs (private keys, don't commit)
|
||||
certs/
|
||||
@@ -41,9 +40,6 @@ deploy/telegram.env
|
||||
deploy/zenmoney.token
|
||||
# IMAP password, read by mavmaild (never in argv, never committed)
|
||||
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
|
||||
/tmp/
|
||||
@@ -54,10 +50,6 @@ opencode.json
|
||||
# Test coverage output
|
||||
coverage.out
|
||||
|
||||
# Python service/test bytecode.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Agent worktrees and local agent state. The workflow itself is tracked: the
|
||||
# hooks, the skills and the prose dictionary are how a session behaves, so they
|
||||
# get reviewed like code. Everything else under .claude/ is scratch.
|
||||
@@ -74,20 +66,3 @@ __pycache__/
|
||||
|
||||
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
|
||||
.env
|
||||
# silero-vad, downloaded (see AGENTS.md)
|
||||
/models/vad/
|
||||
|
||||
# Go build cache and GOPATH from the containerised e2eprobe build. Created by
|
||||
# the command in docs/capabilities/README.md, which runs as root in a container
|
||||
# and so cannot share the host cache. Multi-GB, entirely reproducible.
|
||||
/.cache/
|
||||
|
||||
# docs/architecture/ derived output. The sources, findings.md and the inventory
|
||||
# JSON are tracked; these rebuild from them with pack_evidence.sh and are large.
|
||||
/docs/architecture/index.html
|
||||
/docs/architecture/anchors.md
|
||||
/docs/architecture/architecture-evidence.txt
|
||||
/docs/architecture/tree.txt
|
||||
/docs/architecture/docker-compose.redacted.yml
|
||||
/docs/architecture/diagrams/*.svg
|
||||
/maven-evidence.zip
|
||||
|
||||
@@ -95,22 +95,6 @@ model: the code puts `query: ` in front of a question and `passage: ` in front
|
||||
of a stored note, which is how e5 was trained. The quantized file is the one
|
||||
that is downloaded, deployed and measured.
|
||||
|
||||
## Voice activity model for mavwaked
|
||||
|
||||
`mavwaked` decides an utterance has started with silero-vad when `-vad-model`
|
||||
points at it, and with an energy threshold when it does not. The model is 2.3MB
|
||||
and is not committed:
|
||||
|
||||
```sh
|
||||
mkdir -p models/vad
|
||||
curl -sL -o models/vad/silero_vad.onnx \
|
||||
https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx
|
||||
```
|
||||
|
||||
It needs the same `libonnxruntime.so` the embedder needs, passed as `-onnx-lib`
|
||||
or read from `MAVEN_ONNX_LIB`. The measurement is
|
||||
`docs/evals/2026-08-09-silero-vad.md`, and the tests skip without the file.
|
||||
|
||||
**Also need ONNX Runtime** (`libonnxruntime.so`):
|
||||
|
||||
```sh
|
||||
@@ -135,41 +119,37 @@ Russian recall — you may see many "clarify" responses).
|
||||
|
||||
## Qwen3 resident model for router + phraser
|
||||
|
||||
The deployed resident model is stock **Qwen3-1.7B** (`UD-Q4_K_XL`), a Thinking
|
||||
variant at `n_ctx` 4096. `CLAUDE.md` carries the rule on which models qualify.
|
||||
The target daemon uses the locally trained Qwen3-1.7B checkpoint for both
|
||||
routing and phrasing. Training is Qwen3 Base → RU CPT → joint persona/router
|
||||
SFT → merged GGUF, and is still in flight (#122) — until it lands, the deployed
|
||||
resident model is stock **Qwen3.5-0.8B** (`Q4_K_M`), see `deploy/mavend.json`.
|
||||
Without a configured model, `StubPhraser` plus the classifier remain the
|
||||
deterministic floor.
|
||||
|
||||
A locally trained Qwen3-1.7B checkpoint is still in flight (V-122). Training
|
||||
runs Qwen3 Base, then RU CPT, then joint persona and router SFT, then a merged
|
||||
GGUF. The
|
||||
runbook is `docs/plans/2026-07-18-qwen3-resident-training-eval.md`. After the
|
||||
decision gate and SFT pass, copy the merged GGUF into the mounted model
|
||||
directory and point `model_path` at it.
|
||||
|
||||
**Configure in `deploy/mavend.json`.** This is the deployed `phraser` block:
|
||||
During training, use the runbook in
|
||||
`docs/plans/2026-07-18-qwen3-resident-training-eval.md`. After the decision gate
|
||||
and SFT pass, copy the merged GGUF into the mounted model directory and set:
|
||||
|
||||
```json
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||
"model_path": "/opt/maven/models/llm/Qwen3-Maven-1.7B-Q8_0.gguf",
|
||||
"bin_path": "llama-server",
|
||||
"n_gpu_layers": 99,
|
||||
"n_ctx": 4096,
|
||||
"cache_ram_mib": 512,
|
||||
"timeout": "60s"
|
||||
"n_ctx": 2048
|
||||
}
|
||||
```
|
||||
|
||||
The daemon spawns `llama-server` as a subprocess. The router and replier reach
|
||||
that one server through the shared `internal/llm` client. Model files live in
|
||||
`/mnt/hdd1/llms`, bind-mounted over `models/llm/`, so a gguf sitting in the repo
|
||||
is loaded by nothing.
|
||||
**Configure in `deploy/mavend.json`** — the `phraser` block points at this
|
||||
model and the daemon spawns `llama-server` as a subprocess. The router and
|
||||
replier use the same llama-server via the shared `internal/llm` client.
|
||||
|
||||
Telegram tokens are read from `deploy/telegram.env` (gitignored), expanded
|
||||
via `${VAR}` in the JSON config.
|
||||
|
||||
The cascade order, and which stage may decline to the next, is in
|
||||
`docs/routing.md`. It is not restated here.
|
||||
**Routing is Qwen-first** with classifier fallback. The LLM router runs
|
||||
after stage-0 (exact-match grammar) and before the classifier cascade. On any
|
||||
error or parse failure, the classifier handles the utterance — the turn never
|
||||
breaks on the model.
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
|
||||
@@ -1,214 +1,272 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code (claude.ai/code) working in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
**This is a rules file.** It loads into every session, so it carries only what
|
||||
changes what an agent does. A measurement belongs in `docs/evals/`, dated and
|
||||
never edited after the day. A subsystem's reasoning belongs in its living doc
|
||||
under `docs/`. Read that doc before changing the subsystem.
|
||||
Maven is a self-hosted, privacy-first voice assistant (Russian + English). Go daemons
|
||||
talking over unix sockets; one resident small model for routing + phrasing; whisper.cpp STT, piper TTS.
|
||||
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
||||
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
||||
|
||||
| Read this | Before |
|
||||
|---|---|
|
||||
| `docs/routing.md` | touching `internal/router/` or `queryWalk` |
|
||||
| `docs/deployment.md` | touching a daemon, compose, a systemd unit or the web UI |
|
||||
| `docs/offload.md` | touching a daemon seam or adding a model caller |
|
||||
| `docs/world.md` | touching search, Kiwix or the world chain |
|
||||
| `docs/language.md` | changing a prompt contract or a Russian word list |
|
||||
| `docs/ecosystem.md` | touching Nexus, Praxis or Hexis |
|
||||
| `docs/spec.md` | asking what a capability is for, or whether it is done |
|
||||
| `docs/roadmap.md` | picking what to work on next |
|
||||
| `docs/rearchitecture.md`, `docs/design.md` | changing the shape of anything |
|
||||
| `docs/workflow.md` | the five stores, the doc tiers, the guards |
|
||||
| `docs/caveats/` | a known limit, its task id and its revisit trigger |
|
||||
| `docs/CLAUDE.md` | which tier a doc belongs in, and what each one holds |
|
||||
| `AGENTS.md` | local preview, screenshots, model downloads |
|
||||
**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:
|
||||
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 `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.
|
||||
|
||||
## What Maven is
|
||||
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
||||
Stock already speaks good Russian; what it gets wrong is the persona — it writes `я рад`,
|
||||
masculine, where Maven needs `рада`. That is what the CPT is for.
|
||||
|
||||
A self-hosted, privacy-first voice assistant in Russian and English. Go daemons
|
||||
talk over unix sockets. One resident small model routes and phrases. whisper.cpp
|
||||
does speech-to-text and piper does text-to-speech.
|
||||
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and
|
||||
both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers
|
||||
"столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in
|
||||
Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in
|
||||
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
||||
`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`.
|
||||
|
||||
The resident model is **Qwen3-1.7B** (`UD-Q4_K_XL`) on homesrv, a Thinking
|
||||
variant at `n_ctx` 4096. Keep it at 1.7B or under. Sub-500M models are unusable
|
||||
in Russian (`docs/evals/2026-07-31-model-bakeoff.md`). Model files live in
|
||||
`/mnt/hdd1/llms`, bind-mounted over the repo's `models/llm/`, so a gguf sitting
|
||||
in the repo is loaded by nothing.
|
||||
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.
|
||||
|
||||
The workstation is workpc and it holds the remote model and speech-to-text.
|
||||
**It is never assumed up.** **Fall back silently** when it would only do the job
|
||||
better. **Name the gap** when the resident model cannot do the job at all.
|
||||
## Build & test
|
||||
|
||||
**The embedder stays on homesrv permanently**, because it backs that floor.
|
||||
`EmbedQuery` and `EmbedPassage` apply the `query:` and `passage:` prefixes
|
||||
multilingual-e5-small was trained with. Calling plain `Embed` on a note is a bug.
|
||||
|
||||
## Build and test
|
||||
|
||||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored
|
||||
toolchain wired through the Makefile. **Do not call `go build` on them bare**,
|
||||
and **do not hand-write the CGO preamble**. This box runs zsh, so an unquoted
|
||||
`-run Test*` dies on "no matches found" before `go` is reached. `make t` also
|
||||
carries `-count=1` and sets `MAVEN_ONNX_LIB`. Without that variable the four
|
||||
`TestONNX*` measurements self-skip and the run still prints `ok`.
|
||||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
||||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||
|
||||
```sh
|
||||
make build # all 11 binaries. make build-web for one (web/waked/poll/caldav skip CGO)
|
||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race
|
||||
make analyze # staticcheck, deadcode and govulncheck. Not in `test`: all three need the network
|
||||
make build # all 9 binaries
|
||||
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||
```
|
||||
|
||||
**The static gates pass against a baseline, not against zero**
|
||||
(`scripts/analyzers/*.baseline`, reasoning in `docs/workflow.md`). A fix must
|
||||
delete its baseline entry, because the gate also fails on an entry whose finding
|
||||
is gone. **`make audit` is a git-grep inventory, not analysis.** Do not cite it
|
||||
as a reachability check.
|
||||
Run a single test (must carry the CGO env for packages that touch STT/TTS/voice):
|
||||
|
||||
## The daemons
|
||||
```sh
|
||||
CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \
|
||||
CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \
|
||||
LD_LIBRARY_PATH="$(pwd)/deps/lib" \
|
||||
deps/go/go/bin/go test -run TestName ./internal/router/
|
||||
```
|
||||
|
||||
Eleven binaries under `cmd/`, wired socket-to-socket over `internal/ipc`, not
|
||||
linked. `mavend` is the core and owns the DB and the IPC socket.
|
||||
`deploy/mavend.json` sets sockets, model paths and the phraser and embedder
|
||||
blocks, with `${VAR}` expansion from gitignored `deploy/telegram.env`.
|
||||
**`docker-compose.yml` runs five**: `mavend`, `mavsttd`, `mavttsd`, `mavweb`,
|
||||
`mavpoll`. Count against compose, not against `make build`. `mavwaked` runs on
|
||||
workpc under systemd. `docs/deployment.md` says who else is absent and why.
|
||||
Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test ./pkg/`.
|
||||
|
||||
- **Passwords are read from files, never taken as flag values.**
|
||||
- **The voice wire is plaintext with no auth.** mavend's voice port stays on
|
||||
homesrv loopback and reaches workpc over ssh. Do not LAN-bind it.
|
||||
`SurfaceVoice` caps acts at L0, and L0 does not cap reading.
|
||||
- **A GPU service added beside mavgpud goes in `cmd/mavgpud`, never in systemd.**
|
||||
The card needs one owner. A second unit made mavgpud evict llama-server every
|
||||
few seconds and took the model arm down for eight minutes.
|
||||
## The daemons (`cmd/`)
|
||||
|
||||
| Binary | Role |
|
||||
|---|---|
|
||||
| `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB + IPC socket. |
|
||||
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
||||
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
||||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||||
| `mavwaked` | Wake-word / VAD gate. |
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
||||
| `mavpoll` | Telegram long-poll reach. |
|
||||
| `mavcaldav` | CalDAV calendar sync. |
|
||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||
|
||||
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
||||
|
||||
## The ecosystem: Nexus, Praxis, Hexis
|
||||
|
||||
Nexus identifies, Praxis observes, Hexis acts, Maven understands. Maven 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
|
||||
`docs/ecosystem.md`. All three are `nil` unless configured and each degrades
|
||||
alone. An outage means a named gap, never a broken turn or a guess.
|
||||
`docs/ecosystem.md`.
|
||||
|
||||
- **No component reads another component's database.** Praxis attention comes
|
||||
over HTTP, never from its SQLite file.
|
||||
- **Identity lives in Nexus.** Do not invent a local fact key for something
|
||||
Nexus resolves. `cmd/mavend/factenrichment.go` resolves `actionFact.Subject`.
|
||||
- **Free text never reaches a mutating Hexis call.** Resolve to a canonical
|
||||
entity id first. Ambiguous resolution asks the owner, it does not pick.
|
||||
```text
|
||||
Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordinates.
|
||||
```
|
||||
|
||||
| Service | Owns | Maven's client | Configured at |
|
||||
|---|---|---|---|
|
||||
| **Nexus** | Canonical entity ids, names, aliases, relationships. Projects, services, devices, people, pets, places. | `nexusClient` in `cmd/mavend/ecosystem.go`, `POST /api/v1/resolve` | `nexus.url` (`http://nexus:9740`) |
|
||||
| **Praxis** | Operational attention and item lifecycle. What needs looking at, what changed, what is still unresolved. | `praxisClient`, the HTTP tools API under `/api/v1/tools/` | `praxis.url` (`http://praxis:8989`) |
|
||||
| **Hexis** | The capability registry and the only path to executing anything. | vendored `github.com/kami/hexis/pkg/client` | `hexis.url` (`http://hexis:9741`) |
|
||||
|
||||
All three are `nil` unless configured, and every one of them degrades on its own.
|
||||
An outage means a named gap in the answer, never a broken turn and never a guess.
|
||||
|
||||
Rules that are not negotiable:
|
||||
|
||||
- **No component reads another component's database.** Praxis attention comes over
|
||||
HTTP, never from its SQLite file.
|
||||
- **Identity lives in Nexus.** Do not invent a local fact key for something Nexus
|
||||
resolves. `actionFact` already sets `Subject`, and `cmd/mavend/factenrichment.go`
|
||||
resolves it in the background against Nexus.
|
||||
- **Free text never reaches a mutating Hexis call.** Resolve to a canonical entity id
|
||||
first. Ambiguous resolution asks the owner, it does not pick.
|
||||
- **LLM output is not authorization.** Confirmation binds capability id, target
|
||||
entity, arguments, requester and expiry (`cmd/mavend/confirm.go`).
|
||||
- **Praxis lifecycle words differ.** Surfaced is not acknowledged, acknowledged
|
||||
is not resolved, execution success is not recovery. Reading an item aloud
|
||||
calls `Surface`, never `Acknowledge`.
|
||||
- **No automatic attention-to-action path.** Digestion may summarise Praxis and
|
||||
may not call Hexis.
|
||||
- Every cross-service call carries a correlation id minted once per action
|
||||
(`withCorrelationID`), a contract version header, and `X-Requested-By: maven`.
|
||||
entity, arguments, requester and expiry. See `cmd/mavend/confirm.go`.
|
||||
- **Praxis lifecycle words mean different things.** Surfaced is not acknowledged,
|
||||
acknowledged is not resolved, execution success is not recovery. Reading an item
|
||||
aloud calls `Surface`, never `Acknowledge`.
|
||||
- **No automatic attention-to-action path.** Digestion may summarise Praxis. It may
|
||||
not call Hexis.
|
||||
|
||||
## Routing
|
||||
Every cross-service call carries a correlation id minted once per action
|
||||
(`withCorrelationID`), a contract version header, and `X-Requested-By: maven`.
|
||||
|
||||
**Read `docs/routing.md` before touching `internal/router/` or `queryWalk`.** It
|
||||
carries the reasoning, the measurements and every rule's why. A route produces
|
||||
two decisions. **Intent** is one of seven values. **Source** is where the answer
|
||||
lives and is read on `IntentQuery` alone. Score them separately. The cascade is
|
||||
stage 0 grammars, then the routing heads, then the resident model, then the
|
||||
classifier. Every stage may decline and the next one answers.
|
||||
## Routing — read this before touching the router
|
||||
|
||||
- **The classifier is the floor, not dead code.** It answers when the resident
|
||||
model is off, absent, or erroring. **Any model error falls through.**
|
||||
- **The stage 0 set lives in `router.StageZeroGrammars`**, and both `buildRouter`
|
||||
and the eval fixture call it. Add a grammar there, in the right place, and read
|
||||
the comment above the line you insert after. Do not restate the list anywhere.
|
||||
- **Go's `\b` is ASCII-only** and never fires after a Cyrillic letter. A Russian
|
||||
pattern needs an explicit `(\s|[?!.]|$)`.
|
||||
- **`PraxisGrammars()` is the only path to Praxis**, not a faster one.
|
||||
- **`voice.embedder.heads_path` must never point at `model_path`.** Recall
|
||||
depends on the resident e5-small scoring what it scored. Fine-tune a copy.
|
||||
Refused at config load since V-692, symlinks included.
|
||||
- **Routing traces are retained 14 days**, enforced on write and again on start.
|
||||
- **Bump `tokenizerRev` on any change to what `encodeWord` emits**, so a
|
||||
tokenizer fix triggers `ReembedAll` the way swapping the model file does.
|
||||
- **A new rung in the `runTurn` ladder needs its name in `preRouteLadder`**
|
||||
(`cmd/mavend/decisiontrace.go`), or it is missing from the decision record.
|
||||
`internal/router/` has TWO layered engines. **The LLM router is now the default and it is
|
||||
on in deploy** — this section used to say it was wired `nil`, which stopped being true on
|
||||
2026-07-31.
|
||||
|
||||
**`queryWalk` takes query sources out and moves none** (`actions_query.go`). That
|
||||
is the safety argument and it is not negotiable. The table's order is
|
||||
load-bearing and carries "the owner's data first, then the world".
|
||||
`SourceUnknown` is the floor and walks the whole chain. A named destination
|
||||
removes only the sources marked `guesses: true`, so a source that looks rather
|
||||
than guesses is always asked. **The personal boundary is the one exception and
|
||||
it is deliberate.** It guesses, so naming `SourceWorld` drops it. **Only a stage
|
||||
0 grammar may drop it** (owner's call, V-666). `queryWalk` reads
|
||||
`Decision.SourceAnchored` for the source marked `boundary: true` and no other.
|
||||
- **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
|
||||
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`
|
||||
(`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`.
|
||||
- **Classifier cascade (the failure floor, not dead code):** `classifier.go` +
|
||||
`embedder.go` nearest-neighbour over frozen seed phrases. It runs when the LLM router is
|
||||
off, when there is no llama-server to talk to (`pickLLMRouter` logs that and degrades),
|
||||
and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known
|
||||
cause of weak RU query handling, but a turn must never break on the model.
|
||||
|
||||
Judge a routing change against the classifier and the resident model, since
|
||||
those always answer. **Their scores live in `docs/routing.md`, never here.** A
|
||||
pair copied into this file goes stale silently. The fixture has changed size
|
||||
more than once, so a number compares only to another number on the same
|
||||
fixture.
|
||||
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.
|
||||
|
||||
## Language: model output and Russian
|
||||
Measured on the 77-case RU fixture (`docs/evals/2026-07-31-model-bakeoff.md`): the classifier scores
|
||||
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
|
||||
cascade at p50 ≈825ms. Accuracy roughly doubled, latency is ~27× worse, and that trade was
|
||||
accepted deliberately. **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
|
||||
work off the bakeoff table. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||||
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
||||
`gateLLMDecision` in `router.go`. Note the second half of that bug: the LLM branch never
|
||||
consulted `r.threshold` at all, so a correct low confidence would have been discarded anyway.
|
||||
|
||||
Both contracts are in `docs/language.md`. What must not be broken:
|
||||
Re-measured on the fixture after the fix: **missed clarify 6/6 → 1**, at the cost of 3 false
|
||||
clarifies and 2.6pt of full accuracy (72.7% → 70.1%, intent-only 67.5% → 74.0%). Two of the
|
||||
three false clarifies are acts the model mis-routed and the gate caught — asking beats wrongly
|
||||
executing, so the fixture and the daemon disagree about what is correct there. The third,
|
||||
`"поужинал"`, was a real defect: the single-token rule was an English intuition and does not
|
||||
transfer to Russian, where one word is routinely a whole sentence.
|
||||
|
||||
- **One parser for model text, `parseResponseMood`** in
|
||||
`internal/phraser/parse.go`. Every phrasing path reaches it. Mood is an enum.
|
||||
- **The router prompt is a separate contract** over 7 intents, and
|
||||
`llm/check_prompt_parity.py` keeps the Go and relabelling copies identical.
|
||||
- **Russian words are matched by three mechanisms and no fourth**:
|
||||
`internal/lexicon` for closed classes, `internal/morph` for grammar, and
|
||||
`cmd/mavend/topics.go` with the embedder for open sets. A regex whose output
|
||||
is a fact or a route is the defect. A regex over structured input is not.
|
||||
- **Seeds are scoring data.** Editing one moves a recogniser and must be
|
||||
re-measured against the `TestONNX*` tests, not eyeballed.
|
||||
Narrowed 01-08-2026. `thinSingleToken` (`internal/router/singletoken.go`) still thins a bare
|
||||
one-word nominal — "вода", "бэкап" — but spares two classes: a closed lexicon of social and
|
||||
control singles ("привет", "спасибо", "стоп", "yes"), and any token carrying a Russian verb
|
||||
ending (past tense, 2nd person, reflexive), because a verb already contains its subject. Both
|
||||
tests are offline and cost nothing. Re-measured: **false clarifies 3 → 2, intent-only 74.0% →
|
||||
75.3%, full accuracy unchanged at 70.1%, missed clarify still 1.** The two remaining false
|
||||
clarifies are the act-with-no-allowlisted-fn arm of the gate, not this rule.
|
||||
|
||||
## Non-goals and hard constraints
|
||||
Agenda questions taken off the model, 01-08-2026. `AgendaQueryGrammars` (`stage0.go`, wired
|
||||
after the clock rules in `buildRouter`) routes "что у меня сегодня", "во сколько у меня
|
||||
встреча" and anything naming a calendar to `IntentQuery` at stage 0. They were going to
|
||||
`IntentSystem`, where `replySystem` has no agenda arm and answered "пока не умею" — the
|
||||
fixture had said `query` since ru-query-019 was written. Measured: **full accuracy 70.1% →
|
||||
72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2**, clarify counts unchanged. Note that
|
||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||||
explicit `(\s|[?!.]|$)`.
|
||||
|
||||
Not a nag, not autonomous.
|
||||
**The persona is feminine.** Russian self-reference takes feminine forms: `рада`
|
||||
not `рад`, `поняла` not `понял`. The owner is male and she speaks to him
|
||||
informally. Use "ты", singular, never "вы" or "ваш", and never "он" or "его".
|
||||
She talks TO the owner, not about him. Pet names such as "милый" are forbidden.
|
||||
The name "Ками" is not. `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||
`internal/phraser/eval/checks.go` enforce this, scored by `make eval-phrasing`.
|
||||
## LLM output contract
|
||||
|
||||
**"Never phones home" is deprecated** (owner's call, 2026-07-31). She reads
|
||||
external sources, and `docs/world.md` carries that chain. What holds regardless:
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
`internal/phraser/llmphraser.go`), with fallback to plain text and the legacy
|
||||
`{"body","summary"}`. Mood is a fixed enum. Router prompt is a separate contract:
|
||||
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||
workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
- **No telemetry, no cloud model, no third-party account.** Inference stays on
|
||||
the box and nothing about Maven is reported to anyone.
|
||||
- **The owner's data first, then the world.** Every source reading his facts,
|
||||
notes, calendar, tasks or house runs first, and the personal boundary sits
|
||||
between them and anything outside.
|
||||
- **His notes and facts are never search input.** Only the utterance goes out,
|
||||
never the persona block, the history, or matched notes.
|
||||
- **External search is allowed and off unless configured.** Deleting the
|
||||
`search` block in `deploy/mavend.json` turns it off.
|
||||
- **`Response.Empty()` is the whole gate** on a world answer. There is no
|
||||
quality threshold in front of it and four candidate signals all failed.
|
||||
## Non-goals (hard constraints)
|
||||
|
||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||||
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
|
||||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||||
|
||||
**"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard
|
||||
constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer
|
||||
world questions, so she needs to read external sources. What replaces it:
|
||||
|
||||
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
|
||||
about Maven is reported to anyone, and inference stays on the box.
|
||||
- **His data first, then the world.** Every source that reads his facts, notes, calendar,
|
||||
tasks or house runs before anything outside, and the personal boundary sits between them.
|
||||
Reading beats recalling for a small model.
|
||||
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
|
||||
2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on
|
||||
homesrv answer when the search is empty, unreachable, or the line is down.
|
||||
- **External search is allowed and off unless configured**, like the weather and telegram
|
||||
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
|
||||
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
|
||||
it off again.
|
||||
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
||||
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||
out, never the persona block, history, or matched notes.
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
||||
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
|
||||
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`.
|
||||
|
||||
## Vikunja
|
||||
|
||||
This repo is project **Maven** (ID 2) in Vikunja. MCP: `http://localhost:9100/mcp` (or
|
||||
`http://192.168.1.104:9100/mcp` from workpc). Feature/bug/deploy tasks go there.
|
||||
|
||||
Vikunja is the durable task store. A task holds the goal, the constraints and the
|
||||
assumption ledger. Work without a task id is work nobody can resume, so a session that
|
||||
has no id asks for one before it starts.
|
||||
|
||||
## Session workflow
|
||||
|
||||
`docs/workflow.md` carries the five stores, the doc tiers and the guards. One
|
||||
task, one session, one PR. `/pickup` opens a session and `/wrap` closes it. Wrap
|
||||
at roughly half context rather than letting the session compact.
|
||||
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
|
||||
session, one PR.
|
||||
|
||||
```sh
|
||||
task start <vikunja-id> # branch off origin/master, write TASK.md, fetch review comments
|
||||
task pr # push, open or refresh the PR, label Vikunja, notify
|
||||
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
|
||||
task comments # re-pull this branch's review comments into .task/
|
||||
```
|
||||
|
||||
- This repo is Vikunja project **Maven** (ID 2), MCP at
|
||||
`http://localhost:9100/mcp` on homesrv. **`vikunja-mcp` publishes to
|
||||
`127.0.0.1:9100` only, so the LAN address never answers from workpc.** A
|
||||
refused connection is that, not an outage: three sessions read it as "Vikunja
|
||||
is down" and filed nothing. Tunnel first, then use `localhost`:
|
||||
`ssh -N -f -L 9100:127.0.0.1:9100 kami@192.168.1.104`.
|
||||
- **Close a finished task with `done: true` and nothing else** (owner's call,
|
||||
2026-08-07). `update_task` carrying a `description` resets `done` to false.
|
||||
- **`pre-commit` refuses master** and more than 300 changed lines in
|
||||
non-markdown files. Markdown is exempt and may land as one batch.
|
||||
- **`diff-budget.sh` blocks edits past 600 changed lines** on a `task/` branch.
|
||||
- **`--no-verify` exists.** Using it means saying why in the commit body.
|
||||
Around that, `/pickup` opens a session and `/wrap` closes it. Wrap at roughly half
|
||||
context rather than letting the session compact.
|
||||
|
||||
Five stores, and each one owns something the others must not hold:
|
||||
|
||||
| Store | Holds | Lifetime |
|
||||
|---|---|---|
|
||||
| Vikunja task | goal, constraints, assumption ledger, status | durable |
|
||||
| `CLAUDE.md`, `AGENTS.md` | what an agent must know before touching code | durable |
|
||||
| `docs/` | design, measurements, decisions | durable |
|
||||
| `TASK.md` | the brief for this branch, written by `task start`, immutable | one branch |
|
||||
| `HANDOFF.md` | only what the next agent needs to resume | one session |
|
||||
|
||||
`TASK.md` and `.task/` are excluded through `.git/info/exclude`. `HANDOFF.md` is
|
||||
gitignored and injected at session start. If a line in the handoff would still matter
|
||||
next week, it is in the wrong file.
|
||||
|
||||
Docs are tiered by path, so staleness is visible from the filename. Files directly under
|
||||
`docs/` are living and carry a `Last verified: <date> @ <sha>` line. Files under
|
||||
`docs/evals/` are dated measurements and are never edited after the day, so a newer
|
||||
number is a new file. Files under `docs/archive/` are dead and read by nobody by default.
|
||||
|
||||
## Git guards
|
||||
|
||||
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. Fresh clone:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
- `pre-commit` refuses master, and refuses more than 300 changed lines in non-markdown
|
||||
files. Markdown is exempt and may land as one batch.
|
||||
- `commit-msg` requires the subject to end with `(V-<id>)`. `V-` and not `#`, because
|
||||
Gitea autolinks `#123` to a Gitea issue, which is the wrong tracker.
|
||||
|
||||
Two more guards live outside the repo, in `~/.claude/hooks/`. `diff-budget.sh` blocks
|
||||
further edits past 600 changed lines on a `task/` branch. `prose_lint_hook.py` checks
|
||||
prose on every write. Both measure against `origin/master`, so a local master that is
|
||||
ahead of the remote makes the diff budget read high.
|
||||
|
||||
`--no-verify` exists. Using it means saying why in the commit body.
|
||||
|
||||
-11
@@ -82,22 +82,11 @@ FROM debian:trixie-slim AS runtime
|
||||
# tzdata so the TZ env (set in compose) resolves — otherwise Go can't load the
|
||||
# zone and time.Now() stays UTC, and mavend answers clock/date queries and
|
||||
# evaluates quiet-hours in UTC.
|
||||
#
|
||||
# TZ is a build arg as well as an env because the image was self-inconsistent
|
||||
# without it (V-545): compose set TZ=Europe/Samara and Go read it, but
|
||||
# /etc/localtime still pointed at Etc/UTC, so anything asking the system zone
|
||||
# instead of the environment answered UTC. The reminder path shells out to
|
||||
# python dateparser, which is exactly such a caller. Compose passes the same
|
||||
# zone it already declares, so the zone is written in one place.
|
||||
ARG TZ=Etc/UTC
|
||||
ENV TZ=$TZ
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata \
|
||||
python3 python3-pip && \
|
||||
pip3 install --no-cache-dir --break-system-packages 'dateparser==1.4.1' && \
|
||||
apt-get purge -y --auto-remove python3-pip && \
|
||||
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && \
|
||||
echo "$TZ" > /etc/timezone && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# runtime native libs: whisper/ggml (incl. vulkan) are real files in deps/lib.
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# Handoff
|
||||
|
||||
Master is at `5cae33a`, pushed, tree clean apart from this file. Working on master
|
||||
raw by the owner's call: no branch, `--no-verify` on every commit with the reason
|
||||
in the body.
|
||||
|
||||
## Landed this session
|
||||
|
||||
Twelve commits pushed. Nine were the previous session's tree, already described in
|
||||
the commit log. Three are new:
|
||||
|
||||
- `78a9c61` `docs/spec.md`, 51 capabilities with a DoD each.
|
||||
- `02e3d27` `docs/roadmap.md`, seven milestones, plus both pointer rows in `CLAUDE.md`.
|
||||
- `5cae33a` honesty split into three milestones, five capabilities deferred past v1.
|
||||
|
||||
**Read `docs/spec.md` and `docs/roadmap.md` before anything else.** Every decision
|
||||
from this session is in them. This file holds only what they do not.
|
||||
|
||||
## The two documents
|
||||
|
||||
`docs/spec.md` is the union of the 39 audited rows
|
||||
(`docs/evals/2026-08-13-capability-audit.md`) and the owner's 18-item v1 list.
|
||||
Twelve of his items had no audit row, so the file has 51 entries. Each entry
|
||||
carries three parts. State is a reference to the living doc that owns it. DoD is
|
||||
a plain list observable on the running box. Scenario names a file in
|
||||
`cmd/mavend/testdata/scenarios/`.
|
||||
|
||||
`docs/roadmap.md` orders them into nine milestones. Honesty, then reach, then
|
||||
breadth. Not ordered by code work, because none of the four broken capabilities is
|
||||
a code defect.
|
||||
|
||||
## Decided, do not re-ask
|
||||
|
||||
- **v1 is a voice assistant, minimum viable.** Each DoD is written at
|
||||
"voice-reachable and honest", not "feature-complete".
|
||||
- **Honesty splits into three.** M1 the turn path, M2 memory he cannot correct,
|
||||
M3 step-up. M1 and M2 touch different code and owe different docs. Step-up is
|
||||
configuration, not honesty, and sits before M4 because M4 is what first makes
|
||||
acts real.
|
||||
- **Five capabilities deferred past v1** (owner's call, 2026-08-15): speaker
|
||||
recognition, smart home, bluetooth control, model swap, self-update. Bluetooth
|
||||
was on the v1 list and came off it. Their spec entries keep their DoD.
|
||||
- **A milestone closes its own doc gaps and writes its own scenarios.** Neither
|
||||
becomes a milestone of its own. Otherwise the 17 missing docs and 46 missing
|
||||
scenario files collect at the end.
|
||||
- **Learning means behavioral, not weights.** Stored outcomes only. No adapter, no
|
||||
training set.
|
||||
- **Email and calendar need the product decision before deploying.** Both are
|
||||
built and neither is in `docker-compose.yml`. That is M8.
|
||||
|
||||
## Findings the spec pass produced
|
||||
|
||||
- **Recurring reminders do not exist on the spoken path.** `store.Reminder` carries
|
||||
`Cron` and `ipc.CreateReminder` takes one. `grep "Cron:" --include='*.go'`
|
||||
outside tests returns only `internal/ipc/client.go`, `internal/ipc/storeapi.go`
|
||||
and `cmd/mavend/tick_routines.go`, and the last is routines, a separate
|
||||
mechanism. Pills, the dog, the vet and the kibble are unbuilt on top of finished
|
||||
storage and delivery. This is M6.
|
||||
- **Seventeen capabilities have no living doc.** Memory is the worst cluster:
|
||||
facts, notes and the digestion worker have no owning document at all.
|
||||
- **Webhooks barely exist.** The only one in the tree is
|
||||
`internal/delivery/telegramsink/intake.go`, Telegram's own inbound hook.
|
||||
- **Command chaining does not exist.** The `chain` in `internal/router` is the
|
||||
world chain and the source chain.
|
||||
- **Desk notifications are inbound only.** `ambient:notif` reads his desktop.
|
||||
There is no outbound desk reach, and which direction he meant is undecided.
|
||||
- **Only 5 of 51 spec entries cite a scenario that exists.**
|
||||
|
||||
## Not filed, and this is the risk
|
||||
|
||||
Vikunja returned 503 across this session and the last, so **none of this has a
|
||||
task id**. The three commits above are tagged `V-719`, which is the
|
||||
reminder-cancellation task, not this work. Retag or file when Vikunja is back.
|
||||
|
||||
Unfiled, listed again at the end of `docs/roadmap.md`:
|
||||
|
||||
1. The capability audit itself, headed "unfiled".
|
||||
2. Remember-versus-query misroute, two of seven audit probes.
|
||||
3. The masculine reply on the wire, caught live while `CheckFeminine` passed.
|
||||
4. Recurring reminders having no caller.
|
||||
5. The seventeen capabilities with no living doc.
|
||||
6. The 46 scenario files the spec names and does not have.
|
||||
|
||||
## Next
|
||||
|
||||
Open a session on M1, which is three gate items on the turn path and owes no new
|
||||
doc. If Vikunja is up, file the six above first and give M1 a real id.
|
||||
|
||||
One command still outstanding from the last session, cheap and unrelated:
|
||||
|
||||
```sh
|
||||
docker compose up -d --force-recreate mavsttd mavttsd mavpoll
|
||||
```
|
||||
-341
@@ -1,341 +0,0 @@
|
||||
# Maven completion journal
|
||||
|
||||
This journal tracks the autonomous completion goal started on 2026-08-13. It
|
||||
is an operational index, not a substitute for living subsystem documentation,
|
||||
dated evaluations, Vikunja tasks, or focused caveat entries.
|
||||
|
||||
## 2026-08-13 — baseline and backlog reconstruction
|
||||
|
||||
Goal: make Maven usable end to end with every current and planned feature
|
||||
wired, tested, and polished. Completion requires clean automated gates and
|
||||
successful sessions across local, degraded-ecosystem, and integrated modes.
|
||||
|
||||
Initial observations:
|
||||
|
||||
- `HEAD` is `2cf8b7e`, identical to both local and remote `master`, while the
|
||||
checked-out branch is the stale `task/704-...` branch.
|
||||
- The worktree already contained staged documentation/evaluation changes,
|
||||
staged transcript deletions, an unstaged `deploy/mavwaked.service` change,
|
||||
and untracked `deploy/asoundrc`. These are pre-existing work and are being
|
||||
preserved and validated before any commit.
|
||||
- The repository has no prior goal journal. Durable subsystem facts continue
|
||||
to belong under `docs/`; unresolved limits continue to belong under
|
||||
`docs/caveats/` with a task and revisit trigger.
|
||||
|
||||
Work streams started:
|
||||
|
||||
- Vikunja project 2: inventory every open task and recover acceptance criteria.
|
||||
- Repository: compare feature plans, caveats, routing/ecosystem contracts, and
|
||||
implementation.
|
||||
- Verification: run build, race tests, simulator, analyzer gates, and inspect
|
||||
skipped hardware/model evaluations separately.
|
||||
- Runtime: exercise the web, IPC, voice, model, and sibling-service paths with
|
||||
real local dependencies where available and explicit degraded-mode probes
|
||||
otherwise.
|
||||
|
||||
References: `docs/workflow.md`, `docs/qa.md`, `docs/ecosystem.md`,
|
||||
`docs/routing.md`, `docs/caveats/CLAUDE.md`, and Vikunja Maven project 2.
|
||||
|
||||
### Backlog correction
|
||||
|
||||
The first Vikunja page was accidentally read without a `done: false` filter and
|
||||
mixed closed history into the working set. Re-querying all pages strictly open
|
||||
produced 127 records: 89 implementation-open, 20 shipped-but-QA-open, 12
|
||||
external/owner gates, and 6 duplicate or stale-open records. Closed tasks are
|
||||
used only as commit provenance; they are not work to redo. V-704 was the only
|
||||
open hanging task and was closed after its measured correction landed.
|
||||
|
||||
### Model-aware baseline
|
||||
|
||||
The ordinary `make test` passed but does not set `MAVEN_ONNX_LIB`, so model-aware
|
||||
tests can self-skip. The explicit ONNX boundary gate exposed V-702/V-703: the
|
||||
held-out `я рассказывал тебе про байкал?` was the sole miss at 28/29. A
|
||||
three-neighbour class score fixed the semantic collision without adding a word
|
||||
pattern or copying the held-out sentence. Boundary is now 29/29 and the adjacent
|
||||
topic gate remains 43/43. Measurement:
|
||||
`docs/evals/2026-08-13-personal-boundary-neighbourhood.md`.
|
||||
|
||||
That narrow result was not accepted as the completion gate. A second agent
|
||||
wrote a balanced 72-case RU/EN matrix across remembered speech, possession,
|
||||
narrative, proper nouns, personal preambles, and advice/current questions. It
|
||||
contains no production seeds and no Baikal paraphrase. The top-three candidate
|
||||
scores only 61/72 (84.7%); top-two reaches 62/72, one-neighbour 56/72, and a
|
||||
whole-class centroid 54/72. V-702 therefore remains open while a principled
|
||||
classifier is developed against the independent matrix. The 29/29 measurement
|
||||
describes the narrow regression set, not general boundary quality.
|
||||
|
||||
### Live delivery incident
|
||||
|
||||
The five-service compose stack was running, but a due reminder was being
|
||||
re-phrased and retried through ntfy every tick. The sink returned HTTP 403 each
|
||||
time. Only secret names were inspected: the configured ntfy and workstation
|
||||
token variables were absent from the deployed environment file; no secret
|
||||
values were read into this journal. The durable outbox records each failed
|
||||
attempt, but the retry path has no backoff or alternate channel and spends the
|
||||
resident model again before every failure. This is active V-651 behavior, with
|
||||
the repeated-phrasing shape related to V-687.
|
||||
|
||||
V-715 now owns the incident acceptance criteria. In the working tree, phrases,
|
||||
collapsed-group identity, attempts, and next-attempt time are durable; definite
|
||||
failure backs off from one minute to a capped hour; retries and restarts reuse
|
||||
the exact phrase; and away delivery tries ntfy then Telegram, stopping at the
|
||||
first success. The committed deployment explicitly disables the uncredentialed
|
||||
ntfy block. Independent review added a real occurrence key for collapsed
|
||||
bundles, suppresses crash-ambiguous attempts from automatic replay, classifies
|
||||
HTTP 401/403 as permanent, blocks permanently unreachable reminders visibly,
|
||||
and commits the successful outbox result plus every collapsed original in one
|
||||
SQLite transaction. The store, delivery, IPC, loop, config, and mavweb race
|
||||
suites pass. A live rebuild and one-time delivery of the existing backlog are
|
||||
still required before V-715 is closed.
|
||||
|
||||
### Explicit integration enablement
|
||||
|
||||
V-691's deployment boundary was audited against every `${VAR}` reference. The
|
||||
canonical `deploy/telegram.env.example` now names Telegram, ntfy, workstation
|
||||
model, workstation STT, Home Assistant, ambient, CW2, and database-key inputs.
|
||||
Enabled Telegram, ntfy, ambient, non-loopback workstation model, and
|
||||
non-loopback workstation STT paths refuse missing credentials; each arm has an
|
||||
explicit disabled state. The live config disables the currently uncredentialed
|
||||
ntfy and workstation-model arms while retaining the separately credentialed
|
||||
STT arm. CW2 also refuses a non-loopback bind without its token.
|
||||
|
||||
Focused Go race suites, the Python CW2 startup contract, deploy-config drift
|
||||
test, secret-expanded config validation (values not printed), and
|
||||
`docker compose config --quiet` pass. The workpc is unreachable from this host,
|
||||
so installing the updated CW2 script there remains an external deployment step;
|
||||
the affected model arm is explicitly dark rather than ambiguously half-live.
|
||||
|
||||
### Traceable web failures
|
||||
|
||||
V-689 gives every mavweb response a server-generated request ID and routes
|
||||
every handler failure through one sanitized problem envelope. Stable error
|
||||
codes and the request ID reach the browser; the wrapped internal error reaches
|
||||
only the server log beside the same ID. Degraded inline panels use stable public
|
||||
text rather than backend paths or tokens, and direct ecosystem reads propagate
|
||||
the web request ID as their correlation ID. An AST guard prevents new production
|
||||
handlers from bypassing the contract with `http.Error`. The full mavweb race
|
||||
suite passes, including disclosure, untrusted-ID, log-join, and propagation
|
||||
tests.
|
||||
|
||||
### Bounded external responses
|
||||
|
||||
Three audit defects were repaired and committed directly to `master`:
|
||||
|
||||
- V-608 (`d7e8804`): llama completion responses are capped at 1 MiB, including
|
||||
the LAN workstation seam.
|
||||
- V-675 (`459fe7a`): remote STT requires nonblank text and an explicit finite
|
||||
confidence in `[0,1]`, caps JSON at 64 KiB, and falls back to mavsttd on a
|
||||
malformed HTTP 200.
|
||||
- V-676 (`7d0250a`): Open-Meteo geocoding and forecasts are bounded, required
|
||||
fields are nullable/validated, and coordinates/weather values are range
|
||||
checked so `{}` cannot become plausible zero-degree weather.
|
||||
|
||||
Each focused race suite passed and each task was closed only after the commit.
|
||||
|
||||
### Transport shutdown
|
||||
|
||||
V-679 (`de61b75`) adds the listener's `done` channel to TCP `Accept`. A
|
||||
concurrent-close test holds a silent peer in handshake and proves an in-flight
|
||||
accept returns `net.ErrClosed`; the race test passed twenty consecutive runs.
|
||||
|
||||
V-688 (`80b6068`, caveat retirement `a0e6643`) bounds the browser push-to-talk
|
||||
body at ten minutes of mono PCM and configures header, idle, and read limits on
|
||||
the web server. The unused `/ws` handler was removed instead of retaining a
|
||||
second unauthenticated streaming transport with no browser caller. Focused race
|
||||
tests prove the exact-size request succeeds and an oversized request returns
|
||||
HTTP 413.
|
||||
|
||||
### Conversation continuity
|
||||
|
||||
V-542 (`da9114b`) repairs the five-turn monitor conversation without changing
|
||||
single-turn intent classification. Exact user utterances are now persisted
|
||||
separately from normalized intent slots and retained in chronological order.
|
||||
An anaphoric query with live transcript context reaches the chat path, while
|
||||
non-anaphoric sources are unchanged and acts stay fail-closed. An explicit
|
||||
conversation opener extends the session lifetime through later fact/query/chat
|
||||
routes without suppressing the grounded fact write.
|
||||
|
||||
The deterministic scenario now names the monitor in all four contextual
|
||||
replies, proves that the original raw turn reached `PhraseChat` four times,
|
||||
stores the fact once, and produces zero unsolicited sends across five turns and
|
||||
one tick. Focused race tests passed for `cmd/mavend`, `internal/dialogue`,
|
||||
`internal/router`, and `internal/lexicon`. Measurement:
|
||||
`docs/evals/2026-08-13-conversation-continuity.md`.
|
||||
|
||||
### Personal-data boundary
|
||||
|
||||
V-702 replaces the narrow nearest-neighbour privacy gate with a frozen,
|
||||
class-balanced logistic head over multilingual-e5-small. It introduces no
|
||||
lexical exception and leaves the decision threshold at 0.5. Historical
|
||||
regressions score 29/29 and the balanced 72-case RU/EN fixture scores 72/72.
|
||||
|
||||
The first 24-case challenge found one private-configuration miss. That result
|
||||
was treated as model-selection data rather than advertised as independent
|
||||
proof. Shrinkage LDA and an LDA/logistic ensemble repaired it but regressed the
|
||||
72-case gate, so both were rejected. Increasing the logistic L2 coefficient
|
||||
from 0.0001 to 0.0003 repairs the miss while improving four-fold corpus
|
||||
cross-validation from 97/104 to 99/104 and whole-shape holdout from 91/104 to
|
||||
92/104. A fresh 24-case challenge written only after that head was frozen scores
|
||||
24/24 at minimum signed probability margin +0.1718. The original challenge is
|
||||
also 24/24 but its +0.0001 edge remains documented as a regression, not fresh
|
||||
evidence. Full measurement:
|
||||
`docs/evals/2026-08-13-personal-boundary-linear-head.md`.
|
||||
|
||||
During the audit, running multiple ONNX-backed tests in one `go test` process
|
||||
showed that only the first initializes; later tests self-skip because the
|
||||
runtime is process-global. All V-702 figures were therefore rerun in separate
|
||||
processes. V-716 tracks fixing that harness gap rather than hiding it in this
|
||||
feature.
|
||||
|
||||
### Forced dialogue and repair state
|
||||
|
||||
V-573 closes all four repair seams exposed by the dialogue contract: a
|
||||
correction wins before a parked clarify answer; a repaired decision is checked
|
||||
for required slots before acting; a request completed through clarification is
|
||||
correctable; and declined or stale repairs do not prematurely spend the repair
|
||||
pointer. Same-intent corrections are handled explicitly without redoing the
|
||||
action, so their prose cannot route fresh and overwrite the retained pointer.
|
||||
|
||||
The independent state audit found two deeper stack losses. A handled repair
|
||||
could leave an older question silently parked with its old TTL, and a repaired
|
||||
request needing clarification could overwrite—or, on completion, delete—the
|
||||
older flow. Repairs now suspend and audibly resume live questions, repaired
|
||||
questions push onto the bounded dialogue stack, and completion/cancellation
|
||||
pops only the active top before resuming the flow underneath.
|
||||
|
||||
`MAVEN_DIALOGUE_NO_SKIP=1 go test -race ./cmd/mavend -run
|
||||
'^TestDialogueTraces$' -count=1` passes all 22 traces. The complete forced
|
||||
`cmd/mavend` race suite passes in 208.031s. The integrated race command over
|
||||
`cmd/mavend`, `internal/dialogue`, and all `internal/router` packages also
|
||||
passes (162.310s for mavend; every package green). Focused structural
|
||||
possession, repair-pointer, nested-stack, and repaired-clarify tests pass under
|
||||
the race detector.
|
||||
|
||||
### ONNX test/runtime lifecycle
|
||||
|
||||
V-716 found that each embedder constructor tried to initialize ONNX Runtime,
|
||||
while `Close` destroyed only its session. In one package process the first
|
||||
model-aware test ran and later tests converted “already initialized” into a
|
||||
green skip. The router now owns the process-global environment through
|
||||
reference-counted leases held by each embedder and routing-head session; the
|
||||
last owned lease performs cleanup, and close is idempotent.
|
||||
|
||||
The router and mavend test packages hold a lease across their model gates.
|
||||
`make eval-router` additionally requires proof that both named aggregate gates
|
||||
actually executed. In one process the classifier baseline scored 72/96 and the
|
||||
routing heads 93/96; destination was 11/33 and 25/33 respectively, and ecosystem
|
||||
reach remained 28/30. The lifecycle reacquire test, focused race suite, full
|
||||
aggregate command, and portable no-runtime packages all pass. Measurement:
|
||||
`docs/evals/2026-08-13-onnx-runtime-lifecycle.md`.
|
||||
|
||||
### Clarification exhaustion is fail-closed
|
||||
|
||||
V-717 closes the terminal-policy hole found during the V-573 audit. A request
|
||||
with two required gaps could spend its only question on the first, fill that
|
||||
slot, and then reach `applyAction` with the second still absent. The attempt cap
|
||||
was accidentally acting as permission to execute a partial action.
|
||||
|
||||
The resolver now rebuilds the pending action and re-runs the canonical
|
||||
`missingFor` schema after every filled gap. One remaining gap produces exactly
|
||||
one next question only while the shared `PendingAction.CanAsk` budget permits
|
||||
it. Exhaustion visibly gives up, removes only the active stack level, and makes
|
||||
no write or action. `finishRebuilt` repeats the same invariant at the execution
|
||||
boundary. Reminder time answers remain separate from the clean payload but are
|
||||
included in the schema decision used for validation.
|
||||
|
||||
The original `TestClarifySecondGapRespectsTheAttemptCap` now asserts the exact
|
||||
give-up and zero reminders. New tests cover direct boundary refusal and a
|
||||
two-level stack where exhausting the top appends the surviving lower question
|
||||
to the same reply. The focused V-717 race cases pass in 4.529s; every clarify
|
||||
case plus all 22 forced dialogue traces pass under the race detector in
|
||||
26.202s; `internal/dialogue` passes under race in 2.293s. Routing contract:
|
||||
`docs/routing.md` section “Required slots and attempt exhaustion”.
|
||||
|
||||
### A suppressed nudge is identified before it is phrased
|
||||
|
||||
V-687 closes the phrase-before-dedupe hole in the digestion worker. The dedupe
|
||||
was reported by `EnqueueDigestEntry`, which runs after `PhraseNudge` has already
|
||||
been paid, and the `else if deduped { continue }` meant to skip the cost was the
|
||||
last statement in the loop body. Every tick that kept suppressing the same rule
|
||||
spent the resident model again, against the cache claim in the comment above it.
|
||||
|
||||
The fix gives a rule a durable semantic identity instead of hashing its prose. A
|
||||
rule eligible for the digest declares `DigestIdentity`, a function of state
|
||||
beside its predicate; `loop.DigestCandidateFingerprint` frames the rule name and
|
||||
severity around it so two rules cannot alias on a shared fact. `BreakRule`
|
||||
anchors on the last completed break rather than on `desk_active`, which is
|
||||
freshness evidence the poller refreshes without the unmet need changing. A rule
|
||||
with no declared identity does not enter the digest, because inventing a generic
|
||||
state hash would either change every tick or ignore an input the rule reads.
|
||||
|
||||
`tick_digest.go` now looks up `LiveDigestEntry` by rule and fingerprint before
|
||||
phrasing. Migration #26 adds `candidate_fingerprint` with a partial unique index
|
||||
over live pending rows; `EnqueueDigestEntry` expires a matching stale row and
|
||||
inserts inside one transaction, so sweep order is not part of correctness and a
|
||||
second caller cannot race the pre-phrase read into a duplicate. Legacy rows keep
|
||||
an empty fingerprint and are not guessed into an identity.
|
||||
|
||||
Six tests cover the contract: one phrase call across three suppressed ticks,
|
||||
zero after a daemon restart, and two when the meaning changes, when the entry
|
||||
expires, and when it has been drained. `./cmd/mavend/ -run TestSuppressedCareDigest`
|
||||
passes under race in 4.626s, the digest store and loop cases in 4.123s and
|
||||
1.046s, and the three full packages in 264.076s, 64.496s and 4.280s. The caveat
|
||||
`docs/caveats/workers.md#nudges` and the `SA4006` baseline entry are deleted.
|
||||
|
||||
## 2026-08-15 — authoritative-state continuation
|
||||
|
||||
The continuation began by treating the checked-out tree and external task state
|
||||
as authoritative. `master` was clean, identical to `origin/master`, and already
|
||||
contained the V-717 and V-687 branch merges. Their interrupted worker messages
|
||||
were therefore not used as evidence of missing work.
|
||||
|
||||
### Live reminder catch-up closes V-570 and V-715
|
||||
|
||||
The rebuilt stack loaded the resident Qwen model, multilingual ONNX embedder,
|
||||
and routing heads. On its first eligible tick it phrased the three overdue
|
||||
reminders once as one catch-up message. Disabled ntfy left one durable failed
|
||||
attempt for delivery group `reminder #83`; ordered fallback then left exactly
|
||||
one successful Telegram attempt for the same group. All originals became
|
||||
`fired`. Four later ticks produced no second phrase, send, pending attempt, or
|
||||
unknown attempt. `/`, `/reminders`, and `/notifications` each returned HTTP 200
|
||||
with a server-generated request ID. This supplies the live evidence that was
|
||||
still missing in the 2026-08-13 entry; V-715 and the stale-open V-570 are closed.
|
||||
|
||||
The first HTTP repro command also exposed a zsh test-harness trap: `path` is a
|
||||
special array tied to `PATH`, so using it as a loop variable removed command
|
||||
lookup inside the loop. The corrected probe used `probe_path` and `wget
|
||||
--no-proxy`; the deployed web process had not crashed.
|
||||
|
||||
### Stale-open task reconciliation
|
||||
|
||||
The open Vikunja list was read with `done: false` and compared with the current
|
||||
tree before choosing work. V-397 already described itself as done and merged;
|
||||
V-557 is present as `ea0eb16` plus the forced missing-slot dialogue contract;
|
||||
V-570 is covered by the live catch-up proof above. Those three records were
|
||||
closed rather than reimplemented. V-651 remains open: its nil-sink and reminder
|
||||
spin halves landed with V-715, but the first failed severity-4 Telegram send is
|
||||
still not represented in the repeat-until-ack stream.
|
||||
|
||||
### Stable hash-floor performance evidence [V-718]
|
||||
|
||||
`TestPersonalBoundaryHashFloorLatency` coupled correctness to ambient machine
|
||||
load while running a numeric training loop under race and coverage
|
||||
instrumentation. It is now a deterministic fit-and-score test that also proves
|
||||
the 1024-dimensional head was built. Elapsed time remains observable through
|
||||
`BenchmarkPersonalBoundaryHashFloorFitAndScore`, where three one-iteration runs
|
||||
on this host measured 75.1 ms, 76.8 ms and 81.4 ms without making those host
|
||||
figures a CI pass condition.
|
||||
|
||||
The exact race-plus-coverage focused test passed in 9.445s. `make test` then
|
||||
passed on its first run: formatting, vet, CW2 configuration tests, and every
|
||||
internal and command package under race plus coverage; `cmd/mavend` completed
|
||||
in 205.712s. This closes V-718 without raising a brittle timeout.
|
||||
|
||||
### Delegation availability
|
||||
|
||||
All three available subagent slots were filled: clarification exhaustion,
|
||||
durable nudge identity, and a read-only live deployment probe. The first two
|
||||
left complete merges on `master`; all three later reported the same shared
|
||||
Codex usage limit, with capacity unavailable until 2026-08-20. Work continues
|
||||
serially. The temporary constraint and revisit trigger are recorded at
|
||||
`docs/caveats/workers.md#agent-quota` under the V-714 completion umbrella.
|
||||
@@ -4,7 +4,7 @@
|
||||
# `test` below fail on the two packages that have no test files. deps-go builds
|
||||
# the missing tools in, so the vendored tree is self-sufficient. Keep the version
|
||||
# here in step with the `go` directive in go.mod.
|
||||
GO_VERSION := 1.25.12
|
||||
GO_VERSION := 1.25.5
|
||||
GO := $(shell pwd)/deps/go/go/bin/go
|
||||
export GOTOOLCHAIN := local
|
||||
GOFLAGS :=
|
||||
@@ -16,11 +16,11 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
.PHONY: t audit simulate stt-fixtures test-stt-golden test-cw2-config 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 deps-vuln vuln deps-lint lint deadcode analyze tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
.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
|
||||
|
||||
all: build
|
||||
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update build-gpud
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update
|
||||
|
||||
build-stt:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
@@ -59,12 +59,6 @@ build-mail:
|
||||
build-update:
|
||||
$(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
|
||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||
|
||||
@@ -73,8 +67,8 @@ run-web: build-web
|
||||
# builds them on demand, but `go test -coverprofile` calls covdata through
|
||||
# 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_SHA256 := 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
|
||||
deps-go: deps-sentinel
|
||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||
deps-go:
|
||||
@mkdir -p deps/go
|
||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
||||
@@ -84,89 +78,6 @@ deps-go: deps-sentinel
|
||||
done
|
||||
$(GO) version
|
||||
|
||||
# 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
|
||||
|
||||
# vuln — the advisory gate the 2026-08-10 audit found missing (V-682). It reads
|
||||
# the published database over the network, so it is not part of `test`, which
|
||||
# has to pass on a box with no route out. Run it before a toolchain or
|
||||
# dependency bump lands, because that is what it grades: on 2026-08-11 the
|
||||
# pinned Go 1.25.5 and x/text 0.14.0 carried 20 reachable advisories and the
|
||||
# bumped pair carries none.
|
||||
#
|
||||
# govulncheck is a tool and not a dependency, so it is installed into deps/ like
|
||||
# the toolchain rather than added to go.mod. The version is pinned here for the
|
||||
# same reason GO_VERSION is: a gate that moves on its own is not a gate.
|
||||
GOVULNCHECK_VERSION := v1.6.0
|
||||
GOVULNCHECK := $(shell pwd)/deps/bin/govulncheck
|
||||
|
||||
deps-vuln: deps-sentinel
|
||||
@mkdir -p deps/bin
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
|
||||
|
||||
# The CGO env is the same one `test` carries: govulncheck loads the packages,
|
||||
# and the four CGO daemons do not load without it.
|
||||
vuln: deps-vuln
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local $(GOVULNCHECK) ./...
|
||||
|
||||
# lint and deadcode — the other two analyzers the 2026-08-10 audit asked for
|
||||
# (V-694). They are not part of `test` for the same reason `vuln` is not: they
|
||||
# install over the network, and they are slow enough that a change to one Go
|
||||
# file should not pay for them.
|
||||
#
|
||||
# Neither reports zero, so neither fails on its own output. The accepted set
|
||||
# lives in scripts/analyzers/*.baseline and scripts/analyzer-gate.sh decides.
|
||||
# What is new fails, and so does a baseline entry whose finding is gone.
|
||||
#
|
||||
# deadcode runs with -test, so a test file is a root. Without it the report is
|
||||
# 172 lines, most of internal/router/eval, and none of it is a mistake.
|
||||
STATICCHECK_VERSION := v0.7.0
|
||||
DEADCODE_VERSION := v0.48.0
|
||||
STATICCHECK := $(shell pwd)/deps/bin/staticcheck
|
||||
DEADCODE := $(shell pwd)/deps/bin/deadcode
|
||||
|
||||
deps-lint: deps-sentinel
|
||||
@mkdir -p deps/bin
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION)
|
||||
|
||||
# Both load the packages, so both carry the CGO env `test` carries. Without it
|
||||
# the four CGO daemons do not load and the analyzer reports a build error
|
||||
# instead of a finding -- which analyzer-gate.sh fails on rather than filters.
|
||||
ANALYZER_ENV = CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" \
|
||||
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local
|
||||
|
||||
lint: deps-lint
|
||||
@$(ANALYZER_ENV) $(STATICCHECK) ./... | scripts/analyzer-gate.sh staticcheck
|
||||
|
||||
deadcode: deps-lint
|
||||
@$(ANALYZER_ENV) $(DEADCODE) -test ./... | scripts/analyzer-gate.sh deadcode
|
||||
|
||||
# Every static gate in one command. Not `check`, because it is not the thing to
|
||||
# run before a commit: vuln reads the network and all three are slow.
|
||||
analyze: lint deadcode vuln
|
||||
|
||||
# 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.
|
||||
# Run `gofmt -w` on whatever this prints.
|
||||
@@ -188,63 +99,19 @@ simulate:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -v -count=1 -run TestSimulator ./cmd/mavend/
|
||||
|
||||
test-cw2-config:
|
||||
python3 -m unittest discover -s deploy/cw2 -p 'test_*.py'
|
||||
|
||||
test: fmt-check vet test-cw2-config
|
||||
test: fmt-check vet
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
||||
|
||||
# t — run ONE package or ONE test with the toolchain env already wired. This is
|
||||
# the iteration target; `test` is the gate. Reach for it instead of pasting the
|
||||
# CGO_CFLAGS/CGO_LDFLAGS/LD_LIBRARY_PATH preamble by hand, which is how it was
|
||||
# done ~390 times across past sessions and is where the shell-quoting failures
|
||||
# came from -- the interactive shell here is zsh, and an unquoted `-run Test*`
|
||||
# or `--include=*.go` dies on "no matches found" before go ever starts.
|
||||
#
|
||||
# make t # whole tree (same scope as `test`)
|
||||
# make t PKG=./internal/router/
|
||||
# make t PKG=./cmd/mavend/ RUN=TestSimulator
|
||||
# make t PKG=./internal/router/eval/ RUN='TestONNX' V=1
|
||||
# make t PKG=./internal/store/ RACE=0 # drop -race when iterating hot
|
||||
#
|
||||
# -race is on by default so a green `make t` cannot turn red under `make test`.
|
||||
# -count=1 because a cached PASS from before your edit is worse than no answer.
|
||||
# MAVEN_ONNX_LIB is set for the same reason: the four TestONNX* measurements
|
||||
# self-skip when it is unset, so a targeted eval run would otherwise report the
|
||||
# deterministic hash ratchet and look like it scored the real embedder.
|
||||
PKG ?= ./internal/... ./cmd/...
|
||||
RUN ?=
|
||||
V ?=
|
||||
RACE ?= 1
|
||||
|
||||
t:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" \
|
||||
$(GO) test $(if $(V),-v,) $(if $(filter-out 0,$(RACE)),-race,) -count=1 \
|
||||
$(if $(RUN),-run '$(RUN)',) $(PKG)
|
||||
|
||||
# eval-router — score the held-out RU routing fixture (internal/router/eval).
|
||||
# Verbose so the report tables land in the terminal. MAVEN_ONNX_LIB points the
|
||||
# prod-representative baseline at the vendored runtime; override it or set it
|
||||
# empty to run only the deterministic hash ratchet. This is the measurement
|
||||
# Vikunja #319 compares before #320 flips the route decider. With a non-empty
|
||||
# runtime path the package must prove that at least two model gates executed;
|
||||
# a constructor skip after the first process-global initialization is a failure.
|
||||
# Vikunja #319 compares before #320 flips the route decider.
|
||||
MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so
|
||||
|
||||
eval-router:
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" \
|
||||
MAVEN_ONNX_REQUIRED_GATES="$(if $(MAVEN_ONNX_LIB),2,0)" \
|
||||
$(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/
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
|
||||
|
||||
# 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,
|
||||
@@ -289,16 +156,6 @@ eval-models:
|
||||
# scores the fixtures against ggml-small and self-skips when the model is
|
||||
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
|
||||
# and the manifest with no model at all.
|
||||
# audit — the repo inventory: LOC per package, open TODOs, real stubs, living-doc
|
||||
# staleness, test shape, packages with no test. Read-only, prints, writes nothing.
|
||||
# Run it instead of rebuilding the same greps by hand; past sessions spent 93 of
|
||||
# them on this before their first edit. SECTION=loc|todo|stubs|docs|tests|gaps
|
||||
# narrows it.
|
||||
SECTION ?= all
|
||||
|
||||
audit:
|
||||
@SECTION="$(SECTION)" ./scripts/audit.sh
|
||||
|
||||
stt-fixtures:
|
||||
./scripts/gen-stt-fixtures.sh
|
||||
|
||||
@@ -315,7 +172,7 @@ run-tts: build-tts
|
||||
./mavttsd -socket /tmp/maven/tts.sock \
|
||||
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
||||
|
||||
deps: deps-sentinel deps-whisper deps-piper
|
||||
deps: deps-whisper deps-piper
|
||||
|
||||
deps-whisper:
|
||||
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||
@@ -334,7 +191,7 @@ deps-piper:
|
||||
# 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.
|
||||
# The quantized file is the one we download, deploy and measure — see
|
||||
# docs/evals/2026-08-04-recall-e5-small.md for what the swap bought.
|
||||
# docs/evals/2026-07-31-recall.md.
|
||||
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_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,284 +0,0 @@
|
||||
// e2eprobe is a temporary typed IPC driver used by the 2026-08-15 isolated
|
||||
// whole-Maven acceptance session. It is removed after the session; keeping the
|
||||
// driver inside the module lets it import Maven's internal IPC contract rather
|
||||
// than peeking into sqlite.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "e2eprobe:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
fs := flag.NewFlagSet("e2eprobe", flag.ContinueOnError)
|
||||
sock := fs.String("sock", "", "mavend unix socket")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
argv := fs.Args()
|
||||
if len(argv) == 0 {
|
||||
return errors.New("usage: e2eprobe -sock PATH COMMAND [ARGS]")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
if argv[0] == "score-pair" {
|
||||
out, err := scorePair(ctx, argv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return encode(out)
|
||||
}
|
||||
if argv[0] == "parse-task-status" {
|
||||
if len(argv) != 2 {
|
||||
return errors.New("parse-task-status needs TEXT")
|
||||
}
|
||||
parsed, ok := router.ParseTaskStatus(argv[1])
|
||||
return encode(map[string]any{"accepted": ok, "parsed": parsed})
|
||||
}
|
||||
if *sock == "" {
|
||||
return errors.New("usage: e2eprobe -sock PATH COMMAND [ARGS]")
|
||||
}
|
||||
|
||||
cli, err := ipc.DialWait(*sock, 15*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
var out any
|
||||
switch argv[0] {
|
||||
case "ping":
|
||||
out, err = cli.Ping(ctx)
|
||||
case "chat":
|
||||
if len(argv) < 3 {
|
||||
return errors.New("chat needs CONVERSATION TEXT")
|
||||
}
|
||||
out, err = cli.Chat(ctx, argv[1], strings.Join(argv[2:], " "))
|
||||
case "create-reminder":
|
||||
if len(argv) < 3 || len(argv) > 4 {
|
||||
return errors.New("create-reminder needs RFC3339 TEXT [CRON]")
|
||||
}
|
||||
fire, parseErr := time.Parse(time.RFC3339, argv[1])
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
cron := ""
|
||||
if len(argv) == 4 {
|
||||
cron = argv[3]
|
||||
}
|
||||
id, createErr := cli.CreateReminder(ctx, fire, `{"text":`+quote(argv[2])+`}`, cron)
|
||||
out, err = map[string]any{"id": id}, createErr
|
||||
case "cancel-reminder":
|
||||
id, parseErr := oneID(argv)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
err = cli.CancelReminder(ctx, id)
|
||||
out = map[string]any{"cancelled": id}
|
||||
case "mark-reminder":
|
||||
if len(argv) != 3 {
|
||||
return errors.New("mark-reminder needs ID STATUS")
|
||||
}
|
||||
id, parseErr := strconv.ParseInt(argv[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
err = cli.MarkReminder(ctx, id, argv[2])
|
||||
out = map[string]any{"marked": id, "status": argv[2]}
|
||||
case "reminders":
|
||||
n, parseErr := optionalN(argv, 200)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.ListReminders(ctx, n)
|
||||
case "pending-reminders":
|
||||
n, parseErr := optionalN(argv, 0)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.ListPendingReminders(ctx, n)
|
||||
case "create-task":
|
||||
if len(argv) != 2 {
|
||||
return errors.New("create-task needs TEXT")
|
||||
}
|
||||
out, err = cli.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||
Text: argv[1], Source: "tap:web", Status: store.TaskOpen, Ts: time.Now(),
|
||||
})
|
||||
case "tasks":
|
||||
status := "live"
|
||||
if len(argv) == 2 {
|
||||
status = argv[1]
|
||||
} else if len(argv) != 1 {
|
||||
return errors.New("tasks takes optional STATUS")
|
||||
}
|
||||
out, err = cli.ListTasks(ctx, status)
|
||||
case "notes":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentNotes(ctx, n)
|
||||
case "query-notes":
|
||||
if len(argv) != 2 {
|
||||
return errors.New("query-notes needs TEXT")
|
||||
}
|
||||
embedder, embedErr := router.NewONNXEmbedder(
|
||||
"models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||
"models/embedder/multilingual-e5-small/tokenizer.json",
|
||||
"deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so",
|
||||
)
|
||||
if embedErr != nil {
|
||||
return embedErr
|
||||
}
|
||||
defer embedder.Close()
|
||||
vec, embedErr := router.EmbedQuery(ctx, embedder, argv[1])
|
||||
if embedErr != nil {
|
||||
return embedErr
|
||||
}
|
||||
out, err = cli.QueryNotes(ctx, vec, 10)
|
||||
case "score-pair":
|
||||
out, err = scorePair(ctx, argv)
|
||||
case "facts":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentFacts(ctx, n)
|
||||
case "decisions":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.TurnDecisions(ctx, n)
|
||||
case "events":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentEvents(ctx, n)
|
||||
case "eco-traces":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentEcosystemTraces(ctx, n)
|
||||
case "delivery-attempts":
|
||||
status := ""
|
||||
if len(argv) == 2 {
|
||||
status = argv[1]
|
||||
} else if len(argv) != 1 {
|
||||
return errors.New("delivery-attempts takes optional STATUS")
|
||||
}
|
||||
out, err = cli.DeliveryAttempts(ctx, status, 200)
|
||||
case "nudges":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentNudges(ctx, n)
|
||||
case "tools":
|
||||
status := ""
|
||||
if len(argv) == 2 {
|
||||
status = argv[1]
|
||||
} else if len(argv) != 1 {
|
||||
return errors.New("tools takes optional STATUS")
|
||||
}
|
||||
out, err = cli.ListTools(ctx, status)
|
||||
case "plan":
|
||||
out, err = cli.DayPlan(ctx)
|
||||
case "correct":
|
||||
if len(argv) != 3 {
|
||||
return errors.New("correct needs TRACE_ID SHOULD_BE")
|
||||
}
|
||||
id, parseErr := strconv.ParseInt(argv[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
err = cli.CorrectTurn(ctx, id, argv[2])
|
||||
out = map[string]any{"corrected": id, "should_be": argv[2]}
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", argv[0])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return encode(out)
|
||||
}
|
||||
|
||||
func encode(out any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func scorePair(ctx context.Context, argv []string) (any, error) {
|
||||
if len(argv) != 3 {
|
||||
return nil, errors.New("score-pair needs QUERY PASSAGE")
|
||||
}
|
||||
embedder, err := router.NewONNXEmbedder(
|
||||
"models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||
"models/embedder/multilingual-e5-small/tokenizer.json",
|
||||
"deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer embedder.Close()
|
||||
qvec, err := router.EmbedQuery(ctx, embedder, argv[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pvec, err := router.EmbedPassage(ctx, embedder, argv[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(qvec) != len(pvec) {
|
||||
return nil, fmt.Errorf("embedding widths differ: %d != %d", len(qvec), len(pvec))
|
||||
}
|
||||
var dot float64
|
||||
for i := range qvec {
|
||||
dot += float64(qvec[i]) * float64(pvec[i])
|
||||
}
|
||||
return map[string]any{"score": math.Round(dot*1e9) / 1e9}, nil
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func oneID(argv []string) (int64, error) {
|
||||
if len(argv) != 2 {
|
||||
return 0, errors.New("command needs ID")
|
||||
}
|
||||
return strconv.ParseInt(argv[1], 10, 64)
|
||||
}
|
||||
|
||||
func optionalN(argv []string, fallback int) (int, error) {
|
||||
if len(argv) == 1 {
|
||||
return fallback, nil
|
||||
}
|
||||
if len(argv) != 2 {
|
||||
return 0, errors.New("command takes optional N")
|
||||
}
|
||||
return strconv.Atoi(argv[1])
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Command labelgen labels utterances with the stage 0 grammars and prints JSONL.
|
||||
//
|
||||
// docs/plans/18-routing-heads-on-e5-small.md calls the labeled set the whole
|
||||
// project, and it names the stage 0 grammars as the high-precision label
|
||||
// functions to start from. This runs them — the real ones, in the real
|
||||
// buildRouter order — rather than a reimplementation, so a rule change moves
|
||||
// the training data with it.
|
||||
//
|
||||
// A grammar that declines leaves the line unlabeled. Those go to the model, and
|
||||
// keeping them is the point: a set labeled only by the rules teaches only the
|
||||
// rules.
|
||||
//
|
||||
// go run ./cmd/labelgen < utterances.txt > labeled.jsonl
|
||||
//
|
||||
// The wakeword-act grammar is absent, because its allowlist is the deployment's
|
||||
// enabled tool names and this tool has no deployment. Every other rule is here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// label is one output row. The grammar name rides along so a reviewer can see
|
||||
// which rule made the claim, and so a rule that turns out to be wrong can have
|
||||
// its rows pulled without re-running everything.
|
||||
type label struct {
|
||||
Utterance string `json:"utterance"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Fn string `json:"fn,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Labeled bool `json:"labeled"`
|
||||
}
|
||||
|
||||
// grammars is the daemon's canonical ordered stage-zero set. Label generation
|
||||
// must not maintain a second copy: that drift was the defect fixed by V-693.
|
||||
func grammars() []router.Grammar {
|
||||
return router.StageZeroGrammars(router.DefaultActMatcher{})
|
||||
}
|
||||
|
||||
func match(gs []router.Grammar, utterance string) label {
|
||||
out := label{Utterance: utterance}
|
||||
for _, g := range gs {
|
||||
d, matched, ok := g.Evaluate(utterance)
|
||||
if !matched || !ok {
|
||||
continue
|
||||
}
|
||||
out.Intent = string(d.Intent)
|
||||
out.Grammar = g.Name
|
||||
out.Key = d.Slots.Key
|
||||
out.Value = d.Slots.Value
|
||||
out.Fn = d.Slots.Fn
|
||||
out.Text = d.Slots.Text
|
||||
out.Labeled = true
|
||||
return out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
gs := grammars()
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
defer out.Flush()
|
||||
|
||||
enc := json.NewEncoder(out)
|
||||
var seen, labeled int
|
||||
for in.Scan() {
|
||||
line := strings.TrimSpace(in.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
l := match(gs, line)
|
||||
if l.Labeled {
|
||||
labeled++
|
||||
}
|
||||
if err := enc.Encode(l); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err := in.Err(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Coverage on stderr, so the count is visible without polluting the JSONL.
|
||||
fmt.Fprintf(os.Stderr, "labelgen: %d/%d labeled by %d grammars\n", labeled, seen, len(gs))
|
||||
}
|
||||
+9
-41
@@ -50,10 +50,10 @@ func run(args []string) error {
|
||||
socket := fs.String("socket", "", "core IPC socket path (required)")
|
||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||
passFile := fs.String("pass-file", "", "file holding the CalDAV basic-auth password (required — never passed as a flag value)")
|
||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
||||
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
|
||||
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
|
||||
renderPassFile := fs.String("render-pass-file", "", "file holding the password for -render-url (defaults to -pass-file)")
|
||||
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
|
||||
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
||||
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||
@@ -63,22 +63,13 @@ func run(args []string) error {
|
||||
if *socket == "" {
|
||||
return fmt.Errorf("-socket is required")
|
||||
}
|
||||
if *url == "" || *user == "" || *passFile == "" {
|
||||
return fmt.Errorf("-url, -user, -pass-file are required")
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
}
|
||||
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The password is read from a file, never taken as a flag value: an argv
|
||||
// secret is visible in `ps` to every user on the box and lands in the compose
|
||||
// file and the shell history. Same rule mavmaild and mavpoll follow. Read
|
||||
// once at start, so a rotated password means a restart.
|
||||
pass, err := readSecret(*passFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -94,20 +85,17 @@ func run(args []string) error {
|
||||
http: hc,
|
||||
url: strings.TrimRight(*url, "/"),
|
||||
user: *user,
|
||||
pass: pass,
|
||||
pass: *pass,
|
||||
}
|
||||
|
||||
var rend *renderer
|
||||
if *renderURL != "" {
|
||||
ru, rp := *renderUser, pass
|
||||
ru, rp := *renderUser, *renderPass
|
||||
if ru == "" {
|
||||
ru = *user
|
||||
}
|
||||
if *renderPassFile != "" {
|
||||
rp, err = readSecret(*renderPassFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rp == "" {
|
||||
rp = *pass
|
||||
}
|
||||
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
||||
@@ -143,21 +131,6 @@ func run(args []string) error {
|
||||
// It takes the whole read set, not one URL. The guarantee in the package
|
||||
// comment is about every calendar maven reads, and a second read target added
|
||||
// later must not quietly fall outside the check.
|
||||
// readSecret reads one credential from a file and refuses an empty one. An
|
||||
// empty file is a deployment mistake, not a password, and CalDAV basic auth
|
||||
// would send it and get a 401 every poll.
|
||||
func readSecret(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password file: %w", err)
|
||||
}
|
||||
secret := strings.TrimSpace(string(raw))
|
||||
if secret == "" {
|
||||
return "", fmt.Errorf("password file %s is empty", path)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func checkRenderTarget(readURLs []string, renderURL string) error {
|
||||
if renderURL == "" {
|
||||
return nil
|
||||
@@ -215,11 +188,6 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// maxResponseBody bounds every CalDAV response this daemon reads (the poller's
|
||||
// GET and the renderer's PROPFIND) — a misbehaving or malicious server gets a
|
||||
// truncated read, not an unbounded one.
|
||||
const maxResponseBody = 4 << 20
|
||||
|
||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
@@ -235,7 +203,7 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,38 +5,12 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The password comes from a file so it never reaches argv. An empty or missing
|
||||
// file must fail at start rather than authenticate as "" against his calendar.
|
||||
func TestReadSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
good := filepath.Join(dir, "ok")
|
||||
if err := os.WriteFile(good, []byte(" hunter2\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := readSecret(good); err != nil || got != "hunter2" {
|
||||
t.Fatalf("readSecret(good) = %q, %v; want \"hunter2\", nil", got, err)
|
||||
}
|
||||
|
||||
empty := filepath.Join(dir, "empty")
|
||||
if err := os.WriteFile(empty, []byte("\n \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readSecret(empty); err == nil {
|
||||
t.Fatal("readSecret(empty) = nil error, want refusal")
|
||||
}
|
||||
if _, err := readSecret(filepath.Join(dir, "absent")); err == nil {
|
||||
t.Fatal("readSecret(absent) = nil error, want refusal")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
||||
|
||||
@@ -142,7 +142,7 @@ func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -31,11 +31,15 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
@@ -151,3 +155,9 @@ func writeWAV(path string, a audio.Audio) error {
|
||||
// jsonUnmarshal — kept local rather than pulling encoding/json into main.go
|
||||
// top-level space.
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// keep strconv + io + net + time alive for future duration/size helpers.
|
||||
var _ = strconv.Atoi
|
||||
var _ io.Reader = (io.Reader)(nil)
|
||||
var _ = net.IPv4
|
||||
var _ = time.Second
|
||||
|
||||
+2
-3
@@ -16,7 +16,6 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -36,10 +35,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 {
|
||||
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||
return phraser.Ack(phraser.FailAck, nil), true
|
||||
return "не получилось отметить.", true
|
||||
}
|
||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return phraser.Ack(phraser.AckNudge, nil), true
|
||||
return "отлично, отметила.", true
|
||||
}
|
||||
|
||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// resolveAction produces an ActionCandidate from a routing decision. It is the
|
||||
// single boundary between routing and action execution: everything downstream
|
||||
// (refusesCommand, task-status, Praxis, Hexis, proposeGap, tool.Executor.Exec)
|
||||
// consumes the candidate rather than re-resolving the function.
|
||||
//
|
||||
// Delegates to router.ResolveActionCandidate for the resolution logic, then
|
||||
// records the outcome in the decision trace.
|
||||
func (h *reactiveHandler) resolveAction(ctx context.Context, dec router.Decision) router.ActionCandidate {
|
||||
candidate := router.ResolveActionCandidate(dec, h.matcher)
|
||||
|
||||
// Record the resolution outcome in the decision trace.
|
||||
if dec.Intent == router.IntentAct {
|
||||
if candidate.ActionResolved() {
|
||||
noteActionResolution(ctx, string(candidate.Source), candidate.Fn, true)
|
||||
} else {
|
||||
noteActionResolution(ctx, "matcher", "", false)
|
||||
}
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
// noteActionResolution records the action resolution outcome in the decision
|
||||
// trace. A nil recorder is the normal case in tests.
|
||||
func noteActionResolution(ctx context.Context, source, fn string, resolved bool) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
outcome := decision.Declined
|
||||
reason := "no match"
|
||||
if resolved {
|
||||
outcome = decision.Won
|
||||
reason = "resolved via " + source
|
||||
if fn != "" {
|
||||
reason += ": " + fn
|
||||
}
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-resolve",
|
||||
Outcome: outcome,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// noteActionValidation records the structural validation outcome in the
|
||||
// decision trace. Five outcomes: unresolved (matcher miss), valid
|
||||
// (structurally admissible), invalid_argument, missing_argument, or
|
||||
// ambiguous_target (structurally malformed).
|
||||
func noteActionValidation(ctx context.Context, v router.ActionValidationResult) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
switch v.Status {
|
||||
case router.ActionUnresolved:
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Declined,
|
||||
Reason: "unresolved",
|
||||
})
|
||||
case router.ActionValid:
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Won,
|
||||
Reason: "valid",
|
||||
})
|
||||
default:
|
||||
reason := string(v.Status)
|
||||
if len(v.Issues) > 0 {
|
||||
reason = string(v.Status) + ":" + v.Issues[0].Reason
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Declined,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// newActHandler builds a handler with the act path wired: a matcher over
|
||||
// whatever tools the test enabled, no model, no ecosystem.
|
||||
func newActHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
matcher := tool.NewMatcher(api)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
tools: tool.NewExecutor(api, 2*time.Second),
|
||||
matcher: matcher,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestActRouteSource_NoMatcherInvoke pins that an act with HasFn=true
|
||||
// produces a candidate from the route and does not invoke the matcher.
|
||||
func TestActRouteSource_NoMatcherInvoke(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable a tool so the matcher has something to match against.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act with HasFn=true: the candidate must come from the route.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("route-sourced act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActMatcherSource_FallbackMatch pins that an act without Fn invokes
|
||||
// the matcher and produces a matcher-sourced candidate.
|
||||
func TestActMatcherSource_FallbackMatch(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable a tool so the matcher can find it.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without HasFn: the matcher must resolve "status" from the text.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("matcher-sourced act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActMatcherMiss_ProposeGap pins that a matcher miss produces the
|
||||
// same propose-gap behavior as before.
|
||||
func TestActMatcherMiss_ProposeGap(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable one tool so the matcher has an allowlist, but not the one asked for.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without HasFn and text that doesn't match any tool.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy the thing",
|
||||
Slots: router.Slots{Text: "deploy the thing"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("matcher miss replied %q; want propose-gap behavior", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActDestructive_ConfirmationUnchanged pins that a destructive tool
|
||||
// still triggers the confirmation flow.
|
||||
func TestActDestructive_ConfirmationUnchanged(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive act replied %q; want a confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActTaskStatus_InterceptUnchanged pins that task_status is intercepted
|
||||
// before reaching the tool executor.
|
||||
func TestActTaskStatus_InterceptUnchanged(t *testing.T) {
|
||||
h, _ := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "task status",
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true,
|
||||
Text: "task status"},
|
||||
})
|
||||
// task_status is intercepted by resolveTaskStatus, which returns a
|
||||
// status phrase. The exact reply depends on the store state, but it
|
||||
// must not be a tool execution result.
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Errorf("task_status was not intercepted, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActStage0_SameResult pins that a stage-0 act (grammar match with
|
||||
// HasFn=true) produces the same tool execution as before.
|
||||
func TestActStage0_SameResult(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Stage: 0,
|
||||
Confidence: 1.0,
|
||||
Utterance: "maven, restart nginx",
|
||||
Slots: router.Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
Producer: router.RouteProducerGrammar,
|
||||
})
|
||||
if !strings.Contains(reply, "сделала") && !strings.Contains(reply, "готово") {
|
||||
t.Errorf("stage-0 act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActLearnedRouter_NoFn_FallbackMatch pins that a learned-router act
|
||||
// without Fn falls through to the matcher and produces the same result.
|
||||
func TestActLearnedRouter_NoFn_FallbackMatch(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// LLM routed the act but did not fill Fn (common when the model returns
|
||||
// the verb in Text but not in Fn).
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Stage: 1,
|
||||
Confidence: 0.85,
|
||||
Utterance: "could you restart nginx",
|
||||
Slots: router.Slots{Text: "restart nginx"},
|
||||
Producer: router.RouteProducerLLM,
|
||||
})
|
||||
if !strings.Contains(reply, "сделала") && !strings.Contains(reply, "готово") {
|
||||
t.Errorf("learned-router act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAction_CandidateSource_Verified pins the candidate source
|
||||
// for both route-resolved and matcher-resolved actions.
|
||||
func TestResolveAction_CandidateSource_Verified(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Route-resolved: HasFn=true.
|
||||
c1 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if c1.Source != router.ActionSourceRoute {
|
||||
t.Errorf("route candidate source = %q, want route", c1.Source)
|
||||
}
|
||||
if c1.Fn != "status" {
|
||||
t.Errorf("route candidate Fn = %q, want status", c1.Fn)
|
||||
}
|
||||
|
||||
// Matcher-resolved: no Fn, text matches.
|
||||
c2 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if c2.Source != router.ActionSourceMatcher {
|
||||
t.Errorf("matcher candidate source = %q, want matcher", c2.Source)
|
||||
}
|
||||
if c2.Fn != "status" {
|
||||
t.Errorf("matcher candidate Fn = %q, want status", c2.Fn)
|
||||
}
|
||||
|
||||
// Matcher miss: no Fn, text doesn't match.
|
||||
c3 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if c3.ActionResolved() {
|
||||
t.Errorf("miss candidate resolved = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// --- structural validation integration tests ---
|
||||
|
||||
// TestActValidation_MalformedCandidate_BlankFn pins that a resolved
|
||||
// candidate with a blank (whitespace-only) Fn does not execute and
|
||||
// produces a failure response.
|
||||
func TestActValidation_MalformedCandidate_BlankFn(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Simulate a malformed candidate by writing a blank Fn into Slots
|
||||
// after resolution. This tests that the validation layer catches
|
||||
// structurally invalid candidates.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
// The blank Fn should not reach tool execution. It either hits
|
||||
// the validation gate (ActFail) or the existing error paths.
|
||||
if reply == "" {
|
||||
t.Error("expected a response, got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_UnresolvedCandidate_ProposeGap pins that an unresolved
|
||||
// candidate (matcher miss) still flows to proposeGap, unchanged.
|
||||
func TestActValidation_UnresolvedCandidate_ProposeGap(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy everything",
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("unresolved candidate replied %q; want propose-gap behavior", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_DestructiveValid_StillConfirms pins that a destructive
|
||||
// valid action still reaches the confirmation path through validation.
|
||||
func TestActValidation_DestructiveValid_StillConfirms(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive valid act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_IrreversibleValid_NeedsAuthedSurface pins that an
|
||||
// irreversible valid action still reaches ErrNeedsAuthedSurface.
|
||||
func TestActValidation_IrreversibleValid_NeedsAuthedSurface(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Register an irreversible tool: cmd containing "drop" triggers the
|
||||
// irreversible tier via RiskOf → isIrreversible.
|
||||
if err := st.EnableTool(ctx, "drop_table", []string{"drop"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "drop_table",
|
||||
Slots: router.Slots{Fn: "drop_table", HasFn: true},
|
||||
})
|
||||
// Irreversible tools return ErrNeedsAuthedSurface, which produces
|
||||
// a specific phraser response.
|
||||
if !strings.Contains(reply, "выполню") && !strings.Contains(reply, "запусти") {
|
||||
t.Errorf("irreversible valid act replied %q; want authed-surface response", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_ValidationTracing pins that validation outcomes are
|
||||
// recorded in the decision trace.
|
||||
func TestActValidation_ValidationTracing(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
now := h.now()
|
||||
|
||||
// Valid candidate: trace should show action-validation:won.
|
||||
ctx, rec := decision.With(context.Background(), "status", "tap:text")
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
records := rec.Claims
|
||||
found := false
|
||||
for _, c := range records {
|
||||
if c.Claimant == "action-validation" && c.Outcome == decision.Won {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected action-validation:won in trace, got %v", records)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActExecutionFromCandidateNotSlots pins that downstream execution reads
|
||||
// resolved action data from ActionCandidate, not from Decision.Slots. The
|
||||
// decision has empty Fn/Args/HasFn — the bridge used to copy candidate values
|
||||
// back into these fields. After the bridge removal, execution must still
|
||||
// succeed because the candidate carries the resolved function.
|
||||
func TestActExecutionFromCandidateNotSlots(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without any Fn/Args/HasFn in Slots — the matcher resolves from Text.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("execution from candidate replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// --- validation status boundary tests ---
|
||||
|
||||
// TestActValidation_StatusValidRoute pins that a route-resolved valid action
|
||||
// produces ActionValid status and reaches execution.
|
||||
func TestActValidation_StatusValidRoute(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("valid route act replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusValidMatcher pins that a matcher-resolved valid
|
||||
// action produces ActionValid status and reaches execution.
|
||||
func TestActValidation_StatusValidMatcher(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("valid matcher act replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusUnresolved pins that an unresolved candidate
|
||||
// produces ActionUnresolved status and flows to proposeGap.
|
||||
func TestActValidation_StatusUnresolved(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy everything",
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("unresolved act replied %q; want propose-gap", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusInvalid pins that a structurally invalid candidate
|
||||
// produces ActionInvalidArgument status and refuses execution.
|
||||
func TestActValidation_StatusInvalid(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
if reply == "" {
|
||||
t.Error("expected a response for invalid candidate")
|
||||
}
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Error("invalid candidate should not reach tool execution")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_DestructiveValidStatus pins that a destructive but
|
||||
// structurally valid action still produces ActionValid status and reaches
|
||||
// the confirmation path (not validation failure).
|
||||
func TestActValidation_DestructiveValidStatus(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive valid act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_ConfirmationUnchanged pins that the confirmation flow
|
||||
// is unchanged by validation.
|
||||
func TestActValidation_ConfirmationUnchanged(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart nginx",
|
||||
Slots: router.Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("confirmation act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_TaskStatusInterceptUnchanged pins that task_status
|
||||
// interception is unchanged by validation.
|
||||
func TestActValidation_TaskStatusInterceptUnchanged(t *testing.T) {
|
||||
h, _ := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "task status",
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Text: "task status"},
|
||||
})
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Errorf("task_status was not intercepted, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_NoExecutionOnFailure pins that validation failure
|
||||
// prevents downstream execution.
|
||||
func TestActValidation_NoExecutionOnFailure(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Error("validation failure should not reach tool execution")
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -40,7 +40,6 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -58,18 +57,11 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r
|
||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||
// Conversational: build history from dialogue session (prior user turns)
|
||||
// and let the LLM respond from general knowledge + context.
|
||||
if h.phraser == nil {
|
||||
return "поговорили."
|
||||
}
|
||||
history := h.chatHistory(ctx)
|
||||
// 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.
|
||||
history := h.chatHistory()
|
||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||
if err != nil {
|
||||
log.Printf("voice: chat: %v", err)
|
||||
}
|
||||
if reply == "" {
|
||||
return phraser.ChatFallback()
|
||||
return "поговорили."
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
+28
-74
@@ -6,121 +6,75 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// actionAct handles router.IntentAct: resolve the action, offer it to the
|
||||
// ecosystems first, and run it behind the confirm gate and the allowlist.
|
||||
// proposeGap and the confirm gate itself live in confirm.go.
|
||||
//
|
||||
// Action resolution happens in resolveAction (actionresolve.go) — a single
|
||||
// boundary that produces an ActionCandidate before execution. This function
|
||||
// consumes the candidate; it no longer decides which function/tool the user
|
||||
// meant.
|
||||
// actionAct handles router.IntentAct: match a verb to an enabled tool, offer
|
||||
// it to the ecosystems first, and run it behind the confirm gate and the
|
||||
// allowlist. proposeGap and the confirm gate itself live in confirm.go.
|
||||
func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string {
|
||||
// An allowlist or a model route is evidence about WHAT could run, never
|
||||
// authority to run it. Keep the user's negative command at the execution
|
||||
// boundary too: actionAct is also reached by rebuilt decisions outside the
|
||||
// ordinary pre-route ladder.
|
||||
if refusesCommand(dec) {
|
||||
return commandProhibitionReply
|
||||
}
|
||||
|
||||
// Resolve the action: produce an ActionCandidate from the routing
|
||||
// decision. The candidate carries the resolved function, its arguments,
|
||||
// and where the resolution came from (route or matcher).
|
||||
candidate := h.resolveAction(ctx, dec)
|
||||
|
||||
// Structural validation: is this candidate complete enough to proceed?
|
||||
// Unresolved (Fn empty) flows to proposeGap; invalid (Fn present but
|
||||
// malformed) is refused; valid proceeds to execution.
|
||||
validation := router.ValidateActionCandidate(candidate)
|
||||
noteActionValidation(ctx, validation)
|
||||
|
||||
if !validation.Unresolved() && !validation.Valid() {
|
||||
// Resolved but structurally malformed: refuse execution.
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
}
|
||||
|
||||
// The board is Maven's own store, so a spoken status change is answered here
|
||||
// and never offered to an ecosystem client (Vikunja #512). First, because
|
||||
// task_status is on no allowlist and no capability registry: reaching either
|
||||
// of them would answer a turn about his own task list with a gap.
|
||||
if candidate.Fn == router.TaskStatusFn {
|
||||
return h.resolveTaskStatus(ctx, dec, candidate)
|
||||
// tool executor: run the matched fn against the enabled allowlist.
|
||||
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
|
||||
// didn't go through the stage-0 act grammar).
|
||||
if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil {
|
||||
if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok {
|
||||
dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true
|
||||
}
|
||||
}
|
||||
|
||||
// Praxis ecosystem tools: intercept before the system command executor.
|
||||
if h.ecosystem != nil && h.ecosystem.praxis != nil && candidate.ActionResolved() {
|
||||
if reply := h.handlePraxisAct(ctx, dec, candidate); reply != "" {
|
||||
if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn {
|
||||
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
// Hexis ecosystem action: if ecosystem is configured and we have a verb
|
||||
// + entity text, try to resolve the entity and execute via Hexis.
|
||||
if h.ecosystem != nil && h.ecosystem.hexis != nil && router.ActHasEntityTarget(dec) {
|
||||
if reply := h.handleHexisAct(ctx, dec, candidate); reply != "" {
|
||||
if h.ecosystem != nil && h.ecosystem.hexis != nil && dec.Slots.Text != "" {
|
||||
if reply := h.handleHexisAct(ctx, dec); reply != "" {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
// Unresolved candidate ⇒ no allowlist match: scaffold a 'proposed' tool
|
||||
// HasFn still false ⇒ no allowlist match: scaffold a 'proposed' tool
|
||||
// the user can enable on the authed surface ("earn the right to ask").
|
||||
if !candidate.ActionResolved() {
|
||||
if !dec.Slots.HasFn {
|
||||
return h.proposeGap(ctx, dec)
|
||||
}
|
||||
out, err := h.tools.Exec(ctx, candidate.Fn, candidate.Args, false)
|
||||
out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, tool.ErrNeedsConfirm):
|
||||
// destructive: park it and ask. The next utterance answers.
|
||||
phrase := actPhrase(candidate.Fn, candidate.Args)
|
||||
h.park(candidate.Fn, candidate.Args, phrase)
|
||||
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
||||
case errors.Is(err, tool.ErrUnknownTarget):
|
||||
// The verb reached a tool and the tail did not reach a target, so
|
||||
// nothing ran. Saying which word she could not place is the whole
|
||||
// answer: he either renames it or gives the row an alias that
|
||||
// carries the target, and both are one turn away (V-634).
|
||||
word := ""
|
||||
var unknown *tool.UnknownTargetError
|
||||
if errors.As(err, &unknown) {
|
||||
word = unknown.Target
|
||||
}
|
||||
return phraser.A(phraser.ActUnknownTarget, map[string]string{"name": word})
|
||||
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
||||
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||
// help: everything that proposed this act — the STT, the router,
|
||||
// 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)
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||
case errors.Is(err, tool.ErrNotEnabled):
|
||||
return h.proposeGap(ctx, dec)
|
||||
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
|
||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||
// question.
|
||||
return phraser.A(phraser.ActServerDown, nil)
|
||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
||||
case errors.Is(err, mcp.ErrToolGone):
|
||||
return phraser.A(phraser.ActWithdrawn, nil)
|
||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||
// An MCP tool that wants named arguments a spoken verb cannot
|
||||
// supply. Guessing them would be a wrong act, so she says so
|
||||
// instead — the tool is still runnable from the authed surface,
|
||||
// where a human types them.
|
||||
return phraser.A(phraser.ActNeedsArgs, nil)
|
||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", candidate.Fn, err)
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
if out != "" {
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
}
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
return "не получилось выполнить команду."
|
||||
}
|
||||
if out != "" {
|
||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||
return "готово: " + firstLine(out)
|
||||
}
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
return "готово."
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+18
-114
@@ -6,77 +6,23 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
||||
// it for recall, and let pattern detection propose a routine.
|
||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||
if !dec.Slots.HasKey {
|
||||
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 = ""
|
||||
// Defensive reconstruction must preserve the same literal destination
|
||||
// the stage-0 router would have named. A learned fact decision has no
|
||||
// source, and without restoring this anchored world frame the personal
|
||||
// boundary can claim "latest Go version" by similarity and prevent the
|
||||
// live source from ever being asked.
|
||||
if world, ok := router.WorldQueryDecision(dec.Utterance); ok {
|
||||
q.Source = world.Source
|
||||
q.SourceAnchored = world.SourceAnchored
|
||||
}
|
||||
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)
|
||||
return "не разобрала, что записать — попробуй иначе."
|
||||
}
|
||||
now := h.now()
|
||||
req := ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
Kind: "self",
|
||||
Key: dec.Slots.Key,
|
||||
Value: dec.Slots.Value,
|
||||
Source: "tap:voice",
|
||||
// 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),
|
||||
Ts: now,
|
||||
Kind: "self",
|
||||
Key: dec.Slots.Key,
|
||||
Value: dec.Slots.Value,
|
||||
Source: "tap:voice",
|
||||
Confidence: 1.0,
|
||||
// Subject: the key doubles as the entity-resolution candidate —
|
||||
// a voice-tapped fact's key is usually the thing/person it's
|
||||
// about ("espresso_machine", "kate"), so queueing it for Nexus
|
||||
@@ -88,35 +34,19 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
factID, err := h.api.WriteFact(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("voice: write fact: %v", err)
|
||||
return phraser.Ack(phraser.FailFact, nil)
|
||||
return "не получилось сохранить факт."
|
||||
}
|
||||
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||
// write). Facts aren't in the notes table, so this is the only recall path
|
||||
// for them — "когда я пил воду?" reads back from here.
|
||||
//
|
||||
// The indexed text is the fact, not the utterance (#493). queryMemory
|
||||
// returns a fact's stored text verbatim, so what goes in here is what he
|
||||
// hears; storing the utterance meant recall answered with his own sentence
|
||||
// rather than the value. The utterance stays alongside as provenance —
|
||||
// readable on /trace, never the answer and never embedded.
|
||||
//
|
||||
// The vector id carries a timestamp, so a second tap of the same key adds a
|
||||
// row rather than replacing one, and recall then scores the superseded
|
||||
// value against the current one. CorrectValue and VoidLatestFact already
|
||||
// drop the key's vectors; an ordinary re-tap is the third way a value is
|
||||
// superseded and it did not (#493). Dropping first keeps exactly one vector
|
||||
// per key, which is what "recall answers with the current value" means.
|
||||
if h.recall.memStore != nil {
|
||||
pruneFactVectors(ctx, h.recall.memStore, dec.Slots.Key)
|
||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||
// Index the fact utterance in long-term memory (best-effort, must not
|
||||
// fail the fact write). Facts aren't in the notes table, so this is the
|
||||
// only recall path for them — "когда я пил воду?" reads back from here.
|
||||
if h.memStore != nil {
|
||||
if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil {
|
||||
log.Printf("voice: embed fact for memory: %v", err)
|
||||
} else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "fact",
|
||||
"text": text,
|
||||
"utterance": dec.Utterance,
|
||||
"ts": strconv.FormatInt(now.Unix(), 10),
|
||||
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "fact",
|
||||
"text": dec.Utterance,
|
||||
"ts": strconv.FormatInt(now.Unix(), 10),
|
||||
}); err != nil {
|
||||
log.Printf("voice: memory insert fact: %v", err)
|
||||
}
|
||||
@@ -132,29 +62,3 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
}
|
||||
return "" // replier phrases the success reply
|
||||
}
|
||||
|
||||
// vectorPruner — the part of the vector index this file needs and memory.Store
|
||||
// does not carry. store.MemoryStore implements it; the in-memory test double
|
||||
// may not, and a double that cannot prune is not a reason to fail a fact write.
|
||||
type vectorPruner interface {
|
||||
DeletePrefix(ctx context.Context, prefix string) (int64, error)
|
||||
}
|
||||
|
||||
// pruneFactVectors drops every vector for one fact key, so the insert that
|
||||
// follows is the only one left. Best-effort and silent on a store that cannot
|
||||
// prune: the fact row is the truth, and a stale vector costs a wrong recall,
|
||||
// not a lost fact.
|
||||
func pruneFactVectors(ctx context.Context, ms memory.Store, key string) {
|
||||
p, ok := ms.(vectorPruner)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := p.DeletePrefix(ctx, "fact:"+key+":")
|
||||
if err != nil {
|
||||
log.Printf("voice: prune memory vectors for %q: %v", key, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("voice: %q superseded, dropped %d stale memory vector(s)", key, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// listFloor — the keyword test behind topicList, in the shape turnIsAbout takes.
|
||||
func listFloor(u string) bool {
|
||||
_, ok := router.ParseListQuery(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if h.dataStore == nil {
|
||||
return "", false
|
||||
}
|
||||
// The seeds decide the subject and listQueryPrefixes is the floor behind
|
||||
// them (V-522). Which list he named is a noun lookup either way.
|
||||
if !h.turnIsAbout(ctx, t, topicList, listFloor) {
|
||||
return "", false
|
||||
}
|
||||
list := router.ListNamedIn(t.dec.Utterance)
|
||||
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
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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,66 +5,39 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// nothingToCorrectReply — what she says to a correction that points at
|
||||
// nothing. Filing it would put a sentence in his memory that reads as a fact.
|
||||
const nothingToCorrectReply = "не поняла, что поправить. скажи целиком, и я запишу."
|
||||
|
||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||
// index it for recall.
|
||||
//
|
||||
// The stored body comes only from dec.Utterance (V-576/V-721). An explicit
|
||||
// leading capture frame is structurally removed; an unmarked note is otherwise
|
||||
// byte-for-byte his utterance. It is never Slots.Text, phraser output or any
|
||||
// other model string: a note is durable, the embedder indexes it, and it comes
|
||||
// back later as recall in his own words. Phrasing belongs in the confirmation.
|
||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||
// A correction with no referent. Everything that could own one has already
|
||||
// run by here: clarify, confirm and repair are all resolved before routing,
|
||||
// so a fragment reaching the note path has nothing behind it (V-576).
|
||||
if correctionFragment(dec.Utterance) {
|
||||
return nothingToCorrectReply
|
||||
}
|
||||
// An utterance that explicitly files a task is work, not recall, and
|
||||
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||
// is paid for. Everything else is a note, exactly as before.
|
||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||
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
|
||||
}
|
||||
noteText := dec.Utterance
|
||||
if body, explicit := router.ParseNoteCapture(dec.Utterance); explicit {
|
||||
noteText = body
|
||||
}
|
||||
// embed the note text with the same model the classifier uses, persist
|
||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||
// facts — no predicate reads it (spec's two-memory split).
|
||||
vec, err := router.EmbedPassage(ctx, h.recall.embedder, noteText)
|
||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
return "не получилось сохранить заметку."
|
||||
}
|
||||
noteTs := h.now()
|
||||
noteID, err := h.api.WriteNote(ctx, noteTs, noteText, vec, "tap:voice")
|
||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||
if err != nil {
|
||||
log.Printf("voice: write note: %v", err)
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
return "не получилось сохранить заметку."
|
||||
}
|
||||
// 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).
|
||||
if h.recall.memStore != nil {
|
||||
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
if h.memStore != nil {
|
||||
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "note",
|
||||
"text": noteText,
|
||||
"text": dec.Utterance,
|
||||
"ts": strconv.FormatInt(noteTs.Unix(), 10),
|
||||
}); err != nil {
|
||||
log.Printf("voice: memory insert: %v", err)
|
||||
|
||||
+115
-465
@@ -8,15 +8,11 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/kiwix"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -60,35 +56,6 @@ type querySource struct {
|
||||
// sources search text with no notion of a day. When one of them grows a
|
||||
// date parameter, flip its flag here.
|
||||
dateAware bool
|
||||
|
||||
// dest — the destination this source serves, when the cascade named one
|
||||
// (V-655). Several sources share a destination: the three recall passes and
|
||||
// the fact-by-key lookup are all SourceRecall, because which of them lands
|
||||
// the hit is an ordering detail no utterance can name. A source with no
|
||||
// dest is reachable only by walking the chain.
|
||||
dest router.Source
|
||||
|
||||
// guesses — this source decides whether the turn is its own by scoring the
|
||||
// utterance against frozen seeds, rather than by looking something up and
|
||||
// coming back empty.
|
||||
//
|
||||
// The distinction is the whole point of the field. A source that looks can
|
||||
// be wrong about relevance and still harmless, because the miss shows up as
|
||||
// no rows. A source that guesses answers whatever it claims: weather has no
|
||||
// local table to miss against, so "что такое TCP?" became "для какого
|
||||
// города?". So when the cascade names a destination, the guessers that were
|
||||
// not named do not get to try. The lookups still run, because a named
|
||||
// destination is evidence and not a promise.
|
||||
guesses bool
|
||||
|
||||
// boundary — dropping this source widens what leaves the box, so only a
|
||||
// literal pattern may do it (V-666, owner's call of 2026-08-09).
|
||||
//
|
||||
// Every other guesser costs an answer when it is wrongly taken off a turn.
|
||||
// This one costs the rule that a question about him never reaches an
|
||||
// upstream engine. A grammar read the words to name a destination. A model
|
||||
// and a softmax both inferred one, and neither may spend that.
|
||||
boundary bool
|
||||
}
|
||||
|
||||
// querySources is the ordered chain actionQuery walks; first source to claim
|
||||
@@ -97,85 +64,63 @@ type querySource struct {
|
||||
// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one
|
||||
// line here plus its method; where you put the line is the whole decision.
|
||||
var querySources = []querySource{
|
||||
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey, dest: router.SourceRecall},
|
||||
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey},
|
||||
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||||
// the more specific ask (its matcher requires a plan word), so the calendar
|
||||
// listing would otherwise swallow it.
|
||||
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan, dest: router.SourceCalendar},
|
||||
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan},
|
||||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||
// and the habit question is the more specific one. Its matcher requires a
|
||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||
// Wednesday still reaches the calendar.
|
||||
{name: "habits", answer: (*reactiveHandler).queryHabits, dest: router.SourceCalendar},
|
||||
{name: "habits", answer: (*reactiveHandler).queryHabits},
|
||||
// Before "calendar" and before the recall sources: "что мне нужно
|
||||
// сделать?" is a question about the task list, and the notes pass would
|
||||
// otherwise answer it with whatever note happens to be nearest. Its
|
||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||
// date-bearing question still reaches the calendar.
|
||||
{name: "tasks", answer: (*reactiveHandler).queryTasks, dest: router.SourceTasks},
|
||||
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||||
// question about the operational state Praxis holds, and it used to fall
|
||||
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||
// an attention marker, and it falls through when Praxis is not configured.
|
||||
{name: "attention", answer: (*reactiveHandler).queryAttention, dest: router.SourceAttention, guesses: true},
|
||||
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||
// about the shopping list, and the recall pass would otherwise answer it
|
||||
// from an old note about the shop. Its matcher needs an explicit list
|
||||
// marker, so "надо бы съездить в магазин" is untouched.
|
||||
{name: "list", answer: (*reactiveHandler).queryList, dest: router.SourceList, guesses: true},
|
||||
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney, dest: router.SourceMoney},
|
||||
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||
// the facts he tapped in, and the notes pass would answer it with whatever
|
||||
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||||
// history phrase and bails out when he names a topic, so "что я говорил
|
||||
// про сервер" is still recall.
|
||||
{name: "history", answer: (*reactiveHandler).queryHistory, dest: router.SourceRecall},
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||
// "у меня новая лента в инстаграме" is untouched.
|
||||
{name: "feeds", answer: (*reactiveHandler).queryFeeds, dest: router.SourceFeeds, guesses: true},
|
||||
{name: "feeds", answer: (*reactiveHandler).queryFeeds},
|
||||
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||||
// a question about the house, and the notes pass would otherwise answer it
|
||||
// from whatever he once said about the lights. Its matcher needs a house
|
||||
// marker plus an ask plus a device word, and it bails out on weather
|
||||
// wording, so "какая температура на улице?" still reaches the weather
|
||||
// source.
|
||||
{name: "home", answer: (*reactiveHandler).queryHome, dest: router.SourceHome, guesses: true},
|
||||
{name: "home", answer: (*reactiveHandler).queryHome},
|
||||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||
// question about the LAN, and the recall pass would otherwise answer it
|
||||
// from an old note about the router. Its matcher needs a network word plus
|
||||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork, dest: router.SourceNetwork, guesses: true},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true, dest: router.SourceCalendar},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather, dest: router.SourceWeather, guesses: true},
|
||||
// A question about her, above the three sources that search his own data
|
||||
// (Vikunja #555). It has no answer anywhere else: below the boundary
|
||||
// SearXNG answers about somebody else's assistant, and above it his notes
|
||||
// answer by proximity — "кто ты" came back from a note of his, measured on
|
||||
// the box, because the recall index has no idea the subject is her.
|
||||
{name: "self", answer: (*reactiveHandler).querySelf, dest: router.SourceSelf, guesses: true},
|
||||
{name: "embed", answer: (*reactiveHandler).queryEmbed, dest: router.SourceRecall},
|
||||
{name: "memory", answer: (*reactiveHandler).queryMemory, dest: router.SourceRecall},
|
||||
{name: "notes", answer: (*reactiveHandler).queryNotes, dest: router.SourceRecall},
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
||||
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
||||
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
||||
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
||||
// THE BOUNDARY. Everything above answers from his own data; everything
|
||||
// below answers from the world's. A question about him that got this far
|
||||
// has no answer in his data, and no outside source can supply one, so this
|
||||
// stops the walk rather than let the encyclopedia and the model guess.
|
||||
{name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true, boundary: true},
|
||||
{name: "personal", answer: (*reactiveHandler).queryPersonal},
|
||||
// The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats
|
||||
// a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at
|
||||
// stake by this point — the boundary above already stopped every question
|
||||
// about him, and only the query string leaves the box.
|
||||
{name: "search", answer: (*reactiveHandler).querySearch, dest: router.SourceWorld},
|
||||
{name: "search", answer: (*reactiveHandler).querySearch},
|
||||
// The offline encyclopedia, now the fallback for when the line is down or
|
||||
// the search comes back empty. It reads the way it always did; what changed
|
||||
// is that it no longer gets first refusal on a world question.
|
||||
{name: "kiwix", answer: (*reactiveHandler).queryKiwix, dest: router.SourceWorld},
|
||||
{name: "kiwix", answer: (*reactiveHandler).queryKiwix},
|
||||
// LAST before the model answers from memory, and that position is the whole
|
||||
// design (Vikunja #259): everything of his, then the search, then the ZIMs,
|
||||
// and only then a page he named. The model does NOT come first: it
|
||||
@@ -183,100 +128,27 @@ var querySources = []querySource{
|
||||
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
||||
// This source only claims a turn where he named a URL, so it never competes
|
||||
// with a local answer.
|
||||
{name: "web", answer: (*reactiveHandler).queryWeb, dest: router.SourceWorld},
|
||||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral, dest: router.SourceWorld},
|
||||
}
|
||||
|
||||
// queryWalk narrows the chain for one turn against the destination the cascade
|
||||
// named, and says which sources were left out (V-655).
|
||||
//
|
||||
// It takes sources OUT and never moves one, which is the whole safety argument.
|
||||
// The table's order is load-bearing and every comment on it argues a reason
|
||||
// between two sources; none of those reasons is about this. Above all, the
|
||||
// order carries "his data first, then the world", and a destination named by a
|
||||
// model must not be able to reverse that. Naming SourceWorld does not send the
|
||||
// turn outside — it stops the guessers from claiming it on the way.
|
||||
//
|
||||
// What comes out is exactly the sources that guess. Those decide whether a turn
|
||||
// is theirs by scoring it against frozen seeds, and then answer whatever they
|
||||
// claimed, because they have no lookup that can come back empty. That is the
|
||||
// whole of the 2026-08-07 defect: weather claiming "что такое TCP?", the feed
|
||||
// claiming "какой у меня любимый язык?", the personal boundary claiming "кто
|
||||
// такой Линус Торвальдс?". The sources that look are all still asked, so a
|
||||
// wrong destination costs nothing but the guess it prevented.
|
||||
//
|
||||
// No destination named ⇒ the table exactly as written, which is what shipped
|
||||
// before the field existed. That is the floor. The classifier arm names
|
||||
// nothing, so a box whose model is down routes queries the way it always did.
|
||||
// The personal boundary is the one exception, and anchored is what buys it
|
||||
// (V-666). A grammar matched a literal pattern to name the destination. The
|
||||
// routing heads and the resident model inferred one, and an inferred SourceWorld
|
||||
// takes the boundary off a question about him. That widens what is asked
|
||||
// upstream rather than costing a local answer, so those two keep it.
|
||||
func queryWalk(dest router.Source, anchored bool) (walk, skipped []querySource) {
|
||||
if dest == router.SourceUnknown {
|
||||
return querySources, nil
|
||||
}
|
||||
for _, s := range querySources {
|
||||
if s.guesses && s.dest != dest && (anchored || !s.boundary) {
|
||||
skipped = append(skipped, s)
|
||||
continue
|
||||
}
|
||||
walk = append(walk, s)
|
||||
}
|
||||
return walk, skipped
|
||||
{name: "web", answer: (*reactiveHandler).queryWeb},
|
||||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral},
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||||
t := &queryTurn{dec: dec}
|
||||
// The roster, so the record can say which sources were never reached rather
|
||||
// than leaving them out and letting a reader assume they looked and passed
|
||||
// (V-564). Finish names everyone below the winner.
|
||||
decision.Expect(ctx, decision.StageQuery, querySourceNames())
|
||||
rec := decision.From(ctx)
|
||||
walk, skipped := queryWalk(dec.Source, dec.SourceAnchored)
|
||||
for _, src := range skipped {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||
Reason: "it decides by similarity and the cascade named " + string(dec.Source),
|
||||
})
|
||||
}
|
||||
for _, src := range walk {
|
||||
for _, src := range querySources {
|
||||
if dec.Continued && !src.dateAware {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||
Reason: "a continuation turn only asks the date-aware sources",
|
||||
})
|
||||
continue
|
||||
}
|
||||
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. The same name goes to the turn's sink when the
|
||||
// caller asked for one, so /chat can show it (V-539).
|
||||
log.Printf("voice: query claimed by source %q", src.name)
|
||||
noteQuerySource(ctx, src.name)
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name,
|
||||
Intent: string(dec.Intent), Outcome: decision.Won,
|
||||
})
|
||||
return reply
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.Declined,
|
||||
Reason: "it had no answer for this turn",
|
||||
})
|
||||
}
|
||||
if dec.Continued {
|
||||
// The previous question cannot be re-asked for another day. Saying so
|
||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||
// truth is that she never looked.
|
||||
return phraser.Q(phraser.QueryOtherDay, nil)
|
||||
return "про другой день так не отвечу — спроси целиком."
|
||||
}
|
||||
return phraser.Q(phraser.QueryUnknown, nil)
|
||||
return "не знаю."
|
||||
}
|
||||
|
||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||
@@ -294,11 +166,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
if dec.Slots.HasTime {
|
||||
// The query asks about timing — the fact's own timestamp is the
|
||||
// answer it's looking for. Format as a natural reply.
|
||||
return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
||||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
||||
}
|
||||
// General fact reference: describe what we know.
|
||||
if dec.Utterance == "" {
|
||||
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
|
||||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
||||
}
|
||||
// The utterance still carries the question; fall through to normal RAG
|
||||
// with the resolved key in context.
|
||||
@@ -312,13 +184,7 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed and reads only the next
|
||||
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
|
||||
// nothing, and she read 43 entries aloud in one sentence (V-618).
|
||||
//
|
||||
// "что у меня сегодня?" is a different question and is not narrowed here — it
|
||||
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
|
||||
// answers the whole day.
|
||||
// day, so that phrasing trims what has already passed.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
@@ -330,30 +196,23 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
plan, err := h.api.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return phraser.Q(phraser.QueryFailPlan, nil), true
|
||||
return "не получилось собрать план.", true
|
||||
}
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
// rendered the whole day — one formatter, one persona.
|
||||
//
|
||||
// The instants are put back in the asking clock's zone on the way in. They
|
||||
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
|
||||
// hours in the plan's own frame, so setting that frame here is what makes
|
||||
// the recital name his clock rather than the store's (V-614).
|
||||
zone := h.now().Location()
|
||||
p := morning.Plan{Date: plan.Date.In(zone)}
|
||||
p := morning.Plan{Date: plan.Date}
|
||||
for _, it := range plan.Items {
|
||||
p.Items = append(p.Items, morning.PlanEntry{
|
||||
At: it.At.In(zone),
|
||||
At: it.At,
|
||||
Text: it.Text,
|
||||
Kind: morning.PlanKind(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
})
|
||||
}
|
||||
return p.Next(h.now(), morning.NextSpoken).FormatRU(), true
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
@@ -382,7 +241,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return phraser.Q(phraser.QueryFailNotes, nil), true
|
||||
return "не получилось посмотреть записи.", true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
@@ -406,14 +265,6 @@ const (
|
||||
feedReadOut = 3
|
||||
)
|
||||
|
||||
// feedFloor — the keyword test behind topicFeed, in the one-string shape
|
||||
// turnIsAbout takes. router.ParseFeedQuery returns the category too, which the
|
||||
// gate has no use for; the caller reads it separately.
|
||||
func feedFloor(u string) bool {
|
||||
_, ok := router.ParseFeedQuery(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
@@ -421,26 +272,15 @@ func feedFloor(u string) bool {
|
||||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||||
// changed is "Maven is not a nag", not a detail of this file.
|
||||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
// The seeds decide the subject; router.ParseFeedQuery is the floor behind
|
||||
// them (V-522). The category still comes from the utterance either way,
|
||||
// because a topic is marked by a preposition and needs no recogniser.
|
||||
if !h.turnIsAbout(ctx, t, topicFeed, feedFloor) {
|
||||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
category := router.FeedCategoryOf(t.dec.Utterance)
|
||||
if !h.feedsOn {
|
||||
// Claim only when nothing below can read the world. The reason this
|
||||
// source used to claim unconditionally was that general knowledge would
|
||||
// answer "что нового?" with an invented news bulletin — true, and it
|
||||
// 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
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
}
|
||||
// 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 "в
|
||||
@@ -448,11 +288,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
||||
return "не получилось посмотреть ленты.", true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), category) {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary, category tag and link; she reads the
|
||||
@@ -464,40 +304,18 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if category != "" {
|
||||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||
if q.Category != "" {
|
||||
return "по этой теме в лентах пока ничего.", true
|
||||
}
|
||||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||
return "в лентах пока ничего нового.", true
|
||||
}
|
||||
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
|
||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||
}
|
||||
|
||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
// Weather was one instance of a wider class (Vikunja #552). Naming a day
|
||||
// does not make a question his agenda: "какой сегодня курс доллара" and
|
||||
// "во сколько закат сегодня" both answered "ничего нет", which reads as an
|
||||
// answer about a subject she never looked at. All of them have an answer
|
||||
// in search, and search sits below this source. So the question must ask
|
||||
// about his schedule, not merely name a day.
|
||||
//
|
||||
// A continuation is exempt. "а завтра?" names no agenda and cannot: the
|
||||
// subject was in the turn before it, and this is the only date-aware
|
||||
// source there is.
|
||||
if !t.dec.Continued && !router.IsAgendaQuestion(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||
if !ok {
|
||||
return "", false
|
||||
@@ -505,7 +323,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
log.Printf("voice: calendar events: %v", err)
|
||||
return phraser.Q(phraser.QueryFailCalendar, nil), true
|
||||
return "не получилось проверить календарь.", true
|
||||
}
|
||||
// Provenance travels with each event. A work meeting relayed off a phone
|
||||
// notification (source ambient:notif, #126) is stored below full confidence
|
||||
@@ -518,15 +336,11 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
return f.FormatEntries(entries, date), true
|
||||
}
|
||||
|
||||
// homeTimeout — the whole house read. Longer than the weather call because the
|
||||
// hub is polled over the LAN and answers for every device at once.
|
||||
const homeTimeout = 10 * time.Second
|
||||
|
||||
// queryHome answers a question about the house. Read-only by construction: it
|
||||
// calls States and nothing else, so there is no confirm turn here — the only
|
||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) {
|
||||
if !isHomeQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.home == nil {
|
||||
@@ -538,7 +352,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, homeTimeout)
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return h.home.homeSummary(ctxH)
|
||||
}
|
||||
@@ -548,107 +362,53 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
// because Scan takes no target — the utterance selects the question, never the
|
||||
// subnet.
|
||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) {
|
||||
if !isNetworkQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// The recogniser already matched, so this is a question about HIS LAN
|
||||
// and there is no scanner to answer it. Falling through sent it to the
|
||||
// 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
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
// weatherTimeout — one geocode plus one forecast read. He asked a question with
|
||||
// a one-line answer, so a provider that is slower than this is a failure.
|
||||
const weatherTimeout = 5 * time.Second
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||
if !isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||
if loc == "" {
|
||||
// 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.
|
||||
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, weatherTimeout)
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
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
|
||||
return "погода не настроена.", true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("voice: weather: %v", err)
|
||||
return phraser.Q(phraser.QueryFailWeather, nil), true
|
||||
return "не получилось узнать погоду.", 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
|
||||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
||||
}
|
||||
|
||||
// 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
|
||||
// never claims the turn.
|
||||
//
|
||||
// It used to claim on an embedder error, and that made a RAG hint a hard gate
|
||||
// over everything below it (V-568): one failing EmbedQuery and the memory, the
|
||||
// notes, the boundary, the search, the ZIMs, the named page and the model all
|
||||
// answered "не смогла ответить", including the questions search and Kiwix
|
||||
// would have answered without ever touching the embedder. A failed embed means
|
||||
// this source cannot claim, not that the turn is over — same shape as
|
||||
// turnVector in topics.go, which had it right.
|
||||
// only claims the turn when the embedder fails.
|
||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
// A topic source above already paid for this one; see turnVector.
|
||||
if len(t.vec) > 0 {
|
||||
return "", false
|
||||
}
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// Logged once, here, and the chain walks on. The two recall sources
|
||||
// below read the empty vector and pass; the boundary drops to its
|
||||
// offline floor.
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return "", false
|
||||
return "не получилось найти ответ.", true
|
||||
}
|
||||
t.vec = vec
|
||||
return "", false
|
||||
}
|
||||
|
||||
// memoryRecallWidth and noteRecallWidth — how many candidates each recall pass
|
||||
// pulls before the gate reads them. Both are small on purpose: the gate wants a
|
||||
// best hit and its runner-up, and every further row is a margin the top match
|
||||
// has to beat.
|
||||
const (
|
||||
memoryRecallWidth = 3
|
||||
noteRecallWidth = 5
|
||||
)
|
||||
|
||||
// recallOnTopic — the topic veto both recall sources apply after the score gate
|
||||
// (#470). A memory about his slow network scored high enough to answer "почему
|
||||
// небо синее?", because the right-note and must-be-silent score ranges overlap
|
||||
// and no threshold sits between them.
|
||||
func recallOnTopic(utterance, text string) bool {
|
||||
if memory.RecallAllowed(utterance, text,
|
||||
router.IsOpenQuestionShaped(utterance), router.IsLocativeQuestionShaped(utterance)) {
|
||||
return true
|
||||
}
|
||||
log.Printf("voice: recall %q rejected for %q: no structural ask with a shared topic, or a world/locative question with no shared topic", text, utterance)
|
||||
return false
|
||||
}
|
||||
|
||||
// queryMemory — long-term memory first: ONE search over everything Maven
|
||||
// remembers (notes and facts share this index) and ONE confidence gate, so
|
||||
// the memory that is clearly the best match answers — a note just as much as
|
||||
@@ -660,39 +420,23 @@ func recallOnTopic(utterance, text string) bool {
|
||||
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
||||
// which memory gets to answer them.
|
||||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if h.recall.memStore == nil {
|
||||
if h.memStore == nil {
|
||||
return "", false
|
||||
}
|
||||
if len(t.vec) == 0 {
|
||||
// No query vector: the embed above failed or there is no embedder.
|
||||
// Searching on an empty vector is not a search, and its scores are not
|
||||
// a "there is nothing" answer — pass rather than gate the chain.
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, memoryRecallWidth)
|
||||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
||||
if herr != nil {
|
||||
log.Printf("voice: memory search: %v", herr)
|
||||
return "", false
|
||||
}
|
||||
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
|
||||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
text := hit.Meta["text"]
|
||||
// The score cleared the gate and the topic still has to match.
|
||||
if !recallOnTopic(t.dec.Utterance, text) {
|
||||
return "", false
|
||||
}
|
||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||
// stored.
|
||||
if hit.Meta["type"] == "note" {
|
||||
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 != "":
|
||||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
||||
return reply, true
|
||||
}
|
||||
}
|
||||
@@ -711,30 +455,17 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
// band. See memory.Confident. Failing the gate passes the turn on to general
|
||||
// knowledge, which is what "don't read back the runner-up" means here.
|
||||
func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if len(t.vec) == 0 {
|
||||
// Same reason as queryMemory above (V-568): with no query vector this
|
||||
// source could not look, and could-not-look passes.
|
||||
return "", false
|
||||
}
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, noteRecallWidth)
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||
if err != nil {
|
||||
// The store failed, so this source could not look either. It used to
|
||||
// claim here, which stopped the search, the ZIMs and the model from
|
||||
// answering a question that never needed a note (V-568).
|
||||
log.Printf("voice: query notes: %v", err)
|
||||
return "", false
|
||||
return "не получилось найти ответ.", true
|
||||
}
|
||||
t.notes = notes
|
||||
noteScores := make([]float64, len(notes))
|
||||
for i, n := range notes {
|
||||
noteScores[i] = n.Score
|
||||
}
|
||||
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||
return "", false
|
||||
}
|
||||
// The best note must be about what he asked, not merely the nearest vector
|
||||
// in the index.
|
||||
if !recallOnTopic(t.dec.Utterance, notes[0].Text) {
|
||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
||||
return "", false
|
||||
}
|
||||
texts := make([]string, len(notes))
|
||||
@@ -746,7 +477,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: phrase query: %v", err)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
||||
reply = "вот что я нашла: " + texts[0]
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -756,23 +487,6 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
// prompt, the persona block and the reply.
|
||||
const webPageContextRunes = 1500
|
||||
|
||||
// webFetchTimeout — the whole named-page source. Longer than the other outside
|
||||
// sources because he named this page himself, so waiting for it is what he asked
|
||||
// for, and there is nothing below that can answer instead.
|
||||
const webFetchTimeout = 30 * time.Second
|
||||
|
||||
// readBackRunes — how much of the evidence is read out when the phraser gave
|
||||
// nothing back. It is spoken aloud, so it is a couple of sentences and not a
|
||||
// page.
|
||||
const readBackRunes = 300
|
||||
|
||||
// readBack — what an outside source says when the phraser gave nothing back.
|
||||
// The evidence is read out plainly rather than dropped, because the fetch did
|
||||
// happen and its result is a better answer than silence.
|
||||
func readBack(evidence string) string {
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(evidence, readBackRunes)})
|
||||
}
|
||||
|
||||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||
//
|
||||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||
@@ -785,34 +499,38 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
return "", false
|
||||
}
|
||||
if h.crawler == nil {
|
||||
// He named a URL, so the question is about that page and nothing else
|
||||
// can answer it. The older comment here argued for falling through and
|
||||
// letting the model answer as if the URL had not been said; that is a
|
||||
// guess dressed as an answer (Vikunja #479).
|
||||
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
||||
// where it was never turned on the older behaviour is right: the model
|
||||
// answers the question as if the URL had not been said. Announcing a
|
||||
// configuration status is for a capability that exists and failed, not
|
||||
// for one he never asked for.
|
||||
return "", false
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, webFetchTimeout)
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
if errors.Is(err, crawl.ErrRobots) {
|
||||
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||
}
|
||||
log.Printf("voice: web: %v", err)
|
||||
return phraser.Q(phraser.QueryFailPage, nil), true
|
||||
return "не получилось прочитать страницу.", true
|
||||
}
|
||||
if page.Text == "" {
|
||||
return phraser.Q(phraser.QueryPageEmpty, nil), true
|
||||
return "страница открылась, но читать там нечего.", true
|
||||
}
|
||||
// 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
|
||||
// recite the page.
|
||||
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||||
reply := h.phraseSource(ctx, "web", t.dec.Utterance, []string{snippet})
|
||||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||||
if perr != nil {
|
||||
log.Printf("voice: web: phrase: %v", perr)
|
||||
}
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, readBackRunes)}), true
|
||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -874,11 +592,18 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
||||
// 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.
|
||||
evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes)
|
||||
reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence})
|
||||
var reply string
|
||||
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 == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// pretend the search did not happen.
|
||||
return readBack(resp.Snippets()[0]), true
|
||||
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -905,19 +630,11 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout)
|
||||
defer cancel()
|
||||
|
||||
// A Russian question reads the Russian ZIM verbatim when there is one
|
||||
// (V-508). Kiwix ranks by keyword overlap rather than meaning, so an English
|
||||
// book matches a Russian sentence not at all, and the rewriter exists to
|
||||
// turn the question into English keywords with the resident model. Against a
|
||||
// Russian book that is a translation of his own words back at him: it costs
|
||||
// a model call and drops whatever the keywords do not carry.
|
||||
book, verbatim := h.kiwix.book, false
|
||||
if h.kiwix.bookRU != "" && hasCyrillic(t.dec.Utterance) {
|
||||
book, verbatim = h.kiwix.bookRU, true
|
||||
}
|
||||
|
||||
// The ZIMs are English and kiwix ranks by keyword overlap, not meaning, so
|
||||
// a Russian sentence matches nothing at all. The rewriter turns it into a
|
||||
// handful of English keywords with the resident model.
|
||||
pattern := t.dec.Utterance
|
||||
if h.kiwix.rewriter != nil && !verbatim {
|
||||
if h.kiwix.rewriter != nil {
|
||||
q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// Fall through to the verbatim question rather than give up. It
|
||||
@@ -928,29 +645,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
|
||||
// The topic, not the sentence (V-668). Kiwix ranks by keyword overlap, so
|
||||
// the question words outrank the one word that names the article: measured
|
||||
// on 2026-08-09, "что такое TCP" returns "Перехват TCP-соединения" and
|
||||
// "TCP" returns TCP. Only the verbatim path needs this. The rewriter
|
||||
// already reduces a question to English keywords, and reducing twice would
|
||||
// take the topic off the input it reads.
|
||||
if verbatim {
|
||||
if topic := kiwix.Topic(pattern); topic != "" {
|
||||
// The article named exactly, before any ranking runs. A ZIM is
|
||||
// addressable by title and a wrong title is a 404, so this either
|
||||
// answers or costs one request that says nothing.
|
||||
for _, cand := range kiwix.TitleCandidates(topic) {
|
||||
page, err := h.kiwix.client.Article(ctxK, kiwix.TitlePath(book, cand), h.kiwix.runes)
|
||||
if err == nil && page.Text != "" {
|
||||
log.Printf("voice: kiwix: %q in %q → title hit %q", topic, book, page.Title)
|
||||
return h.kiwixReply(ctx, t, page.Title, page.Text)
|
||||
}
|
||||
}
|
||||
pattern = topic
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, book, h.kiwix.max)
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, h.kiwix.book, h.kiwix.max)
|
||||
if err != nil {
|
||||
log.Printf("voice: kiwix: search %q: %v", pattern, err)
|
||||
return "", false
|
||||
@@ -962,7 +657,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
// Logged on the way through, not only on failure. Without this there is no
|
||||
// way to tell from the outside whether an answer came off a ZIM or out of
|
||||
// the model's weights, and those are the two cases worth telling apart.
|
||||
log.Printf("voice: kiwix: %q in %q → %d hits, top %q", pattern, book, len(hits), top.Title)
|
||||
log.Printf("voice: kiwix: %q → %d hits, top %q", pattern, len(hits), top.Title)
|
||||
|
||||
// The top hit only, read as an article rather than as a snippet. Kiwix
|
||||
// builds its snippet from wherever the keyword matched, which on Wikipedia
|
||||
@@ -982,34 +677,25 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
page = crawl.Page{Title: top.Title, Text: top.Snippet}
|
||||
}
|
||||
return h.kiwixReply(ctx, t, top.Title, page.Text)
|
||||
}
|
||||
|
||||
// kiwixReply hands one article over the same way a note or a page is handed
|
||||
// over: context for the question he asked, not something to recite.
|
||||
func (h *reactiveHandler) kiwixReply(ctx context.Context, t *queryTurn, title, text string) (string, bool) {
|
||||
snippet := title + "\n" + crawl.TrimRunes(text, h.kiwix.runes)
|
||||
reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
|
||||
// Handed over the same way a note or a page is: context for the question he
|
||||
// asked, not something to recite.
|
||||
snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes)
|
||||
var reply string
|
||||
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 == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// the search did not happen.
|
||||
return readBack(title + " — " + text), true
|
||||
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// hasCyrillic reports whether the text carries a Cyrillic letter, which is the
|
||||
// whole test for "he asked this in Russian". A question mixing a Latin proper
|
||||
// noun into a Russian sentence is still Russian, so one letter is enough.
|
||||
func hasCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Cyrillic, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queryPersonal — stop the walk on a question about him that his own data did
|
||||
// not answer.
|
||||
//
|
||||
@@ -1031,11 +717,11 @@ func hasCyrillic(s string) bool {
|
||||
// not be sent to an upstream engine at all. The guard closes both holes with
|
||||
// the same test.
|
||||
func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.isPersonalTurn(ctx, t) {
|
||||
if !isPersonalQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
||||
return phraser.Q(phraser.QueryPersonalNone, nil), true
|
||||
return "не знаю — не нашла у тебя такой записи.", true
|
||||
}
|
||||
|
||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||
@@ -1056,9 +742,7 @@ var personalMarkers = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\bdid\s+i\b`),
|
||||
}
|
||||
|
||||
// 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.
|
||||
// isPersonalQuery reports whether the utterance asks about something of his.
|
||||
func isPersonalQuery(utterance string) bool {
|
||||
if utterance == "" {
|
||||
return false
|
||||
@@ -1071,47 +755,13 @@ func isPersonalQuery(utterance string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isPersonalTurn — the boundary test. The seeds decide when the embedder is
|
||||
// there, which is every deployed box; the possession markers are the floor
|
||||
// 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.
|
||||
// queryGeneral — general knowledge from the phraser, the last source before
|
||||
// giving up. It always claims: either the model answers or Maven says she
|
||||
// doesn't know.
|
||||
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
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
|
||||
}
|
||||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil)
|
||||
if err != nil || reply == "" {
|
||||
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||
return "не знаю.", true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ func TestIsPersonalQuery(t *testing.T) {
|
||||
"when is my meeting",
|
||||
"do i have anything today",
|
||||
"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) {
|
||||
t.Errorf("isPersonalQuery(%q) = false, want true", s)
|
||||
@@ -37,10 +33,6 @@ func TestIsPersonalQuery(t *testing.T) {
|
||||
"почему небо синее",
|
||||
"столица франции",
|
||||
"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) {
|
||||
|
||||
@@ -2,67 +2,32 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// actionReminder handles router.IntentReminder: parse the time when stage-0
|
||||
// skipped the extractor, then create the reminder.
|
||||
func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decision) string {
|
||||
// The pre-route belt normally answers this before routing. Keep the write
|
||||
// boundary guarded as well: a model calling the sentence a reminder does
|
||||
// not turn "don't ..." into permission to create a row.
|
||||
if refusesCommand(dec) {
|
||||
return commandProhibitionReply
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
// Stage-0 (reminder-wakeword grammar) skips the extractor, so the
|
||||
// time wasn't parsed. Run the parser as a fallback.
|
||||
if dec.Stage == 0 && h.timeParser != nil {
|
||||
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
|
||||
// Same gate as the extractor (V-577, V-579, V-610): a request whose
|
||||
// hour was not spoken, or was spoken and not read, gets asked about
|
||||
// and is never completed from the clock.
|
||||
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
|
||||
if err == nil && ok {
|
||||
dec.Slots.Time = t
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||
return "не получилось разобрать время напоминания."
|
||||
}
|
||||
}
|
||||
// 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)) + `}`
|
||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
return "не получилось поставить напоминание."
|
||||
}
|
||||
// Phrased from the row, never from the utterance (Vikunja #507). The
|
||||
// replier only ever saw Slots.Text, so it named whatever hour the sentence
|
||||
// contained — including one the parser had rejected or read differently.
|
||||
// A confirmation naming an hour no row holds is worse than a clarify,
|
||||
// because he stops thinking about it.
|
||||
return reminderConfirm(dec.Slots.Time, h.now())
|
||||
}
|
||||
|
||||
// reminderConfirm — the confirmation for a reminder that exists, naming the
|
||||
// stored fire time. Deterministic on purpose: the one sentence that must match
|
||||
// a database row is not one to hand to a 1.7B.
|
||||
func reminderConfirm(fire, now time.Time) string {
|
||||
when := dayPrefix(now, fire)
|
||||
if when == "это" {
|
||||
// Further out than the day words reach — say the date instead of a
|
||||
// word that would be wrong.
|
||||
date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
|
||||
return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "."
|
||||
return ""
|
||||
}
|
||||
|
||||
+5
-105
@@ -3,11 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
@@ -43,18 +40,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
return phraser.Ack(phraser.FailTask, nil), true
|
||||
return "не получилось записать задачу.", true
|
||||
}
|
||||
if resp.Promoted {
|
||||
// It was a candidate Maven derived from something she read, and he has
|
||||
// now said it himself. Saying "уже в списке" here would be answering a
|
||||
// confirmation with a shrug.
|
||||
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
||||
return "поняла, беру в работу: " + cap.Text, true
|
||||
}
|
||||
if !resp.Created {
|
||||
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
|
||||
return "это уже в списке.", true
|
||||
}
|
||||
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
|
||||
return "записала: " + cap.Text, true
|
||||
}
|
||||
|
||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
@@ -73,104 +70,7 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: list tasks: %v", err)
|
||||
return "не получилось посмотреть задачи.", 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(ctx, cands)
|
||||
reply := tasks.FormatRU(ranked)
|
||||
// The counted shapes, after the list and only when there are any (V-512).
|
||||
// They answer "what is going wrong with this list" without assessing any of
|
||||
// it, and they are said here rather than announced: no tick rule reads them.
|
||||
if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" {
|
||||
if !strings.HasSuffix(reply, ".") {
|
||||
reply += "."
|
||||
}
|
||||
reply += " " + stalls
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// resolveTaskStatus moves a task he named out loud (Vikunja #512).
|
||||
//
|
||||
// The position path already worked: resolveCandidate answers "первую сделал"
|
||||
// against the list she just read. This is the other half — naming the task
|
||||
// instead of its position, which reached no code at all before the stage-0 rule
|
||||
// in internal/router/taskstatus.go filled the fn slot.
|
||||
//
|
||||
// Three answers besides the move, and none of them guesses. No match says so. A
|
||||
// match on more than one asks which, because closing the wrong task is work he
|
||||
// never finished being marked done. No task named asks which too, since the
|
||||
// router claims the turn without the referent and the list lives here.
|
||||
func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision, candidate router.ActionCandidate) string {
|
||||
live, err := h.api.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
log.Printf("voice: task status: list: %v", err)
|
||||
return "не получилось посмотреть задачи."
|
||||
}
|
||||
if dec.Slots.Text == "" {
|
||||
return "какую задачу?"
|
||||
}
|
||||
match := matchTaskText(live, dec.Slots.Text)
|
||||
switch len(match) {
|
||||
case 0:
|
||||
return "не нашла такой задачи."
|
||||
case 1:
|
||||
default:
|
||||
return "у тебя несколько подходящих — какую именно?"
|
||||
}
|
||||
pick := match[0]
|
||||
status := dec.Slots.Value
|
||||
// A candidate is work Maven proposed and he never confirmed, and the store
|
||||
// refuses candidate → done: the legal move is to open it first. Saying it is
|
||||
// done IS the confirmation, so both writes happen rather than the turn
|
||||
// naming a gap about a distinction he did not make.
|
||||
if pick.Status == store.TaskCandidate && status == store.TaskDone {
|
||||
if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil {
|
||||
log.Printf("voice: task status: promote %d: %v", pick.ID, err)
|
||||
return "не получилось изменить задачу."
|
||||
}
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil {
|
||||
log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err)
|
||||
return "не получилось изменить задачу."
|
||||
}
|
||||
log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status)
|
||||
if status == store.TaskDropped {
|
||||
return "убрала: " + pick.Text
|
||||
}
|
||||
return "закрыла: " + pick.Text
|
||||
}
|
||||
|
||||
// matchTaskText finds the live tasks he could have meant.
|
||||
//
|
||||
// Normalised containment, either direction, over store.NormalizeTaskText — the
|
||||
// same key capture dedupes on, so a task he can file twice is a task he can name
|
||||
// twice. Either direction because he shortens what he said ("молоко" for
|
||||
// "купить молоко") as often as he pads it.
|
||||
//
|
||||
// Deliberately not fuzzy. A ranked best guess would always return exactly one
|
||||
// answer, and the one thing this must be able to say is that it is not sure.
|
||||
func matchTaskText(live []ipc.Task, named string) []ipc.Task {
|
||||
want := store.NormalizeTaskText(named)
|
||||
if want == "" {
|
||||
return nil
|
||||
}
|
||||
var out []ipc.Task
|
||||
for _, t := range live {
|
||||
have := store.NormalizeTaskText(t.Text)
|
||||
if have == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(have, want) || strings.Contains(want, have) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -26,9 +25,6 @@ type taskAPI struct {
|
||||
tasks []ipc.Task
|
||||
listArg string
|
||||
listErr error
|
||||
|
||||
moved []setStatusCall
|
||||
moveErr error
|
||||
}
|
||||
|
||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
@@ -112,7 +108,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||
}
|
||||
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want an honest failure", reply)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +137,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||
if !(openIdx < candIdx) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -256,117 +252,3 @@ func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he
|
||||
// says is done can be shown to take both legal moves.
|
||||
type setStatusCall struct {
|
||||
id int64
|
||||
status string
|
||||
by string
|
||||
}
|
||||
|
||||
func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
||||
a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by})
|
||||
return a.moveErr
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||
{ID: 8, Text: "оплатить интернет", Status: "open"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
|
||||
}
|
||||
if len(api.moved) != 1 {
|
||||
t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved)
|
||||
}
|
||||
if api.moved[0].id != 7 || api.moved[0].status != "done" {
|
||||
t.Errorf("moved %+v, want id 7 → done", api.moved[0])
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want the task named back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression crosses the grammar/action seam instead of handing the
|
||||
// action a repaired Decision. The stored title is a normal imperative title,
|
||||
// while the spoken marker names only its topic; framing words must not become
|
||||
// identity and the unrelated live row must remain untouched.
|
||||
func TestActionActMarkerReferentMovesOnlyTheNamedStoredTask(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 17, Text: "настроить бэкапы", Status: "open"},
|
||||
{ID: 18, Text: "обновить сертификаты", Status: "open"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
dec, matched, accepted := router.TaskStatusGrammar().Evaluate("отметь задачу про бэкапы как сделанную")
|
||||
if !matched || !accepted {
|
||||
t.Fatalf("task-status grammar matched=%v accepted=%v", matched, accepted)
|
||||
}
|
||||
|
||||
reply := h.actionAct(context.Background(), dec)
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("listed %q, want live", api.listArg)
|
||||
}
|
||||
if len(api.moved) != 1 {
|
||||
t.Fatalf("moved %+v, want exactly the named stored task", api.moved)
|
||||
}
|
||||
if got := api.moved[0]; got.id != 17 || got.status != "done" || got.by != "tap:voice" {
|
||||
t.Errorf("moved %+v, want task 17 → done by tap:voice", got)
|
||||
}
|
||||
if !strings.Contains(reply, "настроить бэкапы") {
|
||||
t.Errorf("reply = %q, want the transitioned stored title", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tasks []ipc.Task
|
||||
named string
|
||||
want string
|
||||
}{
|
||||
{"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"},
|
||||
{"two matches", []ipc.Task{
|
||||
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||
{ID: 8, Text: "купить молоко и хлеб", Status: "open"},
|
||||
}, "купить молоко", "несколько"},
|
||||
{"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
api := &taskAPI{tasks: c.tasks}
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
if len(api.moved) != 0 {
|
||||
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
|
||||
}
|
||||
if !strings.Contains(reply, c.want) {
|
||||
t.Errorf("reply = %q, want it to contain %q", reply, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
|
||||
// The store refuses candidate → done. Saying it is done is the confirmation
|
||||
// the candidate was waiting for, so the arm makes both legal moves.
|
||||
api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}}
|
||||
h := taskHandler(api)
|
||||
h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
if len(api.moved) != 2 {
|
||||
t.Fatalf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
if api.moved[0].status != "open" || api.moved[1].status != "done" {
|
||||
t.Errorf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
|
||||
// pending telegram nudge every repeat_interval, and nothing in the tree could
|
||||
// ever mark one acked. None of what follows can be reproduced by hand without
|
||||
// sitting in front of the box for hours, so it is covered here or nowhere.
|
||||
|
||||
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
|
||||
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
|
||||
t.Helper()
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
|
||||
t.Fatalf("seed service_down:db=%s: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
|
||||
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
|
||||
// repeat these tests are about never happens. The daemon wires it (main.go).
|
||||
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
|
||||
t.Helper()
|
||||
rules := loop.DefaultRules()
|
||||
g := loop.NewGatherer(st, rules)
|
||||
d := delivery.NewDispatcher(delivery.Config{
|
||||
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||
Ack: st, Nudges: st, Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// telegramSends counts sends that went out on the telegram reach.
|
||||
func telegramSends(sink *fakeSink, rule string) int {
|
||||
n := 0
|
||||
for _, s := range sink.sends {
|
||||
if s.RuleName == rule {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// outcomes returns the outcome of every nudge row for a rule, newest first.
|
||||
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
|
||||
t.Helper()
|
||||
rows, err := st.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("recent nudges: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, n := range rows {
|
||||
if n.Rule == rule {
|
||||
out = append(out, n.Outcome)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
|
||||
// The condition clearing is the ending that should happen. StillTrue reads
|
||||
// the same DownServices helper the phraser reads, so the repeat stops on
|
||||
// exactly the monitor he was told about.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
if telegramSends(sink, "service_down") == 0 {
|
||||
t.Fatal("the alarm never went out; the rest of this test proves nothing")
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
|
||||
}
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeResolved {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
|
||||
// Still down, still un-acked, and nobody has answered in two hours. That is
|
||||
// not one more repeat away from being answered.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
|
||||
}
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
|
||||
}
|
||||
// Ignored, not resolved: nothing says the service got better.
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeIgnored {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
|
||||
// Down, up, down again. Closing the first run must not make the second run
|
||||
// unreportable, and must not silently reopen the closed rows either.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
first := len(outcomes(t, st, ctx, "service_down"))
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
tl.tick(ctx, now.Add(2*time.Minute))
|
||||
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
|
||||
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "down", now.Add(25*time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(31*time.Minute))
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
|
||||
}
|
||||
got := outcomes(t, st, ctx, "service_down")
|
||||
if len(got) <= first {
|
||||
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
|
||||
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
|
||||
// that says nothing must keep its alarm until the age cap, or a rule author
|
||||
// silences their own alarm by omission.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
|
||||
|
||||
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
|
||||
t.Fatalf("record nudge: %v", err)
|
||||
}
|
||||
|
||||
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
|
||||
}
|
||||
|
||||
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
|
||||
if len(live) != 0 {
|
||||
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An empty attention list used to be answered "ничего не требует внимания"
|
||||
// unconditionally, which is an all-clear Maven had no way to know was true
|
||||
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("healthy and quiet should be an all-clear, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[
|
||||
{"source_id":"src_ntfy","health":"ok"},
|
||||
{"source_id":"src_llamacpp","health":"failed"},
|
||||
{"source_id":"src_imap","health":"stale"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a failed source must not read as all-clear, got %q", reply)
|
||||
}
|
||||
for _, want := range []string{"src_llamacpp", "src_imap"} {
|
||||
if !strings.Contains(reply, want) {
|
||||
t.Errorf("reply names no %s: %q", want, reply)
|
||||
}
|
||||
}
|
||||
if strings.Contains(reply, "src_ntfy") {
|
||||
t.Errorf("the healthy source is named as a problem: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A Praxis that polls nothing knows nothing, which is the state the box is in.
|
||||
func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "источник") {
|
||||
t.Errorf("reply does not say why she cannot tell: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The spec's own mechanism, which the deployed Praxis does not send yet: the
|
||||
// envelope's degraded array is believed without a second call.
|
||||
func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t,
|
||||
`{"items":[],"degraded":["src_metrics"]}`,
|
||||
`[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "src_metrics") {
|
||||
t.Fatalf("the envelope's degraded source is not named: %q", reply)
|
||||
}
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Path == "/api/v1/sources" {
|
||||
t.Error("sources was read even though the response carried degraded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A sources endpoint that errors is not evidence of a fault: the attention call
|
||||
// itself succeeded, and hedging on it would make her permanently uncertain.
|
||||
func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
praxis.SetRouteFault("/api/v1/sources", 500)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Both response shapes decode, because the spec says one and the box sends the
|
||||
// other.
|
||||
func TestPraxisAttentionDecodesBothShapes(t *testing.T) {
|
||||
var bare praxisAttention
|
||||
if err := json.Unmarshal([]byte(`[{"id":"item_1"}]`), &bare); err != nil {
|
||||
t.Fatalf("bare array: %v", err)
|
||||
}
|
||||
if len(bare.Items) != 1 || len(bare.Degraded) != 0 {
|
||||
t.Errorf("bare array decoded as %+v", bare)
|
||||
}
|
||||
|
||||
var env praxisAttention
|
||||
if err := json.Unmarshal([]byte(`{"items":[{"id":"item_2"}],"degraded":["src_a"]}`), &env); err != nil {
|
||||
t.Fatalf("envelope: %v", err)
|
||||
}
|
||||
if len(env.Items) != 1 || len(env.Degraded) != 1 || env.Degraded[0] != "src_a" {
|
||||
t.Errorf("envelope decoded as %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// A source that reports no health at all counts as healthy. A Praxis that never
|
||||
// fills the field would otherwise make every quiet turn a hedge.
|
||||
func TestUnhealthySourcesTreatsAnAbsentHealthFieldAsHealthy(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_a"},{"id":"src_b","health":"stale"}]`)
|
||||
bad, total, err := newPraxisClient(praxis.URL).UnhealthySources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("UnhealthySources: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("total = %d, want 2", total)
|
||||
}
|
||||
if len(bad) != 1 || bad[0] != "src_b" {
|
||||
t.Errorf("bad = %v, want [src_b]", bad)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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},
|
||||
}, router.ActionCandidate{
|
||||
Fn: "list_attention",
|
||||
Source: router.ActionSourceRoute,
|
||||
})
|
||||
if reply == "" {
|
||||
return "", false
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The two boot paths meet here. run() wires the daemon twice: once at boot
|
||||
// when a key is in the environment, and once inside UnlockFn after a passkey
|
||||
// assertion, minutes or days later. Listing the same wiring in both places is
|
||||
// what let them drift — seven workers started untracked on the unlock path and
|
||||
// two daemonAPI fields were never set there, silently, for as long as anyone
|
||||
// had been cold-starting (V-639).
|
||||
//
|
||||
// So both paths call newDaemonAPI and startBackground and nothing else. A
|
||||
// field or a worker added later reaches both paths or neither.
|
||||
|
||||
// bootDeps is everything the two constructors below read. It is filled from
|
||||
// the same variables on both paths, by depsNow in run().
|
||||
type bootDeps struct {
|
||||
coreFor func() ipc.CoreAPI
|
||||
tl *tickLoop
|
||||
evBus *event.Bus
|
||||
voiceW *voiceWiring
|
||||
st *store.Store
|
||||
factWorker *factEnrichmentWorker
|
||||
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
||||
}
|
||||
|
||||
// newDaemonAPI builds the real CoreAPI, with every field set. The unlock path
|
||||
// used to leave nexus and getMCPServers nil, so after a cold start
|
||||
// ResolveEntity refused with a nexus block configured and /tools rendered
|
||||
// "not configured" with an mcp block configured. Empty is a wrong answer
|
||||
// there, not a degraded one.
|
||||
func newDaemonAPI(d bootDeps) *daemonAPI {
|
||||
api := &daemonAPI{
|
||||
CoreAPI: d.coreFor(),
|
||||
getTrace: d.tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return d.tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return d.tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(d.evBus),
|
||||
getDecisions: turnDecisionsFn(d.voiceW),
|
||||
seedStore: seedStoreIfAllowed(d.st),
|
||||
nexus: nexusOf(d.voiceW),
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.handler != nil {
|
||||
api.chatFn = d.voiceW.handler.handleText
|
||||
// And the reverse: the handler was wired with the bare store adapter,
|
||||
// which cannot serve the day plan. See upgradeAPI.
|
||||
d.voiceW.handler.upgradeAPI(api)
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.mcp != nil {
|
||||
api.getMCPServers = d.voiceW.mcp.status
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
// namedWorker is one long-running goroutine. The name exists so the set is
|
||||
// assertable from a test and readable in a log; nothing dispatches on it.
|
||||
type namedWorker struct {
|
||||
name string
|
||||
run func(ctx context.Context)
|
||||
}
|
||||
|
||||
// backgroundWorkers lists what this deployment runs. It is pure — it starts
|
||||
// nothing — so a test can compare the set the two paths would start without
|
||||
// standing a daemon up.
|
||||
func backgroundWorkers(d bootDeps) []namedWorker {
|
||||
var ws []namedWorker
|
||||
if d.voiceW != nil && d.voiceW.server != nil {
|
||||
ws = append(ws, namedWorker{"voice", func(context.Context) {
|
||||
if err := d.voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}})
|
||||
}
|
||||
ws = append(ws,
|
||||
namedWorker{"tick", d.tl.run},
|
||||
namedWorker{"fact-enrichment", d.factWorker.run},
|
||||
)
|
||||
if d.evalWorker != nil {
|
||||
ws = append(ws, namedWorker{"memory-eval", d.evalWorker.run})
|
||||
}
|
||||
if d.feedWkr != nil {
|
||||
ws = append(ws, namedWorker{"feed", d.feedWkr.run})
|
||||
}
|
||||
if d.crawlWkr != nil {
|
||||
ws = append(ws, namedWorker{"crawl", d.crawlWkr.run})
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.mcp != nil {
|
||||
ws = append(ws, namedWorker{"mcp", d.voiceW.mcp.run})
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.home != nil {
|
||||
ws = append(ws, namedWorker{"home", d.voiceW.home.run})
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
// startBackground starts every worker through goWorker, so waitWorkers can
|
||||
// wait for it at shutdown. A worker started as a bare `go func()` is the
|
||||
// shutdown bug documented at the end of run(): run() never returns, the
|
||||
// deferred Close never seals the database, and the ciphertext goes stale.
|
||||
func startBackground(ctx context.Context, wg *sync.WaitGroup, d bootDeps) {
|
||||
for _, w := range backgroundWorkers(d) {
|
||||
goWorker(wg, func() { w.run(ctx) })
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.server != nil {
|
||||
log.Printf("mavend: voice listening on %s", d.voiceW.server.Addr())
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// fullDeps — a deployment with every optional piece present. Nothing here is
|
||||
// run: newDaemonAPI takes method values and backgroundWorkers is pure, so
|
||||
// zero-value wirings are enough to say what WOULD be started.
|
||||
func fullDeps() bootDeps {
|
||||
h := &reactiveHandler{
|
||||
ecosystem: &ecosystemWiring{nexus: &nexusClient{}},
|
||||
decisions: decision.NewRing(),
|
||||
}
|
||||
return bootDeps{
|
||||
coreFor: func() ipc.CoreAPI { return ipc.UnimplementedCoreAPI{} },
|
||||
tl: &tickLoop{},
|
||||
evBus: event.NewBus(4),
|
||||
st: &store.Store{},
|
||||
factWorker: &factEnrichmentWorker{},
|
||||
evalWorker: &memoryEvalWorker{},
|
||||
feedWkr: &feedWorker{},
|
||||
crawlWkr: &crawlWorker{},
|
||||
voiceW: &voiceWiring{
|
||||
server: &voice.Server{},
|
||||
handler: h,
|
||||
mcp: &mcpWiring{},
|
||||
home: &homeWiring{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The unlock path used to build its own daemonAPI literal and leave nexus and
|
||||
// getMCPServers nil (V-639). Both paths call newDaemonAPI now, so the drift
|
||||
// that can still happen is a field added to the struct and not to the
|
||||
// constructor. This catches that one, by name.
|
||||
func TestNewDaemonAPISetsEveryField(t *testing.T) {
|
||||
prev := allowSeedOnStart
|
||||
allowSeedOnStart = true
|
||||
defer func() { allowSeedOnStart = prev }()
|
||||
|
||||
api := newDaemonAPI(fullDeps())
|
||||
v := reflect.ValueOf(*api)
|
||||
for i := range v.NumField() {
|
||||
if v.Field(i).IsZero() {
|
||||
t.Errorf("newDaemonAPI left %s unset — a fully wired deployment must fill every field", v.Type().Field(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The handler is wired with the bare store adapter and cannot serve the day
|
||||
// plan until upgradeAPI hands it the real one. The unlocked path did that and
|
||||
// the unlock path did it too; keep it a property of the constructor.
|
||||
func TestNewDaemonAPIUpgradesTheHandler(t *testing.T) {
|
||||
d := fullDeps()
|
||||
api := newDaemonAPI(d)
|
||||
if d.voiceW.handler.api != ipc.CoreAPI(api) {
|
||||
t.Fatal("newDaemonAPI did not hand the handler the API it built")
|
||||
}
|
||||
}
|
||||
|
||||
// Every worker the daemon runs goes through startBackground, so shutdown can
|
||||
// wait for it. The unlock path used to start seven of these as bare
|
||||
// `go func()` under a shadowed WaitGroup.
|
||||
func TestBackgroundWorkersFullSet(t *testing.T) {
|
||||
want := []string{"voice", "tick", "fact-enrichment", "memory-eval", "feed", "crawl", "mcp", "home"}
|
||||
var got []string
|
||||
for _, w := range backgroundWorkers(fullDeps()) {
|
||||
got = append(got, w.name)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("workers = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A default box configures none of the optional blocks. Two workers always run
|
||||
// and the rest stay dark, rather than a nil run being scheduled.
|
||||
func TestBackgroundWorkersFloor(t *testing.T) {
|
||||
d := fullDeps()
|
||||
d.evalWorker, d.feedWkr, d.crawlWkr, d.voiceW = nil, nil, nil, nil
|
||||
want := []string{"tick", "fact-enrichment"}
|
||||
var got []string
|
||||
for _, w := range backgroundWorkers(d) {
|
||||
got = append(got, w.name)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("workers = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestTextAndVoiceConvergeOnNormalizedInput — both entry points construct a
|
||||
// NormalizedInput and pass it to runTurn. The same utterance produces the same
|
||||
// route intent regardless of whether it arrived as text or voice.
|
||||
func TestTextAndVoiceConvergeOnNormalizedInput(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
ctx := context.Background()
|
||||
|
||||
utterance := "который час"
|
||||
voiceCtx := withDialogueID(ctx, dialogueIDFor(sourceVoice, ""))
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
|
||||
voiceReply := h.runTurn(voiceCtx, router.NormalizedInput{Text: utterance, Source: sourceVoice})
|
||||
textReply := h.runTurn(textCtx, router.NormalizedInput{Text: utterance, Source: sourceText})
|
||||
|
||||
// Both paths should produce the same kind of reply (time answer).
|
||||
for _, pair := range []struct {
|
||||
label, reply string
|
||||
}{
|
||||
{"voice", voiceReply},
|
||||
{"text", textReply},
|
||||
} {
|
||||
if !strings.Contains(pair.reply, "час") && !strings.Contains(pair.reply, "время") {
|
||||
t.Errorf("%s reply %q does not look like a time answer", pair.label, pair.reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizedInputSourcePreserved — the source survives into the decision
|
||||
// record so a trace can tell voice from text.
|
||||
func TestNormalizedInputSourcePreserved(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
ctx := context.Background()
|
||||
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
h.runTurn(textCtx, router.NormalizedInput{Text: "привет", Source: sourceText})
|
||||
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("no decision record")
|
||||
}
|
||||
if recs[0].InputSource != string(sourceText) {
|
||||
t.Errorf("InputSource = %q, want %q", recs[0].InputSource, sourceText)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteProducerOnDecisionRecord — the producer is carried from the router
|
||||
// decision into the decision record for observability.
|
||||
func TestRouteProducerOnDecisionRecord(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
ctx := context.Background()
|
||||
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
h.runTurn(textCtx, router.NormalizedInput{Text: "который час", Source: sourceText})
|
||||
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("no decision record")
|
||||
}
|
||||
// A time query is a stage-0 grammar match.
|
||||
if recs[0].RouteProducer != string(router.RouteProducerGrammar) {
|
||||
t.Errorf("RouteProducer = %q, want %q", recs[0].RouteProducer, router.RouteProducerGrammar)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreRouteClaimHasNoRouteProducer — a turn claimed by a pre-route resolver
|
||||
// never reaches the router, so the record's RouteProducer must be empty.
|
||||
func TestPreRouteClaimHasNoRouteProducer(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
// Park a confirm so the next "да" is consumed before routing.
|
||||
// newRoutingClarifyHandler uses a fixed clock at 2026-07-31 09:00 UTC.
|
||||
h.pending = &pendingAct{
|
||||
fn: "test",
|
||||
phrase: "delete everything",
|
||||
expiry: time.Date(2026, 7, 31, 9, 1, 0, 0, time.UTC),
|
||||
}
|
||||
ctx := context.Background()
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
h.runTurn(textCtx, router.NormalizedInput{Text: "да", Source: sourceText})
|
||||
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("no decision record")
|
||||
}
|
||||
if recs[0].RouteProducer != "" {
|
||||
t.Errorf("RouteProducer = %q, want empty (pre-route claimed the turn)", recs[0].RouteProducer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStage0ProducerUnchanged — grammars still produce the exact same intents
|
||||
// at confidence 1.0. This pins stage-0 behavior through the new boundary.
|
||||
func TestStage0ProducerUnchanged(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
ctx := context.Background()
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
|
||||
cases := []struct {
|
||||
utterance string
|
||||
intent router.Intent
|
||||
}{
|
||||
{"напомни позвонить маме завтра", router.IntentReminder},
|
||||
{"который час", router.IntentSystem},
|
||||
}
|
||||
for _, c := range cases {
|
||||
reply := h.runTurn(textCtx, router.NormalizedInput{Text: c.utterance, Source: sourceText})
|
||||
_ = reply // behavior unchanged; we test the record, not the reply text.
|
||||
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) == 0 {
|
||||
t.Errorf("%s: no decision record", c.utterance)
|
||||
continue
|
||||
}
|
||||
rec := recs[0]
|
||||
if rec.RouteProducer != string(router.RouteProducerGrammar) {
|
||||
t.Errorf("%s: RouteProducer = %q, want %q", c.utterance, rec.RouteProducer, router.RouteProducerGrammar)
|
||||
}
|
||||
// Clear the ring for the next case.
|
||||
h.decisions = decision.NewRing()
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestCalendarStepsAsideForTheWorld — the defect (Vikunja #552). Weather was
|
||||
// one instance of a wider class, and V-474 fixed only that instance. Every one
|
||||
// of these answered "на 05.08.2026 ничего нет" on the deployed daemon, and
|
||||
// every one of them has an answer in search, which sits below the calendar.
|
||||
func TestCalendarStepsAsideForTheWorld(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 world questions, want 0", api.events)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same narrowing: a question about his own day still
|
||||
// reaches the calendar, including the one that names no subject at all.
|
||||
func TestCalendarStillAnswersHisDay(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"что у меня сегодня",
|
||||
"во сколько у меня встреча сегодня",
|
||||
"какие встречи завтра",
|
||||
"что в календаре на завтра",
|
||||
"что сегодня?",
|
||||
} {
|
||||
h, _ := contQueryHandler()
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); !ok {
|
||||
t.Errorf("the calendar passed on %q", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation carries its subject in the turn before it, and the calendar
|
||||
// is the only date-aware source, so the narrowing must not reach it.
|
||||
func TestCalendarStillAnswersAContinuation(t *testing.T) {
|
||||
h, _ := contQueryHandler()
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "а завтра?", Continued: true},
|
||||
}); !ok {
|
||||
t.Error("the calendar passed on a continuation")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// to get rather than an empty meeting summary.
|
||||
func unwrapSummary(raw string) string {
|
||||
s := phraser.StripThink(strings.TrimSpace(raw))
|
||||
s := stripThink(strings.TrimSpace(raw))
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end <= start {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The chat path must answer when llama-server is down (Vikunja #45 step 4).
|
||||
// Both halves of a turn call the model — the router and the replier — and each
|
||||
// has its own floor: the cascade falls to the classifier, the replier falls to
|
||||
// the stub. This wires a client at a closed port so both floors are exercised
|
||||
// by a dial error rather than by a stubbed error value.
|
||||
func TestChatAnswersWithNoLlamaServer(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead), nil)
|
||||
h.replier = newLLMReplier(dead, nil)
|
||||
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
for _, utt := range []string{
|
||||
"привет",
|
||||
"запиши что кофе закончился",
|
||||
"что у меня сегодня",
|
||||
} {
|
||||
reply := h.handleText(ctx, "web", utt)
|
||||
if reply == "" {
|
||||
t.Errorf("%q answered with nothing; a dead model must degrade to the stub", utt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// daemonAPI.Chat reports an error only when the voice path was never wired.
|
||||
// A turn that reaches handleText always carries text, which is what keeps
|
||||
// mavweb's /api/chat off its error branch when the model is down.
|
||||
func TestChatAPIErrsOnlyWhenUnwired(t *testing.T) {
|
||||
d := &daemonAPI{}
|
||||
if _, err := d.Chat(context.Background(), "web", "привет"); err == nil {
|
||||
t.Fatal("an unwired daemon must say so")
|
||||
}
|
||||
d.chatFn = func(context.Context, string, string) string { return "" }
|
||||
if _, err := d.Chat(context.Background(), "web", "привет"); err != nil {
|
||||
t.Fatalf("a wired daemon must not error: %v", err)
|
||||
}
|
||||
}
|
||||
+99
-452
@@ -6,7 +6,6 @@ import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
@@ -34,10 +33,19 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
router.IntentAct: {dialogue.SlotFn},
|
||||
}
|
||||
|
||||
// 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
|
||||
// used to hold; a re-ask says it differently, because a question he already
|
||||
// failed to answer is the worst one to repeat unchanged.
|
||||
// clarifyQuestions — one short question per missing slot.
|
||||
//
|
||||
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
||||
// would wander, and a question whose wording changes every time is harder to
|
||||
// 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
|
||||
// says so out loud: dropping the request in silence would leave him thinking it
|
||||
@@ -66,28 +74,8 @@ var clarifyExpiredVariants = []string{
|
||||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||||
}
|
||||
|
||||
// clarifyExpiredPluralVariants — the same notice when TWO parked requests died
|
||||
// together (Vikunja #561). Since a side query suspends the flow instead of
|
||||
// dropping it, the stack can hold both the flow and the thing he interrupted it
|
||||
// with, and TakeExpired drops the whole stack when the top times out. "Прошлую
|
||||
// просьбу" would then be a lie about the count: he loses two and hears about
|
||||
// one.
|
||||
//
|
||||
// Two phrasings only, against five for the singular. This fires when he walks
|
||||
// off in the middle of an interrupted exchange, which is rarer than walking off
|
||||
// in the middle of a plain one, so it repeats less and needs less variety.
|
||||
var clarifyExpiredPluralVariants = []string{
|
||||
"Прости, я слишком долго ждала и отпустила обе прошлые просьбы. Если они ещё нужны, скажи заново.",
|
||||
"Я не дождалась ответа и убрала обе прошлые просьбы. Повтори, если они всё ещё нужны.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random. n is how many requests died;
|
||||
// anything above one gets the plural wording, because the bound is two today and
|
||||
// a third would still be "обе" short of the truth only if MaxStackDepth grew.
|
||||
func clarifyExpiredLine(n int) string {
|
||||
if n > 1 {
|
||||
return clarifyExpiredPluralVariants[rand.Intn(len(clarifyExpiredPluralVariants))]
|
||||
}
|
||||
// clarifyExpiredLine picks one of them at random.
|
||||
func clarifyExpiredLine() string {
|
||||
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||||
}
|
||||
|
||||
@@ -95,50 +83,38 @@ func clarifyExpiredLine(n int) string {
|
||||
// notice is glued in front of this turn's reply (see withNotice), so a caller
|
||||
// checking for it has to match a prefix, not the whole string.
|
||||
func isClarifyExpired(s string) bool {
|
||||
_, ok := cutClarifyExpired(s)
|
||||
return ok
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||||
// reply. "" ⇒ the notice was the whole thing.
|
||||
func trimClarifyExpired(s string) string {
|
||||
rest, ok := cutClarifyExpired(s)
|
||||
if !ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// cutClarifyExpired matches either expiry deck as a prefix and returns what
|
||||
// follows it. Both decks, since V-561 added the plural line: a caller asking
|
||||
// "did she say a request timed out" means the fact, not which wording carried
|
||||
// it, and a helper that knew only the singular would read the two-request
|
||||
// notice as ordinary reply text.
|
||||
func cutClarifyExpired(s string) (string, bool) {
|
||||
for _, deck := range [][]string{clarifyExpiredVariants, clarifyExpiredPluralVariants} {
|
||||
for _, v := range deck {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v)), true
|
||||
}
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v))
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||
// out, and "" when nothing was parked. Call it right after
|
||||
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
||||
// only reported here — the words themselves still go on to be routed fresh.
|
||||
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||
func (h *reactiveHandler) clarifyExpiredNotice() string {
|
||||
if h.clarifyStore == nil {
|
||||
return ""
|
||||
}
|
||||
n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now())
|
||||
if n == 0 {
|
||||
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n)
|
||||
return clarifyExpiredLine(n)
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
return clarifyExpiredLine()
|
||||
}
|
||||
|
||||
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||||
@@ -154,47 +130,10 @@ func withNotice(notice, reply string) string {
|
||||
return notice + " " + reply
|
||||
}
|
||||
|
||||
// withResumed puts the resumed question on the END of this turn's reply, where
|
||||
// withNotice puts the expiry notice on the front (Vikunja #561).
|
||||
//
|
||||
// The order is the owner's: "в Риме сейчас ..., на какое время поставить
|
||||
// напоминание?" — answer first, then the open question. A question in front of
|
||||
// its own answer would read as ignoring what he asked.
|
||||
//
|
||||
// Two sentences, not one (V-654). This used to fold the answer's full stop into
|
||||
// a comma, on the strength of the owner having written it that way once. Spliced
|
||||
// onto a real answer it reads as one run-on thought — "вот что я нашла: вайфай
|
||||
// пароль лежит в ящике стола, на какое время поставить напоминание?" — and the
|
||||
// question disappears into the tail of a sentence about something else. A reply
|
||||
// with no terminator of its own is given one, so the join never depends on how
|
||||
// the phraser chose to end.
|
||||
//
|
||||
// A resume with no answer in front of it is just the question.
|
||||
func withResumed(reply, resumed string) string {
|
||||
if resumed == "" {
|
||||
return reply
|
||||
}
|
||||
reply = strings.TrimSpace(reply)
|
||||
if reply == "" {
|
||||
return resumed
|
||||
}
|
||||
if !endsSentence(reply) {
|
||||
reply += "."
|
||||
}
|
||||
return reply + " " + resumed
|
||||
}
|
||||
|
||||
// endsSentence reports whether s already closes itself. The ellipsis counts: a
|
||||
// trailing "…" is a deliberate end, and a full stop after it reads as a typo.
|
||||
func endsSentence(s string) bool {
|
||||
r, _ := utf8.DecodeLastRuneInString(s)
|
||||
return strings.ContainsRune(".!?…", r)
|
||||
}
|
||||
|
||||
// missingFor returns the slots a decision still needs, most important first.
|
||||
// Empty ⇒ there is nothing identifiable to ask about.
|
||||
func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
return stillMissingFor(dec.Intent, dec.Utterance, toDialogueSlots(dec.Slots))
|
||||
return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots))
|
||||
}
|
||||
|
||||
// clarifyQuestion picks the one question to ask for a clarify decision. Returns
|
||||
@@ -203,49 +142,30 @@ func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
// One question about one thing: if two slots are missing she asks about the
|
||||
// first only. Two questions in one breath is an interrogation. The second gap
|
||||
// is picked up on the turn after the first one is answered (askRemainingGap).
|
||||
func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
missing := missingFor(dec)
|
||||
if len(missing) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "")
|
||||
q, ok := clarifyQuestions[missing[0]]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return missing[0], q, true
|
||||
}
|
||||
|
||||
// questionFor picks the wording for one gap. Every slot but the reminder's time
|
||||
// reads its deck by attempt; the time asks about whichever of the hour, the half
|
||||
// of the day and the day he has not said, and states the clock while it does
|
||||
// (V-579).
|
||||
//
|
||||
// taken is the acknowledgement of what his last turn added, empty when it added
|
||||
// nothing and empty for a first ask, which has no turn behind it (V-593).
|
||||
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) {
|
||||
if slot != dialogue.SlotTime {
|
||||
return clarifyQuestionFor(slot, attempt)
|
||||
}
|
||||
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken)
|
||||
}
|
||||
|
||||
// askClarify parks the request and returns the question to ask instead of the
|
||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||
// the caller falls back to the canned reply.
|
||||
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
slot, question, ok := h.clarifyQuestion(dec)
|
||||
slot, question, ok := clarifyQuestion(dec)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
// An act she could not resolve is a refusal, not a question (Vikunja #556).
|
||||
if slot == dialogue.SlotFn {
|
||||
log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance)
|
||||
return actNotRecognized, true
|
||||
}
|
||||
q := &dialogue.PendingQuestion{
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: toDialogueSlots(dec.Slots),
|
||||
Missing: []dialogue.Slot{slot},
|
||||
@@ -254,171 +174,53 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
TTL: clarifyTTL,
|
||||
Attempts: 1, // this ask
|
||||
MaxAttempts: h.clarifyMaxAttempts,
|
||||
}
|
||||
// Push, not Put, when this turn suspended a flow (Vikunja #561): the side
|
||||
// query needs clarifying of ITS own, and Put would replace the top of the
|
||||
// stack — which is the very question the side query was allowed to interrupt
|
||||
// rather than kill. Push keeps both.
|
||||
//
|
||||
// Push returns whatever the depth bound forced out, and that one has to be
|
||||
// spoken: MaxStackDepth is a promise that every level she keeps is a level
|
||||
// she can name when it dies. It is glued in front, like every other notice
|
||||
// about something let go.
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt != nil && rt.suspended {
|
||||
if evicted := h.clarifyStore.Push(dialogueIDOf(ctx), q); evicted != nil {
|
||||
log.Printf("voice: clarify — stack full at %d, letting go of the request behind %q", dialogue.MaxStackDepth, evicted.Utterance)
|
||||
rt.dropped = withNotice(rt.dropped, clarifyDropped)
|
||||
}
|
||||
// One question per breath still holds. The side query turned out to need
|
||||
// a question of its own, so THAT is the one she asks; resuming as well
|
||||
// would put two questions in one reply, which is the interrogation
|
||||
// askRemainingGap already refuses to run. The suspended flow keeps its
|
||||
// place underneath and is not lost — if it is never reached it dies on
|
||||
// the TTL, and the expiry notice (now plural-aware) says so.
|
||||
rt.resume = ""
|
||||
log.Printf("voice: clarify — asked about %s for intent=%s, stacked on a suspended flow", slot, dec.Intent)
|
||||
return question, true
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
})
|
||||
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
||||
return question, true
|
||||
}
|
||||
|
||||
// clarifyCancelled — he called the half-built request off. Said out loud, like
|
||||
// every other way it can end: a silent drop reads as "done". Feminine
|
||||
// self-reference ("отменила"), as everywhere.
|
||||
const clarifyCancelled = "Хорошо, отменила."
|
||||
|
||||
// clarifyDropped — he asked for something ELSE instead, so the parked request
|
||||
// is gone. Glued in front of the answer to what he actually asked, because
|
||||
// nothing may be dropped in silence.
|
||||
// resolveClarifyAnswer reads an utterance as the answer to a parked question.
|
||||
// Returns ("", false) when no live question is parked (or it expired), so the
|
||||
// caller routes the utterance normally as a fresh request. Sibling of
|
||||
// resolveConfirm and checked in the same place.
|
||||
//
|
||||
// Only new_request and cancel reach this since V-561. A side query used to as
|
||||
// well, and the owner rejected it on sight: he asks about the weather in the
|
||||
// middle of setting a reminder, and hearing "прошлую просьбу отпускаю" tells him
|
||||
// a thing he did not ask to lose has been lost. It had not been — there was
|
||||
// simply nowhere to put it. Now there is (ClarifyStore's stack), so a side query
|
||||
// suspends and resumes, and apologising for a drop that did not happen is worse
|
||||
// than saying nothing.
|
||||
const clarifyDropped = "Прошлую просьбу отпускаю."
|
||||
|
||||
// resolveClarifyAnswer reads an utterance against the parked question and
|
||||
// decides what it IS before deciding what to do with it. Returns ("", false)
|
||||
// when the turn is not this resolver's — nothing parked, or the utterance turned
|
||||
// out to be a request of its own — so the caller dispatches it normally.
|
||||
//
|
||||
// The order is the point (Vikunja #560). The utterance is ROUTED first, and the
|
||||
// role is read off that decision: a routed decision that stands on its own is
|
||||
// not an answer, whatever the extractor found inside it. Before this the
|
||||
// extractor decided, so "какая сейчас погода в Риме?" became the time of a
|
||||
// reminder on the strength of the word "сейчас".
|
||||
//
|
||||
// The answer itself is parsed with the same extractor the router uses, for the
|
||||
// intent she parked — no second parser. If it still does not fill the gap she
|
||||
// asks again, up to MaxAttempts; after that she says out loud that she did not
|
||||
// The answer is parsed with the same extractor the router uses, for the intent
|
||||
// she parked — no second parser. If it still does not fill the gap she asks
|
||||
// again, up to MaxAttempts; after that she says out loud that she did not
|
||||
// understand. She never drops the request in silence.
|
||||
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
return "", false // not_applicable: nothing is pending
|
||||
return "", false
|
||||
}
|
||||
|
||||
intent := router.Intent(q.Intent)
|
||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||||
|
||||
var (
|
||||
routed router.Decision
|
||||
routedOK bool
|
||||
)
|
||||
if needsRoute(text) {
|
||||
routed, routedOK = h.routeForRole(ctx, text)
|
||||
}
|
||||
role := classifyTurnRole(q, text, toDialogueSlots(answer), routed, routedOK)
|
||||
log.Printf("voice: clarify — %q is a %s against %s (routed=%v)", text, role, dialogue.CapabilityFor(q.Intent), routedOK)
|
||||
|
||||
switch role {
|
||||
case roleCancel:
|
||||
h.completeClarifyTop(ctx)
|
||||
return clarifyCancelled, true
|
||||
case roleSideQuery:
|
||||
// He asked something of his own WITHOUT leaving the flow. The question
|
||||
// stays exactly where it is — same slot, same attempt, same parked
|
||||
// utterance — and these words go on to be answered as themselves. The
|
||||
// resumed question is then glued onto the back of that answer, so one
|
||||
// reply carries both acts (Vikunja #561).
|
||||
//
|
||||
// No attempt is spent. He answered the side query, not the parked
|
||||
// question, and charging a retry for a turn that was never an answer is
|
||||
// the V-554 shape.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleAside:
|
||||
// He stated something in the middle of the flow. Same machinery as a
|
||||
// side query and for the same reason: the words are answered as
|
||||
// themselves, so the note or the fact is stored, and the question comes
|
||||
// back on the end of the same reply (V-577 shape 2). Storing it in
|
||||
// silence and dropping it in silence are both wrong, and dropping it is
|
||||
// what she did.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleNewRequest:
|
||||
// He moved on. A parked question used to swallow whatever came next, so
|
||||
// one act she could not fulfil ate the following three turns (Vikunja
|
||||
// #554) and a world question set a reminder for a time nobody asked for
|
||||
// (#558). Drop the question, say so, and let these words be themselves.
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.noteDropped(ctx)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// He is answering, so the run of step-asides is over (V-654). Reset here
|
||||
// rather than where a gap is FILLED: "позвонить маме" against a question
|
||||
// about the time gives her nothing she asked for and still means he is in
|
||||
// the exchange, and the retry it costs is bound enough on its own. The
|
||||
// counter is for the case the bounds miss — he asked for other things and
|
||||
// never came back.
|
||||
q.Suspends = 0
|
||||
|
||||
merged := q.Answer(text, toDialogueSlots(answer))
|
||||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
// A fact answers with a key and a value and fills no Text slot at all, so
|
||||
// the fold above leaves the utterance at the bare "запиши" — and that is
|
||||
// what the confirmation now reads back to him (V-592). His raw words are the
|
||||
// only record of what he said, so they are what is folded. Never for a time
|
||||
// question: what he says about when is kept apart in WhenText on purpose,
|
||||
// or the reminder would read the day back at him when it fires.
|
||||
if merged.Text == "" && !asksAboutTime(q.Missing) {
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
return h.reaskOrGiveUp(q, merged, text), true
|
||||
}
|
||||
// An answer about the time joins everything else he has said about the time,
|
||||
// and the whole of it is re-read as one request (V-579). "завтра" names the
|
||||
// day of an hour she is already holding, and read alone it names no hour at
|
||||
// all, so the parser would have nothing and she would ask for ever.
|
||||
// What he had already said about the time, read BEFORE this answer joins it.
|
||||
// A re-ask that cannot tell the two apart is the one that repeats itself
|
||||
// byte for byte (V-593).
|
||||
var taken string
|
||||
if asksAboutTime(q.Missing) {
|
||||
before := whenKnownOf(whenTextOf(q), q.Slots.HasTime)
|
||||
q.WhenText = strings.TrimSpace(q.WhenText + " " + text)
|
||||
if t, ok := h.readWhen(ctx, intent, q, text); ok {
|
||||
merged.Time, merged.HasTime = t, true
|
||||
}
|
||||
if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) {
|
||||
taken = whenTakenLine(text)
|
||||
}
|
||||
}
|
||||
if stillOpen(q.Missing, whenTextOf(q), merged) {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text, taken), true
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
|
||||
// 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
|
||||
// reminder wants both a subject and a time. "напомни" with neither used to
|
||||
// ask "О чём напомнить?", accept "позвонить маме", and then hand applyAction
|
||||
// a reminder with no time, which answered "не получилось разобрать время
|
||||
// напоминания." — an error for a request she never finished asking about.
|
||||
// Re-enter the loop instead, one question at a time as before.
|
||||
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// Rebuild the decision as if it had routed cleanly, then run it down the
|
||||
// normal path. Clarify is deliberately false and the intent is unchanged:
|
||||
// filling in an argument never grants authority, so the completed decision
|
||||
@@ -430,102 +232,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
Intent: intent,
|
||||
Slots: applyDialogueSlots(answer, merged),
|
||||
}
|
||||
// Time answers stay out of dec.Utterance because it is also the reminder
|
||||
// payload. The action schema still needs that evidence, so validate a copy
|
||||
// carrying the full time exchange while executing the clean decision.
|
||||
schemaDec := dec
|
||||
schemaDec.Utterance = whenTextOf(q)
|
||||
|
||||
// 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
|
||||
// reminder wants both a subject and a time. Re-enter the schema one question
|
||||
// at a time. If the shared attempt budget is spent, askRemainingGap visibly
|
||||
// gives up and removes this stack level; it must never turn an incomplete
|
||||
// decision into permission to act (V-717).
|
||||
if reply, handled := h.askRemainingGap(ctx, q, schemaDec); handled {
|
||||
return reply, true
|
||||
}
|
||||
h.completeClarifyTop(ctx)
|
||||
return h.finishClarified(ctx, dec, schemaDec), true
|
||||
}
|
||||
|
||||
// completeClarifyTop finishes only the active question. A nested question can
|
||||
// sit above a flow that was suspended by a side request or repair; deleting the
|
||||
// dialogue id here erased both. If one survives underneath, restart its clock
|
||||
// from the moment it is spoken again and attach its question to this turn.
|
||||
func (h *reactiveHandler) completeClarifyTop(ctx context.Context) {
|
||||
if h.clarifyStore == nil {
|
||||
return
|
||||
}
|
||||
_, resumed := h.clarifyStore.CompleteTop(dialogueIDOf(ctx), h.now())
|
||||
if resumed == nil || len(resumed.Missing) == 0 {
|
||||
return
|
||||
}
|
||||
question, ok := clarifyResumedFor(resumed.Missing[0])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if rt := turnRouteFrom(ctx); rt != nil {
|
||||
rt.resume = question
|
||||
}
|
||||
}
|
||||
|
||||
// noteDropped records that the parked request was let go this turn, so runTurn
|
||||
// can say it in front of whatever these words are answered with. Nothing to
|
||||
// record outside runTurn — a unit test calling one resolver has no turn to glue
|
||||
// a notice onto.
|
||||
func (h *reactiveHandler) noteDropped(ctx context.Context) {
|
||||
if rt := turnRouteFrom(ctx); rt != nil {
|
||||
rt.dropped = clarifyDropped
|
||||
}
|
||||
}
|
||||
|
||||
// noteSuspended keeps the parked question alive across a side query and records
|
||||
// the words that bring it back, so runTurn can put them after this turn's answer
|
||||
// (Vikunja #561).
|
||||
//
|
||||
// Two things happen to the question and neither is an attempt. Its clock is
|
||||
// restarted, because she is about to ask it again and the 90s TTL measures the
|
||||
// pause since she last spoke it — leaving Asked at the original ask would let a
|
||||
// flow he is actively working through die of a wait he did not take. And the
|
||||
// stack is left exactly as it is: the question is already on top, so suspending
|
||||
// it is not a write.
|
||||
//
|
||||
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
|
||||
// says nothing. She must not claim to be holding a question she cannot re-ask.
|
||||
//
|
||||
// Suspension is bounded, since V-654. Neither of the two things above is a
|
||||
// limit: no attempt is spent, and restarting the clock means the TTL cannot
|
||||
// arrive while he keeps talking. So the count is the only thing that ends it,
|
||||
// and past MaxSuspends she lets the request go and says so with the same line
|
||||
// every other drop uses. The rule is unchanged — a question ends by being
|
||||
// answered or by being let go out loud — this only recognises three unrelated
|
||||
// requests in a row as the second of those.
|
||||
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt == nil || len(q.Missing) == 0 {
|
||||
return
|
||||
}
|
||||
question, ok := clarifyResumedFor(q.Missing[0])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !q.CanResume() {
|
||||
h.completeClarifyTop(ctx)
|
||||
h.noteDropped(ctx)
|
||||
log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
|
||||
return
|
||||
}
|
||||
q.Suspends++
|
||||
// Rides is the same event counted without the reset (V-663). Incremented
|
||||
// beside Suspends and never anywhere else, so the two cannot disagree about
|
||||
// what happened, only about how much of it they remember.
|
||||
q.Rides++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
rt.resume = question
|
||||
rt.suspended = true
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d, ride %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends, q.Rides, dialogue.MaxRides)
|
||||
return h.finishClarified(ctx, dec), true
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
@@ -544,64 +251,49 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
return strings.TrimSpace(utterance) + " " + subject
|
||||
}
|
||||
|
||||
// askRemainingGap re-parks the request when the answer closed one gap and the
|
||||
// action schema still names another. Returns ("", false) only when the request
|
||||
// is complete. A remaining gap is always handled here: one next question while
|
||||
// budget remains, otherwise an explicit give-up with no partial action (V-717).
|
||||
// askRemainingGap re-parks the request when the answer closed one gap and
|
||||
// wantedSlots still names another. Returns ("", false) when the request is
|
||||
// complete, when there is no question for what is left, or when she is out of
|
||||
// attempts — in all three the caller runs the decision as it stands, which for
|
||||
// the out-of-attempts case is the old behaviour and is the right one: she has
|
||||
// already asked enough.
|
||||
//
|
||||
// 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
|
||||
// still bounds how many times she can speak before acting or letting go.
|
||||
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, dec router.Decision) (string, bool) {
|
||||
remaining := missingFor(dec)
|
||||
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||
// with the re-ask path, so the second gap is worded like a second try.
|
||||
merged := toDialogueSlots(dec.Slots)
|
||||
question, ok := h.questionFor(remaining[0], q.Attempts+1, dec.Utterance, merged, "")
|
||||
question, ok := clarifyQuestions[remaining[0]]
|
||||
if !ok || !q.CanAsk() {
|
||||
h.completeClarifyTop(ctx)
|
||||
log.Printf("voice: clarify — gave up with required gap %s still open after %d question(s); no action ran", remaining[0], q.Attempts)
|
||||
return clarifyGaveUp, true
|
||||
return "", false
|
||||
}
|
||||
// Suspends is not carried, and by this point it is already zero: the answer
|
||||
// path resets it (V-654). Left off the literal so the zero is stated where
|
||||
// the struct is built, rather than inherited from a field nobody names.
|
||||
//
|
||||
// Rides IS carried, and that is the whole point of it (V-663). This is the
|
||||
// same request under a second question, not a new one, so the turns it has
|
||||
// already ridden still count against it. Dropping the field here is exactly
|
||||
// the re-basing that let one question ride twenty-six replies.
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
Utterance: q.Utterance,
|
||||
WhenText: q.WhenText,
|
||||
Asked: h.now(),
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
Rides: q.Rides,
|
||||
})
|
||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], dec.Intent, q.Attempts+1)
|
||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
||||
return question, true
|
||||
}
|
||||
|
||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||
// again while she has attempts left, otherwise say she did not understand and
|
||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||
// taken is the acknowledgement of what this answer DID give, empty when it gave
|
||||
// nothing (V-593). The give-up line never carries it: it is not another ask.
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string {
|
||||
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
|
||||
question = clarifyQuestions[q.Missing[0]]
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.completeClarifyTop(ctx)
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||
return clarifyGaveUp
|
||||
}
|
||||
@@ -610,56 +302,23 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
|
||||
q.Slots = merged
|
||||
q.Attempts++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
h.clarifyStore.Put(voiceDialogueID, q)
|
||||
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||||
return question
|
||||
}
|
||||
|
||||
// finishClarified completes a decision whose parked gaps were already checked
|
||||
// by resolveClarifyAnswer. It still records the turn for a later correction;
|
||||
// the old path made anything completed through dialogue uncorrectable (V-573).
|
||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec, schemaDec router.Decision) string {
|
||||
return h.finishRebuilt(ctx, dec, schemaDec, false)
|
||||
}
|
||||
|
||||
// finishRepaired validates a decision rebuilt from an older utterance. Unlike
|
||||
// resolveClarifyAnswer, repair has not passed the current slot gate, so it must
|
||||
// ask about any missing argument before acting (V-573).
|
||||
func (h *reactiveHandler) finishRepaired(ctx context.Context, dec router.Decision) string {
|
||||
return h.finishRebuilt(ctx, dec, dec, true)
|
||||
}
|
||||
|
||||
// finishRebuilt is the execution boundary for decisions reconstructed from
|
||||
// dialogue. schemaDec is the same action with all validation evidence present;
|
||||
// a clarified reminder includes the separately-held time answers there while
|
||||
// dec keeps the clean reminder payload. No rebuilt action crosses this boundary
|
||||
// while missingFor still names a required slot.
|
||||
func (h *reactiveHandler) finishRebuilt(ctx context.Context, dec, schemaDec router.Decision, ask bool) string {
|
||||
missing := missingFor(schemaDec)
|
||||
if ask && (schemaDec.Clarify || len(missing) > 0) {
|
||||
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||||
return reply
|
||||
}
|
||||
if question, asked := h.askClarify(ctx, dec); asked {
|
||||
return question
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
log.Printf("voice: clarify — refusing incomplete rebuilt intent=%s with required gaps %v; no action ran", dec.Intent, missing)
|
||||
return clarifyGaveUp
|
||||
}
|
||||
// finishClarified runs a completed decision through the same steps a freshly
|
||||
// routed one takes: remember the turn, act, then phrase.
|
||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
||||
if h.dialogueSessions != nil {
|
||||
now := h.now()
|
||||
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
h.rememberTurn(ctx, prev, dec, now)
|
||||
}
|
||||
if !dec.Clarify {
|
||||
h.recordTurn(dec.Utterance, dec.Intent)
|
||||
h.rememberTurn(prev, dec, now)
|
||||
}
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply == "" {
|
||||
reply = h.replier.Reply(ctx, dec)
|
||||
reply = h.replier.Reply(dec)
|
||||
}
|
||||
if reply == "" {
|
||||
// Belt: an empty reply here would be a silent drop.
|
||||
@@ -668,35 +327,25 @@ func (h *reactiveHandler) finishRebuilt(ctx context.Context, dec, schemaDec rout
|
||||
return reply
|
||||
}
|
||||
|
||||
// maxCarriedHistory — how many turns of PRIOR history (beyond the immediate
|
||||
// last turn) rememberTurn carries forward. The session ends up holding this
|
||||
// many plus the one just-finished turn, so callers describing the total
|
||||
// depth (chatHistory's doc comment, this one) say "up to 4".
|
||||
const maxCarriedHistory = 3
|
||||
|
||||
// rememberTurn stores this turn as the dialogue session the next follow-up
|
||||
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
|
||||
// one long conversation can't grow the session unboundedly.
|
||||
func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
var history []dialogue.Turn
|
||||
if prev != nil {
|
||||
// History is chronological. Keep the newest tail of the older history,
|
||||
// then append the immediate prior turn. The previous implementation put
|
||||
// the newest turn first while the type contract said newest-last, so the
|
||||
// model read a conversation backwards.
|
||||
from := len(prev.History) - maxCarriedHistory
|
||||
if from < 0 {
|
||||
from = 0
|
||||
history = append(history, dialogue.Turn{
|
||||
Intent: prev.Intent,
|
||||
Slots: prev.Slots,
|
||||
Text: prev.Slots.Text,
|
||||
})
|
||||
maxHist := len(prev.History)
|
||||
if maxHist > 3 {
|
||||
maxHist = 3
|
||||
}
|
||||
history = append(history, prev.History[from:]...)
|
||||
history = append(history, sessionAsTurn(prev))
|
||||
}
|
||||
conversational := dec.Intent == router.IntentChat || opensConversation(dec.Utterance)
|
||||
if prev != nil && (prev.Conversational || prev.Intent == dialogue.IntentChat) {
|
||||
conversational = true
|
||||
history = append(history, prev.History[:maxHist]...)
|
||||
}
|
||||
ttl := time.Duration(0) // use the store default (2 min)
|
||||
if conversational {
|
||||
if dec.Intent == router.IntentChat {
|
||||
ttl = 15 * time.Minute // conversational turns should last longer
|
||||
}
|
||||
// A system or query turn often carries no Text slot at all — a stage-0
|
||||
@@ -717,13 +366,11 @@ func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Sessi
|
||||
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
|
||||
slots.Text = dec.Utterance
|
||||
}
|
||||
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: slots,
|
||||
Utterance: dec.Utterance,
|
||||
Conversational: conversational,
|
||||
Timestamp: now,
|
||||
TTL: ttl,
|
||||
History: history,
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: slots,
|
||||
Timestamp: now,
|
||||
TTL: ttl,
|
||||
History: history,
|
||||
})
|
||||
}
|
||||
|
||||
+66
-452
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -58,26 +57,18 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
||||
want string
|
||||
asked bool
|
||||
}{
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Сейчас 09:00. Когда?", true},
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||||
// A time with nothing to say at that time is still half a reminder, so
|
||||
// the subject is what she asks about — not silence.
|
||||
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
|
||||
// A bare hour is half of a day away from being an answer, and she asks
|
||||
// which half rather than picking one (V-579).
|
||||
{"reminder whose hour could be either half of the day", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "Сейчас 09:00. Это утра или вечера?", true},
|
||||
{"reminder that has all three", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни завтра в 15:00 позвонить маме"), "", false},
|
||||
// The owner's own two, confirmed 2026-08-06: an unambiguous time and a
|
||||
// relative one are both complete and are never asked about.
|
||||
{"an interval names the instant by itself", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни через час позвонить маме"), "", false},
|
||||
{"half an hour is an interval too", clarifyDec(router.IntentReminder, router.Slots{Text: "выключить духовку", HasTime: true}, "напомни через полчаса выключить духовку"), "", false},
|
||||
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
|
||||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||||
}
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
for _, tc := range cases {
|
||||
_, got, asked := h.clarifyQuestion(tc.dec)
|
||||
_, got, asked := clarifyQuestion(tc.dec)
|
||||
if asked != tc.asked || got != tc.want {
|
||||
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
||||
}
|
||||
@@ -90,14 +81,12 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "Сейчас 09:00. Когда?" {
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
// The answer names the day as well as the hour. A reminder commits on what,
|
||||
// what time and what day, and a dayless hour is asked about (V-579).
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00")
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00")
|
||||
if !handled {
|
||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||
}
|
||||
@@ -123,7 +112,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||
t.Fatal("a fact with no key should be asked about")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||||
@@ -139,7 +128,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
@@ -158,7 +147,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a first question")
|
||||
}
|
||||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||||
@@ -167,14 +156,8 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||
}
|
||||
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||
// question he already failed to answer is the worst way to ask it.
|
||||
want, _ := whenQuestion(whenNoHour, i, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||
}
|
||||
if first, _ := whenQuestion(whenNoHour, 1, h.now(), ""); reply == first {
|
||||
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
t.Fatalf("attempt %d must leave the question armed", i)
|
||||
@@ -202,7 +185,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||||
@@ -216,7 +199,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// First answer parses, but re-park it by hand as if she had asked again:
|
||||
@@ -226,11 +209,10 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
if q == nil {
|
||||
t.Fatal("expected an armed question")
|
||||
}
|
||||
first := h.extractor.Extract(ctx, router.IntentReminder, "сегодня в 11:00", h.now())
|
||||
q.Slots = q.Answer("сегодня в 11:00", toDialogueSlots(first))
|
||||
q.WhenText = "сегодня в 11:00"
|
||||
first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now())
|
||||
q.Slots = q.Answer("в 11:00", toDialogueSlots(first))
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, сегодня в 15:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
@@ -243,35 +225,34 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestActOffAllowlistIsStillRefused — naming a capability is not being granted
|
||||
// one. Since Vikunja #556 an unresolved act no longer parks a question, so this
|
||||
// goes through applyAction, which is the only way an act runs.
|
||||
func TestActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
// TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an
|
||||
// argument, it never grants authority.
|
||||
func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||||
if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.applyAction(ctx, router.Decision{
|
||||
Utterance: "rm " + marker,
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "rm " + marker, HasFn: true},
|
||||
})
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("an act with no fn should be asked about")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||||
if !handled {
|
||||
t.Fatal("the answer should be consumed")
|
||||
}
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Fatalf("an act that is not on the allowlist must not report success: %q", reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("a clarified act off the allowlist ran anyway: %v", err)
|
||||
}
|
||||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 1 {
|
||||
t.Fatalf("an act must not enable a tool: tools=%+v err=%v", tools, err)
|
||||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 0 {
|
||||
t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDestructiveActStillNeedsConfirm — the confirm gate stands on the act path.
|
||||
func TestDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
// TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the
|
||||
// clarify path.
|
||||
func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "destructive-ran")
|
||||
@@ -279,16 +260,18 @@ func TestDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.applyAction(ctx, router.Decision{
|
||||
Utterance: "delete_backups",
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "delete_backups", HasFn: true},
|
||||
})
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||||
if !handled {
|
||||
t.Fatal("the answer should be consumed")
|
||||
}
|
||||
if !strings.Contains(reply, "да") || h.pending == nil {
|
||||
t.Fatalf("a destructive act must park a confirm: reply=%q pending=%+v", reply, h.pending)
|
||||
t.Fatalf("a clarified destructive act must still park a confirm: reply=%q pending=%+v", reply, h.pending)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("a destructive act ran before confirmation: %v", err)
|
||||
t.Fatalf("a clarified destructive act ran before confirmation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +284,7 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
||||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
||||
} {
|
||||
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||||
if question, asked := h.askClarify(dec); asked {
|
||||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||||
}
|
||||
}
|
||||
@@ -313,29 +296,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||||
// TTL: she must say the old request is gone AND still answer the new words.
|
||||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
ctx := context.Background()
|
||||
h, _, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil, nil)
|
||||
h.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "", "как дела")
|
||||
reply := h.handleText(ctx, "как дела")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||
}
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -357,7 +340,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -366,11 +349,8 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed as an answer")
|
||||
}
|
||||
// Second gap, second attempt, so it is the second wording of the time
|
||||
// question — the attempt budget is shared between the two paths.
|
||||
want, _ := whenQuestion(whenNoHour, 2, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
@@ -380,7 +360,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||||
}
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
@@ -394,86 +374,25 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
|
||||
// TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question
|
||||
// out of the same budget, so it cannot turn a capped exchange into an endless
|
||||
// one. With one attempt allowed she gives up visibly and creates nothing: the
|
||||
// cap is a bound on dialogue, never a path around the action schema (V-717).
|
||||
// one. With one attempt allowed she acts on what she has instead of asking.
|
||||
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
t.Fatal("expected the subject question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled || reply != clarifyGaveUp {
|
||||
t.Fatalf("out of attempts she must give up visibly, handled=%v reply=%q", handled, reply)
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed")
|
||||
}
|
||||
if isAnyClarifyQuestion(reply) {
|
||||
t.Fatalf("out of attempts she must not ask another question: %q", reply)
|
||||
if reply == "Когда?" {
|
||||
t.Fatal("out of attempts she must not ask a second question")
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("no question may stay armed past the cap")
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("an incomplete exhausted request created a reminder: reminders=%+v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausting a nested top request removes only that request and makes the
|
||||
// lower flow audible again in the same reply. This is the multi-gap exhaustion
|
||||
// shape, not the ordinary failed-answer path covered in repair_test.go.
|
||||
func TestClarifySecondGapExhaustionResumesLowerFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
older := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.IntentReminder,
|
||||
Slots: dialogue.Slots{Text: "позвонить маме"}, Missing: []dialogue.Slot{dialogue.SlotTime},
|
||||
Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL,
|
||||
Attempts: 1, MaxAttempts: dialogue.DefaultMaxAttempts,
|
||||
}
|
||||
top := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotText},
|
||||
Utterance: "напомни", Asked: h.now(), TTL: clarifyTTL,
|
||||
Attempts: 1, MaxAttempts: 1,
|
||||
}
|
||||
h.clarifyStore.Push(voiceDialogueID, older)
|
||||
h.clarifyStore.Push(voiceDialogueID, top)
|
||||
|
||||
reply := h.runTurn(ctx, router.NormalizedInput{Text: "купить хлеб", Source: sourceText})
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
want := withResumed(clarifyGaveUp, resumed)
|
||||
if reply != want {
|
||||
t.Fatalf("reply=%q, want visible top give-up followed by resumed lower question %q", reply, want)
|
||||
}
|
||||
if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 1 {
|
||||
t.Fatalf("exhausting the top request left stack depth %d, want 1", depth)
|
||||
}
|
||||
if got := h.clarifyStore.Get(voiceDialogueID, h.now()); got != older {
|
||||
t.Fatalf("resumed flow=%+v, want the older question", got)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("nested exhaustion partially created a reminder: reminders=%+v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The rebuilt-action boundary repeats the schema invariant even though the
|
||||
// normal resolver checked it one branch earlier. A future dialogue caller must
|
||||
// not be able to bypass required slots by calling the completion wrapper.
|
||||
func TestFinishClarifiedRefusesIncompleteAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
dec := router.Decision{
|
||||
Utterance: "напомни позвонить маме",
|
||||
Stage: 2,
|
||||
Intent: router.IntentReminder,
|
||||
Slots: router.Slots{Text: "позвонить маме"},
|
||||
}
|
||||
if reply := h.finishClarified(ctx, dec, dec); reply != clarifyGaveUp {
|
||||
t.Fatalf("incomplete rebuilt action reply=%q, want %q", reply, clarifyGaveUp)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("rebuilt-action guard allowed a partial reminder: reminders=%+v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that
|
||||
@@ -491,10 +410,9 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
eval.CheckAddress: true,
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
lines := append([]string{clarifyGaveUp, clarifyCancelled, clarifyDropped}, clarifyExpiredVariants...)
|
||||
lines = append(lines, clarifyMissedVariants...)
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
lines = append(lines, variants...)
|
||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||
for _, q := range clarifyQuestions {
|
||||
lines = append(lines, q)
|
||||
}
|
||||
for _, line := range lines {
|
||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||
@@ -522,10 +440,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
// 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.
|
||||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
ctx := context.Background()
|
||||
h, _, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// A confirm parked with a longer life than the question, so only the
|
||||
@@ -533,7 +451,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "", "нет")
|
||||
reply := h.handleText(ctx, "нет")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||
}
|
||||
@@ -543,311 +461,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm must still have been consumed")
|
||||
}
|
||||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStepsAsideForItsOwnRequest — Vikunja #554. An act she could not
|
||||
// fulfil parked "Что сделать?", and the three turns after it were scored as
|
||||
// answers to that question: a world question, then "как дела", then the give-up
|
||||
// line. None of them was ever an answer.
|
||||
func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
// A reminder, not the act this bug was found on: since Vikunja #556 an act
|
||||
// no longer parks anything, so it can no longer eat the turn after it.
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("a reminder with no time should be asked about")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled {
|
||||
t.Fatalf("a world question must route as itself, got %q", reply)
|
||||
}
|
||||
// Not eating the turn is `handled == false` above, and that is the whole of
|
||||
// #554. Since V-561 the question also SURVIVES it: a side query suspends the
|
||||
// flow rather than ending it, so the reminder is still there and still on the
|
||||
// attempt it was parked with.
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
t.Fatal("a side query must suspend the parked question, not drop it")
|
||||
}
|
||||
if q.Attempts != 1 {
|
||||
t.Errorf("a turn that was never an answer spent an attempt: %d, want 1", q.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStillRetriesOnAnAnswerThatMissed — the other half of #554, and the
|
||||
// reason the test above is narrow. A bare noun answers nothing either, but it
|
||||
// carries no request of its own, so she asks again as before.
|
||||
func TestClarifyStillRetriesOnAnAnswerThatMissed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "ага")
|
||||
if !handled || reply == "" {
|
||||
t.Fatalf("a missed answer must still be re-asked, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
t.Error("the question must survive a missed answer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands — the guard runs only
|
||||
// where nothing was filled. "во сколько?" is question-shaped and is also how a
|
||||
// time gets said back, so an answer that closes the gap wins whatever its shape.
|
||||
func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если сегодня в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("reminder was not created: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnresolvedActSaysItDoesNotKnowTheCommand — Vikunja #556. "Что сделать?"
|
||||
// has no answer he can give, so an act that matched no capability is refused in
|
||||
// one line and nothing is parked. It does not recite what she can do instead.
|
||||
func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
// Enabled tools change nothing here: this act matched none of them.
|
||||
if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply, spoken := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "выключи свет"}, "выключи свет"))
|
||||
if !spoken || reply != actNotRecognized {
|
||||
t.Fatalf("reply = %q spoken=%v, want %q", reply, spoken, actNotRecognized)
|
||||
}
|
||||
if strings.Contains(reply, "uptime") {
|
||||
t.Errorf("reply = %q, want no list of capabilities he did not ask about", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Error("nothing to ask about, so nothing may be parked")
|
||||
}
|
||||
}
|
||||
|
||||
// newRoutingClarifyHandler wires the real cascade (hash embedder, no model) onto
|
||||
// the clarify handler, so a test can drive handleText end to end and see which
|
||||
// gate claimed the turn.
|
||||
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
|
||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestIncompleteReminderAsksInsteadOfFailing — Vikunja #557. "напомни позвонить"
|
||||
// is routed confidently and is still half a request. It used to reach applyAction,
|
||||
// fail on the missing time and park nothing, so the "в семь вечера" that followed
|
||||
// was routed as a world question and web-searched.
|
||||
func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
|
||||
reply := h.handleText(ctx, "web", "напомни позвонить маме")
|
||||
want, _ := whenQuestion(whenNoHour, 1, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("reply = %q, want the time question %q", reply, want)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||||
t.Fatal("the request must be parked, or the answer has nowhere to land")
|
||||
}
|
||||
if reply := h.handleText(ctx, "web", "сегодня в семь вечера"); strings.Contains(reply, "нашла") {
|
||||
t.Fatalf("the answer to her own question must not be looked up: %q", reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("the answer did not complete the reminder: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBareCaptureVerbAsksWhatToRecord — the other half of #557. A bare "запиши"
|
||||
// went to the resident model as chat, which agreed to a wording change nobody
|
||||
// asked for. It is a fact with no key, and that gap has a question.
|
||||
func TestBareCaptureVerbAsksWhatToRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
|
||||
reply := h.handleText(ctx, "web", "запиши")
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotKey, 1)
|
||||
if reply != want {
|
||||
t.Fatalf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||||
t.Fatal("the request must be parked so the next utterance completes it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACompleteTurnStillDoesNotAsk — the gate reads a missing slot, not any
|
||||
// slot, so a request she can act on must never turn into a question. Checked on
|
||||
// the decision rather than through the cascade: what is at stake is the gate's
|
||||
// condition, and driving it through the hash embedder would measure routing.
|
||||
func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
complete := []router.Decision{
|
||||
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни завтра в 11 утра позвонить маме"},
|
||||
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"},
|
||||
{Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"},
|
||||
{Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"},
|
||||
}
|
||||
for _, dec := range complete {
|
||||
if gaps := missingFor(dec); len(gaps) > 0 {
|
||||
t.Errorf("%q reads as incomplete: %v", dec.Utterance, gaps)
|
||||
}
|
||||
if reply, asked := h.askClarify(ctx, dec); asked {
|
||||
t.Errorf("%q was answered with a question: %q", dec.Utterance, reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheResumedQuestionIsItsOwnSentence — V-654. The re-ask used to be spliced
|
||||
// onto the answer with a comma, so a real answer and an unrelated open question
|
||||
// read as one run-on thought and the question vanished into its tail.
|
||||
func TestTheResumedQuestionIsItsOwnSentence(t *testing.T) {
|
||||
const resumed = "На какое время поставить напоминание?"
|
||||
cases := []struct {
|
||||
name string
|
||||
reply string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// The measured line, shortened. Two sentences, and the question keeps
|
||||
// its capital.
|
||||
name: "a statement keeps its full stop",
|
||||
reply: "Вайфай пароль лежит в ящике стола.",
|
||||
want: "Вайфай пароль лежит в ящике стола. " + resumed,
|
||||
},
|
||||
{
|
||||
name: "a reply with no terminator is given one",
|
||||
reply: "Вайфай пароль лежит в ящике стола",
|
||||
want: "Вайфай пароль лежит в ящике стола. " + resumed,
|
||||
},
|
||||
{
|
||||
// She sometimes answers a side query by asking him to say it again.
|
||||
// Two questions, and neither may swallow the other.
|
||||
name: "a question keeps its mark",
|
||||
reply: "Можешь переформулировать?",
|
||||
want: "Можешь переформулировать? " + resumed,
|
||||
},
|
||||
{
|
||||
name: "an ellipsis is already an ending",
|
||||
reply: "Не уверена…",
|
||||
want: "Не уверена… " + resumed,
|
||||
},
|
||||
{
|
||||
name: "a resume with no answer in front of it is just the question",
|
||||
reply: "",
|
||||
want: resumed,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := withResumed(tc.reply, resumed); got != tc.want {
|
||||
t.Errorf("%s: withResumed(%q) = %q, want %q", tc.name, tc.reply, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := withResumed("Готово.", ""); got != "Готово." {
|
||||
t.Errorf("nothing to resume must leave the reply alone, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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: {
|
||||
"Что записать?",
|
||||
"Что именно отметить?",
|
||||
"Назови, что записать — например, «выпил воды».",
|
||||
},
|
||||
// Not spoken since Vikunja #556: askClarify answers actNotRecognized for a
|
||||
// missing capability rather than asking. Kept because clarifyQuestion still
|
||||
// reports the gap, and a re-ask deck with a hole in it is harder to read.
|
||||
dialogue.SlotFn: {
|
||||
"Что сделать?",
|
||||
"Какое действие выполнить?",
|
||||
"Назови действие — я умею только то, что ты мне разрешил.",
|
||||
},
|
||||
}
|
||||
|
||||
// clarifyResumedVariants — the wording for a question coming BACK after a side
|
||||
// query took the turn away from it (Vikunja #561).
|
||||
//
|
||||
// It is not the first question again. "Когда?" works in the same breath as
|
||||
// "напомни позвонить маме", because the thing it is about was just said. After
|
||||
// a turn about the weather in Rome it does not: he has been thinking about
|
||||
// something else, and a bare "Когда?" asks him to remember what she is holding.
|
||||
// So the resumed form names the request — "напоминание", "заметка" — and the
|
||||
// first form stays short.
|
||||
//
|
||||
// One wording per slot, not a rotation and not an attempt ladder. A resume does
|
||||
// not spend an attempt (that is the point of suspending rather than re-asking),
|
||||
// so there is no attempt number to vary on, and this line is heard once per
|
||||
// interruption rather than repeatedly.
|
||||
//
|
||||
// Persona holds: infinitive, so no gender agreement, "ты" nowhere needed, no pet
|
||||
// names.
|
||||
var clarifyResumedVariants = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "На какое время поставить напоминание?",
|
||||
dialogue.SlotText: "Так о чём напомнить?",
|
||||
dialogue.SlotKey: "Так что записать?",
|
||||
dialogue.SlotFn: "Так какое действие выполнить?",
|
||||
}
|
||||
|
||||
// clarifyResumedFor gives the resumed wording for a slot. ("", false) when the
|
||||
// slot has none, and the caller then resumes nothing rather than inventing a
|
||||
// question — a flow it cannot re-ask is one it must not claim to be holding.
|
||||
func clarifyResumedFor(slot dialogue.Slot) (string, bool) {
|
||||
q, ok := clarifyResumedVariants[slot]
|
||||
return q, ok
|
||||
}
|
||||
|
||||
// actNotRecognized is what an act she cannot run gets (Vikunja #556).
|
||||
//
|
||||
// The deck used to ask "Что сделать?" instead. That question has no answer he
|
||||
// can give: he already said what he wanted, and nothing he repeats will match a
|
||||
// capability that is not there. So she asked, failed, asked again and gave up —
|
||||
// three turns spent on one refusal. She says it once now, and parks nothing.
|
||||
//
|
||||
// It does not recite the allowlist. A list of names he did not ask about is not
|
||||
// an answer to the thing he did ask about.
|
||||
const actNotRecognized = "Такую команду я не знаю."
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// commandProhibitionReply is deliberately operation-neutral. At this boundary
|
||||
// Maven may know only that the user denied authority, not whether the model
|
||||
// would have called it a reminder, board transition, local tool or Hexis act.
|
||||
const commandProhibitionReply = "хорошо, не буду."
|
||||
|
||||
// resolveCommandProhibition is the first mutation boundary in a turn. It runs
|
||||
// before a parked clarify answer or candidate selection can consume the words,
|
||||
// and before any route/model is consulted. A direct prohibition is complete in
|
||||
// itself: it needs no target lookup and makes no external call.
|
||||
//
|
||||
// A parked clarify request is unrelated state. Preserve it and say the pending
|
||||
// question again, using the same bounded suspend policy as every other side
|
||||
// request. Candidate lists likewise remain untouched; no ordinal was selected.
|
||||
func (h *reactiveHandler) resolveCommandProhibition(ctx context.Context, text string) (string, bool) {
|
||||
if !router.IsCommandProhibition(text) {
|
||||
return "", false
|
||||
}
|
||||
// A later bare "да" must not revive authority the user has just revoked.
|
||||
// Confirmation slots are all mutation authority and are process-local, so
|
||||
// clearing the three under their shared mutex is both conservative and
|
||||
// atomic. Clarify questions and candidate lists are not authority and stay.
|
||||
h.mu.Lock()
|
||||
h.pending = nil
|
||||
h.pendingHexis = nil
|
||||
h.pendingRoutine = nil
|
||||
h.mu.Unlock()
|
||||
if h.clarifyStore != nil {
|
||||
if q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now()); q != nil {
|
||||
h.noteSuspended(ctx, q)
|
||||
}
|
||||
}
|
||||
return commandProhibitionReply, true
|
||||
}
|
||||
|
||||
// refusesCommand is the defense-in-depth form for execution entry points which
|
||||
// can also be called with a reconstructed or test decision outside runTurn.
|
||||
// The sentinel cannot be renamed into an enabled function, and the original
|
||||
// utterance remains the authority even when a model rewrites Slots.Text.
|
||||
func refusesCommand(dec router.Decision) bool {
|
||||
return dec.CapabilitySelection.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestRefusesCommandUsesCapabilitySelection proves that refusesCommand reads
|
||||
// CapabilitySelection.Fn rather than the compatibility Slots.Fn. When
|
||||
// CapabilitySelection is populated with the prohibited sentinel but Slots.Fn
|
||||
// is blank, the refusal must still fire.
|
||||
func TestRefusesCommandUsesCapabilitySelection(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
CapabilitySelection: router.CapabilitySelection{
|
||||
Fn: router.ProhibitedActFn,
|
||||
Resolved: true,
|
||||
},
|
||||
// Slots compatibility fields deliberately blank.
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true when CapabilitySelection.Fn == ProhibitedActFn")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandUtteranceFallback proves that the utterance-based
|
||||
// prohibition check still works as a defense-in-depth belt when
|
||||
// CapabilitySelection does not carry the sentinel.
|
||||
func TestRefusesCommandUtteranceFallback(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Utterance: "don't restart nginx",
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true for a prohibited utterance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandNonProhibitedCapability proves that an act with a
|
||||
// non-prohibited capability is NOT refused even when Slots.Fn happens to
|
||||
// carry the prohibited sentinel (cross-contamination).
|
||||
func TestRefusesCommandNonProhibitedCapability(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart nginx",
|
||||
CapabilitySelection: router.CapabilitySelection{
|
||||
Fn: "restart",
|
||||
Resolved: true,
|
||||
},
|
||||
}
|
||||
if refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return false for a non-prohibited capability")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusesCommandProhibitedSentinelPreservedThroughPipeline proves that the
|
||||
// command-prohibition grammar sentinel survives through SelectCapability into
|
||||
// CapabilitySelection byte-for-byte.
|
||||
func TestRefusesCommandProhibitedSentinelPreservedThroughPipeline(t *testing.T) {
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{
|
||||
Fn: router.ProhibitedActFn,
|
||||
HasFn: true,
|
||||
},
|
||||
}
|
||||
sel := router.SelectCapability(dec, nil)
|
||||
dec.CapabilitySelection = sel
|
||||
|
||||
if dec.CapabilitySelection.Fn != router.ProhibitedActFn {
|
||||
t.Errorf("CapabilitySelection.Fn = %q, want %q", dec.CapabilitySelection.Fn, router.ProhibitedActFn)
|
||||
}
|
||||
if !dec.CapabilitySelection.Resolved {
|
||||
t.Error("CapabilitySelection.Resolved should be true")
|
||||
}
|
||||
if !refusesCommand(dec) {
|
||||
t.Fatal("refusesCommand should return true after pipeline preserves the sentinel")
|
||||
}
|
||||
}
|
||||
+34
-125
@@ -3,14 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -24,14 +19,6 @@ type pendingHexisExec struct {
|
||||
entityID string
|
||||
displayName string
|
||||
expiry time.Time
|
||||
|
||||
// correlationID — the id the proposing turn minted for this action. A
|
||||
// confirm arrives on a later turn with a context of its own, so without
|
||||
// carrying it here the execution recorded a fresh id and no causation at
|
||||
// all, and the resolve, the discovery and the thing they authorised sat in
|
||||
// the trace as unrelated calls. The contract mints one id per action, and
|
||||
// the action began when she asked.
|
||||
correlationID string
|
||||
}
|
||||
|
||||
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
|
||||
@@ -69,18 +56,10 @@ func (h *reactiveHandler) park(fn string, args []string, phrase string) {
|
||||
// resolveConfirm interprets an utterance as the answer to a parked destructive
|
||||
// act OR a parked routine proposal. Returns (reply, true) when it consumed the
|
||||
// utterance as a y/n answer; ("", false) when there's nothing pending (or the
|
||||
// parked act expired), so the caller routes the utterance normally.
|
||||
//
|
||||
// An utterance that is not clearly yes or no is not an answer at all, so it is
|
||||
// handed straight back and the pending stays parked until it expires (V-567).
|
||||
// This resolver runs before routing and holds the most dangerous trigger on the
|
||||
// box; it may only claim a turn it is certain about.
|
||||
// parked act expired), so the caller routes the utterance normally. An
|
||||
// unrecognised answer cancels the pending and routes normally — a confirm that
|
||||
// can't be answered clearly is safer abandoned than left armed.
|
||||
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
|
||||
verdict := classifyConfirm(text)
|
||||
if verdict == confirmUnknown {
|
||||
return "", false
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -88,12 +67,16 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
|
||||
if !r.claim() {
|
||||
continue
|
||||
}
|
||||
// The slot is already cleared by claim().
|
||||
switch verdict {
|
||||
// The slot is already cleared by claim(): every branch below drops the
|
||||
// pending, including the unclear one — a confirm that can't be
|
||||
// answered clearly is safer abandoned than left armed.
|
||||
switch classifyConfirm(text) {
|
||||
case confirmYes:
|
||||
return r.yes(), true
|
||||
default:
|
||||
case confirmNo:
|
||||
return r.no(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
@@ -135,13 +118,13 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
//
|
||||
// Acceptance itself is recorded by /routines, and the tick
|
||||
// loop nudges on the interval from there (Vikunja #366).
|
||||
return phraser.C(phraser.ConfirmRoutineAuthed, nil)
|
||||
return "поняла — подтверди на странице рутин, и начну напоминать."
|
||||
},
|
||||
no: func() string {
|
||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||
log.Printf("voice: dismiss proposed routine: %v", err)
|
||||
}
|
||||
return phraser.C(phraser.ConfirmRoutineNo, nil)
|
||||
return "хорошо, не буду."
|
||||
},
|
||||
},
|
||||
// Hexis execution confirm. Bound to the exact capability + target that
|
||||
@@ -152,13 +135,9 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
return hx != nil && !h.now().After(hx.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
execCtx := ctx
|
||||
if hx.correlationID != "" {
|
||||
execCtx = withCorrelationID(execCtx, hx.correlationID)
|
||||
}
|
||||
return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
no: func() string { return "отменила." },
|
||||
},
|
||||
// Tool confirm.
|
||||
{
|
||||
@@ -171,16 +150,16 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
if err != nil {
|
||||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
||||
if out != "" {
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
}
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
return "не получилось выполнить команду."
|
||||
}
|
||||
if out != "" {
|
||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||
return "готово: " + firstLine(out)
|
||||
}
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
return "готово."
|
||||
},
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
no: func() string { return "отменила." },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -191,18 +170,17 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
|
||||
name := firstWord(stripWake(dec.Utterance))
|
||||
if name == "" {
|
||||
return phraser.C(phraser.ProposeNoVerb, nil)
|
||||
return "не разобрала команду — попробуй иначе."
|
||||
}
|
||||
vars := map[string]string{"name": name}
|
||||
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
|
||||
if err != nil {
|
||||
log.Printf("voice: propose tool %q: %v", name, err)
|
||||
return phraser.C(phraser.ProposeFailed, vars)
|
||||
return "команды «" + name + "» нет в списке разрешённых."
|
||||
}
|
||||
if newly {
|
||||
return phraser.C(phraser.ProposeNew, vars)
|
||||
return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент."
|
||||
}
|
||||
return phraser.C(phraser.ProposeAlready, vars)
|
||||
return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент."
|
||||
}
|
||||
|
||||
// confirmVerdict — the parse of a y/n confirm answer.
|
||||
@@ -214,92 +192,23 @@ const (
|
||||
confirmNo
|
||||
)
|
||||
|
||||
// confirmWords are the two closed sets, tokenized once and ordered
|
||||
// longest-first so "не надо" is read before "нет" could claim any of it.
|
||||
var (
|
||||
confirmYesPhrases = confirmPhrases(lexicon.ConfirmYes())
|
||||
confirmNoPhrases = confirmPhrases(lexicon.ConfirmNo())
|
||||
)
|
||||
|
||||
// confirmPhrases splits each lexicon member into tokens and sorts the result
|
||||
// longest-first, so a walk that tries them in order matches the longest member
|
||||
// that fits.
|
||||
func confirmPhrases(words []string) [][]string {
|
||||
out := make([][]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
if toks := confirmTokens(w); len(toks) > 0 {
|
||||
out = append(out, toks)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
// confirmTokens splits an utterance into lowercase word tokens. Punctuation and
|
||||
// spacing are separators; an apostrophe is not, because "don't" is one word.
|
||||
func confirmTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
if r == '\'' || r == '’' {
|
||||
return false
|
||||
}
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// classifyConfirm reads a short ru/en yes-or-no answer to a parked confirm.
|
||||
//
|
||||
// The whole utterance must consist of confirmation words and filler, matched as
|
||||
// whole tokens against the closed lexicon sets. Anything else is
|
||||
// confirmUnknown, which leaves the confirm parked and routes the turn — see
|
||||
// resolveConfirm. Both halves of that are the fix for V-567: this used to be a
|
||||
// substring test over bare stems, so "погода", "дальше", "надо" and "давление"
|
||||
// all read as "да", and "покажи" and "около" read as "ок". A parked destructive
|
||||
// act fired on a question about the weather.
|
||||
//
|
||||
// Requiring the WHOLE utterance is the second half. A leading confirm word does
|
||||
// not make a sentence an answer: "давай посмотрим погоду" opens a request, and
|
||||
// the only safe reading of a sentence that carries its own subject is that he
|
||||
// moved on. Guessing wrong here executes something; guessing wrong the other way
|
||||
// asks again.
|
||||
// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the
|
||||
// stems so inflections/fillers ("да, давай", "нет, отмени") still land.
|
||||
func classifyConfirm(text string) confirmVerdict {
|
||||
tokens := confirmTokens(text)
|
||||
if len(tokens) == 0 {
|
||||
return confirmUnknown
|
||||
}
|
||||
verdict := confirmUnknown
|
||||
for i := 0; i < len(tokens); {
|
||||
// Negatives first: "не надо" and "не хочу" open with a token that is
|
||||
// not itself an answer, and a yes hit must never shadow them.
|
||||
if n := matchConfirm(confirmNoPhrases, tokens[i:]); n > 0 {
|
||||
t := strings.ToLower(strings.TrimSpace(text))
|
||||
// negatives first — "не надо" contains no "да", but check no-stems before
|
||||
// yes so a leading "нет" isn't shadowed.
|
||||
for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} {
|
||||
if strings.Contains(t, no) {
|
||||
return confirmNo
|
||||
}
|
||||
if n := matchConfirm(confirmYesPhrases, tokens[i:]); n > 0 {
|
||||
verdict, i = confirmYes, i+n
|
||||
continue
|
||||
}
|
||||
if lexicon.IsFillerParticle(tokens[i]) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// A word that is neither an answer nor filler carries a subject of its
|
||||
// own, so this utterance is not an answer to her question.
|
||||
return confirmUnknown
|
||||
}
|
||||
return verdict
|
||||
}
|
||||
|
||||
// matchConfirm reports the length of the longest phrase matching at the head of
|
||||
// tokens, or 0.
|
||||
func matchConfirm(phrases [][]string, tokens []string) int {
|
||||
for _, p := range phrases {
|
||||
if len(p) > len(tokens) {
|
||||
continue
|
||||
}
|
||||
if slices.Equal(p, tokens[:len(p)]) {
|
||||
return len(p)
|
||||
for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} {
|
||||
if strings.Contains(t, yes) {
|
||||
return confirmYes
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return confirmUnknown
|
||||
}
|
||||
|
||||
// actPhrase renders "fn arg1 arg2" for the confirm prompt.
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// TestClassifyConfirmRejectsSubstrings — V-567. The old matcher tested bare
|
||||
// stems with strings.Contains, so every word below answered a question she had
|
||||
// asked about something else: "погода", "дальше", "надо" and "давление" carry
|
||||
// "да"; "покажи", "около" and "окно" carry "ок". A parked destructive act fired
|
||||
// on a question about the weather.
|
||||
func TestClassifyConfirmRejectsSubstrings(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"погода",
|
||||
"какая погода",
|
||||
"что дальше",
|
||||
"надо ещё",
|
||||
"покажи заметки",
|
||||
"около окна",
|
||||
"давление",
|
||||
"давай посмотрим погоду",
|
||||
"не забудь купить хлеб",
|
||||
"окно открыто",
|
||||
"стоит ли брать зонт",
|
||||
"",
|
||||
} {
|
||||
if got := classifyConfirm(text); got != confirmUnknown {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmUnknown", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyConfirmAcceptsAnswers keeps every genuine answer the substring
|
||||
// matcher accepted, and pins the pair the fix could most easily get wrong:
|
||||
// "надо" is not an answer and "не надо" is the opposite of one.
|
||||
func TestClassifyConfirmAcceptsAnswers(t *testing.T) {
|
||||
yes := []string{"да", "Да!", "ага", "давай", "да, давай", "конечно", "подтверждаю", "ну да", "yes", "yeah", "ok", "okay", "confirm"}
|
||||
no := []string{"нет", "Нет.", "не надо", "не нужно", "не сейчас", "отмена", "отмени", "стоп", "нет, отмени", "no", "nope", "cancel", "stop", "don't"}
|
||||
|
||||
for _, text := range yes {
|
||||
if got := classifyConfirm(text); got != confirmYes {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmYes", text, got)
|
||||
}
|
||||
}
|
||||
for _, text := range no {
|
||||
if got := classifyConfirm(text); got != confirmNo {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmNo", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnrelatedTurnLeavesConfirmParked — the whole point of V-567. An utterance
|
||||
// that is not an answer must not execute the parked act, must not consume the
|
||||
// turn, and must not disarm the confirm either: the answer he has not given yet
|
||||
// is still answerable until it expires.
|
||||
func TestUnrelatedTurnLeavesConfirmParked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
dataStore: st,
|
||||
now: func() time.Time { return now },
|
||||
tools: tool.NewExecutor(api, time.Second),
|
||||
}
|
||||
|
||||
marker := filepath.Join(t.TempDir(), "destructive-tool-ran")
|
||||
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.park("delete_backups", nil, "delete_backups")
|
||||
|
||||
if reply, handled := h.resolveConfirm(ctx, "какая погода"); handled {
|
||||
t.Fatalf("the weather question was consumed as a confirm: %q", reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("the parked destructive command ran on an unrelated turn: %v", err)
|
||||
}
|
||||
if h.pending == nil {
|
||||
t.Fatal("the confirm was disarmed by a turn that did not answer it")
|
||||
}
|
||||
|
||||
// It is still answerable, and answering it still runs the act.
|
||||
reply, handled := h.resolveConfirm(ctx, "да")
|
||||
if !handled || !strings.Contains(reply, "готово") {
|
||||
t.Fatalf("the still-parked confirm did not resolve: handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("confirmed destructive command did not run: %v", err)
|
||||
}
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm stayed parked after being answered")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -150,13 +149,12 @@ func TestRememberTurnRefreshesTheTopic(t *testing.T) {
|
||||
now: func() time.Time { return contNow },
|
||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||
}
|
||||
ctx := context.Background()
|
||||
h.rememberTurn(ctx, nil, router.Decision{
|
||||
h.rememberTurn(nil, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||||
}, contNow)
|
||||
// The second turn arrives with the first turn's Text already merged in.
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, contNow)
|
||||
h.rememberTurn(ctx, prev, router.Decision{
|
||||
h.rememberTurn(prev, router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня планы",
|
||||
Slots: router.Slots{Text: "во сколько у меня встреча"},
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestRememberTurnKeepsIntentIndependentTranscriptInSpeakingOrder(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 1, 0, 0, 0, time.UTC)
|
||||
h := &reactiveHandler{
|
||||
now: func() time.Time { return now },
|
||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||
}
|
||||
ctx := context.Background()
|
||||
turns := []router.Decision{
|
||||
{Intent: router.IntentFact, Utterance: "я купил новый монитор", Slots: router.Slots{Key: "purchase", Value: "новый монитор", HasKey: true}},
|
||||
{Intent: router.IntentQuery, Utterance: "а он большой?", Slots: router.Slots{Text: "normalized query"}},
|
||||
{Intent: router.IntentChat, Utterance: "кажется, я переплатил", Slots: router.Slots{Text: "normalized chat"}},
|
||||
{Intent: router.IntentQuery, Utterance: "стоит его вернуть?", Slots: router.Slots{Text: "normalized return query"}},
|
||||
}
|
||||
for i, dec := range turns {
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
h.rememberTurn(ctx, prev, dec, now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
|
||||
got := h.dialogueSessions.Get(voiceDialogueID, now.Add(4*time.Second))
|
||||
if got == nil {
|
||||
t.Fatal("no dialogue session")
|
||||
}
|
||||
if got.Utterance != turns[3].Utterance {
|
||||
t.Fatalf("current utterance = %q, want %q", got.Utterance, turns[3].Utterance)
|
||||
}
|
||||
want := []string{turns[0].Utterance, turns[1].Utterance, turns[2].Utterance}
|
||||
if len(got.History) != len(want) {
|
||||
t.Fatalf("history = %+v, want %d prior turns", got.History, len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got.History[i].Text != want[i] {
|
||||
t.Errorf("history[%d] = %q, want %q", i, got.History[i].Text, want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// actionChat runs after rememberTurn. It must receive only prior turns;
|
||||
// handing over the current turn here would duplicate the model's user input.
|
||||
history := h.chatHistory(ctx)
|
||||
if len(history) != len(want) {
|
||||
t.Fatalf("chat history = %+v, want exactly the prior turns", history)
|
||||
}
|
||||
for _, turn := range history {
|
||||
if turn.Text == got.Utterance {
|
||||
t.Fatalf("current utterance was duplicated into chat history: %+v", history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsTurnReadsLegacySlotText(t *testing.T) {
|
||||
legacy := &dialogue.Session{
|
||||
Intent: dialogue.IntentQuery,
|
||||
Slots: dialogue.Slots{Text: "старый сохранённый вопрос"},
|
||||
}
|
||||
if got := sessionAsTurn(legacy).Text; got != legacy.Slots.Text {
|
||||
t.Fatalf("legacy turn text = %q, want %q", got, legacy.Slots.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitConversationOpenerKeepsCrossIntentSessionAlive(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 1, 0, 0, 0, time.UTC)
|
||||
h := &reactiveHandler{
|
||||
now: func() time.Time { return now },
|
||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||
}
|
||||
ctx := context.Background()
|
||||
h.rememberTurn(ctx, nil, router.Decision{
|
||||
Intent: router.IntentFact, Utterance: "давай поболтаем: я купил новый монитор",
|
||||
Slots: router.Slots{Key: "purchase", Value: "новый монитор", HasKey: true},
|
||||
}, now)
|
||||
|
||||
later := now.Add(10 * time.Minute)
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, later)
|
||||
if prev == nil {
|
||||
t.Fatal("explicit conversation expired at the ordinary two-minute TTL")
|
||||
}
|
||||
if !prev.Conversational || prev.TTL != 15*time.Minute {
|
||||
t.Fatalf("conversation state = %+v, want conversational 15m session", prev)
|
||||
}
|
||||
got := followUpMerge(prev, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "а он большой?",
|
||||
}, later)
|
||||
if got.Intent != router.IntentChat {
|
||||
t.Fatalf("anaphoric follow-up intent = %s, want chat", got.Intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationOpenerDoesNotMatchAnotherDavaiCommand(t *testing.T) {
|
||||
if opensConversation("давай запишем новый монитор") {
|
||||
t.Fatal("an ordinary cooperative command opened a conversation")
|
||||
}
|
||||
for _, text := range []string{
|
||||
"давай поговорим: я купил монитор",
|
||||
"давайте пообщаемся",
|
||||
"let's talk: I bought a monitor",
|
||||
} {
|
||||
if !opensConversation(text) {
|
||||
t.Errorf("%q did not open a conversation", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,13 +173,6 @@ func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, erro
|
||||
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
|
||||
case errors.Is(err, webfetch.ErrStatus):
|
||||
// Carry the code across the seam. The crawler needs to tell a 5xx
|
||||
// from a 404 to decide what a failed robots.txt means, and it must
|
||||
// not learn that by reading this sentence.
|
||||
var se *webfetch.StatusError
|
||||
if errors.As(err, &se) {
|
||||
return nil, &crawl.StatusError{Code: se.Code}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||
}
|
||||
return nil, err
|
||||
|
||||
+11
-16
@@ -125,17 +125,13 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon where page reading was never turned on names the gap. He asked
|
||||
// about one page, nothing else on the box can read it, and the old behaviour
|
||||
// here was to answer as though the URL had not been said (Vikunja #479).
|
||||
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||
// A daemon where page reading was never turned on — the default — answers the
|
||||
// question the way it did before the capability existed. Claiming the turn to
|
||||
// report a configuration status is for something that exists and failed.
|
||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
||||
h := buildWebHandler(nil)
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||
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)
|
||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,22 +156,21 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// robots.txt is honoured on the answer path too, and she says the page is
|
||||
// closed instead of reporting a generic failure.
|
||||
// robots.txt is honoured on the answer path too, and she says so instead of
|
||||
// reporting a generic failure.
|
||||
func TestQueryWebObeysRobots(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
// She names the cause without reading a filename out loud.
|
||||
if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") {
|
||||
t.Errorf("reply = %q, want the closed-page answer with no filename", reply)
|
||||
if !strings.Contains(reply, "robots.txt") {
|
||||
t.Errorf("reply = %q, want the robots answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,12 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -113,88 +110,6 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
|
||||
// instants on it carry the zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
|
||||
// the store's clock instead of his (V-614). The asking clock is three hours off
|
||||
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
|
||||
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: &planAPI{plan: ipc.DayPlan{
|
||||
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
|
||||
}},
|
||||
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
|
||||
}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
|
||||
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
|
||||
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
|
||||
// nothing, because the whole day is still ahead. She read 43 entries aloud as
|
||||
// one sentence. The zone is three hours off UTC so the test also fails under
|
||||
// TZ=UTC if the rendering ever slips zones.
|
||||
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
|
||||
zone := time.FixedZone("MSK", 3*60*60)
|
||||
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
|
||||
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
|
||||
for i := 0; i < 43; i++ {
|
||||
plan.Items = append(plan.Items, ipc.DayPlanItem{
|
||||
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
|
||||
Text: fmt.Sprintf("пункт %d", i),
|
||||
Kind: "event",
|
||||
})
|
||||
}
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
||||
}}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
|
||||
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
|
||||
t.Errorf("the next thing is not first: %q", reply)
|
||||
}
|
||||
// The rest is counted, not silently dropped.
|
||||
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
|
||||
t.Errorf("the sentence hides that the day goes on: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
|
||||
// plan word, so the plan source declines it and the calendar listing answers —
|
||||
// asserted here beside the cap so the two questions cannot drift together.
|
||||
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
|
||||
if router.IsDayPlanQuery("что у меня сегодня?") {
|
||||
t.Error("the plan source claims the whole-day question")
|
||||
}
|
||||
if !router.IsDayPlanQuery("что дальше?") {
|
||||
t.Error("the plan source stopped claiming the rest-of-day question")
|
||||
}
|
||||
if router.IsRestOfDayQuery("какие планы на сегодня?") {
|
||||
t.Error("the whole-day plan question got narrowed to the rest of the day")
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
@@ -232,8 +147,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||
}
|
||||
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
||||
t.Errorf("reply = %q, want the honest failure", reply)
|
||||
if reply != "не получилось собрать план." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// mavend/decisiontrace.go — the daemon's half of the per-turn decision record.
|
||||
//
|
||||
// V-564. The router says what the cascade did (internal/router/decisiontrace.go);
|
||||
// this file covers the two claimant sets that live in the daemon: the stateful
|
||||
// resolvers that run BEFORE routing and pre-empt it unconditionally, and the
|
||||
// query source chain that runs after. Those two are where the arbitration is
|
||||
// least visible, because both are a hardcoded order of functions that each
|
||||
// answer "is this mine?" alone and none of which answers "is this more mine
|
||||
// than yours?" (V-558).
|
||||
//
|
||||
// Recording changes no route. Every helper here is a no-op on a context with no
|
||||
// record, which is what every test that does not ask for one gets.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// preRouteLadder — the resolvers runTurn offers the utterance to before the
|
||||
// router sees it, in the order they get their say. Kept here as a roster rather
|
||||
// than derived from the code, so a resolver that returns early and skips the
|
||||
// rest still leaves the rest NAMED in the record: a claimant that never looked
|
||||
// and one that looked and passed are the distinction the ordering hides, and
|
||||
// they are the difference between a bug in the ladder and a bug in a resolver.
|
||||
//
|
||||
// Adding a step to runTurn means adding its name here. Nothing enforces that,
|
||||
// and nothing should: a missing name costs one line of the record, while a
|
||||
// check that walks the ladder would have to run the ladder.
|
||||
var preRouteLadder = []string{
|
||||
"confirm", "repair", "repair-negative", "command-prohibition", "clarify-answer", "quiet-toggle",
|
||||
"snooze", "ack", "reminder-cancel", "ordinal",
|
||||
}
|
||||
|
||||
// notePreRoute records one rung of that ladder and passes its verdict through
|
||||
// unchanged, so the call site stays the single `if handled` it already was.
|
||||
func notePreRoute(ctx context.Context, name string, handled bool) bool {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return handled
|
||||
}
|
||||
if handled {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Won,
|
||||
Reason: "it pre-empted routing, so the router never saw this turn",
|
||||
})
|
||||
return handled
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Declined,
|
||||
Reason: "nothing of its own was pending",
|
||||
})
|
||||
return handled
|
||||
}
|
||||
|
||||
// noteTerminal records whoever actually produced the reply, but only if the
|
||||
// turn is still unclaimed. A route decides the intent; it does not answer, and
|
||||
// on a thinned route or a plain act nothing downstream keeps a scoreboard. So
|
||||
// the record would otherwise close with an empty winner, which reads as a lost
|
||||
// turn instead of an asked question.
|
||||
func noteTerminal(ctx context.Context, claimant string, intent router.Intent, reason string) {
|
||||
decision.From(ctx).NoteIfUnclaimed(decision.Claim{
|
||||
Stage: decision.StageAction, Claimant: claimant,
|
||||
Intent: string(intent), Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// noteMerge records the follow-up merge, which is the one claimant that edits
|
||||
// the winning decision instead of taking the turn from it. It is compared on
|
||||
// the four slots the merge can fill, because a Decision holds a slice and is
|
||||
// not comparable.
|
||||
func noteMerge(ctx context.Context, before, after router.Decision) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
changed := before.Slots.HasTime != after.Slots.HasTime ||
|
||||
before.Slots.HasKey != after.Slots.HasKey ||
|
||||
before.Slots.HasFn != after.Slots.HasFn ||
|
||||
before.Slots.Text != after.Slots.Text ||
|
||||
before.Intent != after.Intent
|
||||
if !changed {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageMerge, Claimant: "follow-up-merge", Outcome: decision.Declined,
|
||||
Reason: "no slot of this turn was left for a previous one to fill",
|
||||
})
|
||||
return
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageMerge, Claimant: "follow-up-merge", Intent: string(after.Intent),
|
||||
Outcome: decision.Merged, Reason: "filled this turn's gaps from the previous turn",
|
||||
})
|
||||
}
|
||||
|
||||
// turnDecisionsFn — the reader mavweb gets, or nil when voice was never wired.
|
||||
// Same shape as intakeEventsFn: the daemon holds the ring, the IPC layer only
|
||||
// converts it.
|
||||
func turnDecisionsFn(w *voiceWiring) func(int) []ipc.TurnDecision {
|
||||
if w == nil || w.handler == nil || w.handler.decisions == nil {
|
||||
return nil
|
||||
}
|
||||
ring := w.handler.decisions
|
||||
return func(n int) []ipc.TurnDecision {
|
||||
recs := ring.Recent(n)
|
||||
out := make([]ipc.TurnDecision, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
claims := make([]ipc.TurnClaim, 0, len(rec.Claims))
|
||||
for _, c := range rec.Claims {
|
||||
claims = append(claims, ipc.TurnClaim{
|
||||
Stage: c.Stage, Claimant: c.Claimant, Intent: c.Intent,
|
||||
Score: c.Score, HasScore: c.HasScore,
|
||||
Outcome: c.Outcome, Reason: c.Reason,
|
||||
})
|
||||
}
|
||||
out = append(out, ipc.TurnDecision{
|
||||
Ts: rec.Ts, Utterance: rec.Utterance, Winner: rec.Winner, Claims: claims,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// querySourceNames — the query chain's roster, in chain order.
|
||||
func querySourceNames() []string {
|
||||
names := make([]string, len(querySources))
|
||||
for i, src := range querySources {
|
||||
names[i] = src.name
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"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"
|
||||
)
|
||||
|
||||
// traceHandler — a handler with the decision ring wired, the same shape the
|
||||
// daemon builds in wireVoice.
|
||||
func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
return &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
decisions: ring,
|
||||
}
|
||||
}
|
||||
|
||||
// findClaim — the first claim for a claimant, or nil.
|
||||
func findClaim(rec *decision.Record, claimant string) *decision.Claim {
|
||||
for i := range rec.Claims {
|
||||
if rec.Claims[i].Claimant == claimant {
|
||||
return &rec.Claims[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestTurnRecordNamesWinnerAndLosers — the point of V-564. A turn a stage-0
|
||||
// grammar claims must leave a record naming that grammar as the winner, naming
|
||||
// a pre-route resolver that declined, and naming the routing engines that were
|
||||
// never reached at all. The last of those is the fact the hardcoded ordering
|
||||
// hides: "classifier" absent from the record and "classifier" never asked read
|
||||
// the same to a human, and only one of them is the truth.
|
||||
func TestTurnRecordNamesWinnerAndLosers(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
|
||||
reply := h.handleText(context.Background(), "web", "сколько сейчас времени")
|
||||
if reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
|
||||
recs := ring.Recent(5)
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("want 1 record, got %d", len(recs))
|
||||
}
|
||||
rec := recs[0]
|
||||
if rec.Utterance != "сколько сейчас времени" {
|
||||
t.Errorf("utterance = %q", rec.Utterance)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Winner, "stage0:") {
|
||||
t.Errorf("want a stage-0 grammar as the winner, got %q", rec.Winner)
|
||||
}
|
||||
|
||||
// A loser that examined the turn: the confirm resolver ran first and had
|
||||
// nothing pending.
|
||||
confirm := findClaim(rec, "confirm")
|
||||
if confirm == nil || confirm.Outcome != decision.Declined {
|
||||
t.Errorf("confirm claim = %+v, want a decline", confirm)
|
||||
}
|
||||
|
||||
// A loser that never looked: stage 0 answered, so neither routing engine
|
||||
// was reached.
|
||||
for _, name := range []string{"llm-router", "classifier"} {
|
||||
c := findClaim(rec, name)
|
||||
if c != nil && c.Outcome == decision.Won {
|
||||
t.Errorf("%s cannot have won a stage-0 turn: %+v", name, c)
|
||||
}
|
||||
}
|
||||
|
||||
// And every rung of the ladder below the winner is named, not omitted.
|
||||
for _, name := range preRouteLadder {
|
||||
if findClaim(rec, name) == nil {
|
||||
t.Errorf("ladder rung %q is missing from the record", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordingDoesNotChangeTheReply — instrumentation, so a turn with the ring
|
||||
// wired and the same turn without it must answer identically. If this ever
|
||||
// fails, a claim site is doing more than noting.
|
||||
func TestRecordingDoesNotChangeTheReply(t *testing.T) {
|
||||
for _, utt := range []string{
|
||||
"сколько сейчас времени",
|
||||
"запиши что я пил воду",
|
||||
"что у меня сегодня",
|
||||
} {
|
||||
withRing := traceHandler(t, decision.NewRing()).handleText(context.Background(), "web", utt)
|
||||
without := traceHandler(t, nil).handleText(context.Background(), "web", utt)
|
||||
if withRing != without {
|
||||
t.Errorf("%q: recorded reply %q != unrecorded %q", utt, withRing, without)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryChainRecordsWhoWasNeverAsked — a query source below the claimant is
|
||||
// never consulted, and the record must say so rather than leave it out. This is
|
||||
// the arm that would have explained the Rome misroute in one read.
|
||||
func TestQueryChainRecordsWhoWasNeverAsked(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.handleText(context.Background(), "web", "что у меня сегодня")
|
||||
|
||||
rec := ring.Recent(1)[0]
|
||||
var asked, never int
|
||||
for _, c := range rec.Claims {
|
||||
if c.Stage != decision.StageQuery {
|
||||
continue
|
||||
}
|
||||
if c.Outcome == decision.NeverAsked {
|
||||
never++
|
||||
} else {
|
||||
asked++
|
||||
}
|
||||
}
|
||||
if asked == 0 {
|
||||
t.Fatal("no query source reported at all")
|
||||
}
|
||||
if never == 0 {
|
||||
t.Fatal("no query source was recorded as never asked; the chain cannot have run to the end")
|
||||
}
|
||||
if got := len(querySourceNames()); asked+never != got {
|
||||
t.Errorf("record covers %d of %d query sources", asked+never, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnDecisionsFnConvertsTheRing — the IPC read path. Nil when voice was
|
||||
// never wired, because a box with no turns is an empty page and not an error.
|
||||
func TestTurnDecisionsFnConvertsTheRing(t *testing.T) {
|
||||
if fn := turnDecisionsFn(nil); fn != nil {
|
||||
t.Error("no wiring should mean no reader")
|
||||
}
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.handleText(context.Background(), "web", "сколько сейчас времени")
|
||||
|
||||
fn := turnDecisionsFn(&voiceWiring{handler: h})
|
||||
if fn == nil {
|
||||
t.Fatal("wired handler produced no reader")
|
||||
}
|
||||
out := fn(10)
|
||||
if len(out) != 1 || out[0].Winner == "" || len(out[0].Claims) == 0 {
|
||||
t.Fatalf("conversion lost the record: %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -1,799 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Dialogue contract tests (V-563, child of V-558).
|
||||
//
|
||||
// Every other clarify test is single-shot: one ask, one answer, one assertion.
|
||||
// Three bugs of the same family shipped in two days that way — V-554 (a parked
|
||||
// question ate the three turns after it), V-557 (a confidently routed but
|
||||
// incomplete reminder parked nothing, so the answer was web-searched) and the
|
||||
// Rome case in V-558 (a side question was eaten as the time answer). None of
|
||||
// them is visible in one turn. The dialogue path is a state machine, so it can
|
||||
// be enumerated instead: whole traces, each with a per-turn expectation and an
|
||||
// expected END state — what was written to the store, and what is still parked.
|
||||
//
|
||||
// Two rules for the rows below.
|
||||
//
|
||||
// Where today's behaviour is correct, it is asserted. Where it is WRONG, the row
|
||||
// carries the CORRECT expectation and is skipped with the Vikunja id that will
|
||||
// unskip it. A weakened expectation would be worse than no row: it would pin the
|
||||
// bug as the contract.
|
||||
//
|
||||
// Everything runs on the offline floor — hash embedder, no llama-server, no
|
||||
// ONNX, StubDateTimeParser. That has one consequence worth knowing before
|
||||
// reading a fire time here: the stub reads "в 11:00" and "через час" and does
|
||||
// not read "на 9" or "на завтра", so a trace that needs those is noted where it
|
||||
// sits.
|
||||
|
||||
// claim — which claimant consumed an utterance. Not asserted: it is derived from
|
||||
// the log lines the daemon already emits and printed on every failure, because
|
||||
// "the reply differed" does not distinguish a wrong claimant from wrong copy,
|
||||
// and that distinction is the whole point of V-558.
|
||||
type claim struct {
|
||||
utterance string
|
||||
steps []string
|
||||
}
|
||||
|
||||
func (c claim) String() string { return c.utterance + " ⇒ " + strings.Join(c.steps, " → ") }
|
||||
|
||||
// claimMarkers — log fragment to claimant name, in the order runTurn checks
|
||||
// them. The fragments are the daemon's own words (clarify.go, repair.go,
|
||||
// voice.go); a rename there shows up here as an "unclaimed" step rather than a
|
||||
// silent mislabel.
|
||||
var claimMarkers = []struct{ fragment, name string }{
|
||||
{"parked question expired", "clarify:expired"},
|
||||
{"is its own request", "clarify:stepped-aside"},
|
||||
{"gave up on", "clarify:gave-up"},
|
||||
{"did not fill", "clarify:re-ask"},
|
||||
{"one gap filled", "clarify:ask-second-gap"},
|
||||
{"asked about", "clarify:ask"},
|
||||
{"repair —", "repair"},
|
||||
{"route result: intent=", "route"},
|
||||
}
|
||||
|
||||
// claimsOf reads the turn's log output and names the claimants that touched it.
|
||||
func claimsOf(utterance, logged string) claim {
|
||||
c := claim{utterance: utterance}
|
||||
for _, line := range strings.Split(logged, "\n") {
|
||||
for _, m := range claimMarkers {
|
||||
if strings.Contains(line, m.fragment) {
|
||||
name := m.name
|
||||
if m.name == "route" {
|
||||
name = "route:" + intentInLine(line)
|
||||
}
|
||||
c.steps = append(c.steps, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.steps) == 0 {
|
||||
c.steps = []string{"unclaimed"}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func intentInLine(line string) string {
|
||||
_, rest, ok := strings.Cut(line, "intent=")
|
||||
if !ok {
|
||||
return "?"
|
||||
}
|
||||
intent, _, _ := strings.Cut(rest, " ")
|
||||
return intent
|
||||
}
|
||||
|
||||
// parkedWant — the question that must be armed after a turn. Attempt matters:
|
||||
// a claimant that spends a retry on an utterance that was never an answer is
|
||||
// exactly the V-554 shape, and the count is the only place it shows.
|
||||
type parkedWant struct {
|
||||
slot dialogue.Slot
|
||||
attempt int
|
||||
// carries — a substring the parked utterance must still hold, so a re-park
|
||||
// that lost the answered subject fails here rather than three turns later.
|
||||
carries string
|
||||
}
|
||||
|
||||
// turn — one utterance and everything that must be true right after it.
|
||||
type turn struct {
|
||||
say string
|
||||
// wait — the clock moves this far BEFORE the utterance. The only way to
|
||||
// reach the TTL without sleeping.
|
||||
wait time.Duration
|
||||
// question — the reply must be exactly this clarify question, worded for
|
||||
// this attempt. Zero slot ⇒ not checked.
|
||||
question dialogue.Slot
|
||||
attempt int
|
||||
// gap — which part of the time she is asking about, for a SlotTime question
|
||||
// (V-579). Zero value is the missing hour, which is what she asks first.
|
||||
gap whenGap
|
||||
// took — the words of the PREVIOUS turn that this ask must acknowledge
|
||||
// before asking again (V-593). Empty ⇒ the ask carries no acknowledgement,
|
||||
// which is right for a first ask and for an answer that moved nothing.
|
||||
took string
|
||||
// differs — this reply must not be byte-identical to the one before it. Set
|
||||
// on a re-ask whose turn moved the request forward (V-593).
|
||||
differs bool
|
||||
contains []string
|
||||
notContain []string
|
||||
// noQuestion — the reply must not be any clarify question. Used where the
|
||||
// correct behaviour is known but her wording for it is not written yet: a
|
||||
// cancel must not be answered with another question, whatever it does say.
|
||||
noQuestion bool
|
||||
expired bool // the reply must open with the TTL notice
|
||||
// parked — what is armed after the turn. nil ⇒ nothing may be armed.
|
||||
parked *parkedWant
|
||||
}
|
||||
|
||||
// endState — what the store holds once the trace is over. Counts and
|
||||
// substrings, not rows: a trace is about who claimed what, and a payload
|
||||
// substring is enough to catch a request landing under the wrong words.
|
||||
type endState struct {
|
||||
reminders []reminderWant
|
||||
factKeys []string
|
||||
notes int
|
||||
tasks []string
|
||||
}
|
||||
|
||||
type reminderWant struct {
|
||||
payload string // substring of the stored payload
|
||||
fireAt string // "2006-01-02 15:04" in UTC, "" ⇒ not checked
|
||||
}
|
||||
|
||||
// trace — a named conversation, its turns, and the end state.
|
||||
type trace struct {
|
||||
name string
|
||||
skip string // non-empty ⇒ t.Skip: today's behaviour is wrong, this names the fix
|
||||
turns []turn
|
||||
end endState
|
||||
}
|
||||
|
||||
// newDialogueHandler — the offline floor with the real cascade and a movable
|
||||
// clock: newClarifyHandler's wiring (stub date parser, real fact parser, tool
|
||||
// matcher) plus the router newRoutingClarifyHandler builds, and the `now`
|
||||
// pointer so a turn can carry a wait.
|
||||
func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
|
||||
t.Helper()
|
||||
h, st, now := newClarifyHandler(t)
|
||||
// A minute no trace ever says, so "fires at the current clock" is a defect
|
||||
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
|
||||
// landing on it, and at 09:00 the row that answers "на 9" would trip that.
|
||||
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
|
||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
|
||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||
return h, st, now
|
||||
}
|
||||
|
||||
// wantedQuestion builds the question a turn must be answered with, from the
|
||||
// same code the daemon asks through. A time question is built from the gap,
|
||||
// because she names the clock and asks about the part he left out (V-579).
|
||||
func wantedQuestion(tn turn, now time.Time) (string, bool) {
|
||||
if tn.question == dialogue.SlotTime {
|
||||
gap := tn.gap
|
||||
if gap == whenComplete {
|
||||
gap = whenNoHour
|
||||
}
|
||||
return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took))
|
||||
}
|
||||
return clarifyQuestionFor(tn.question, tn.attempt)
|
||||
}
|
||||
|
||||
// runTrace drives one trace through handleText and checks every turn, then the
|
||||
// end state. Every failure carries the decision trace so far, so a wrong
|
||||
// claimant reads differently from wrong copy.
|
||||
func runTrace(t *testing.T, tr trace) {
|
||||
t.Helper()
|
||||
// MAVEN_DIALOGUE_NO_SKIP=1 runs the rows that fail today. That is how
|
||||
// whoever lands V-560, V-561 or V-562 sees their row go green before
|
||||
// deleting its skip, and it is also the check that a skip is still earned:
|
||||
// a row that passes with the skip in place is a fix nobody noticed.
|
||||
if tr.skip != "" && os.Getenv("MAVEN_DIALOGUE_NO_SKIP") == "" {
|
||||
t.Skip(tr.skip)
|
||||
}
|
||||
ctx := context.Background()
|
||||
h, st, now := newDialogueHandler(t)
|
||||
const conversation = "web"
|
||||
id := dialogueIDFor(sourceText, conversation)
|
||||
|
||||
var claims []claim
|
||||
var previous string
|
||||
fail := func(turnIdx int, format string, args ...any) {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(claims))
|
||||
for _, c := range claims {
|
||||
lines = append(lines, " "+c.String())
|
||||
}
|
||||
t.Fatalf("turn %d: "+format+"\n who claimed what:\n%s",
|
||||
append([]any{turnIdx}, append(args, strings.Join(lines, "\n"))...)...)
|
||||
}
|
||||
|
||||
for i, tn := range tr.turns {
|
||||
if tn.wait > 0 {
|
||||
*now = now.Add(tn.wait)
|
||||
}
|
||||
var logged bytes.Buffer
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&logged)
|
||||
reply := h.handleText(ctx, conversation, tn.say)
|
||||
log.SetOutput(prev)
|
||||
claims = append(claims, claimsOf(tn.say, logged.String()))
|
||||
|
||||
body := reply
|
||||
if tn.expired {
|
||||
if !isClarifyExpired(reply) {
|
||||
fail(i, "reply %q must open with the expiry notice", reply)
|
||||
}
|
||||
body = trimClarifyExpired(reply)
|
||||
// The notice is glued in front of this turn's reply, and both halves
|
||||
// have to survive: the words he just said are routed fresh, and
|
||||
// answering only "I let the old one go" drops them.
|
||||
if body == "" {
|
||||
fail(i, "the notice was the whole reply; the fresh words were never answered")
|
||||
}
|
||||
} else if isClarifyExpired(reply) {
|
||||
fail(i, "reply %q announced an expiry nothing asked for", reply)
|
||||
}
|
||||
if tn.question != "" {
|
||||
want, ok := wantedQuestion(tn, h.now())
|
||||
if !ok {
|
||||
fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt)
|
||||
}
|
||||
if body != want {
|
||||
fail(i, "reply %q, want the %s question worded for attempt %d, %q", body, tn.question, tn.attempt, want)
|
||||
}
|
||||
}
|
||||
if tn.noQuestion && isAnyClarifyQuestion(body) {
|
||||
fail(i, "reply %q is another question; this turn is not something to ask about", body)
|
||||
}
|
||||
for _, want := range tn.contains {
|
||||
if !strings.Contains(body, want) {
|
||||
fail(i, "reply %q does not carry %q", body, want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tn.notContain {
|
||||
if strings.Contains(body, unwanted) {
|
||||
fail(i, "reply %q carries %q and must not", body, unwanted)
|
||||
}
|
||||
}
|
||||
if tn.differs && reply == previous {
|
||||
fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply)
|
||||
}
|
||||
previous = reply
|
||||
checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
|
||||
}
|
||||
checkEnd(t, ctx, st, h, tr.end, claims)
|
||||
}
|
||||
|
||||
// isAnyClarifyQuestion — is this reply one of her clarify questions, at any
|
||||
// attempt wording? Reads the templates rather than a list of its own.
|
||||
func isAnyClarifyQuestion(reply string) bool {
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
for _, v := range variants {
|
||||
// HasSuffix, not equality: a question about the time opens with the
|
||||
// clock she is reasoning from (V-579).
|
||||
if strings.HasSuffix(reply, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// The two questions with no deck behind them, asked when the hour is said
|
||||
// and its half of the day or its day is not.
|
||||
return strings.HasSuffix(reply, "утра или вечера?") || strings.HasSuffix(reply, "В какой день?")
|
||||
}
|
||||
|
||||
func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) {
|
||||
t.Helper()
|
||||
if want == nil {
|
||||
if got != nil {
|
||||
fail(i, "a question about %v is still armed and nothing should be: %+v", got.Missing, got.Slots)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
fail(i, "nothing is armed, want a question about %s (attempt %d)", want.slot, want.attempt)
|
||||
return
|
||||
}
|
||||
if len(got.Missing) != 1 || got.Missing[0] != want.slot {
|
||||
fail(i, "armed question is about %v, want %s", got.Missing, want.slot)
|
||||
}
|
||||
if got.Attempts != want.attempt {
|
||||
fail(i, "armed question is on attempt %d, want %d — a retry spent on something that was never an answer is the V-554 shape", got.Attempts, want.attempt)
|
||||
}
|
||||
if want.carries != "" && !strings.Contains(got.Utterance, want.carries) {
|
||||
fail(i, "the parked request no longer carries %q: %q", want.carries, got.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHandler, want endState, claims []claim) {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(claims))
|
||||
for _, c := range claims {
|
||||
lines = append(lines, " "+c.String())
|
||||
}
|
||||
trace := "\n who claimed what:\n" + strings.Join(lines, "\n")
|
||||
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(14*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("DueReminders: %v", err)
|
||||
}
|
||||
if len(reminders) != len(want.reminders) {
|
||||
t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace)
|
||||
}
|
||||
// No trace may leave a reminder at the current clock, whatever else it
|
||||
// asserts (V-577, V-579). Twice on the box a sentence naming a day and no
|
||||
// hour was completed from time.Now(): "что у меня сегодня?" became 01:28 and
|
||||
// "на завтра" became 01:38. Neither minute was ever spoken, and a row that
|
||||
// only checked the payload would have passed both.
|
||||
for _, r := range reminders {
|
||||
if r.FireTs.In(h.now().Location()).Format("15:04") == h.now().Format("15:04") {
|
||||
t.Fatalf("end state: reminder %q fires at %s, which is the clock — a time slot naming no hour is asked about, never filled from now()%s",
|
||||
r.Payload, r.FireTs.Format("15:04"), trace)
|
||||
}
|
||||
}
|
||||
for i, w := range want.reminders {
|
||||
if !strings.Contains(reminders[i].Payload, w.payload) {
|
||||
t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace)
|
||||
}
|
||||
if w.fireAt != "" {
|
||||
if got := reminders[i].FireTs.UTC().Format("2006-01-02 15:04"); got != w.fireAt {
|
||||
t.Fatalf("end state: reminder %d fires at %s, want %s%s", i, got, w.fireAt, trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
facts, err := st.RecentFacts(ctx, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentFacts: %v", err)
|
||||
}
|
||||
if len(facts) != len(want.factKeys) {
|
||||
t.Fatalf("end state: %d fact(s), want %d: %+v%s", len(facts), len(want.factKeys), facts, trace)
|
||||
}
|
||||
for i, key := range want.factKeys {
|
||||
if facts[i].Key != key {
|
||||
t.Fatalf("end state: fact %d is %q, want %q%s", i, facts[i].Key, key, trace)
|
||||
}
|
||||
}
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != want.notes {
|
||||
t.Fatalf("end state: %d note(s), want %d%s", len(notes), want.notes, trace)
|
||||
}
|
||||
|
||||
tasks, err := st.ListTasks(ctx, store.TaskOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasks: %v", err)
|
||||
}
|
||||
if len(tasks) != len(want.tasks) {
|
||||
t.Fatalf("end state: %d open task(s), want %d: %+v%s", len(tasks), len(want.tasks), tasks, trace)
|
||||
}
|
||||
for i, text := range want.tasks {
|
||||
if !strings.Contains(tasks[i].Text, text) {
|
||||
t.Fatalf("end state: task %d is %q, want it to carry %q%s", i, tasks[i].Text, text, trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogueTraces(t *testing.T) {
|
||||
for _, tr := range dialogueTraces() {
|
||||
tr := tr
|
||||
t.Run(tr.name, func(t *testing.T) { runTrace(t, tr) })
|
||||
}
|
||||
}
|
||||
|
||||
// dialogueTraces — the fixture. Order is the order the shapes were found, not a
|
||||
// dependency: each trace builds its own handler and store.
|
||||
func dialogueTraces() []trace {
|
||||
return []trace{
|
||||
// The plain two-turn shape, and the one every other row is a deviation
|
||||
// from: she asks for the time, he gives it, the reminder lands with the
|
||||
// subject he said in the FIRST turn.
|
||||
{
|
||||
// Three turns since V-579, not two. An hour with no day named is
|
||||
// not an answer she can act on: 11:00 today has passed as often as
|
||||
// not, and picking one for him is the invention the whole rule is
|
||||
// against. So she says the clock she is reasoning from and asks
|
||||
// which day.
|
||||
name: "reminder completed over three turns",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
// The same shape on the fact path, where the answer carries both halves
|
||||
// of what was missing — the key and the value — in one breath.
|
||||
{
|
||||
name: "fact completed over two turns",
|
||||
turns: []turn{
|
||||
{say: "запиши", question: dialogue.SlotKey, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
|
||||
// His words back, not the key the parser filed them under
|
||||
// (V-592). "water" is machine vocabulary and he never said it.
|
||||
{say: "пил воду", contains: []string{"пил воду"}},
|
||||
},
|
||||
end: endState{factKeys: []string{"water"}},
|
||||
},
|
||||
// An answer past the TTL is a new request, not an answer (V-385). She
|
||||
// says the old one is gone and routes the words fresh. A bare time on
|
||||
// its own carries no request, so the fresh routing lands on the canned
|
||||
// reply — the point of the row is that NOTHING is created: a reminder
|
||||
// here would fire with the subject of a request she had already let go.
|
||||
{
|
||||
name: "answer arrives after the TTL",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "в 11:00", wait: clarifyTTL + time.Second, expired: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// Three questions is the budget, and running out is SPOKEN: a mute
|
||||
// give-up reads as "done" and he would wait for a reminder that was
|
||||
// never set. The wording changes with the attempt (V-457).
|
||||
{
|
||||
name: "three unclear answers then the give-up line",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 2,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 3,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
{say: "ну не знаю", contains: []string{clarifyGaveUp}, noQuestion: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// A correction points at the previous ACTED turn (repair.go): she redoes
|
||||
// it under the intent he names and says so out loud, because a
|
||||
// correction he cannot see is indistinguishable from one that was
|
||||
// dropped. The task she filed first stays filed — repair redoes, it does
|
||||
// not retract, and V-455 decided that deliberately.
|
||||
//
|
||||
// The corrected-to intent has to differ from the one she used, or repair
|
||||
// declines: teaching the classifier the label it already produced is
|
||||
// worse than doing nothing.
|
||||
{
|
||||
name: "correction of the previous turn",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
// He walks away from his own request: a question is parked, the next
|
||||
// utterance is an unrelated request of its own, and nothing follows.
|
||||
// V-554's fix is what makes this row pass — the question steps aside
|
||||
// rather than scoring "добавь в задачи" as the time. The reminder is
|
||||
// dropped in silence and that is the decision: if he meant it he says it
|
||||
// again, and a question left armed eats the turn after next.
|
||||
{
|
||||
name: "abandoned flow: parked, then an unrelated request",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "спасибо"},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
|
||||
// V-577 shape 1, the worst of the nine claimants measured on 2026-08-06.
|
||||
// Every token of "что у меня сегодня?" is frame — an interrogative, a
|
||||
// preposition, a particle and a day word — so the role classifier never
|
||||
// looked at the route, the parked reminder read "сегодня" as its time,
|
||||
// and the hour came from the clock. He got a reminder he never asked for
|
||||
// at a minute he never said, and his question was answered nowhere.
|
||||
//
|
||||
// Two claims: the calendar answers, and nothing is written. The flow
|
||||
// survives underneath, because a question of his own is not a request to
|
||||
// abandon the one he was making.
|
||||
{
|
||||
name: "an agenda question mid-flow is answered, not eaten",
|
||||
turns: []turn{
|
||||
{say: "напомни забрать посылку", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
|
||||
{say: "что у меня сегодня?", contains: []string{"31.07.2026"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-577 shape 2. He states something in the middle of the flow. It is
|
||||
// neither a slot value nor a cancel, and it was scored as a failed
|
||||
// answer and dropped in silence: alone the same sentence is stored.
|
||||
// Silence is the one option that is wrong, so it is stored, no retry is
|
||||
// spent, and the question comes back on the end of the same reply.
|
||||
//
|
||||
// The words are a fact and not the owner's note, because the fact parser
|
||||
// is deterministic and the offline floor marks every classifier route
|
||||
// Clarify. The row below carries his own sentence and needs the model.
|
||||
//
|
||||
// What this floor can prove is the arbitration: no retry is spent, the
|
||||
// flow survives on the same attempt, and the words are answered as
|
||||
// themselves with the question coming back after them. Whether the fact
|
||||
// is then WRITTEN is the routing engine's business — the hash embedder
|
||||
// is unsure of every sentence it sees, and an unsure fact has never been
|
||||
// stored.
|
||||
{
|
||||
name: "a fact stated mid-flow steps aside without spending a retry",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
// Nothing she says about it may be a word he did not say
|
||||
// (V-592). On the box this sentence came back as "Проверила, что
|
||||
// ты выпел стакан воды": a non-word for the verb, a glass copied
|
||||
// out of the example in ReplySystemPrompt, and a claim to have
|
||||
// checked something. The store held key=water value="drank"
|
||||
// throughout, so all of it was generated from two tokens.
|
||||
//
|
||||
// The positive half of the contract — the confirmation IS his
|
||||
// sentence — is asserted by "fact completed over two turns"
|
||||
// above. It cannot be asserted here: the hash embedder marks
|
||||
// this route Clarify, and an unsure fact is answered with the
|
||||
// canned line rather than a confirmation of anything.
|
||||
{say: "я выпил воды", contains: []string{"напоминание?"},
|
||||
notContain: []string{"стакан", "выпел", "Проверила", "water"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-593: two asks about the same half of the day, with a turn between
|
||||
// them that answered the DAY. Asking again is right and asking in the
|
||||
// same bytes is not — from his side it is indistinguishable from not
|
||||
// having been heard, which is what the whole V-558 family is about.
|
||||
//
|
||||
// The clock still opens every ask (the owner's rule, V-579); the
|
||||
// acknowledgement goes after it and before the question.
|
||||
{
|
||||
name: "a re-ask names what the answer before it gave her",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
contains: []string{"Сейчас "},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра",
|
||||
contains: []string{"Сейчас ", "завтра"}, differs: true,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-579 turn 3: the preposition decided whether the hour was read. "в 9"
|
||||
// set the reminder and "на 9" was not read at all, on the same build and
|
||||
// with the same cardinal.
|
||||
{
|
||||
// It is read, and being read is not the same as being enough: nine is
|
||||
// either half of the day, so she asks which and then which day
|
||||
// (V-579). Both answers are frame words and neither carries an hour
|
||||
// of its own, so this row is also the proof that an answer is read
|
||||
// against the whole request rather than alone.
|
||||
name: "на 9 answers the time question like в 9",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
{say: "завтра", contains: []string{"09:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
|
||||
},
|
||||
// The owner's own four, ruled 2026-08-06 (V-579). A reminder commits
|
||||
// when what, what time and what day are all answered, and every ask
|
||||
// states the clock she is reasoning from.
|
||||
{
|
||||
name: "his first example: a bare 3 is asked about",
|
||||
turns: []turn{
|
||||
{say: "напомни завтра в 3 заказать цветы",
|
||||
question: dialogue.SlotTime, attempt: 1, gap: whenAmbiguousHour,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "цветы"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
{
|
||||
// The hour is unambiguous and the day is still missing, so she asks.
|
||||
// Today being a valid reading is not the same as him saying it.
|
||||
name: "his second example: nine in the evening of which day",
|
||||
turns: []turn{
|
||||
{say: "напомни в 9 вечера разгрузить стиралку",
|
||||
question: dialogue.SlotTime, attempt: 1, gap: whenNoDay,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "стиралку"}},
|
||||
{say: "завтра", contains: []string{"21:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "стиралку", fireAt: "2026-08-01 21:00"}}},
|
||||
},
|
||||
{
|
||||
// All three answered in one breath, so she does not ask at all.
|
||||
name: "his third example: a full time commits",
|
||||
turns: []turn{
|
||||
{say: "напомни завтра в 15:00 заказать цветы", notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "цветы", fireAt: "2026-08-01 15:00"}}},
|
||||
},
|
||||
{
|
||||
// An interval is one instant, so it answers the hour and the day
|
||||
// together. Confirmed by the owner: "через час is fine as is".
|
||||
name: "an interval commits without a question",
|
||||
turns: []turn{
|
||||
{say: "напомни через час позвонить маме", notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "маме", fireAt: "2026-07-31 10:17"}}},
|
||||
},
|
||||
// V-579 turn 4: he named a day and no hour, and got the day at the
|
||||
// current minute. She has to ask instead, and the global check in
|
||||
// checkEnd refuses the invented minute for every row at once.
|
||||
{
|
||||
name: "a day with no hour is asked about, not taken from the clock",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 2,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
|
||||
// ---- formerly failing interleavings; kept as permanent contracts ----
|
||||
|
||||
// The owner's own sentence from V-577 shape 2, in his words. It needs
|
||||
// an engine that can route it: the hash embedder marks it note with
|
||||
// Clarify set, and a route she is not sure of is not evidence that he
|
||||
// stated anything. The row above is the same contract in words the
|
||||
// floor's deterministic fact parser reads.
|
||||
{
|
||||
name: "a note stated mid-flow is stored, not dropped",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
{say: "у меня новый ноутбук",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{notes: 1},
|
||||
},
|
||||
|
||||
// The owner's target transcript, V-561. He asks for a reminder, she asks
|
||||
// when, he asks something else entirely, and then comes back to her
|
||||
// question. On the box this created a reminder at 00:12 and never
|
||||
// answered Rome; on the offline floor the side question is recognised as
|
||||
// its own request and the flow is dropped instead, so the wrong reminder
|
||||
// is not made and the right one is not either.
|
||||
//
|
||||
// Both are the same defect: there is no suspend and resume. The correct
|
||||
// shape is the middle turn answered on its own and the parked question
|
||||
// still standing, on the same attempt — a side query is not a failed
|
||||
// answer and must not spend a retry.
|
||||
//
|
||||
// The skip came off with V-579. What held it was the parser, not the
|
||||
// arbitration: neither the stub nor the production one read "на 9",
|
||||
// because only "в" framed a spoken hour, and "на завтра" was completed
|
||||
// from the clock.
|
||||
//
|
||||
// Turn 3 now closes the flow, where the transcript has one more exchange
|
||||
// in it. That is the 12-hour question — the owner's turn 4 answers "на
|
||||
// 9" with "сейчас 15:23, на 9 сегодня вечером?" — and it is a decision of
|
||||
// its own, not one to invent here. Nine o'clock is read as nine and, at
|
||||
// 09:17, as tomorrow's, which is where the transcript ends up anyway.
|
||||
// Turn 4 then has nothing to answer and must not write anything.
|
||||
{
|
||||
name: "the owner's transcript from V-561",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
// His words, unchanged. What changed under V-579 is that "на 9"
|
||||
// is a question and not a commit: nine could be either half of
|
||||
// the day, so she says the clock she is reading from and asks.
|
||||
// "на завтра." then answers the day and leaves the half open, so
|
||||
// she asks that one again.
|
||||
// Each ask names what the turn before it gave her (V-593). The
|
||||
// two asks about the half of the day are the same question and
|
||||
// must not be the same sentence: he answered between them, and a
|
||||
// reply with no trace of that reads as not having been heard.
|
||||
{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// The same shape said in words StubDateTimeParser reads. GREEN since
|
||||
// V-561. Same three claims: Rome is answered, the question survives the
|
||||
// side query on the same attempt, and the answer after it completes the
|
||||
// reminder he actually asked for.
|
||||
//
|
||||
// It sits under the "fail today" header because the row above it still
|
||||
// does. Do not re-skip it to tidy that up: this is the owner's
|
||||
// acceptance test in the only words the offline floor can read.
|
||||
{
|
||||
name: "nested question: a parked question, then one of his own",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
// A cancel is one of the five turn roles V-560 names, and today it is
|
||||
// none of them: "неважно" fills no slot and carries no request of its
|
||||
// own, so it reads as a failed answer and spends a retry. Two turns
|
||||
// later she is still asking about a reminder he called off.
|
||||
//
|
||||
// The row asserts what is knowable — nothing armed, nothing written, and
|
||||
// not another question — rather than her wording for it, which is not
|
||||
// written yet and is not this task's to invent.
|
||||
{
|
||||
name: "cancel: a parked question, then never mind",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "неважно", noQuestion: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// Order in runTurn is the whole arbitration (V-558), and this is what it
|
||||
// costs: the clarify answer is checked at step 3 and the repair marker at
|
||||
// step 4d, so while a question is parked no correction can be made. She
|
||||
// scores "нет, это была заметка" as a bad time answer and asks again.
|
||||
{
|
||||
name: "correction while a question is parked",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
// Stage 0 has extracted the hour since V-572. The day remains genuinely
|
||||
// absent, and V-579 deliberately refuses to invent it even when 11:00 is
|
||||
// still ahead on today's clock. This stale skipped row used to expect a
|
||||
// commit and contradicted every neighbouring time-contract row.
|
||||
{
|
||||
name: "a stage-zero reminder keeps its hour and asks for the missing day",
|
||||
turns: []turn{
|
||||
{say: "напомни в 11:00 позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
gap: whenNoDay, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// The same gap on the repair path. A correction redoes the request
|
||||
// through finishClarified, which goes straight to applyAction — it never
|
||||
// passes the clarify gate — so a redo that lands short answers with the
|
||||
// parse error V-557 removed from the routing path: "не поняла, на когда
|
||||
// напомнить." She should ask, exactly as she does for a fresh reminder
|
||||
// with no time.
|
||||
{
|
||||
name: "a correction that lands short asks rather than failing",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "нет, это было напоминание", contains: []string{"поняла, это напоминание"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The list she read at the mic is not the list a browser is looking at
|
||||
// (Vikunja #45 step 3). The clarify store was keyed per reach in #466; the
|
||||
// dialogue session was still one slot for the box, so "второй" typed on the web
|
||||
// closed the second task she had recited out loud.
|
||||
func TestCandidatesDoNotCrossReaches(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, voiceCtx, ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
if reply, handled := h.resolveCandidate(webCtx, "первую сделал", sourceText); handled {
|
||||
t.Fatalf("a web turn picked from the list she read aloud: %q", reply)
|
||||
}
|
||||
live, err := st.ListTasks(context.Background(), "live")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(live) != 2 {
|
||||
t.Fatalf("%d tasks live, want 2 — the web turn moved one", len(live))
|
||||
}
|
||||
// The reach that was offered the list still owns it.
|
||||
if _, handled := h.resolveCandidate(voiceCtx, "первую сделал", sourceVoice); !handled {
|
||||
t.Fatal("the mic lost its own list")
|
||||
}
|
||||
}
|
||||
|
||||
// A selection writes a fact, so the fact must name the reach the words arrived
|
||||
// on. It said "tap:voice" for a typed turn.
|
||||
func TestCandidateProvenanceFollowsTheReach(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ctx, ids, "купить хлеб")
|
||||
|
||||
if _, handled := h.resolveCandidate(ctx, "первую сделал", sourceText); !handled {
|
||||
t.Fatal("the pick was not acted on")
|
||||
}
|
||||
done, err := st.ListTasks(context.Background(), "done")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(done) != 1 {
|
||||
t.Fatalf("%d tasks done, want 1", len(done))
|
||||
}
|
||||
if by := done[0].ResolvedBy; by != string(sourceText) {
|
||||
t.Errorf("resolved_by = %q, want %q", by, sourceText)
|
||||
}
|
||||
}
|
||||
|
||||
// Anaphora is per reach too: an ellipsis typed on the web must not continue the
|
||||
// question he asked at the mic. Both surfaces stay usable at once, which is the
|
||||
// case a single-owner box actually hits — a phone open while he talks.
|
||||
func TestAnaphoraDoesNotCrossReaches(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
now := h.now()
|
||||
|
||||
h.rememberTurn(voiceCtx, nil, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||||
}, now)
|
||||
|
||||
if sess := h.dialogueSessions.Get(dialogueIDOf(webCtx), now); sess != nil {
|
||||
t.Fatalf("the web reach inherited the mic's turn: %+v", sess)
|
||||
}
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(voiceCtx), now)
|
||||
if sess == nil || !strings.Contains(sess.Slots.Text, "встреча") {
|
||||
t.Fatalf("the mic lost its own turn: %+v", sess)
|
||||
}
|
||||
}
|
||||
+2
-167
@@ -2,26 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
type nudgeCountingPhraser struct {
|
||||
phraser.Phraser
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *nudgeCountingPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
||||
p.calls++
|
||||
return p.Phraser.PhraseNudge(ctx, c)
|
||||
}
|
||||
|
||||
// Vikunja #281 — the fourth delivery outcome: a care candidate the restraint
|
||||
// gate suppresses (quiet hours / away / calendar-busy) is not necessarily
|
||||
// lost. If it's worth resurfacing (loop.DigestEligible), it's durably held
|
||||
@@ -51,7 +38,7 @@ func TestSuppressedCareDigestsAcrossQuietHours(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
|
||||
entries, err := st.PendingDigestEntries(ctx, now)
|
||||
@@ -101,11 +88,7 @@ func TestSuppressedCareDigestDedupesAcrossTicks(t *testing.T) {
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
quiet.Facts = breakCandidateFacts(now, 1)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
for i := 0; i < 3; i++ {
|
||||
quiet.Now = now.Add(time.Duration(i) * time.Minute)
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now.Add(time.Duration(i)*time.Minute))
|
||||
}
|
||||
|
||||
@@ -116,154 +99,6 @@ func TestSuppressedCareDigestDedupesAcrossTicks(t *testing.T) {
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("3 suppressions of the same nudge must collapse to 1 pending entry, got %d", len(entries))
|
||||
}
|
||||
if counting.calls != 1 {
|
||||
t.Fatalf("3 suppressed ticks phrased %d times, want exactly 1", counting.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressedCareDigestAcrossRealTicksDoesOnePhraseCall(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
markPresent(t, st, ctx, now)
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "break", "tap:test", "done", now.Add(-2*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", true, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
for i := 0; i < 3; i++ {
|
||||
tl.tick(ctx, now.Add(time.Duration(i)*30*time.Second))
|
||||
}
|
||||
|
||||
if counting.calls != 1 {
|
||||
t.Fatalf("3 complete suppressed ticks phrased %d times, want exactly 1", counting.calls)
|
||||
}
|
||||
entries, err := st.PendingDigestEntries(ctx, now.Add(time.Minute))
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("complete ticks should retain one durable entry: entries=%+v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuppressedCareDigestDedupeSurvivesRestart proves V-687 at its actual
|
||||
// boundary: a fresh tickLoop has no memory of the first call, yet durable
|
||||
// candidate identity still prevents a second PhraseNudge.
|
||||
func TestSuppressedCareDigestDedupeSurvivesRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "digest-restart.db")
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 9)}
|
||||
|
||||
firstStore, err := store.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := newTestTickLoop(t, firstStore, &fakeSink{}, nil)
|
||||
firstPhraser := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
first.phraser = firstPhraser
|
||||
first.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
if firstPhraser.calls != 1 {
|
||||
t.Fatalf("first loop phrase calls = %d, want 1", firstPhraser.calls)
|
||||
}
|
||||
if err := firstStore.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
secondStore, err := store.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = secondStore.Close() })
|
||||
second := newTestTickLoop(t, secondStore, &fakeSink{}, nil)
|
||||
secondPhraser := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
second.phraser = secondPhraser
|
||||
quiet.Now = now.Add(time.Minute)
|
||||
second.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, quiet.Now)
|
||||
if secondPhraser.calls != 0 {
|
||||
t.Fatalf("same candidate after restart phrased %d times, want 0", secondPhraser.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressedCareDigestRephrasesWhenMeaningChanges(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
quiet.Facts = breakCandidateFacts(now.Add(time.Minute), 2)
|
||||
quiet.Now = now.Add(time.Minute)
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, quiet.Now)
|
||||
|
||||
if counting.calls != 2 {
|
||||
t.Fatalf("two semantic occurrences phrased %d times, want 2", counting.calls)
|
||||
}
|
||||
entries, err := st.PendingDigestEntries(ctx, quiet.Now)
|
||||
if err != nil || len(entries) != 2 {
|
||||
t.Fatalf("changed meaning should create a second entry: entries=%+v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressedCareDigestRephrasesAfterExpiry(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
// Deliberately do not run the expiry sweep. The pre-phrase lookup and
|
||||
// enqueue path must agree that this occurrence is no longer live.
|
||||
afterExpiry := now.Add(digestExpiry + time.Minute)
|
||||
quiet.Now = afterExpiry
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, afterExpiry)
|
||||
|
||||
if counting.calls != 2 {
|
||||
t.Fatalf("expired occurrence phrased %d times total, want 2", counting.calls)
|
||||
}
|
||||
entries, err := st.PendingDigestEntries(ctx, afterExpiry)
|
||||
if err != nil || len(entries) != 1 || !entries[0].CreatedTs.Equal(afterExpiry) {
|
||||
t.Fatalf("expired row was not replaced by one fresh row: entries=%+v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressedCareDigestRephrasesAfterDrain(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
clearAt := now.Add(time.Minute)
|
||||
tl.maybeDrainDigest(ctx, loop.State{Now: clearAt, Presence: store.Present}, clearAt)
|
||||
quiet.Now = clearAt.Add(time.Minute)
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, quiet.Now)
|
||||
|
||||
if counting.calls != 2 {
|
||||
t.Fatalf("same occurrence after drain phrased %d times, want 2", counting.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func breakCandidateFacts(now time.Time, occurrenceID int64) map[string]store.Fact {
|
||||
return map[string]store.Fact{
|
||||
"break": {
|
||||
ID: occurrenceID, Ts: now.Add(-2 * time.Hour), Kind: store.KindSelf,
|
||||
Key: "break", Value: "done", Source: "tap:test", Confidence: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuppressedCareDigestExpiresRatherThanDeliveringLate — an entry that
|
||||
@@ -276,7 +111,7 @@ func TestSuppressedCareDigestExpiresRatherThanDeliveringLate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
|
||||
// well past digestExpiry (24h) before the suppression ever clears.
|
||||
|
||||
+107
-217
@@ -12,7 +12,6 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
@@ -43,100 +42,28 @@ const ecosystemAPIVersion = "v1"
|
||||
// anonymous HTTP client.
|
||||
const mavenRequester = "maven"
|
||||
|
||||
// ecosystemHTTP is the JSON transport every ecosystem client shares: one base
|
||||
// URL, one bearer token, and the header set the contract requires on each
|
||||
// request. Nexus and Praxis differ only in the service name and the version
|
||||
// header, so both embed this rather than repeating build, send and classify.
|
||||
type ecosystemHTTP struct {
|
||||
service string // "nexus", "praxis" — the name errors and traces carry
|
||||
versionHeader string
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newEcosystemHTTP(service, versionHeader, baseURL string) ecosystemHTTP {
|
||||
return ecosystemHTTP{
|
||||
service: service,
|
||||
versionHeader: versionHeader,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// setHeaders stamps the version, requester, auth and correlation headers common
|
||||
// to every outgoing ecosystem request. The token may be empty, which means the
|
||||
// transport itself is trusted (loopback or unix socket).
|
||||
// setEcosystemHeaders stamps the version, requester, auth and correlation
|
||||
// headers common to every outgoing ecosystem request. token may be empty,
|
||||
// which means the transport itself is trusted (loopback or unix socket).
|
||||
//
|
||||
// The correlation ID is read from the request's own context and never minted
|
||||
// here. Minting one per request sent the far side an ID that existed nowhere on
|
||||
// this side, and gave a single multi-hop action as many unrelated IDs as it
|
||||
// made calls. Callers that start an action assign the ID once (handleHexisAct,
|
||||
// The correlation ID is read from the context and never minted here. Minting
|
||||
// one per request sent the far side an ID that existed nowhere on this side,
|
||||
// and gave a single multi-hop action as many unrelated IDs as it made calls.
|
||||
// Callers that start an action assign the ID once (handleHexisAct,
|
||||
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
|
||||
func (t *ecosystemHTTP) setHeaders(req *http.Request) {
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(t.versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set(versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", mavenRequester)
|
||||
if t.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+t.token)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if id := correlationIDFromCtx(req.Context()); id != "" {
|
||||
if id := correlationIDFromCtx(ctx); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
}
|
||||
|
||||
// call sends one request and decodes the JSON answer into out, which may be nil
|
||||
// when the body carries nothing worth reading. op is the logical operation name
|
||||
// for errors and traces: the path carries the query string, and after entity
|
||||
// scoping that means an entity id in every log line built from the error, next
|
||||
// to a trace that redacts far less than that.
|
||||
//
|
||||
// Every failure is an *ecosystemError, including the transport and decode ones.
|
||||
// Some of these paths mutate remote state, and the question worth answering
|
||||
// afterwards is whether the call never left or was refused.
|
||||
func (t *ecosystemHTTP) call(ctx context.Context, method, op, path string, payload, out any) error {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, body)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
t.setHeaders(req)
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError(t.service, op, resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out.
|
||||
func (t *ecosystemHTTP) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
return t.call(ctx, http.MethodGet, op, path, nil, out)
|
||||
}
|
||||
|
||||
// postJSON posts a JSON payload and decodes the JSON answer into out.
|
||||
func (t *ecosystemHTTP) postJSON(ctx context.Context, op, path string, payload, out any) error {
|
||||
return t.call(ctx, http.MethodPost, op, path, payload, out)
|
||||
}
|
||||
|
||||
// ecosystemError is the typed failure every ecosystem client returns, so
|
||||
// callers can tell a transport failure from a refusal from a contract
|
||||
// mismatch without matching on message text. The distinction matters:
|
||||
@@ -180,60 +107,17 @@ func httpError(service, op string, status int) *ecosystemError {
|
||||
}
|
||||
}
|
||||
|
||||
// hexisStatusTexts maps the http.StatusText spelling back to its code, for the
|
||||
// failure statuses a Hexis call can plausibly answer with. It is the inverse of
|
||||
// what the vendored client threw away.
|
||||
var hexisStatusTexts = func() map[string]int {
|
||||
codes := []int{
|
||||
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
|
||||
http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotAcceptable,
|
||||
http.StatusRequestTimeout, http.StatusConflict, http.StatusGone,
|
||||
http.StatusUnprocessableEntity, http.StatusUpgradeRequired,
|
||||
http.StatusTooManyRequests, http.StatusInternalServerError,
|
||||
http.StatusNotImplemented, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout,
|
||||
}
|
||||
m := make(map[string]int, len(codes))
|
||||
for _, c := range codes {
|
||||
m[http.StatusText(c)] = c
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// hexisError re-wraps an error from the vendored Hexis client as an
|
||||
// *ecosystemError, so a Hexis failure classifies the same way a Nexus or Praxis
|
||||
// one does and ecosystemGap can tell a refused credential from an outage.
|
||||
//
|
||||
// This is a boundary adapter and it is not the fix anyone would choose. The
|
||||
// Hexis client lives in another repository and returns
|
||||
// fmt.Errorf("%s: %s", http.StatusText(status), body) for every status at or
|
||||
// above 400, so the status text is the only signal that survives — the correct
|
||||
// fix is a typed error carrying the code, and Maven cannot land it unilaterally
|
||||
// (Vikunja #587, docs/plans/20-two-artifacts-and-neither-is-spring.md). Parsing
|
||||
// here is bounded: the message's first colon-delimited segment is the status
|
||||
// text verbatim, no status text contains a colon, and anything unrecognised —
|
||||
// "do request: ...", "create request: ..." — is a transport failure and is left
|
||||
// at status 0, which is exactly what Unreachable() means.
|
||||
func hexisError(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
return err
|
||||
}
|
||||
head, _, _ := strings.Cut(err.Error(), ": ")
|
||||
return &ecosystemError{
|
||||
Service: "hexis", Op: op, Status: hexisStatusTexts[head], Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
type nexusClient struct {
|
||||
ecosystemHTTP
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newNexusClient(url string) *nexusClient {
|
||||
return &nexusClient{newEcosystemHTTP("nexus", "X-Nexus-Version", url)}
|
||||
return &nexusClient{
|
||||
baseURL: url,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// withToken sets the bearer token sent on every request. Returns the client so
|
||||
@@ -292,26 +176,62 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
body["types"] = types
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/resolve", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("nexus", "resolve", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result nexusResolveResult
|
||||
if err := c.postJSON(ctx, "resolve", "/api/v1/resolve", body, &result); err != nil {
|
||||
return nil, err
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *nexusClient) Health(ctx context.Context) error {
|
||||
return c.getJSON(ctx, "health", "/health", nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("nexus", "health", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// praxisClient talks to the Praxis HTTP tools API. Maven must not open Praxis's
|
||||
// SQLite store directly (ecosystem invariant: no component reads another's DB),
|
||||
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
||||
type praxisClient struct {
|
||||
ecosystemHTTP
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newPraxisClient(url string) *praxisClient {
|
||||
return &praxisClient{newEcosystemHTTP("praxis", "X-Praxis-Version", url)}
|
||||
return &praxisClient{
|
||||
baseURL: url,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
@@ -319,85 +239,42 @@ func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// praxisAttention — an attention response in either of the two shapes Praxis
|
||||
// may send (Vikunja #540).
|
||||
//
|
||||
// ECOSYSTEM-SPEC §2.6 says the response carries `degraded: [source_ids]` when a
|
||||
// source is failed or stale, and that Maven is required to say so rather than
|
||||
// report all-clear. The deployed Praxis answers with a bare JSON array and no
|
||||
// envelope at all, so both are decoded here: an array is the items, an object is
|
||||
// the spec envelope. This lands the Maven half without waiting on the server,
|
||||
// and the sources read below is what makes the hedge work meanwhile.
|
||||
type praxisAttention struct {
|
||||
Items []map[string]any
|
||||
Degraded []string
|
||||
}
|
||||
|
||||
func (a *praxisAttention) UnmarshalJSON(data []byte) error {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||
return json.Unmarshal(trimmed, &a.Items)
|
||||
}
|
||||
var env struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Degraded []string `json:"degraded"`
|
||||
}
|
||||
if err := json.Unmarshal(trimmed, &env); err != nil {
|
||||
// getJSON performs a GET and decodes the JSON body into out. op is the logical
|
||||
// operation name for errors and traces: the path carries the query string, and
|
||||
// after entity scoping that means an entity id in every log line built from the
|
||||
// error, next to a trace that redacts far less than that.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Items, a.Degraded = env.Items, env.Degraded
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) (praxisAttention, error) {
|
||||
var out praxisAttention
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// praxisSource — one polled source, as much of it as the hedge needs. The tools
|
||||
// API does not expose sources, so this decodes the plain `/api/v1/sources` rows.
|
||||
type praxisSource struct {
|
||||
ID string `json:"id"`
|
||||
SourceID string `json:"source_id"`
|
||||
Health string `json:"health"`
|
||||
}
|
||||
|
||||
func (s praxisSource) name() string {
|
||||
if s.SourceID != "" {
|
||||
return s.SourceID
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
// UnhealthySources reports which sources cannot be trusted to have reported,
|
||||
// and how many sources Praxis has at all (Vikunja #540).
|
||||
//
|
||||
// Only read when the attention list came back empty, which is the one turn where
|
||||
// an all-clear is at stake. A source whose health field is absent counts as
|
||||
// healthy: a Praxis that never reports health would otherwise make every quiet
|
||||
// turn a hedge, and an unreported field is not evidence of a fault. Everything it
|
||||
// does report other than "ok" — failed, stale, degraded, unknown — counts as
|
||||
// cannot-tell, because none of them mean the source has spoken.
|
||||
func (c *praxisClient) UnhealthySources(ctx context.Context) (bad []string, total int, err error) {
|
||||
var out []praxisSource
|
||||
if err := c.getJSON(ctx, "sources", "/api/v1/sources", &out); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for _, s := range out {
|
||||
if s.Health != "" && s.Health != "ok" {
|
||||
bad = append(bad, s.name())
|
||||
}
|
||||
}
|
||||
return bad, len(out), nil
|
||||
}
|
||||
|
||||
// ListAttentionForEntity is ListAttention scoped to a single canonical Nexus
|
||||
// entity, so callers already holding a resolved entity_id (e.g. after
|
||||
// resolveEntityReference) can ask "what needs attention for this entity"
|
||||
// instead of filtering the unscoped list client-side.
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) (praxisAttention, error) {
|
||||
var out praxisAttention
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, "attention_for_entity",
|
||||
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||
return out, err
|
||||
@@ -432,14 +309,31 @@ type praxisItem struct {
|
||||
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
||||
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
|
||||
return c.postItem(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
}
|
||||
|
||||
// postItem posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
func (c *praxisClient) postItem(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
// Every failure is a *ecosystemError, including the transport and decode ones:
|
||||
// these are the paths that mutate remote state, and the question worth
|
||||
// answering afterwards is whether the call never left or was refused.
|
||||
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
var out praxisItem
|
||||
if err := c.postJSON(ctx, op, path, payload, &out); err != nil {
|
||||
return nil, err
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -464,7 +358,7 @@ func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem,
|
||||
}
|
||||
|
||||
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
||||
return c.postItem(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
}
|
||||
|
||||
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
@@ -579,7 +473,6 @@ func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID str
|
||||
}
|
||||
caps, err := w.hexis.Capabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
err = hexisError("capabilities", err)
|
||||
log.Printf("ecosystem: hexis capabilities error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -607,10 +500,7 @@ func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, t
|
||||
|
||||
exec, err := w.hexis.Execute(ctx, req)
|
||||
if err != nil {
|
||||
// A classified dependency failure. The two returns below are NOT: an
|
||||
// execution that ran and failed is the command failing, not Hexis
|
||||
// degrading, and it keeps its plain error so the caller says so.
|
||||
return correlationID, hexisError("execute", err)
|
||||
return correlationID, fmt.Errorf("execute: %w", err)
|
||||
}
|
||||
if exec.Status == "succeeded" {
|
||||
return correlationID, nil
|
||||
|
||||
+79
-418
@@ -5,42 +5,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"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"
|
||||
servicePraxis = "Praxis"
|
||||
serviceHexis = "Hexis"
|
||||
)
|
||||
|
||||
// serviceVars — the one-key map the eco_down and eco_denied lines take.
|
||||
func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
|
||||
|
||||
// ecosystemGap names the service that failed. A rejected credential gets its
|
||||
// own line, because a wrong token looks exactly like an outage to him and
|
||||
// "try again" is advice that will never work. Every degrade path reads through
|
||||
// here, so all of them name the service and none of them guesses instead.
|
||||
func ecosystemGap(service string, err error) string {
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(service))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(service))
|
||||
}
|
||||
|
||||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||||
// rather than a map[string]func because each arm carries its own state: the
|
||||
// verb aliases it answers to, the trace name it records, and its own reply
|
||||
@@ -107,7 +79,7 @@ var praxisCapabilities = []praxisCapability{
|
||||
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
|
||||
// Returns "" when the act is not a Praxis verb (the caller falls through to the
|
||||
// system command executor). Returns a reply string otherwise.
|
||||
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision, candidate router.ActionCandidate) string {
|
||||
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision) string {
|
||||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -118,16 +90,9 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
px := h.ecosystem.praxis
|
||||
dec, ok := h.resolveSurfacedPosition(dec)
|
||||
if !ok {
|
||||
// A demonstrative with no digest behind it. "я это сделал" is a sentence
|
||||
// about his day, so the rest of the cascade gets it back rather than
|
||||
// hearing "какой пункт?" for something that was never about a пункт.
|
||||
return ""
|
||||
}
|
||||
for _, capability := range praxisCapabilities {
|
||||
for _, alias := range capability.aliases() {
|
||||
if alias == candidate.Fn {
|
||||
if alias == dec.Slots.Fn {
|
||||
return capability.handle(ctx, h, px, dec)
|
||||
}
|
||||
}
|
||||
@@ -139,16 +104,10 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
// praxisItemAction is the shared shape of the item-lifecycle capabilities: take
|
||||
// an item id from the value slot, call one Praxis endpoint, trace the result.
|
||||
type praxisItemAction struct {
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
// failure is the first half of the reply when the Praxis call errors: which
|
||||
// operation did not happen. ecosystemGap supplies the second half, which
|
||||
// names Praxis and splits a refused token from an outage — those two used to
|
||||
// produce the identical sentence and neither said "Praxis" (Vikunja #588).
|
||||
// The verb is kept alongside the service name because the trace is the only
|
||||
// other place it exists, and he is not reading the trace.
|
||||
failure string
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
failure string // reply when the Praxis call errors
|
||||
success string
|
||||
call func(ctx context.Context, px *praxisClient, id string) error
|
||||
}
|
||||
@@ -165,7 +124,7 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
|
||||
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
|
||||
return a.failure + " " + ecosystemGap(servicePraxis, err)
|
||||
return a.failure
|
||||
}
|
||||
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||
return a.success
|
||||
@@ -180,34 +139,24 @@ func (listAttentionCapability) aliases() []string {
|
||||
|
||||
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
att, err := px.ListAttention(ctx, 20)
|
||||
items, err := px.ListAttention(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return phraser.A(phraser.AttentionFail, nil)
|
||||
return "не могу сейчас узнать, что требует внимания."
|
||||
}
|
||||
items := att.Items
|
||||
if len(items) == 0 {
|
||||
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, started); hedge != "" {
|
||||
return hedge
|
||||
}
|
||||
return phraser.A(phraser.AttentionNone, nil)
|
||||
return "ничего не требует внимания."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
|
||||
importance, _ := item["importance"].(float64)
|
||||
rule, _ := item["rule"].(string)
|
||||
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 {
|
||||
s += fmt.Sprintf(" (важность %d", int(importance))
|
||||
if rule != "" {
|
||||
@@ -217,20 +166,16 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
}
|
||||
parts = append(parts, s)
|
||||
|
||||
// Recorded in the order she says them, and only for items she could
|
||||
// say: an item skipped above has no position in what he heard (#516).
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
// Speaking an item surfaces it, it does not acknowledge it
|
||||
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
|
||||
// a failed surface call must not block delivering the digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.rememberSurfaced(spoken)
|
||||
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, "; ")})
|
||||
return "требует внимания: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// listChangesCapability reads the recent-changes feed.
|
||||
@@ -247,29 +192,19 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
||||
log.Printf("ecosystem: praxis changes: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return phraser.A(phraser.ChangesFail, nil)
|
||||
return "не могу сейчас узнать об изменениях."
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return phraser.A(phraser.ChangesNone, nil)
|
||||
return "нет изменений."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||
var parts []string
|
||||
for _, c := range changes {
|
||||
title, _ := c["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
typ, _ := c["change_type"].(string)
|
||||
if typ == "" {
|
||||
parts = append(parts, title)
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return phraser.A(phraser.ChangesNone, nil)
|
||||
}
|
||||
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||
return "изменения: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||
@@ -295,12 +230,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
subject = dec.Slots.Text
|
||||
}
|
||||
if subject == "" {
|
||||
return phraser.A(phraser.EcoAboutWhat, nil)
|
||||
return "про что именно спросить?"
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||
// than quietly answering about something else.
|
||||
return phraser.A(phraser.EcoNoNexus, nil)
|
||||
return "не могу связать это с сущностью — Nexus не настроен."
|
||||
}
|
||||
|
||||
started := h.now()
|
||||
@@ -310,27 +245,32 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
// trace gets. A trace that stores a rune count next to a log line
|
||||
// storing the runes is not redacted at all.
|
||||
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
||||
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
return phraser.A(phraser.EcoUnknownEntity, nil)
|
||||
return "не знаю такой сущности."
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = subject
|
||||
}
|
||||
|
||||
queried := h.now()
|
||||
att, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
items, scoped := scopedToEntity(att.Items, entityID)
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
if !scoped {
|
||||
// A Praxis old enough to ignore an unknown query parameter answers the
|
||||
// scoped question with the unscoped list. Reading that back as "по
|
||||
@@ -339,59 +279,33 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||
"entity_id": entityID, "count": len(items),
|
||||
})
|
||||
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
// Same surfaced != acknowledged rule as the unscoped digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The scoped digest is a list she read out, so it replaces the positional
|
||||
// memory exactly as the unscoped one does. It used to surface these items
|
||||
// and remember none of them, which left the previous digest live: "отметь
|
||||
// второй как сделанное" then indexed into a list he had not just heard and
|
||||
// transitioned somebody else's item (docs/ecosystem.md — a wrong guess here
|
||||
// transitions the wrong item).
|
||||
h.rememberSurfaced(spoken)
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
// The scoped list is as exposed to a silent source as the unscoped one,
|
||||
// and a per-entity all-clear is the more convincing of the two (#540).
|
||||
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, queried); hedge != "" {
|
||||
return hedge
|
||||
}
|
||||
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
||||
return "по «" + displayName + "» ничего нет."
|
||||
}
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// surfaceSpoken marks an item she just read out as surfaced. Speaking an item
|
||||
// surfaces it, it does not acknowledge it (ECOSYSTEM-SPEC.md §2.3: surfaced !=
|
||||
// acknowledged), so this calls Surface and nothing else. Best effort: a failed
|
||||
// surface call must not block delivering the digest. Returns the item id, or ""
|
||||
// when the item carried none.
|
||||
func surfaceSpoken(ctx context.Context, px *praxisClient, item map[string]any) string {
|
||||
id, _ := item["id"].(string)
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
return id
|
||||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
@@ -456,7 +370,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
|
||||
out := "я помню: " + strings.Join(parts, ", ")
|
||||
if more {
|
||||
out += ", и это не всё"
|
||||
}
|
||||
@@ -502,14 +416,6 @@ func traceStatusForError(err error) string {
|
||||
return traceFailed
|
||||
}
|
||||
|
||||
// nexusResolveFailed records a resolve that failed and returns the named gap.
|
||||
// The subject is his words, so the trace keeps a rune count and not the runes.
|
||||
func (h *reactiveHandler) nexusResolveFailed(ctx context.Context, subject string, started time.Time, err error) string {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
return ecosystemGap(serviceNexus, err)
|
||||
}
|
||||
|
||||
// redactSubject reduces a user utterance to something safe to persist in a
|
||||
// trace: its length only. Traces are diagnostics, and his words are not
|
||||
// diagnostics — the correlation ID is what ties a trace to the turn.
|
||||
@@ -568,102 +474,33 @@ func unauthorizedEcosystemError(err error) bool {
|
||||
return errors.As(err, &ee) && ee.Unauthorized()
|
||||
}
|
||||
|
||||
// isEcosystemError reports a failure that belongs to the service rather than to
|
||||
// what was asked of it: a call that never landed, or one the far side refused.
|
||||
// It separates "Hexis is down" from "the restart failed".
|
||||
func isEcosystemError(err error) bool {
|
||||
var ee *ecosystemError
|
||||
return errors.As(err, &ee)
|
||||
}
|
||||
|
||||
// traceErrorFields describes an ecosystemError for a trace without leaking the
|
||||
// payload: the HTTP status and the failure class, nothing else.
|
||||
func traceErrorFields(err error) map[string]any {
|
||||
fields := map[string]any{"class": "error"}
|
||||
fields := map[string]any{}
|
||||
var ee *ecosystemError
|
||||
if !errors.As(err, &ee) {
|
||||
if errors.As(err, &ee) {
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
default:
|
||||
fields["class"] = "error"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
}
|
||||
fields["class"] = "error"
|
||||
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
|
||||
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||
// or "" to fall through to the system command executor.
|
||||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision, candidate router.ActionCandidate) string {
|
||||
// This method is intentionally callable outside runTurn by ecosystem
|
||||
// harnesses. Refuse before correlation ids, Nexus resolution or capability
|
||||
// discovery so the no-op sentinel can never leak into Hexis as a verb.
|
||||
if refusesCommand(dec) {
|
||||
return commandProhibitionReply
|
||||
}
|
||||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string {
|
||||
if h.ecosystem == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -677,26 +514,28 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
|
||||
// Resolve the utterance text as an entity reference through Nexus. An
|
||||
// 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()
|
||||
res := h.resolveEntityCandidates(ctx, entityReferences(dec))
|
||||
subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||
// and report degradation rather than silently falling through to the
|
||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||
// independently, never a silent all-clear).
|
||||
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
map[string]any{"candidates": len(ambiguous)})
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||
map[string]any{"subject": redactSubject(subject)})
|
||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
||||
return ""
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||
@@ -710,7 +549,10 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
@@ -721,27 +563,16 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
// Match the user's verb to a capability by name/description. Collect all
|
||||
// matches: more than one is itself ambiguous, so we ask rather than pick
|
||||
// the first (ecosystem invariant: no arbitrary target for mutation).
|
||||
verb := candidate.Fn
|
||||
verb := dec.Slots.Fn
|
||||
if verb == "" {
|
||||
verb = dec.Slots.Text
|
||||
}
|
||||
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 := !candidate.ActionResolved()
|
||||
var matches []*hexisclient.Capability
|
||||
for i, c := range caps {
|
||||
name := strings.ToLower(c.Name)
|
||||
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 {
|
||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
||||
matches = append(matches, &caps[i])
|
||||
}
|
||||
}
|
||||
@@ -753,30 +584,13 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
for _, m := range matches {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
|
||||
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
||||
}
|
||||
matched := matches[0]
|
||||
|
||||
// Read-only capabilities run immediately; mutating ones are parked for an
|
||||
// explicit spoken confirm bound to this capability + target.
|
||||
// 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 {
|
||||
if !matched.ReadOnly {
|
||||
h.mu.Lock()
|
||||
h.pendingHexis = &pendingHexisExec{
|
||||
capabilityID: matched.ID,
|
||||
@@ -784,14 +598,11 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
entityID: entityID,
|
||||
displayName: displayName,
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
// This action's id, so the execution the confirm authorises is
|
||||
// joined to the resolve and the discovery that proposed it.
|
||||
correlationID: correlationIDFromCtx(ctx),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName})
|
||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
||||
}
|
||||
|
||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||
@@ -811,15 +622,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
// Hexis never answering, or answering "no", is a gap in Hexis and is
|
||||
// named as one — a refused token said "не получилось выполнить команду"
|
||||
// here and sent him to debug a capability that was never reached
|
||||
// (Vikunja #587). An execution that genuinely ran and failed is not an
|
||||
// ecosystemError and keeps the command-level line.
|
||||
if isEcosystemError(err) {
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
}
|
||||
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||
return "не получилось выполнить команду для " + displayName + "."
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
// thing under a different key, in a different shape.
|
||||
@@ -827,147 +630,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
"entity_id": entityID, "entity_name": displayName,
|
||||
"capability": capName, "causation_id": causationID,
|
||||
})
|
||||
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 {
|
||||
// A thinned model act reaches this hook before actionAct. Negative authority
|
||||
// must therefore stop here as well, before even a read to Nexus/Hexis.
|
||||
if refusesCommand(dec) {
|
||||
return commandProhibitionReply
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||
return ""
|
||||
}
|
||||
if dec.Intent != router.IntentAct || dec.Slots.HasFn || !router.ActHasEntityTarget(dec) {
|
||||
return ""
|
||||
}
|
||||
// Resolve the action candidate. Use the matcher when available; when the
|
||||
// handler has no matcher (ecosystem-only test harnesses), build an
|
||||
// unresolved candidate directly — the matcher would not have matched either.
|
||||
var candidate router.ActionCandidate
|
||||
if h.matcher != nil {
|
||||
candidate = h.resolveAction(ctx, dec)
|
||||
} else {
|
||||
candidate = router.ResolveActionCandidate(dec, nil)
|
||||
}
|
||||
return h.handleHexisAct(ctx, dec, candidate)
|
||||
}
|
||||
|
||||
// attentionCannotTell returns the hedge to say instead of an all-clear, or ""
|
||||
// when an empty attention list really does mean nothing needs looking at
|
||||
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
//
|
||||
// "Nothing needs attention" and "I cannot currently tell" are different answers
|
||||
// and only one of them was ever said. The spec's mechanism is a `degraded` array
|
||||
// on the attention response, which the deployed Praxis does not send, so the
|
||||
// source health read is the half that works today. It costs one HTTP call and
|
||||
// only on the empty-list turn, which is the only turn where an all-clear is at
|
||||
// stake.
|
||||
//
|
||||
// A failed sources read is deliberately NOT a hedge. The attention call itself
|
||||
// succeeded, and not being able to ask about health is not evidence of a fault —
|
||||
// hedging on it would turn one flaky endpoint into a permanently uncertain
|
||||
// assistant.
|
||||
func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string {
|
||||
if len(degraded) > 0 {
|
||||
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||
"degraded": strings.Join(degraded, ","), "source": "response",
|
||||
})
|
||||
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")})
|
||||
}
|
||||
bad, total, err := px.UnhealthySources(ctx)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis sources: %v", err)
|
||||
return ""
|
||||
}
|
||||
if total == 0 {
|
||||
// A Praxis that polls nothing knows nothing, so its silence is not an
|
||||
// all-clear either. This is the state the box is in as of 2026-08-05:
|
||||
// /api/v1/sources answers with an empty array.
|
||||
h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0})
|
||||
return phraser.A(phraser.AttentionNoSources, nil)
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||
"degraded": strings.Join(bad, ","), "sources": total, "source": "health",
|
||||
})
|
||||
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")})
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rememberSurfaced records the item ids she just read out, replacing whatever the
|
||||
// previous digest left. Called with the ids in speaking order (Vikunja #516).
|
||||
func (h *reactiveHandler) rememberSurfaced(ids []string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.surfacedItems = ids
|
||||
}
|
||||
|
||||
// resolveSurfacedPosition turns a positional item reference into a Praxis item
|
||||
// id, using the list she last read out.
|
||||
//
|
||||
// The router names a position and not an id, because only the daemon has the
|
||||
// list: PraxisGrammars fills the value slot with "2", "last" or "this". An id is
|
||||
// left alone, since "item_ab12" is already one.
|
||||
//
|
||||
// The second return says whether the turn is still Praxis's. A position that
|
||||
// names nothing keeps the turn and clears the slot, so the capability answers its
|
||||
// own "какой пункт?" — he said "второй пункт" and deserves to hear that there is
|
||||
// no second one. A demonstrative that resolves to nothing gives the turn BACK,
|
||||
// because "я это сделал" was probably never about a пункт at all. "это" also
|
||||
// needs the list to hold exactly one item: pointing at one of five is a guess,
|
||||
// and a wrong guess here transitions the wrong item.
|
||||
func (h *reactiveHandler) resolveSurfacedPosition(dec router.Decision) (router.Decision, bool) {
|
||||
ref := dec.Slots.Value
|
||||
if ref == "" || strings.HasPrefix(ref, "item") {
|
||||
return dec, true
|
||||
}
|
||||
h.mu.Lock()
|
||||
ids := h.surfacedItems
|
||||
h.mu.Unlock()
|
||||
|
||||
idx := -1
|
||||
switch {
|
||||
case ref == "this":
|
||||
if len(ids) != 1 {
|
||||
log.Printf("ecosystem: praxis \"это\" has no single item (%d surfaced)", len(ids))
|
||||
return dec, false
|
||||
}
|
||||
idx = 0
|
||||
case ref == "last":
|
||||
idx = len(ids) - 1
|
||||
default:
|
||||
n, err := strconv.Atoi(ref)
|
||||
if err != nil || n < 1 {
|
||||
// Neither a position nor an id: leave it for the capability to
|
||||
// reject rather than silently rewriting what he said.
|
||||
return dec, true
|
||||
}
|
||||
idx = n - 1
|
||||
}
|
||||
if idx < 0 || idx >= len(ids) {
|
||||
log.Printf("ecosystem: praxis position %q has no item (%d surfaced)", ref, len(ids))
|
||||
dec.Slots.Value = ""
|
||||
return dec, true
|
||||
}
|
||||
dec.Slots.Value = ids[idx]
|
||||
return dec, true
|
||||
return "команда выполнена для " + displayName + "."
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
// 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.
|
||||
nexus.SetFault(503)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||
@@ -104,7 +104,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a recorded nexus failure must not degrade the praxis digest, got %q", reply)
|
||||
}
|
||||
@@ -114,10 +114,10 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
|
||||
// And the reverse: a Praxis outage mid-session leaves the Hexis path whole.
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("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)
|
||||
}
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestEcosystem_OneEndpointDownDoesNotMuteTheService(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 503)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a downed surface endpoint must not mute the digest, got %q", reply)
|
||||
}
|
||||
@@ -150,11 +150,11 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||
}
|
||||
if actRan(reply) {
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
@@ -172,7 +172,7 @@ func TestEcosystem_RejectedCredentialSaysSo(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(status)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "токен") {
|
||||
t.Fatalf("http %d must read as a credential problem, got %q", status, reply)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestEcosystem_MalformedPraxisBodyDegrades(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetBody(`[{"title":`)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("a malformed praxis body must not answer with silence")
|
||||
}
|
||||
@@ -211,8 +211,8 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" || actRan(reply) {
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
||||
nexus := newFakeNexus(t, body)
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||
}
|
||||
})
|
||||
@@ -249,8 +249,8 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" || actRan(reply) {
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
@@ -267,8 +267,8 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecutionFailed("exec_1", "unit not found"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if actRan(reply) {
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||
}
|
||||
if reply == "" {
|
||||
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
exec := tracesFor(t, h, "hexis", "execute")
|
||||
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
if len(traces(t, h)) == 0 {
|
||||
@@ -339,7 +339,7 @@ func TestEcosystem_AmbiguousTargetBlocksExecution(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous resolve must list candidates, got %q", reply)
|
||||
}
|
||||
@@ -360,7 +360,7 @@ func TestEcosystem_NoAutonomousPraxisToHexis(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("attention digest must not contact hexis on its own")
|
||||
}
|
||||
@@ -378,7 +378,7 @@ func TestEcosystem_MutatingCapabilityWaitsForConfirmation(t *testing.T) {
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("restart"))
|
||||
if !strings.Contains(reply, "restart") || !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
@@ -404,7 +404,7 @@ func TestEcosystem_SurfaceFailureStillDelivers(t *testing.T) {
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 500)
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("failed surface must not swallow the digest, got %q", reply)
|
||||
}
|
||||
@@ -426,15 +426,15 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
for name, reply := range map[string]string{
|
||||
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")),
|
||||
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")),
|
||||
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes"), routeCandidate("list_changes")),
|
||||
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"), routeCandidate("acknowledge_item")),
|
||||
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer")),
|
||||
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention")),
|
||||
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes")),
|
||||
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1")),
|
||||
} {
|
||||
if reply == "" {
|
||||
t.Errorf("%s: total outage must not answer with silence", name)
|
||||
}
|
||||
if actRan(reply) {
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
||||
}
|
||||
}
|
||||
@@ -458,11 +458,11 @@ func TestEcosystem_RecoveryAfterOutageNeedsNoRestart(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); strings.Contains(reply, "disk") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("outage must not serve content, got %q", reply)
|
||||
}
|
||||
praxis.SetFault(0)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("recovery must work on the next turn, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A refused credential and an outage are different answers, and on the Hexis
|
||||
// path only one of them used to be said. These tests pin the difference at both
|
||||
// Hexis sites: the discovery hop and the execute hop (Vikunja #587). The Praxis
|
||||
// half of the same defect is in praxis_gap_test.go.
|
||||
//
|
||||
// unreachableURL is a port nothing listens on, which is what "the service is
|
||||
// down" looks like from inside a call: the connection is refused, no HTTP
|
||||
// answer is ever produced, and ecosystemError.Unreachable() is true.
|
||||
const unreachableURL = "http://127.0.0.1:1"
|
||||
|
||||
func denied(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDenied, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
func down(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDown, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
// hexisGapHandler wires a handler whose Nexus resolves cleanly and whose Hexis
|
||||
// is the caller's to break. hexisURL is taken separately so a test can point it
|
||||
// at a dead port.
|
||||
func hexisGapHandler(t *testing.T, nexusURL, hexisURL string) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
dataStore: st,
|
||||
now: func() time.Time { return now },
|
||||
ecosystem: stubEcosystem(nexusURL, hexisURL),
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscovery401IsDeniedNotDown — the discovery hop.
|
||||
//
|
||||
// The vendored Hexis client returns a plain fmt.Errorf for every status at or
|
||||
// above 400, so errors.As for *ecosystemError never matched and every failure
|
||||
// fell through to the outage line. "Hexis is down" for a rejected token sends
|
||||
// him to inspect a service that is running fine.
|
||||
func TestHexisDiscovery401IsDeniedNotDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
hexis.SetFault(401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
if !strings.Contains(reply, serviceHexis) {
|
||||
t.Errorf("reply does not name Hexis: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscoveryOutageIsDownNotDenied — the other half of the same fork.
|
||||
// Without this the fix could pass by calling everything a refused credential.
|
||||
func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
h := hexisGapHandler(t, nexus.URL, unreachableURL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecute401IsDeniedNotCommandFailure — the execute hop, which did not
|
||||
// consult ecosystemGap at all and named neither the service nor the cause.
|
||||
func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
// Discovery stays healthy; only the execute endpoint refuses. A blanket
|
||||
// fault would never reach the site under test.
|
||||
hexis.SetRouteFault("/api/v1/execute", 401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecuteOutageIsDown — same site, the other classification.
|
||||
//
|
||||
// Discovery and execution share one base URL, so the outage has to be scoped to
|
||||
// the execute endpoint rather than to the server: it answers capabilities
|
||||
// normally and drops the connection on execute, which is what the client sees
|
||||
// when the far side dies mid-call. That produces no HTTP status at all, which is
|
||||
// what Unreachable() means.
|
||||
func TestHexisExecuteOutageIsDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/execute" {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("hijack: %v", err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(caps))
|
||||
}))
|
||||
t.Cleanup(hexis.Close)
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecutionFailedStaysCommandFailure — the boundary of the fix. Hexis
|
||||
// answering 200 with a failed execution is the command failing, not Hexis
|
||||
// degrading, and it must keep the command-level line rather than accusing a
|
||||
// healthy service of being down.
|
||||
func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
|
||||
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
|
||||
}
|
||||
if !phraser.IsA(phraser.ActFailEntity, map[string]string{"name": muzickIndexer}, reply) {
|
||||
t.Fatalf("want the command-failure line, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,6 @@ func praxisActDec(fn string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}}
|
||||
}
|
||||
|
||||
// routeCandidate builds an ActionCandidate matching a route-resolved Decision.
|
||||
func routeCandidate(fn string) router.ActionCandidate {
|
||||
return router.ActionCandidate{Fn: fn, Source: router.ActionSourceRoute}
|
||||
}
|
||||
|
||||
// praxisItemDec is praxisActDec for the lifecycle verbs, which need an item id
|
||||
// in the value slot. Without one they answer "which item?" and never reach
|
||||
// Praxis at all, which makes them useless for testing a Praxis outage.
|
||||
@@ -51,7 +46,7 @@ func TestPraxisAttention_HappyPathSurfacesItems(t *testing.T) {
|
||||
praxis := newFakePraxis(t, items)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("expected attention digest to mention the item, got %q", reply)
|
||||
}
|
||||
@@ -82,7 +77,7 @@ func TestPraxisAttention_DegradedFailsClosedNotEmpty(t *testing.T) {
|
||||
praxis.SetFault(500)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("praxis outage must not produce an empty reply")
|
||||
}
|
||||
@@ -109,94 +104,14 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
}
|
||||
|
||||
nexus.SetFault(503)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if actRan(reply) {
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !actRan(reply) {
|
||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
|
||||
// she read out, so a positional follow-up must land on one of ITS items. It
|
||||
// surfaced them and remembered none, which left the previous digest live and
|
||||
// sent "отметь второй" at somebody else's item.
|
||||
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
scoped := fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
|
||||
praxis := newFakePraxis(t, scoped)
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
// A digest from an earlier turn, still the positional memory.
|
||||
h.rememberSurfaced([]string{"item_stale"})
|
||||
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
||||
}, routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "indexer wedged") {
|
||||
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
surfaced := append([]string(nil), h.surfacedItems...)
|
||||
h.mu.Unlock()
|
||||
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
||||
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
||||
}
|
||||
|
||||
// The follow-up resolves against what he just heard, not the stale list.
|
||||
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last"), routeCandidate("resolve_item")); reply == "" {
|
||||
t.Fatal("positional follow-up should have been claimed by praxis")
|
||||
}
|
||||
var body string
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
||||
body = string(r.Body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "item_scoped_1") {
|
||||
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
||||
}
|
||||
if strings.Contains(body, "item_stale") {
|
||||
t.Fatal("resolve transitioned an item from a previous digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
||||
// later turn with a context of its own. The contract mints one id per action,
|
||||
// so the execution it authorises must still be joinable to the resolve and the
|
||||
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
||||
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart")); !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
resolve := findTrace(t, h, "nexus", "resolve")
|
||||
if resolve == nil || resolve.CorrelationID == "" {
|
||||
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
||||
}
|
||||
|
||||
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
||||
t.Fatal("confirm should have been claimed")
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec == nil {
|
||||
t.Fatal("expected a hexis execute trace")
|
||||
}
|
||||
if exec.CausationID != resolve.CorrelationID {
|
||||
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
||||
exec.CausationID, resolve.CorrelationID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -73,7 +72,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false,"risk":"high"}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating cap should ask to confirm, got %q", reply)
|
||||
}
|
||||
@@ -86,7 +85,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
|
||||
// The follow-up "да" turn executes exactly the parked capability.
|
||||
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
||||
if !handled || !actRan(confirmReply) {
|
||||
if !handled || !strings.Contains(confirmReply, "выполнена") {
|
||||
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
||||
}
|
||||
if !*executed {
|
||||
@@ -103,7 +102,7 @@ func TestHexisConfirmNoDoesNotExecute(t *testing.T) {
|
||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply, handled := h.resolveConfirm(ctx, "нет")
|
||||
if !handled || !strings.Contains(reply, "отменила") {
|
||||
t.Fatalf("no should cancel, got handled=%v reply=%q", handled, reply)
|
||||
@@ -119,14 +118,14 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
||||
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !*executed {
|
||||
t.Fatal("read-only cap should execute without confirmation")
|
||||
}
|
||||
if h.pendingHexis != nil {
|
||||
t.Fatal("read-only cap should not park a confirmation")
|
||||
}
|
||||
if !actRan(reply) {
|
||||
if !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("unexpected reply %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +135,7 @@ func TestHexisAmbiguousAsksClarification(t *testing.T) {
|
||||
ambiguous := `{"status":"ambiguous","candidates":[{"entity_id":"ent_muzick","display_name":"Muzick indexer"},{"entity_id":"ent_manga","display_name":"Manga indexer"}]}`
|
||||
h, executed := newHexisTestHandler(t, ambiguous, `[]`)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("the indexer"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("the indexer"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Manga indexer") {
|
||||
t.Fatalf("ambiguous should list candidates, got %q", reply)
|
||||
}
|
||||
@@ -154,7 +153,7 @@ func TestHexisResolveFlatShapeAccepted(t *testing.T) {
|
||||
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
|
||||
h, executed := newHexisTestHandler(t, flat, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !*executed {
|
||||
t.Fatalf("flat-shaped resolved entity should still execute, got reply %q", reply)
|
||||
}
|
||||
@@ -183,11 +182,11 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
||||
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
|
||||
}
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
if actRan(reply) {
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -216,11 +215,11 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
||||
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
|
||||
}
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
if actRan(reply) {
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -234,7 +233,7 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
||||
notFound := `{"status":"not_found"}`
|
||||
h, executed := newHexisTestHandler(t, notFound, `[]`)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("turn off the lights"), routeCandidate("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("turn off the lights"))
|
||||
if reply != "" {
|
||||
t.Fatalf("not_found resolution should fall through with empty reply, got %q", reply)
|
||||
}
|
||||
@@ -242,73 +241,3 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
||||
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"), routeCandidate("restart"))
|
||||
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"), routeCandidate("restart"))
|
||||
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"), routeCandidate("restart"))
|
||||
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"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestEcosystemTrace_OneCorrelationIDPerPraxisAction(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("setup: expected the digest, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestEcosystemTrace_FailuresAreTracedToo(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(401)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
@@ -242,7 +242,7 @@ func TestEcosystemTrace_UnreachableIsNotRefused(t *testing.T) {
|
||||
h := ecoHandler(t, nil, nil, nil)
|
||||
h.ecosystem.nexus = newNexusClient("http://127.0.0.1:1")
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
@@ -263,7 +263,7 @@ func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"), routeCandidate("restart"))
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
|
||||
|
||||
recorded := traces(t, h)
|
||||
if len(recorded) == 0 {
|
||||
@@ -295,7 +295,7 @@ func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, ambig, nil, hexis)
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"), routeCandidate("restart"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"))
|
||||
if d := findTrace(t, h, "nexus", "resolve"); d == nil || d.Status != traceAmbig {
|
||||
t.Fatalf("ambiguous resolve must be traced as such, got %+v", d)
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
mutating := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
h2 := ecoHandler(t, nexus, nil, newFakeHexis(t, mutating, fixtureHexisExecuted("exec_1", "succeeded")))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart"))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"))
|
||||
d := findTrace(t, h2, "hexis", "confirmation")
|
||||
if d == nil || d.Status != tracePending {
|
||||
t.Fatalf("a parked confirmation must be traced, got %+v", d)
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
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, routeCandidate("restart"))
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// A verb is not an entity. Before the reach gate, an exact local matcher hit
|
||||
// with no arguments still sent the raw verb to Nexus and could discover a
|
||||
// similarly named entity through Hexis. The local tool lane may handle or
|
||||
// reject it, but the ecosystem must not be consulted without a target.
|
||||
func TestBareMatchedActNeverReachesNexus(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_power", "Power", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.ecosystem = ecoHandler(t, nexus, nil, hexis).ecosystem
|
||||
|
||||
reply := h.actionAct(context.Background(), router.Decision{
|
||||
Utterance: "выключи",
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "выключи", HasFn: true, Text: "выключи"},
|
||||
})
|
||||
if len(nexus.Requests()) != 0 {
|
||||
t.Fatalf("bare verb reached Nexus: %+v", nexus.Requests())
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("bare act disappeared instead of staying in Maven's local lane")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnresolvedActNeverReachesNexusBeforeClarify(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_it", "It", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
dec := router.Decision{
|
||||
Utterance: "сделай это",
|
||||
Intent: router.IntentAct,
|
||||
Stage: 3,
|
||||
Clarify: true,
|
||||
Slots: router.Slots{Text: "сделай это"},
|
||||
}
|
||||
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
|
||||
t.Fatalf("unresolved act was answered by Hexis: %q", reply)
|
||||
}
|
||||
if len(nexus.Requests()) != 0 {
|
||||
t.Fatalf("unresolved act reached Nexus: %+v", nexus.Requests())
|
||||
}
|
||||
}
|
||||
|
||||
// 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, routeCandidate("restart"))
|
||||
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, routeCandidate("restart"))
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestEntityAttention_ScopesPraxisByCanonicalID(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("expected the scoped item in the reply, got %q", reply)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func TestEntityAttention_FoldsInLocalFactsForSameEntity(t *testing.T) {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"), routeCandidate("entity_attention"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
if !strings.Contains(reply, "descaled in june") {
|
||||
t.Fatalf("expected entity-scoped local facts in the reply, got %q", reply)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestEntityAttention_UnscopedPraxisResponseIsRefused(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("an unscoped response must not be read back as entity-scoped, got %q", reply)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestEntityAttention_ForeignItemsAreDropped(t *testing.T) {
|
||||
praxis := newFakePraxis(t, mustJSON(mixed))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("the matching item must be spoken, got %q", reply)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestEntityAttention_TruncationIsNamed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"), routeCandidate("entity_attention"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
if !strings.Contains(reply, "и это не всё") {
|
||||
t.Fatalf("a truncated recall must say it is truncated, got %q", reply)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestEntityAttention_AmbiguousAsksInsteadOfGuessing(t *testing.T) {
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"), routeCandidate("entity_attention"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous subject must ask, got %q", reply)
|
||||
}
|
||||
@@ -173,13 +173,13 @@ func TestEntityAttention_MissingAndDegradedAreDistinct(t *testing.T) {
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"), routeCandidate("entity_attention"))
|
||||
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
if missing == "" {
|
||||
t.Fatal("an unknown entity must still get an answer")
|
||||
}
|
||||
|
||||
nexus.SetFault(503)
|
||||
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"), routeCandidate("entity_attention"))
|
||||
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
if degraded == missing {
|
||||
t.Fatalf("outage and unknown-entity must not read the same: %q", degraded)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestEntityAttention_DelayedNexusDegradesNotHangs(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("a delayed resolve must still answer")
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func TestEntityAttention_WithoutNexusSaysSo(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("without nexus, items must not be passed off as entity-scoped, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -106,12 +106,7 @@ func TestSystemSafetyScenarios(t *testing.T) {
|
||||
hexis := newFakeHexis(t, fixtureHexisCapabilities(map[string]any{"id": "restart", "name": "restart", "read_only": false}), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h, _ := newSafetyHandler(t)
|
||||
h.ecosystem = stubEcosystem(nexus.URL, hexis.URL)
|
||||
// A matched function carries its entity target in Args. Text may be
|
||||
// model phrasing, but Args is the production matcher contract and the
|
||||
// ecosystem reach gate deliberately requires that evidence.
|
||||
reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{
|
||||
Fn: "restart", HasFn: true, Args: []string{"indexer"}, Text: "indexer",
|
||||
}})
|
||||
reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "indexer"}})
|
||||
if !strings.Contains(reply, "Indexer A") || !strings.Contains(reply, "Indexer B") {
|
||||
t.Fatalf("ambiguous entity must prompt for clarification, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -24,14 +24,15 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
||||
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
matcher := tool.NewMatcher(api)
|
||||
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
|
||||
rtr := buildRouter(emb, matcher, 0.55, nil)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
embedder: emb,
|
||||
router: rtr,
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ type factEnrichmentWorker struct {
|
||||
nextTry map[int64]time.Time // fact id → earliest retry
|
||||
}
|
||||
|
||||
// enrichmentScanLimit bounds how deep a single tick walks the pending queue
|
||||
// looking for facts whose backoff has elapsed. The queue is
|
||||
// enrichmentScanLimit bounds how deep a single tick (or status report) walks
|
||||
// the pending queue looking for facts whose backoff has elapsed. The queue is
|
||||
// ordered by id, so without a scan the oldest facts hold every batch slot
|
||||
// whether or not they are eligible, and one permanently failing fact stalls
|
||||
// every younger one behind it.
|
||||
@@ -75,8 +75,8 @@ func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval tim
|
||||
// has been down all day must be visible as a backlog, not as facts that
|
||||
// silently never got tagged.
|
||||
//
|
||||
// All three numbers describe the same set of rows, whatever is still pending
|
||||
// out of the first enrichmentScanLimit facts. Counting Pending over a thousand rows
|
||||
// All three numbers describe the same set of rows, the first
|
||||
// enrichmentScanLimit pending facts. Counting Pending over a thousand rows
|
||||
// while counting InBackoff over the twenty that reached the head of a batch
|
||||
// described two different populations under one struct.
|
||||
type enrichmentStatus struct {
|
||||
@@ -86,22 +86,13 @@ type enrichmentStatus struct {
|
||||
Scanned int // rows the other three counts were taken over
|
||||
}
|
||||
|
||||
// status reads the queue and counts over it. For a caller with no batch in
|
||||
// hand — anything asking the worker how it is doing from outside the tick.
|
||||
func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: status: %v", err)
|
||||
return enrichmentStatus{}
|
||||
return st
|
||||
}
|
||||
return w.statusOf(pending)
|
||||
}
|
||||
|
||||
// statusOf counts over a batch the caller already has. The batch is the query
|
||||
// the tick already ran, so reporting the backlog costs no second read of the
|
||||
// scan limit — up to a thousand rows, on a database that serialises them.
|
||||
func (w *factEnrichmentWorker) statusOf(pending []store.Fact) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
st.Pending = len(pending)
|
||||
st.Scanned = len(pending)
|
||||
w.mu.Lock()
|
||||
@@ -153,24 +144,17 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
}
|
||||
w.forgetDeparted(pending)
|
||||
skipped, failed, attempted := 0, 0, 0
|
||||
// A resolved fact leaves the pending queue, so the batch in hand overstates
|
||||
// the backlog by however many succeeded. Drop them here rather than
|
||||
// re-reading the queue to find out.
|
||||
remaining := make([]store.Fact, 0, len(pending))
|
||||
for _, f := range pending {
|
||||
if attempted >= w.batch {
|
||||
remaining = append(remaining, f)
|
||||
continue
|
||||
break
|
||||
}
|
||||
if !w.due(f.ID) {
|
||||
skipped++
|
||||
remaining = append(remaining, f)
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
if !w.resolveOne(ctx, f) {
|
||||
failed++
|
||||
remaining = append(remaining, f)
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
@@ -180,7 +164,7 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
// Report the backlog every tick, not only when something failed: the
|
||||
// stalled state worth seeing is the one where nothing failed because
|
||||
// nothing was attempted.
|
||||
if st := w.statusOf(remaining); st.Pending > 0 {
|
||||
if st := w.status(ctx); st.Pending > 0 {
|
||||
log.Printf("factenrichment: %d facts pending entity resolution, %d in backoff, worst attempt %d (scanned %d)",
|
||||
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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, 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())
|
||||
searchH, seen := searchHandler(t,
|
||||
`{"answers":["Актуальная версия Go — 1.25."],"results":[]}`,
|
||||
http.StatusOK)
|
||||
h.search = searchH.search
|
||||
|
||||
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 world query chain instead. This asserts the actual
|
||||
// destination, not merely that the write was refused: the regression was
|
||||
// the personal boundary claiming this question before search.
|
||||
if !strings.Contains(reply, "1.25") {
|
||||
t.Fatalf("reply = %q, want live world evidence", reply)
|
||||
}
|
||||
if !strings.Contains(*seen, "q="+url.QueryEscape("какая последняя версия языка Go?")) {
|
||||
t.Fatalf("search query = %q; world source was not reached verbatim", *seen)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// A second tap of the same key supersedes the first, so recall must hold one
|
||||
// vector and it must be the new value (Vikunja #493). Before this the id
|
||||
// carried a timestamp, both rows stayed, and the superseded value went on
|
||||
// competing for the turn.
|
||||
func TestActionFact_ARetapSupersedesTheOldVector(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
||||
})
|
||||
// A later tap of the same key. The clock moves, so the old id and the new
|
||||
// one differ — which is exactly what used to leave two rows behind.
|
||||
h.now = func() time.Time { return time.Now().Add(time.Hour) }
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"500мл"`},
|
||||
})
|
||||
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want one vector for the key, got %d: %+v", len(hits), hits)
|
||||
}
|
||||
if got := hits[0].Meta["text"]; got != "water — 500мл" {
|
||||
t.Errorf("indexed text = %q, want the current value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Another key is not this key. A prefix delete that widened would take the
|
||||
// whole index with it.
|
||||
func TestActionFact_ARetapLeavesOtherKeysAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я обедал",
|
||||
Slots: router.Slots{Key: "meal", HasKey: true, Value: `"суп"`},
|
||||
})
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
||||
})
|
||||
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "обед"), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, hit := range hits {
|
||||
if hit.Meta["text"] == "meal — суп" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("writing water dropped the meal vector: %+v", hits)
|
||||
}
|
||||
}
|
||||
@@ -178,14 +178,6 @@ func (fs *fakeServer) Requests() []capturedRequest {
|
||||
return out
|
||||
}
|
||||
|
||||
// ResetRequests drops the captured requests, so a test can assert about one
|
||||
// turn without subtracting the setup turn's calls.
|
||||
func (fs *fakeServer) ResetRequests() {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.requests = nil
|
||||
}
|
||||
|
||||
func jsonHandler(status int, body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -338,17 +330,7 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
|
||||
// Maven's praxisClient calls. Every route returns its fixed body until a
|
||||
// fault is injected via SetFault.
|
||||
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
||||
// One healthy source by default: an empty attention list only means
|
||||
// all-clear when something is actually polling (Vikunja #540), and the
|
||||
// other tests here are about attention rather than about source health.
|
||||
return newFakePraxisWithSources(t, attentionBody, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
}
|
||||
|
||||
// newFakePraxisWithSources is newFakePraxis with the /api/v1/sources body
|
||||
// under the test's control, for the degraded and no-sources hedges.
|
||||
func newFakePraxisWithSources(t *testing.T, attentionBody, sourcesBody string) *fakeServer {
|
||||
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||
"GET /api/v1/sources": jsonHandler(http.StatusOK, sourcesBody),
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
|
||||
@@ -29,12 +29,12 @@ func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHa
|
||||
}
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
recall: recallWiring{embedder: nil},
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
embedder: nil,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||
if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
|
||||
if !strings.Contains(reply, "ничего") {
|
||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||
}
|
||||
}
|
||||
@@ -87,22 +87,16 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||
// answered by the model inventing a bulletin.
|
||||
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)
|
||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||
if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||
if !ok || !strings.Contains(reply, "не настроены") {
|
||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
on := buildFeedHandler(t, true)
|
||||
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
||||
if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
|
||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||
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) {
|
||||
@@ -200,22 +194,3 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-160
@@ -1,66 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// voiceDialogueID — the dialogue-session and clarify key for the microphone.
|
||||
// This is a single-user box (ponytail), so one slot per reach suffices; a
|
||||
// second speaker would need per-speaker ids, which waits on voice-print
|
||||
// attribution (see PROGRESS multi-user deferral).
|
||||
// voiceDialogueID — the single dialogue-session key. This is a single-user box
|
||||
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids,
|
||||
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
|
||||
const voiceDialogueID = "voice"
|
||||
|
||||
// textDialogueID — the clarify key for a text turn that named no conversation.
|
||||
// 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
|
||||
@@ -72,16 +23,15 @@ func dialogueIDOf(ctx context.Context) string {
|
||||
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
|
||||
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
||||
return dialogue.Slots{
|
||||
Time: s.Time,
|
||||
HasTime: s.HasTime,
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
HasKey: s.HasKey,
|
||||
Text: s.Text,
|
||||
Fn: s.Fn,
|
||||
Args: s.Args,
|
||||
HasFn: s.HasFn,
|
||||
ResolvedBy: string(s.ResolvedBy),
|
||||
Time: s.Time,
|
||||
HasTime: s.HasTime,
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
HasKey: s.HasKey,
|
||||
Text: s.Text,
|
||||
Fn: s.Fn,
|
||||
Args: s.Args,
|
||||
HasFn: s.HasFn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,25 +41,20 @@ func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
||||
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
|
||||
base.Text = d.Text
|
||||
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
|
||||
base.ResolvedBy = router.ActionResolutionMethod(d.ResolvedBy)
|
||||
return base
|
||||
}
|
||||
|
||||
// anaphoraResolver is a shared instance for pronoun detection.
|
||||
var anaphoraResolver router.AnaphoraResolver
|
||||
|
||||
// followUpMerge carries the current conversation across a prior non-expired
|
||||
// session — the multi-turn seam. It handles four cases:
|
||||
// followUpMerge fills the current turn's missing slots from a prior
|
||||
// non-expired session — the multi-turn seam. It handles three cases:
|
||||
//
|
||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior),
|
||||
// except a reminder time the current sentence named and the parser missed.
|
||||
// 2. An anaphoric query becomes chat. A question whose subject lives in this
|
||||
// conversation is answered from its transcript, not sent through unrelated
|
||||
// note, web and encyclopedia sources as a context-free lookup.
|
||||
// 3. Cross-intent anaphora: if the current utterance contains a pronoun
|
||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior).
|
||||
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
|
||||
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
|
||||
// the key for fact-lookup queries and reminder creation.
|
||||
// 4. Query after Fact: a query that references the prior fact's subject
|
||||
// 3. Query after Fact: a query that references the prior fact's subject
|
||||
// inherits the key so the handler can do a fact-by-key lookup.
|
||||
//
|
||||
// A clarify turn resolves nothing, so it never inherits. InheritSlots only
|
||||
@@ -119,56 +64,16 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r
|
||||
return dec
|
||||
}
|
||||
|
||||
ref, isAnaphoric := anaphoraResolver.Resolve(dec.Utterance)
|
||||
if dec.Intent == router.IntentQuery && isAnaphoric && ref != "mine" && sessionHasContext(prev) {
|
||||
// The router correctly identified a question. What it cannot know from
|
||||
// one utterance is that its subject is in the live dialogue. Chat is the
|
||||
// only action path that receives that dialogue, so preserve the route's
|
||||
// slots but answer it there. Clear query-only provenance: no query source
|
||||
// was selected and an anchored destination must not survive an intent
|
||||
// change the daemon made from state the router could not see.
|
||||
dec.Intent = router.IntentChat
|
||||
dec.Source = router.SourceUnknown
|
||||
dec.SourceAnchored = false
|
||||
// Keep a structured referent when the prior route had one. The chat
|
||||
// phraser primarily reads the transcript, but the session must not lose
|
||||
// the fact identity merely because one follow-up crossed an intent.
|
||||
if !dec.Slots.HasKey && prev.Slots.HasKey {
|
||||
dec.Slots.Key = prev.Slots.Key
|
||||
dec.Slots.HasKey = true
|
||||
}
|
||||
if dec.Slots.Value == "" {
|
||||
dec.Slots.Value = prev.Slots.Value
|
||||
}
|
||||
if !dec.Slots.HasTime && prev.Slots.HasTime {
|
||||
dec.Slots.Time = prev.Slots.Time
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
}
|
||||
|
||||
// Case 1: same-intent inheritance (existing).
|
||||
if prev.Intent == dialogue.Intent(dec.Intent) {
|
||||
// A reminder that named an hour nobody could read must not borrow the
|
||||
// last one's. Two reminders in a row and the second landed at the
|
||||
// first's time, confirmed as if it had been read from the sentence:
|
||||
// "напомни без четверти восемь выходить" fired at 07:30 (V-543). The
|
||||
// hour is also what fills before the action's own fallback parse can
|
||||
// run, so inheriting it hid a time that did parse.
|
||||
//
|
||||
// Inheriting is still right when the sentence names no time at all,
|
||||
// which is the follow-up this seam exists for.
|
||||
blockTime := dec.Intent == router.IntentReminder &&
|
||||
!dec.Slots.HasTime && router.MentionsTime(dec.Utterance)
|
||||
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
|
||||
dec.Slots = applyDialogueSlots(dec.Slots, merged)
|
||||
if blockTime {
|
||||
dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false
|
||||
}
|
||||
return dec
|
||||
}
|
||||
|
||||
// Cases 3 & 4: cross-intent anaphora + query-after-fact.
|
||||
// Cases 2 & 3: cross-intent anaphora + query-after-fact.
|
||||
// A query after a fact may reference the fact's subject by pronoun.
|
||||
_, isAnaphoric := anaphoraResolver.Resolve(dec.Utterance)
|
||||
if !isAnaphoric && !dec.Slots.HasKey {
|
||||
// No anaphora and no explicit key — this is a truly new topic.
|
||||
return dec
|
||||
@@ -194,49 +99,3 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r
|
||||
|
||||
return dec
|
||||
}
|
||||
|
||||
// sessionHasContext distinguishes a live transcript from a session that only
|
||||
// carries timing/candidate bookkeeping. The raw utterance is the primary
|
||||
// source. The slot fallback keeps sessions persisted by older binaries useful
|
||||
// after an upgrade: those blobs have no Utterance field, but may still carry
|
||||
// the exact turn in Text or a structured fact key/value.
|
||||
func sessionHasContext(s *dialogue.Session) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
return s.Utterance != "" || s.Slots.Text != "" || s.Slots.HasKey || s.Slots.Value != ""
|
||||
}
|
||||
|
||||
// opensConversation recognises an explicit cooperative opener without making
|
||||
// it a competing route. The substantive clause after the colon may still be a
|
||||
// fact worth storing; this function only chooses the session's lifetime.
|
||||
//
|
||||
// The marker is grammatical and closed (Russian давай/давайте, English let's),
|
||||
// and the action vocabulary lives in lexicon rather than a substring pattern.
|
||||
// A bare chat route needs none of this — rememberTurn marks it conversational
|
||||
// from its intent. This catches the compound shape whose fact clause otherwise
|
||||
// hides the opener from the single-intent router.
|
||||
func opensConversation(text string) bool {
|
||||
tokens := quietTokens(text)
|
||||
if len(tokens) < 2 {
|
||||
return false
|
||||
}
|
||||
from := 1
|
||||
switch {
|
||||
case tokens[0] == "давай" || tokens[0] == "давайте":
|
||||
case tokens[0] == "lets":
|
||||
case len(tokens) >= 3 && tokens[0] == "let" && tokens[1] == "s":
|
||||
from = 2
|
||||
default:
|
||||
return false
|
||||
}
|
||||
verbs := lexicon.ConversationVerbs()
|
||||
for _, token := range tokens[from:] {
|
||||
for _, verb := range verbs {
|
||||
if token == verb || morph.SameWord(token, verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -35,42 +35,6 @@ func TestFollowUpMerge(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// V-543, measured on the box: four reminders in a row all landed at the
|
||||
// first one's hour, each confirmed as if it had been read from the sentence.
|
||||
// A sentence that names a time and fails to parse must ask, not borrow.
|
||||
t.Run("a named time that did not parse is not inherited", func(t *testing.T) {
|
||||
for _, utt := range []string{
|
||||
"напомни без четверти восемь выходить",
|
||||
"напомни в половине первого пообедать",
|
||||
"напомни завтра принять лекарство",
|
||||
"remind me at noon to stretch",
|
||||
} {
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentReminder,
|
||||
Utterance: utt,
|
||||
Slots: router.Slots{Text: utt},
|
||||
}
|
||||
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
||||
if got.Slots.HasTime {
|
||||
t.Errorf("%q borrowed the previous hour %v", utt, got.Slots.Time)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The follow-up this seam exists for still works: the sentence names no
|
||||
// time, so the previous one is the only one it could mean.
|
||||
t.Run("a follow-up naming no time still inherits", func(t *testing.T) {
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentReminder,
|
||||
Utterance: "и ещё полить цветы",
|
||||
Slots: router.Slots{Text: "полить цветы"},
|
||||
}
|
||||
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
||||
if !got.Slots.HasTime || !got.Slots.Time.Equal(fireAt) {
|
||||
t.Errorf("time not inherited: HasTime=%v Time=%v", got.Slots.HasTime, got.Slots.Time)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("current slot wins over prior (gaps only)", func(t *testing.T) {
|
||||
own := base.Add(48 * time.Hour)
|
||||
cur := router.Decision{
|
||||
@@ -138,7 +102,6 @@ func TestFollowUpMerge(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentFact,
|
||||
Slots: dialogue.Slots{Key: "water", HasKey: true},
|
||||
Utterance: "я выпил воду",
|
||||
Timestamp: base,
|
||||
TTL: 2 * time.Minute,
|
||||
}
|
||||
@@ -153,72 +116,6 @@ func TestFollowUpMerge(t *testing.T) {
|
||||
if got.Slots.Key != "water" {
|
||||
t.Errorf("query after fact: got key=%q, want water", got.Slots.Key)
|
||||
}
|
||||
if got.Intent != router.IntentChat {
|
||||
t.Errorf("anaphoric query intent = %s, want chat with dialogue context", got.Intent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anaphoric query after unkeyed query uses raw dialogue context", func(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentQuery,
|
||||
Slots: dialogue.Slots{Text: "кто изобрёл телефон?"},
|
||||
Utterance: "кто изобрёл телефон?",
|
||||
Timestamp: base,
|
||||
TTL: 2 * time.Minute,
|
||||
}
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "а когда он это сделал?",
|
||||
Source: router.SourceWorld,
|
||||
SourceAnchored: true,
|
||||
}
|
||||
got := followUpMerge(prior, cur, base.Add(30*time.Second))
|
||||
if got.Intent != router.IntentChat {
|
||||
t.Fatalf("intent = %s, want chat", got.Intent)
|
||||
}
|
||||
if got.Source != router.SourceUnknown || got.SourceAnchored {
|
||||
t.Errorf("query-only source survived contextual chat: source=%s anchored=%v", got.Source, got.SourceAnchored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anaphora without a usable prior session stays routed", func(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentQuery,
|
||||
Timestamp: base,
|
||||
TTL: 2 * time.Minute,
|
||||
}
|
||||
cur := router.Decision{Intent: router.IntentQuery, Utterance: "что это?"}
|
||||
got := followUpMerge(prior, cur, base.Add(30*time.Second))
|
||||
if got.Intent != router.IntentQuery {
|
||||
t.Errorf("empty session changed intent to %s", got.Intent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("possessive determiner does not turn an explicit query into chat", func(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentChat, Utterance: "привет",
|
||||
Timestamp: base, TTL: 2 * time.Minute,
|
||||
}
|
||||
cur := router.Decision{Intent: router.IntentQuery, Utterance: "где мой телефон?"}
|
||||
got := followUpMerge(prior, cur, base.Add(30*time.Second))
|
||||
if got.Intent != router.IntentQuery {
|
||||
t.Errorf("explicit possessive query changed intent to %s", got.Intent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anaphoric act is never widened into chat", func(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentChat, Utterance: "сервер homesrv",
|
||||
Timestamp: base, TTL: 2 * time.Minute,
|
||||
}
|
||||
cur := router.Decision{Intent: router.IntentAct, Utterance: "выключи его"}
|
||||
got := followUpMerge(prior, cur, base.Add(30*time.Second))
|
||||
if got.Intent != router.IntentAct {
|
||||
t.Errorf("act intent changed to %s", got.Intent)
|
||||
}
|
||||
if got.Slots.HasFn {
|
||||
t.Error("anaphora invented an executable function")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query after fact without anaphora does not inherit", func(t *testing.T) {
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
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 "}
|
||||
|
||||
// historySide — whose turn the question asks about. The rows read are the same
|
||||
// either way, because a tapped fact is one act seen from two sides, but the
|
||||
// sentence is not: answering "что ты записала сегодня?" with "ты говорил…"
|
||||
// hands the question back instead of answering it (Vikunja #456).
|
||||
type historySide int
|
||||
|
||||
const (
|
||||
historyAskedHim historySide = iota // "что я тебе говорил"
|
||||
historyAskedHer // "что ты записала сегодня"
|
||||
)
|
||||
|
||||
// isHistoryQuery reports whether he is asking what he told her.
|
||||
func isHistoryQuery(u string) bool {
|
||||
_, ok := historyAsks(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// historyAsks reports whether this is a history question, and whose turn it is
|
||||
// about.
|
||||
func historyAsks(u string) (historySide, bool) {
|
||||
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
|
||||
if s == " " {
|
||||
return historyAskedHim, false
|
||||
}
|
||||
for _, r := range historyRecall {
|
||||
if strings.Contains(s, r) {
|
||||
return historyAskedHim, false
|
||||
}
|
||||
}
|
||||
for _, pair := range historyMarkersEn {
|
||||
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||
if strings.Contains(pair[0], "you") {
|
||||
return historyAskedHer, true
|
||||
}
|
||||
return historyAskedHim, true
|
||||
}
|
||||
}
|
||||
toks := historyTokens(s)
|
||||
if !hasAny(toks, "что", "чего") {
|
||||
return historyAskedHim, false
|
||||
}
|
||||
// His side is tested first: "отмечать" is on both verb lists, so "что я
|
||||
// отметил" must not read as a question about her.
|
||||
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
|
||||
return historyAskedHim, true
|
||||
}
|
||||
if hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) {
|
||||
return historyAskedHer, true
|
||||
}
|
||||
return historyAskedHim, false
|
||||
}
|
||||
|
||||
// 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) {
|
||||
side, ok := historyAsks(t.dec.Utterance)
|
||||
if !ok {
|
||||
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.
|
||||
if side == historyAskedHer {
|
||||
return "за последние сутки я ничего с твоих слов не записывала.", true
|
||||
}
|
||||
return "за последние сутки ты мне ничего такого не говорил.", true
|
||||
}
|
||||
if side == historyAskedHer {
|
||||
return "я записала: " + strings.Join(said, "; "), 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"))
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// The rows are the same either way, because a tapped fact is one act seen from
|
||||
// two sides. The sentence is not: "что ты записала" answered with "ты говорил"
|
||||
// hands the question back (Vikunja #456).
|
||||
func TestHistoryAnswersTheSideItWasAsked(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
h, _ := historyHandler(now, ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)})
|
||||
|
||||
his, ok := askHistory(h, "что я тебе говорил?")
|
||||
if !ok || !strings.HasPrefix(his, "ты говорил") {
|
||||
t.Errorf("reply = %q, ok = %v, want his side", his, ok)
|
||||
}
|
||||
hers, ok := askHistory(h, "что ты записала сегодня?")
|
||||
if !ok || !strings.HasPrefix(hers, "я записала") {
|
||||
t.Errorf("reply = %q, ok = %v, want her side", hers, ok)
|
||||
}
|
||||
// "отмечать" is on both verb lists, so his subject has to win.
|
||||
if side, ok := historyAsks("что я отметил?"); !ok || side != historyAskedHim {
|
||||
t.Errorf("historyAsks(что я отметил) = %v, %v", side, ok)
|
||||
}
|
||||
|
||||
empty, _ := historyHandler(now)
|
||||
none, ok := askHistory(empty, "что ты записала сегодня?")
|
||||
if !ok || !strings.Contains(none, "не записывала") {
|
||||
t.Errorf("empty reply = %q, ok = %v, want her side", none, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasCyrillic(t *testing.T) {
|
||||
for _, s := range []string{"что такое фотосинтез", "кто такой Elon Musk", "фотосинтез"} {
|
||||
if !hasCyrillic(s) {
|
||||
t.Errorf("hasCyrillic(%q) = false; it is a Russian question", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"what is photosynthesis", "", "3:2"} {
|
||||
if hasCyrillic(s) {
|
||||
t.Errorf("hasCyrillic(%q) = true; there is no Cyrillic in it", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The book choice and the rewrite decision are the same decision: a Russian
|
||||
// book reads his question as he asked it, an English one needs it translated
|
||||
// into keywords first (V-508).
|
||||
func TestKiwixBookChoice(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wiring kiwixWiring
|
||||
utterance string
|
||||
wantBook string
|
||||
wantVerb bool
|
||||
}{
|
||||
{"a russian question reads the russian book verbatim",
|
||||
kiwixWiring{book: "en", bookRU: "ru"}, "что такое фотосинтез", "ru", true},
|
||||
{"an english question reads the english book",
|
||||
kiwixWiring{book: "en", bookRU: "ru"}, "what is photosynthesis", "en", false},
|
||||
{"no russian book configured leaves every question on the english one",
|
||||
kiwixWiring{book: "en"}, "что такое фотосинтез", "en", false},
|
||||
} {
|
||||
book, verbatim := tc.wiring.book, false
|
||||
if tc.wiring.bookRU != "" && hasCyrillic(tc.utterance) {
|
||||
book, verbatim = tc.wiring.bookRU, true
|
||||
}
|
||||
if book != tc.wantBook || verbatim != tc.wantVerb {
|
||||
t.Errorf("%s: book=%q verbatim=%v, want %q/%v", tc.name, book, verbatim, tc.wantBook, tc.wantVerb)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-10
@@ -19,12 +19,8 @@ type kiwixWiring struct {
|
||||
client *kiwix.Client
|
||||
rewriter *kiwix.Rewriter // nil ⇒ the question is searched verbatim
|
||||
book string
|
||||
// bookRU — searched instead of book when the question is Cyrillic, and
|
||||
// searched verbatim because it is in his language already (V-508). Empty ⇒
|
||||
// every question goes to book.
|
||||
bookRU string
|
||||
max int
|
||||
runes int
|
||||
max int
|
||||
runes int
|
||||
}
|
||||
|
||||
// wireKiwix builds the ZIM reader from the `kiwix` block, or returns nil when
|
||||
@@ -42,13 +38,9 @@ func wireKiwix(cfg *config.Config, c *llm.Client) *kiwixWiring {
|
||||
w := &kiwixWiring{
|
||||
client: kiwix.New(kc.URL),
|
||||
book: kc.Book,
|
||||
bookRU: kc.BookRU,
|
||||
max: kc.MaxResults,
|
||||
runes: kc.SnippetRunes,
|
||||
}
|
||||
if kc.BookRU != "" {
|
||||
log.Printf("voice: kiwix: russian questions read %q verbatim", kc.BookRU)
|
||||
}
|
||||
switch {
|
||||
case !kc.RewriteEnabled():
|
||||
log.Printf("voice: kiwix at %s (book %q, query rewriting off by config)", kc.URL, kc.Book)
|
||||
|
||||
+282
-214
@@ -68,11 +68,6 @@ import (
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// stepUpTTL is how long one passkey assertion keeps the session stepped up.
|
||||
// Long enough for the unlock call that follows it, short enough that a walked
|
||||
// away laptop does not stay authorized.
|
||||
const stepUpTTL = 5 * time.Minute
|
||||
|
||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||
@@ -132,12 +127,8 @@ func run(args []string) error {
|
||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||
wipe := flag.Bool("wipe", false, "print every table and its row count, then exit without serving; add -confirm-wipe to delete all of it")
|
||||
confirmWipe := flag.Bool("confirm-wipe", false, "with -wipe, actually remove every piece of personal data (facts, notes, vectors, events, tasks, sessions, traces, voiceprints). config, models, passkeys and the encryption key are files and survive")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
allowSeedOnStart = *allowSeed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -217,14 +208,6 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- wipe: never serves, exits when it is done (Vikunja #494) -----
|
||||
if *wipe {
|
||||
if locked {
|
||||
return fmt.Errorf("wipe: the store is locked and there is no key to open it with")
|
||||
}
|
||||
return runWipe(ctx, st, os.Stdout, *confirmWipe)
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
// Pre-declare so the unlock path can wire them later.
|
||||
var (
|
||||
@@ -252,31 +235,43 @@ func run(args []string) error {
|
||||
// envelope per successful intake write.
|
||||
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||
|
||||
// depsNow reads whatever the current path has wired. Both boot paths build
|
||||
// the CoreAPI and start the workers from this one value, so neither can
|
||||
// hold a field the other misses. See cmd/mavend/boot.go.
|
||||
depsNow := func() bootDeps {
|
||||
return bootDeps{
|
||||
coreFor: coreFor,
|
||||
tl: tl,
|
||||
evBus: evBus,
|
||||
voiceW: voiceW,
|
||||
st: st,
|
||||
factWorker: factWorker,
|
||||
evalWorker: evalWorker,
|
||||
feedWkr: feedWkr,
|
||||
crawlWkr: crawlWkr,
|
||||
}
|
||||
}
|
||||
|
||||
if !locked {
|
||||
rules = wireRules(cfg)
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
|
||||
// phraser
|
||||
phr, err = wirePhraser(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = "127.0.0.1:0"
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = 2048
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = 30 * time.Second
|
||||
}
|
||||
var err error
|
||||
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ecosystem — nexus + hexis + praxis (all over HTTP; no direct DB access)
|
||||
@@ -289,19 +284,70 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
// delivery
|
||||
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||
if err != nil {
|
||||
return err
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
// A crashed prior run may have left "pending" delivery attempts (send
|
||||
// may have landed externally, then the process died before recording
|
||||
// it) — reconcile them to "unknown" before the tick loop resumes
|
||||
// sending, so nothing auto-resends into that ambiguity.
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
dispatcher = delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
})
|
||||
|
||||
// tick loop
|
||||
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
coreAPI = newDaemonAPI(depsNow())
|
||||
coreAPI = &daemonAPI{
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
api.chatFn = voiceW.handler.handleText
|
||||
// And the reverse: the handler was wired with the bare store
|
||||
// adapter, which cannot serve the day plan. See upgradeAPI.
|
||||
voiceW.handler.upgradeAPI(api)
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
|
||||
}
|
||||
} else {
|
||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
||||
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||
@@ -318,7 +364,7 @@ func run(args []string) error {
|
||||
return fmt.Errorf("ipc listen: %w", err)
|
||||
}
|
||||
|
||||
passkeySess := webauthn.NewPasskeySession(stepUpTTL)
|
||||
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
||||
|
||||
// Set Server.Check — the single authorization guard, run once by
|
||||
// Server.dispatch before any CoreAPI method is called (see
|
||||
@@ -362,9 +408,6 @@ func run(args []string) error {
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Inbound telegram (V-637). Dark unless the telegram block says intake,
|
||||
// and it reads one chat.
|
||||
wireTelegramIntake(ctx, &wg, coreAPI, cfg)
|
||||
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
||||
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
@@ -469,11 +512,39 @@ func run(args []string) error {
|
||||
|
||||
// Wire everything.
|
||||
rules = wireRules(cfg)
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
|
||||
phr, err = wirePhraser(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = "127.0.0.1:0"
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = 2048
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = 30 * time.Second
|
||||
}
|
||||
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
eco = wireEcosystem(cfg)
|
||||
@@ -483,26 +554,64 @@ func run(args []string) error {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
|
||||
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||
if err != nil {
|
||||
return err
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
dispatcher = delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
})
|
||||
|
||||
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
newAPI := newDaemonAPI(depsNow())
|
||||
newAPI := &daemonAPI{
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
voiceW.handler.upgradeAPI(newAPI)
|
||||
}
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Same on the unlock path, with the API that has just replaced the
|
||||
// locked placeholder (V-637).
|
||||
wireTelegramIntake(ctx, &wg, newAPI, cfg)
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
@@ -510,10 +619,59 @@ func run(args []string) error {
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
|
||||
// The voice server and every background worker, on the outer wg
|
||||
// so shutdown waits for them. This used to be nine bare
|
||||
// `go func()` calls and a shadowed WaitGroup (V-639).
|
||||
startBackground(ctx, &wg, depsNow())
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
||||
}
|
||||
|
||||
// Start tick loop.
|
||||
go func() {
|
||||
tl.run(ctx)
|
||||
}()
|
||||
|
||||
// Start fact-entity enrichment worker.
|
||||
go func() {
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
|
||||
// Start background memory evaluation (nil unless configured).
|
||||
if evalWorker != nil {
|
||||
go func() {
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start feed reading (nil unless configured).
|
||||
if feedWkr != nil {
|
||||
go func() {
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start the watched-page crawls (nil unless configured).
|
||||
if crawlWkr != nil {
|
||||
go func() {
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Keep MCP connections alive (nil unless configured).
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
go voiceW.mcp.run(ctx)
|
||||
}
|
||||
|
||||
// Re-enumerate the house for new devices (nil unless configured).
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
go voiceW.home.run(ctx)
|
||||
}
|
||||
|
||||
dl.unlock(st)
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
@@ -521,15 +679,72 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
goWorker(&wg, func() {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := srv.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("ipc serve: %v", err)
|
||||
}
|
||||
})
|
||||
}()
|
||||
log.Printf("mavend: ipc listening on %s", srv.Path())
|
||||
|
||||
if !locked && voiceW != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
||||
}
|
||||
|
||||
if !locked {
|
||||
startBackground(ctx, &wg, depsNow())
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tl.run(ctx)
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
if evalWorker != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
if feedWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if crawlWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.mcp.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.home.run(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
@@ -559,7 +774,7 @@ func run(args []string) error {
|
||||
func personaFacts(cfg *config.Config) persona.Facts {
|
||||
f := persona.Facts{
|
||||
// Telegram lives outside the voice block, so it counts either way.
|
||||
Telegram: cfg.Telegram != nil && !cfg.Telegram.Disabled && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
Telegram: cfg.Telegram != nil && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
}
|
||||
if cfg.Voice == nil {
|
||||
return f
|
||||
@@ -573,22 +788,6 @@ func personaFacts(cfg *config.Config) persona.Facts {
|
||||
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.
|
||||
// Per turn, not once at startup, because the block states the current time.
|
||||
func contextBlockFn(cfg *config.Config, now func() time.Time) func() string {
|
||||
@@ -616,137 +815,6 @@ func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Phraser defaults, applied when the config block leaves a field unset. They
|
||||
// are the daemon's, not the library's: phraser.Config carries no defaults of
|
||||
// its own, so an empty field here would reach llama-server as an empty flag.
|
||||
const (
|
||||
defaultLlamaBin = "llama-server"
|
||||
defaultPhraserListen = "127.0.0.1:0"
|
||||
defaultPhraserNCtx = 2048
|
||||
defaultPhraserTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// wirePhraser builds the phrasing seam. No phraser block means the
|
||||
// deterministic stub, which is the floor and not an error: the daemon answers
|
||||
// without a model, in fixed words.
|
||||
func wirePhraser(ctx context.Context, cfg *config.Config) (phraser.Phraser, error) {
|
||||
if cfg.Phraser == nil {
|
||||
return phraser.NewStub(), nil
|
||||
}
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = defaultLlamaBin
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = defaultPhraserListen
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = defaultPhraserNCtx
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = defaultPhraserTimeout
|
||||
}
|
||||
return phraser.NewLLMPhraser(ctx, pc)
|
||||
}
|
||||
|
||||
// wireGatherer builds the nudge gatherer over the given rule set and applies
|
||||
// the configured quiet hours.
|
||||
func wireGatherer(st *store.Store, cfg *config.Config, rules []loop.Rule) *loop.Gatherer {
|
||||
g := loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
g.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// wireDispatcher builds the delivery fan-out. Each sink stays nil unless its
|
||||
// config block is present, and a sink that fails to build fails the boot
|
||||
// rather than going quiet.
|
||||
//
|
||||
// A crashed prior run may have left "pending" delivery attempts (send may have
|
||||
// landed externally, then the process died before recording it). They are
|
||||
// reconciled to "unknown" here, before the tick loop resumes sending, so
|
||||
// nothing auto-resends into that ambiguity.
|
||||
func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*delivery.Dispatcher, error) {
|
||||
ntfy, err := wireNtfySink(cfg.Ntfy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil && !cfg.Telegram.Disabled {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
return delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// wireNtfySink keeps an optional reach optional without ever turning a missing
|
||||
// secret into anonymous publishing. A block is live unless it says disabled;
|
||||
// therefore an expanded-empty token in a live block fails startup instead of
|
||||
// spending days in a permanent 403 retry loop. Disabled is an explicit
|
||||
// operator choice and lets another away reach take over.
|
||||
func wireNtfySink(cfg *ntfysink.Config) (delivery.Sink, error) {
|
||||
if cfg == nil || cfg.Disabled {
|
||||
return nil, nil
|
||||
}
|
||||
sink, err := ntfysink.New(*cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
return sink, nil
|
||||
}
|
||||
|
||||
// wireTickLoop reads the loop's three intervals and its schedules out of the
|
||||
// config, so the two boot paths cannot disagree about them.
|
||||
func wireTickLoop(st *store.Store, gatherer *loop.Gatherer, dispatcher *delivery.Dispatcher, phr phraser.Phraser, rules []loop.Rule, cfg *config.Config) *tickLoop {
|
||||
return newTickLoop(st, gatherer, dispatcher, phr, rules,
|
||||
time.Duration(cfg.TickInterval),
|
||||
time.Duration(cfg.RepeatInterval),
|
||||
time.Duration(cfg.AutotuneInterval),
|
||||
cfg.Digest,
|
||||
routinesFromConfig(cfg.Routines),
|
||||
config.MorningRoutinesFromConfig(cfg.MorningRoutines),
|
||||
cfg.PatternProposals)
|
||||
}
|
||||
|
||||
// goWorker starts run on its own goroutine and registers it with wg, so
|
||||
// shutdown can wait for it inside workerGrace.
|
||||
func goWorker(wg *sync.WaitGroup, run func()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run()
|
||||
}()
|
||||
}
|
||||
|
||||
// wireRules builds the nudge rule set, minus anything config turned off. The
|
||||
// drop is logged because a rule vanishing silently is indistinguishable from a
|
||||
// rule that is broken, and the next person to wonder why she stopped nudging
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"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
|
||||
@@ -107,7 +106,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
return phraser.Q(phraser.QueryFailNetscan, nil), true
|
||||
return "не получилось просканировать сеть.", true
|
||||
}
|
||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||
// устройств" after stopping two thirds of the way through the range is a
|
||||
@@ -117,9 +116,9 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
if len(res.Hosts) == 0 {
|
||||
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
out += ", " + shape
|
||||
}
|
||||
@@ -180,7 +179,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
if w.api == nil {
|
||||
return
|
||||
}
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if res.Truncated {
|
||||
head += " (не вся сеть)"
|
||||
}
|
||||
@@ -211,6 +210,22 @@ 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
|
||||
// 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
|
||||
|
||||
+12
-21
@@ -10,8 +10,6 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
@@ -76,6 +74,18 @@ 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) {
|
||||
yes := []string{
|
||||
"какие устройства в сети?",
|
||||
@@ -159,22 +169,3 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// correctionFragment reports that an utterance replaces a referent and states
|
||||
// nothing of its own: "нет, не маме, а папе" (V-576).
|
||||
//
|
||||
// Measured on the box 2026-08-06, that fragment routed to note and was filed.
|
||||
// It is not a repair either, because it names no intent, so parseRepair
|
||||
// declines it and repair.go is the wrong place to catch it. This is the note
|
||||
// path saying it has nothing to store.
|
||||
//
|
||||
// Three offline tests, all of them narrow on purpose. The sentence opens with a
|
||||
// refusal word from the lexicon, it carries the contrastive "а" that names the
|
||||
// replacement, and no token in it is a verb form. The verb test is what keeps
|
||||
// the rule off real notes: "нет, я не поеду, а останусь" says something, and a
|
||||
// Russian verb carries its own subject and tense.
|
||||
func correctionFragment(utterance string) bool {
|
||||
toks := repairTokens(strings.ToLower(strings.TrimSpace(utterance)))
|
||||
if len(toks) < 3 {
|
||||
return false
|
||||
}
|
||||
if !refusalWord(toks[0]) {
|
||||
return false
|
||||
}
|
||||
var negated, contrasted bool
|
||||
for _, tok := range toks[1:] {
|
||||
switch tok {
|
||||
case "не", "not":
|
||||
negated = true
|
||||
case "а", "but":
|
||||
contrasted = true
|
||||
}
|
||||
if morph.IsVerbForm(tok) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return negated && contrasted
|
||||
}
|
||||
|
||||
// refusalWord reports that a token is a one-word refusal. The lexicon set holds
|
||||
// phrases too ("не надо"), and those are not what opens a correction.
|
||||
func refusalWord(tok string) bool {
|
||||
for _, w := range lexicon.ConfirmNo() {
|
||||
if strings.ContainsRune(w, ' ') {
|
||||
continue
|
||||
}
|
||||
if w == tok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
func TestCorrectionFragment(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want bool
|
||||
}{
|
||||
{"нет, не маме, а папе", true},
|
||||
{"Нет, не маме — а папе", true},
|
||||
{"no, not mom, but dad", true},
|
||||
// States something of its own, so it is his to keep.
|
||||
{"нет, я не поеду, а останусь дома", false},
|
||||
{"нет", false},
|
||||
{"не маме, а папе", false}, // no refusal word opening it
|
||||
{"нет, маме и папе", false}, // nothing negated
|
||||
{"нет, не маме", false}, // nothing put in its place
|
||||
{"запомни что кофе закончился", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := correctionFragment(c.utterance); got != c.want {
|
||||
t.Errorf("correctionFragment(%q) = %v, want %v", c.utterance, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newNoteHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestNoteBodyIsTheUtterance — the stored body comes from the utterance, never
|
||||
// from Slots.Text, which the LLM router is free to write anything into (V-576).
|
||||
func TestNoteBodyIsTheUtterance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentNote,
|
||||
Utterance: "купил хлеб и молоко",
|
||||
Slots: router.Slots{Text: "ты поедешь на дачу"},
|
||||
}
|
||||
if reply := h.applyAction(ctx, dec); reply != "" {
|
||||
t.Fatalf("applyAction = %q, want empty", reply)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 || notes[0].Text != dec.Utterance {
|
||||
t.Fatalf("stored note = %+v, want body %q", notes, dec.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoteBodyIsStable — the same utterance twice stores the same text.
|
||||
func TestNoteBodyIsStable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{Intent: router.IntentNote, Utterance: "кофе закончился"}
|
||||
h.applyAction(ctx, dec)
|
||||
h.applyAction(ctx, dec)
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 2 {
|
||||
t.Fatalf("notes = %d, want 2", len(notes))
|
||||
}
|
||||
if notes[0].Text != notes[1].Text || notes[0].Text != dec.Utterance {
|
||||
t.Fatalf("bodies differ: %q vs %q", notes[0].Text, notes[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectionFragmentWritesNoNote — a correction with nothing behind it is
|
||||
// not a note, and she says so instead of filing it (V-576).
|
||||
func TestCorrectionFragmentWritesNoNote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{Intent: router.IntentNote, Utterance: "нет, не маме, а папе"}
|
||||
if reply := h.applyAction(ctx, dec); reply != nothingToCorrectReply {
|
||||
t.Fatalf("reply = %q, want %q", reply, nothingToCorrectReply)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %+v, want none", notes)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||
)
|
||||
|
||||
func TestWireNtfySinkRejectsMissingCredentialWhenEnabled(t *testing.T) {
|
||||
_, err := wireNtfySink(&ntfysink.Config{
|
||||
BaseURL: "https://ntfy.example", Topic: "maven",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expanded-empty credential did not fail an enabled reach")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireNtfySinkLeavesExplicitlyDisabledReachDark(t *testing.T) {
|
||||
sink, err := wireNtfySink(&ntfysink.Config{
|
||||
Disabled: true, BaseURL: "https://ntfy.example", Topic: "maven",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("wireNtfySink: %v", err)
|
||||
}
|
||||
if sink != nil {
|
||||
t.Fatal("disabled reach built a live sink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireNtfySinkRejectsMalformedEnabledConfig(t *testing.T) {
|
||||
_, err := wireNtfySink(&ntfysink.Config{Token: "token", Topic: "maven"})
|
||||
if err == nil {
|
||||
t.Fatal("malformed enabled config did not fail wiring")
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestMain holds one ONNX Runtime lease across the model-aware topic and
|
||||
// personal-boundary gates. Each test still owns and closes its model session;
|
||||
// the process-global environment is released only after the final test.
|
||||
func TestMain(m *testing.M) {
|
||||
var lease *router.ONNXRuntimeLease
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib != "" {
|
||||
if _, err := os.Stat(lib); err == nil {
|
||||
var acquireErr error
|
||||
lease, acquireErr = router.AcquireONNXRuntime(lib)
|
||||
if acquireErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "initialize shared ONNX test runtime: %v\n", acquireErr)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
if lease != nil {
|
||||
if err := lease.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ONNX test runtime cleanup: %v\n", err)
|
||||
code = 1
|
||||
}
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"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.
|
||||
|
||||
// The position words come from the lexicon, which lists every form with its
|
||||
// position and "последний" as -1 (V-522). They used to be stem prefixes here —
|
||||
// {"перв", 1}, {"втор", 2} — which is the shape that sweep removed: a stem
|
||||
// decides meaning by guessing where a word ends, and "трет" also opens
|
||||
// "third-party". The lexicon runs to twelve rather than five, so he can pick
|
||||
// past the fifth of a longer list; resolveCandidate already answers a position
|
||||
// she did not read.
|
||||
|
||||
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
|
||||
// "15" starts with "1" and is a time, not a position. Digits are not a Russian
|
||||
// word list, so they stay here rather than in the lexicon.
|
||||
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
|
||||
|
||||
// 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.
|
||||
toks := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
for i, tok := range toks {
|
||||
if n, ok := candidateDigits[tok]; ok {
|
||||
return n, true
|
||||
}
|
||||
// A spoken half hour names the hour it is entering with the same
|
||||
// genitive ordinal: "в половине восьмого" is 07:30, not the eighth
|
||||
// thing she read out. She reads a list and he answers with a time
|
||||
// often enough that this has to be declined here, or the reminder
|
||||
// becomes a selection.
|
||||
if i > 0 && lexicon.IsHalfHour(toks[i-1]) {
|
||||
continue
|
||||
}
|
||||
if n, ok := lexicon.Ordinal(tok); ok {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseReminderCancelChoice is intentionally narrower than parseOrdinal. A
|
||||
// task ordinal may appear inside a sentence carrying its transition verb; the
|
||||
// reminder list was already offered specifically for cancellation, so the next
|
||||
// mutation requires the whole turn to be one affirmative position answer.
|
||||
// Questions, negation, two positions and new requests all decline and route as
|
||||
// fresh turns instead of cancelling whichever ordinal happened to appear.
|
||||
func parseReminderCancelChoice(text string) (int, bool) {
|
||||
if router.IsQuestionShaped(text) {
|
||||
return 0, false
|
||||
}
|
||||
tokens := turnTokens(text)
|
||||
nth, positions := 0, 0
|
||||
for _, tok := range tokens {
|
||||
if reminderCancelNegation(tok) {
|
||||
return 0, false
|
||||
}
|
||||
if n, ok := candidateDigits[tok]; ok {
|
||||
nth, positions = n, positions+1
|
||||
continue
|
||||
}
|
||||
if n, ok := lexicon.Ordinal(tok); ok {
|
||||
nth, positions = n, positions+1
|
||||
continue
|
||||
}
|
||||
if lexicon.IsFillerParticle(tok) || reminderCancelVerbs[tok] ||
|
||||
isReminderCancelTarget(tok) || reminderCancelFrame[tok] {
|
||||
continue
|
||||
}
|
||||
switch tok {
|
||||
case "номер", "вариант", "number", "option", "one":
|
||||
continue
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return nth, positions == 1
|
||||
}
|
||||
|
||||
// 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(ctx context.Context, cands []dialogue.Candidate) {
|
||||
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||
return
|
||||
}
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), cands)
|
||||
}
|
||||
|
||||
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
||||
// the list she just read.
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
if h.dialogueSessions == nil {
|
||||
return "", false
|
||||
}
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(ctx), h.now())
|
||||
if sess == nil || len(sess.Candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
reminderList := true
|
||||
for _, candidate := range sess.Candidates {
|
||||
if candidate.Kind != "reminder-cancel" {
|
||||
reminderList = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if reminderList && classifyConfirm(text) == confirmNo {
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
|
||||
return "хорошо, ничего не отменяю.", true
|
||||
}
|
||||
nth, ok := parseOrdinal(text)
|
||||
if reminderList {
|
||||
nth, ok = parseReminderCancelChoice(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]
|
||||
if pick.Kind == "reminder-cancel" {
|
||||
// Unlike a task list, this list was offered in answer to the explicit
|
||||
// question "which reminder should I cancel?" A bare ordinal is the
|
||||
// answer to that question and therefore completes the cancellation.
|
||||
return h.cancelReminderChoice(ctx, pick.Ref, pick.Label), true
|
||||
}
|
||||
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(), string(src)); 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(dialogueIDOf(ctx), h.now(), nil)
|
||||
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||
return say + ": " + pick.Label, true
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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},
|
||||
// Forms the stem list used to miss, and positions past its fifth.
|
||||
{"вторым", 2, true},
|
||||
{"седьмую", 7, true},
|
||||
{"одиннадцатый", 11, true},
|
||||
// A spoken half hour names its hour with the same genitive ordinal, so
|
||||
// this is 07:30 and not the eighth thing she read out (V-522).
|
||||
{"напомни в половине восьмого", 0, false},
|
||||
{"полвосьмого", 0, false},
|
||||
// The ordinal still wins when the half word is not in front of it.
|
||||
{"восьмую сделал", 8, true},
|
||||
}
|
||||
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(), "второй", sourceVoice); 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, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "второй", sourceVoice)
|
||||
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, "первый", sourceVoice); !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, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал", sourceVoice)
|
||||
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, "второй", sourceVoice); 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, context.Background(), ids, "купить хлеб")
|
||||
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий", sourceVoice)
|
||||
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
|
||||
}
|
||||
|
||||
// putCandidates binds a list to the reach the ctx names, the way queryTasks
|
||||
// does when she recites one.
|
||||
func putCandidates(h *reactiveHandler, ctx context.Context, 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(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(ctx, cands)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"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. A frozen bilingual corpus is
|
||||
// embedded at model-fit time, then a class-balanced logistic head is fitted
|
||||
// over those vectors. The head learns a direction in semantic space instead
|
||||
// of choosing whichever single example happens to share the most words. That
|
||||
// matters for a public noun inside a private question and for advice about an
|
||||
// owned object: nearest-neighbour scoring confuses both, while a trained head
|
||||
// combines the evidence across the whole sentence.
|
||||
//
|
||||
// The corpus covers six sentence shapes on both sides: remembered speech,
|
||||
// possession, narrative, first-person preambles, current advice/information,
|
||||
// and public proper nouns. Training weights each class equally, so the larger
|
||||
// world corpus cannot move the prior merely by containing more examples. A
|
||||
// small L2 term makes the solution stable; its value and the fixed optimiser
|
||||
// are measured by model-backed cross-validation, not adjusted at runtime.
|
||||
//
|
||||
// This linear head measures 29/29 on the historical regression suite and
|
||||
// 72/72 on the separate stratified fixture (V-702, 13-08-2026). The gate is
|
||||
// still probability 0.5: a false claim costs one honest "не знаю", while a
|
||||
// false pass can send his life to an upstream engine.
|
||||
//
|
||||
// The embedder is the one model CLAUDE.md pins to homesrv permanently. Its head
|
||||
// is fitted and verified by the model-backed gate, then frozen into the binary;
|
||||
// inference is one dot product against a vector the turn already has. Unknown
|
||||
// embedding spaces fit their own head once per process instead of applying
|
||||
// foreign weights. Neither path calls llama-server or the network.
|
||||
|
||||
// personalSeeds and worldSeeds are the frozen training corpus for the linear
|
||||
// boundary head. Editing either changes a model, not a phrase list: every edit
|
||||
// therefore needs the model-backed regression, stratified evaluation and
|
||||
// training-corpus cross-validation. The examples describe where an answer can
|
||||
// come from, in both languages. None is a special case copied from an eval.
|
||||
var personalSeeds = []string{
|
||||
// The original compact corpus. It remains here both as training signal and
|
||||
// as provenance for the regressions that introduced the semantic boundary.
|
||||
"что я говорил про это",
|
||||
"я тебе рассказывал об этом?",
|
||||
"что я записал про врача",
|
||||
"я упоминал эту тему?",
|
||||
"что у меня сегодня",
|
||||
"когда моя встреча",
|
||||
"what did i say about this",
|
||||
"did i mention this to you",
|
||||
|
||||
// Remembered speech.
|
||||
"какой адрес я тебе сообщал?",
|
||||
"что я говорил о своём самочувствии?",
|
||||
"какое решение по ремонту я озвучил?",
|
||||
"что я обещал сделать после отпуска?",
|
||||
"what reason did I give for declining the offer?",
|
||||
"did I tell you where I grew up?",
|
||||
"which restaurant did I say I wanted to visit?",
|
||||
"what explanation did I give for missing the meeting?",
|
||||
|
||||
// Stored attributes of his possessions and records.
|
||||
"где лежит мой договор аренды?",
|
||||
"когда заканчивается моя подписка на спортзал?",
|
||||
"какой размер у моей запасной куртки?",
|
||||
"до какой даты действует мой пропуск?",
|
||||
"какой размер у моего велосипедного шлема?",
|
||||
"where is my vehicle registration document?",
|
||||
"when is my museum membership renewal?",
|
||||
"what number is on my travel insurance policy?",
|
||||
"which shelf did I put my tax folder on?",
|
||||
"what size is my waterproof coat?",
|
||||
|
||||
// Narratives that only his memories or records can supply.
|
||||
"собери по моим записям рассказ о поездке в Самару",
|
||||
"напомни, как прошёл мой первый урок вождения",
|
||||
"восстанови из дневника, как я искал первую квартиру",
|
||||
"перескажи по моим словам, как прошла встреча выпускников",
|
||||
"summarize my account of moving into this apartment",
|
||||
"tell me what happened during my first week at the new job",
|
||||
"recreate the story of my graduation from my journal",
|
||||
"piece together my account of adopting the dog",
|
||||
|
||||
// First-person framing around a private answer.
|
||||
"возвращаясь к нашей беседе, какой банк я выбрал?",
|
||||
"кажется, я уже говорил: на какую дату записался к врачу?",
|
||||
"если мы это обсуждали, какую школу вождения я предпочёл?",
|
||||
"напомню наш разговор: когда я решил менять работу?",
|
||||
"as I mentioned before, which contractor did I hire?",
|
||||
"coming back to our chat, what date did I book the inspection for?",
|
||||
"if we covered this already, which course did I enroll in?",
|
||||
"back to what I told you: where did I plan to stay in Oslo?",
|
||||
|
||||
// Current information that lives only in his records.
|
||||
"какой счёт мне нужно оплатить на этой неделе?",
|
||||
"сколько часов я работал в прошлом месяце?",
|
||||
"какую процедуру мастер советовал выполнить утром?",
|
||||
"какая из моих заявок всё ещё не закрыта?",
|
||||
"which appointment do I have tomorrow morning?",
|
||||
"how many kilometres did I run last week?",
|
||||
"what maintenance did the mechanic tell me to schedule?",
|
||||
"which item on my project list is overdue?",
|
||||
|
||||
// Public names inside questions that still require his records.
|
||||
"какую цитату из Набокова я сохранил?",
|
||||
"когда у меня созвон с Ириной Петровой?",
|
||||
"что я думал о романе Умберто Эко?",
|
||||
"какую оценку я дал выставке Айвазовского?",
|
||||
"какую фотографию Эрмитажа я отметил для печати?",
|
||||
"what did I note down after Margaret Hamilton's lecture?",
|
||||
"when is my booking at the Royal Albert Hall?",
|
||||
"which Nina Simone song did I call my favourite?",
|
||||
"what opinion did I share about Zadie Smith's new novel?",
|
||||
"what reminder did I attach to the Jira migration?",
|
||||
}
|
||||
|
||||
var worldSeeds = []string{
|
||||
// The original compact corpus, retained as above.
|
||||
"почему небо синее",
|
||||
"какая столица франции",
|
||||
"как сварить борщ",
|
||||
"кто написал эту книгу",
|
||||
"what is the capital of france",
|
||||
"as i said, why is the sky blue",
|
||||
"as i said, what is the population of india",
|
||||
"что я могу посмотреть вечером",
|
||||
"что мне почитать про историю",
|
||||
"что я должен знать про питон",
|
||||
"what can i watch tonight",
|
||||
// A third shape that looks personal and is not: asking when something
|
||||
// happens (Vikunja #553). "во сколько закат сегодня" scored personal,
|
||||
// because "что у меня сегодня" and "когда моя встреча" put that frame on
|
||||
// the personal side and nothing here answered it. The sunset is the one
|
||||
// thing on his list that is the same for everybody standing outside.
|
||||
// "сегодня" is carried on purpose. Without it these caught nothing: the
|
||||
// day word is most of what pulls the frame personal, because "что у меня
|
||||
// сегодня" is a personal seed and the day word is the half it shares.
|
||||
"во сколько сегодня открывается магазин",
|
||||
"когда сегодня начинается матч",
|
||||
"во сколько сегодня восход солнца",
|
||||
// The other frame a day word carries, and the same story: "что у меня
|
||||
// сегодня" is a personal seed, so "какой сегодня праздник" and "что
|
||||
// интересного произошло сегодня в мире" were refused as his after the
|
||||
// topic seeds had already let them past the weather source.
|
||||
"какой сегодня курс валют",
|
||||
"что сегодня происходит в мире",
|
||||
// The narrative shape (Vikunja #554). "расскажи про Байкал" was refused as
|
||||
// his by 0.0052, and nothing here was phrased as an order rather than a
|
||||
// question: every world seed above opens with an interrogative. So a world
|
||||
// question that names its subject and asks for prose landed nearer "я тебе
|
||||
// рассказывал об этом?", which is the same verb about his own words.
|
||||
"расскажи про байкал",
|
||||
"расскажи про древний рим",
|
||||
"объясни как работает двигатель",
|
||||
"tell me about the roman empire",
|
||||
|
||||
// Speech and reports by somebody other than the owner.
|
||||
"что Александр Пушкин писал о Москве?",
|
||||
"как учёные объясняли исчезновение динозавров?",
|
||||
"что Менделеев говорил о будущем химии?",
|
||||
"какие выводы сделал Амундсен после экспедиции?",
|
||||
"what did Virginia Woolf write about fiction?",
|
||||
"how did researchers describe the Tunguska event?",
|
||||
"what did witnesses report after the Lisbon earthquake?",
|
||||
"which ideas did Ada Lovelace describe in her notes?",
|
||||
|
||||
// General advice about an owned object. Ownership supplies context, but an
|
||||
// outside source can still supply the answer.
|
||||
"как починить мой скрипящий стул?",
|
||||
"почему мой роутер теряет соединение?",
|
||||
"чем очистить мой велосипед от ржавчины?",
|
||||
"какой бензин подходит для моего генератора?",
|
||||
"какой чехол подобрать для моего планшета?",
|
||||
"как защитить мой деревянный стол от влаги?",
|
||||
"how do I remove a stain from my jacket?",
|
||||
"why is my freezer building up ice?",
|
||||
"which oil should I use in my lawn mower?",
|
||||
"what detergent is safe for my washing machine?",
|
||||
"which replacement blade should I buy for my circular saw?",
|
||||
"how can I keep my garden tools from rusting?",
|
||||
|
||||
// Public narratives.
|
||||
"расскажи историю строительства Транссибирской магистрали",
|
||||
"опиши, как развивалась письменность",
|
||||
"объясни, как появился периодический закон",
|
||||
"опиши первую успешную зимовку в Антарктиде",
|
||||
"tell the story of the discovery of penicillin",
|
||||
"describe how the first transatlantic cable was laid",
|
||||
"explain how the Olympic Games were revived",
|
||||
"describe the expedition that first reached the South Pole",
|
||||
|
||||
// First-person framing around a public answer.
|
||||
"как я уже спрашивал, почему звёзды мерцают?",
|
||||
"повторю свой вопрос: как образуются коралловые рифы?",
|
||||
"возможно, я повторяюсь: когда возвели собор Святого Петра?",
|
||||
"я мог уже спрашивать: из чего делают фарфор?",
|
||||
"as I asked earlier, why do leaves change colour?",
|
||||
"to repeat my question, how are fjords formed?",
|
||||
"I might be asking twice, when was Angkor Wat constructed?",
|
||||
"I may have asked before, what causes bioluminescence?",
|
||||
|
||||
// Public current information and generally applicable advice.
|
||||
"какие поезда сегодня идут из Москвы в Тверь?",
|
||||
"как правильно хранить чугунную сковороду?",
|
||||
"какие выставки проходят в Петербурге в этом месяце?",
|
||||
"какой сейчас уровень воды в Волге?",
|
||||
"what is the latest supported version of Ubuntu?",
|
||||
"how should I prepare a wooden deck for winter?",
|
||||
"which film festivals are taking place this season?",
|
||||
"what is the current exchange rate for the Norwegian krone?",
|
||||
|
||||
// Public facts about named people, places and organisations.
|
||||
"кто такая Софья Ковалевская?",
|
||||
"когда была основана компания Nintendo?",
|
||||
"чем прославился архитектор Фрэнк Ллойд Райт?",
|
||||
"где находится музей Прадо?",
|
||||
"who was James Baldwin?",
|
||||
"what is the city of Petra known for?",
|
||||
"when was the composer Philip Glass born?",
|
||||
"where is the Uffizi Gallery located?",
|
||||
}
|
||||
|
||||
// personalBoundary holds the frozen or locally fitted head and, when fitting
|
||||
// was necessary, its embedded corpus. 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
|
||||
head personalBoundaryLinearHead
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// load selects the pinned frozen head or embeds and fits the seed sets once per
|
||||
// process for another embedding space. Seeds are embedded on the QUERY side,
|
||||
// like the utterance they classify. 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
|
||||
}
|
||||
// The deployed e5-small head is fitted offline from the corpus below and
|
||||
// checked back against it by TestONNXPersonalBoundaryFrozenHead. Loading
|
||||
// it directly keeps the first personal query from embedding 132 examples.
|
||||
if router.EmbedderID(emb) == personalBoundaryHeadModelID {
|
||||
head, ok := frozenPersonalBoundaryHead()
|
||||
if ok && len(head.weights) == emb.Dim() {
|
||||
b.head, b.loaded = head, true
|
||||
return
|
||||
}
|
||||
log.Printf("voice: frozen personal boundary head is corrupt; rebuilding from its corpus")
|
||||
}
|
||||
|
||||
p, w := embedAll(personalSeeds), embedAll(worldSeeds)
|
||||
if p == nil || w == nil {
|
||||
return
|
||||
}
|
||||
epochs := personalBoundaryTrainingEpochs
|
||||
if router.EmbedderID(emb) == personalBoundaryHashModelID {
|
||||
epochs = personalBoundaryHashTrainingEpochs
|
||||
}
|
||||
head, ok := trainPersonalBoundaryLinearHeadEpochs(p, w, epochs)
|
||||
if !ok {
|
||||
log.Printf("voice: personal boundary training examples have inconsistent dimensions; falling back to possession markers")
|
||||
return
|
||||
}
|
||||
b.personal, b.world, b.head, b.loaded = p, w, head, true
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
personalBoundaryTrainingEpochs = 5000
|
||||
personalBoundaryLearningRate = 10.0
|
||||
personalBoundaryL2 = 0.0003
|
||||
)
|
||||
|
||||
const personalBoundaryHeadModelID = "model_quantized@384/tok2"
|
||||
|
||||
const personalBoundaryHashModelID = "hash@1024"
|
||||
|
||||
const personalBoundaryHeadWeights = "a3q5vmod2L7msrs+1RE8Pp5HDEBlv609AC9cvzm4D0CL7Fc/FU9cvxLmAMA638c/BgDBP6Is1r7PzBO/6MVAPsmEWT6XowjAouT0v8jMN79d2Sk+7XLlPX2akD+lmKi/q922vvLSFcBb0ma/cN3QP27zBMDl45i/iuE0P4KIJb+7dua+gTePP5unVz9H3q29Sxsev7YJe7+SvoQ+r6jyPxW2DL8sMQc/+iExQM5y8D/qJSZAtFyKP3PbyD8OK0dAHD+0v056qj4AbOS+AFHzP1KPeT9+cqu/aMIQv9wCqL8WbYe/xED1vu7pHMCPlxe/ZUGLPqFoDb8GPQ6/XE6cvqPVi7xKdr0/CE1PP4dPrj6TxoK+KokGP7xxu73h6DW/Lw8APsjd1D43aci/ZBMoQPyy8D8G6w/AMT1tPSEUU7/Sp+c+sjpRvyfl2L4KDs8/q/Ibv3urHj/+7ls/yxjaP8WS8jy8cd6+BO+4P/IcJkBTEPo/q2VGvqvsUD9anuk8UiO/PSw707+5+oY+zBpHP6e+UT4qaEe/zqjGvypN1j45TFY+nZ36v9rP8L9bmyE/Rn8UwONI0D5Yhs6/InCYv4kGgz/LNXO/rhK+Pu2Qdz/W8ijAdi3hv5qT5D9383k8Ir2wP0MRD0AxCCQ/0CUDP5kWoz+TQjdAOxI0vSbxDb/xj54/N/G6v86Ixr932Lk/jQ2jvqn2nr9y3JC96jDDPsyPlj9q/OQ/cOcCQJ+15z9747s/8Zh8PoS4oL0GKma/lfuPv/Clgb9GPKW+2OR3vimzAUBVYxXARcw0vynpsr/IUqe/bsUhv5kwWcCZtnE/fr87vjvfdr4mHis/xMpzvn20HL4SHFu/1DFXvVgOg76GXEq/pB2QP2u6e71q7w0+7F3APlte1j9YKXK/1cljPkFx0L/CndS9b4CeP4BIvj/fP5Q99jbZvL1h778WhC0/pNhov4+x1r+lYeE/9Y6gP9gtqr75dIe/wGiKv4q56D10ckY+UuvDvoIUnz/3TVM/moHcP6FkUz6//pY+FYhcwFEkD8B2a2c9mC+UP/ZeTb5FgIq+rgEOvylj8D9dvx8/OngmPyiplT9oiLy/AJwswKOJdL+i8/m9GPNfvyyWk77jVPC/0u+IPpx/Fz/QdvG/Ag9gP41l2rxmXUo/hdL0vx1XX0BUp+w9hmYyPk21dT6UJmK/zajGP7gBSD0FqoXAkis4P7kehz94wNa//nfZvxA0Fz8b9ze/IETPv3xEb76BG8k/SpyVP9xkEUC2/jlAcv8/wKKxU75E0xM+9BItPzlQKr6S0wdAMa39v0GKA8AMB3G/IeKvvyTZkz+es62/UEYTP3j+lj4SRM+/Dbfgvupdsj/wcUbAbjqRv/WV/r5WRaO/iB67P3/UyD8AK5Q+LzvJPsjPPL/fwkS/atd9P56MHz9CIJu9ugjgvp7J2D8otQC/YYoowKGEFD4eMVC/xy3UP2UEND9nU0i/ol4GQJuwfb+xeaa/B3IjwDK6Gz8dVv8/2wbLPlUo6j+FDCk/4Q/VP/J8JkCYVd0/gMS/P9Bwhj9R94a9M0Mjv/hKdL8cl6Y/lD73vwgior9+56Q/YI+1v9Wd0j8ltAjAmD5dP56Hnb+rdrA+gn2jP0bFA7/lkZU/tK6VP63ItT5Oi7O+YjfUv5iUzT+n5H8/zXMpvjefvj67z66/GA71Pj2h2T5bXxW/EyfLP1LZxr/B758/iCd2v0jnoT8twoG/oAO9vjpYDr61q6I+AEVFv1OP2b1VQpO/5FYdP5vgaz/4Lbm9CMCjvhbWlL9pYQk/1l5hPjCTYj8dtiJATXjavb6SlL7rp0E/cMBgP9UIXLwVYXC+rFS2v9yeFUD88JBAbwWcvt7s1D/bsuU/BCv0PzSdQEA7l36/FULEvmxlo79jjzc+gFvav1vptb/YjkS/Zo76vqK+3j+qvqi/qyfpPj1BLj+ehSzA4Z8nPyS/1b8kz5a9NIuZv31beL/k0oXAXFO/P8cCh8BSPzS+N7agvhjPUD6/G24/GIP0PYlNOsAFe6q+"
|
||||
|
||||
// HashEmbedder is a deterministic offline floor. Its 1024-dimensional head is
|
||||
// trained on first use instead of embedded here because the binary form is
|
||||
// still tiny but not meaningful as a production quality claim. The floor's
|
||||
// optimizer uses fewer steps: the hash vectors are sparse and converge long
|
||||
// before the semantic head, keeping an unconfigured box responsive.
|
||||
const personalBoundaryHashTrainingEpochs = 400
|
||||
|
||||
type personalBoundaryLinearHead struct {
|
||||
weights []float64
|
||||
bias float64
|
||||
}
|
||||
|
||||
func frozenPersonalBoundaryHead() (personalBoundaryLinearHead, bool) {
|
||||
raw, err := base64.StdEncoding.DecodeString(personalBoundaryHeadWeights)
|
||||
if err != nil || len(raw)%4 != 0 {
|
||||
return personalBoundaryLinearHead{}, false
|
||||
}
|
||||
weights := make([]float64, len(raw)/4)
|
||||
for i := range weights {
|
||||
weights[i] = float64(math.Float32frombits(binary.LittleEndian.Uint32(raw[4*i:])))
|
||||
}
|
||||
return personalBoundaryLinearHead{weights: weights, bias: -3.122734201373742}, true
|
||||
}
|
||||
|
||||
// trainPersonalBoundaryLinearHead fits binary logistic regression with full
|
||||
// batch gradient descent. Each side contributes total weight 0.5 regardless
|
||||
// of its number of examples. The optimiser is intentionally tiny and local:
|
||||
// the embedder supplies all learned language knowledge; this only learns one
|
||||
// separating hyperplane over its 384-dimensional vectors.
|
||||
func trainPersonalBoundaryLinearHead(personal, world [][]float32) (personalBoundaryLinearHead, bool) {
|
||||
return trainPersonalBoundaryLinearHeadEpochs(personal, world, personalBoundaryTrainingEpochs)
|
||||
}
|
||||
|
||||
func trainPersonalBoundaryLinearHeadEpochs(personal, world [][]float32, epochs int) (personalBoundaryLinearHead, bool) {
|
||||
if len(personal) == 0 || len(world) == 0 || len(personal[0]) == 0 {
|
||||
return personalBoundaryLinearHead{}, false
|
||||
}
|
||||
dim := len(personal[0])
|
||||
for _, vectors := range [][][]float32{personal, world} {
|
||||
for _, vector := range vectors {
|
||||
if len(vector) != dim {
|
||||
return personalBoundaryLinearHead{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
head := personalBoundaryLinearHead{weights: make([]float64, dim)}
|
||||
personalWeight := 0.5 / float64(len(personal))
|
||||
worldWeight := 0.5 / float64(len(world))
|
||||
for epoch := 0; epoch < epochs; epoch++ {
|
||||
gradient := make([]float64, dim)
|
||||
biasGradient := 0.0
|
||||
accumulate := func(vectors [][]float32, target, sampleWeight float64) {
|
||||
for _, vector := range vectors {
|
||||
probability := logistic(head.logit(vector))
|
||||
error := (probability - target) * sampleWeight
|
||||
biasGradient += error
|
||||
for i, value := range vector {
|
||||
gradient[i] += error * float64(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
accumulate(personal, 1, personalWeight)
|
||||
accumulate(world, 0, worldWeight)
|
||||
|
||||
step := personalBoundaryLearningRate / (1 + float64(epoch)/1000)
|
||||
for i := range head.weights {
|
||||
head.weights[i] -= step * (gradient[i] + personalBoundaryL2*head.weights[i])
|
||||
}
|
||||
head.bias -= step * biasGradient
|
||||
}
|
||||
return head, true
|
||||
}
|
||||
|
||||
func (h personalBoundaryLinearHead) logit(vec []float32) float64 {
|
||||
if len(vec) != len(h.weights) {
|
||||
return 0
|
||||
}
|
||||
score := h.bias
|
||||
for i, value := range vec {
|
||||
score += h.weights[i] * float64(value)
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func logistic(value float64) float64 {
|
||||
if value >= 0 {
|
||||
return 1 / (1 + math.Exp(-value))
|
||||
}
|
||||
exp := math.Exp(value)
|
||||
return exp / (1 + exp)
|
||||
}
|
||||
|
||||
// score returns complementary class probabilities. ok is false when the
|
||||
// corpus is not loaded or the query vector belongs to another embedding
|
||||
// space, which is the caller's signal to use the offline marker floor.
|
||||
func (b *personalBoundary) score(vec []float32) (personal, world float64, ok bool) {
|
||||
if !b.loaded || len(vec) != len(b.head.weights) {
|
||||
return 0, 0, false
|
||||
}
|
||||
personal = logistic(b.head.logit(vec))
|
||||
return personal, 1 - personal, 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))
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// This fixture is intentionally separate from personalboundary_test.go. The
|
||||
// small regression table there explains individual fixes; this matrix measures
|
||||
// the boundary as a classifier and prevents a repaired sentence shape from
|
||||
// standing in for language and subject coverage.
|
||||
//
|
||||
//go:embed testdata/personal_boundary_v1.json
|
||||
var personalBoundaryFixtureJSON []byte
|
||||
|
||||
type personalBoundaryEvalCase struct {
|
||||
ID string `json:"id"`
|
||||
Utterance string `json:"utterance"`
|
||||
Lang string `json:"lang"`
|
||||
Want string `json:"want"`
|
||||
Stratum string `json:"stratum"`
|
||||
}
|
||||
|
||||
type personalBoundaryEvalFixture struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Cases []personalBoundaryEvalCase `json:"cases"`
|
||||
}
|
||||
|
||||
var personalBoundaryEvalStrata = []string{
|
||||
"remembered_speech",
|
||||
"possession",
|
||||
"narrative",
|
||||
"first_person_preamble",
|
||||
"advice_current_info",
|
||||
"public_proper_nouns",
|
||||
}
|
||||
|
||||
func loadPersonalBoundaryEvalFixture(t *testing.T) personalBoundaryEvalFixture {
|
||||
t.Helper()
|
||||
var fixture personalBoundaryEvalFixture
|
||||
if err := json.Unmarshal(personalBoundaryFixtureJSON, &fixture); err != nil {
|
||||
t.Fatalf("parse personal boundary fixture: %v", err)
|
||||
}
|
||||
if fixture.SchemaVersion != 1 {
|
||||
t.Fatalf("personal boundary fixture schema_version = %d, want 1", fixture.SchemaVersion)
|
||||
}
|
||||
if fixture.Name != "personal_boundary_v1" {
|
||||
t.Fatalf("personal boundary fixture name = %q, want personal_boundary_v1", fixture.Name)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
// TestPersonalBoundaryEvalFixture enforces the sampling contract separately
|
||||
// from the model measurement. It runs in ordinary CI even when ONNX Runtime is
|
||||
// absent, so a fixture edit cannot silently unbalance a language, side or
|
||||
// sentence shape, or turn a production seed into a held-out case.
|
||||
func TestPersonalBoundaryEvalFixture(t *testing.T) {
|
||||
fixture := loadPersonalBoundaryEvalFixture(t)
|
||||
const wantPerCell = 3
|
||||
const wantTotal = 6 * 2 * 2 * wantPerCell
|
||||
if len(fixture.Cases) != wantTotal {
|
||||
t.Errorf("fixture has %d cases, want %d", len(fixture.Cases), wantTotal)
|
||||
}
|
||||
|
||||
validStrata := make(map[string]bool, len(personalBoundaryEvalStrata))
|
||||
for _, stratum := range personalBoundaryEvalStrata {
|
||||
validStrata[stratum] = true
|
||||
}
|
||||
seedSource := make(map[string]string, len(personalSeeds)+len(worldSeeds))
|
||||
for _, seed := range personalSeeds {
|
||||
seedSource[normalizePersonalBoundaryEval(seed)] = "personalSeeds"
|
||||
}
|
||||
for _, seed := range worldSeeds {
|
||||
seedSource[normalizePersonalBoundaryEval(seed)] = "worldSeeds"
|
||||
}
|
||||
|
||||
seenID := make(map[string]bool, len(fixture.Cases))
|
||||
seenUtterance := make(map[string]string, len(fixture.Cases))
|
||||
cells := make(map[string]int)
|
||||
for _, c := range fixture.Cases {
|
||||
if strings.TrimSpace(c.ID) == "" || seenID[c.ID] {
|
||||
t.Errorf("case %q: empty or duplicate id", c.ID)
|
||||
}
|
||||
seenID[c.ID] = true
|
||||
if c.Lang != "ru" && c.Lang != "en" {
|
||||
t.Errorf("%s: lang = %q, want ru|en", c.ID, c.Lang)
|
||||
}
|
||||
if c.Want != "personal" && c.Want != "world" {
|
||||
t.Errorf("%s: want = %q, want personal|world", c.ID, c.Want)
|
||||
}
|
||||
if !validStrata[c.Stratum] {
|
||||
t.Errorf("%s: stratum = %q, not one of the six declared strata", c.ID, c.Stratum)
|
||||
}
|
||||
|
||||
normalized := normalizePersonalBoundaryEval(c.Utterance)
|
||||
if normalized == "" {
|
||||
t.Errorf("%s: empty utterance", c.ID)
|
||||
}
|
||||
if previous, ok := seenUtterance[normalized]; ok {
|
||||
t.Errorf("%s: utterance duplicates %s after normalization", c.ID, previous)
|
||||
}
|
||||
seenUtterance[normalized] = c.ID
|
||||
if source, ok := seedSource[normalized]; ok {
|
||||
t.Errorf("%s: %q is verbatim in %s, so it is not held out", c.ID, c.Utterance, source)
|
||||
}
|
||||
// The original failure names Baikal. Replacing that sentence's verb or
|
||||
// punctuation would measure an exception, not the boundary. This corpus
|
||||
// instead varies people, places, products and events.
|
||||
if strings.Contains(normalized, "байкал") || strings.Contains(normalized, "baikal") {
|
||||
t.Errorf("%s: the stratified fixture must not copy the Baikal regression", c.ID)
|
||||
}
|
||||
cells[c.Stratum+"/"+c.Lang+"/"+c.Want]++
|
||||
}
|
||||
|
||||
for _, stratum := range personalBoundaryEvalStrata {
|
||||
for _, lang := range []string{"ru", "en"} {
|
||||
for _, want := range []string{"personal", "world"} {
|
||||
cell := stratum + "/" + lang + "/" + want
|
||||
if got := cells[cell]; got != wantPerCell {
|
||||
t.Errorf("fixture cell %s has %d cases, want %d", cell, got, wantPerCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizePersonalBoundaryEval compares content rather than typography:
|
||||
// case, punctuation and repeated whitespace cannot disguise a copied seed or
|
||||
// duplicate case. This is fixture hygiene only; it does not participate in the
|
||||
// production boundary.
|
||||
func normalizePersonalBoundaryEval(s string) string {
|
||||
var b strings.Builder
|
||||
space := true
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
b.WriteRune(r)
|
||||
space = false
|
||||
continue
|
||||
}
|
||||
if !space {
|
||||
b.WriteByte(' ')
|
||||
space = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
type personalBoundaryEvalStat struct {
|
||||
Correct int
|
||||
Total int
|
||||
}
|
||||
|
||||
type personalBoundaryEvalReport struct {
|
||||
Name string
|
||||
Correct int
|
||||
Total int
|
||||
MinimumMargin float64
|
||||
ByStratum map[string]personalBoundaryEvalStat
|
||||
ByLanguage map[string]personalBoundaryEvalStat
|
||||
ByExpectedClass map[string]personalBoundaryEvalStat
|
||||
ByCell map[string]personalBoundaryEvalStat
|
||||
}
|
||||
|
||||
func newPersonalBoundaryEvalReport(name string) *personalBoundaryEvalReport {
|
||||
return &personalBoundaryEvalReport{
|
||||
Name: name,
|
||||
MinimumMargin: math.Inf(1),
|
||||
ByStratum: make(map[string]personalBoundaryEvalStat),
|
||||
ByLanguage: make(map[string]personalBoundaryEvalStat),
|
||||
ByExpectedClass: make(map[string]personalBoundaryEvalStat),
|
||||
ByCell: make(map[string]personalBoundaryEvalStat),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *personalBoundaryEvalReport) add(c personalBoundaryEvalCase, gotPersonal bool, personal, world float64) {
|
||||
wantPersonal := c.Want == "personal"
|
||||
correct := gotPersonal == wantPersonal
|
||||
r.Total++
|
||||
if correct {
|
||||
r.Correct++
|
||||
}
|
||||
signedMargin := personal - world
|
||||
if !wantPersonal {
|
||||
signedMargin = -signedMargin
|
||||
}
|
||||
if signedMargin < r.MinimumMargin {
|
||||
r.MinimumMargin = signedMargin
|
||||
}
|
||||
add := func(stats map[string]personalBoundaryEvalStat, key string) {
|
||||
stat := stats[key]
|
||||
stat.Total++
|
||||
if correct {
|
||||
stat.Correct++
|
||||
}
|
||||
stats[key] = stat
|
||||
}
|
||||
add(r.ByStratum, c.Stratum)
|
||||
add(r.ByLanguage, c.Lang)
|
||||
add(r.ByExpectedClass, c.Want)
|
||||
add(r.ByCell, c.Stratum+"/"+c.Lang+"/"+c.Want)
|
||||
}
|
||||
|
||||
// TestONNXPersonalBoundaryStratified scores the model homesrv actually runs.
|
||||
// Production is read from personalBoundary.score; top1, top2, top3 and a
|
||||
// whole-class centroid are diagnostics over the same embedded seeds. Today
|
||||
// production and top3 coincide, but keeping them separate means a later scoring
|
||||
// experiment can be compared without rewriting this evaluation or putting its
|
||||
// candidate math in runtime code. The privacy boundary is a hard contract, so
|
||||
// every production miss is a test failure rather than an accuracy target to
|
||||
// average away.
|
||||
func TestONNXPersonalBoundaryStratified(t *testing.T) {
|
||||
if os.Getenv("MAVEN_EVAL_PERSONAL_BOUNDARY") == "" {
|
||||
t.Skip("set MAVEN_EVAL_PERSONAL_BOUNDARY=1 to run the deliberately strict V-702 matrix")
|
||||
}
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
modelDir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
model := filepath.Join(modelDir, "model_quantized.onnx")
|
||||
tokenizer := filepath.Join(modelDir, "tokenizer.json")
|
||||
for _, path := range []string{lib, model, tokenizer} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("personal boundary eval dependency %s unavailable: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
embedder, err := router.NewONNXEmbedder(model, tokenizer, lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer embedder.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
boundary := &personalBoundary{}
|
||||
boundary.load(ctx, embedder)
|
||||
if !boundary.loaded {
|
||||
t.Fatal("personal boundary seeds did not load with a working embedder")
|
||||
}
|
||||
// Production loads its model-ID-pinned frozen head and deliberately skips
|
||||
// the 132 corpus embeddings on a user's first query. This test still needs
|
||||
// those vectors for the historical top-k/centroid diagnostics, so build
|
||||
// them here without putting that latency back in runtime code.
|
||||
embedCorpus := func(values []string) [][]float32 {
|
||||
vectors := make([][]float32, len(values))
|
||||
for i, value := range values {
|
||||
vector, err := router.EmbedQuery(ctx, embedder, value)
|
||||
if err != nil {
|
||||
t.Fatalf("embed diagnostic corpus %q: %v", value, err)
|
||||
}
|
||||
vectors[i] = vector
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
boundary.personal = embedCorpus(personalSeeds)
|
||||
boundary.world = embedCorpus(worldSeeds)
|
||||
personalCentroid := personalBoundaryEvalCentroid(boundary.personal)
|
||||
worldCentroid := personalBoundaryEvalCentroid(boundary.world)
|
||||
if len(personalCentroid) == 0 || len(worldCentroid) == 0 {
|
||||
t.Fatal("personal boundary seed vectors do not share a dimension")
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
name string
|
||||
score func([]float32) (float64, float64)
|
||||
}
|
||||
candidates := []candidate{
|
||||
{name: "production", score: func(vec []float32) (float64, float64) {
|
||||
personal, world, ok := boundary.score(vec)
|
||||
if !ok {
|
||||
t.Fatal("loaded personal boundary declined to score")
|
||||
}
|
||||
return personal, world
|
||||
}},
|
||||
{name: "top1", score: func(vec []float32) (float64, float64) {
|
||||
return meanNearest(vec, boundary.personal, 1), meanNearest(vec, boundary.world, 1)
|
||||
}},
|
||||
{name: "top2", score: func(vec []float32) (float64, float64) {
|
||||
return meanNearest(vec, boundary.personal, 2), meanNearest(vec, boundary.world, 2)
|
||||
}},
|
||||
{name: "top3", score: func(vec []float32) (float64, float64) {
|
||||
return meanNearest(vec, boundary.personal, 3), meanNearest(vec, boundary.world, 3)
|
||||
}},
|
||||
{name: "centroid", score: func(vec []float32) (float64, float64) {
|
||||
return cosine(vec, personalCentroid), cosine(vec, worldCentroid)
|
||||
}},
|
||||
}
|
||||
reports := make(map[string]*personalBoundaryEvalReport, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
reports[candidate.name] = newPersonalBoundaryEvalReport(candidate.name)
|
||||
}
|
||||
|
||||
fixture := loadPersonalBoundaryEvalFixture(t)
|
||||
for _, c := range fixture.Cases {
|
||||
vec, err := router.EmbedQuery(ctx, embedder, c.Utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: embed %q: %v", c.ID, c.Utterance, err)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
personal, world := candidate.score(vec)
|
||||
gotPersonal := personal > world
|
||||
reports[candidate.name].add(c, gotPersonal, personal, world)
|
||||
if candidate.name == "production" && gotPersonal != (c.Want == "personal") {
|
||||
t.Errorf("%s [%s/%s]: got %s, want %s (personal %.4f world %.4f delta %+.4f): %q",
|
||||
c.ID, c.Lang, c.Stratum, boundaryEvalSide(gotPersonal), c.Want,
|
||||
personal, world, personal-world, c.Utterance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
report := reports[candidate.name]
|
||||
t.Logf("candidate %-15s %2d/%d (%.1f%%), minimum signed margin %+.4f",
|
||||
report.Name, report.Correct, report.Total,
|
||||
100*float64(report.Correct)/float64(report.Total), report.MinimumMargin)
|
||||
}
|
||||
production := reports["production"]
|
||||
for _, lang := range []string{"ru", "en"} {
|
||||
stat := production.ByLanguage[lang]
|
||||
t.Logf("production language %-2s %2d/%d", lang, stat.Correct, stat.Total)
|
||||
}
|
||||
for _, side := range []string{"personal", "world"} {
|
||||
stat := production.ByExpectedClass[side]
|
||||
t.Logf("production expected %-8s %2d/%d", side, stat.Correct, stat.Total)
|
||||
}
|
||||
strata := append([]string(nil), personalBoundaryEvalStrata...)
|
||||
sort.Strings(strata)
|
||||
for _, stratum := range strata {
|
||||
stat := production.ByStratum[stratum]
|
||||
ruPersonal := production.ByCell[stratum+"/ru/personal"]
|
||||
ruWorld := production.ByCell[stratum+"/ru/world"]
|
||||
enPersonal := production.ByCell[stratum+"/en/personal"]
|
||||
enWorld := production.ByCell[stratum+"/en/world"]
|
||||
t.Logf("production stratum %-21s %2d/%d | ru personal %d/%d world %d/%d | en personal %d/%d world %d/%d",
|
||||
stratum, stat.Correct, stat.Total,
|
||||
ruPersonal.Correct, ruPersonal.Total, ruWorld.Correct, ruWorld.Total,
|
||||
enPersonal.Correct, enPersonal.Total, enWorld.Correct, enWorld.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func personalBoundaryEvalCentroid(vectors [][]float32) []float32 {
|
||||
if len(vectors) == 0 {
|
||||
return nil
|
||||
}
|
||||
centroid := make([]float32, len(vectors[0]))
|
||||
for _, vector := range vectors {
|
||||
if len(vector) != len(centroid) {
|
||||
return nil
|
||||
}
|
||||
for i, value := range vector {
|
||||
centroid[i] += value
|
||||
}
|
||||
}
|
||||
for i := range centroid {
|
||||
centroid[i] /= float32(len(vectors))
|
||||
}
|
||||
return centroid
|
||||
}
|
||||
|
||||
func boundaryEvalSide(personal bool) string {
|
||||
if personal {
|
||||
return "personal"
|
||||
}
|
||||
return "world"
|
||||
}
|
||||
|
||||
// meanNearest is an evaluation baseline retained beside the strict fixture;
|
||||
// production uses the linear head in personalboundary.go.
|
||||
func meanNearest(vec []float32, seeds [][]float32, k int) float64 {
|
||||
if len(seeds) == 0 || k <= 0 {
|
||||
return -1
|
||||
}
|
||||
if k > len(seeds) {
|
||||
k = len(seeds)
|
||||
}
|
||||
top := make([]float64, k)
|
||||
for i := range top {
|
||||
top[i] = -1
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
candidate := cosine(vec, seed)
|
||||
for i := range top {
|
||||
if candidate > top[i] {
|
||||
candidate, top[i] = top[i], candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
var sum float64
|
||||
for _, similarity := range top {
|
||||
sum += similarity
|
||||
}
|
||||
return sum / float64(k)
|
||||
}
|
||||
@@ -1,748 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestPersonalBoundaryLinearHeadSeparatesSemanticDirections(t *testing.T) {
|
||||
personal := [][]float32{{1, 0}, {0.9, 0.1}, {0.8, -0.1}}
|
||||
world := [][]float32{{-1, 0}, {-0.9, 0.1}, {-0.8, -0.1}}
|
||||
head, ok := trainPersonalBoundaryLinearHead(personal, world)
|
||||
if !ok {
|
||||
t.Fatal("valid training vectors were rejected")
|
||||
}
|
||||
b := personalBoundary{personal: personal, world: world, head: head, loaded: true}
|
||||
for _, tc := range []struct {
|
||||
vector []float32
|
||||
personal bool
|
||||
}{
|
||||
{vector: []float32{0.75, 0.2}, personal: true},
|
||||
{vector: []float32{-0.75, 0.2}, personal: false},
|
||||
} {
|
||||
personalScore, worldScore, ok := b.score(tc.vector)
|
||||
if !ok {
|
||||
t.Fatal("loaded boundary did not score")
|
||||
}
|
||||
if got := personalScore > worldScore; got != tc.personal {
|
||||
t.Fatalf("vector %v classified personal=%v (scores %.4f/%.4f), want %v",
|
||||
tc.vector, got, personalScore, worldScore, tc.personal)
|
||||
}
|
||||
if math.Abs(personalScore+worldScore-1) > 1e-12 {
|
||||
t.Fatalf("scores %.8f and %.8f are not complementary probabilities", personalScore, worldScore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryTrainingBalancesClasses(t *testing.T) {
|
||||
personal := [][]float32{{1, 0}, {0.8, 0.2}}
|
||||
world := [][]float32{{-1, 0}}
|
||||
oneWorld, ok := trainPersonalBoundaryLinearHead(personal, world)
|
||||
if !ok {
|
||||
t.Fatal("valid training vectors were rejected")
|
||||
}
|
||||
repeatedWorld := make([][]float32, 12)
|
||||
for i := range repeatedWorld {
|
||||
repeatedWorld[i] = world[0]
|
||||
}
|
||||
twelveWorld, ok := trainPersonalBoundaryLinearHead(personal, repeatedWorld)
|
||||
if !ok {
|
||||
t.Fatal("valid repeated training vectors were rejected")
|
||||
}
|
||||
if math.Abs(oneWorld.bias-twelveWorld.bias) > 1e-10 {
|
||||
t.Fatalf("duplicating one class moved bias from %.12f to %.12f", oneWorld.bias, twelveWorld.bias)
|
||||
}
|
||||
for i := range oneWorld.weights {
|
||||
if math.Abs(oneWorld.weights[i]-twelveWorld.weights[i]) > 1e-10 {
|
||||
t.Fatalf("duplicating one class moved weight %d from %.12f to %.12f",
|
||||
i, oneWorld.weights[i], twelveWorld.weights[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryTrainingRejectsMixedDimensions(t *testing.T) {
|
||||
if _, ok := trainPersonalBoundaryLinearHead(
|
||||
[][]float32{{1, 0}},
|
||||
[][]float32{{-1, 0, 0}},
|
||||
); ok {
|
||||
t.Fatal("mixed embedding dimensions were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The corpus is grouped by sentence shape in personalboundary.go. This test
|
||||
// leaves one entire shape out of training at a time, then requires the linear
|
||||
// head to classify the omitted examples from the semantics learned from the
|
||||
// other shapes. It is ordinary deterministic CI: the small axis vectors stand
|
||||
// in for frozen embedding directions, so the test proves the training code
|
||||
// generalises across groups rather than memorising one row at a time.
|
||||
func TestPersonalBoundaryLinearHeadLeaveOneShapeOut(t *testing.T) {
|
||||
type example struct {
|
||||
vector []float32
|
||||
shape int
|
||||
want bool
|
||||
}
|
||||
const shapeCount = 6
|
||||
examples := make([]example, 0, shapeCount*4)
|
||||
for shape := 0; shape < shapeCount; shape++ {
|
||||
for variant := 0; variant < 2; variant++ {
|
||||
personal := make([]float32, shapeCount+1)
|
||||
world := make([]float32, shapeCount+1)
|
||||
personal[0], world[0] = 1, -1
|
||||
personal[shape+1] = float32(0.1 * float64(variant+1))
|
||||
world[shape+1] = float32(-0.1 * float64(variant+1))
|
||||
examples = append(examples,
|
||||
example{vector: personal, shape: shape, want: true},
|
||||
example{vector: world, shape: shape, want: false},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for omitted := 0; omitted < shapeCount; omitted++ {
|
||||
var personal, world [][]float32
|
||||
for _, example := range examples {
|
||||
if example.shape == omitted {
|
||||
continue
|
||||
}
|
||||
if example.want {
|
||||
personal = append(personal, example.vector)
|
||||
} else {
|
||||
world = append(world, example.vector)
|
||||
}
|
||||
}
|
||||
head, ok := trainPersonalBoundaryLinearHead(personal, world)
|
||||
if !ok {
|
||||
t.Fatalf("fold %d rejected valid vectors", omitted)
|
||||
}
|
||||
for _, example := range examples {
|
||||
if example.shape != omitted {
|
||||
continue
|
||||
}
|
||||
if got := head.logit(example.vector) > 0; got != example.want {
|
||||
t.Errorf("fold %d classified %v as personal=%v, want %v", omitted, example.vector, got, example.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryTrainingCorpusIsIndependent(t *testing.T) {
|
||||
// The strict stratified fixture already enforces this for its 72 rows. The
|
||||
// historical regression table lives here, so protect it here too: a future
|
||||
// seed addition must not copy a regression sentence into training.
|
||||
training := make(map[string]bool, len(personalSeeds)+len(worldSeeds))
|
||||
for _, seed := range append(append([]string(nil), personalSeeds...), worldSeeds...) {
|
||||
training[normalizePersonalBoundaryTraining(seed)] = true
|
||||
}
|
||||
for _, regression := range []string{
|
||||
"что я говорил про бэкапы?",
|
||||
"что я сказал вчера про отпуск",
|
||||
"я писал что-нибудь про сервер",
|
||||
"я упоминал про конференцию?",
|
||||
"что я отмечал по поводу переезда",
|
||||
"я рассказывал тебе про новую работу?",
|
||||
"во сколько у меня встреча",
|
||||
"когда мой следующий отпуск",
|
||||
"what did i say about backups",
|
||||
"did i tell you about the doctor",
|
||||
"как я говорил, почему небо синее",
|
||||
"как уже я говорил, какая столица франции",
|
||||
"почему трава зелёная",
|
||||
"столица франции",
|
||||
"как мне сварить борщ",
|
||||
"что мне посмотреть вечером",
|
||||
"я хочу узнать про рим",
|
||||
"кто такой гагарин",
|
||||
"how do i boil an egg",
|
||||
"во сколько закат сегодня",
|
||||
"когда сегодня заканчивается концерт",
|
||||
"во сколько завтра открывается аптека",
|
||||
"какой сегодня праздник",
|
||||
"что интересного произошло сегодня в мире",
|
||||
"кто выиграл вчера матч",
|
||||
"расскажи про эверест",
|
||||
"расскажи про войну 1812 года",
|
||||
"объясни что такое инфляция",
|
||||
"я рассказывал тебе про байкал?",
|
||||
} {
|
||||
if training[normalizePersonalBoundaryTraining(regression)] {
|
||||
t.Errorf("regression utterance leaked into training: %q", regression)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryFrozenHeadDecodes(t *testing.T) {
|
||||
head, ok := frozenPersonalBoundaryHead()
|
||||
if !ok {
|
||||
t.Fatal("frozen head did not decode")
|
||||
}
|
||||
if len(head.weights) != 384 {
|
||||
t.Fatalf("frozen head has %d weights, want 384", len(head.weights))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryHashFloorFitsAndScores(t *testing.T) {
|
||||
b := &personalBoundary{}
|
||||
embedder := router.NewHashEmbedder(1024)
|
||||
query, err := router.EmbedQuery(context.Background(), embedder, "когда моя встреча")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.load(context.Background(), embedder)
|
||||
if _, _, ok := b.score(query); !ok {
|
||||
t.Fatal("hash-floor boundary declined to score")
|
||||
}
|
||||
if len(b.head.weights) != 1024 {
|
||||
t.Fatalf("hash-floor boundary has %d weights, want 1024", len(b.head.weights))
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPersonalBoundaryHashFloorFitAndScore keeps startup cost measurable
|
||||
// without making ambient CI load a correctness condition. In particular,
|
||||
// -race and coverage instrumentation both multiply the cost of this numeric
|
||||
// training loop; the functional test above is the deterministic gate.
|
||||
func BenchmarkPersonalBoundaryHashFloorFitAndScore(b *testing.B) {
|
||||
embedder := router.NewHashEmbedder(1024)
|
||||
query, err := router.EmbedQuery(context.Background(), embedder, "когда моя встреча")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
boundary := &personalBoundary{}
|
||||
boundary.load(context.Background(), embedder)
|
||||
if _, _, ok := boundary.score(query); !ok {
|
||||
b.Fatal("hash-floor boundary declined to score")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePersonalBoundaryTraining(value string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(value)), " ")
|
||||
}
|
||||
|
||||
// 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},
|
||||
// Asking when a public thing happens (Vikunja #553). "во сколько закат
|
||||
// сегодня" was answered "не знаю — не нашла у тебя такой записи",
|
||||
// because the frame lived only on the personal side. The pair above it
|
||||
// is the control: "во сколько у меня встреча" is the same frame about
|
||||
// something that IS his, and it has to stay personal.
|
||||
{"во сколько закат сегодня", false},
|
||||
{"когда сегодня заканчивается концерт", false},
|
||||
{"во сколько завтра открывается аптека", false},
|
||||
// The "какой сегодня X" frame. These clear the weather topic after the
|
||||
// V-553 seeds and were then refused here, which is the same defect one
|
||||
// source further down the chain.
|
||||
{"какой сегодня праздник", false},
|
||||
{"что интересного произошло сегодня в мире", false},
|
||||
{"кто выиграл вчера матч", false},
|
||||
// The narrative shape, held out from the seeds above (Vikunja #554).
|
||||
// The control is the row after them: the same verb about his own words
|
||||
// is still his.
|
||||
{"расскажи про эверест", false},
|
||||
{"расскажи про войну 1812 года", false},
|
||||
{"объясни что такое инфляция", false},
|
||||
{"я рассказывал тебе про байкал?", true},
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func TestONNXPersonalBoundaryFourFold(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()
|
||||
ctx := context.Background()
|
||||
embedAll := func(values []string) [][]float32 {
|
||||
vectors := make([][]float32, len(values))
|
||||
for i, value := range values {
|
||||
vector, err := router.EmbedQuery(ctx, emb, value)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", value, err)
|
||||
}
|
||||
vectors[i] = vector
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
personalVectors := embedAll(personalSeeds)
|
||||
worldVectors := embedAll(worldSeeds)
|
||||
|
||||
type group struct {
|
||||
name string
|
||||
personalStart, personalCount int
|
||||
worldStart, worldCount int
|
||||
}
|
||||
groups := []group{
|
||||
{name: "remembered_speech", personalStart: 8, personalCount: 8, worldStart: 20, worldCount: 8},
|
||||
{name: "possession", personalStart: 16, personalCount: 10, worldStart: 28, worldCount: 12},
|
||||
{name: "narrative", personalStart: 26, personalCount: 8, worldStart: 40, worldCount: 8},
|
||||
{name: "first_person_preamble", personalStart: 34, personalCount: 8, worldStart: 48, worldCount: 8},
|
||||
{name: "advice_current_info", personalStart: 42, personalCount: 8, worldStart: 56, worldCount: 8},
|
||||
{name: "public_proper_nouns", personalStart: 50, personalCount: 10, worldStart: 64, worldCount: 8},
|
||||
}
|
||||
|
||||
const foldCount = 4
|
||||
aggregateCorrect, aggregateTotal := 0, 0
|
||||
for omittedFold := 0; omittedFold < foldCount; omittedFold++ {
|
||||
trainingPersonal := append([][]float32(nil), personalVectors[:8]...)
|
||||
trainingWorld := append([][]float32(nil), worldVectors[:20]...)
|
||||
var heldPersonal, heldWorld [][]float32
|
||||
partition := func(vectors [][]float32, start, count int, training, held *[][]float32) {
|
||||
for relative, vector := range vectors[start : start+count] {
|
||||
if relative%foldCount == omittedFold {
|
||||
*held = append(*held, vector)
|
||||
} else {
|
||||
*training = append(*training, vector)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, group := range groups {
|
||||
partition(personalVectors, group.personalStart, group.personalCount, &trainingPersonal, &heldPersonal)
|
||||
partition(worldVectors, group.worldStart, group.worldCount, &trainingWorld, &heldWorld)
|
||||
}
|
||||
head, ok := trainPersonalBoundaryLinearHead(
|
||||
trainingPersonal,
|
||||
trainingWorld,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatalf("fold %d: valid training fold rejected", omittedFold)
|
||||
}
|
||||
correct, total := 0, 0
|
||||
for _, vector := range heldPersonal {
|
||||
total++
|
||||
if head.logit(vector) > 0 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
for _, vector := range heldWorld {
|
||||
total++
|
||||
if head.logit(vector) <= 0 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
t.Logf("fold %d: %d/%d held-out training examples", omittedFold+1, correct, total)
|
||||
aggregateCorrect += correct
|
||||
aggregateTotal += total
|
||||
}
|
||||
t.Logf("four-fold aggregate: %d/%d", aggregateCorrect, aggregateTotal)
|
||||
if aggregateCorrect < 99 {
|
||||
t.Errorf("four-fold aggregate %d/%d, want at least 99/104", aggregateCorrect, aggregateTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestONNXPersonalBoundarySemanticGroupHoldout(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()
|
||||
ctx := context.Background()
|
||||
embedAll := func(values []string) [][]float32 {
|
||||
vectors := make([][]float32, len(values))
|
||||
for i, value := range values {
|
||||
vector, err := router.EmbedQuery(ctx, emb, value)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", value, err)
|
||||
}
|
||||
vectors[i] = vector
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
personalVectors := embedAll(personalSeeds)
|
||||
worldVectors := embedAll(worldSeeds)
|
||||
|
||||
type group struct {
|
||||
name string
|
||||
personalStart, personalCount int
|
||||
worldStart, worldCount int
|
||||
}
|
||||
groups := []group{
|
||||
{name: "remembered_speech", personalStart: 8, personalCount: 8, worldStart: 20, worldCount: 8},
|
||||
{name: "possession", personalStart: 16, personalCount: 10, worldStart: 28, worldCount: 12},
|
||||
{name: "narrative", personalStart: 26, personalCount: 8, worldStart: 40, worldCount: 8},
|
||||
{name: "first_person_preamble", personalStart: 34, personalCount: 8, worldStart: 48, worldCount: 8},
|
||||
{name: "advice_current_info", personalStart: 42, personalCount: 8, worldStart: 56, worldCount: 8},
|
||||
{name: "public_proper_nouns", personalStart: 50, personalCount: 10, worldStart: 64, worldCount: 8},
|
||||
}
|
||||
|
||||
aggregateCorrect, aggregateTotal := 0, 0
|
||||
for _, omitted := range groups {
|
||||
excluding := func(vectors [][]float32, start, count int) [][]float32 {
|
||||
result := make([][]float32, 0, len(vectors)-count)
|
||||
result = append(result, vectors[:start]...)
|
||||
return append(result, vectors[start+count:]...)
|
||||
}
|
||||
head, ok := trainPersonalBoundaryLinearHead(
|
||||
excluding(personalVectors, omitted.personalStart, omitted.personalCount),
|
||||
excluding(worldVectors, omitted.worldStart, omitted.worldCount),
|
||||
)
|
||||
if !ok {
|
||||
t.Fatalf("%s: valid training fold rejected", omitted.name)
|
||||
}
|
||||
correct, total := 0, 0
|
||||
for _, vector := range personalVectors[omitted.personalStart : omitted.personalStart+omitted.personalCount] {
|
||||
total++
|
||||
if head.logit(vector) > 0 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
for _, vector := range worldVectors[omitted.worldStart : omitted.worldStart+omitted.worldCount] {
|
||||
total++
|
||||
if head.logit(vector) <= 0 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
t.Logf("leave %-21s out: %d/%d", omitted.name, correct, total)
|
||||
aggregateCorrect += correct
|
||||
aggregateTotal += total
|
||||
// Whole-shape holdout is an honest diagnostic, not a 100% release gate:
|
||||
// some shapes (notably private-vs-general possession) define a distinct
|
||||
// semantic ambiguity. The separately authored challenge set remains the
|
||||
// strict generalisation gate.
|
||||
}
|
||||
if aggregateCorrect < 92 {
|
||||
t.Errorf("whole-shape aggregate %d/%d, want at least 92/104", aggregateCorrect, aggregateTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// This challenge set was originally authored after the six-shape training
|
||||
// corpus and the 72-case matrix were frozen. Its sole miss then informed the
|
||||
// regularisation comparison, so it is now a strict regression gate rather than
|
||||
// independent evidence. It remains outside the production corpus.
|
||||
func TestONNXPersonalBoundaryChallenge(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},
|
||||
{"почему мой монитор мерцает при частоте 144 герца?", false},
|
||||
{"подойдёт ли кабель Thunderbolt 3 к разъёму USB4?", false},
|
||||
{"как вывести запах дыма из моей куртки?", false},
|
||||
{"что означают кольца на флаге Олимпиады?", false},
|
||||
{"почему после дождя на асфальте видна радуга?", false},
|
||||
{"какой формат файлов поддерживает Kindle Paperwhite?", false},
|
||||
{"which SSH key did I install on the build server?", true},
|
||||
{"what spending limit did I set for the travel card?", true},
|
||||
{"where did I store the spare apartment fob?", true},
|
||||
{"which objection did I raise during the design review?", true},
|
||||
{"what route did I plan for the Sunday hike?", true},
|
||||
{"when did I promise Maya I would send the draft?", true},
|
||||
{"why does my mechanical keyboard sometimes chatter?", false},
|
||||
{"can my USB-C charger safely power a Steam Deck?", false},
|
||||
{"how do I stop condensation inside my camera lens?", false},
|
||||
{"what caused the Tacoma Narrows Bridge to collapse?", false},
|
||||
{"why are some auroras red instead of green?", false},
|
||||
{"which codecs does the current Firefox release support?", false},
|
||||
}
|
||||
|
||||
b := &personalBoundary{}
|
||||
b.load(context.Background(), emb)
|
||||
correct := 0
|
||||
minimumMargin := math.Inf(1)
|
||||
for _, testCase := range cases {
|
||||
vector, err := router.EmbedQuery(context.Background(), emb, testCase.utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", testCase.utterance, err)
|
||||
}
|
||||
personal, world, ok := b.score(vector)
|
||||
if !ok {
|
||||
t.Fatal("loaded boundary declined to score")
|
||||
}
|
||||
got := personal > world
|
||||
signedMargin := personal - world
|
||||
if !testCase.personal {
|
||||
signedMargin = -signedMargin
|
||||
}
|
||||
if signedMargin < minimumMargin {
|
||||
minimumMargin = signedMargin
|
||||
}
|
||||
if got == testCase.personal {
|
||||
correct++
|
||||
} else {
|
||||
t.Logf("miss %q: personal=%v want %v (%.4f/%.4f)", testCase.utterance, got, testCase.personal, personal, world)
|
||||
}
|
||||
}
|
||||
t.Logf("regularisation challenge: %d/%d, minimum signed margin %+.4f", correct, len(cases), minimumMargin)
|
||||
if correct != len(cases) {
|
||||
t.Errorf("regularisation challenge %d/%d, want every case correct", correct, len(cases))
|
||||
}
|
||||
}
|
||||
|
||||
// TestONNXPersonalBoundaryPostRetuneChallenge was authored only after the L2
|
||||
// coefficient and frozen head had been selected using corpus cross-validation.
|
||||
// It deliberately returns to private configuration, commitments and stored
|
||||
// choices with new objects, and contrasts them with public technical facts,
|
||||
// compatibility and maintenance. No result from this table may be used to
|
||||
// tune the current head; a miss is evidence for the next independently
|
||||
// evaluated model revision.
|
||||
func TestONNXPersonalBoundaryPostRetuneChallenge(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
|
||||
}{
|
||||
{"какое имя я выбрал для гостевой сети Wi-Fi?", true},
|
||||
{"на какой день я перенёс техосмотр машины?", true},
|
||||
{"какую сумму мы с Мариной согласовали за ремонт кухни?", true},
|
||||
{"где я сохранил резервные коды от GitHub?", true},
|
||||
{"какой из макетов визитки я одобрил?", true},
|
||||
{"что я решил делать со страховкой перед поездкой?", true},
|
||||
{"какой диапазон частот использует Wi-Fi 6E?", false},
|
||||
{"почему OLED-экраны со временем выгорают?", false},
|
||||
{"можно ли подключить монитор DisplayPort к Thunderbolt 4?", false},
|
||||
{"чем безопасно чистить замшевые ботинки?", false},
|
||||
{"когда появился протокол WebSocket?", false},
|
||||
{"почему соль ускоряет таяние льда?", false},
|
||||
{"which hostname did I assign to the home NAS?", true},
|
||||
{"what date did I move the annual checkup to?", true},
|
||||
{"where did I save the recovery phrase for the hardware wallet?", true},
|
||||
{"which catering quote did we accept for the party?", true},
|
||||
{"what did I decide about renewing the domain?", true},
|
||||
{"which paint sample did I approve for the hallway?", true},
|
||||
{"does Wi-Fi 7 work with older wireless clients?", false},
|
||||
{"why can an SSD slow down when it is nearly full?", false},
|
||||
{"how should suede shoes be cleaned?", false},
|
||||
{"when was the WebSocket protocol standardized?", false},
|
||||
{"what does a hardware-wallet recovery phrase do?", false},
|
||||
{"why does road salt damage concrete?", false},
|
||||
}
|
||||
|
||||
b := &personalBoundary{}
|
||||
b.load(context.Background(), emb)
|
||||
correct := 0
|
||||
minimumMargin := math.Inf(1)
|
||||
for _, testCase := range cases {
|
||||
vector, err := router.EmbedQuery(context.Background(), emb, testCase.utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", testCase.utterance, err)
|
||||
}
|
||||
personal, world, ok := b.score(vector)
|
||||
if !ok {
|
||||
t.Fatal("loaded boundary declined to score")
|
||||
}
|
||||
got := personal > world
|
||||
signedMargin := personal - world
|
||||
if !testCase.personal {
|
||||
signedMargin = -signedMargin
|
||||
}
|
||||
if signedMargin < minimumMargin {
|
||||
minimumMargin = signedMargin
|
||||
}
|
||||
if got == testCase.personal {
|
||||
correct++
|
||||
} else {
|
||||
t.Logf("miss %q: personal=%v want %v (%.4f/%.4f)", testCase.utterance, got, testCase.personal, personal, world)
|
||||
}
|
||||
}
|
||||
t.Logf("post-retune challenge: %d/%d, minimum signed margin %+.4f", correct, len(cases), minimumMargin)
|
||||
if correct != len(cases) {
|
||||
t.Errorf("post-retune challenge %d/%d, want every case correct", correct, len(cases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestONNXPersonalBoundaryLatency(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()
|
||||
ctx := context.Background()
|
||||
query, err := router.EmbedQuery(ctx, emb, "что я решил насчёт переезда?")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := &personalBoundary{}
|
||||
coldStart := time.Now()
|
||||
b.load(ctx, emb)
|
||||
if _, _, ok := b.score(query); !ok {
|
||||
t.Fatal("loaded boundary declined to score")
|
||||
}
|
||||
cold := time.Since(coldStart)
|
||||
|
||||
const iterations = 100000
|
||||
steadyStart := time.Now()
|
||||
for i := 0; i < iterations; i++ {
|
||||
if _, _, ok := b.score(query); !ok {
|
||||
t.Fatal("loaded boundary declined to score")
|
||||
}
|
||||
}
|
||||
steady := time.Since(steadyStart) / iterations
|
||||
t.Logf("boundary cold load+train+score: %s; steady score: %s/op", cold, steady)
|
||||
// This is a user-visible first-turn path. Keep a generous ceiling to avoid
|
||||
// noisy CI while making an accidental per-turn training/load regression
|
||||
// unmistakable.
|
||||
if cold > 5*time.Second {
|
||||
t.Errorf("cold boundary load %s exceeds 5s local usability ceiling", cold)
|
||||
}
|
||||
if steady > 100*time.Microsecond {
|
||||
t.Errorf("steady boundary score %s exceeds 100µs ceiling", steady)
|
||||
}
|
||||
}
|
||||
|
||||
func TestONNXPersonalBoundaryFrozenHeadMatchesCorpusFit(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()
|
||||
ctx := context.Background()
|
||||
embedAll := func(values []string) [][]float32 {
|
||||
vectors := make([][]float32, len(values))
|
||||
for i, value := range values {
|
||||
vector, err := router.EmbedQuery(ctx, emb, value)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", value, err)
|
||||
}
|
||||
vectors[i] = vector
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
fitted, ok := trainPersonalBoundaryLinearHead(embedAll(personalSeeds), embedAll(worldSeeds))
|
||||
if !ok {
|
||||
t.Fatal("corpus fit failed")
|
||||
}
|
||||
frozen, ok := frozenPersonalBoundaryHead()
|
||||
if !ok {
|
||||
t.Fatal("frozen head did not decode")
|
||||
}
|
||||
if math.Abs(fitted.bias-frozen.bias) > 1e-9 {
|
||||
t.Fatalf("frozen bias %.12f != fitted %.12f", frozen.bias, fitted.bias)
|
||||
}
|
||||
for i := range fitted.weights {
|
||||
if math.Abs(fitted.weights[i]-frozen.weights[i]) > 5e-7 {
|
||||
t.Fatalf("frozen weight %d %.12f != fitted %.12f", i, frozen.weights[i], fitted.weights[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A Praxis lifecycle failure used to return a hardcoded constant that named the
|
||||
// verb and never the service, so an outage, a refused token and a contract
|
||||
// mismatch all produced the identical sentence (Vikunja #588). The helpers and
|
||||
// the Hexis half of the same defect are in ecosystem_gap_test.go.
|
||||
|
||||
func TestPraxisLifecycle401NamesPraxis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"), routeCandidate("resolve_item"))
|
||||
if !strings.Contains(reply, servicePraxis) {
|
||||
t.Fatalf("praxis failure does not name Praxis: %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, phraser.A(phraser.EcoDenied, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("401 from praxis: got %q, want the denied line", reply)
|
||||
}
|
||||
// The verb that did not happen is still said: the trace is the only other
|
||||
// place it exists and he is not reading the trace.
|
||||
if !strings.Contains(reply, "не получилось отметить сделанным.") {
|
||||
t.Errorf("reply dropped the operation that failed: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisLifecycleOutageDiffersFrom401 — the identity that was the bug.
|
||||
func TestPraxisLifecycleOutageDiffersFrom401(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"), routeCandidate("acknowledge_item"))
|
||||
|
||||
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
|
||||
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"), routeCandidate("acknowledge_item"))
|
||||
|
||||
if refused == outage {
|
||||
t.Fatalf("a refused token and an outage still say the same thing: %q", refused)
|
||||
}
|
||||
if !strings.Contains(outage, phraser.A(phraser.EcoDown, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("praxis outage: got %q, want the outage line naming Praxis", outage)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user