Compare commits
239 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed491e23fc | |||
| 246db4e609 | |||
| b6abb19090 | |||
| 1f7fd476ec | |||
| 08512ad58b | |||
| ff71d981ef | |||
| 4d83f8c785 | |||
| a439117995 | |||
| 2689715c2d | |||
| 05f47aef4b | |||
| 45a5e37963 | |||
| 6d8a95095a | |||
| 7e21cd06b3 | |||
| 09c648b934 | |||
| 4f516657da | |||
| 990a4a99e9 | |||
| 5b622389c5 | |||
| a820a95ebb | |||
| ea9c746852 | |||
| d60a51c9e7 | |||
| 9aabb01e2a | |||
| 2815adee03 | |||
| 9f51596e2f | |||
| 87d176153a | |||
| 7d4b4ad736 | |||
| 569991bb15 | |||
| c915115096 | |||
| 908d92a7e8 | |||
| 43f2c37538 | |||
| 6d3f5b5b01 | |||
| eda1112f3b | |||
| 71041029e2 | |||
| 35018226ef | |||
| 8833a9c76b | |||
| 6c07409452 | |||
| 1c2541f7d6 | |||
| 9e25f18a3e | |||
| 197897516e | |||
| 767748720a | |||
| 58051b5af1 | |||
| f9b2391a8b | |||
| f229795cea | |||
| 6e5364a0ed | |||
| 86817d6d06 | |||
| 0fc2e3a18a | |||
| 453919db20 | |||
| ad60e10e95 | |||
| 1528697287 | |||
| dbdab2d570 | |||
| b9371dcac6 | |||
| 62c2e92ec0 | |||
| aec94eb2e8 | |||
| 4dfe106fe3 | |||
| 2e0e2fd0bb | |||
| f3fa6b353a | |||
| 6645f64c3e | |||
| f10e0068dd | |||
| 9b124d9194 | |||
| 12530c8a95 | |||
| 51256c4c9a | |||
| 76481c2736 | |||
| bcc2305cd0 | |||
| 0ceeac8df4 | |||
| 4fae13af75 | |||
| 774217199e | |||
| 2db59d52a7 | |||
| 92d5fd580c | |||
| edeef19ff0 | |||
| 018f7a6f47 | |||
| eca41798bd | |||
| cc423567e7 | |||
| 8088ef9e00 | |||
| 666b924d29 | |||
| e52c616592 | |||
| 2b97bac51e | |||
| ab42db2b87 | |||
| 94d553570d | |||
| 2e97b905b4 | |||
| fbcca449be | |||
| 2076e4a788 | |||
| 30eb6add1b | |||
| dc266056d1 | |||
| 1c786b7156 | |||
| a3af10a830 | |||
| c0de473382 | |||
| 3e534340bf | |||
| 1a704d704d | |||
| e57adcb001 | |||
| bec7362b7b | |||
| a3ec746a01 | |||
| af0eec250e | |||
| 20aa2d59c9 | |||
| 4bad90dedb | |||
| 2b8d0f74fa | |||
| af9d2133dc | |||
| a1fdfccd61 | |||
| 5c05163266 | |||
| 92d2629001 | |||
| bdcfccce77 | |||
| f4deccacc9 | |||
| 8aaac01de6 | |||
| feb6f2c03d | |||
| 99bb3526db | |||
| bb8cb8d014 | |||
| 5e66aa8f22 | |||
| e332f167b2 | |||
| 322401b9af | |||
| 4f34a232d4 | |||
| 93987f2dfc | |||
| c0d61a71a4 | |||
| 0b89294af7 | |||
| 7079a240f7 | |||
| 587f1e6a07 | |||
| 99193ff1d1 | |||
| 63a389a1f8 | |||
| 2150a18e98 | |||
| 612ca8cf1b | |||
| 14e98334ad | |||
| a103708a08 | |||
| a654b0126f | |||
| b8227295b8 | |||
| b35151418a | |||
| 17964d1162 | |||
| 079cf689aa | |||
| 9397f9e5f6 | |||
| 3f98a99f44 | |||
| 53616836db | |||
| cf40f13573 | |||
| 7d08d27efb | |||
| d0d0021659 | |||
| 79c3b994cf | |||
| ba1d8e3f44 | |||
| 29329b5f0e | |||
| 47dda97226 | |||
| 8fdb9e5cd1 | |||
| f1a809121b | |||
| 79893d646b | |||
| c04c5eca9c | |||
| bfdbe0045e | |||
| d09954d85d | |||
| 7e402b279d | |||
| 6915e6a714 | |||
| 0db31d21b9 | |||
| df3220d039 | |||
| 76a251a20d | |||
| 724e90759e | |||
| 2bf11f052d | |||
| 2ca5ffa4f9 | |||
| 77888c1a9c | |||
| 71b42e31bd | |||
| cb04799b09 | |||
| 2a3701d012 | |||
| 327726a06a | |||
| 57161fb762 | |||
| 8846b7e43c | |||
| b436be69c3 | |||
| d0e98a9419 | |||
| 9a9f4464d5 | |||
| 543aefde4b | |||
| e926e4e6df | |||
| 694d9e4e45 | |||
| 4b052fb9d2 | |||
| 61ba58388f | |||
| 5e52b55ee9 | |||
| 59cdcc4e19 | |||
| 3588da9e28 | |||
| d3fcc1dfdb | |||
| 89afe4ca99 | |||
| fa783cba8f | |||
| f8af9299dd | |||
| 5c17b2db06 | |||
| 6316354518 | |||
| 88d25d31ac | |||
| 708a69375f | |||
| d68708b5e1 | |||
| 3ff2a9340a | |||
| 617476772e | |||
| 802d5961ac | |||
| 252f773223 | |||
| f432eb0b25 | |||
| 5aaecd2a53 | |||
| ec5167de3a | |||
| f02f3b55b6 | |||
| da62a2f25e | |||
| 52f56947bb | |||
| 5e0417306b | |||
| 87d03cf8c6 | |||
| 1c94df76b7 | |||
| 9e383eb751 | |||
| 8c6332f95c | |||
| bddf52d1ee | |||
| b3c2fad4ec | |||
| d62ba093f5 | |||
| c21d8fdcee | |||
| 810076451f | |||
| fa799bc051 | |||
| 4757ff6d7b | |||
| 7ab9b48259 | |||
| aee20a6abc | |||
| b2eb08bb51 | |||
| ba33a677f8 | |||
| f891a81ab2 | |||
| 38b09ded95 | |||
| 6c81df17ec | |||
| d69a1f8076 | |||
| e4bfcd958f | |||
| 012bdcc1ae | |||
| 4f012e350c | |||
| 4e4c9170e3 | |||
| 88c841cb0e | |||
| 0e83ddf3df | |||
| 49dfeb879e | |||
| 7f42cc73be | |||
| 927e46bca3 | |||
| 08f3db318f | |||
| 69e2800ef3 | |||
| a8fcb404be | |||
| dc4c5b7841 | |||
| 33e53ee897 | |||
| 45b5e16eff | |||
| 4eca20bd94 | |||
| fed33a4e16 | |||
| 62cc072f8c | |||
| 7c7bd8ceeb | |||
| aa1a26532c | |||
| d92349ca6e | |||
| 8d5e357b57 | |||
| 95ae900a58 | |||
| be066a4b04 | |||
| ad074cea31 | |||
| 2c1b0eede0 | |||
| cb3641e7bb | |||
| ee7bec11e3 | |||
| f42d1594ef | |||
| b4646155b4 | |||
| da647e87d0 | |||
| bf6ccf9aea | |||
| 7b2b96b957 | |||
| c8444813e2 |
@@ -0,0 +1,160 @@
|
||||
# Maven project dictionary for the direct-prose skill.
|
||||
#
|
||||
# These terms override every word preference in the skill's word-choice tables.
|
||||
# Each entry exists because the name drifted in real docs or real answers, not
|
||||
# because the word looked improvable.
|
||||
#
|
||||
# Format and the rule for adding a term: ~/.claude/skills/direct-prose/references/modes.md
|
||||
|
||||
terms:
|
||||
resident_model:
|
||||
name: resident model
|
||||
meaning: the one always-warm Qwen3-1.7B llama-server that both routes and phrases
|
||||
avoid:
|
||||
- the model
|
||||
- the LLM
|
||||
- the 1.7B
|
||||
- the phraser model
|
||||
examples:
|
||||
good: The resident model emits GBNF-constrained JSON.
|
||||
bad: The 1.7B emits GBNF-constrained JSON.
|
||||
|
||||
router:
|
||||
name: router
|
||||
meaning: the stage that turns an utterance into a Decision with one of 7 intents
|
||||
avoid:
|
||||
- orchestrator
|
||||
- intent classifier
|
||||
- dispatcher
|
||||
|
||||
classifier:
|
||||
name: classifier
|
||||
meaning: the embedder nearest-neighbour path that runs when the router is off or errors
|
||||
avoid:
|
||||
- the fallback
|
||||
- the floor
|
||||
- the old router
|
||||
examples:
|
||||
good: A router error falls through to the classifier.
|
||||
bad: A router error falls through to the floor.
|
||||
|
||||
cascade:
|
||||
name: cascade
|
||||
meaning: the ordered path stage 0, then router, then classifier
|
||||
avoid:
|
||||
- the pipeline
|
||||
- the chain
|
||||
- the fallback chain
|
||||
|
||||
stage_0:
|
||||
name: stage 0
|
||||
meaning: the deterministic rules that answer before the resident model is called
|
||||
avoid:
|
||||
- the fast path
|
||||
- bypass
|
||||
- deterministic assist
|
||||
- preemption
|
||||
examples:
|
||||
good: Stage 0 routes agenda questions to IntentQuery.
|
||||
bad: The bypass routes agenda questions to IntentQuery.
|
||||
|
||||
query_source:
|
||||
name: query source
|
||||
meaning: one entry in querySources, which either claims a turn or passes
|
||||
avoid:
|
||||
- arm
|
||||
- handler
|
||||
- branch
|
||||
- answerer
|
||||
examples:
|
||||
good: Kiwix is the last query source before the model answers from memory.
|
||||
bad: Kiwix is the last arm before the model answers from memory.
|
||||
|
||||
personal_boundary:
|
||||
name: personal boundary
|
||||
meaning: the query source that stops a question about him from reaching the world
|
||||
avoid:
|
||||
- the boundary
|
||||
- the privacy gate
|
||||
- the personal filter
|
||||
|
||||
clarify:
|
||||
name: clarify
|
||||
meaning: the turn outcome where Maven asks instead of acting
|
||||
avoid:
|
||||
- refusal
|
||||
- rejection
|
||||
- punt
|
||||
examples:
|
||||
good: The gate produced two false clarifies.
|
||||
bad: The gate produced two false refusals.
|
||||
|
||||
fact:
|
||||
name: fact
|
||||
meaning: a keyed, supersedable row in the fact store
|
||||
avoid:
|
||||
- memory entry
|
||||
- datum
|
||||
- record
|
||||
|
||||
note:
|
||||
name: note
|
||||
meaning: free text he captured, indexed for recall
|
||||
avoid:
|
||||
- memo
|
||||
- entry
|
||||
|
||||
memory:
|
||||
name: memory
|
||||
meaning: the embedded index over notes and facts that backs recall
|
||||
avoid:
|
||||
- RAG store
|
||||
- vector db
|
||||
- long-term memory
|
||||
|
||||
nudge:
|
||||
name: nudge
|
||||
meaning: one proactive message the digestion worker proposes and the dispatcher sends
|
||||
avoid:
|
||||
- suggestion
|
||||
- proposal
|
||||
- proactive prompt
|
||||
- reminder
|
||||
examples:
|
||||
good: A fact can close the nudge that asked for it.
|
||||
bad: A fact can close the suggestion that asked for it.
|
||||
|
||||
digestion_worker:
|
||||
name: digestion worker
|
||||
meaning: the background engine that consolidates memory and proposes nudges
|
||||
avoid:
|
||||
- digestion tick
|
||||
- background engine
|
||||
- reflection loop
|
||||
|
||||
reach:
|
||||
name: reach
|
||||
meaning: an outbound channel Maven speaks through, such as telegram, ntfy or voice
|
||||
avoid:
|
||||
- sink
|
||||
- delivery channel
|
||||
- notification backend
|
||||
|
||||
ecosystem:
|
||||
name: ecosystem
|
||||
meaning: Nexus, Praxis and Hexis together
|
||||
avoid:
|
||||
- the services
|
||||
- the integrations
|
||||
- upstream
|
||||
|
||||
act:
|
||||
name: act
|
||||
meaning: the intent that runs a capability through Hexis
|
||||
avoid:
|
||||
- action
|
||||
- command
|
||||
- execution
|
||||
examples:
|
||||
good: An act with no allowlisted fn is gated to a clarify.
|
||||
bad: An action with no allowlisted fn is gated to a clarify.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=.claude/prose-dictionary.yaml; [ -f \"$f\" ] && jq -Rs '{hookSpecificOutput:{hookEventName:\"SessionStart\",additionalContext:(\"Project prose dictionary. These terms override every word preference in the direct-prose output style. Use the name, never the avoid list.\\n\\n\"+.)}}' \"$f\" 2>/dev/null || true",
|
||||
"statusMessage": "Loading prose dictionary"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=HANDOFF.md; [ -f \"$f\" ] && jq -Rs '{hookSpecificOutput:{hookEventName:\"SessionStart\",additionalContext:(\"An unconsumed HANDOFF.md is present. Run the pickup skill before anything else: read it, read the Vikunja task it names, restate the assumption set in at most five bullets, and wait for the user to confirm before writing code. It is a claim from the previous session, not truth. Delete it once consumed.\\n\\n\"+.)}}' \"$f\" 2>/dev/null || true",
|
||||
"statusMessage": "Loading handoff"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: pickup
|
||||
description: Start a work session on a Maven task. Runs task start, reads the brief and the disposable handoff, restates the assumption set, and waits for correction before touching code. Use at the start of any session that continues earlier work, when the user says "pickup", "continue", "resume", or names a Vikunja task id.
|
||||
---
|
||||
|
||||
# Pickup
|
||||
|
||||
The point of this skill is the pause in step 5. Every wasted session in this repo
|
||||
started with an agent that inferred the goal instead of stating it back.
|
||||
|
||||
## 0. Get on the branch
|
||||
|
||||
```sh
|
||||
task start <vikunja-id>
|
||||
```
|
||||
|
||||
`~/.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.
|
||||
|
||||
`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.
|
||||
|
||||
## 1. Read the handoff
|
||||
|
||||
`HANDOFF.md` at the repo root, if it exists. It is gitignored, it belongs to one
|
||||
session, and it holds only what is needed to resume. Treat it as a claim from the
|
||||
previous agent, not as truth. It can be stale or wrong.
|
||||
|
||||
If there is no handoff, that is normal. It means the last session closed clean.
|
||||
|
||||
## 2. Read the durable state
|
||||
|
||||
In this order, and stop as soon as you have enough:
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
`git status`, `git log --oneline -5`, and the diff on the current branch. What the
|
||||
repo says beats what any document says.
|
||||
|
||||
## 4. Restate, then stop
|
||||
|
||||
Write at most five bullets and stop. Do not write code, do not open files to "check
|
||||
one thing first", do not start with a small safe change.
|
||||
|
||||
```
|
||||
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.
|
||||
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, not into the handoff.
|
||||
The handoff dies tonight. The task does not.
|
||||
|
||||
## 5. Then begin
|
||||
|
||||
- Delete `HANDOFF.md`. It has been consumed and must not outlive this step.
|
||||
- On master, cut the branch: `scripts/task-branch.sh <id> <slug>`.
|
||||
- One task per session. When context passes roughly half, run `/wrap` rather than
|
||||
pushing on. A compacted session is a session that forgot why it made a choice.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: wrap
|
||||
description: Close a Maven work session cleanly. Runs the tests, updates the durable docs, commits in reviewable slices with the Vikunja ref, pushes so the PR opens, records state in Vikunja, and leaves a disposable handoff only if work remains. Use when the user says "wrap", "wrap up", "done for now", or when context passes roughly half.
|
||||
---
|
||||
|
||||
# Wrap
|
||||
|
||||
Run every step. A partial wrap is worse than none, because the next session trusts
|
||||
the parts that did run.
|
||||
|
||||
## 1. Prove it works
|
||||
|
||||
`make test`. If something fails, fix it or say plainly in the handoff and in Vikunja
|
||||
that it fails, with the output. Never wrap on an untested claim.
|
||||
|
||||
## 2. Update the durable docs
|
||||
|
||||
Ask what a future agent would have to learn the hard way, and write that down.
|
||||
|
||||
- `CLAUDE.md` when a fact an agent needs before touching code has changed: routing
|
||||
behaviour, a measured number, a flag default, a constraint. A commit that changed
|
||||
routing or phrasing without touching the matching CLAUDE.md section is a bug.
|
||||
Correct stale text in place. Do not append a new paragraph next to the wrong one.
|
||||
- `AGENTS.md` when the recipe to build, run or preview changed.
|
||||
- The one file under `docs/` that owns the area, plus its `Last verified: <date> @ <sha>`
|
||||
line. Only a doc directly under `docs/` carries that line.
|
||||
- A new dated file under `docs/evals/` when you measured something. Never edit an
|
||||
existing dated file. A newer measurement is a new file, and the living doc points
|
||||
at it.
|
||||
|
||||
Nothing that must survive tonight goes anywhere else. Not into the handoff, not into
|
||||
a commit message, not into a comment in the code.
|
||||
|
||||
## 3. Commit in slices
|
||||
|
||||
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, and it
|
||||
ends with the Vikunja ref:
|
||||
|
||||
```
|
||||
router: narrow the single-token rule (V-359)
|
||||
```
|
||||
|
||||
If a change genuinely cannot split under 300 lines, say why in the commit body before
|
||||
reaching for `--no-verify`.
|
||||
|
||||
## 4. Land it
|
||||
|
||||
```sh
|
||||
task pr
|
||||
```
|
||||
|
||||
It refuses a dirty tree, pushes, opens or refreshes the PR against the repo default
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 6. Leave the handoff, or leave none
|
||||
|
||||
If the task is finished, delete `HANDOFF.md` and stop. An empty root is the correct
|
||||
end state.
|
||||
|
||||
If work remains, write `HANDOFF.md` with nothing but what the next agent needs to
|
||||
resume, and no history:
|
||||
|
||||
```markdown
|
||||
# Handoff — <date>
|
||||
|
||||
Task: V-359 <one line>
|
||||
Branch: task/359-<slug>, cut from master
|
||||
|
||||
## Where I stopped
|
||||
<two sentences, mid-thought detail that is nowhere else>
|
||||
|
||||
## Next action
|
||||
<the single concrete next step>
|
||||
|
||||
## Do not
|
||||
<the trap I nearly fell into, or the approach already ruled out>
|
||||
```
|
||||
|
||||
Nothing else goes in it. No summary of what landed, that is in git and Vikunja. No
|
||||
design rationale, that is in `docs/`. No fact an agent needs on any task, that is in
|
||||
`CLAUDE.md`. If a line in the handoff would still matter next week, it is in the wrong
|
||||
file.
|
||||
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
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Two guards, both bypassable with --no-verify when you mean it.
|
||||
# 1. master is not a working branch.
|
||||
# 2. a code commit stays under 300 changed lines.
|
||||
# Markdown is exempt from the size cap on purpose: docs land as one batch.
|
||||
|
||||
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
|
||||
|
||||
case "$branch" in
|
||||
master|main)
|
||||
echo "pre-commit: refusing to commit on $branch." >&2
|
||||
echo " task start <vikunja-id> # branch off origin/master, write TASK.md" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Added + deleted lines across staged files that are not markdown.
|
||||
# numstat prints "-\t-\t<path>" for binaries; those count 0 and that is fine,
|
||||
# a binary blob is not the kind of diff this cap exists to stop.
|
||||
loc=$(git diff --cached --numstat -- . ':(exclude)*.md' |
|
||||
awk '$1 ~ /^[0-9]+$/ { a += $1 } $2 ~ /^[0-9]+$/ { d += $2 } END { print a + d + 0 }')
|
||||
|
||||
if [ "$loc" -gt 300 ]; then
|
||||
echo "pre-commit: $loc changed lines in non-markdown files, cap is 300." >&2
|
||||
echo " Split it. Each commit should be one reviewable idea." >&2
|
||||
echo " git reset <path> to unstage, or --no-verify if this genuinely cannot split." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+26
-2
@@ -7,6 +7,9 @@
|
||||
/mavpoll
|
||||
/mavcaldav
|
||||
/mavwaked
|
||||
/mavmaild
|
||||
/mavupdate
|
||||
/mavgpud
|
||||
|
||||
# Certs (private keys, don't commit)
|
||||
certs/
|
||||
@@ -34,6 +37,13 @@ deps
|
||||
deploy/db_key.env
|
||||
# Deploy secret (telegram bot token + chat id) — never commit
|
||||
deploy/telegram.env
|
||||
# zenmoney API token, read by mavpoll (never in argv, never committed)
|
||||
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/
|
||||
@@ -44,5 +54,19 @@ opencode.json
|
||||
# Test coverage output
|
||||
coverage.out
|
||||
|
||||
# Agent worktrees and local agent state
|
||||
.claude/
|
||||
# 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.
|
||||
/.claude/*
|
||||
!/.claude/settings.json
|
||||
!/.claude/prose-dictionary.yaml
|
||||
!/.claude/skills/
|
||||
|
||||
# The disposable handoff. One session, then deleted. Never committed:
|
||||
# anything worth keeping belongs in Vikunja, CLAUDE.md or docs/.
|
||||
/HANDOFF.md
|
||||
/models/stt
|
||||
/models/tts
|
||||
|
||||
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
|
||||
.env
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
beyond the model and tts work, the useful additions are mostly around **reliability, context, and reach**, not more intelligence.
|
||||
|
||||
## highest-value additions
|
||||
|
||||
### 1. unified event intake
|
||||
|
||||
maven should receive normalized events from:
|
||||
|
||||
* praxis
|
||||
* calendar
|
||||
* telegram
|
||||
* local notifications
|
||||
* system/service health
|
||||
* manual checklists
|
||||
* eventually email bridges
|
||||
|
||||
one internal envelope:
|
||||
|
||||
```go
|
||||
type Event struct {
|
||||
Source string
|
||||
Kind string
|
||||
EntityIDs []string
|
||||
Title string
|
||||
Body string
|
||||
Priority string
|
||||
OccurredAt time.Time
|
||||
Payload json.RawMessage
|
||||
}
|
||||
```
|
||||
|
||||
this gives digestion one stable input instead of source-specific logic.
|
||||
|
||||
---
|
||||
|
||||
### 2. explicit morning routine engine — **core engine done (2026-07-20)**
|
||||
|
||||
`internal/morning` — pure checklist engine, mirrors `internal/loop`/
|
||||
`internal/routine`'s no-I/O contract. `Evaluate(routine, facts, now)` answers
|
||||
"what's still missing" any time (order-independent — checks facts, not
|
||||
sequence); `Due(routines, facts, last, now)` fires the once-per-day nag only
|
||||
at `NudgeAt` (defaults to window end) and only when something's unevidenced,
|
||||
with a `last`-map dedupe identical in shape to `routine.Due`'s cold-start/
|
||||
last-fire tracking. Evidence is just a fact timestamped inside today's
|
||||
window — manual (voice-tapped) and inferred (another daemon writing the same
|
||||
key) are indistinguishable, satisfying the manual/inferred requirement for
|
||||
free. Weekday/weekend variants are two `Routine`s with different `Weekdays`
|
||||
sets under different names. Wired into `config.MorningRoutineConfig` +
|
||||
`cmd/mavend/tick.go`'s `fireMorningRoutines` (reads only the fact keys the
|
||||
configured items reference, dispatches through the normal severity/presence
|
||||
routing table, body is literal joined item labels — not LLM-phrased, same
|
||||
no-hallucination rationale as cron routines). 13 unit tests in
|
||||
`internal/morning/morning_test.go`.
|
||||
|
||||
Added since (2026-07-20, same day): a read-only `/morning` page in mavweb —
|
||||
`ipc.CoreAPI.MorningStatus` (new wire method, mirrors `TickTrace`'s
|
||||
daemon-cache-only shape: the store adapter errors, `daemonAPI` serves it from
|
||||
a `tickLoop.morningStatus` closure) returns each routine's active/window/
|
||||
per-item done state, server-rendered same as `/trace` (no live-update loop —
|
||||
checklist state moves on minutes, not seconds).
|
||||
|
||||
Not yet done: no config wired in `deploy/mavend.json` (no morning routines
|
||||
configured on homesrv yet — add items there when the medicine/water/pets
|
||||
fact keys the phone/desktop write are settled), no voice query path for
|
||||
"what did I miss this morning" (Evaluate supports it; nothing calls it yet),
|
||||
no way to create/edit routines from the web UI — construction still means
|
||||
hand-editing config, deliberately deferred: routines are operator-declared
|
||||
config (like cron routines), and a CRUD editor would mean moving them to a
|
||||
DB table + hot-reload, a bigger change than this pass.
|
||||
|
||||
not ordinary reminders.
|
||||
|
||||
support:
|
||||
|
||||
* required morning items
|
||||
* order-independent completion
|
||||
* soft time windows
|
||||
* skipped-step detection
|
||||
* one nudge, not repeated spam
|
||||
* manual and inferred completion evidence
|
||||
* weekend/weekday variants
|
||||
|
||||
example:
|
||||
|
||||
```text
|
||||
08:00–11:00
|
||||
- medicine
|
||||
- water
|
||||
- pets
|
||||
- check praxis attention
|
||||
```
|
||||
|
||||
maven should know what is still missing, not merely fire four timers.
|
||||
|
||||
---
|
||||
|
||||
### 3. cross-device presence
|
||||
|
||||
**status (2026-07-20):** the hysteresis engine and 3 of the listed signals are
|
||||
already built and wired live: `internal/store/presence.go` (noisy-OR combiner
|
||||
+ Schmitt-trigger bucket resolve), fed by `desk_active` (workstation, via
|
||||
`scripts/desk-active.sh` posting to `/api/signal`), `page_heartbeat` (mavweb
|
||||
tab, `app.js`), and `wg_handshake` (`mavpoll` polling `wg show`) — threaded
|
||||
into the tick loop via `internal/loop/gather.go`. Not done: phone-reachable,
|
||||
homesrv-available, audio-output, and active-maven-client signals from the
|
||||
list below are still missing.
|
||||
|
||||
a small presence daemon on each trusted device:
|
||||
|
||||
* workstation active/idle
|
||||
* phone reachable
|
||||
* homesrv available
|
||||
* last keyboard/mouse activity
|
||||
* wireguard presence
|
||||
* current audio output
|
||||
* active maven client
|
||||
|
||||
mavend receives only compact state, not raw activity logs.
|
||||
|
||||
useful for:
|
||||
|
||||
* choosing delivery channel
|
||||
* suppressing voice while away
|
||||
* surfacing reminders when you return
|
||||
* knowing whether an agent result should be spoken or sent as text
|
||||
|
||||
---
|
||||
|
||||
### 4. interruption policy — **done (2026-07-20), turned out to already be built**
|
||||
|
||||
audited the existing code before writing anything new: `internal/loop.Gate`
|
||||
already answers deliver_now vs. drop (quiet-hours/cooldown/snooze/presence/
|
||||
calendar-busy), and `cmd/mavend/tick.go`'s `digestQ` + `config.DigestConfig`
|
||||
already implement queue/digest (low-severity nudges batch into one
|
||||
notification, flushed on window elapsed or max-items reached). The four
|
||||
outcomes below were already covered by these two mechanisms; nothing new to
|
||||
build for the core policy.
|
||||
|
||||
Gap that *was* real: `deploy/mavend.json` had no `digest` block, so batching
|
||||
was disabled in prod despite being fully implemented. Fixed — see the config
|
||||
change alongside this note.
|
||||
|
||||
before delivering anything, evaluate:
|
||||
|
||||
```text
|
||||
urgency
|
||||
current activity
|
||||
quiet hours
|
||||
recent nudges
|
||||
available channels
|
||||
whether already surfaced
|
||||
```
|
||||
|
||||
result:
|
||||
|
||||
```text
|
||||
deliver_now
|
||||
queue
|
||||
digest
|
||||
drop
|
||||
```
|
||||
|
||||
this prevents maven from becoming annoying once praxis and other sources start producing more data.
|
||||
|
||||
---
|
||||
|
||||
### 5. entity-aware memory — **done (2026-07-20)**
|
||||
|
||||
`03fa52d`/`9876187` (Vikunja #279): facts gain `Subject`/`EntityID`/
|
||||
`ResolutionState`; an async enrichment worker resolves free-text subjects to
|
||||
canonical Nexus entity_ids (mirrors Praxis's enrichment pattern). Ambiguous
|
||||
or unreachable Nexus never guesses — the fact stays `pending` or terminal
|
||||
`ambiguous`. Voice-tapped facts (`IntentFact`) now flow into the enrichment
|
||||
queue automatically via an optional `Subject` field on `WriteFactReq` (old
|
||||
callers unaffected).
|
||||
|
||||
Landed alongside this in the same session (not originally on this list, but
|
||||
closes the plumbing gaps the last brief flagged for Nexus/Praxis maturity):
|
||||
a typed Praxis lifecycle client (`398997f` — surface/acknowledge/resolve/
|
||||
ignore/pin; fixes the surfaced≠acknowledged gap where reading an item aloud
|
||||
left no trace), correlation-ID/version headers on the Nexus/Praxis clients
|
||||
(`b743860`), entity-scoped Praxis attention queries (`0579ef9`), a durable
|
||||
delivery outbox with begin-before-send/complete-after semantics
|
||||
(`29f23e3`+`9ff726e` — closes a duplicate-send-on-crash bug), fail-closed
|
||||
handling on ambiguous IPC mutation outcomes and Nexus/Hexis dependency
|
||||
errors (`838fde1`+`d9fa4d6`), and a reusable fake-ecosystem test harness
|
||||
with fault injection (`c932cd8`).
|
||||
|
||||
connect maven memory to nexus ids.
|
||||
|
||||
instead of:
|
||||
|
||||
```text
|
||||
key = "кошачий фонтан"
|
||||
```
|
||||
|
||||
store:
|
||||
|
||||
```text
|
||||
entity_id = ent_pet_water_fountain
|
||||
predicate = refilled_at
|
||||
value = 2026-07-19T...
|
||||
```
|
||||
|
||||
benefits:
|
||||
|
||||
* stable russian/english aliases
|
||||
* fewer duplicate facts
|
||||
* better “when did i last…” queries
|
||||
* easier routine detection
|
||||
* cleaner praxis correlation
|
||||
|
||||
---
|
||||
|
||||
### 6. bounded follow-up state
|
||||
|
||||
for short continuations:
|
||||
|
||||
* “yes”
|
||||
* “tomorrow”
|
||||
* “the second one”
|
||||
* “not that project”
|
||||
* “do it later”
|
||||
|
||||
store explicit pending state instead of relying on chat history:
|
||||
|
||||
```go
|
||||
type PendingInteraction struct {
|
||||
Kind string
|
||||
Candidates []string
|
||||
Args json.RawMessage
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
this matters a lot for a 1.7b model.
|
||||
|
||||
---
|
||||
|
||||
### 7. evaluation lab — **skipped for now (2026-07-20)**
|
||||
|
||||
runs on a different machine (GPU box), and CPT is currently in progress
|
||||
there — deprioritized until the training pipeline has a checkpoint to gate.
|
||||
Not abandoned, just off the immediate list.
|
||||
|
||||
before every new checkpoint or lora deploy:
|
||||
|
||||
* routing accuracy
|
||||
* slot accuracy
|
||||
* malformed json rate
|
||||
* russian/english mixed input
|
||||
* ambiguous entity handling
|
||||
* reminder vs note vs fact
|
||||
* direct answer vs tool call
|
||||
* confirmation safety
|
||||
* phrasing quality
|
||||
* latency and ram
|
||||
|
||||
also replay real anonymized traces against old and new checkpoints.
|
||||
|
||||
this should be a hard deployment gate.
|
||||
|
||||
---
|
||||
|
||||
### 8. replayable full-system simulator
|
||||
|
||||
fake:
|
||||
|
||||
* clock
|
||||
* presence
|
||||
* caldav
|
||||
* telegram
|
||||
* praxis
|
||||
* nexus
|
||||
* hexis
|
||||
* stt
|
||||
* tts
|
||||
* llama-server
|
||||
|
||||
scenario:
|
||||
|
||||
```text
|
||||
08:30 user appears
|
||||
08:35 medicine not completed
|
||||
08:40 correx agent waits
|
||||
08:45 calendar sync stale
|
||||
08:50 user says “what did i miss?”
|
||||
```
|
||||
|
||||
assert:
|
||||
|
||||
* what tools were called
|
||||
* what was surfaced
|
||||
* what stayed unresolved
|
||||
* what maven said
|
||||
* what was not executed
|
||||
|
||||
this will save more time than another feature daemon.
|
||||
|
||||
---
|
||||
|
||||
## useful second-wave additions
|
||||
|
||||
### voice session quality
|
||||
|
||||
* barge-in
|
||||
* interrupt tts on wake word
|
||||
* partial stt display
|
||||
* confidence-aware clarification
|
||||
* retry only failed stt segment
|
||||
* per-room microphone profiles
|
||||
* noise-floor calibration
|
||||
* short response mode when speaking
|
||||
|
||||
### notification bridge framework
|
||||
|
||||
small adapters for:
|
||||
|
||||
* ntfy
|
||||
* telegram
|
||||
* matrix
|
||||
* web push
|
||||
* android notification forwarding
|
||||
* local dbus notifications
|
||||
|
||||
normalize into maven/praxis events instead of treating each as a separate feature.
|
||||
|
||||
### local knowledge ingestion
|
||||
|
||||
* markdown/docs ingestion
|
||||
* git repo summaries
|
||||
* project decision records
|
||||
* conversation exports
|
||||
* provenance and source links
|
||||
* incremental reindexing
|
||||
|
||||
keep this read-only and separate from personal fact memory.
|
||||
|
||||
### service self-diagnostics
|
||||
|
||||
`maven doctor`:
|
||||
|
||||
* socket reachability
|
||||
* model health
|
||||
* stt/tts readiness
|
||||
* embedder availability
|
||||
* caldav freshness
|
||||
* telegram poll state
|
||||
* praxis/nexus/hexis reachability
|
||||
* db integrity
|
||||
* disk usage
|
||||
* recent failures
|
||||
|
||||
### config and secret management
|
||||
|
||||
* schema-validated config
|
||||
* config migration
|
||||
* secret references instead of inline values
|
||||
* dry-run validation
|
||||
* redacted config dump
|
||||
* per-daemon health config
|
||||
* startup dependency report
|
||||
|
||||
---
|
||||
|
||||
## things i would not build yet
|
||||
|
||||
* autonomous multi-step planning
|
||||
* large external reasoner
|
||||
* generic workflow engine
|
||||
* self-editing memory
|
||||
* automatic hexis actions from praxis
|
||||
* emotion simulation beyond phrasing
|
||||
* full home-assistant replacement
|
||||
* more model layers before routing is stable
|
||||
|
||||
## recommended order
|
||||
|
||||
**status as of 2026-07-20:**
|
||||
|
||||
1. ~~evaluation lab~~ — **skipped, GPU-box work, deprioritized while CPT is in progress**
|
||||
2. ~~entity-aware memory~~ — **done** (`03fa52d`/`9876187`, plus adjacent
|
||||
Nexus/Praxis plumbing hardening — see item 5 above)
|
||||
3. ~~morning routine engine~~ — **core engine done** (`internal/morning` +
|
||||
`cmd/mavend` wiring — see item 2 above; not yet configured on homesrv,
|
||||
no voice query, no web UI)
|
||||
4. interruption/delivery policy
|
||||
5. presence agents
|
||||
6. unified event intake
|
||||
7. full-system simulator
|
||||
8. notification bridges
|
||||
9. knowledge ingestion
|
||||
10. voice-session polish
|
||||
|
||||
the main goal should be: **maven reliably knows what is happening, knows what you meant, and chooses the least annoying correct response**. everything else can wait.
|
||||
|
||||
@@ -5,6 +5,46 @@ This repo maps to **Maven** (project ID: 2) in Vikunja.
|
||||
Feature work, bugs, deployment tasks all go here.
|
||||
MCP endpoint: `http://localhost:9100/mcp` (or `http://192.168.1.104:9100/mcp` from workpc)
|
||||
|
||||
## The sibling services (Nexus, Praxis, Hexis)
|
||||
|
||||
Maven is the conversational front end of a four-service ecosystem. The other three
|
||||
live in sibling repos next to this one.
|
||||
|
||||
| Service | Repo | Port | Answers |
|
||||
|---|---|---|---|
|
||||
| Nexus | `../nexus` | 9740 | who or what is this name |
|
||||
| Praxis | `../praxis` | 8989 | what needs attention |
|
||||
| Hexis | `../hexis` | 9741 | what can be run, and running it |
|
||||
|
||||
Division of labour: Nexus identifies, Praxis observes, Hexis acts, Maven understands
|
||||
and coordinates. Maven is not the source of truth for any of the three. The full
|
||||
contract is `docs/ecosystem.md`, and the constraints that bite during
|
||||
implementation are summarised in `CLAUDE.md`.
|
||||
|
||||
Where things are in this repo:
|
||||
|
||||
- `cmd/mavend/ecosystem.go` holds `nexusClient` and `praxisClient`. The Hexis client
|
||||
is vendored from `github.com/kami/hexis/pkg/client`.
|
||||
- `cmd/mavend/ecosystem_acts.go` routes an act through capability discovery.
|
||||
- `cmd/mavend/factenrichment.go` resolves each stored fact's `Subject` against Nexus
|
||||
on a background poll loop, with backoff and no give-up.
|
||||
- `internal/store/entityfacts.go` holds the entity-tagged fact rows.
|
||||
- Config blocks are `nexus`, `praxis` and `hexis` in `deploy/mavend.json`. Each is
|
||||
optional. Absent means that integration is dark, not broken.
|
||||
|
||||
Bring the whole ecosystem up locally:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/ecosystem/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
That builds all three from the sibling working trees, so commit or stash there first.
|
||||
Each publishes on loopback at the port above. Maven reaches them by service name on
|
||||
the shared compose network.
|
||||
|
||||
Testing without them running: `cmd/mavend/fakeecosystem_test.go` provides stubs, and
|
||||
`cmd/mavend/ecosystem_degraded_test.go` covers each service being unreachable.
|
||||
|
||||
## Rendering / previewing the web UI locally
|
||||
|
||||
To see mavweb pages with real data without touching the production stack:
|
||||
|
||||
@@ -10,7 +10,7 @@ compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B
|
||||
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
||||
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 `MODEL-BAKEOFF-31-07-2026.md`. It is a Thinking variant, so `n_ctx` is 4096
|
||||
talk fixture. See `docs/evals/2026-07-31-model-bakeoff.md`. It is a Thinking variant, so `n_ctx` is 4096
|
||||
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
|
||||
|
||||
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
||||
@@ -25,16 +25,35 @@ Spanish. Their strong published IFEval/BFCL numbers are English-only. Model file
|
||||
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
||||
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
||||
|
||||
See `REARCH.md` for the target architecture, `DESIGN.md` for the folded design spec, and
|
||||
See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and
|
||||
`AGENTS.md` for local-preview + model-download recipes.
|
||||
|
||||
**Model work is moving to the workstation** (owner's call, 2026-08-02). homesrv cannot grow a
|
||||
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
|
||||
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
|
||||
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
|
||||
stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small,
|
||||
quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:`
|
||||
prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced
|
||||
MiniLM and bought ten points of recall@1 and 2.5× the speed; see
|
||||
`docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before
|
||||
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||
are the work.
|
||||
|
||||
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
|
||||
through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A
|
||||
world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not
|
||||
free — `worldGap` in `cmd/mavend/worldmodel.go`, which he hears instead of an invented
|
||||
answer. A box with no `workstation` block behaves exactly as it did before the seam: naming
|
||||
a gap requires a gap. The offload table in `docs/offload.md` says which caller is which.
|
||||
|
||||
## Build & test
|
||||
|
||||
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 8 binaries
|
||||
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
|
||||
```
|
||||
@@ -62,18 +81,58 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
||||
| `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
|
||||
|
||||
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`.
|
||||
|
||||
```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. 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.
|
||||
|
||||
Every cross-service call carries a correlation id minted once per action
|
||||
(`withCorrelationID`), a contract version header, and `X-Requested-By: maven`.
|
||||
|
||||
## Routing — read this before touching the router
|
||||
|
||||
`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.
|
||||
|
||||
- **LLM router (the intended design, REARCH.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||||
- **LLM router (the intended design, docs/rearchitecture.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||||
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
||||
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`
|
||||
@@ -87,10 +146,23 @@ on in deploy** — this section used to say it was wired `nil`, which stopped be
|
||||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||||
|
||||
Measured on the 77-case RU fixture (`MODEL-BAKEOFF-31-07-2026.md`): the classifier scores
|
||||
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
|
||||
cascade at p50 ≈2.7s. Accuracy roughly doubled, latency is ~90× worse, and that trade was
|
||||
accepted deliberately. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
|
||||
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
|
||||
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
|
||||
seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores
|
||||
77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy,
|
||||
not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure
|
||||
that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
|
||||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
||||
work off the bakeoff table.
|
||||
|
||||
**The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing
|
||||
completes through `llm.Pair` against gemma-4-12b and scores **84.4% full / 93.5% intent-only at
|
||||
p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
|
||||
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
|
||||
routing change against the classifier and the resident model, since those are what always answer.
|
||||
|
||||
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||
#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
|
||||
@@ -101,8 +173,25 @@ Re-measured on the fixture after the fix: **missed clarify 6/6 → 1**, at the c
|
||||
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,
|
||||
`"поужинал"`, is a real defect: **the single-token rule is an English intuition and does not
|
||||
transfer to Russian**, where one word is routinely a whole sentence. Narrow or drop it.
|
||||
`"поужинал"`, 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.
|
||||
|
||||
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.
|
||||
|
||||
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|[?!.]|$)`.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
@@ -128,18 +217,27 @@ 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.
|
||||
- **Local sources first.** Kiwix ZIMs on homesrv (Wikipedia, ifixit) before anything on the
|
||||
network. Reading beats recalling for a small model, and a local read costs nothing.
|
||||
- **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.
|
||||
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
|
||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
|
||||
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
|
||||
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
|
||||
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
|
||||
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
|
||||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||||
|
||||
@@ -147,3 +245,60 @@ data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.m
|
||||
|
||||
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
|
||||
|
||||
`~/.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
|
||||
task comments # re-pull this branch's review comments into .task/
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ RUN go build -o /out/mavend ./cmd/mavend && \
|
||||
go build -o /out/mavttsd ./cmd/mavttsd && \
|
||||
go build -o /out/mavweb ./cmd/mavweb && \
|
||||
go build -o /out/mavpoll ./cmd/mavpoll && \
|
||||
go build -o /out/mavcaldav ./cmd/mavcaldav
|
||||
go build -o /out/mavcaldav ./cmd/mavcaldav && \
|
||||
go build -o /out/mavmaild ./cmd/mavmaild
|
||||
|
||||
# llama.cpp Vulkan build — the phraser/router LFM engine (llama-server). Built
|
||||
# from source (not a prebuilt vendored blob) so the binary's glibc/GLIBCXX match
|
||||
|
||||
@@ -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: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
|
||||
|
||||
all: build
|
||||
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update build-gpud
|
||||
|
||||
build-stt:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
@@ -50,6 +50,21 @@ build-poll:
|
||||
build-caldav:
|
||||
$(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/
|
||||
|
||||
build-mail:
|
||||
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
|
||||
|
||||
# mavupdate is an operator CLI, not a daemon: nothing runs it but a human on the
|
||||
# box. It is built with the rest so a broken update path is caught by `make
|
||||
# build` rather than the first time it is needed.
|
||||
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
|
||||
|
||||
@@ -69,7 +84,7 @@ deps-go:
|
||||
done
|
||||
$(GO) version
|
||||
|
||||
# fmt-check fails if any file needs gofmt. DESIGN.md has always said `make
|
||||
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
||||
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
||||
# Run `gofmt -w` on whatever this prints.
|
||||
fmt-check:
|
||||
@@ -82,6 +97,14 @@ vet:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) vet ./internal/... ./cmd/...
|
||||
|
||||
# simulate — replay every scripted day under cmd/mavend/testdata/scenarios
|
||||
# through the real router, store, tick loop and intake journal, on a fake clock
|
||||
# (Vikunja #284). Verbose so the transcript of each scenario lands in the
|
||||
# terminal. Also runs as part of `make test`; this target is for reading it.
|
||||
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: 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/...
|
||||
@@ -130,6 +153,22 @@ eval-models:
|
||||
MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \
|
||||
-run TestLLMRouterBaseline ./internal/router/eval/
|
||||
|
||||
# stt-fixtures — regenerate the golden STT audio in cmd/mavsttd/testdata from
|
||||
# the piper voices (#288). The committed WAVs are synthesised, never recorded,
|
||||
# so this is the only way they should ever change. The spoken text is read out
|
||||
# of testdata/golden_v1.json, so edit the transcript there and rerun this.
|
||||
#
|
||||
# test-stt-golden runs both golden tests: TestGoldenAudioTranscription, which
|
||||
# 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.
|
||||
stt-fixtures:
|
||||
./scripts/gen-stt-fixtures.sh
|
||||
|
||||
test-stt-golden:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -v -count=1 -run TestGolden ./cmd/mavsttd/
|
||||
|
||||
run-stt: build-stt
|
||||
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
|
||||
@@ -158,7 +197,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
|
||||
# RECALL-EVAL-31-07-2026.md.
|
||||
# docs/evals/2026-08-04-recall-e5-small.md for what the swap bought.
|
||||
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
||||
EMBEDDER_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
|
||||
@@ -185,4 +224,4 @@ download-embedder:
|
||||
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
|
||||
|
||||
clean:
|
||||
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked
|
||||
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked mavmaild
|
||||
|
||||
-468
@@ -1,468 +0,0 @@
|
||||
## Maven — current state (updated 2026-07-20)
|
||||
|
||||
### Session 2026-07-20 — ecosystem hardening + entity-aware facts
|
||||
|
||||
Ten commits, focused on closing the Nexus/Praxis integration gaps flagged
|
||||
as "wired but immature" in the prior review, plus the entity-aware-memory
|
||||
backlog item (`20-07-2026-BACKLOG.md` item 5).
|
||||
|
||||
- **Entity-aware fact resolution (Vikunja #279)** — facts gain
|
||||
`Subject`/`EntityID`/`ResolutionState`; an async worker resolves
|
||||
free-text subjects to canonical Nexus entity_ids (mirrors Praxis's own
|
||||
enrichment pattern). Ambiguous/unreachable Nexus never guesses — stays
|
||||
`pending` or terminal `ambiguous`. Voice-tapped facts (`IntentFact`) flow
|
||||
into the queue automatically via an optional `Subject` field on
|
||||
`WriteFactReq` (old callers unaffected, no signature break).
|
||||
- **Typed Praxis lifecycle client (Vikunja #271)** — `GetItem`/`Search`/
|
||||
`Surface`/`Acknowledge`/`Resolve`/`Ignore`/`Pin`, routed through new RU/EN
|
||||
dialogue verbs. Fixes a real lifecycle-invariant bug: reading an
|
||||
attention item aloud now calls `Surface` — previously the digest path
|
||||
read items without recording that they'd been surfaced, so "Maven
|
||||
mentioned it" was indistinguishable from "never came up."
|
||||
- **Durable delivery outbox (Vikunja #270)** — `BeginDeliveryAttempt`
|
||||
before `Send`, `CompleteDeliveryAttempt` after; a stale `pending` row
|
||||
found at startup reconciles to `unknown` (never silently resent or
|
||||
dropped — same rule as Hexis's execution-timeout handling). Closes a
|
||||
crash-window duplicate-send bug. Wired into `DispatchNudge`,
|
||||
`DispatchReminder`, `RepeatUnacked`; reconciliation runs once at boot
|
||||
before the tick loop resumes.
|
||||
- **Fail-closed IPC/dependency handling (Vikunja #269, #272/#273)** —
|
||||
ambiguous mutation outcomes (frame sent, reply lost) no longer blindly
|
||||
retry; Nexus/Hexis dependency errors fail closed instead of guessing.
|
||||
- **Correlation IDs + version headers (Vikunja #273)** — the hand-rolled
|
||||
Nexus/Praxis HTTP clients now send `X-Nexus-Version`/`X-Praxis-Version`
|
||||
and thread the same correlation ID already generated in
|
||||
`executeCapability` through the whole call chain, matching the Hexis
|
||||
client's existing behavior.
|
||||
- **Entity-scoped Praxis attention queries** — callers holding a resolved
|
||||
entity_id can ask "what needs attention for this entity" directly
|
||||
instead of filtering the unscoped list client-side.
|
||||
- **Fake-ecosystem test harness with fault injection** — a reusable
|
||||
`fakeServer` (Nexus/Praxis/Hexis fixtures, runtime-toggleable
|
||||
`SetFault`, fake clock) replacing ad-hoc per-test `httptest` servers;
|
||||
covers a gap that had zero test coverage (`handlePraxisAct`) and adds a
|
||||
fault-then-recovery regression test for the fail-closed fixes above.
|
||||
- **Ops fix** — `deploy/mavend.json`'s phraser was pointed at a 4B model
|
||||
with `n_gpu_layers=99`, which OOM'd under memory pressure and left a
|
||||
zombie `llama-server` child; swapped to the 2B Qwen model matching the
|
||||
intended resident-model size.
|
||||
|
||||
Net effect: the Nexus/Praxis wiring described as "plumbing exists, thin
|
||||
compared to Maven's test depth" in the prior review is now materially
|
||||
hardened — typed clients, fail-closed error handling, durable delivery,
|
||||
and a proper fault-injection test harness are all in place. Evaluation lab
|
||||
(`20-07-2026-BACKLOG.md` item 7) is explicitly skipped for now — it runs
|
||||
on the GPU box, which is occupied by CPT. Morning routine engine (backlog
|
||||
item 3) is next up, not started.
|
||||
|
||||
---
|
||||
|
||||
> **Resolved 2026-07-30 (task #318).** The resident checkpoint is
|
||||
> **Qwen3.5-0.8B** (`Q4_K_M`), set in `deploy/mavend.json`; the **target** is
|
||||
> the locally CPT'd **Qwen3-1.7B**, still training (#122). Older model claims
|
||||
> below — the LFM references, the pipeline line, and the "swapped to the 2B
|
||||
> Qwen model" ops entry above — are historical. Read them as a log of what was
|
||||
> true at the time, not as current fact. Note also that `/mnt/hdd1/llms` is
|
||||
> bind-mounted over `models/llm/`, so the LFM2.5 gguf in the repo tree is
|
||||
> never loaded.
|
||||
|
||||
Architecture decision (as written on 2026-07-20): the target resident
|
||||
router/phraser is the locally trained Qwen3-1.7B model — still the target as
|
||||
of 2026-07-30. Older LFM references below describe the then-deployed
|
||||
historical stack, not the target checkpoint. RU CPT has a successful
|
||||
full-weight checkpoint at step 1000/8077; evaluation and Qwen3 SFT tooling are
|
||||
tracked in `docs/plans/2026-07-18-qwen3-resident-training-eval.md`.
|
||||
|
||||
Consolidated status. The reactive↔proactive core is closed and testable through
|
||||
the web PWA. The former SPEC's open items 1–7 (now `DESIGN.md` § execution ledger) are landed (protocol doc, away-channel
|
||||
fallthrough, CalDAV poller, quiet-hours schedule, tools enable/disable, note RAG,
|
||||
passkey step-up); item 8 (multi-user) is deliberately deferred — see the tail.
|
||||
The two big infra gaps from the jul5 revision are closed on `overnight-jul5`:
|
||||
**at-rest encryption** (AES-256-GCM, tmpfs working copy — not sqlcipher, see
|
||||
`internal/store/crypt.go`) and **Docker deployment** (one image, six daemon
|
||||
containers). The `overnight-jul6` session (now on `master`) closed the biggest
|
||||
*query-surface* gaps — **calendar querying, general-knowledge answers, and
|
||||
weather** — plus a populated homelab act allowlist and two pure scaffolds
|
||||
(dialogue state, long-term-memory vector store). ~15.2k LOC + ~8.5k test, 303
|
||||
tests, `-race` in `make test`.
|
||||
|
||||
### Access model
|
||||
|
||||
- **Phone** → needs the wg tunnel to reach homesrv (no homesrv DNS otherwise;
|
||||
raw IP or a DNS tweak can bypass, not the default).
|
||||
- **PC** → uses homesrv DNS, resolves the domains over local-net, **no wg needed**.
|
||||
- nginx + ufw both scope to `10.42.0.0/24` (wg) + `192.168.1.0/24` (LAN), deny all else.
|
||||
- **Surface in use now: the web PWA (`mavweb`).** Voice PTT + in-app nudges both ride it.
|
||||
|
||||
### Works end-to-end (tested)
|
||||
|
||||
- **Reactive voice:** PWA record → Whisper STT (`mavsttd`) → ONNX classifier →
|
||||
resident phraser (llama-server subprocess; Qwen3.5-0.8B as of 2026-07-30 —
|
||||
this line historically named "LFM 2.5-1.2B") → Piper TTS
|
||||
(`mavttsd`) → reply.
|
||||
HTTP POST path (mobile-Chrome drops WS for the audio).
|
||||
- **Capture:** `fact` (EN **and RU** — root-substring recognizers) + `reminder`
|
||||
persist through CoreAPI (`source=tap:voice`). This is the substrate the care
|
||||
rules read.
|
||||
- **Notes / query (semantic recall, sqlite — no chroma):** `note` → embed (the
|
||||
classifier's ONNX embedder) → `notes` table. `query` → embed → brute-force
|
||||
cosine top-k → confidence-gated (below `queryMinScore` 0.55 ⇒ "no note", not a
|
||||
guess). **Note RAG (SPEC item 6):** the gated top-k feed the phraser
|
||||
(`PhraseQuery`) to compose a natural answer ("вот что я нашла: …") instead of
|
||||
a verbatim dump; raw-notes fallback on any LLM error. Stub is deterministic.
|
||||
- **Monitoring (`/dash`):** mavweb server-renders presence + recent nudges (by
|
||||
outcome) + recent facts from the append-only store via CoreAPI. Read-only,
|
||||
meta-refresh, no JS.
|
||||
- **Proactive loop:** 60s dumb ticker, pure predicates over a State snapshot,
|
||||
universal gate (quiet-hours/presence/cooldown/snooze/calendar), one-nudge-per-
|
||||
tick max-severity, reminders (gate-bypassing), sev4 repeat-til-ack, feedback
|
||||
auto-tuner (outcome ratio → bounded cooldown, persisted as `source=feedback`).
|
||||
- **Rules:** water/meal/break (sev1–2 care), service_down (sev4, `poll:uptimekuma`),
|
||||
netdata_critical (sev3, `poll:netdata`).
|
||||
- **Routines (`internal/routine`):** operator-declared clockwork — the third
|
||||
proactive class beside reminders (user-stated) and care rules (world-state).
|
||||
Config `routines[]` (cron + literal RU body + severity) fire through the normal
|
||||
dispatcher on schedule (an 08:00 briefing, a 22:00 wind-down). Bodies are
|
||||
literal (not LLM-phrased ⇒ can't hallucinate); rule name `routine:<name>` so
|
||||
they don't pollute the care autotuner; cold-start guard seeds on first sight so
|
||||
a restart never replays a missed schedule. Pure `routine.Due`, unit-tested; the
|
||||
tick driver holds the last-fired map.
|
||||
- **Env facts (`mavpoll`):** netdata alarms → `netdata_alarm` (fires immediately
|
||||
on a real CRITICAL); kuma monitor_status → `service_down`. Writes only on
|
||||
value-change (no append-only churn).
|
||||
- **Presence:** noisy-OR decay + Schmitt hysteresis. Live via `page_heartbeat`
|
||||
(PWA auto-pings `/api/signal` every 30s → present when a tab's open).
|
||||
- **Delivery:** ntfy / telegram / voice by `f(severity, presence)`; minimal body
|
||||
on away channels. PWA subscribes to ntfy over **WebSocket** for in-app nudges.
|
||||
- **Away-channel fallthrough (SPEC item 2):** when the router picks voice but no
|
||||
live session exists at push time (presence guess was wrong), the dispatcher
|
||||
reroutes through the AWAY table — sev3→ntfy, sev4→telegram-repeat-til-ack,
|
||||
sev≤2→drop — instead of silently dropping. Covers nudges + reminders.
|
||||
- **Calendar busy (SPEC item 3, `mavcaldav`):** new poller queries a self-hosted
|
||||
**Radicale** CalDAV server on an interval, writes `calendar_busy` + event facts
|
||||
through CoreAPI (value-change only). The loop gate already consumes `calendar_busy`.
|
||||
- **Quiet-hours schedule (SPEC item 4):** the gate reads `quiet_hours`; a config
|
||||
time window (`voice.quiet_hours`, HH:MM, midnight-crossing handled) now sets it
|
||||
on each tick — in addition to the "тихий режим" voice toggle. Both activate quiet.
|
||||
- **Client protocol (SPEC item 1):** the voice wire format (length-prefixed JSON
|
||||
frames) is published in `PROTOCOL.md`, generated from `internal/voice/wire.go`
|
||||
so third-party clients don't need the Go source.
|
||||
- **Passkey step-up (SPEC item 7):** `internal/webauthn` does real WebAuthn —
|
||||
ES256/P-256 register + assert, ecdsa signature verification, rpIdHash + UP/UV
|
||||
flag binding (UV = the gesture), sign-count regression check. `PasskeySession`
|
||||
bumps the auth session L2→L3 for a TTL on assert. mavweb serves `/auth/passkey`
|
||||
(enroll + step-up) + the begin/finish endpoints. Crypto is round-trip tested
|
||||
(incl. tampered-sig / missing-UV / wrong-origin negatives).
|
||||
- **Stability:** llama-server orphan leak fixed (`Pdeathsig` kills the child on
|
||||
any mavend death); `kill-maven.sh` reaps strays (matches the model, not a
|
||||
bogus `llama-server.*maven` pattern); `start-maven.sh` wires `-core` + poller.
|
||||
|
||||
### Wired but needs a deploy action (not code)
|
||||
|
||||
- **`desk_active`** (strongest presence signal) — `scripts/desk-active.sh` runs
|
||||
on the **desk PC** (hypridle-gated systemd timer), posts over wg to mavweb.
|
||||
- **`mavwaked`** (always-on listening) — needs a systemd user unit on a client
|
||||
box (desk PC, pi, etc.) where the mic is attached. Connects to mavend over wg
|
||||
or local net via `-addr`. Deferred until a client box is wired with a mic.
|
||||
|
||||
Caveats / gotchas:
|
||||
- **desk_active is a workstation deploy, not code** — 0 facts ever written; presence
|
||||
runs on page_heartbeat alone (dash reads "away"/"never at desk"). `scripts/desk-active.sh`
|
||||
+ a hypridle-gated `maven-desk` timer must be installed on the desk PC (not homesrv).
|
||||
- **Notes recall needs the ONNX embedder** — under the HashEmbedder floor, cosine is
|
||||
lexical (token overlap), not semantic; scores are low, so most RU commands sit under
|
||||
the 0.35 route threshold and clarify. Configure `voice.embedder` for confident recall+routing.
|
||||
(The floor now at least tokenizes Cyrillic — see below — so it ranks correctly, just weakly.)
|
||||
- **Switching the embedder model silently breaks old notes** — different dim ⇒
|
||||
cosine 0 ⇒ they stop matching; brute-force can't re-embed. Re-embed on a model change.
|
||||
- **`wg_handshake` is OFF and should stay off** — in this topology the phone only
|
||||
runs wg when *outside*, so a fresh handshake means AWAY, not here. The `mavpoll
|
||||
-wg` flag exists (defaults `""`) and could later back the spec's "away override"
|
||||
by flipping the sign; as a presence-*here* signal it's inverted. desk_active +
|
||||
page_heartbeat cover home presence.
|
||||
- **Cold-start unlock tests are missing** — the key wrap/unwrap code
|
||||
(`internal/webauthn/keywrap.go`) and locked-mode IPC gating (`cmd/mavend/main.go`)
|
||||
are correct but have **zero test coverage**. The roadmap (item 2.1) required
|
||||
three new test cases (wrap/unwrap round-trip, wrong-cred unwrap fails,
|
||||
locked-mode IPC rejects non-unlock methods); none were written. `make test`
|
||||
is green by omission. Write these before relying on the cold-start path with
|
||||
real keys.
|
||||
|
||||
### Done since last revision (overnight-jul6, 2026-07-06)
|
||||
|
||||
Seven tasks (session board `SESSION-06-07-2026.md`, deleted 2026-07-30 — see git history), one commit each, merged to `master`.
|
||||
This session was run through **opencode**, not Claude Code (co-author trailer).
|
||||
|
||||
Since then (**2026-07-06, second session**):
|
||||
|
||||
- **Always-on listening (gap 1, MVP)** — `cmd/mavwaked/`: 825 lines, 10 `-race`
|
||||
tests. Energy-based VAD over 30ms windows (same RMS threshold as mavsttd's
|
||||
`gateReason`), adaptive noise floor, speech→silence state machine. Captures
|
||||
PCM from arecord(1) subprocess, sends `PushToTalk` with `Surface=SurfaceVoice`
|
||||
(L0 — no destructive acts). Reply plays through aplay(1). No wake word yet
|
||||
(pure VAD trigger); the 30ms frame shape matches silero-vad ONNX input 1:1,
|
||||
so swapping energy-threshold for ONNX inference is a local change in vad.go.
|
||||
`Makefile` `build-waked` target. Runs on client boxes (not docker/homesrv)
|
||||
via systemd user unit; connects to mavend over wg or local net.
|
||||
|
||||
Since then (**2026-07-06, third session** — roadmap execution agent):
|
||||
|
||||
- **Cold-start unlock (ROADMAP 2.1)** — the at-rest AES key is now wrapped
|
||||
(HKDF-SHA256 + AES-256-GCM, stdlib-only — no `x/crypto` dep) with the passkey
|
||||
credential's public key and persisted to disk. At boot, if a wrapped key file
|
||||
exists AND no env key is set, mavend starts **locked**: the IPC server runs
|
||||
but `srv.Check` rejects everything except `MethodAssertStepUp` +
|
||||
`MethodUnlock`. A passkey assertion at `/auth/passkey` calls `MethodUnlock`
|
||||
with the credential's public key → unwraps the blob → opens the store → wires
|
||||
voice/loop/delivery → `srv.SetAPI` swaps the locked stub for the real
|
||||
CoreAPI. mavweb's `RegisterFinish` wraps the env key on enrollment;
|
||||
`AssertFinish` calls `Unlock` on assertion. Env-key fallback preserved
|
||||
(dev/CI path unchanged). **Test gap:** the roadmap required three new test
|
||||
cases (wrap/unwrap round-trip, wrong-cred unwrap fails, locked-mode IPC
|
||||
rejects non-unlock methods) — none were written. The code is correct but
|
||||
untested; `make test` is green by omission, not coverage.
|
||||
- **Conversation depth (ROADMAP 3.2)** — cross-intent anaphora + fact-by-key
|
||||
lookup. `AnaphoraResolver` in `router/slots.go` detects RU pronouns
|
||||
(это/он/она/оно/тот/мой + inflected forms). `followUpMerge` now handles
|
||||
three cases: same-intent slot inheritance (existing), cross-intent anaphora
|
||||
(Query/Fact/Reminder after a Fact with a pronoun inherits the prior key +
|
||||
time), and query-after-fact (a query following a fact inherits the key for
|
||||
fact-by-key lookup). `Session.History []Turn` added as the multi-turn
|
||||
scaffold (capped at 4). 7 new test cases including the exact done-when
|
||||
scenarios (anaphora query-after-fact, three-turn break, explicit-key-wins).
|
||||
- **Routing quality + persona (ROADMAP 4.1/4.4)** — `QueryMinScore` is now a
|
||||
config knob (`voice.query_min_score`, default 0.55) instead of a hardcoded
|
||||
const. `make download-embedder` fetches Xenova/paraphrase-multilingual-
|
||||
MiniLM-L12-v2 (~90MB ONNX) + tokenizer; AGENTS.md documents the embedder +
|
||||
libonnxruntime setup. `Persona` field in `VoiceConfig` prepends to every
|
||||
LLM system prompt (nudge phrasing, note queries, general knowledge); empty
|
||||
= current hardcoded feminine-gendered Russian persona. Also fixed two
|
||||
pre-existing data races found by `-race`: `voice/server.go` wg.Add vs
|
||||
wg.Wait (accept mutex), `mavweb/server.go` s.api field (atomic.Value).
|
||||
|
||||
- **Calendar querying (task 3)** — "что у меня завтра?" now answers from the
|
||||
CalDAV facts the poller already writes. Added `store.CalendarEvents(from,to)`,
|
||||
a RU date-scope parser («сегодня»/«завтра») in `router/slots.go`, and an
|
||||
IPC `CalendarEvents` RPC (api/client/server/wire) feeding the `IntentQuery`
|
||||
handler. Empty day → «на сегодня ничего нет». Previously calendar only *gated*
|
||||
nudges; it's now queryable.
|
||||
- **General-knowledge routing (task 4)** — when notes-RAG misses `queryMinScore`,
|
||||
the query now falls through to the phraser with an anti-hallucination system
|
||||
prompt (`router.KnowledgePrompt`, single tested source) instead of giving up.
|
||||
Empty/errored/Stub phraser → «не знаю.», never a fabrication.
|
||||
- **Weather (task 5)** — new `internal/weather/`: `Provider` interface, a stub
|
||||
(«погода не настроена»), and a real **keyless Open-Meteo** provider (geocode +
|
||||
current_weather, injectable `*http.Client`, mocked in tests — no live network).
|
||||
Wired into `IntentQuery` (keywords погода/градус/температура) with a ~5s
|
||||
context timeout; selected by `voice.weather.provider` ("open-meteo" | "" → stub).
|
||||
- **Homelab act allowlist (task 2)** — `voice.tools` seeded with read-only acts
|
||||
(`systemctl status`, `docker ps`, `uptime`, `df`, `free`, `journalctl` reads)
|
||||
as `destructive:false` and mutating ones (restart/stop/start/reboot,
|
||||
docker-restart/stop) as `destructive:true`. Guardrail verified: no dangerous
|
||||
verb is `destructive:false`. RU phrasings seeded in `act.txt`.
|
||||
- **Embedder config validation (task 1)** — a partially-filled `voice.embedder`
|
||||
block (some of model/tokenizer/lib paths missing) is now a load error instead
|
||||
of a silent fall-through to the Hash floor; the floor fallback logs explicitly.
|
||||
- **Dialogue state scaffold (task 6)** — `internal/dialogue/`: `Session` +
|
||||
TTL `SessionStore` + pure `InheritSlots`. **Now wired** (post-merge follow-up):
|
||||
the voice handler carries slots across same-intent turns within a 2-min window
|
||||
(`followUpMerge`, unit-tested) — bounded gap-filling, not full multi-turn yet.
|
||||
- **Long-term memory interface (task 7)** — `internal/memory/`: `Store` interface
|
||||
+ `InMemoryStore` (cosine). Wired into `IntentNote` (best-effort insert) and,
|
||||
post-merge, into `IntentFact` (facts indexed) + `IntentQuery` (read-back after
|
||||
notes-RAG misses). In-memory only — no persistent backend yet (gap #8).
|
||||
|
||||
Follow-ups (Claude Code, post-merge): gofmt'd `handlers_test.go` (the jul6
|
||||
verification commit left it misaligned, so `gofmt -l` still flagged it despite the
|
||||
"all gates green" claim); deduped the task-4 knowledge prompt to the single tested
|
||||
`router.KnowledgePrompt()`. Tree is now genuinely green (gofmt/vet/303 tests).
|
||||
|
||||
### Done since the jul5 revision (overnight-jul5, 2026-07-05)
|
||||
|
||||
The overnight session (`SESSION-05-07-2026.md`, deleted 2026-07-30 — see git history; 25 tasks) closed the previous
|
||||
"not built yet" items 1–3 and added feature depth:
|
||||
|
||||
- **At-rest encryption** — the on-disk db is AES-256-GCM ciphertext; the daemon
|
||||
works on a tmpfs (RAM) plaintext copy, sealed back atomically on close. Wrong
|
||||
key / tamper ⇒ fail closed, never a plaintext fallback. Legacy plaintext dbs
|
||||
upgrade on first clean shutdown. Key via config/env (`db_key_env`); no KDF —
|
||||
raw 32-byte key, base64. The passkey cold-start unlock plugs into the same
|
||||
`store.OpenEncrypted` seam later.
|
||||
- **Docker deployment** — single image, one container per daemon
|
||||
(`docker-compose.yml`); only mavend mounts the key + db volume; IPC over a
|
||||
shared socket volume. `ipc.DialWait` (boot-order tolerance) + redial-on-drop
|
||||
(core restarts don't kill modules). `deploy/README.md` has the runbook.
|
||||
- **Tests** — mavcaldav, mavttsd, voicesink, mavweb main/handlers covered;
|
||||
`make test` runs `-race -coverprofile`.
|
||||
- **Recurring reminders** — `cron` + `next_fire_ts` on reminders; recurring ones
|
||||
reschedule (instead of mark-fired) after successful delivery.
|
||||
- **Notification digest/batching** — low-severity nudges queue and flush as one
|
||||
digest per window/max-items (`digest` config block); stale-reminder bursts on
|
||||
boot collapse into a single digest reminder, completed only after delivery.
|
||||
- **Rule trace engine** — `ExplainTick`/`ExplainGate` record per-rule
|
||||
predicate/gate/selection results each tick; served over IPC (`tick_trace`)
|
||||
and rendered at mavweb `/trace` ("why didn't she nudge me").
|
||||
- **Web UI** — new `/history` (facts + revert buttons), `/notifications` (nudge
|
||||
history), `/trace` pages; nav links on `/dash`; RU/EN cheatsheet toggle in the
|
||||
PWA; manifest icons (`icon.svg`). POST `/tools` now requires an in-process
|
||||
passkey step-up when WebAuthn is configured.
|
||||
- **Revert/undo** — `RevertFact` voids the latest fact for a key (append-only
|
||||
void-marker, audit trail intact); exposed at `/api/revert` from `/history`.
|
||||
- **Tool scopes** — `scope` column on tools, threaded through propose/enable/UI.
|
||||
`DisableTool` raised to AuthStepUp alongside Enable.
|
||||
- **Passkey persistence** — mavweb credentials in a JSON file (`-passkey-file`),
|
||||
surviving restarts; rollback-on-persist-failure keeps memory and disk in sync.
|
||||
- **STT silence gate** — min-duration + RMS floor drop non-speech before whisper
|
||||
hallucinates on it (`-min-ms`, `-silence-rms` flags on mavsttd).
|
||||
- **Housekeeping** — `db_key.env` gitignored (+`.env.example`), `build-caldav`
|
||||
target, zero-timestamp "never" fix on /dash.
|
||||
|
||||
### Not built yet (ranked by ROI)
|
||||
|
||||
1. **Multi-user (SPEC item 8)** — deliberately deferred, see the tail.
|
||||
|
||||
Closed (jul6 follow-ups): `/api/revert` now sits behind the same passkey
|
||||
step-up as POST `/tools`; `go.mod` direct deps (`onnxruntime_go`,
|
||||
`coder/websocket`, `robfig/cron`) are labeled correctly — `go mod tidy` can't
|
||||
run here because it walks the vendored `deps/go` toolchain tree.
|
||||
Purge+rotate leaked db key (#12) — investigated and closed: the key was
|
||||
**never committed** to git history (gitignored at introduction, no commit
|
||||
ever tracked `deploy/db_key.env`), so nothing to scrub. File stays on disk
|
||||
and in deploy env by design — at-rest encryption needs it at boot.
|
||||
|
||||
Done earlier (2026-07-03): **act tool executor, store-backed, full flow**
|
||||
(`internal/tool` + `internal/store/tools.go` + `tools` CoreAPI methods).
|
||||
- **Execution:** IntentAct runs the matched fn against the store's ENABLED
|
||||
allowlist. argv, no shell → STT text can't inject. Live store read, so a
|
||||
newly-enabled tool runs without a daemon restart.
|
||||
- **proposed→enabled→disabled (SPEC item 5):** an act whose verb isn't enabled is
|
||||
scaffolded as a `proposed` tool (maven suggests). A human enables it (fills argv
|
||||
+ destructive) on the authed **`mavweb /tools`** page — never voice — and can
|
||||
disable it back to `proposed` (kept in the store, won't run). `EnableTool`/
|
||||
`DisableTool` sit at `AuthStepUp`; the gate is now **live** via `PasskeySession`,
|
||||
so /tools enable requires a passkey assertion at `/auth/passkey` first.
|
||||
- **Confirm turn:** a destructive enabled tool replies "выполнить X? да/нет" and
|
||||
parks; the next utterance (ru/en yes-no) confirms or cancels (90s TTL).
|
||||
- **Config:** `voice.tools` seeds enabled tools at boot (editing mavend.json =
|
||||
the human enable act); mavweb enables ad-hoc ones on top.
|
||||
- **Russian:** fixed grammar in reply strings + seed files; maven's self-
|
||||
reference is feminine ("she") — [[maven-persona-gender]].
|
||||
|
||||
Also fixed:
|
||||
- **HashEmbedder was blind to Cyrillic** (`tokenize` iterated bytes, kept only
|
||||
`a-z0-9`) → every RU utterance embedded to the zero vector → cosine 0 across
|
||||
all intents → misrouted to `act` (alphabetical tie-break). Now rune-based
|
||||
(`unicode.IsLetter`). This was the real cause of "Найди заметку" (a query)
|
||||
landing in `notes`; added note-retrieval query seeds too.
|
||||
- **Notes are now browsable on `/dash`** — `RecentNotes` plumbed through the
|
||||
store + CoreAPI; voice-captured notes were previously only reachable via
|
||||
semantic `query`.
|
||||
Earlier: notes/query recall, `/dash` monitoring, `wg_handshake` poller (NO-OP).
|
||||
|
||||
### Gaps — why "voice assistant" is still aspirational (2026-07-06)
|
||||
|
||||
What separates Maven today from the thing the spec describes. Dealbreakers
|
||||
first — these define the category:
|
||||
|
||||
1. **Always-on listening is code-complete (MVP).** `cmd/mavwaked` captures
|
||||
PCM from arecord → energy-based VAD → PushToTalk with `Surface=SurfaceVoice`
|
||||
(L0). Gap narrowed: no wake word yet (pure voice-activity trigger; every
|
||||
utterance fires). The 30ms frame shape and 16kHz PCM match silero-vad's
|
||||
ONNX input exactly, so a wake-word model swap is a local change in vad.go.
|
||||
Hardware: the mic lives on a client box (desk PC, pi, etc.) — never the
|
||||
homesrv. Deploy action: systemd user unit on whichever box has the mic,
|
||||
connects to mavend over wg or local net.
|
||||
2. **Conversation is deeper now, still not full dialogue.** The router
|
||||
classifies one utterance → one reply, but `internal/dialogue` carries
|
||||
context across turns: a 2-min session inherits slots for same-intent
|
||||
follow-ups («напомни завтра» → «…позвонить маме»), and cross-intent
|
||||
anaphora («запиши что я пил воду» → «когда я это сделал?») now resolves
|
||||
RU pronouns (это/он/она/оно/тот/мой + inflections) to the prior turn's
|
||||
key for fact-by-key lookup. `Session.History []Turn` is the scaffold for
|
||||
real multi-turn. Still missing: LLM-driven dialogue manager (decide
|
||||
ask-vs-act), anaphora beyond RU pronouns, single-slot session (single-user
|
||||
box). The sub-1B phraser only words replies.
|
||||
3. **Latency/shape of a turn.** Clip-based STT (record → upload → whisper →
|
||||
route → phrase → piper → play). No streaming either direction, no barge-in;
|
||||
every exchange is a full round trip.
|
||||
|
||||
Capability-class gaps — built but thin:
|
||||
|
||||
4. **Act surface is a small argv allowlist.** propose→enable works and the
|
||||
allowlist now ships a homelab starter set (jul6 task 2 — status/ps/uptime/
|
||||
df/free/logs read-only, restart/stop/reboot gated). Still bounded to what's
|
||||
seeded; broadening it is config, not code.
|
||||
5. **Query answers now cover notes + calendar + weather + general knowledge**
|
||||
(jul6 tasks 3/4/5). Calendar querying, keyless Open-Meteo weather, and a
|
||||
phraser knowledge-fallback all landed; caveat — general-knowledge quality is
|
||||
only as good as the sub-1B phraser, and weather needs `voice.weather.provider`
|
||||
set. The cheatsheet and router are now roughly aligned.
|
||||
6. **Routing quality depends on the ONNX embedder being configured** — the
|
||||
HashEmbedder floor makes RU recall lexical/weak; many commands fall to
|
||||
"clarify". `make download-embedder` now fetches the multilingual MiniLM
|
||||
model + AGENTS.md documents libonnxruntime setup; `voice.query_min_score`
|
||||
is a config knob (default 0.55) so the floor can be tuned without recompile.
|
||||
7. **Presence is effectively one signal** (page_heartbeat); desk_active is
|
||||
still an undeployed script — "voice when near" routing runs on a guess.
|
||||
8. **Long-term memory is now persistent (store-backed), not the spec's chroma.**
|
||||
`internal/memory` has a `Store` interface; the daemon now wires
|
||||
`store.MemoryStore` (`internal/store/memory.go`) — a **persistent** backend
|
||||
in the **same encrypted sqlite db** (survives restarts; recall text inherits
|
||||
at-rest encryption, so no plaintext sidecar). Vectors are float32 blobs,
|
||||
search is brute-force cosine (fine at single-user scale; ANN is the later
|
||||
swap behind the same interface). Notes **and facts** are indexed on capture;
|
||||
`IntentQuery` reads it back (after notes-RAG misses, before general-knowledge)
|
||||
— fact recall («когда я пил воду?») is its distinct payoff. The in-memory
|
||||
impl remains the test/no-store floor. Remaining: an ANN/external index is
|
||||
optional-scale, not a gap. Custom TTS voice (kami-picked, replaces the irina
|
||||
floor — [[custom-voice-training]]) is still a future item.
|
||||
|
||||
Ops footnote: voice-over-web verified 2026-07-06 — mavend binds 0.0.0.0:9100
|
||||
and mavweb reaches it cross-container at mavend:9100 (nc -z confirmed).
|
||||
mavpoll uses network_mode=host to reach localhost services (netdata, kuma).
|
||||
|
||||
### Future / logged, not now
|
||||
|
||||
Custom TTS voice training (kami-picked voice, replaces irina floor); listening
|
||||
modes 2–3 (meeting-record, ambient-derive).
|
||||
|
||||
### Services & layout
|
||||
|
||||
- `mavend` (core, IPC unix socket) — store + loop + phraser; the only key-holder.
|
||||
- `mavsttd` / `mavttsd` — STT/TTS worker modules (unix sockets).
|
||||
- `mavweb` — PWA bridge (HTTP), `/api/ptt` voice, `/api/signal` presence ingest,
|
||||
`/api/ntfy` WS-subscribe config, `/dash` read-only monitoring.
|
||||
- `mavpoll` — env poller (netdata/kuma → facts via CoreAPI).
|
||||
- `mavcaldav` — CalDAV poller (Radicale → `calendar_busy` + events via CoreAPI).
|
||||
- All behind wg + nginx deny-all; no phone-home. CGo only in `mavsttd`.
|
||||
- Start/stop: `./start-maven.sh [build]`, `./kill-maven.sh`.
|
||||
- Config: `~/.config/maven/mavend.json` (or `mavend.json` in repo root).
|
||||
|
||||
### Key files
|
||||
|
||||
- `cmd/mavend/{main,tick,voice}.go` — daemon wiring, loop driver, voice handler
|
||||
- `internal/loop/{loop,rules,gather,feedback}.go` — proactive engine
|
||||
- `internal/store/` — append-only facts/reminders/nudges/presence/notes
|
||||
- `cmd/mavweb/{main.go,dash.html}` — PWA bridge + `/dash` monitoring
|
||||
- `internal/router/{classifier,slots,stage0}.go` — reactive routing + slot parse
|
||||
- `internal/delivery/` — dispatcher + ntfy/telegram/voice sinks
|
||||
- `internal/auth/` — scope/gate/policy; `FloorEnrollment` (same-uid = device
|
||||
trust) + `webauthn.PasskeySession` (real step-up for L3)
|
||||
- `internal/webauthn/`, `cmd/mavweb/webauthn.go` — passkey register/assert
|
||||
- `cmd/mavcaldav/`, `cmd/mavpoll/`, `scripts/desk-active.sh` — env producers
|
||||
|
||||
### Why multi-user (SPEC item 8) is deferred
|
||||
|
||||
Not neglect — the one item where doing nothing now beats doing something:
|
||||
|
||||
- **No second user exists yet** (the "gf phase"). Building per-user partitioning
|
||||
now means code exercised by zero users and validated by nobody — YAGNI.
|
||||
- **The append-only schema makes it a migration, not a rewrite.** No row is ever
|
||||
mutated, so adding `facts/notes/reminders.user_id` later is add-columns +
|
||||
backfill-to-"kami" — no reshaping, no dual-write window. Deferral is cheap.
|
||||
- **The hard part is speaker attribution, and it needs the second voice.** A
|
||||
voice-print discriminator (kami vs gf vs unknown) can't be trained or tuned
|
||||
with one voice in the house. Plumbing before the model is pipe with no water.
|
||||
- **It's fenced deliberately** (`DO NOT TOUCH THIS PHASE` in `DESIGN.md` § Users) so an
|
||||
autonomous agent doesn't add `user_id` columns while touching the store and
|
||||
commit us to a schema before the constraints that shape it exist.
|
||||
+25
-13
@@ -66,7 +66,7 @@ func run(args []string) error {
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
}
|
||||
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
||||
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -122,17 +122,26 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// checkRenderTarget refuses a render URL that is also a read URL. This is the
|
||||
// structural half of #127's "cannot write to your work calendar": the write
|
||||
// credential and the write URL are separate flags, and the one calendar maven
|
||||
// is known to only read is rejected as a target at startup rather than trusted
|
||||
// at runtime.
|
||||
func checkRenderTarget(readURL, renderURL string) error {
|
||||
// checkRenderTarget refuses a render URL that is also one of the read URLs.
|
||||
// This is the structural half of #127's "cannot write to your work calendar":
|
||||
// the write credential and the write URL are separate flags, and a calendar
|
||||
// maven is known to only read is rejected as a target at startup rather than
|
||||
// trusted at runtime.
|
||||
//
|
||||
// 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.
|
||||
func checkRenderTarget(readURLs []string, renderURL string) error {
|
||||
if renderURL == "" {
|
||||
return nil
|
||||
}
|
||||
if sameCollection(readURL, renderURL) {
|
||||
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
|
||||
for _, read := range readURLs {
|
||||
if read == "" {
|
||||
continue
|
||||
}
|
||||
if sameCollection(read, renderURL) {
|
||||
return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -163,7 +172,7 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
}
|
||||
|
||||
// Write calendar_busy on change.
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now); err != nil {
|
||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -173,7 +182,7 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
// reaching back to Radicale.
|
||||
for _, e := range events {
|
||||
key := calendar.FactKey(e)
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
@@ -206,7 +215,10 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
|
||||
}
|
||||
|
||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
|
||||
// Everything this poller writes is a calendar read, which is full confidence by
|
||||
// definition; a source that is not, such as the notification relay, does not
|
||||
// come through here.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == val:
|
||||
@@ -220,7 +232,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
||||
Key: key,
|
||||
Value: val,
|
||||
Source: source,
|
||||
Confidence: confidence,
|
||||
Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("no previous fact writes", func(t *testing.T) {
|
||||
fc := &fakeCore{}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
writeErr: fmt.Errorf("disk full"),
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -190,13 +190,15 @@ func TestPollOnce(t *testing.T) {
|
||||
t.Errorf("calendar_busy ts is zero")
|
||||
}
|
||||
|
||||
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM"
|
||||
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM".
|
||||
// The iCal states the event in UTC and the fact is stamped on the owner's
|
||||
// clock, so the expected key date and times are the local reading of it.
|
||||
eventReq := fc.writeLog[1]
|
||||
expectedKey := "calendar_event_" + start.Format("20060102") + "_Current-meeting"
|
||||
expectedKey := "calendar_event_" + start.Local().Format("20060102") + "_Current-meeting"
|
||||
if eventReq.Key != expectedKey {
|
||||
t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey)
|
||||
}
|
||||
expectedVal := "Current meeting @ " + start.Format("15:04") + "-" + end.Format("15:04")
|
||||
expectedVal := "Current meeting @ " + start.Local().Format("15:04") + "-" + end.Local().Format("15:04")
|
||||
if eventReq.Value != expectedVal {
|
||||
t.Errorf("event value = %q, want %q", eventReq.Value, expectedVal)
|
||||
}
|
||||
|
||||
+80
-3
@@ -2,15 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// renderer is the write half of maven's own local calendar (Vikunja #127).
|
||||
@@ -38,6 +41,13 @@ type renderer struct {
|
||||
// unchanged reminder costs nothing. Purely an optimisation: a restart
|
||||
// re-publishes every reminder once, which is idempotent.
|
||||
published map[int64]string
|
||||
|
||||
// reconciled — whether the collection has been read once since start. It
|
||||
// has to be, because published is in-memory: withdrawal used to cover only
|
||||
// the reminders THIS process published, so a reminder that fired while the
|
||||
// daemon was down kept its event in the calendar forever, and nothing ever
|
||||
// revisited it.
|
||||
reconciled bool
|
||||
}
|
||||
|
||||
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
|
||||
@@ -64,7 +74,7 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
|
||||
live := make(map[int64]bool, len(reminders))
|
||||
for _, rem := range reminders {
|
||||
if rem.Status != "pending" {
|
||||
if rem.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
live[rem.ID] = true
|
||||
@@ -81,10 +91,28 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
|
||||
}
|
||||
|
||||
stale := make(map[int64]bool)
|
||||
for id := range r.published {
|
||||
if live[id] {
|
||||
continue
|
||||
if !live[id] {
|
||||
stale[id] = true
|
||||
}
|
||||
}
|
||||
if !r.reconciled {
|
||||
remote, err := r.listPublished(ctx)
|
||||
if err != nil {
|
||||
// Try again next tick. A collection maven cannot read is not a
|
||||
// reason to stop publishing to it.
|
||||
log.Printf("mavcaldav: reconcile: %v", err)
|
||||
} else {
|
||||
r.reconciled = true
|
||||
for _, id := range remote {
|
||||
if !live[id] {
|
||||
stale[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range stale {
|
||||
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
|
||||
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
|
||||
continue
|
||||
@@ -94,6 +122,55 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// listPublished PROPFINDs the collection and returns the reminder ids maven has
|
||||
// events for in it. Only resources carrying calendar.ReminderUIDPrefix are
|
||||
// reported, so a reconciliation pass can never propose deleting a file maven
|
||||
// did not create — the same bound every other path in this file has.
|
||||
func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", r.url+"/", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusMultiStatus && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
|
||||
return nil, fmt.Errorf("PROPFIND %s: %s", r.url, resp.Status)
|
||||
}
|
||||
|
||||
var ms struct {
|
||||
Responses []struct {
|
||||
Href string `xml:"href"`
|
||||
} `xml:"response"`
|
||||
}
|
||||
if err := xml.Unmarshal(raw, &ms); err != nil {
|
||||
return nil, fmt.Errorf("PROPFIND %s: %w", r.url, err)
|
||||
}
|
||||
var ids []int64
|
||||
for _, resp := range ms.Responses {
|
||||
href, err := url.PathUnescape(strings.TrimSpace(resp.Href))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if id, ok := calendar.ReminderIDFromPath(href); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// renderMaxReminders bounds the read. Reminders past this count are older than
|
||||
// anything a calendar view is useful for.
|
||||
const renderMaxReminders = 200
|
||||
|
||||
+125
-10
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -27,12 +29,16 @@ func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, erro
|
||||
return c.reminders, nil
|
||||
}
|
||||
|
||||
// calSrv records what a CalDAV collection received.
|
||||
// calSrv records what a CalDAV collection received. existing seeds resources
|
||||
// that were already in the collection before this process started, which is
|
||||
// what a restart looks like from the renderer's side.
|
||||
type calSrv struct {
|
||||
mu sync.Mutex
|
||||
puts map[string]string
|
||||
dels []string
|
||||
status int
|
||||
mu sync.Mutex
|
||||
puts map[string]string
|
||||
dels []string
|
||||
existing []string
|
||||
propfind int
|
||||
status int
|
||||
*httptest.Server
|
||||
}
|
||||
|
||||
@@ -47,12 +53,43 @@ func newCalSrv() *calSrv {
|
||||
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
|
||||
case http.MethodDelete:
|
||||
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
|
||||
case "PROPFIND":
|
||||
s.propfind++
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, s.multistatusLocked(r.URL.Path))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(s.status)
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
// multistatusLocked renders the collection listing. Caller holds the lock.
|
||||
func (s *calSrv) multistatusLocked(base string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><D:multistatus xmlns:D="DAV:">`)
|
||||
b.WriteString("<D:response><D:href>" + base + "</D:href></D:response>")
|
||||
names := append([]string{}, s.existing...)
|
||||
for name := range s.puts {
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, name := range names {
|
||||
if slices.Contains(s.dels, name) {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<D:response><D:href>/cal/" + name + "</D:href></D:response>")
|
||||
}
|
||||
b.WriteString("</D:multistatus>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *calSrv) deleted() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string{}, s.dels...)
|
||||
}
|
||||
|
||||
func (s *calSrv) putCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -168,19 +205,97 @@ func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
|
||||
|
||||
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
|
||||
read := "http://localhost:5232/kami/personal"
|
||||
if err := checkRenderTarget(read, ""); err != nil {
|
||||
if err := checkRenderTarget([]string{read}, ""); err != nil {
|
||||
t.Fatalf("rendering off must be fine: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
|
||||
if err := checkRenderTarget([]string{read}, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a distinct collection must be accepted: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget(read, read); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, read); err == nil {
|
||||
t.Error("rendering into the read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget(read, read+"/"); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, read+"/"); err == nil {
|
||||
t.Error("a trailing slash must not defeat the check")
|
||||
}
|
||||
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, strings.ToUpper(read)); err == nil {
|
||||
t.Error("case must not defeat the check")
|
||||
}
|
||||
// Every read target is checked, not the first one. A second calendar to
|
||||
// read must not fall outside the guarantee just by being added later.
|
||||
work := "http://localhost:5232/kami/work"
|
||||
if err := checkRenderTarget([]string{read, work}, work); err == nil {
|
||||
t.Error("rendering into the second read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget([]string{read, work}, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a collection maven owns must still be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Withdrawal has to survive a restart. published is in-memory, so a fresh
|
||||
// process knows nothing about the events an earlier one wrote: fire a reminder,
|
||||
// restart mavcaldav, and its event used to sit in the collection forever
|
||||
// because nothing ever revisited it. The first tick reads the collection and
|
||||
// reconciles what it finds against what is pending.
|
||||
func TestRenderOnceWithdrawsAfterRestart(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
// Left behind by a previous process: 4 is still pending, 5 has fired.
|
||||
// The third file is not maven's and must not be touched.
|
||||
srv.existing = []string{"maven-reminder-4.ics", "maven-reminder-5.ics", "dentist.ics"}
|
||||
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 4, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
||||
{ID: 5, FireTs: time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC), Payload: "уже прозвенело", Status: "fired"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
dels := srv.deleted()
|
||||
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
||||
t.Fatalf("deleted %v, want only the fired reminder's event", dels)
|
||||
}
|
||||
|
||||
// The collection is read once, not on every tick.
|
||||
r.renderOnce(context.Background())
|
||||
srv.mu.Lock()
|
||||
n := srv.propfind
|
||||
srv.mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Errorf("PROPFIND ran %d times, want once per process", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A collection maven cannot read is not a reason to stop publishing to it, and
|
||||
// the reconciliation must be retried rather than skipped for the process.
|
||||
func TestRenderOnceRetriesReconcile(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
srv.existing = []string{"maven-reminder-6.ics"}
|
||||
failing := &http.Client{Transport: &propfindFailure{base: srv.Client().Transport}}
|
||||
|
||||
core := &reminderCore{}
|
||||
r := newRenderer(core, failing, srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.deleted(); len(got) != 0 {
|
||||
t.Fatalf("nothing can be withdrawn on a failed read: %v", got)
|
||||
}
|
||||
if r.reconciled {
|
||||
t.Fatal("a failed read must not count as reconciled")
|
||||
}
|
||||
|
||||
r.http = srv.Client()
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.deleted(); len(got) != 1 || got[0] != "maven-reminder-6.ics" {
|
||||
t.Fatalf("deleted %v, want the orphaned event on the retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
// propfindFailure fails PROPFIND and passes everything else through.
|
||||
type propfindFailure struct{ base http.RoundTripper }
|
||||
|
||||
func (f *propfindFailure) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Method == "PROPFIND" {
|
||||
return nil, errors.New("collection unreachable")
|
||||
}
|
||||
return f.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package main is mavenclient — maven's reference client.
|
||||
//
|
||||
// Per DESIGN.md § Voice pipeline (STT / TTS): capture lives on the client;
|
||||
// Per docs/design.md § Voice pipeline (STT / TTS): capture lives on the client;
|
||||
// the server transcribes + synthesises on demand. The PC client runs the
|
||||
// wake-word / VAD gate (cmd/mavwaked) and ships ONE clean audio blob per
|
||||
// utterance on activation. The server never owns a mic.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Spoken ack — the other half of the snooze wire. "готово" said out loud
|
||||
// resolves a live nudge as `acted`, and a fact that answers the nudge on its
|
||||
// own ("выпил воды" after the water rule fired) closes it without him having
|
||||
// to say anything extra.
|
||||
//
|
||||
// Two entry points rather than one, because the two utterances are different
|
||||
// acts. A bare "готово" carries no content and is intercepted before the
|
||||
// router, exactly like the snooze. "выпил воды" IS content: it has to route
|
||||
// normally and write its fact, and only then close the nudge. Folding the
|
||||
// second into a pre-route intercept would have thrown the fact away, which is
|
||||
// the thing he actually said.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// resolveAck — pre-route keyword check for a contentless acknowledgement,
|
||||
// run after the snooze. Same window and same fall-through rule: the words only
|
||||
// count when a nudge is actually live, so "готово" with nothing pending routes
|
||||
// normally.
|
||||
func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
if !classifyAck(text) {
|
||||
return "", false
|
||||
}
|
||||
now := h.now()
|
||||
target, ok := h.pendingNudge(ctx, now)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
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 "не получилось отметить.", true
|
||||
}
|
||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return "отлично, отметила.", true
|
||||
}
|
||||
|
||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||
// applied. A fact whose key is the substrate of a live nudge's rule answers
|
||||
// that nudge, so the nudge is resolved `acted` and the auto-tuner learns the
|
||||
// rule is working.
|
||||
//
|
||||
// Silent by design: it returns nothing and never changes the reply. He said
|
||||
// "выпил воды" and the fact reply is what he is owed; "отлично, отметила" on
|
||||
// top would be her congratulating him for obeying, which is the nag she is
|
||||
// explicitly not.
|
||||
//
|
||||
// Best-effort throughout. A failure here loses one feedback signal and must
|
||||
// never turn a written fact into an error the user hears.
|
||||
func (h *reactiveHandler) ackFromFact(ctx context.Context, dec router.Decision) {
|
||||
if dec.Clarify || dec.Intent != router.IntentFact || !dec.Slots.HasKey {
|
||||
return
|
||||
}
|
||||
rules := ackRulesForKey(dec.Slots.Key)
|
||||
if len(rules) == 0 {
|
||||
return
|
||||
}
|
||||
now := h.now()
|
||||
target, ok := h.pendingNudge(ctx, now)
|
||||
if !ok || !rules[target.Rule] {
|
||||
return
|
||||
}
|
||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
||||
log.Printf("voice: ack nudge %d from fact %q: %v", target.ID, dec.Slots.Key, err)
|
||||
return
|
||||
}
|
||||
log.Printf("voice: nudge %d (rule %s) acked by fact %q", target.ID, target.Rule, dec.Slots.Key)
|
||||
}
|
||||
|
||||
// ackRulesForKey — which rules a fact under this key answers.
|
||||
//
|
||||
// Derived from each rule's InertWhenNoData rather than written out as a map,
|
||||
// so a rule added later is covered the day it lands. That field already names
|
||||
// the substrate the rule reads; a fresh fact under one of those keys is by
|
||||
// definition the thing the rule was complaining about the absence of.
|
||||
//
|
||||
// DefaultRules, not the daemon's wired set: a rule disabled in config cannot
|
||||
// have a pending nudge to close anyway, and reading the canonical set here
|
||||
// keeps this free of the config plumbing.
|
||||
func ackRulesForKey(key string) map[string]bool {
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
var out map[string]bool
|
||||
for _, r := range loop.DefaultRules() {
|
||||
for _, k := range r.InertWhenNoData {
|
||||
if k != key {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]bool{}
|
||||
}
|
||||
out[r.Name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ackPhrases — the acknowledgement vocabulary, as stem sequences. Matched by
|
||||
// quietPhrase (quiet_toggle.go), so a single-word pattern matches only a
|
||||
// single-word utterance.
|
||||
//
|
||||
// "да" and "ок" are deliberately absent. Both are answers to a question she
|
||||
// asked, and the clarify gate upstream (resolveClarifyAnswer) has the stronger
|
||||
// claim on them; letting them close a nudge as well would mean a stray "да"
|
||||
// silently rewrites the feedback the auto-tuner learns from.
|
||||
var ackPhrases = [][]string{
|
||||
{"готово"}, {"сделал"}, {"сделано"}, {"выполнил"}, {"уже"},
|
||||
{"уже", "сделал"}, {"уже", "готово"}, {"всё", "сделал"},
|
||||
{"done"}, {"already", "did"},
|
||||
}
|
||||
|
||||
// classifyAck reads an utterance as a contentless acknowledgement.
|
||||
func classifyAck(text string) bool {
|
||||
tokens := quietTokens(text)
|
||||
for _, p := range ackPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func TestClassifyAck(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"готово", "сделал", "сделано", "выполнил", "уже",
|
||||
"уже сделал", "всё сделал", "done",
|
||||
} {
|
||||
if !classifyAck(s) {
|
||||
t.Errorf("classifyAck(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
// "да" and "ок" belong to the clarify gate, not to the nudge.
|
||||
"да", "ок", "хорошо",
|
||||
// A single-word pattern must not eat the sentence it appears in.
|
||||
"сделал бэкап базы", "готово ли обновление", "уже поздно",
|
||||
"напомни завтра позвонить маме", "",
|
||||
} {
|
||||
if classifyAck(s) {
|
||||
t.Errorf("classifyAck(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAckMarksTheNudgeActed(t *testing.T) {
|
||||
h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(6, 2*time.Minute)})
|
||||
reply, handled := h.resolveAck(context.Background(), "готово", sourceVoice)
|
||||
if !handled || reply == "" {
|
||||
t.Fatalf("got (%q, %v), want a reply", reply, handled)
|
||||
}
|
||||
if api.gotID != 6 || api.gotOutcome != store.NudgeActed {
|
||||
t.Fatalf("resolved (%d, %q), want (6, %q)", api.gotID, api.gotOutcome, store.NudgeActed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAckFallsThroughWithNothingPending(t *testing.T) {
|
||||
h, api := snoozeHandler(nil)
|
||||
if reply, handled := h.resolveAck(context.Background(), "готово", sourceVoice); handled || reply != "" {
|
||||
t.Fatalf("got (%q, %v), want fall-through", reply, handled)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Fatalf("resolved a nudge with nothing pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAckRulesForKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
want string // "" means no rule
|
||||
}{
|
||||
{"water", "water"},
|
||||
{"meal", "meal"},
|
||||
{"break", "break"},
|
||||
{"desk_active", "break"},
|
||||
{"weight", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ackRulesForKey(tc.key)
|
||||
if tc.want == "" {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("ackRulesForKey(%q) = %v, want none", tc.key, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !got[tc.want] {
|
||||
t.Errorf("ackRulesForKey(%q) = %v, want %q in it", tc.key, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAckFromFactClosesTheMatchingNudge(t *testing.T) {
|
||||
h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(11, time.Minute)}) // rule "water"
|
||||
h.ackFromFact(context.Background(), router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Slots: router.Slots{Key: "water", HasKey: true},
|
||||
})
|
||||
if api.gotID != 11 || api.gotOutcome != store.NudgeActed {
|
||||
t.Fatalf("resolved (%d, %q), want (11, %q)", api.gotID, api.gotOutcome, store.NudgeActed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAckFromFactIgnoresAnUnrelatedFact(t *testing.T) {
|
||||
// The live nudge is "water"; a meal fact does not answer it. Closing it
|
||||
// anyway would tell the auto-tuner the water rule works when he ignored it.
|
||||
h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(12, time.Minute)})
|
||||
for _, dec := range []router.Decision{
|
||||
{Intent: router.IntentFact, Slots: router.Slots{Key: "meal", HasKey: true}},
|
||||
{Intent: router.IntentFact, Slots: router.Slots{Key: "weight", HasKey: true}},
|
||||
{Intent: router.IntentFact}, // no key
|
||||
{Intent: router.IntentQuery, Slots: router.Slots{Key: "water", HasKey: true}},
|
||||
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", HasKey: true}, Clarify: true},
|
||||
} {
|
||||
h.ackFromFact(context.Background(), dec)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Fatalf("resolved %d nudge(s) on unrelated decisions", api.calls)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
@@ -52,6 +53,19 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
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 "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
||||
case errors.Is(err, mcp.ErrToolGone):
|
||||
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 "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
if out != "" {
|
||||
|
||||
+52
-16
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
||||
@@ -15,14 +16,41 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
if !dec.Slots.HasKey {
|
||||
return "не разобрала, что записать — попробуй иначе."
|
||||
}
|
||||
// A question is never a fact about him (#470). "какая последняя версия
|
||||
// языка Go?" used to land here, and the value stored was whatever the
|
||||
// model invented for it, at confidence 1.00, indexed for recall under the
|
||||
// question's own text. Two such rows then claimed seven unrelated world
|
||||
// questions through recall and silently disabled world answering.
|
||||
//
|
||||
// The routing error itself is not fixed here — the answer is to answer.
|
||||
// Sending the turn down the query chain is what he asked for anyway, and
|
||||
// it costs a mis-routed capture nothing: an explicit "запиши ..." is not
|
||||
// question-shaped, so it never takes this branch.
|
||||
if router.IsQuestionShaped(dec.Utterance) {
|
||||
log.Printf("voice: fact write refused, utterance is a question: %q (key %q) — answering as a query",
|
||||
dec.Utterance, dec.Slots.Key)
|
||||
q := dec
|
||||
q.Intent = router.IntentQuery
|
||||
// The key the model extracted is its guess at what to store, not a
|
||||
// fact he has. Left in place, queryFactByKey would read it back and
|
||||
// claim the turn before any real source ran.
|
||||
q.Slots.Key, q.Slots.HasKey = "", false
|
||||
q.Slots.Value = ""
|
||||
return h.actionQuery(ctx, q)
|
||||
}
|
||||
now := h.now()
|
||||
req := ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
Kind: "self",
|
||||
Key: dec.Slots.Key,
|
||||
Value: dec.Slots.Value,
|
||||
Source: "tap:voice",
|
||||
Confidence: 1.0,
|
||||
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),
|
||||
// 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
|
||||
@@ -36,17 +64,25 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
log.Printf("voice: write fact: %v", err)
|
||||
return "не получилось сохранить факт."
|
||||
}
|
||||
// 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 {
|
||||
// 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.
|
||||
if h.recall.memStore != nil {
|
||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||
log.Printf("voice: embed fact for memory: %v", err)
|
||||
} 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),
|
||||
} 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),
|
||||
}); err != nil {
|
||||
log.Printf("voice: memory insert fact: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
// Money questions (Vikunja #125).
|
||||
//
|
||||
// This is the whole read side: mavpoll holds the zenmoney token and writes
|
||||
// facts(kind=env, source=poll:zenmoney); core reads them back when he asks.
|
||||
// Core never sees the token, never calls zenmoney, and has no rule on these
|
||||
// keys — a total is never a reason for Maven to speak first. Maven is not a
|
||||
// nag, least of all about his money.
|
||||
//
|
||||
// Nothing here can reach the external search capability: the figures are read
|
||||
// from the store and rendered locally, and his financial data is never search
|
||||
// input.
|
||||
|
||||
// queryMoney — "сколько я потратил сегодня?", "покажи мои траты".
|
||||
//
|
||||
// Answers only from the latest fact the poller wrote. Three honest outcomes and
|
||||
// no fourth: the figure, "the fact is old and here is its date", or "money
|
||||
// tracking is not connected". It never computes, estimates or rounds a total of
|
||||
// its own — an invented number about his money is the worst thing this could do.
|
||||
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if q.Window == router.MoneyUnsupported {
|
||||
// Two windows are stored and no others. Answering "сколько я потратил
|
||||
// вчера?" with the month-to-date total answers a different question
|
||||
// with a real number, which is the shape of a lie he cannot spot.
|
||||
return "я храню только сегодняшние траты и за этот месяц.", true
|
||||
}
|
||||
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
||||
if q.Window == router.MoneyToday {
|
||||
key, phrase = zenmoney.KeySpentToday, "сегодня"
|
||||
}
|
||||
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
|
||||
if err != nil {
|
||||
// No fact at all is the normal state when the capability is off. Claim
|
||||
// the turn anyway: falling through to recall would answer a question
|
||||
// about money with whatever note happens to be nearest.
|
||||
if !isNoFactErr(err) {
|
||||
log.Printf("voice: money fact: %v", err)
|
||||
}
|
||||
return "я не отслеживаю траты — не подключено.", true
|
||||
}
|
||||
val, err := zenmoney.ParseFactValue(fact.Value)
|
||||
if err != nil {
|
||||
log.Printf("voice: money fact: decode: %v", err)
|
||||
return "не получилось прочитать траты.", true
|
||||
}
|
||||
now := h.now()
|
||||
if q.Window == router.MoneyToday && !val.CoversDay(now) {
|
||||
// The day window rolled over and the poller had nothing to write,
|
||||
// because he has not spent anything yet today. The fact is fresh by ts
|
||||
// and covers yesterday, so no staleness check can catch it — only the
|
||||
// window stamp inside the value can.
|
||||
return "сегодня пока ничего не вижу.", true
|
||||
}
|
||||
reply := val.FormatRU(phrase)
|
||||
if q.Income {
|
||||
reply = val.FormatIncomeRU(phrase)
|
||||
}
|
||||
if reply == "" {
|
||||
return "по тратам пока нечего сказать.", true
|
||||
}
|
||||
// A stale fact is reported as stale rather than spoken as today's number.
|
||||
// The age is measured from when the figure was last READ, not from when it
|
||||
// last changed: a month with no spending in it does not go stale.
|
||||
asOf := val.AsOf
|
||||
if asOf.IsZero() {
|
||||
asOf = fact.Ts
|
||||
}
|
||||
if now.Sub(asOf) > zenmoney.StaleAfter {
|
||||
return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The
|
||||
// hand-rolled loop this replaces missed any error implementing Is(error) bool.
|
||||
func isNoFactErr(err error) bool {
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
|
||||
// which is the assertion that answering a money question costs no model call
|
||||
// and reaches no network.
|
||||
type moneyAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
fact ipc.Fact
|
||||
err error
|
||||
gotKey string
|
||||
gotSrc string
|
||||
callCnt int
|
||||
}
|
||||
|
||||
func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||
a.gotKey, a.gotSrc = key, source
|
||||
a.callCnt++
|
||||
return a.fact, a.err
|
||||
}
|
||||
|
||||
func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }
|
||||
|
||||
func moneyFact(ts time.Time, val string) ipc.Fact {
|
||||
return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
|
||||
}
|
||||
|
||||
func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
|
||||
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the money source must claim a money question")
|
||||
}
|
||||
if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
|
||||
t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
|
||||
}
|
||||
if !strings.Contains(reply, "1749.5") {
|
||||
t.Errorf("reply = %q, want the exact figure", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "в этом месяце") {
|
||||
t.Errorf("reply = %q, want the window named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMoneyPicksTodaysKey(t *testing.T) {
|
||||
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
if _, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
||||
}); !ok {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if api.gotKey != zenmoney.KeySpentToday {
|
||||
t.Errorf("key = %q, want today's", api.gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// The capability is off unless configured, and then there is no fact. She says
|
||||
// so instead of letting the recall pass answer a money question from a note.
|
||||
func TestQueryMoneySaysNotConnected(t *testing.T) {
|
||||
h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if !strings.Contains(reply, "не подключено") {
|
||||
t.Errorf("reply = %q, want an honest 'not connected'", reply)
|
||||
}
|
||||
// No number of any kind in that answer.
|
||||
for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
|
||||
if strings.Contains(reply, d) {
|
||||
t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fact older than the staleness bound is dated rather than spoken as if it
|
||||
// were current: the poller can be down, and last week's total presented as
|
||||
// today's is a lie by omission.
|
||||
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
|
||||
old := moneyNow().Add(-72 * time.Hour)
|
||||
api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||
})
|
||||
if !strings.Contains(reply, "данные от") {
|
||||
t.Errorf("reply = %q, want the stale fact dated", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
|
||||
api := &moneyAPI{}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
|
||||
if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||
t.Errorf("the money source claimed %q", u)
|
||||
}
|
||||
}
|
||||
if api.callCnt != 0 {
|
||||
t.Error("a non-money question must not read the money facts")
|
||||
}
|
||||
}
|
||||
|
||||
// Money must be answered before the recall sources, or a question about
|
||||
// spending gets answered by the nearest note.
|
||||
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
|
||||
moneyAt, notesAt := -1, -1
|
||||
for i, src := range querySources {
|
||||
switch src.name {
|
||||
case "money":
|
||||
moneyAt = i
|
||||
case "notes":
|
||||
notesAt = i
|
||||
}
|
||||
}
|
||||
if moneyAt < 0 || notesAt < 0 {
|
||||
t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
|
||||
}
|
||||
if moneyAt > notesAt {
|
||||
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
|
||||
}
|
||||
}
|
||||
|
||||
// The day window rolls over at midnight and the poller writes nothing until the
|
||||
// first spend of the new day, so the last money_today fact is fresh by ts and
|
||||
// covers yesterday. No staleness check can catch that.
|
||||
func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) {
|
||||
yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1))
|
||||
sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3}
|
||||
val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour))
|
||||
if !ok {
|
||||
t.Fatal("want a fact value")
|
||||
}
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentToday, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, claimed := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
||||
})
|
||||
if !claimed {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if strings.Contains(reply, "1749.5") {
|
||||
t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Ts advances only when the number moves, so a quiet month used to be reported
|
||||
// as stale while being current. The read stamp inside the value is what the
|
||||
// staleness check means.
|
||||
func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) {
|
||||
from, _ := zenmoney.MonthWindow(moneyNow())
|
||||
sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1}
|
||||
val, _ := sum.Value(moneyNow().Add(-time.Hour))
|
||||
// The fact itself last CHANGED three days ago: nothing was spent since.
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
||||
})
|
||||
if strings.Contains(reply, "данные от") {
|
||||
t.Errorf("reply = %q — the figure was read an hour ago and is current", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Two windows are stored and no others. Answering "вчера" with the
|
||||
// month-to-date total answers a different question with a real number.
|
||||
func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) {
|
||||
api := &moneyAPI{}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил вчера?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a money question must be claimed, not passed to recall")
|
||||
}
|
||||
if !strings.Contains(reply, "только") {
|
||||
t.Errorf("reply = %q, want her to say which windows she keeps", reply)
|
||||
}
|
||||
if api.callCnt != 0 {
|
||||
t.Error("a window she does not keep must not read a fact")
|
||||
}
|
||||
}
|
||||
|
||||
// "сколько я заработал" reads the same fact and must lead with the income.
|
||||
func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) {
|
||||
from, _ := zenmoney.MonthWindow(moneyNow())
|
||||
sum := zenmoney.Summary{
|
||||
From: from,
|
||||
Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}},
|
||||
Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}},
|
||||
Count: 2,
|
||||
}
|
||||
val, _ := sum.Value(moneyNow())
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow(),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"},
|
||||
})
|
||||
if strings.Index(reply, "3000") > strings.Index(reply, "100") {
|
||||
t.Errorf("reply = %q, want the income he asked about first", reply)
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,16 @@ import (
|
||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||
// index it for recall.
|
||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||
// 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
|
||||
}
|
||||
// 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.embedder, dec.Utterance)
|
||||
vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
@@ -27,8 +33,8 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
}
|
||||
// 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.memStore != nil {
|
||||
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
if h.recall.memStore != nil {
|
||||
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "note",
|
||||
"text": dec.Utterance,
|
||||
|
||||
+555
-24
@@ -5,13 +5,18 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"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"
|
||||
"github.com/kami/maven/internal/weather"
|
||||
)
|
||||
|
||||
@@ -38,6 +43,20 @@ type queryTurn struct {
|
||||
type querySource struct {
|
||||
name string
|
||||
answer func(*reactiveHandler, context.Context, *queryTurn) (string, bool)
|
||||
// dateAware — this source reads the day out of the turn and answers for
|
||||
// THAT day. Only such a source may claim a continuation ("а завтра?"),
|
||||
// because a continuation is a question about a different day and nothing
|
||||
// else. A date-blind source claiming one would answer with today's data
|
||||
// under tomorrow's question, which is a wrong answer delivered in a
|
||||
// confident voice — the failure mode that took reminder out of
|
||||
// continuableIntents (continuation.go).
|
||||
//
|
||||
// Exactly one source qualifies today, and that is not an oversight in the
|
||||
// table: CalendarEvents is the only CoreAPI call that takes a date at all.
|
||||
// DayPlan is today-only, CurrentWeather is now-only, and the recall
|
||||
// sources search text with no notion of a day. When one of them grows a
|
||||
// date parameter, flip its flag here.
|
||||
dateAware bool
|
||||
}
|
||||
|
||||
// querySources is the ordered chain actionQuery walks; first source to claim
|
||||
@@ -46,26 +65,90 @@ 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{
|
||||
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
||||
{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.
|
||||
{"day-plan", (*reactiveHandler).queryDayPlan},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
{"memory", (*reactiveHandler).queryMemory},
|
||||
{"notes", (*reactiveHandler).queryNotes},
|
||||
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
||||
{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},
|
||||
// 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},
|
||||
// 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},
|
||||
// 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},
|
||||
// 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},
|
||||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||
// question about the LAN, and the recall pass would otherwise answer it
|
||||
// from an old note about the router. Its matcher needs a network word plus
|
||||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
||||
{name: "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},
|
||||
// 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},
|
||||
// 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},
|
||||
// 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
|
||||
// answers after this, because a URL he said out loud is an instruction and
|
||||
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
||||
// This source only claims a turn where he named a URL, so it never competes
|
||||
// with a local answer.
|
||||
{name: "web", answer: (*reactiveHandler).queryWeb},
|
||||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral},
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||||
t := &queryTurn{dec: dec}
|
||||
for _, src := range querySources {
|
||||
if dec.Continued && !src.dateAware {
|
||||
continue
|
||||
}
|
||||
if reply, ok := src.answer(h, ctx, t); ok {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
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 "про другой день так не отвечу — спроси целиком."
|
||||
}
|
||||
return "не знаю."
|
||||
}
|
||||
|
||||
@@ -97,11 +180,16 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
|
||||
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||||
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||||
// reminders, and any morning checklist still outstanding.
|
||||
// reminders, and every morning checklist item today still has no evidence for,
|
||||
// including the ones whose window has closed.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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").
|
||||
// The spoken recital here is the current answer, not the decided one.
|
||||
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
@@ -111,7 +199,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
}
|
||||
if !isRestOfDayQuery(t.dec.Utterance) {
|
||||
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
|
||||
@@ -128,11 +216,101 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
}
|
||||
|
||||
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||||
// that means "from now on" rather than "the whole day".
|
||||
func isRestOfDayQuery(text string) bool {
|
||||
s := strings.ToLower(text)
|
||||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
// over. Enough for a season of habits without scanning the whole store on every
|
||||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||
//
|
||||
// The read is kind-filtered in SQL, and that is the load-bearing part. When this
|
||||
// was a plain recent-facts read the window was a row budget over every writer,
|
||||
// and the machine writers dwarf the taps: mavpoll writes a wg_handshake row
|
||||
// whenever a peer rehandshakes, which is roughly every two minutes per peer, so
|
||||
// 2000 rows was under three days of history. A weekday habit needs
|
||||
// memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so
|
||||
// she answered "по вторникам у меня пока нет ничего постоянного" forever on a
|
||||
// store with a year of taps in it. Self facts come from voice taps, and he does
|
||||
// not tap seven hundred times a day.
|
||||
const habitFactWindow = 2000
|
||||
|
||||
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||||
// answer out of the fact log rather than asking the model to summarise a life:
|
||||
// see internal/memory/behavior.go for why nothing here is generated.
|
||||
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseHabitQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
|
||||
}
|
||||
profile := memory.BuildProfile(obs, h.now())
|
||||
if q.HasWeekday {
|
||||
return profile.FormatWeekdayRU(q.Weekday), true
|
||||
}
|
||||
if q.Weekend {
|
||||
return profile.FormatWeekendRU(), true
|
||||
}
|
||||
return profile.FormatOverallRU(), true
|
||||
}
|
||||
|
||||
// feedNoteWindow — how many recent FEED notes are scanned, and
|
||||
// feedReadOut — how many headlines she actually reads back. She summarises the
|
||||
// top of the pile, she does not recite a river.
|
||||
const (
|
||||
feedNoteWindow = 200
|
||||
feedReadOut = 3
|
||||
)
|
||||
|
||||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
// This is the ONLY way a feed item reaches him. The poller writes notes and
|
||||
// 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) {
|
||||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if !h.feedsOn {
|
||||
// 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 "в
|
||||
// лентах пока ничего нового" while the poller was working fine.
|
||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return "не получилось посмотреть ленты.", true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary, category tag and link; she reads the
|
||||
// title alone. The tag is for the match above, and piper reads brackets
|
||||
// out loud.
|
||||
picked = append(picked, rss.NoteHeadline(n.Text))
|
||||
if len(picked) == feedReadOut {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
return "по этой теме в лентах пока ничего.", true
|
||||
}
|
||||
return "в лентах пока ничего нового.", true
|
||||
}
|
||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||
}
|
||||
|
||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||
@@ -159,17 +337,64 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
return f.FormatEntries(entries, date), true
|
||||
}
|
||||
|
||||
// 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 !isHomeQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.home == nil {
|
||||
// Fall through rather than claim the turn. A capability that is off
|
||||
// must not change what an unconfigured box answers: "какая температура
|
||||
// в доме?" on a Maven with no smarthome block reached recall before
|
||||
// this source existed, and a stored fact is a better answer than
|
||||
// "дом не подключён" from a house that was never configured. The
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return h.home.homeSummary(ctxH)
|
||||
}
|
||||
|
||||
// queryNetwork answers a question about the LAN with a bounded scan. There is
|
||||
// no confirm turn because nothing is changed, and no way to widen the range
|
||||
// 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 !isNetworkQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
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 "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
return "погода не настроена.", true
|
||||
}
|
||||
if errors.Is(err, weather.ErrLocationUnknown) {
|
||||
// He named a place and the geocoder does not have it. Saying so beats
|
||||
// reading out the default city's temperature (Vikunja #421).
|
||||
return "не знаю такого города — " + loc + ".", true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("voice: weather: %v", err)
|
||||
return "не получилось узнать погоду.", true
|
||||
@@ -181,7 +406,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
// sources below both need, run once, in the position it always ran in. It
|
||||
// only claims the turn when the embedder fails.
|
||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
@@ -201,19 +426,27 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
||||
// 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.memStore == nil {
|
||||
if h.recall.memStore == nil {
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
|
||||
if herr != nil {
|
||||
log.Printf("voice: memory search: %v", herr)
|
||||
return "", false
|
||||
}
|
||||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
||||
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
text := hit.Meta["text"]
|
||||
// The score cleared the gate and the topic still has to match (#470). A
|
||||
// note about his slow network scored high enough to answer "почему небо
|
||||
// синее?", because the right-note and must-be-silent score ranges overlap
|
||||
// and no threshold sits between them.
|
||||
if !memory.RecallAllowed(t.dec.Utterance, text) {
|
||||
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance)
|
||||
return "", false
|
||||
}
|
||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||
// stored.
|
||||
if hit.Meta["type"] == "note" {
|
||||
@@ -246,7 +479,13 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
for i, n := range notes {
|
||||
noteScores[i] = n.Score
|
||||
}
|
||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
||||
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||
return "", false
|
||||
}
|
||||
// Same topic veto as queryMemory above: the best note must be about what
|
||||
// he asked, not merely the nearest vector in the index.
|
||||
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
|
||||
log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance)
|
||||
return "", false
|
||||
}
|
||||
texts := make([]string, len(notes))
|
||||
@@ -263,11 +502,303 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// 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.
|
||||
// webPageContextRunes — how much of a fetched page is handed to the phraser.
|
||||
// Less than the crawler keeps: the rest of the 4096-token window belongs to the
|
||||
// prompt, the persona block and the reply.
|
||||
const webPageContextRunes = 1500
|
||||
|
||||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||
//
|
||||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||
// becoming a habit: no URL, no fetch, and the model answers from what is local.
|
||||
// What leaves the box is the URL and nothing else — no note, no fact, no history
|
||||
// travels with it.
|
||||
func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
link, ok := router.FirstURL(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if h.crawler == nil {
|
||||
// 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, 30*time.Second)
|
||||
defer cancel()
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
if errors.Is(err, crawl.ErrRobots) {
|
||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||
}
|
||||
log.Printf("voice: web: %v", err)
|
||||
return "не получилось прочитать страницу.", true
|
||||
}
|
||||
if page.Text == "" {
|
||||
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})
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// kiwixTimeout — the whole ZIM source, rewrite included. The rewrite is one
|
||||
// short constrained completion and the search is a LAN request; if the pair
|
||||
// takes longer than this something is wrong and he is better served by the
|
||||
// model's own answer than by more waiting.
|
||||
const kiwixTimeout = 20 * time.Second
|
||||
|
||||
// searchTimeout — the whole metasearch source. websearch.Client already holds a
|
||||
// per-request timeout from config; this is the outer bound on the turn, so a
|
||||
// hung dial cannot outlive it either. Shorter than kiwixTimeout because there
|
||||
// is no rewrite call in front of it: the question goes out verbatim.
|
||||
const searchTimeout = 12 * time.Second
|
||||
|
||||
// querySearch — the live web, through a self-hosted SearXNG.
|
||||
//
|
||||
// Ahead of Kiwix by the owner's ruling of 2026-08-02: a search reads what is
|
||||
// true today, a ZIM reads what was true when it was built, and the ZIM is the
|
||||
// fallback for a box with no line out. Everything of his still answers first —
|
||||
// the personal boundary is directly above this source, so a question ABOUT him
|
||||
// never becomes a query.
|
||||
//
|
||||
// What leaves this process is the query string and nothing else. His notes, his
|
||||
// facts, the persona block and the history do not travel with it: the websearch
|
||||
// package cannot read the store. That is the CLAUDE.md rule made mechanical,
|
||||
// not a promise about how the prompt is assembled.
|
||||
//
|
||||
// It claims the turn only when the search returns something. An empty result,
|
||||
// an unreachable instance and a 403 from an instance without the JSON format
|
||||
// all fall through to Kiwix, which is the point of the ordering.
|
||||
func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if h.search == nil {
|
||||
// Off unless configured, same as the crawler and the ZIMs. Nothing is
|
||||
// said about it: he never asked for a capability he did not enable.
|
||||
return "", false
|
||||
}
|
||||
ctxS, cancel := context.WithTimeout(ctx, searchTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Verbatim. No rewriter: SearXNG ranks by meaning through real engines, and
|
||||
// reducing "почему небо голубое" to English keywords would throw away the
|
||||
// language he asked in along with the ranking that handles it.
|
||||
resp, err := h.search.client.Search(ctxS, t.dec.Utterance, h.search.max)
|
||||
if err != nil {
|
||||
log.Printf("voice: search %q: %v", t.dec.Utterance, err)
|
||||
return "", false
|
||||
}
|
||||
if resp.Empty() {
|
||||
return "", false
|
||||
}
|
||||
// Logged on the way through, not only on failure. Without this there is no
|
||||
// telling from the outside whether an answer came off the web, off a ZIM or
|
||||
// out of the model's weights, and those are the cases worth telling apart.
|
||||
log.Printf("voice: search: %q → %d answers, %d results", t.dec.Utterance, len(resp.Answers), len(resp.Results))
|
||||
|
||||
// Handed over the same way a note, a page or an article is: evidence for the
|
||||
// question he asked, not something to recite. The trim is one budget over the
|
||||
// joined block, so a long first snippet cannot crowd out the rest.
|
||||
evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes)
|
||||
reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence})
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// pretend the search did not happen.
|
||||
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// queryKiwix — the offline encyclopedia, and the fallback behind querySearch:
|
||||
// everything of his has already had its turn and the live search found nothing
|
||||
// or could not be reached. Reading beats recalling for a 1.7B either way.
|
||||
//
|
||||
// What leaves this process is the search query and nothing else. His notes,
|
||||
// his facts, the persona block and the history do not travel with it — the
|
||||
// kiwix package cannot read the store. That holds even though the server is on
|
||||
// the LAN, because "local sources first" is not a licence to widen what a
|
||||
// lookup is allowed to see.
|
||||
//
|
||||
// It claims the turn only when the search returns something. No results is not
|
||||
// a failure worth announcing: it means the ZIM does not cover this, and the
|
||||
// model answering next is the better outcome than "ничего не нашла".
|
||||
func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if h.kiwix == nil {
|
||||
// Off unless configured, same as the crawler and the weather. Nothing
|
||||
// is said about it: he never asked for a capability he did not enable.
|
||||
return "", false
|
||||
}
|
||||
ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 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 {
|
||||
q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// Fall through to the verbatim question rather than give up. It
|
||||
// will usually miss, and missing is a fall-through too.
|
||||
log.Printf("voice: kiwix: rewrite: %v", err)
|
||||
} else if q != "" {
|
||||
pattern = q
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return "", false
|
||||
}
|
||||
top := hits[0]
|
||||
// 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 → %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
|
||||
// is usually the navigation box at the foot of the page — the first version
|
||||
// of this joined three of those and she recited "Ecological economics
|
||||
// Ecological footprint …" at him. The head of the article is the lead
|
||||
// paragraph, which is the definition the snippet was meant to be.
|
||||
page, aerr := h.kiwix.client.Article(ctxK, top.Path, h.kiwix.runes)
|
||||
if aerr != nil || page.Text == "" {
|
||||
if aerr != nil {
|
||||
log.Printf("voice: kiwix: article %s: %v", top.Path, aerr)
|
||||
}
|
||||
// The search did find something, so fall back to its snippet rather
|
||||
// than throw the hit away.
|
||||
if top.Snippet == "" {
|
||||
return "", false
|
||||
}
|
||||
page = crawl.Page{Title: top.Title, Text: top.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)
|
||||
reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// the search did not happen.
|
||||
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// queryPersonal — stop the walk on a question about him that his own data did
|
||||
// not answer.
|
||||
//
|
||||
// Every source above this one reads something of his: his facts, his calendar,
|
||||
// his tasks, his house, his notes. Everything below reads the world: an offline
|
||||
// Wikipedia, a page he named, the model's own weights. The world does not know
|
||||
// when his meeting is, and asked anyway it will produce something.
|
||||
//
|
||||
// It did. "во сколько у меня встреча" reached Kiwix on the deployed daemon,
|
||||
// 01-08-2026; Wikipedia matched an article on the 2015 CPISRA World Games, and
|
||||
// the phraser rendered it as "встреча у тебя в 2015 CPISRA World Games, где
|
||||
// были соревнования по плаванию". Fluent, confident, and about a swimming
|
||||
// competition in Nottingham. Saying "не знаю" is not a worse answer than that
|
||||
// one — it is the only true one.
|
||||
//
|
||||
// Note this is also the privacy edge. The rule in CLAUDE.md is that only the
|
||||
// utterance may leave the box, never his notes; a question that is ABOUT him
|
||||
// carries his life in the utterance itself, so it is the one class that should
|
||||
// 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) {
|
||||
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 "не знаю — не нашла у тебя такой записи.", true
|
||||
}
|
||||
|
||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||
//
|
||||
// "у меня" and "мой" attach to a thing that is his, which is what makes the
|
||||
// question unanswerable from outside. A bare "мне" or "я" does not: "как мне
|
||||
// сварить борщ" and "что я могу посмотреть" are ordinary questions about the
|
||||
// world that happen to mention the asker, and refusing those would be the
|
||||
// opposite mistake. The narrow test is the point.
|
||||
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the
|
||||
// Russian patterns spell the boundary out as "not a letter or a digit". The
|
||||
// English ones keep \b, where it works.
|
||||
var personalMarkers = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])у\s+меня([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])мо(й|я|ё|е|и|его|ей|их|им|ими|ем|ю|ею)([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)\bmy\b`),
|
||||
regexp.MustCompile(`(?i)\bdo\s+i\s+have\b`),
|
||||
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.
|
||||
func isPersonalQuery(utterance string) bool {
|
||||
if utterance == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range personalMarkers {
|
||||
if re.MatchString(utterance) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
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.
|
||||
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil)
|
||||
if h.phraser == nil {
|
||||
// No model of any size. That is not the workstation being asleep, so it
|
||||
// is not that gap: it is simply not knowing.
|
||||
return "не знаю.", true
|
||||
}
|
||||
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
||||
return worldGap, true
|
||||
}
|
||||
if err != nil || reply == "" {
|
||||
return "не знаю.", true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// contQueryAPI records which core call a continued query reached. DayPlan and
|
||||
// LatestFact are here to be caught, not to be used: a continuation must never
|
||||
// reach them, and the counters are how the test says so.
|
||||
type contQueryAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
from, to time.Time
|
||||
events int
|
||||
plans int
|
||||
factLooks int
|
||||
}
|
||||
|
||||
func (a *contQueryAPI) CalendarEvents(_ context.Context, from, to time.Time) ([]ipc.Fact, error) {
|
||||
a.events++
|
||||
a.from, a.to = from, to
|
||||
return []ipc.Fact{{Key: "calendar", Value: "Планёрка @ 14:00", Confidence: 1.0, Ts: from.Add(14 * time.Hour)}}, nil
|
||||
}
|
||||
|
||||
func (a *contQueryAPI) DayPlan(context.Context) (ipc.DayPlan, error) {
|
||||
a.plans++
|
||||
return ipc.DayPlan{Spoken: "план на сегодня"}, nil
|
||||
}
|
||||
|
||||
func (a *contQueryAPI) LatestFact(_ context.Context, key string) (ipc.Fact, error) {
|
||||
a.factLooks++
|
||||
return ipc.Fact{Key: key, Value: "2л", Ts: contNow.Add(-time.Hour)}, nil
|
||||
}
|
||||
|
||||
func contQueryHandler() (*reactiveHandler, *contQueryAPI) {
|
||||
api := &contQueryAPI{}
|
||||
return &reactiveHandler{api: api, now: func() time.Time { return contNow }}, api
|
||||
}
|
||||
|
||||
// A continuation is a question about another day, so the one source that can
|
||||
// read a day answers it — for the day the ellipsis named, not for today.
|
||||
func TestContinuedQueryReachesTheCalendar(t *testing.T) {
|
||||
h, api := contQueryHandler()
|
||||
reply := h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "а завтра?",
|
||||
Continued: true,
|
||||
Slots: router.Slots{Text: "что у меня сегодня", Time: contNow.Add(24 * time.Hour), HasTime: true},
|
||||
})
|
||||
if api.events != 1 {
|
||||
t.Fatalf("CalendarEvents called %d times, want 1", api.events)
|
||||
}
|
||||
if got, want := api.from.Format("2006-01-02"), "2026-08-02"; got != want {
|
||||
t.Errorf("asked the calendar for %s, want %s", got, want)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Error("empty reply")
|
||||
}
|
||||
}
|
||||
|
||||
// The regression this gate exists for: every other source is date-blind, so
|
||||
// letting one claim a continuation answers a question about tomorrow with
|
||||
// today's data. queryFactByKey was the live case — HasKey plus HasTime, both
|
||||
// set by the continuation, and it replies with a stored fact's own timestamp.
|
||||
func TestContinuedQuerySkipsDateBlindSources(t *testing.T) {
|
||||
h, api := contQueryHandler()
|
||||
h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "а вчера?",
|
||||
Continued: true,
|
||||
Slots: router.Slots{
|
||||
Key: "water", HasKey: true,
|
||||
Text: "когда я пил воду",
|
||||
Time: contNow.Add(-24 * time.Hour), HasTime: true,
|
||||
},
|
||||
})
|
||||
if api.factLooks != 0 {
|
||||
t.Errorf("fact-by-key claimed a continuation (%d lookups)", api.factLooks)
|
||||
}
|
||||
if api.plans != 0 {
|
||||
t.Errorf("day-plan claimed a continuation (%d calls)", api.plans)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing date-aware claimed it: say that, rather than "не знаю", which reads
|
||||
// as "no data for that day" when she never looked.
|
||||
func TestContinuedQueryWithNoDateAwareAnswerSaysSo(t *testing.T) {
|
||||
h, _ := contQueryHandler()
|
||||
// No parseable day in the utterance, so even the calendar passes.
|
||||
reply := h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "а?",
|
||||
Continued: true,
|
||||
Slots: router.Slots{Text: "какая погода", HasTime: true},
|
||||
})
|
||||
if reply == "не знаю." || !strings.Contains(reply, "спроси целиком") {
|
||||
t.Fatalf("reply = %q, want the honest continuation refusal", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// An ordinary query is untouched by the gate — every source still runs.
|
||||
func TestOrdinaryQueryStillReachesEverySource(t *testing.T) {
|
||||
h, api := contQueryHandler()
|
||||
h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие планы на сегодня?",
|
||||
})
|
||||
if api.plans != 1 {
|
||||
t.Fatalf("day-plan called %d times on an ordinary query, want 1", api.plans)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestIsPersonalQuery(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"во сколько у меня встреча",
|
||||
"что у меня сегодня",
|
||||
"когда мой следующий отпуск",
|
||||
"где моя книга",
|
||||
"сколько моих задач висит",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
// First person without possession. These are questions about the
|
||||
// world that merely mention the asker, and refusing them would be the
|
||||
// opposite mistake.
|
||||
"как мне сварить борщ",
|
||||
"что я могу посмотреть вечером",
|
||||
"почему небо синее",
|
||||
"столица франции",
|
||||
"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) {
|
||||
t.Errorf("isPersonalQuery(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// kiwixTrapAPI stands in for the world. Nothing below the personal boundary
|
||||
// should be consulted for a question about him, so the test asserts on the
|
||||
// reply rather than on a call: reaching Kiwix or general knowledge produces a
|
||||
// phrased answer, and refusing produces the honest one.
|
||||
func personalHandler() *reactiveHandler {
|
||||
return &reactiveHandler{
|
||||
api: ipc.UnimplementedCoreAPI{},
|
||||
now: func() time.Time { return contNow },
|
||||
// No phraser and no kiwix wiring: if the walk gets past the personal
|
||||
// source it reaches queryGeneral, which returns "не знаю." with a nil
|
||||
// phraser — a different string from the one this guard produces, so
|
||||
// the two cases stay distinguishable.
|
||||
}
|
||||
}
|
||||
|
||||
// The regression: "во сколько у меня встреча" reached Kiwix, Wikipedia matched
|
||||
// an article on the 2015 CPISRA World Games, and the phraser reported it back
|
||||
// as his meeting. Seen on the deployed daemon, 01-08-2026.
|
||||
func TestPersonalQuestionIsNotSentToTheWorld(t *testing.T) {
|
||||
h := personalHandler()
|
||||
reply, ok := h.queryPersonal(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "во сколько у меня встреча"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("queryPersonal passed on a question about him")
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("empty reply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorldQuestionsPassThroughTheBoundary(t *testing.T) {
|
||||
h := personalHandler()
|
||||
for _, u := range []string{"почему небо синее", "столица франции"} {
|
||||
if _, ok := h.queryPersonal(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); ok {
|
||||
t.Errorf("queryPersonal claimed %q, want it to pass to the encyclopedia", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The boundary must sit above kiwix and general-knowledge and below every
|
||||
// source that reads his own data. Asserted on the table itself: an ordering
|
||||
// bug here is silent, because both arrangements answer, just from the wrong
|
||||
// place.
|
||||
func TestPersonalBoundarySitsBetweenHisDataAndTheWorld(t *testing.T) {
|
||||
idx := map[string]int{}
|
||||
for i, s := range querySources {
|
||||
idx[s.name] = i
|
||||
}
|
||||
boundary, ok := idx["personal"]
|
||||
if !ok {
|
||||
t.Fatal("no personal source in the chain")
|
||||
}
|
||||
for _, his := range []string{"fact-by-key", "day-plan", "tasks", "calendar", "memory", "notes"} {
|
||||
if i, ok := idx[his]; !ok || i > boundary {
|
||||
t.Errorf("%q reads his own data and must run before the personal boundary", his)
|
||||
}
|
||||
}
|
||||
for _, world := range []string{"search", "kiwix", "web", "general-knowledge"} {
|
||||
if i, ok := idx[world]; !ok || i < boundary {
|
||||
t.Errorf("%q reads the world and must run after the personal boundary", world)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/websearch"
|
||||
)
|
||||
|
||||
func searchHandler(t *testing.T, body string, status int) (*reactiveHandler, *string) {
|
||||
t.Helper()
|
||||
var seen string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.URL.RawQuery
|
||||
if status != http.StatusOK {
|
||||
http.Error(w, "no", status)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &reactiveHandler{
|
||||
// No phraser: querySearch then reads back the best evidence, which is
|
||||
// what makes the claim visible without a llama-server in the test.
|
||||
search: &searchWiring{client: websearch.New(srv.URL, websearch.Options{}), max: 3, runes: 1500},
|
||||
}, &seen
|
||||
}
|
||||
|
||||
const searchBody = `{"answers":["Небо голубое из-за рэлеевского рассеяния."],
|
||||
"results":[{"title":"Рэлеевское рассеяние","url":"https://ru.wikipedia.org/x","content":"Рассеяние света."}]}`
|
||||
|
||||
func TestQuerySearchClaimsAndReadsBack(t *testing.T) {
|
||||
h, _ := searchHandler(t, searchBody, http.StatusOK)
|
||||
reply, ok := h.querySearch(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("querySearch passed on a search with hits")
|
||||
}
|
||||
if !strings.Contains(reply, "рэлеевского рассеяния") {
|
||||
t.Fatalf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// No rewriter in front of this source: SearXNG ranks by meaning, and reducing
|
||||
// the question to English keywords would throw away the language he asked in.
|
||||
func TestQuerySearchSendsTheQuestionVerbatim(t *testing.T) {
|
||||
h, seen := searchHandler(t, searchBody, http.StatusOK)
|
||||
h.querySearch(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"},
|
||||
})
|
||||
if !strings.Contains(*seen, "q="+url.QueryEscape("почему небо голубое")) {
|
||||
t.Fatalf("query string = %q", *seen)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole reason the ordering is safe: an unreachable or empty instance
|
||||
// passes the turn to Kiwix instead of claiming it with an apology.
|
||||
func TestQuerySearchFallsThroughWhenItFails(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
}{
|
||||
{"http error", "", http.StatusForbidden},
|
||||
{"no hits", `{"answers":[],"results":[]}`, http.StatusOK},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, _ := searchHandler(t, tc.body, tc.status)
|
||||
if _, ok := h.querySearch(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"},
|
||||
}); ok {
|
||||
t.Fatal("querySearch claimed the turn; Kiwix never got its fallback")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured, and silent about it: he never asked for a capability
|
||||
// he did not enable.
|
||||
func TestQuerySearchOffWithoutConfig(t *testing.T) {
|
||||
h := &reactiveHandler{}
|
||||
if _, ok := h.querySearch(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо голубое"},
|
||||
}); ok {
|
||||
t.Fatal("querySearch claimed a turn with no search block")
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's ruling of 2026-08-02: the live search asks first, the ZIM is the
|
||||
// fallback for a box with no line out.
|
||||
func TestSearchRunsBeforeKiwix(t *testing.T) {
|
||||
idx := map[string]int{}
|
||||
for i, s := range querySources {
|
||||
idx[s.name] = i
|
||||
}
|
||||
if idx["search"] > idx["kiwix"] {
|
||||
t.Fatalf("search at %d, kiwix at %d: the ZIM is the fallback, not the first read", idx["search"], idx["kiwix"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
)
|
||||
|
||||
// Task capture on the voice/chat path (Vikunja #130).
|
||||
//
|
||||
// Two halves, both deliberately small:
|
||||
//
|
||||
// - captureTaskFromNote runs at the top of actionNote. An utterance that
|
||||
// explicitly files a task ("добавь в задачи купить молоко") goes to the task
|
||||
// store instead of the note store. Anything without an explicit marker is
|
||||
// still a note — see router.ParseTaskCapture for why "надо бы поспать" must
|
||||
// not become a task.
|
||||
// - queryTasks is a query source that reads the list back.
|
||||
//
|
||||
// Nothing here speaks unprompted. Tasks are answered when asked about; no tick
|
||||
// rule reads the table.
|
||||
|
||||
// captureTaskFromNote claims the turn when the utterance explicitly files a
|
||||
// task, returning the reply. ("", false) hands the turn back to the note path.
|
||||
func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
cap, ok := router.ParseTaskCapture(dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||
Text: cap.Text,
|
||||
Source: "tap:voice",
|
||||
Status: store.TaskOpen, // he stated it himself — not a candidate
|
||||
Weight: cap.Weight, // 0 unless he said "срочно" / "важно"
|
||||
Ts: h.now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
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 "поняла, беру в работу: " + cap.Text, true
|
||||
}
|
||||
if !resp.Created {
|
||||
return "это уже в списке.", true
|
||||
}
|
||||
return "записала: " + cap.Text, true
|
||||
}
|
||||
|
||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
//
|
||||
// Reads the live set and recites it in priority order (Vikunja #129). The order
|
||||
// is computed by internal/tasks from what he told her — deadlines, the urgency
|
||||
// he stated, how long a task has been sitting — never asked of the model. The
|
||||
// rendering is the package's too, so the spoken list and the /tasks page can
|
||||
// never disagree about what comes first.
|
||||
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsTaskListQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
live, err := h.api.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
log.Printf("voice: list tasks: %v", err)
|
||||
return "не получилось посмотреть задачи.", true
|
||||
}
|
||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
// internal/tasks so the ranker stays a pure package with no ipc (and therefore
|
||||
// no store, and therefore no cgo) dependency — the same posture as
|
||||
// internal/morning and internal/memory.
|
||||
func taskItems(ts []ipc.Task) []tasks.Item {
|
||||
out := make([]tasks.Item, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = tasks.Item{
|
||||
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// taskAPI answers only the three task methods; every other call is
|
||||
// unimplemented, which is the assertion that capture needs nothing else — in
|
||||
// particular no embedder, so a filed task costs no model call.
|
||||
type taskAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
captured []ipc.CaptureTaskReq
|
||||
created bool
|
||||
promoted bool
|
||||
capErr error
|
||||
|
||||
tasks []ipc.Task
|
||||
listArg string
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
a.captured = append(a.captured, req)
|
||||
if a.capErr != nil {
|
||||
return ipc.CaptureTaskResp{}, a.capErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 1, Created: a.created, Promoted: a.promoted}, nil
|
||||
}
|
||||
|
||||
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
a.listArg = status
|
||||
return a.tasks, a.listErr
|
||||
}
|
||||
|
||||
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
||||
|
||||
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||
return &reactiveHandler{api: api, now: taskNow}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
|
||||
api := &taskAPI{created: true}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an explicit capture must claim the turn")
|
||||
}
|
||||
if len(api.captured) != 1 {
|
||||
t.Fatalf("captured %d, want 1", len(api.captured))
|
||||
}
|
||||
got := api.captured[0]
|
||||
if got.Text != "купить молоко" {
|
||||
t.Errorf("text = %q, want the marker stripped", got.Text)
|
||||
}
|
||||
if got.Source != "tap:voice" {
|
||||
t.Errorf("source = %q, want tap:voice", got.Source)
|
||||
}
|
||||
if got.Status != "open" {
|
||||
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
|
||||
}
|
||||
if !got.Ts.Equal(taskNow()) {
|
||||
t.Errorf("ts = %v, want the handler clock", got.Ts)
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want it to read the task back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A note is still a note: capture only fires on an explicit marker, so
|
||||
// ordinary recall is untouched.
|
||||
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
|
||||
api := &taskAPI{}
|
||||
h := taskHandler(api)
|
||||
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
|
||||
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
|
||||
t.Errorf("%q was captured as a task", u)
|
||||
}
|
||||
}
|
||||
if len(api.captured) != 0 {
|
||||
t.Errorf("captured %d requests, want none", len(api.captured))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{created: false})
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
|
||||
if !ok {
|
||||
t.Fatal("expected the capture path to claim it")
|
||||
}
|
||||
if !strings.Contains(reply, "уже") {
|
||||
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
|
||||
if !ok {
|
||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want an honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open"},
|
||||
{ID: 2, Text: "продлить страховку", Status: "candidate"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the task source must claim a task-list question")
|
||||
}
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
|
||||
t.Errorf("reply = %q, want both tasks", reply)
|
||||
}
|
||||
// The candidate must be named as unconfirmed, not recited as his work.
|
||||
openIdx := strings.Index(reply, "купить молоко")
|
||||
candIdx := strings.Index(reply, "продлить страховку")
|
||||
if !(openIdx < candIdx) {
|
||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "не подтвердил") {
|
||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The stated urgency rides through capture as a weight, so the ranker can use
|
||||
// it later (Vikunja #129). "срочно" is not part of the task text.
|
||||
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
|
||||
api := &taskAPI{created: true}
|
||||
h := taskHandler(api)
|
||||
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Utterance: "добавь в задачи срочно оплатить интернет",
|
||||
}); !ok {
|
||||
t.Fatal("expected a capture")
|
||||
}
|
||||
got := api.captured[0]
|
||||
if got.Text != "оплатить интернет" {
|
||||
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
|
||||
}
|
||||
if got.Weight == 0 {
|
||||
t.Error("weight = 0 — he said срочно and it was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// The recital is ordered by the ranker, not by insertion: a deadline he named
|
||||
// comes before undated work.
|
||||
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
|
||||
due := taskNow()
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
|
||||
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply, _ := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "какие у меня задачи?"},
|
||||
})
|
||||
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want the dated task first", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "сегодня") {
|
||||
t.Errorf("reply = %q, want the reason named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksEmptyList(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{})
|
||||
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "что мне нужно сделать?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the task source to claim it")
|
||||
}
|
||||
if reply != "задач нет." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
|
||||
api := &taskAPI{}
|
||||
h := taskHandler(api)
|
||||
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
|
||||
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||
t.Errorf("the task source claimed %q", u)
|
||||
}
|
||||
}
|
||||
if api.listArg != "" {
|
||||
t.Error("a non-task question must not read the task list")
|
||||
}
|
||||
}
|
||||
|
||||
// The chain must reach the task source before the recall sources, or "что мне
|
||||
// нужно сделать?" gets answered by whatever note is nearest.
|
||||
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
||||
var tasksAt, notesAt = -1, -1
|
||||
for i, src := range querySources {
|
||||
switch src.name {
|
||||
case "tasks":
|
||||
tasksAt = i
|
||||
case "notes":
|
||||
notesAt = i
|
||||
}
|
||||
}
|
||||
if tasksAt < 0 || notesAt < 0 {
|
||||
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
|
||||
}
|
||||
if tasksAt > notesAt {
|
||||
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Saying a task out loud that Maven had only proposed is a confirmation. She
|
||||
// used to answer "это уже в списке" and then read it back, in the same
|
||||
// conversation, as something he had not confirmed.
|
||||
func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
||||
api := &taskAPI{promoted: true}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Utterance: "добавь в задачи продлить страховку",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an explicit capture must claim the turn")
|
||||
}
|
||||
if strings.Contains(reply, "уже в списке") {
|
||||
t.Errorf("reply = %q — he just confirmed it, that is not a duplicate", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "продлить страховку") {
|
||||
t.Errorf("reply = %q, want the task named back", reply)
|
||||
}
|
||||
// Persona: feminine, informal.
|
||||
for _, bad := range []string{"рад ", "вы ", "ваш"} {
|
||||
if strings.Contains(reply, bad) {
|
||||
t.Errorf("reply %q contains %q", reply, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// mavend/capture.go — core's half of the meeting recorder (Vikunja #253,
|
||||
// docs/plans/08-hearing.md).
|
||||
//
|
||||
// The split: a client that has a microphone (mavenclient, or a phone on the PWA)
|
||||
// is told to start, streams frames over ipc.MethodCaptureAppend, and is told to
|
||||
// stop. Core keeps the PCM, stores it as a WAV blob under the same media store
|
||||
// and the same retention as images, transcribes it through the ONE STT Maven has
|
||||
// (mavsttd's whisper.cpp, reused — not a second engine), and summarises the
|
||||
// transcript on the resident model in windows that fit n_ctx 4096.
|
||||
//
|
||||
// # Off unless configured, twice over
|
||||
//
|
||||
// No `media` block ⇒ nowhere to keep audio ⇒ the four capture methods do not
|
||||
// exist. No `capture` block with enabled ⇒ they still do not exist. On an
|
||||
// unconfigured box there is no wire path that starts a recording, which is the
|
||||
// only guarantee worth making about a capability like this one.
|
||||
//
|
||||
// # What this file refuses to do
|
||||
//
|
||||
// - Nothing listens. There is no VAD hook here, no wake-word branch, no
|
||||
// "start when you hear a meeting". The plan document's keyword-triggered
|
||||
// recorder is refused in internal/capture's package comment for the reason
|
||||
// that applies here too: noticing a keyword requires listening, which is
|
||||
// the behaviour this capability must not have.
|
||||
// - No transcript note by default. The summary is written where he will read
|
||||
// it; the verbatim record of what other people said takes a deliberate
|
||||
// capture.save_transcript.
|
||||
// - The transcript is never search input beyond this box, and the audio never
|
||||
// leaves it at all.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/capture"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// captureSummaryTimeout — the budget for one summary, which is a map-reduce over
|
||||
// the whole meeting: one model call per transcript window plus a reduce, each of
|
||||
// which is seconds on this box. Forty windows is the configured ceiling, so the
|
||||
// budget has to be minutes, not the 60s the reply path uses. It is spent on a
|
||||
// background goroutine, never inside the capture_stop request: a client that
|
||||
// asks Maven to stop recording gets the transcript back in seconds.
|
||||
const captureSummaryTimeout = 20 * time.Minute
|
||||
|
||||
// summaryGrammar — GBNF pinning a summarisation call to one JSON object holding
|
||||
// the summary and nothing else. Same reasoning as responseGrammar and memeval's
|
||||
// evalGrammar: the resident model is a Thinking variant, and a summarisation
|
||||
// prompt is exactly the shape that invites it to answer with its reasoning as
|
||||
// plain text. Demanding JSON leaves the reasoning nowhere to go.
|
||||
//
|
||||
// The bound is 2000 characters, twice the phraser's, because a reduce step over
|
||||
// a two-hour meeting is a paragraph and not a sentence. Newlines are escaped by
|
||||
// the escape rule, so the bullet list the prompt asks for survives the wrapper.
|
||||
const summaryGrammar = `
|
||||
root ::= "{" ws "\"summary\"" ws ":" ws string ws "}"
|
||||
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,2000} "\""
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
// llmCompleter adapts *llm.Client to capture.Completer. The pure package names
|
||||
// the two strings it needs and stays free of the llm request struct; the client
|
||||
// itself is the swap-aware one from llmClientFor, so a model swap re-points it.
|
||||
//
|
||||
// The JSON wrapper lives here, not in internal/capture: that package is
|
||||
// text-in/text-out by design, and the map/reduce steps still see plain prose.
|
||||
type llmCompleter struct {
|
||||
c *llm.Client
|
||||
maxTokens int
|
||||
}
|
||||
|
||||
func (l llmCompleter) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
out, err := l.c.Complete(ctx, llm.Req{
|
||||
System: system,
|
||||
User: user,
|
||||
Grammar: summaryGrammar,
|
||||
MaxTokens: l.maxTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return unwrapSummary(out), nil
|
||||
}
|
||||
|
||||
// unwrapSummary takes the summary out of the JSON object the grammar produced.
|
||||
// Anything that does not parse is returned as-is: an operator running without a
|
||||
// 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))
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end <= start {
|
||||
return s
|
||||
}
|
||||
var parsed struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s[start:end+1]), &parsed); err != nil {
|
||||
return s
|
||||
}
|
||||
// An empty field is the model saying nothing, so hand back nothing. Returning
|
||||
// the raw object here would write `{"summary":""}` into his notes.
|
||||
return strings.TrimSpace(parsed.Summary)
|
||||
}
|
||||
|
||||
// captureWiring — the recorder plus what it needs to write the result down.
|
||||
type captureWiring struct {
|
||||
rec *capture.Recorder
|
||||
st *store.Store
|
||||
emb router.Embedder
|
||||
cfg *config.CaptureConfig
|
||||
now func() time.Time
|
||||
|
||||
// ctx and wg belong to the daemon, not to the request. Summarising happens
|
||||
// after the reply has gone out, so it needs a lifetime that outlives the
|
||||
// call and a shutdown that waits for it.
|
||||
ctx context.Context
|
||||
wg *sync.WaitGroup
|
||||
}
|
||||
|
||||
// newCaptureWiring returns nil when the recorder should not exist: no media
|
||||
// store, no capture block, capture disabled, or no STT to transcribe with.
|
||||
//
|
||||
// A missing llama-server is NOT a reason to return nil. Without one the
|
||||
// recording is still made, stored and transcribed, and the summary is simply
|
||||
// absent — the honest degradation, and much better than refusing to record a
|
||||
// meeting that is happening now.
|
||||
func newCaptureWiring(ctx context.Context, wg *sync.WaitGroup, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
|
||||
if keeper == nil || !cfg.Capture.Records() {
|
||||
return nil
|
||||
}
|
||||
tr := transcriberOf(voiceW)
|
||||
if tr == nil {
|
||||
// Voice off ⇒ no STT client ⇒ nothing could turn the audio into words.
|
||||
// Storing hours of unreadable audio of other people is worse than not
|
||||
// recording, so this is a refusal, not a degradation.
|
||||
log.Printf("capture: enabled but voice/stt is not wired — meeting capture disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
cc := cfg.Capture
|
||||
var sum *capture.Summarizer
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
client := llmClientFor(lp, captureSummaryTimeout)
|
||||
sum = capture.NewSummarizer(
|
||||
llmCompleter{c: client, maxTokens: 512},
|
||||
cc.ChunkRunes, cc.MaxChunks, contextBlockFn(cfg, time.Now),
|
||||
)
|
||||
} else {
|
||||
log.Printf("capture: no llama-server phraser — meetings are transcribed, not summarised")
|
||||
}
|
||||
|
||||
rec, err := capture.New(keeper.store, tr, sum, capture.Config{
|
||||
MaxDuration: cc.MaxDuration(),
|
||||
STTWindow: time.Duration(cc.STTWindow),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("capture: %v — meeting capture disabled", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("capture: enabled, sessions capped at %s", rec.MaxDuration())
|
||||
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now, ctx: ctx, wg: wg}
|
||||
}
|
||||
|
||||
// start handles ipc.MethodCaptureStart.
|
||||
func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.CaptureStartResp, error) {
|
||||
s, err := c.rec.Start(req.Label)
|
||||
if err != nil {
|
||||
return ipc.CaptureStartResp{}, err
|
||||
}
|
||||
// The label is logged; nothing that was said ever is.
|
||||
log.Printf("capture: started %q", s.Label)
|
||||
return ipc.CaptureStartResp{
|
||||
Label: s.Label,
|
||||
Started: s.Started,
|
||||
Token: s.Token,
|
||||
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// append handles ipc.MethodCaptureAppend. ErrExpired is reported as a successful
|
||||
// response with Expired set rather than an error: the cap firing is the designed
|
||||
// behaviour, and the client needs the flag to stop sending and call stop.
|
||||
func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc.CaptureAppendResp, error) {
|
||||
err := c.rec.Append(req.Token, req.Audio)
|
||||
st := c.rec.Status()
|
||||
if errors.Is(err, capture.ErrExpired) {
|
||||
log.Printf("capture: %q hit the %s cap — stopping", st.Label, c.rec.MaxDuration())
|
||||
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds(), Expired: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ipc.CaptureAppendResp{}, err
|
||||
}
|
||||
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds()}, nil
|
||||
}
|
||||
|
||||
// stop handles ipc.MethodCaptureStop.
|
||||
//
|
||||
// The error handling here mirrors vision's, and for the same reason: the audio is
|
||||
// stored first, so a transcription failure returns what exists rather than
|
||||
// nothing. A response can carry a blob id with no transcript (STT failed,
|
||||
// re-runnable) — a degraded success, not an error to the caller.
|
||||
//
|
||||
// Summarising is NOT done here. A two-hour meeting is forty model calls, which
|
||||
// on this box is minutes, and holding the IPC request open for them means the
|
||||
// client that said "стоп" sits there with no answer while its own deadline runs
|
||||
// out. Stop returns the transcript, and the summary note is written by a
|
||||
// goroutine in the daemon's WaitGroup afterwards.
|
||||
func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.CaptureStopResp, error) {
|
||||
if req.Discard {
|
||||
// "забудь, не записывай" — nothing is stored, transcribed or noted.
|
||||
if !c.rec.Abort(req.Token) {
|
||||
return ipc.CaptureStopResp{}, capture.ErrNoSession
|
||||
}
|
||||
log.Printf("capture: session discarded on request")
|
||||
return ipc.CaptureStopResp{Discarded: true}, nil
|
||||
}
|
||||
|
||||
res, err := c.rec.Stop(ctx, req.Token)
|
||||
resp := ipc.CaptureStopResp{
|
||||
BlobID: res.BlobID,
|
||||
Label: res.Label,
|
||||
Started: res.Started,
|
||||
Seconds: res.Duration.Seconds(),
|
||||
Transcript: res.Transcript,
|
||||
Summary: res.Summary,
|
||||
Chunks: res.Chunks,
|
||||
}
|
||||
if err != nil {
|
||||
if res.BlobID == "" && res.Transcript == "" {
|
||||
// Nothing survived: no session, or an empty recording. There is
|
||||
// nothing to hand back, so this is a real error.
|
||||
return ipc.CaptureStopResp{}, err
|
||||
}
|
||||
log.Printf("capture: %q partially finished: %v", res.Label, err)
|
||||
}
|
||||
|
||||
c.summarizeLater(res)
|
||||
log.Printf("capture: finished %q — %s of audio, %d bytes of transcript",
|
||||
res.Label, res.Duration.Round(time.Second), len(res.Transcript))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// summarizeLater runs the map-reduce and writes the notes after stop replied.
|
||||
// The context is the daemon's, not the request's: the request is already
|
||||
// answered, and cancelling the summary because the client hung up would throw
|
||||
// away the only readable record of the meeting.
|
||||
func (c *captureWiring) summarizeLater(res capture.Result) {
|
||||
if res.Transcript == "" {
|
||||
return
|
||||
}
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(c.ctx, captureSummaryTimeout)
|
||||
defer cancel()
|
||||
if err := c.rec.Summarize(ctx, &res); err != nil {
|
||||
// Not fatal: writeNotes falls back to the transcript, so a dead
|
||||
// llama-server costs the summary and not the meeting.
|
||||
log.Printf("capture: summary for %q failed: %v", res.Label, err)
|
||||
}
|
||||
if _, err := c.writeNotes(ctx, res); err != nil {
|
||||
log.Printf("capture: note write for %q failed: %v", res.Label, err)
|
||||
return
|
||||
}
|
||||
log.Printf("capture: summarised %q in %d chunk(s)", res.Label, res.Chunks)
|
||||
}()
|
||||
}
|
||||
|
||||
// writeNotes stores the summary as a note, and the transcript too when
|
||||
// capture.save_transcript is set. Returns the id of the note that carries the
|
||||
// meeting.
|
||||
//
|
||||
// With no summary the transcript is written instead, whatever save_transcript
|
||||
// says. That flag is about keeping the verbatim record IN ADDITION to a summary,
|
||||
// not about whether the meeting is remembered at all. Without this fallback a
|
||||
// llama-server that was down at stop time meant an hour of recorded meeting left
|
||||
// no note behind and nothing recalled it later.
|
||||
//
|
||||
// The note source carries the blob id, which is the only link back to the audio.
|
||||
// When retention prunes the blob the note remains — words about a meeting are a
|
||||
// far lighter thing to keep than a recording of it.
|
||||
func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int64, error) {
|
||||
source := "capture:meeting"
|
||||
if res.BlobID != "" {
|
||||
source = "capture:meeting:" + res.BlobID[:12]
|
||||
}
|
||||
var id int64
|
||||
if text := res.Summary; text != "" {
|
||||
var err error
|
||||
id, err = c.writeNote(ctx, text, source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("summary note: %w", err)
|
||||
}
|
||||
} else if res.Transcript != "" {
|
||||
var err error
|
||||
id, err = c.writeNote(ctx, res.Transcript, source+":transcript")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("transcript note: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
if c.cfg.SaveTranscript && res.Transcript != "" {
|
||||
if _, err := c.writeNote(ctx, res.Transcript, source+":transcript"); err != nil {
|
||||
return id, fmt.Errorf("transcript note: %w", err)
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (c *captureWiring) writeNote(ctx context.Context, text, source string) (int64, error) {
|
||||
var vec []float32
|
||||
if c.emb != nil {
|
||||
// EmbedPassage, not Embed: this is text being searched FOR, and the e5
|
||||
// embedder is asymmetric. Backwards here makes the meeting unfindable by
|
||||
// the question that should have matched it.
|
||||
var err error
|
||||
vec, err = router.EmbedPassage(ctx, c.emb, text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("embed: %w", err)
|
||||
}
|
||||
}
|
||||
return c.st.WriteNote(ctx, c.now(), text, vec, source)
|
||||
}
|
||||
|
||||
// status handles ipc.MethodCaptureStatus.
|
||||
func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error) {
|
||||
st := c.rec.Status()
|
||||
return ipc.CaptureStatusResp{
|
||||
Running: st.Running,
|
||||
Label: st.Label,
|
||||
Started: st.Started,
|
||||
Seconds: st.Duration.Seconds(),
|
||||
Bytes: st.Bytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// wireCapture installs the four IPC hooks, or leaves them nil so every capture
|
||||
// method reports ErrUnknownMethod. Takes the media keeper wireVision already
|
||||
// opened: one blob store, one retention loop, images and audio side by side.
|
||||
func wireCapture(ctx context.Context, wg *sync.WaitGroup, srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
|
||||
cw := newCaptureWiring(ctx, wg, keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
|
||||
if cw == nil {
|
||||
return
|
||||
}
|
||||
srv.CaptureStartFn = cw.start
|
||||
srv.CaptureAppendFn = cw.append
|
||||
srv.CaptureStopFn = cw.stop
|
||||
srv.CaptureStatusFn = cw.status
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/capture"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/media"
|
||||
)
|
||||
|
||||
// silentTranscriber stands in for mavsttd: one fixed phrase per window, so the
|
||||
// wiring can be tested without whisper.
|
||||
type silentTranscriber struct{}
|
||||
|
||||
func (silentTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
|
||||
return "решили купить насос", 1.0, nil
|
||||
}
|
||||
|
||||
func testCaptureWiring(t *testing.T) (*captureWiring, *sync.WaitGroup) {
|
||||
t.Helper()
|
||||
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := capture.New(blobs, silentTranscriber{}, nil, capture.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
return &captureWiring{
|
||||
rec: rec,
|
||||
st: newTestStore(t),
|
||||
cfg: &config.CaptureConfig{},
|
||||
now: time.Now,
|
||||
ctx: context.Background(),
|
||||
wg: &wg,
|
||||
}, &wg
|
||||
}
|
||||
|
||||
// A frame carrying the wrong token must not land in the running session. Append
|
||||
// and stop used to address "whatever is running now", so a client whose session
|
||||
// had already ended went on recording into somebody else's meeting, and any
|
||||
// client could end a recording it never started.
|
||||
func TestCaptureRefusesAnotherClientsToken(t *testing.T) {
|
||||
c, _ := testCaptureWiring(t)
|
||||
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "встреча"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if start.Token == "" {
|
||||
t.Fatal("start handed back no session token")
|
||||
}
|
||||
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
|
||||
Token: "not-mine",
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)},
|
||||
}); err == nil {
|
||||
t.Error("a frame with the wrong token was accepted")
|
||||
}
|
||||
if _, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: "not-mine"}); err == nil {
|
||||
t.Error("a stop with the wrong token ended the session")
|
||||
}
|
||||
if st, _ := c.status(context.Background()); !st.Running {
|
||||
t.Error("the session was ended by a client that does not own it")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop answers with the transcript and does not wait for the summary. The
|
||||
// summary is up to forty model calls, and holding the IPC request for them meant
|
||||
// the client that said "стоп" sat with no answer for minutes.
|
||||
//
|
||||
// With no summariser wired the note still has to be written, from the transcript.
|
||||
// save_transcript is about keeping the verbatim record IN ADDITION to a summary,
|
||||
// not about whether the meeting is remembered at all — without this fallback a
|
||||
// dead llama-server meant an hour of meeting left no note behind.
|
||||
func TestStopReturnsTranscriptAndNotesItWithoutASummary(t *testing.T) {
|
||||
c, wg := testCaptureWiring(t)
|
||||
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "планёрка"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
|
||||
Token: start.Token,
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 32000)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: start.Token})
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if resp.Transcript == "" {
|
||||
t.Fatal("stop returned no transcript")
|
||||
}
|
||||
if resp.Summary != "" {
|
||||
t.Errorf("summary = %q, want none inside the request", resp.Summary)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
notes, err := c.st.RecentNotes(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found bool
|
||||
for _, n := range notes {
|
||||
if strings.Contains(n.Text, "насос") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the meeting left no note behind: %+v", notes)
|
||||
}
|
||||
}
|
||||
|
||||
// The summary path is JSON-wrapped by summaryGrammar, and internal/capture must
|
||||
// keep seeing plain prose. These cover the wrapper and every way it can be
|
||||
// absent or broken, because a meeting summary is written once and not retried.
|
||||
func TestUnwrapSummary(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"grammar output", `{"summary": "решили купить насос"}`, "решили купить насос"},
|
||||
{"multiline field", `{"summary": "- насос\n- бюджет"}`, "- насос\n- бюджет"},
|
||||
{"empty marker survives", `{"summary": "пусто"}`, "пусто"},
|
||||
{"empty field says nothing", `{"summary": ""}`, ""},
|
||||
{"thinking prefix", "<think>hm</think>\n{\"summary\": \"итог\"}", "итог"},
|
||||
{"no grammar, plain prose", "решили купить насос", "решили купить насос"},
|
||||
{"broken json falls back", `{"summary": "обрыв`, `{"summary": "обрыв`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := unwrapSummary(c.in); got != c.want {
|
||||
t.Errorf("unwrapSummary(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+99
-15
@@ -17,7 +17,8 @@ import (
|
||||
const clarifyTTL = 90 * time.Second
|
||||
|
||||
// wantedSlots — what each intent needs before she can act on it. First entry is
|
||||
// the one she asks about; the rest are only used to decide act-vs-drop.
|
||||
// the one she asks about this turn; the rest are asked about on later turns, one
|
||||
// per turn, as each answer lands (see askRemainingGap).
|
||||
//
|
||||
// Intents not listed here are never worth a question: note and query act on the
|
||||
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
||||
@@ -25,8 +26,7 @@ const clarifyTTL = 90 * time.Second
|
||||
// is worse than admitting she missed it.
|
||||
// A reminder wants BOTH what to remind about and when. Subject first: "напомни
|
||||
// в 11" has a time and nothing to say at 11, and a reminder with no subject is
|
||||
// not worth setting. Order here is the order she asks in — she still only asks
|
||||
// about the first one missing.
|
||||
// not worth setting. Order here is the order she asks in.
|
||||
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
||||
router.IntentFact: {dialogue.SlotKey},
|
||||
@@ -106,11 +106,11 @@ func trimClarifyExpired(s string) string {
|
||||
// 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() string {
|
||||
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||
if h.clarifyStore == nil {
|
||||
return ""
|
||||
}
|
||||
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) {
|
||||
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
@@ -140,7 +140,8 @@ func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
// ("", false) when she has no idea what is missing.
|
||||
//
|
||||
// One question about one thing: if two slots are missing she asks about the
|
||||
// first and lets the rest go. Two questions in a row is an interrogation.
|
||||
// first only. Two questions in one breath is an interrogation. The second gap
|
||||
// is picked up on the turn after the first one is answered (askRemainingGap).
|
||||
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
missing := missingFor(dec)
|
||||
if len(missing) == 0 {
|
||||
@@ -156,7 +157,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
// askClarify parks the request and returns the question to ask instead of the
|
||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||
// the caller falls back to the canned reply.
|
||||
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
||||
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -164,7 +165,7 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: toDialogueSlots(dec.Slots),
|
||||
Missing: []dialogue.Slot{slot},
|
||||
@@ -191,7 +192,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||
if q == nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -199,10 +200,26 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
intent := router.Intent(q.Intent)
|
||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||||
merged := q.Answer(text, toDialogueSlots(answer))
|
||||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
return h.reaskOrGiveUp(q, merged, text), true
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||
}
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
|
||||
// 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(ctx, q, intent, merged); asked {
|
||||
return reply, true
|
||||
}
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
|
||||
// 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:
|
||||
@@ -218,16 +235,65 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
return h.finishClarified(ctx, dec), true
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
// unless they already carry it. "напомни" + "позвонить маме" reads as the
|
||||
// request he would have made in one breath. Nothing is appended when the
|
||||
// subject is empty or already present, so re-asking the same question twice
|
||||
// cannot grow the utterance.
|
||||
func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" || strings.Contains(utterance, subject) {
|
||||
return utterance
|
||||
}
|
||||
if strings.TrimSpace(utterance) == "" {
|
||||
return subject
|
||||
}
|
||||
return strings.TrimSpace(utterance) + " " + subject
|
||||
}
|
||||
|
||||
// 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, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
question, ok := clarifyQuestions[remaining[0]]
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
Utterance: q.Utterance,
|
||||
Asked: h.now(),
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
})
|
||||
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".
|
||||
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question = clarifyQuestions[q.Missing[0]]
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||
return clarifyGaveUp
|
||||
}
|
||||
@@ -236,7 +302,7 @@ func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dial
|
||||
q.Slots = merged
|
||||
q.Attempts++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(voiceDialogueID, q)
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||||
return question
|
||||
}
|
||||
@@ -282,9 +348,27 @@ func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decisi
|
||||
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
|
||||
// grammar fills none. The next turn may be an ellipsis ("а завтра?"),
|
||||
// which knows the day but not what was asked ABOUT, so keep the raw
|
||||
// utterance where continuation.go can find it. Only these two intents:
|
||||
// everywhere else Text is a payload and must stay what the router put in.
|
||||
//
|
||||
// Overwritten, not filled: rememberTurn runs AFTER followUpMerge, which
|
||||
// has already inherited a Text from the previous same-intent turn, so a
|
||||
// fill-if-empty rule keeps the OLD topic for ever. Seen on the deployed
|
||||
// daemon 01-08-2026 — "во сколько у меня встреча" then "какие у меня
|
||||
// планы" then "а завтра?" continued the meeting, two turns stale.
|
||||
//
|
||||
// A continuation is the exception and keeps what it inherited: its
|
||||
// utterance is the ellipsis, and the topic it carries is the real one.
|
||||
slots := toDialogueSlots(dec.Slots)
|
||||
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
|
||||
slots.Text = dec.Utterance
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: toDialogueSlots(dec.Slots),
|
||||
Slots: slots,
|
||||
Timestamp: now,
|
||||
TTL: ttl,
|
||||
History: history,
|
||||
|
||||
+235
-15
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
@@ -80,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -111,7 +112,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||
t.Fatal("a fact with no key should be asked about")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||||
@@ -127,7 +128,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
@@ -146,7 +147,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a first question")
|
||||
}
|
||||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||||
@@ -184,7 +185,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||||
@@ -198,7 +199,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// First answer parses, but re-park it by hand as if she had asked again:
|
||||
@@ -231,7 +232,7 @@ func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("an act with no fn should be asked about")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||||
@@ -259,7 +260,7 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||||
@@ -283,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(dec); asked {
|
||||
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||||
}
|
||||
}
|
||||
@@ -295,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 := context.Background()
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
h, _, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.embedder = emb
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
*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(voiceDialogueID, h.now()) != nil {
|
||||
if h.clarifyStore.Get(textDialogueID, 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)
|
||||
}
|
||||
}
|
||||
@@ -330,3 +331,222 @@ func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
||||
t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyAsksAboutTheSecondGapToo — "напомни" with neither a subject nor a
|
||||
// time. She asks about the subject, he gives it, and the request is still not
|
||||
// complete. The old code handed applyAction a reminder with no time, which
|
||||
// answered with a parse error for a question she never asked.
|
||||
func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
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 must be consumed as an answer")
|
||||
}
|
||||
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 {
|
||||
t.Fatal("the second gap must leave a question armed")
|
||||
}
|
||||
if q.Slots.Text == "" {
|
||||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||||
}
|
||||
|
||||
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))
|
||||
if err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("expected one reminder: %v err=%v", reminders, err)
|
||||
}
|
||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||
t.Fatalf("the reminder lost the subject: %q", reminders[0].Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 acts on what she has instead of asking.
|
||||
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
t.Fatal("expected the subject question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that
|
||||
// the phrasing eval never sees, because they never go through the phraser. They
|
||||
// carry feminine self-reference ("ждала", "отпустила") and address him with a
|
||||
// plain imperative, and they are exactly the kind of string someone later edits
|
||||
// reaching for a synonym. Run the eval's own persona checks over them here.
|
||||
func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
// Only the persona checks. Length and on-topic do not apply: these are not
|
||||
// nudges, they have no rule to be on topic about, and the expiry lines are
|
||||
// deliberately longer than a nudge ceiling.
|
||||
want := map[string]bool{
|
||||
eval.CheckFeminine: true,
|
||||
eval.CheckHisGender: true,
|
||||
eval.CheckAddress: true,
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
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") {
|
||||
// The apology clause of the cringe check is scoped to nudges: it
|
||||
// exists because apologising for a greenlit nudge undermines it.
|
||||
// These lines are the opposite case. She did not understand him, or
|
||||
// she let his request go, and "прости" there is ordinary speech
|
||||
// rather than grovelling. Every other cringe rule still applies:
|
||||
// pet names, emoji, exclamations, fake concern, praise.
|
||||
// checkCringe returns the first break it finds, so this skip also
|
||||
// hides a later one in the same line. Kept narrow on purpose: it
|
||||
// only fires on a leading "apology (…)" detail.
|
||||
if r.Name == eval.CheckCringe && strings.HasPrefix(r.Detail, "apology") {
|
||||
continue
|
||||
}
|
||||
if want[r.Name] && !r.Pass {
|
||||
t.Errorf("%q fails %s: %s", line, r.Name, r.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpiryNoticeSurvivesAConfirmTurn — she asks a question, he walks off, the
|
||||
// question expires, he comes back and answers a confirm that is still parked.
|
||||
// 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, ""))
|
||||
h, _, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, 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
|
||||
// question is stale when he speaks.
|
||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "", "нет")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||
}
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the confirm answer must survive the notice, got only the notice: %q", reply)
|
||||
}
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm must still have been consumed")
|
||||
}
|
||||
if h.clarifyStore.Get(textDialogueID, 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)
|
||||
}
|
||||
if !strings.Contains(reminders[0].Payload, "11") {
|
||||
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyIsPerConversation — the parked question belongs to the reach that
|
||||
// was asked. Before this the clarify store had one global key, so a question
|
||||
// asked in the web chat and never answered captured the next utterance from
|
||||
// telegram, or from the mic, and answered it against a request the speaker had
|
||||
// never made (Vikunja #466).
|
||||
func TestClarifyIsPerConversation(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
|
||||
|
||||
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question on the web conversation")
|
||||
}
|
||||
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
|
||||
t.Fatal("a question asked on the web must not eat a telegram utterance")
|
||||
}
|
||||
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
|
||||
t.Fatal("a question asked on the web must not eat what he says at the mic")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// voiceCtx — the mic's conversation, which carries no id of its own.
|
||||
func voiceCtx() context.Context {
|
||||
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
}
|
||||
|
||||
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||||
// question dies with the process, and she does not claim to have let it go —
|
||||
// the words that follow are routed as a fresh request. Restarting is modelled
|
||||
// the way the daemon does it, by building a second handler over the same store.
|
||||
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
ctx := voiceCtx()
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question before the restart")
|
||||
}
|
||||
|
||||
restarted, _, _ := newClarifyHandler(t)
|
||||
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||||
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||||
}
|
||||
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||||
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
func randBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
b[0] |= 1
|
||||
return b
|
||||
}
|
||||
|
||||
func TestDaemonLockStartsLockedAndFlips(t *testing.T) {
|
||||
dl := newDaemonLock(true)
|
||||
if !dl.isLocked() {
|
||||
t.Fatal("newDaemonLock(true) is not locked")
|
||||
}
|
||||
dl.unlock(nil)
|
||||
if dl.isLocked() {
|
||||
t.Fatal("still locked after unlock")
|
||||
}
|
||||
if newDaemonLock(false).isLocked() {
|
||||
t.Fatal("newDaemonLock(false) reports locked")
|
||||
}
|
||||
}
|
||||
|
||||
// closeStore must be safe on a daemon that never unlocked and safe twice —
|
||||
// shutdown runs it unconditionally.
|
||||
func TestDaemonLockCloseStoreIsSafeWhenNeverUnlocked(t *testing.T) {
|
||||
dl := newDaemonLock(true)
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("closeStore with no store: %v", err)
|
||||
}
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("second closeStore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The data-loss bug: in locked mode the store is opened on an IPC goroutine
|
||||
// inside UnlockFn, and shutdown runs on main. Without the handoff nothing
|
||||
// calls Close, and Close is what re-encrypts the tmpfs working copy back over
|
||||
// the ciphertext file — so every write of a cold-started session vanished.
|
||||
func TestDaemonLockSealsTheStoreOpenedAfterUnlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "maven.db")
|
||||
tmpfs := filepath.Join(dir, "work")
|
||||
key := randBytes(t, 32)
|
||||
// Store.Close zeroes the key slice it was handed (encState.key is the
|
||||
// caller's backing array), so the next boot needs its own copy — exactly
|
||||
// as mavend keeps envKeyBytes separate from the config's key.
|
||||
nextBoot := bytes.Clone(key)
|
||||
ctx := context.Background()
|
||||
|
||||
// Cold start: locked, no store.
|
||||
dl := newDaemonLock(true)
|
||||
|
||||
// ... unlock arrives, opens the store and hands it over.
|
||||
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenEncrypted: %v", err)
|
||||
}
|
||||
dl.unlock(st)
|
||||
if _, err := st.WriteNote(ctx, time.Now(), "заметка после холодного старта", nil, "test"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
|
||||
// Shutdown.
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("closeStore: %v", err)
|
||||
}
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("second closeStore after a real store: %v", err)
|
||||
}
|
||||
|
||||
// Next boot with the same key must see the write.
|
||||
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, nextBoot)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer st2.Close()
|
||||
notes, err := st2.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("got %d notes after a cold-started session, want 1 — the session was lost", len(notes))
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the wrapped blob: what sits in the state dir must not let
|
||||
// anyone open the database. Nothing written there may contain the key, and the
|
||||
// ciphertext must not be readable with a wrong one.
|
||||
func TestColdStartLeavesNoPlaintextKeyOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "maven.db")
|
||||
tmpfs := filepath.Join(dir, "work")
|
||||
wrappedPath := filepath.Join(dir, "db_key.wrapped")
|
||||
key := randBytes(t, 32)
|
||||
secret := randBytes(t, 32)
|
||||
ctx := context.Background()
|
||||
|
||||
blob, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(wrappedPath, blob, 0o600); err != nil {
|
||||
t.Fatalf("write wrapped key: %v", err)
|
||||
}
|
||||
|
||||
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenEncrypted: %v", err)
|
||||
}
|
||||
if _, err := st.WriteNote(ctx, time.Now(), "секрет", nil, "test"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
// Walk everything in the state dir; none of it may contain the key.
|
||||
err = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, rerr := os.ReadFile(p)
|
||||
if rerr != nil {
|
||||
return nil // unreadable is not a leak
|
||||
}
|
||||
if bytes.Contains(b, key) {
|
||||
t.Errorf("%s contains the plaintext encryption key", p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
|
||||
// The wrapped file must have owner-only permissions.
|
||||
fi, err := os.Stat(wrappedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("wrapped key file mode = %o, want 600", perm)
|
||||
}
|
||||
|
||||
// A wrong passkey must not open the store.
|
||||
if _, _, err := webauthn.UnwrapKey(blob, randBytes(t, 32)); err == nil {
|
||||
t.Fatal("a wrong PRF secret unwrapped the key")
|
||||
}
|
||||
if _, err := store.OpenEncrypted(ctx, dbPath, filepath.Join(dir, "work2"), randBytes(t, 32)); err == nil {
|
||||
t.Fatal("the encrypted store opened under a wrong key")
|
||||
}
|
||||
|
||||
// And the right one round-trips back to a readable database.
|
||||
got, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("UnwrapKey: %v", err)
|
||||
}
|
||||
if version != webauthn.BlobV2 {
|
||||
t.Errorf("blob version = %v, want v2", version)
|
||||
}
|
||||
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, got)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen with the unwrapped key: %v", err)
|
||||
}
|
||||
defer st2.Close()
|
||||
notes, err := st2.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("got %d notes, want 1", len(notes))
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -107,14 +107,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
return pr != nil && !h.now().After(pr.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
// Only record the acceptance. The tick loop reads accepted
|
||||
// routines and nudges on their own interval. Building a
|
||||
// reminder here made a routine fire exactly once (Vikunja #366).
|
||||
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil {
|
||||
log.Printf("voice: accept proposed routine: %v", err)
|
||||
return "не получилось запомнить рутину."
|
||||
}
|
||||
return "буду напоминать."
|
||||
// Voice does NOT accept (Vikunja #367). Accepting hands the
|
||||
// tick loop a standing new reason to speak, which is the same
|
||||
// tier as enabling a tool — and DESIGN.md § "surface caps
|
||||
// authority" says a room mic, reachable by anyone present, is
|
||||
// structurally incapable of layer 3. So a spoken "да" leaves
|
||||
// the row 'proposed' and points at the authed page, where the
|
||||
// accept button is gated at step-up. The convenience of
|
||||
// answering out loud stays; the authority does not move.
|
||||
//
|
||||
// Acceptance itself is recorded by /routines, and the tick
|
||||
// loop nudges on the interval from there (Vikunja #366).
|
||||
return "поняла — подтверди на странице рутин, и начну напоминать."
|
||||
},
|
||||
no: func() string {
|
||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Elliptical follow-ups — "а завтра?" after "какие напоминания на сегодня".
|
||||
//
|
||||
// These carry no intent of their own. Two words, one of them a particle, and
|
||||
// everything that makes the utterance meaningful lives in the turn before it.
|
||||
// Sent to the router they get whatever the model guesses, which on a 1.7B is
|
||||
// close to a coin flip, and the guess costs ~2.7s to obtain.
|
||||
//
|
||||
// followUpMerge (followup.go) cannot help: it inherits SLOTS once the intent is
|
||||
// known, and here the intent is the missing part. So this runs before the
|
||||
// router and answers from the previous turn directly, which is both correct by
|
||||
// construction and free.
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// continuationMaxTokens — an ellipsis is short by definition. Past four tokens
|
||||
// the utterance carries enough of its own content to be routed on its merits,
|
||||
// and inheriting an intent for it would be overreach.
|
||||
const continuationMaxTokens = 4
|
||||
|
||||
// continuationParticles — the words that open a follow-up. A leading particle
|
||||
// is one of the two ways in; the other is an utterance that is nothing but a
|
||||
// date ("завтра?").
|
||||
var continuationParticles = map[string]bool{
|
||||
"а": true, "и": true, "ну": true,
|
||||
"what": true, "and": true, "how": true,
|
||||
}
|
||||
|
||||
// continuableIntents — which intents an ellipsis may inherit.
|
||||
//
|
||||
// query and system are questions: asking the same question about a different
|
||||
// day is exactly what "а завтра?" means, and re-aiming the Time slot answers it
|
||||
// completely.
|
||||
//
|
||||
// The rest are excluded on purpose. fact and note would write something he did
|
||||
// not say — "поужинал" then "а вчера?" is a question about yesterday, not a
|
||||
// claim about it. chat has no slot to re-aim. act is the dangerous one: an
|
||||
// allowlisted fn inherited by a two-word utterance is a way to run a
|
||||
// destructive command nobody typed, and no follow-up is worth that.
|
||||
//
|
||||
// reminder was in this list and came out after a live check on 01-08-2026. A
|
||||
// reminder's payload is its Text, and the Text embeds the day word it was
|
||||
// created with: continuing "напомни сегодня о событиях" with "а завтра?" fires
|
||||
// tomorrow with the text still reading "сегодня". Re-aiming Time is not enough
|
||||
// when the day is also written into the payload, and rewriting the payload
|
||||
// needs the date's span in the string, which ParseCalendarDate does not report.
|
||||
var continuableIntents = map[dialogue.Intent]bool{
|
||||
dialogue.IntentQuery: true,
|
||||
dialogue.IntentSystem: true,
|
||||
}
|
||||
|
||||
// continuationDecision reads an utterance as "the previous question, but for
|
||||
// this other day". Returns ok=false whenever anything is uncertain, which
|
||||
// hands the turn back to the ordinary router path.
|
||||
//
|
||||
// The date is what makes this safe. An ellipsis with no parseable day is just
|
||||
// a short utterance, and short utterances are the router's job.
|
||||
func continuationDecision(prev *dialogue.Session, text string, now time.Time) (router.Decision, bool) {
|
||||
if prev == nil || prev.IsExpired(now) || !continuableIntents[prev.Intent] {
|
||||
return router.Decision{}, false
|
||||
}
|
||||
tokens := quietTokens(text)
|
||||
if len(tokens) == 0 || len(tokens) > continuationMaxTokens {
|
||||
return router.Decision{}, false
|
||||
}
|
||||
day, ok := router.ParseCalendarDate(text, now)
|
||||
if !ok {
|
||||
return router.Decision{}, false
|
||||
}
|
||||
// Either it opens with a particle, or the whole utterance is the date.
|
||||
if !continuationParticles[tokens[0]] && !isBareDate(tokens, day, now) {
|
||||
return router.Decision{}, false
|
||||
}
|
||||
|
||||
dec := router.Decision{
|
||||
Utterance: text,
|
||||
Intent: router.Intent(prev.Intent),
|
||||
Confidence: 1.0,
|
||||
Stage: 0,
|
||||
Continued: true,
|
||||
Slots: router.Slots{
|
||||
Key: prev.Slots.Key,
|
||||
HasKey: prev.Slots.HasKey,
|
||||
Value: prev.Slots.Value,
|
||||
Text: prev.Slots.Text,
|
||||
// Fn/Args are deliberately not carried: continuableIntents
|
||||
// excludes act, so there is never one to carry.
|
||||
Time: day,
|
||||
HasTime: true,
|
||||
},
|
||||
}
|
||||
return dec, true
|
||||
}
|
||||
|
||||
// isBareDate reports whether the utterance is nothing but its date expression.
|
||||
// "завтра" and "на выходных" qualify; "напомни завтра" does not, because the
|
||||
// verb is content of its own and belongs to the router.
|
||||
//
|
||||
// Implemented by re-parsing each token: if every token that is not part of a
|
||||
// date expression is a preposition or a question mark's leftovers, the
|
||||
// utterance is bare. Cheap enough at four tokens.
|
||||
func isBareDate(tokens []string, day time.Time, now time.Time) bool {
|
||||
for _, t := range tokens {
|
||||
if continuationFillers[t] {
|
||||
continue
|
||||
}
|
||||
if d, ok := router.ParseCalendarDate(t, now); ok && d.Equal(day) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// continuationFillers — tokens that carry no content of their own inside a
|
||||
// date expression ("на выходных", "в среду").
|
||||
var continuationFillers = map[string]bool{
|
||||
"на": true, "в": true, "во": true, "за": true, "про": true,
|
||||
"about": true, "on": true, "for": true,
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
var contNow = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func contSession(intent dialogue.Intent, key string) *dialogue.Session {
|
||||
return &dialogue.Session{
|
||||
Intent: intent,
|
||||
Slots: dialogue.Slots{Key: key, HasKey: key != "", Text: "какие напоминания на сегодня"},
|
||||
Timestamp: contNow.Add(-30 * time.Second),
|
||||
TTL: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationInheritsTheQuestion(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentQuery, "water")
|
||||
dec, ok := continuationDecision(prev, "а завтра?", contNow)
|
||||
if !ok {
|
||||
t.Fatal("continuationDecision returned false, want a decision")
|
||||
}
|
||||
if dec.Intent != router.IntentQuery {
|
||||
t.Errorf("intent = %q, want query", dec.Intent)
|
||||
}
|
||||
if dec.Slots.Key != "water" || !dec.Slots.HasKey {
|
||||
t.Errorf("key = %q, want water carried over", dec.Slots.Key)
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
t.Fatal("no time slot; the whole point is re-aiming the day")
|
||||
}
|
||||
if got, want := dec.Slots.Time.Format("2006-01-02"), "2026-08-02"; got != want {
|
||||
t.Errorf("time = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationAcceptsABareDate(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentQuery, "water")
|
||||
for _, s := range []string{"завтра?", "вчера", "а вчера?", "и завтра"} {
|
||||
if _, ok := continuationDecision(prev, s, contNow); !ok {
|
||||
t.Errorf("continuationDecision(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationDeclinesWhatIsNotAnEllipsis(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentQuery, "water")
|
||||
for _, s := range []string{
|
||||
// No date to re-aim at — an ordinary short utterance, the router's job.
|
||||
"а что там", "а бэкап?", "привет", "",
|
||||
// Content of its own: the verb is not an ellipsis.
|
||||
"напомни завтра позвонить маме",
|
||||
// Too long to be an ellipsis even with a date in it.
|
||||
"а что у меня стоит в календаре на завтра",
|
||||
} {
|
||||
if _, ok := continuationDecision(prev, s, contNow); ok {
|
||||
t.Errorf("continuationDecision(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationDeclinesUncontinuableIntents(t *testing.T) {
|
||||
// act is the one that matters: inheriting an allowlisted fn from a
|
||||
// two-word utterance would be a way to run a destructive command.
|
||||
// reminder is here because its payload is its Text, and the Text embeds
|
||||
// the day word it was created with — see continuableIntents.
|
||||
for _, in := range []dialogue.Intent{
|
||||
dialogue.IntentAct, dialogue.IntentFact, dialogue.IntentNote,
|
||||
dialogue.IntentChat, dialogue.IntentReminder,
|
||||
} {
|
||||
if _, ok := continuationDecision(contSession(in, "water"), "а завтра?", contNow); ok {
|
||||
t.Errorf("continuationDecision inherited intent %q, want refusal", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationDeclinesWithoutALiveSession(t *testing.T) {
|
||||
if _, ok := continuationDecision(nil, "а завтра?", contNow); ok {
|
||||
t.Error("continued with no previous turn")
|
||||
}
|
||||
stale := contSession(dialogue.IntentQuery, "water")
|
||||
stale.Timestamp = contNow.Add(-10 * time.Minute)
|
||||
if _, ok := continuationDecision(stale, "а завтра?", contNow); ok {
|
||||
t.Error("continued an expired session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationNeverCarriesAnFn(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentQuery, "water")
|
||||
prev.Slots.Fn, prev.Slots.HasFn = "restart", true
|
||||
dec, ok := continuationDecision(prev, "а завтра?", contNow)
|
||||
if !ok {
|
||||
t.Fatal("want a decision")
|
||||
}
|
||||
if dec.Slots.HasFn || dec.Slots.Fn != "" {
|
||||
t.Fatalf("carried fn %q into a continuation", dec.Slots.Fn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContinuationCarriesTheTopic — the ellipsis names the day; what he is
|
||||
// asking ABOUT has to come from the previous turn, or replySystem keyword-
|
||||
// matches "а завтра?" and finds nothing. Caught on the deployed daemon.
|
||||
func TestContinuationCarriesTheTopic(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentSystem, "")
|
||||
prev.Slots.Text = "какой сегодня день"
|
||||
dec, ok := continuationDecision(prev, "а завтра?", contNow)
|
||||
if !ok {
|
||||
t.Fatal("want a decision")
|
||||
}
|
||||
if dec.Slots.Text != "какой сегодня день" {
|
||||
t.Fatalf("Slots.Text = %q, want the previous turn's topic", dec.Slots.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplySystemIgnoresAnInheritedTopic — the regression the deployed daemon
|
||||
// showed on 01-08-2026: followUpMerge fills an empty Text from the previous
|
||||
// same-intent turn, so a plain "привет" after "какой сегодня день" arrived at
|
||||
// replySystem carrying the old topic and was answered with the date. Only a
|
||||
// continuation may widen the keyword match.
|
||||
func TestReplySystemIgnoresAnInheritedTopic(t *testing.T) {
|
||||
h := &reactiveHandler{now: func() time.Time { return contNow }}
|
||||
inherited := router.Decision{
|
||||
Utterance: "привет",
|
||||
Intent: router.IntentSystem,
|
||||
Slots: router.Slots{Text: "какой сегодня день"},
|
||||
}
|
||||
if got := h.replySystem(nil, inherited); got != "пока не умею отвечать на этот вопрос." {
|
||||
t.Fatalf("replySystem answered %q on an inherited topic", got)
|
||||
}
|
||||
cont := inherited
|
||||
cont.Utterance, cont.Continued = "а завтра?", true
|
||||
if got := h.replySystem(nil, cont); got == "пока не умею отвечать на этот вопрос." {
|
||||
t.Fatalf("replySystem refused a real continuation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRememberTurnRefreshesTheTopic — rememberTurn runs after followUpMerge,
|
||||
// which has already inherited a Text from the previous same-intent turn. A
|
||||
// fill-if-empty rule therefore pins the FIRST topic of a run of query turns
|
||||
// and never lets go, so a later "а завтра?" continues a question two turns
|
||||
// old. Seen on the deployed daemon, 01-08-2026.
|
||||
func TestRememberTurnRefreshesTheTopic(t *testing.T) {
|
||||
h := &reactiveHandler{
|
||||
now: func() time.Time { return contNow },
|
||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||
}
|
||||
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(prev, router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня планы",
|
||||
Slots: router.Slots{Text: "во сколько у меня встреча"},
|
||||
}, contNow)
|
||||
got := h.dialogueSessions.Get(voiceDialogueID, contNow)
|
||||
if got == nil {
|
||||
t.Fatal("no session")
|
||||
}
|
||||
if got.Slots.Text != "какие у меня планы" {
|
||||
t.Fatalf("topic = %q, want the latest turn's", got.Slots.Text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// mavend/crawls.go — the driver for reading web pages (Vikunja #259,
|
||||
// docs/plans/14-web-crawler.md). The crawler is pure and lives in
|
||||
// internal/crawl; this is the impure half: the guarded fetcher, a ticker for the
|
||||
// scheduled watches, and the fact-backed dedup hashes.
|
||||
//
|
||||
// Two paths, one config block, both off unless configured:
|
||||
//
|
||||
// - ON DEMAND — he names a URL out loud and she reads it. That is the
|
||||
// `queryWeb` source in actions_query.go, LAST in the chain: after his
|
||||
// memory, after the notes, and (once Kiwix is wired into the chain) after
|
||||
// the local ZIMs. A local read costs nothing and leaks nothing; a fetch puts
|
||||
// a URL in someone's log, so it goes last.
|
||||
// - SCHEDULED — a watched page is re-read on its interval, and a page whose
|
||||
// text changed is written as a note. It does NOT announce itself. Same rule
|
||||
// as the feed poller: notes, never nudges.
|
||||
//
|
||||
// Only the URL goes out. Nothing here reads a note, a fact, the persona block or
|
||||
// the history, and internal/crawl has no access to the store at all.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// newCrawler builds the crawler from the `crawl` block, or returns nil when
|
||||
// there is none. Every caller checks for nil, and nil means no page is ever
|
||||
// fetched.
|
||||
func newCrawler(cfg *config.Config) *crawl.Crawler {
|
||||
if cfg.Crawl == nil {
|
||||
return nil
|
||||
}
|
||||
cc := cfg.Crawl
|
||||
// The WATCH crawler, and only it, reaches the watched hosts. webfetch reads
|
||||
// a non-empty allow list as "these and nothing else", so folding the watch
|
||||
// hosts in turned a single watch into an allowlist for everything: a config
|
||||
// with one watch and on_demand true silently refused every other page he
|
||||
// pasted, with "не получилось прочитать страницу." and no clue why.
|
||||
return crawlerWithHosts(cc, crawlHosts(cc, true))
|
||||
}
|
||||
|
||||
// crawlHosts — the allowlist for one of the two crawlers. forWatches adds the
|
||||
// watched pages' own hosts, so a watch does not have to be allowlisted by hand.
|
||||
//
|
||||
// The on-demand crawler gets his allow_hosts and nothing else. webfetch reads a
|
||||
// non-empty list as "these and nothing else", so adding the watch hosts there
|
||||
// would silently narrow on-demand reading to the watched sites.
|
||||
func crawlHosts(cc *config.CrawlConfig, forWatches bool) []string {
|
||||
hosts := append([]string(nil), cc.AllowHosts...)
|
||||
if !forWatches {
|
||||
return hosts
|
||||
}
|
||||
for _, w := range cc.Watches {
|
||||
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
|
||||
hosts = append(hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// crawlerWithHosts builds a crawler over one allowlist. Two callers, two lists:
|
||||
// see newCrawler and onDemandCrawler.
|
||||
func crawlerWithHosts(cc *config.CrawlConfig, hosts []string) *crawl.Crawler {
|
||||
ua := cc.UserAgent
|
||||
if ua == "" {
|
||||
ua = webfetch.DefaultUserAgent
|
||||
}
|
||||
fetcher := webfetch.New(webfetch.Config{
|
||||
AllowHosts: hosts,
|
||||
DenyHosts: cc.DenyHosts,
|
||||
Timeout: time.Duration(cc.Timeout),
|
||||
MaxBytes: cc.MaxBytes,
|
||||
UserAgent: ua,
|
||||
})
|
||||
// The user-agent handed to the crawler is the one the fetcher sends: obeying
|
||||
// robots rules written for a different name would be a lie.
|
||||
return crawl.New(&crawlFetcher{f: fetcher}, crawl.Config{
|
||||
UserAgent: ua,
|
||||
MaxRunes: cc.MaxRunes,
|
||||
})
|
||||
}
|
||||
|
||||
// onDemandCrawler returns a crawler for the answer path, or nil when on-demand
|
||||
// reading is off. The scheduled watches can be on while this is off: reading a
|
||||
// fixed list of pages on a timer and reading whatever URL is in an utterance are
|
||||
// different permissions, and the config keeps them separate.
|
||||
func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
|
||||
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
|
||||
return nil
|
||||
}
|
||||
cc := cfg.Crawl
|
||||
// His own allow_hosts, and nothing added behind his back. Empty means "any
|
||||
// host that is not denied and not private", which is what on-demand reading
|
||||
// of a URL he just said out loud has to mean.
|
||||
if len(cc.AllowHosts) > 0 {
|
||||
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those %d host(s)", len(cc.AllowHosts))
|
||||
}
|
||||
return crawlerWithHosts(cc, crawlHosts(cc, false))
|
||||
}
|
||||
|
||||
// crawlWorker — ticker + watcher for the scheduled half.
|
||||
type crawlWorker struct {
|
||||
watcher *crawl.Watcher
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// crawlTickInterval — how often the worker asks what is due. Per-watch cadence
|
||||
// is the watcher's business.
|
||||
const crawlTickInterval = 15 * time.Minute
|
||||
|
||||
// newCrawlWorker wires the scheduled crawls, or nil when nothing is watched.
|
||||
func newCrawlWorker(c *crawl.Crawler, api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *crawlWorker {
|
||||
if c == nil || cfg.Crawl == nil || len(cfg.Crawl.Watches) == 0 {
|
||||
return nil
|
||||
}
|
||||
watches := make([]crawl.WatchConfig, 0, len(cfg.Crawl.Watches))
|
||||
for _, w := range cfg.Crawl.Watches {
|
||||
watches = append(watches, crawl.WatchConfig{
|
||||
Name: w.Name,
|
||||
URL: w.URL,
|
||||
Interval: time.Duration(w.Interval),
|
||||
})
|
||||
}
|
||||
watcher := crawl.NewWatcher(c, watches, api, &factHashes{api: api},
|
||||
crawlEmbedder(emb), time.Duration(cfg.Crawl.Interval))
|
||||
if watcher == nil {
|
||||
log.Printf("crawl: configured but nothing watchable — scheduled crawls disabled")
|
||||
return nil
|
||||
}
|
||||
log.Printf("crawl: watching %d page(s), checking what is due every %s", len(watches), crawlTickInterval)
|
||||
return &crawlWorker{watcher: watcher, interval: crawlTickInterval}
|
||||
}
|
||||
|
||||
// run checks what is due until ctx is canceled. The first round runs
|
||||
// immediately; it writes notes only, so an early round startles nobody.
|
||||
func (w *crawlWorker) run(ctx context.Context) {
|
||||
w.watcher.CheckDue(ctx, time.Now())
|
||||
t := time.NewTicker(w.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
w.watcher.CheckDue(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// crawlFetcher adapts webfetch to crawl.Fetcher, which is the seam that keeps
|
||||
// net/http out of the crawler package.
|
||||
type crawlFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
// Get maps webfetch's sentinels onto crawl's. This adapter is the one place
|
||||
// that imports both packages, so the mapping belongs here; the crawler used to
|
||||
// match on three substrings of a message it could not see the definition of,
|
||||
// and a reworded error would have quietly turned a blocked host into "there is
|
||||
// no robots.txt here".
|
||||
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
|
||||
resp, err := a.f.Get(ctx, u)
|
||||
if err != nil {
|
||||
switch {
|
||||
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):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
|
||||
}
|
||||
|
||||
// factHashes stores each watch's last content hash as a config fact, so a
|
||||
// restart does not re-note an unchanged page. Same mechanism the feed reader
|
||||
// uses for its marks, and inspectable on /dash.
|
||||
type factHashes struct{ api ipc.CoreAPI }
|
||||
|
||||
func hashKey(name string) string { return "crawl:hash:" + name }
|
||||
|
||||
func (h *factHashes) LastHash(ctx context.Context, name string) (string, error) {
|
||||
f, err := h.api.LatestFact(ctx, hashKey(name))
|
||||
if err != nil {
|
||||
// No hash yet is not an error: the watcher treats "" as "never read".
|
||||
return "", nil
|
||||
}
|
||||
return f.Value, nil
|
||||
}
|
||||
|
||||
func (h *factHashes) SetHash(ctx context.Context, name, hash string) error {
|
||||
_, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "config",
|
||||
Key: hashKey(name),
|
||||
Value: hash,
|
||||
Source: "poll:crawl",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// crawlEmbedder adapts router.Embedder for the watcher, embedding with
|
||||
// EmbedPassage (a page is text being searched FOR, and the e5 embedder is
|
||||
// asymmetric).
|
||||
func crawlEmbedder(emb router.Embedder) crawl.Embedder {
|
||||
if emb == nil {
|
||||
return nil
|
||||
}
|
||||
return passageEmbedder{emb}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// The default config reads nothing. This is the whole "off unless configured"
|
||||
// contract for the crawler, asserted at the wiring level rather than trusted.
|
||||
func TestCrawlOffByDefault(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
if c := newCrawler(cfg); c != nil {
|
||||
t.Error("newCrawler with no crawl block returned a crawler")
|
||||
}
|
||||
if c := onDemandCrawler(cfg); c != nil {
|
||||
t.Error("onDemandCrawler with no crawl block returned a crawler")
|
||||
}
|
||||
if w := newCrawlWorker(nil, nil, nil, cfg); w != nil {
|
||||
t.Error("newCrawlWorker with no crawl block returned a worker")
|
||||
}
|
||||
// Watches configured but on_demand off ⇒ the answer path still reads
|
||||
// nothing: a timer over a fixed list is not permission for arbitrary URLs.
|
||||
withWatch := &config.Config{Crawl: &config.CrawlConfig{
|
||||
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://example.org/p"}},
|
||||
}}
|
||||
if c := onDemandCrawler(withWatch); c != nil {
|
||||
t.Error("onDemandCrawler honoured a watch list as on-demand permission")
|
||||
}
|
||||
if c := newCrawler(withWatch); c == nil {
|
||||
t.Error("newCrawler returned nil for a configured watch")
|
||||
}
|
||||
}
|
||||
|
||||
// The wired fetcher must refuse a private address, because the crawler on this
|
||||
// box sits one hop from the whole homelab. Same guard the webfetch tests cover;
|
||||
// this asserts the daemon actually wires it.
|
||||
func TestCrawlerRefusesPrivateAddress(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte("<html><body>secret</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newCrawler(&config.Config{Crawl: &config.CrawlConfig{OnDemand: true}})
|
||||
if c == nil {
|
||||
t.Fatal("newCrawler returned nil for an on-demand config")
|
||||
}
|
||||
if _, err := c.Page(context.Background(), srv.URL); err == nil {
|
||||
t.Fatalf("reading %s succeeded; a loopback address must be refused", srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactHashesRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
h := &factHashes{api: ipc.NewStoreAPI(st)}
|
||||
|
||||
got, err := h.LastHash(ctx, "page")
|
||||
if err != nil {
|
||||
t.Fatalf("LastHash on a fresh store: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("LastHash = %q, want empty for a never-read page", got)
|
||||
}
|
||||
if err := h.SetHash(ctx, "page", "deadbeef"); err != nil {
|
||||
t.Fatalf("SetHash: %v", err)
|
||||
}
|
||||
got, err = h.LastHash(ctx, "page")
|
||||
if err != nil {
|
||||
t.Fatalf("LastHash: %v", err)
|
||||
}
|
||||
if got != "deadbeef" {
|
||||
t.Errorf("LastHash = %q, want deadbeef", got)
|
||||
}
|
||||
if key := hashKey("page"); key != "crawl:hash:page" {
|
||||
t.Errorf("hashKey = %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// stubCrawlFetcher serves one fixed page to every URL, so queryWeb can be
|
||||
// exercised without a network or an allowlist.
|
||||
type stubCrawlFetcher struct{ body, ctype string }
|
||||
|
||||
func (s *stubCrawlFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||
ct := s.ctype
|
||||
if ct == "" {
|
||||
ct = "text/html"
|
||||
}
|
||||
if strings.HasSuffix(u, "/robots.txt") {
|
||||
return &crawl.Response{URL: u, ContentType: "text/plain", Body: []byte("")}, nil
|
||||
}
|
||||
return &crawl.Response{URL: u, ContentType: ct, Body: []byte(s.body)}, nil
|
||||
}
|
||||
|
||||
func buildWebHandler(c *crawl.Crawler) *reactiveHandler {
|
||||
return &reactiveHandler{
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
crawler: c,
|
||||
}
|
||||
}
|
||||
|
||||
func askWeb(h *reactiveHandler, q string) (string, bool) {
|
||||
return h.queryWeb(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{body: "<html><body>x</body></html>"}, crawl.Config{}))
|
||||
if reply, ok := askWeb(h, "почему небо синее?"); ok {
|
||||
t.Errorf("the web source claimed a question with no URL: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWebReadsThePage(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||
body: "<html><head><title>Заголовок</title></head><body><p>текст страницы</p></body></html>",
|
||||
}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/page — что там?")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "текст страницы") {
|
||||
t.Errorf("reply = %q, want the page text read back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||
body: "\x00\x01binary", ctype: "application/octet-stream",
|
||||
}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "почитай https://example.org/blob.bin")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
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 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")
|
||||
}
|
||||
if !strings.Contains(reply, "robots.txt") {
|
||||
t.Errorf("reply = %q, want the robots answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
type robotsDenyFetcher struct{}
|
||||
|
||||
func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||
if strings.HasSuffix(u, "/robots.txt") {
|
||||
return &crawl.Response{URL: u, ContentType: "text/plain",
|
||||
Body: []byte("User-agent: *\nDisallow: /private\n")}, nil
|
||||
}
|
||||
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
|
||||
}
|
||||
|
||||
// TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist — the on-demand crawler
|
||||
// used to be built over allow_hosts PLUS every watched host. webfetch reads a
|
||||
// non-empty allow list as "these and nothing else", so one watch on a config
|
||||
// with no allow_hosts at all turned unrestricted on-demand reading into
|
||||
// "the watched site only", and every other URL he pasted came back as
|
||||
// "не получилось прочитать страницу." with nothing in the log to explain it.
|
||||
func TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist(t *testing.T) {
|
||||
cc := &config.CrawlConfig{
|
||||
OnDemand: true,
|
||||
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://watched.example/p"}},
|
||||
}
|
||||
if got := crawlHosts(cc, false); len(got) != 0 {
|
||||
t.Errorf("on-demand allowlist = %v; a watch is not an allowlist entry, and an empty list is what means \"anything public\"", got)
|
||||
}
|
||||
if got := crawlHosts(cc, true); len(got) != 1 || got[0] != "watched.example" {
|
||||
t.Errorf("watch allowlist = %v; want the watched host so a watch needs no hand-written entry", got)
|
||||
}
|
||||
|
||||
// With allow_hosts set, his list is what on-demand gets, unchanged.
|
||||
cc.AllowHosts = []string{"wiki.example"}
|
||||
on := crawlHosts(cc, false)
|
||||
if len(on) != 1 || on[0] != "wiki.example" {
|
||||
t.Errorf("on-demand allowlist = %v; want exactly his allow_hosts", on)
|
||||
}
|
||||
if got := crawlHosts(cc, true); len(got) != 2 {
|
||||
t.Errorf("watch allowlist = %v; want his hosts plus the watched one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrawlFetcherReportsARefusalAsARefusal — internal/crawl cannot import
|
||||
// webfetch, so it used to recognise a guard refusal by matching substrings of
|
||||
// webfetch's message text. This adapter owns both packages and is where the
|
||||
// translation belongs.
|
||||
func TestCrawlFetcherReportsARefusalAsARefusal(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
blocked := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"wiki.example"}})}
|
||||
if _, err := blocked.Get(context.Background(), "https://other.example/a"); !errors.Is(err, crawl.ErrFetchRefused) {
|
||||
t.Errorf("a host outside allow_hosts = %v; want crawl.ErrFetchRefused", err)
|
||||
}
|
||||
if _, err := blocked.Get(context.Background(), "file:///etc/passwd"); !errors.Is(err, crawl.ErrFetchRefused) {
|
||||
t.Errorf("a non-http scheme = %v; want crawl.ErrFetchRefused", err)
|
||||
}
|
||||
|
||||
// A 5xx is a different thing: the server answered, badly. robots.txt over
|
||||
// this must refuse the crawl rather than read it as "no rules".
|
||||
open := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"127.0.0.1"}, AllowPrivate: true})}
|
||||
if _, err := open.Get(context.Background(), srv.URL+"/robots.txt"); !errors.Is(err, crawl.ErrFetchStatus) {
|
||||
t.Errorf("a 502 = %v; want crawl.ErrFetchStatus", err)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
||||
@@ -85,12 +88,40 @@ func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" after the last item of the day. The day was not empty, it is
|
||||
// over, and the whole-day empty line says something false about a day he just
|
||||
// lived through.
|
||||
func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
plan := samplePlan()
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 23, 0, 0, 0, time.UTC)
|
||||
}}
|
||||
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 strings.Contains(reply, plan.Date.Format("02.01.2006")) {
|
||||
t.Errorf("the day had things on it and they are done, not empty: %q", reply)
|
||||
}
|
||||
if reply != "на сегодня больше ничего не запланировано." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for _, q := range []string{
|
||||
"что у меня сегодня?",
|
||||
"какие планы на завтра?",
|
||||
// The plan can only be built for the clock's own day. Naming another
|
||||
// one has to fall through, not get answered with today.
|
||||
"какие планы на понедельник?",
|
||||
"какие планы на неделю?",
|
||||
"какие планы на выходные?",
|
||||
"what are my plans for friday?",
|
||||
"когда планёрка?",
|
||||
"какая погода?",
|
||||
"",
|
||||
@@ -141,3 +172,210 @@ func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
||||
t.Errorf("day-plan at %d must come before calendar at %d", plan, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// habitAPI answers only the kind-filtered fact read — the whole input the
|
||||
// behaviour profile needs (Vikunja #254). Nothing is asked of the LLM, so
|
||||
// nothing else is wired. RecentFacts is left unimplemented on purpose: the
|
||||
// profile must not read the mixed window, and a caller that does fails here.
|
||||
type habitAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts []ipc.Fact
|
||||
err error
|
||||
calls int
|
||||
kind string
|
||||
}
|
||||
|
||||
func (a *habitAPI) RecentActiveFactsByKind(_ context.Context, kind string, _ int) ([]ipc.Fact, error) {
|
||||
a.calls++
|
||||
a.kind = kind
|
||||
return a.facts, a.err
|
||||
}
|
||||
|
||||
// tuesdayFacts — n weekly Tuesday rows for key, ending before now.
|
||||
func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact {
|
||||
d := now
|
||||
for d.Weekday() != time.Tuesday {
|
||||
d = d.AddDate(0, 0, -1)
|
||||
}
|
||||
var out []ipc.Fact
|
||||
for i := 0; i < weeks; i++ {
|
||||
day := d.AddDate(0, 0, -7*i)
|
||||
out = append(out, ipc.Fact{
|
||||
Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||
Kind: "self",
|
||||
Key: key,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) {
|
||||
now := planDay() // a Monday
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the habit source must claim a habit question")
|
||||
}
|
||||
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryHabitsPassesOnEverythingElse(t *testing.T) {
|
||||
now := planDay()
|
||||
for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} {
|
||||
api := &habitAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
if reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
}); ok {
|
||||
t.Errorf("%q was claimed by the habit source (reply %q)", q, reply)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Errorf("%q scanned the fact log for a profile it does not want", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both specific sources must precede the calendar listing, which matches any
|
||||
// utterance naming a day.
|
||||
func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||
habits, cal := -1, -1
|
||||
for i, s := range querySources {
|
||||
switch s.name {
|
||||
case "habits":
|
||||
habits = i
|
||||
case "calendar":
|
||||
cal = i
|
||||
}
|
||||
}
|
||||
if habits < 0 || cal < 0 {
|
||||
t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal)
|
||||
}
|
||||
if habits > cal {
|
||||
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryHabitsReadsSelfFactsOnly — the profile window is a budget over rows,
|
||||
// so it must be spent on the rows the profile can use. Reading the mixed table
|
||||
// let one chatty poller (wg_handshake, roughly every two minutes per peer) push
|
||||
// every tap out of the window, and she then reported no habits on a store that
|
||||
// held them.
|
||||
func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
|
||||
now := planDay()
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
if _, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||
}); !ok {
|
||||
t.Fatal("the habit source must claim a habit question")
|
||||
}
|
||||
if api.kind != string(store.KindSelf) {
|
||||
t.Errorf("profile read kind %q, want %q", api.kind, store.KindSelf)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHabitQueryWithPlanWordReachesHabits — the whole chain, not just the
|
||||
// matchers: a habit question carrying "планы" used to be answered by the day
|
||||
// plan with today's calendar, because day-plan sits above habits.
|
||||
func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
|
||||
now := planDay()
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
reply := h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня обычно планы по вторникам?",
|
||||
})
|
||||
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The plan reads the store on the owner's clock: one line per event, the hour
|
||||
// printed once, and reminders selected by fire time rather than by how
|
||||
// recently they were stated.
|
||||
func TestTickDayPlanReadsTheStore(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
|
||||
now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.Local)
|
||||
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{
|
||||
Summary: "Standup",
|
||||
Start: day.Add(14 * time.Hour),
|
||||
End: day.Add(14*time.Hour + 30*time.Minute),
|
||||
}
|
||||
// Rescheduled: same key, a second row.
|
||||
if _, err := st.WriteFact(ctx, ev.Start, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.FactValue(ev), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
moved := ev
|
||||
moved.Start, moved.End = day.Add(16*time.Hour), day.Add(16*time.Hour+30*time.Minute)
|
||||
if _, err := st.WriteFact(ctx, moved.Start, store.KindEnv, calendar.FactKey(moved),
|
||||
calendar.FactValue(moved), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
// One reminder today, one next year. Both are pending; only today's is a
|
||||
// plan for today.
|
||||
if _, err := st.CreateReminder(ctx, day.Add(18*time.Hour), "позвонить маме", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
if _, err := st.CreateReminder(ctx, day.AddDate(1, 0, 0), "продлить страховку", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
|
||||
plan := tl.dayPlan(ctx, now)
|
||||
if len(plan.Items) != 2 {
|
||||
t.Fatalf("got %d items, want the moved standup and today's reminder: %+v", len(plan.Items), plan.Items)
|
||||
}
|
||||
ev0 := plan.Items[0]
|
||||
if ev0.Kind != "event" || ev0.At.In(time.Local).Format("15:04") != "16:00" {
|
||||
t.Errorf("event = %+v, want the 16:00 one", ev0)
|
||||
}
|
||||
if ev0.Text != "Standup" {
|
||||
t.Errorf("text = %q — the plan prints the hour itself", ev0.Text)
|
||||
}
|
||||
if plan.Items[1].Text != "позвонить маме" {
|
||||
t.Errorf("second item = %+v", plan.Items[1])
|
||||
}
|
||||
if strings.Contains(plan.Spoken, "страховку") {
|
||||
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerUpgradesToTheDaemonAPI — wireVoice runs before the tick loop
|
||||
// exists, so the handler starts with the bare store adapter, and that adapter
|
||||
// refuses DayPlan ("not available via direct store API"). main back-patches
|
||||
// the real one in. Without the patch every "какие у меня планы на сегодня"
|
||||
// answered "не получилось собрать план" on the deployed daemon, 01-08-2026.
|
||||
func TestHandlerUpgradesToTheDaemonAPI(t *testing.T) {
|
||||
h := &reactiveHandler{api: ipc.NewStoreAPI(nil), now: planDay}
|
||||
if _, err := h.api.DayPlan(context.Background()); err == nil {
|
||||
t.Fatal("the bare store adapter served a day plan; this test is measuring nothing")
|
||||
}
|
||||
|
||||
want := samplePlan()
|
||||
h.upgradeAPI(&daemonAPI{
|
||||
CoreAPI: ipc.UnimplementedCoreAPI{},
|
||||
getDayPlan: func(context.Context) ipc.DayPlan { return want },
|
||||
})
|
||||
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня планы на сегодня?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("queryDayPlan passed on a plan question")
|
||||
}
|
||||
if reply != want.Spoken {
|
||||
t.Fatalf("reply = %q, want the assembled plan", reply)
|
||||
}
|
||||
}
|
||||
|
||||
+152
-57
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -31,18 +32,84 @@ func correlationIDFromCtx(ctx context.Context) string {
|
||||
return id
|
||||
}
|
||||
|
||||
// setEcosystemHeaders stamps the version and correlation headers common to
|
||||
// every outgoing ecosystem request.
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader string) {
|
||||
// ecosystemAPIVersion is the contract version Maven speaks to Nexus and
|
||||
// Praxis. It is sent on every request so a service that has moved on can
|
||||
// refuse or adapt explicitly instead of misreading an older payload.
|
||||
const ecosystemAPIVersion = "v1"
|
||||
|
||||
// mavenRequester identifies the calling system on every ecosystem request, so
|
||||
// a trace on the far side can attribute a call to Maven rather than to an
|
||||
// anonymous HTTP client.
|
||||
const mavenRequester = "maven"
|
||||
|
||||
// setEcosystemHeaders stamps the version, requester, auth and correlation
|
||||
// headers common to every outgoing ecosystem request. token may be empty,
|
||||
// which means the transport itself is trusted (loopback or unix socket).
|
||||
//
|
||||
// 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 setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(versionHeader, "v1")
|
||||
req.Header.Set(versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", mavenRequester)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if id := correlationIDFromCtx(ctx); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
// "the service is down" and "the service rejected my version" degrade the
|
||||
// same way to the user but not to whoever reads the trace.
|
||||
type ecosystemError struct {
|
||||
Service string // "nexus", "praxis", "hexis"
|
||||
Op string // logical operation, e.g. "resolve"
|
||||
Status int // HTTP status, 0 when the call never got an answer
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ecosystemError) Error() string {
|
||||
if e.Status != 0 {
|
||||
return fmt.Sprintf("%s %s: http %d: %v", e.Service, e.Op, e.Status, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s %s: %v", e.Service, e.Op, e.Err)
|
||||
}
|
||||
|
||||
func (e *ecosystemError) Unwrap() error { return e.Err }
|
||||
|
||||
// Unauthorized reports a rejected or missing credential.
|
||||
func (e *ecosystemError) Unauthorized() bool {
|
||||
return e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden
|
||||
}
|
||||
|
||||
// ContractMismatch reports that the far side refused the version Maven speaks.
|
||||
func (e *ecosystemError) ContractMismatch() bool {
|
||||
return e.Status == http.StatusNotAcceptable || e.Status == http.StatusUpgradeRequired
|
||||
}
|
||||
|
||||
// Unreachable reports a call that never produced an HTTP answer at all
|
||||
// (connection refused, timeout, cancelled).
|
||||
func (e *ecosystemError) Unreachable() bool { return e.Status == 0 }
|
||||
|
||||
// httpError builds an ecosystemError from a response status.
|
||||
func httpError(service, op string, status int) *ecosystemError {
|
||||
return &ecosystemError{
|
||||
Service: service, Op: op, Status: status,
|
||||
Err: errors.New(http.StatusText(status)),
|
||||
}
|
||||
}
|
||||
|
||||
type nexusClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
@@ -53,6 +120,13 @@ func newNexusClient(url string) *nexusClient {
|
||||
}
|
||||
}
|
||||
|
||||
// withToken sets the bearer token sent on every request. Returns the client so
|
||||
// wiring reads as one expression.
|
||||
func (c *nexusClient) withToken(token string) *nexusClient {
|
||||
c.token = token
|
||||
return c
|
||||
}
|
||||
|
||||
type nexusEntity struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
@@ -107,22 +181,22 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("nexus: %s", http.StatusText(resp.StatusCode))
|
||||
return nil, httpError("nexus", "resolve", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result nexusResolveResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -130,16 +204,16 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
func (c *nexusClient) Health(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("nexus health: %s", http.StatusText(resp.StatusCode))
|
||||
return httpError("nexus", "health", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -149,6 +223,7 @@ func (c *nexusClient) Health(ctx context.Context) error {
|
||||
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
||||
type praxisClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
@@ -159,27 +234,38 @@ func newPraxisClient(url string) *praxisClient {
|
||||
}
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, path string, out any) error {
|
||||
func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
c.token = token
|
||||
return c
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out. op is the logical
|
||||
// operation name for errors and traces: the path carries the query string, and
|
||||
// after entity scoping that means an entity id in every log line built from the
|
||||
// error, next to a trace that redacts far less than that.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("praxis: %s", http.StatusText(resp.StatusCode))
|
||||
return httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
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) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -189,13 +275,14 @@ func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[stri
|
||||
// instead of filtering the unscoped list client-side.
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||
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
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
|
||||
err := c.getJSON(ctx, "changes", fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -221,24 +308,32 @@ 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, path, itemID string) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(map[string]any{"item_id": itemID})
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
}
|
||||
|
||||
// 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, err
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("praxis %s: %s", path, http.StatusText(resp.StatusCode))
|
||||
return nil, httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -247,46 +342,28 @@ func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string)
|
||||
// ECOSYSTEM-SPEC.md §2.3). Callers that read attention aloud must call this, never
|
||||
// Acknowledge, so "I mentioned it" stays distinguishable from "you told me you saw it".
|
||||
func (c *praxisClient) Surface(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/surface", itemID)
|
||||
return c.postItemAction(ctx, "surface", "/api/v1/tools/surface", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/acknowledge", itemID)
|
||||
return c.postItemAction(ctx, "acknowledge", "/api/v1/tools/acknowledge", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/resolve", itemID)
|
||||
return c.postItemAction(ctx, "resolve", "/api/v1/tools/resolve", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/ignore", itemID)
|
||||
return c.postItemAction(ctx, "ignore", "/api/v1/tools/ignore", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/tools/pin", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("praxis pin: %s", http.StatusText(resp.StatusCode))
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
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) {
|
||||
var out praxisItem
|
||||
err := c.getJSON(ctx, "/api/v1/tools/items/"+itemID, &out)
|
||||
err := c.getJSON(ctx, "get_item", "/api/v1/tools/items/"+itemID, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -295,7 +372,7 @@ func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem,
|
||||
|
||||
func (c *praxisClient) Search(ctx context.Context, query string, limit int) ([]praxisItem, error) {
|
||||
var out []praxisItem
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
|
||||
err := c.getJSON(ctx, "search", fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -311,7 +388,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Nexus identity service
|
||||
if cfg.Nexus != nil && cfg.Nexus.URL != "" {
|
||||
w.nexus = newNexusClient(cfg.Nexus.URL)
|
||||
w.nexus = newNexusClient(cfg.Nexus.URL).withToken(cfg.Nexus.Token)
|
||||
log.Printf("ecosystem: nexus at %s", cfg.Nexus.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: nexus not configured")
|
||||
@@ -319,7 +396,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Hexis capability service
|
||||
if cfg.Hexis != nil && cfg.Hexis.URL != "" {
|
||||
w.hexis = hexisclient.New(cfg.Hexis.URL)
|
||||
w.hexis = hexisclient.New(cfg.Hexis.URL).WithToken(cfg.Hexis.Token)
|
||||
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: hexis not configured")
|
||||
@@ -327,7 +404,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Praxis attention service (HTTP tools API — never the DB directly)
|
||||
if cfg.Praxis != nil && cfg.Praxis.URL != "" {
|
||||
w.praxis = newPraxisClient(cfg.Praxis.URL)
|
||||
w.praxis = newPraxisClient(cfg.Praxis.URL).withToken(cfg.Praxis.Token)
|
||||
log.Printf("ecosystem: praxis at %s", cfg.Praxis.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: praxis not configured")
|
||||
@@ -352,7 +429,19 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
|
||||
log.Printf("ecosystem: nexus resolve error: %v", err)
|
||||
return "", "", nil, err
|
||||
}
|
||||
if result.Status == "resolved" && result.Entity != nil {
|
||||
if result.Status == "resolved" {
|
||||
// "resolved" with nothing to resolve to is a contract violation, not a
|
||||
// miss. Treating it as "no such entity" let the caller fall straight
|
||||
// through to the local executor with his verb intact, which is a
|
||||
// dependency failure reaching execution.
|
||||
if result.Entity == nil || result.Entity.ID == "" {
|
||||
err := &ecosystemError{
|
||||
Service: "nexus", Op: "resolve", Status: 200,
|
||||
Err: errors.New("resolved status with no entity"),
|
||||
}
|
||||
log.Printf("ecosystem: %v", err)
|
||||
return "", "", nil, err
|
||||
}
|
||||
return result.Entity.ID, result.Entity.DisplayName, nil, nil
|
||||
}
|
||||
if result.Status == "ambiguous" {
|
||||
@@ -372,6 +461,12 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
|
||||
// healthy and genuinely has nothing registered for this entity. Callers must
|
||||
// not conflate the two: a dependency failure must not silently read as "no
|
||||
// capabilities" and fall through to unrelated local execution.
|
||||
//
|
||||
// The correlation header is stamped in the client's do(), so discovery and
|
||||
// execution can be joined on the Hexis side as long as both hops carry the
|
||||
// same ID through ctx. (This used to say the header went out on Execute only;
|
||||
// that was never true of the vendored code and is not true after the 2026-08-01
|
||||
// re-vendor.)
|
||||
func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) {
|
||||
if w == nil || w.hexis == nil || entityID == "" {
|
||||
return nil, nil
|
||||
|
||||
+392
-30
@@ -2,14 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||||
@@ -72,6 +73,7 @@ var praxisCapabilities = []praxisCapability{
|
||||
},
|
||||
},
|
||||
listChangesCapability{},
|
||||
entityAttentionCapability{},
|
||||
}
|
||||
|
||||
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
|
||||
@@ -81,6 +83,12 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||
return ""
|
||||
}
|
||||
// Every hop of this action shares one correlation ID, assigned here, so a
|
||||
// digest that calls attention once and surface N times reads as one turn
|
||||
// on the Praxis side instead of N+1 unrelated request ids.
|
||||
if correlationIDFromCtx(ctx) == "" {
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
px := h.ecosystem.praxis
|
||||
for _, capability := range praxisCapabilities {
|
||||
for _, alias := range capability.aliases() {
|
||||
@@ -111,11 +119,14 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
|
||||
if id == "" {
|
||||
return a.ask
|
||||
}
|
||||
started := h.now()
|
||||
if err := a.call(ctx, px, id); err != nil {
|
||||
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
|
||||
}
|
||||
h.recordPraxisTrace(ctx, a.op, map[string]any{"item_id": id})
|
||||
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||
return a.success
|
||||
}
|
||||
|
||||
@@ -127,15 +138,18 @@ func (listAttentionCapability) aliases() []string {
|
||||
}
|
||||
|
||||
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
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 "не могу сейчас узнать, что требует внимания."
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "ничего не требует внимания."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)})
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
@@ -172,15 +186,18 @@ func (listChangesCapability) aliases() []string {
|
||||
}
|
||||
|
||||
func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
changes, err := px.ListChanges(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis changes: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать об изменениях."
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return "нет изменений."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)})
|
||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||
var parts []string
|
||||
for _, c := range changes {
|
||||
title, _ := c["title"].(string)
|
||||
@@ -190,25 +207,294 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
||||
return "изменения: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// recordPraxisTrace — writes a fact recording a cross-service ecosystem call.
|
||||
// The fact is stored with source "praxis:trace" so the proactive loop can
|
||||
// reference it and the dashboard can display recent ecosystem activity.
|
||||
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) {
|
||||
now := h.now()
|
||||
value := operation
|
||||
if len(details) > 0 {
|
||||
if b, err := json.Marshal(details); err == nil {
|
||||
value = operation + " " + string(b)
|
||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||
// a canonical Nexus entity and asking Praxis for that entity's attention items
|
||||
// (Vikunja #272). Unlike listAttentionCapability it is scoped: the entity_id
|
||||
// travels to Praxis as a query parameter instead of Maven filtering an unscoped
|
||||
// list client-side, which is what makes the ref canonical end to end.
|
||||
//
|
||||
// It also folds in what Maven herself knows about the same entity — facts the
|
||||
// enrichment worker has already resolved to that entity_id — so one question
|
||||
// gets one answer across both stores.
|
||||
type entityAttentionCapability struct{}
|
||||
|
||||
// aliases are matched against Slots.Fn, which carries a function slot from the
|
||||
// act grammar and never free Russian, so only grammar names belong here.
|
||||
func (entityAttentionCapability) aliases() []string {
|
||||
return []string{"entity_attention", "entity_status"}
|
||||
}
|
||||
|
||||
func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
|
||||
subject := dec.Slots.Value
|
||||
if subject == "" {
|
||||
subject = dec.Slots.Text
|
||||
}
|
||||
if subject == "" {
|
||||
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 "не могу связать это с сущностью — Nexus не настроен."
|
||||
}
|
||||
|
||||
started := h.now()
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||||
if err != nil {
|
||||
// The subject is his words, so the log gets the same redaction the
|
||||
// trace gets. A trace that stores a rune count next to a log line
|
||||
// storing the runes is not redacted at all.
|
||||
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
return "не знаю такой сущности."
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = subject
|
||||
}
|
||||
|
||||
queried := h.now()
|
||||
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 "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
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 "по
|
||||
// «X»: ..." is the exact fabrication the entity ref exists to prevent,
|
||||
// so refuse the answer instead of relabelling someone else's items.
|
||||
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 "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||
"entity_id": entityID, "count": len(items),
|
||||
})
|
||||
|
||||
var parts []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
// Same surfaced != acknowledged rule as the unscoped digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, _ = h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
Kind: "system",
|
||||
Key: "praxis:" + operation,
|
||||
Value: value,
|
||||
Source: "praxis:trace",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "по «" + displayName + "» ничего нет."
|
||||
}
|
||||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
// about, and reports whether the response can be trusted as scoped at all. An
|
||||
// item without an entity_id is kept only when at least one sibling carries the
|
||||
// matching id: a whole page with no entity_id is a Praxis that ignored the
|
||||
// scope, not a page of untagged items.
|
||||
func scopedToEntity(items []map[string]any, entityID string) ([]map[string]any, bool) {
|
||||
if len(items) == 0 {
|
||||
return items, true
|
||||
}
|
||||
var kept []map[string]any
|
||||
var sawMatch, sawMismatch bool
|
||||
for _, item := range items {
|
||||
id, _ := item["entity_id"].(string)
|
||||
switch {
|
||||
case id == entityID:
|
||||
sawMatch = true
|
||||
kept = append(kept, item)
|
||||
case id != "":
|
||||
sawMismatch = true
|
||||
default:
|
||||
kept = append(kept, item)
|
||||
}
|
||||
}
|
||||
if sawMatch {
|
||||
return kept, true
|
||||
}
|
||||
if sawMismatch {
|
||||
// Some items were tagged and none matched: the far side answered about
|
||||
// other entities, so nothing here belongs to this one.
|
||||
return nil, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// localFactsForEntity summarises Maven's own facts already resolved to this
|
||||
// canonical entity. Empty when the store is unavailable or nothing matched —
|
||||
// entity-scoped memory is an enrichment of the answer, never a precondition.
|
||||
func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID string) string {
|
||||
if h.dataStore == nil || entityID == "" {
|
||||
return ""
|
||||
}
|
||||
const spoken = 3
|
||||
// One over the spoken limit, so a truncation can be named rather than
|
||||
// passed off as everything she knows.
|
||||
facts, err := h.dataStore.FactsByEntity(ctx, entityID, spoken+1)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: facts by entity %s: %v", entityID, err)
|
||||
return ""
|
||||
}
|
||||
more := false
|
||||
if len(facts) > spoken {
|
||||
facts, more = facts[:spoken], true
|
||||
}
|
||||
var parts []string
|
||||
for _, f := range facts {
|
||||
if f.Value != "" {
|
||||
parts = append(parts, f.Value)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := "я помню: " + strings.Join(parts, ", ")
|
||||
if more {
|
||||
out += ", и это не всё"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeFields overlays b onto a and returns a.
|
||||
func mergeFields(a, b map[string]any) map[string]any {
|
||||
for k, v := range b {
|
||||
a[k] = v
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// recordPraxisTrace — records a completed Praxis call. Thin wrapper over
|
||||
// recordEcosystemTrace so every ecosystem hop lands in one table with one
|
||||
// shape.
|
||||
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, started time.Time, details map[string]any) {
|
||||
h.recordEcosystemTrace(ctx, "praxis", operation, traceOK, started, details)
|
||||
}
|
||||
|
||||
// traceStatus classifies an ecosystem call for the trace record. Kept coarse
|
||||
// on purpose: a trace is read to answer "did this hop work, and how long did
|
||||
// it take", not to re-derive the error.
|
||||
const (
|
||||
traceOK = "ok"
|
||||
traceFailed = "failed" // the call never got an answer
|
||||
traceRefused = "refused" // the far side answered, and said no
|
||||
traceAmbig = "ambiguous"
|
||||
traceNotFound = "not_found"
|
||||
tracePending = "pending" // deliberately not done yet, awaiting a confirm
|
||||
)
|
||||
|
||||
// traceStatusForError distinguishes "I could not reach it" from "it answered
|
||||
// and refused". Both degrade the same way for him and not at all the same way
|
||||
// for whoever reads the trace: one is a network or a dead service, the other
|
||||
// is a token, a version or a rejected argument.
|
||||
func traceStatusForError(err error) string {
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) && !ee.Unreachable() {
|
||||
return traceRefused
|
||||
}
|
||||
return traceFailed
|
||||
}
|
||||
|
||||
// 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.
|
||||
func redactSubject(s string) string {
|
||||
return fmt.Sprintf("<%d chars>", len([]rune(s)))
|
||||
}
|
||||
|
||||
// recordEcosystemTrace writes one hop of a cross-service call: which service,
|
||||
// which operation, the outcome, how long it took, and the correlation ID that
|
||||
// stitches the hops together. It is written for every outcome, not only
|
||||
// success — an unrecorded failure is exactly the hop you need when something
|
||||
// went wrong at 3am.
|
||||
//
|
||||
// Traces go to their own store table, never to facts. One act turn produces
|
||||
// three or four of them, at machine rate, while facts arrive at human rate:
|
||||
// sharing the table meant the habit profile's 2000-row window, memeval's
|
||||
// prompt snapshot and the /dash and /history pages all filled with traces and
|
||||
// stopped seeing his actual facts.
|
||||
func (h *reactiveHandler) recordEcosystemTrace(ctx context.Context, service, op, status string, started time.Time, fields map[string]any) {
|
||||
if h.dataStore == nil {
|
||||
return
|
||||
}
|
||||
tr := store.EcosystemTrace{
|
||||
Ts: h.now(),
|
||||
Service: service,
|
||||
Operation: op,
|
||||
Status: status,
|
||||
DurationMs: h.now().Sub(started).Milliseconds(),
|
||||
CorrelationID: correlationIDFromCtx(ctx),
|
||||
Fields: map[string]any{},
|
||||
}
|
||||
for k, v := range fields {
|
||||
switch k {
|
||||
case "causation_id":
|
||||
tr.CausationID, _ = v.(string)
|
||||
case "http_status":
|
||||
if n, ok := v.(int); ok {
|
||||
tr.HTTPStatus = n
|
||||
continue
|
||||
}
|
||||
tr.Fields[k] = v
|
||||
default:
|
||||
tr.Fields[k] = v
|
||||
}
|
||||
}
|
||||
if _, err := h.dataStore.WriteEcosystemTrace(ctx, tr); err != nil {
|
||||
log.Printf("ecosystem: record trace %s:%s: %v", service, op, err)
|
||||
}
|
||||
}
|
||||
|
||||
// unauthorizedEcosystemError reports a credential the far side rejected. It
|
||||
// gets its own reply: a missing or wrong token looks exactly like an outage to
|
||||
// him, and "try again" is advice that will never work.
|
||||
func unauthorizedEcosystemError(err error) bool {
|
||||
var ee *ecosystemError
|
||||
return errors.As(err, &ee) && ee.Unauthorized()
|
||||
}
|
||||
|
||||
// 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{}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
default:
|
||||
fields["class"] = "error"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
fields["class"] = "error"
|
||||
return fields
|
||||
}
|
||||
|
||||
// handleHexisAct — resolves entity references through Nexus and executes
|
||||
@@ -219,10 +505,26 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
return ""
|
||||
}
|
||||
|
||||
// Every hop of this action shares one correlation ID, assigned here so
|
||||
// resolution and discovery are traceable even when execution never
|
||||
// happens.
|
||||
if correlationIDFromCtx(ctx) == "" {
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
|
||||
// Resolve the utterance text as an entity reference through Nexus. An
|
||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
||||
// The name comes from entityReferenceText, not straight from the Text slot:
|
||||
// the model transliterates Latin names as it routes (Vikunja #476).
|
||||
subject := entityReferenceText(dec)
|
||||
started := h.now()
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
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
|
||||
@@ -230,19 +532,33 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
map[string]any{"candidates": len(ambiguous)})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||
map[string]any{"subject": redactSubject(subject)})
|
||||
return ""
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||
map[string]any{"entity_id": entityID})
|
||||
|
||||
// Discover Hexis capabilities for this entity. A resolved entity with a
|
||||
// genuine Hexis failure must not be treated as "no capabilities" and
|
||||
// fall through to unrelated local execution.
|
||||
discovered := h.now()
|
||||
caps, err := h.ecosystem.discoverCapabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
if len(caps) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -256,10 +572,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
}
|
||||
verbLower := strings.ToLower(verb)
|
||||
|
||||
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
|
||||
// indexer"), which no capability name ever contains. Read it the other way
|
||||
// round then: the phrase is the haystack and the capability name is what we
|
||||
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
|
||||
// fn is a single verb and containment already means what it says.
|
||||
loose := !dec.Slots.HasFn
|
||||
var matches []*hexisclient.Capability
|
||||
for i, c := range caps {
|
||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
||||
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 {
|
||||
matches = append(matches, &caps[i])
|
||||
}
|
||||
}
|
||||
@@ -287,6 +614,8 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
||||
}
|
||||
|
||||
@@ -297,16 +626,49 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
// the correlation ID. It reports command success, never operational recovery
|
||||
// (Praxis observes recovery independently).
|
||||
func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityID, displayName string) string {
|
||||
started := h.now()
|
||||
causationID := correlationIDFromCtx(ctx)
|
||||
correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil)
|
||||
traced := withCorrelationID(ctx, correlationID)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err)
|
||||
h.recordEcosystemTrace(traced, "hexis", "execute", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
return "не получилось выполнить команду для " + displayName + "."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{
|
||||
"entity_id": entityID,
|
||||
"entity_name": displayName,
|
||||
"capability": capName,
|
||||
"correlation_id": correlationID,
|
||||
// One record per hop: the second write this used to make said the same
|
||||
// thing under a different key, in a different shape.
|
||||
h.recordEcosystemTrace(traced, "hexis", "execute", traceOK, started, map[string]any{
|
||||
"entity_id": entityID, "entity_name": displayName,
|
||||
"capability": capName, "causation_id": causationID,
|
||||
})
|
||||
return "команда выполнена для " + displayName + "."
|
||||
}
|
||||
|
||||
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
|
||||
// asks what to do.
|
||||
//
|
||||
// The stage-3 gate thins an act that never matched an allowlisted fn, so
|
||||
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
|
||||
// path was never entered — the capability existed and no utterance could reach
|
||||
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
|
||||
// the verb is matched against the capabilities Hexis registers for the entity,
|
||||
// not against the allowlist.
|
||||
//
|
||||
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
|
||||
// only when Hexis is wired — a box with no ecosystem asks the question it
|
||||
// always asked. A "" back means Nexus knew no such entity or Hexis had no
|
||||
// matching capability, and then she asks after all. Authority is unchanged:
|
||||
// resolution stops on ambiguity and a mutating capability still goes through
|
||||
// the spoken confirm in handleHexisAct.
|
||||
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
|
||||
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||
return ""
|
||||
}
|
||||
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
|
||||
return ""
|
||||
}
|
||||
return h.handleHexisAct(ctx, dec)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
// TestWireEcosystem_HexisToken — a configured Hexis token reaches the wire.
|
||||
//
|
||||
// This is the regression that closes the 2026-08-01 re-vendor. The copy of
|
||||
// github.com/kami/hexis checked into vendor/ used to predate Client.WithToken,
|
||||
// so a configured token could not be sent at all; wireEcosystem refused to wire
|
||||
// Hexis rather than execute unauthenticated. Both halves of that are gone. The
|
||||
// test asserts the outcome the refusal was standing in for: the header goes
|
||||
// out, so nobody has to trust a boot log to know auth is on.
|
||||
func TestWireEcosystem_HexisToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL, Token: "s3cret"}}
|
||||
w := wireEcosystem(cfg)
|
||||
if w.hexis == nil {
|
||||
t.Fatal("hexis not wired with a token configured")
|
||||
}
|
||||
if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil {
|
||||
t.Fatalf("discoverCapabilities: %v", err)
|
||||
}
|
||||
if want := "Bearer s3cret"; gotAuth != want {
|
||||
t.Errorf("Authorization = %q; want %q", gotAuth, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireEcosystem_HexisNoToken — no token configured still wires, unauthed.
|
||||
// Hexis without auth is a valid deployment on a trusted box, and the re-vendor
|
||||
// must not have turned the token into a requirement.
|
||||
func TestWireEcosystem_HexisNoToken(t *testing.T) {
|
||||
var sawAuth bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawAuth = r.Header.Get("Authorization") != ""
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL}}
|
||||
w := wireEcosystem(cfg)
|
||||
if w.hexis == nil {
|
||||
t.Fatal("hexis not wired without a token")
|
||||
}
|
||||
if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil {
|
||||
t.Fatalf("discoverCapabilities: %v", err)
|
||||
}
|
||||
if sawAuth {
|
||||
t.Error("Authorization header sent with no token configured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Phase-5 hardening suite (Vikunja #276). Everything here drives the shared
|
||||
// fake ecosystem (fakeecosystem_test.go) rather than one-off inline handlers,
|
||||
// so the same fault levers — SetFault, SetBody, SetDelay — cover every
|
||||
// service. What is asserted is the degraded-mode contract:
|
||||
//
|
||||
// - services degrade independently: one outage never mutes the others,
|
||||
// - a degraded reply is never silent, never fabricated, never "success",
|
||||
// - contract drift (old shape, unknown fields, garbage) is survivable,
|
||||
// - Maven never acts on an ambiguous target and never chains
|
||||
// Praxis observation into Hexis execution on its own.
|
||||
|
||||
// ecoHandler wires a handler against whichever of the three fakes is given
|
||||
// (pass nil to leave a service unconfigured, which is a different state from
|
||||
// "configured but down").
|
||||
func ecoHandler(t *testing.T, nexus, praxis, hexis *fakeServer) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
clock := newTickingClock(time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), time.Millisecond)
|
||||
w := &ecosystemWiring{}
|
||||
if nexus != nil {
|
||||
w.nexus = newNexusClient(nexus.URL)
|
||||
}
|
||||
if praxis != nil {
|
||||
w.praxis = newPraxisClient(praxis.URL)
|
||||
}
|
||||
if hexis != nil {
|
||||
w.hexis = hexisclient.New(hexis.URL)
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
dataStore: st,
|
||||
now: clock.Now,
|
||||
ecosystem: w,
|
||||
}
|
||||
}
|
||||
|
||||
// traces reads the ecosystem trace table. Traces live there and not in facts,
|
||||
// so a bounded reader of facts never fills up with machine-rate rows.
|
||||
func traces(t *testing.T, h *reactiveHandler) []store.EcosystemTrace {
|
||||
t.Helper()
|
||||
out, err := h.dataStore.RecentEcosystemTraces(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("read traces: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tracesFor returns the traces recorded for one service+operation.
|
||||
func tracesFor(t *testing.T, h *reactiveHandler, service, op string) []store.EcosystemTrace {
|
||||
t.Helper()
|
||||
var out []store.EcosystemTrace
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Service == service && tr.Operation == op {
|
||||
out = append(out, tr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// restartCaps is a read-only capability. Restarting a service is a mutation,
|
||||
// so the read-only one this suite runs through the happy paths is named for
|
||||
// what it is; the mutating restart lives in the confirmation tests.
|
||||
func restartCaps() string {
|
||||
return fixtureHexisCapabilities(map[string]any{
|
||||
"id": "cap_status", "name": "restart status", "read_only": true,
|
||||
})
|
||||
}
|
||||
|
||||
// TestEcosystem_OutagesLeaveNoSharedFailureState: the two act paths share a
|
||||
// handler, a store and a clock, so what is worth asserting is that a failure
|
||||
// on one leaves nothing behind that degrades the other. Faulting one disjoint
|
||||
// call graph and exercising the other only tests the call graph.
|
||||
func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
// 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")); strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||
t.Fatal("the failed resolve must be recorded")
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
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)
|
||||
}
|
||||
if got := tracesFor(t, h, "praxis", "list_attention"); len(got) != 1 || got[0].Status != traceOK {
|
||||
t.Fatalf("the praxis digest must trace its own success, got %+v", got)
|
||||
}
|
||||
|
||||
// And the reverse: a Praxis outage mid-session leaves the Hexis path whole.
|
||||
praxis.SetFault(503)
|
||||
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")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_OneEndpointDownDoesNotMuteTheService: real outages are usually
|
||||
// partial. Attention answering while surface is down must still deliver.
|
||||
func TestEcosystem_OneEndpointDownDoesNotMuteTheService(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)
|
||||
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 503)
|
||||
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)
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Fatal("expected the surface attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_ResolvedWithoutEntityFailsClosed: the contract violation that
|
||||
// decodes cleanly. Nexus says "resolved" and delivers no entity; treating that
|
||||
// as "no such entity" put the user's verb through to the local executor.
|
||||
func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolvedEmpty())
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
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 strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("hexis must not be contacted after a contract-violating resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_RejectedCredentialSaysSo: 401 and 403 must not read as an
|
||||
// outage. "Try again" is advice that never works for a misconfigured token.
|
||||
func TestEcosystem_RejectedCredentialSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, status := range []int{401, 403} {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(status)
|
||||
|
||||
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)
|
||||
}
|
||||
tr := tracesFor(t, h, "nexus", "resolve")
|
||||
if len(tr) != 1 || tr[0].Status != traceRefused || tr[0].HTTPStatus != status {
|
||||
t.Fatalf("http %d must trace as refused with its status, got %+v", status, tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MalformedPraxisBodyDegrades: Praxis has the same decode path
|
||||
// Nexus does, and a 200 carrying garbage there is a dependency failure too.
|
||||
func TestEcosystem_MalformedPraxisBodyDegrades(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)
|
||||
|
||||
praxis.SetBody(`[{"title":`)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("a malformed praxis body must not answer with silence")
|
||||
}
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a malformed body must not produce content, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MalformedNexusResponseFailsClosed: a 200 carrying garbage is a
|
||||
// dependency failure, not "no such entity". It must stop before Hexis.
|
||||
func TestEcosystem_MalformedNexusResponseFailsClosed(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)
|
||||
|
||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||
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 {
|
||||
t.Fatal("hexis must not be contacted after a malformed nexus response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_UnknownContractFieldsTolerated: a newer Nexus adding fields
|
||||
// must not break an older Maven. Same for the older flat resolve shape.
|
||||
func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for name, body := range map[string]string{
|
||||
"future": fixtureNexusResolvedFuture("ent_muzick", "Muzick indexer", "service"),
|
||||
"flat": fixtureNexusResolvedFlat("ent_muzick", "Muzick indexer", "service"),
|
||||
} {
|
||||
t.Run(name, func(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")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_CancelledContextDegrades: a caller hanging up (turn abandoned,
|
||||
// deadline hit) must surface as degradation, never as a fabricated result.
|
||||
func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetDelay(2 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
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 {
|
||||
t.Fatal("hexis must not be contacted after a cancelled resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_ExecutionFailureIsNotSuccess: Hexis answering 200 with
|
||||
// status=failed is a partial failure — the call worked, the command did not.
|
||||
// Maven must report it as a failure and must not write a success trace.
|
||||
func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecutionFailed("exec_1", "unit not found"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
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 == "" {
|
||||
t.Fatal("failed execution must say something")
|
||||
}
|
||||
for _, tr := range tracesFor(t, h, "hexis", "execute") {
|
||||
if tr.Status == traceOK {
|
||||
t.Fatalf("failed execution must not write a success trace: %+v", tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_SuccessfulActionWritesATrace is the positive half the failure
|
||||
// assertions above depend on: without it, "no success trace" passes with the
|
||||
// trace writer deleted. It was, for a while — both writers used a fact kind the
|
||||
// store's CHECK constraint rejects and the error was discarded.
|
||||
func TestEcosystem_SuccessfulActionWritesATrace(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)
|
||||
|
||||
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")
|
||||
if len(exec) != 1 || exec[0].Status != traceOK {
|
||||
t.Fatalf("a successful execution must leave exactly one ok trace, got %+v", exec)
|
||||
}
|
||||
if exec[0].CorrelationID == "" {
|
||||
t.Error("a trace with no correlation id cannot be stitched to anything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_TracesStayOutOfFacts: traces are written at machine rate and
|
||||
// facts at human rate. One act turn used to write four fact rows, which pushed
|
||||
// his facts out of every bounded reader (the habit profile's window, memeval's
|
||||
// prompt, /dash, /history).
|
||||
func TestEcosystem_TracesStayOutOfFacts(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)
|
||||
|
||||
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 {
|
||||
t.Fatal("setup: expected traces")
|
||||
}
|
||||
facts, err := h.dataStore.RecentFacts(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("read facts: %v", err)
|
||||
}
|
||||
if len(facts) != 0 {
|
||||
t.Fatalf("an ecosystem act must write no facts at all, got %+v", facts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_AmbiguousTargetBlocksExecution: ambiguity blocks mutation, and
|
||||
// the clarification must name the candidates rather than pick one.
|
||||
func TestEcosystem_AmbiguousTargetBlocksExecution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
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)
|
||||
}
|
||||
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||
t.Fatal("ambiguous target must never execute")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_NoAutonomousPraxisToHexis: reading the attention digest is an
|
||||
// observation. Maven must never turn an observed problem into a Hexis command
|
||||
// by herself — she is not autonomous.
|
||||
func TestEcosystem_NoAutonomousPraxisToHexis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "muzick indexer is down", "importance": 4.0, "rule": "service_down"},
|
||||
))
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("attention digest must not contact hexis on its own")
|
||||
}
|
||||
if nexus.Count("", "/api/v1/resolve") != 0 {
|
||||
t.Fatal("attention digest must not resolve targets for autonomous action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MutatingCapabilityWaitsForConfirmation: a non-read-only
|
||||
// capability parks for an explicit spoken confirm bound to capability+target.
|
||||
func TestEcosystem_MutatingCapabilityWaitsForConfirmation(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)
|
||||
|
||||
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)
|
||||
}
|
||||
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||
t.Fatal("mutating capability must not execute before confirmation")
|
||||
}
|
||||
h.mu.Lock()
|
||||
pending := h.pendingHexis
|
||||
h.mu.Unlock()
|
||||
if pending == nil || pending.capabilityID != "cap_restart" || pending.entityID != "ent_muzick" {
|
||||
t.Fatalf("confirmation must be bound to capability+target, got %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_SurfaceFailureStillDelivers: surfacing is bookkeeping. If the
|
||||
// surface call fails the digest must still be spoken — a partial failure
|
||||
// downgrades bookkeeping, not the answer.
|
||||
func TestEcosystem_SurfaceFailureStillDelivers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 500)
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
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)
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Fatal("expected the surface attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_TotalOutageSaysSoForEveryPath: with all three down, every
|
||||
// entry point degrades explicitly instead of returning empty or inventing.
|
||||
func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
for _, fs := range []*fakeServer{nexus, praxis, hexis} {
|
||||
fs.SetFault(503)
|
||||
}
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
for name, reply := range map[string]string{
|
||||
"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 strings.Contains(reply, "выполнена") {
|
||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
||||
}
|
||||
}
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Status == traceOK {
|
||||
t.Fatalf("a total outage must not leave success traces behind: %+v", tr)
|
||||
}
|
||||
}
|
||||
if len(tracesFor(t, h, "praxis", "acknowledge")) == 0 {
|
||||
t.Fatal("the acknowledge arm must reach praxis and record the refusal")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_RecoveryAfterOutageNeedsNoRestart: once the dependency comes
|
||||
// back the very next turn works — no cached failure state, no restart.
|
||||
func TestEcosystem_RecoveryAfterOutageNeedsNoRestart(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)
|
||||
|
||||
praxis.SetFault(503)
|
||||
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")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("recovery must work on the next turn, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ func praxisActDec(fn string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func praxisItemDec(fn, itemID string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true, Value: itemID}}
|
||||
}
|
||||
|
||||
func newPraxisTestHandler(t *testing.T, praxis *fakeServer) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
|
||||
@@ -59,8 +59,11 @@ func newHexisTestHandler(t *testing.T, resolveBody string, caps string) (*reacti
|
||||
}, executed
|
||||
}
|
||||
|
||||
func actDec(text string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: text, Fn: "restart", HasFn: true}}
|
||||
// actDec builds an act decision about subject. The verb is always "restart":
|
||||
// the argument is the utterance the entity is resolved from, never the verb,
|
||||
// so actDec("restart") reads as a verb and is not one.
|
||||
func actDec(subject string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: subject, Fn: "restart", HasFn: true}}
|
||||
}
|
||||
|
||||
func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Versioning, authentication and tracing of ecosystem calls (Vikunja #273).
|
||||
|
||||
func findTrace(t *testing.T, h *reactiveHandler, service, op string) *store.EcosystemTrace {
|
||||
t.Helper()
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Service == service && tr.Operation == op {
|
||||
found := tr
|
||||
return &found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestEcosystemHeaders_VersionRequesterAndAuth: every outgoing request carries
|
||||
// the contract version, the requester, and the bearer token when configured.
|
||||
func TestEcosystemHeaders_VersionRequesterAndAuth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
h.ecosystem.nexus = newNexusClient(nexus.URL).withToken("nexus-secret")
|
||||
h.ecosystem.praxis = newPraxisClient(praxis.URL).withToken("praxis-secret")
|
||||
|
||||
_, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
// A bare client call carries whatever the caller assigned. Entry points
|
||||
// assign the ID, the header layer only reads it, so mirror an action here.
|
||||
if _, err := h.ecosystem.praxis.ListAttention(withCorrelationID(ctx, newCorrelationID()), 5); err != nil {
|
||||
t.Fatalf("attention: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
fs *fakeServer
|
||||
versionHeader string
|
||||
token string
|
||||
}{
|
||||
{nexus, "X-Nexus-Version", "nexus-secret"},
|
||||
{praxis, "X-Praxis-Version", "praxis-secret"},
|
||||
} {
|
||||
reqs := tc.fs.Requests()
|
||||
if len(reqs) == 0 {
|
||||
t.Fatalf("%s: no request captured", tc.versionHeader)
|
||||
}
|
||||
r := reqs[0]
|
||||
if got := r.Header.Get(tc.versionHeader); got != ecosystemAPIVersion {
|
||||
t.Errorf("%s = %q, want %q", tc.versionHeader, got, ecosystemAPIVersion)
|
||||
}
|
||||
if got := r.Header.Get("X-Requested-By"); got != mavenRequester {
|
||||
t.Errorf("X-Requested-By = %q, want %q", got, mavenRequester)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+tc.token {
|
||||
t.Errorf("Authorization = %q, want bearer %q", got, tc.token)
|
||||
}
|
||||
if r.Header.Get("X-Correlation-ID") == "" {
|
||||
t.Errorf("%s: missing correlation ID", tc.versionHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemHeaders_NoTokenSendsNoAuth: an unconfigured token means the
|
||||
// transport is trusted, not that a bogus header is sent.
|
||||
func TestEcosystemHeaders_NoTokenSendsNoAuth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
if _, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil); err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got := nexus.Requests()[0].Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("unauthenticated client must send no Authorization header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemError_ClassifiesRefusals: callers must be able to tell a
|
||||
// rejected credential from a version refusal from an unreachable service
|
||||
// without matching on message text.
|
||||
func TestEcosystemError_ClassifiesRefusals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
check func(*ecosystemError) bool
|
||||
wantCls string
|
||||
}{
|
||||
{"unauthorized", 401, (*ecosystemError).Unauthorized, "unauthorized"},
|
||||
{"forbidden", 403, (*ecosystemError).Unauthorized, "unauthorized"},
|
||||
{"contract", 426, (*ecosystemError).ContractMismatch, "contract_mismatch"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
|
||||
nexus.SetFault(tc.status)
|
||||
c := newNexusClient(nexus.URL)
|
||||
_, err := c.Resolve(ctx, "x", nil)
|
||||
ee, ok := err.(*ecosystemError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ecosystemError, got %T (%v)", err, err)
|
||||
}
|
||||
if ee.Service != "nexus" || ee.Status != tc.status {
|
||||
t.Fatalf("unexpected typed error %+v", ee)
|
||||
}
|
||||
if !tc.check(ee) {
|
||||
t.Fatalf("%s not classified: %+v", tc.name, ee)
|
||||
}
|
||||
if got := traceErrorFields(err)["class"]; got != tc.wantCls {
|
||||
t.Fatalf("trace class = %v, want %s", got, tc.wantCls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcosystemError_UnreachableHasNoStatus(t *testing.T) {
|
||||
c := newNexusClient("http://127.0.0.1:1")
|
||||
_, err := c.Resolve(context.Background(), "x", nil)
|
||||
ee, ok := err.(*ecosystemError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ecosystemError, got %T", err)
|
||||
}
|
||||
if !ee.Unreachable() || ee.Unauthorized() || ee.ContractMismatch() {
|
||||
t.Fatalf("a refused connection must classify as unreachable only: %+v", ee)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_SuccessfulActionTracesEveryHop: resolution, discovery and
|
||||
// execution each leave a record sharing one correlation chain, with timing and
|
||||
// status, and execution carries the causation link back to the resolve.
|
||||
func TestEcosystemTrace_SuccessfulActionTracesEveryHop(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)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
var chain string
|
||||
for _, want := range [][2]string{{"nexus", "resolve"}, {"hexis", "capabilities"}, {"hexis", "execute"}} {
|
||||
d := findTrace(t, h, want[0], want[1])
|
||||
if d == nil {
|
||||
t.Fatalf("missing trace for %s %s, got %+v", want[0], want[1], traces(t, h))
|
||||
}
|
||||
if d.Status != traceOK {
|
||||
t.Errorf("%s %s status = %v, want ok", want[0], want[1], d.Status)
|
||||
}
|
||||
if d.CorrelationID == "" {
|
||||
t.Errorf("%s %s trace has no correlation id", want[0], want[1])
|
||||
}
|
||||
if want[1] != "execute" {
|
||||
if chain == "" {
|
||||
chain = d.CorrelationID
|
||||
} else if d.CorrelationID != chain {
|
||||
t.Errorf("%s %s left the correlation chain: %s != %s", want[0], want[1], d.CorrelationID, chain)
|
||||
}
|
||||
}
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec.CausationID == "" {
|
||||
t.Error("execute trace must carry the causation id of the turn that caused it")
|
||||
}
|
||||
if exec.CorrelationID == exec.CausationID {
|
||||
t.Error("execute correlation and causation must be distinguishable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_OneCorrelationIDPerPraxisAction: a digest calls attention
|
||||
// once and surface once per item. All of it is one turn, so the far side must
|
||||
// see one ID and not N+1 unrelated ones.
|
||||
func TestEcosystemTrace_OneCorrelationIDPerPraxisAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
map[string]any{"id": "item_2", "title": "backup is stale", "importance": 2.0},
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("setup: expected the digest, got %q", reply)
|
||||
}
|
||||
|
||||
reqs := praxis.Requests()
|
||||
if len(reqs) < 3 {
|
||||
t.Fatalf("expected attention plus one surface per item, got %d requests", len(reqs))
|
||||
}
|
||||
first := reqs[0].Header.Get("X-Correlation-ID")
|
||||
if first == "" {
|
||||
t.Fatal("every ecosystem request must carry a correlation id")
|
||||
}
|
||||
for _, r := range reqs {
|
||||
if got := r.Header.Get("X-Correlation-ID"); got != first {
|
||||
t.Fatalf("%s %s carried %q, want the action's id %q", r.Method, r.Path, got, first)
|
||||
}
|
||||
}
|
||||
tr := findTrace(t, h, "praxis", "list_attention")
|
||||
if tr == nil || tr.CorrelationID != first {
|
||||
t.Fatalf("the trace must carry the id that was actually sent, got %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_FailuresAreTracedToo: the whole point of the change —
|
||||
// a failed hop is exactly the one worth having recorded.
|
||||
func TestEcosystemTrace_FailuresAreTracedToo(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)
|
||||
nexus.SetFault(401)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
t.Fatal("a failed resolve must still be traced")
|
||||
}
|
||||
if d.Status != traceRefused {
|
||||
t.Errorf("status = %v, want refused: the far side answered", d.Status)
|
||||
}
|
||||
if d.Fields["class"] != "unauthorized" {
|
||||
t.Errorf("class = %v, want unauthorized", d.Fields["class"])
|
||||
}
|
||||
if d.HTTPStatus != 401 {
|
||||
t.Errorf("http_status = %v, want 401", d.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_UnreachableIsNotRefused: never got an answer and answered
|
||||
// with a refusal are different failures, and the trace must say which.
|
||||
func TestEcosystemTrace_UnreachableIsNotRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := ecoHandler(t, nil, nil, nil)
|
||||
h.ecosystem.nexus = newNexusClient("http://127.0.0.1:1")
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
t.Fatal("an unreachable resolve must still be traced")
|
||||
}
|
||||
if d.Status != traceFailed {
|
||||
t.Errorf("status = %v, want failed", d.Status)
|
||||
}
|
||||
if d.Fields["class"] != "unreachable" {
|
||||
t.Errorf("class = %v, want unreachable", d.Fields["class"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_RedactsTheUtterance: traces are diagnostics, his words
|
||||
// are not. The subject must never be persisted verbatim.
|
||||
func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
|
||||
|
||||
recorded := traces(t, h)
|
||||
if len(recorded) == 0 {
|
||||
t.Fatal("expected a not_found resolve trace")
|
||||
}
|
||||
for _, tr := range recorded {
|
||||
for k, v := range tr.Fields {
|
||||
if s, ok := v.(string); ok && strings.Contains(s, "кофемашину") {
|
||||
t.Fatalf("trace leaked the utterance in %s: %q", k, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d.Status != traceNotFound {
|
||||
t.Errorf("status = %v, want not_found", d.Status)
|
||||
}
|
||||
if d.Fields["subject"] != redactSubject("перезапусти кофемашину") {
|
||||
t.Errorf("subject = %v, want a redacted length", d.Fields["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded: the two moments
|
||||
// where Maven deliberately does not act still leave a trail.
|
||||
func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ambig := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, ambig, nil, hexis)
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
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"))
|
||||
d := findTrace(t, h2, "hexis", "confirmation")
|
||||
if d == nil || d.Status != tracePending {
|
||||
t.Fatalf("a parked confirmation must be traced, got %+v", d)
|
||||
}
|
||||
// The confirmation hop is measured from the top of the action, not from
|
||||
// the instant it is recorded, which was always zero.
|
||||
if d.DurationMs == 0 {
|
||||
t.Error("the confirmation trace must report the time the action took to get there")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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._-]*)*`)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// entityReferenceText is the name Nexus is asked to resolve.
|
||||
//
|
||||
// Normally that is the router's Text slot, which is 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 —
|
||||
// then the longest Latin run in his own words is the reference. 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.
|
||||
func entityReferenceText(dec router.Decision) string {
|
||||
text := dec.Slots.Text
|
||||
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
||||
return text
|
||||
}
|
||||
longest := ""
|
||||
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
||||
if len(m) > len(longest) {
|
||||
longest = m
|
||||
}
|
||||
}
|
||||
longest = strings.TrimSpace(longest)
|
||||
// A single stray letter is not a name.
|
||||
if len(longest) < 2 {
|
||||
return text
|
||||
}
|
||||
return longest
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestEntityReferenceText pins when his own words win over the model's.
|
||||
func TestEntityReferenceText(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: "muzick indexer",
|
||||
},
|
||||
{
|
||||
name: "it kept the name, so nothing to repair",
|
||||
utterance: "перезапусти muzick indexer",
|
||||
text: "перезагрузить muzick indexer",
|
||||
want: "перезагрузить muzick indexer",
|
||||
},
|
||||
{
|
||||
name: "an all-Russian entity name is not a rewrite",
|
||||
utterance: "перезапусти домашний сервер",
|
||||
text: "перезагрузить домашний сервер",
|
||||
want: "перезагрузить домашний сервер",
|
||||
},
|
||||
{
|
||||
name: "an English turn never enters the recovery",
|
||||
utterance: "restart muzick indexer",
|
||||
text: "restart muzick indexer",
|
||||
want: "restart muzick indexer",
|
||||
},
|
||||
{
|
||||
name: "the longest Latin run is the name",
|
||||
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
||||
text: "перезагрузить нгинкс",
|
||||
want: "muzick-indexer",
|
||||
},
|
||||
{
|
||||
name: "one stray letter is not a name",
|
||||
utterance: "перезапусти сервер a",
|
||||
text: "перезагрузить сервер",
|
||||
want: "перезагрузить сервер",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
||||
if got := entityReferenceText(dec); got != tc.want {
|
||||
t.Fatalf("entityReferenceText = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
|
||||
// router hands over a transliterated Text, and Nexus must still be asked about
|
||||
// the service that exists.
|
||||
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
dec := router.Decision{
|
||||
Utterance: "перезапусти muzick indexer",
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
|
||||
}
|
||||
h.handleHexisAct(ctx, dec)
|
||||
|
||||
reqs := nexus.Requests()
|
||||
if len(reqs) == 0 {
|
||||
t.Fatal("nexus was never asked")
|
||||
}
|
||||
body := string(reqs[0].Body)
|
||||
if !strings.Contains(body, "muzick indexer") {
|
||||
t.Fatalf("nexus resolve body = %s, want the name he said", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
|
||||
// stage-3 gate thins an act with no allowlisted fn, and that question used to
|
||||
// be the whole turn, so the Hexis path was unreachable from voice or chat.
|
||||
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
dec := router.Decision{
|
||||
Utterance: "перезапусти muzick indexer",
|
||||
Intent: router.IntentAct,
|
||||
Stage: 3,
|
||||
Clarify: true,
|
||||
Slots: router.Slots{Text: "restart status muzick indexer"},
|
||||
}
|
||||
reply := h.hexisBeforeClarify(ctx, dec)
|
||||
if reply == "" {
|
||||
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
|
||||
}
|
||||
if hexis.Count("", "/api/v1") == 0 {
|
||||
t.Fatal("hexis was never contacted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
|
||||
// she asks exactly what she asked before.
|
||||
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
dec := router.Decision{
|
||||
Utterance: "перезапусти muzick indexer",
|
||||
Intent: router.IntentAct,
|
||||
Stage: 3,
|
||||
Clarify: true,
|
||||
Slots: router.Slots{Text: "перезагрузить музик индексер"},
|
||||
}
|
||||
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
|
||||
t.Fatalf("no hexis must mean no reply, got %q", reply)
|
||||
}
|
||||
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
|
||||
t.Fatal("she must still ask what to do")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Entity-ref propagation, Maven side (Vikunja #272): the canonical Nexus
|
||||
// entity_id must reach Praxis as a query scope rather than being resolved and
|
||||
// then thrown away, and the enrichment that produces those ids must degrade
|
||||
// visibly instead of silently.
|
||||
|
||||
func entityAttentionDec(subject string) router.Decision {
|
||||
return router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: subject},
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_ScopesPraxisByCanonicalID: the resolved id must travel
|
||||
// to Praxis in the request, not be used for client-side filtering.
|
||||
func TestEntityAttention_ScopesPraxisByCanonicalID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
var scoped bool
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "GET" && strings.HasPrefix(r.Path, "/api/v1/tools/attention") &&
|
||||
strings.Contains(r.Query, "entity_id=ent_muzick") {
|
||||
scoped = true
|
||||
}
|
||||
}
|
||||
if !scoped {
|
||||
t.Fatalf("expected attention scoped by entity_id, got requests %+v", praxis.Requests())
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Error("a spoken scoped item must be surfaced, like the unscoped digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_FoldsInLocalFactsForSameEntity: facts the enrichment
|
||||
// worker already tagged with the same canonical id join the same answer.
|
||||
func TestEntityAttention_FoldsInLocalFactsForSameEntity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
|
||||
"descaled", "the espresso machine", "descaled in june", "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_UnscopedPraxisResponseIsRefused: a Praxis old enough to
|
||||
// ignore the entity_id parameter answers the scoped question with the whole
|
||||
// unscoped list. Relabelling those items "по «X»" is the same fabrication the
|
||||
// canonical ref exists to prevent, arriving through a different door.
|
||||
func TestEntityAttention_UnscopedPraxisResponseIsRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
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)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("refusing the answer must still say something")
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") != 0 {
|
||||
t.Error("items that were never spoken must not be surfaced")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_ForeignItemsAreDropped: items tagged with another entity
|
||||
// are dropped rather than spoken under this entity's name.
|
||||
func TestEntityAttention_ForeignItemsAreDropped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
mixed := []map[string]any{
|
||||
{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0, "entity_id": "ent_muzick"},
|
||||
{"id": "item_2", "title": "the kettle is descaling", "importance": 1.0, "entity_id": "ent_kettle"},
|
||||
}
|
||||
praxis := newFakePraxis(t, mustJSON(mixed))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
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)
|
||||
}
|
||||
if strings.Contains(reply, "kettle") {
|
||||
t.Fatalf("another entity's item must not be spoken here, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_TruncationIsNamed: reading three of many remembered
|
||||
// facts must not be presented as everything she knows.
|
||||
func TestEntityAttention_TruncationIsNamed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
|
||||
"note", "the espresso machine", "факт "+string(rune('а'+i)), "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_AmbiguousAsksInsteadOfGuessing.
|
||||
func TestEntityAttention_AmbiguousAsksInsteadOfGuessing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
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)
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("an ambiguous subject must not be queried against praxis")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_MissingAndDegradedAreDistinct: "no such entity" and
|
||||
// "Nexus is down" must not produce the same answer.
|
||||
func TestEntityAttention_MissingAndDegradedAreDistinct(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
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("нечто"))
|
||||
if degraded == missing {
|
||||
t.Fatalf("outage and unknown-entity must not read the same: %q", degraded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_DelayedNexusDegradesNotHangs: a slow Nexus past the
|
||||
// caller's deadline degrades and never queries Praxis with an empty scope.
|
||||
func TestEntityAttention_DelayedNexusDegradesNotHangs(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
nexus.SetDelay(2 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("a delayed resolve must still answer")
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("praxis must not be queried without a resolved scope")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_WithoutNexusSaysSo: no Nexus means no canonical ref, so
|
||||
// the scoped query is refused rather than answered about something else.
|
||||
func TestEntityAttention_WithoutNexusSaysSo(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 := 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)
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("no canonical ref means no scoped query at all")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichmentBackoff_HoldsAndReleases: repeated Nexus failures back the
|
||||
// fact off instead of hammering, and the fact is retried once the window
|
||||
// elapses. Nothing is ever given up on.
|
||||
func TestEnrichmentBackoff_HoldsAndReleases(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
st := newTestStore(t)
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
|
||||
nexus.SetFault(503)
|
||||
w.tick(ctx)
|
||||
failedCalls := nexus.Count("POST", "/api/v1/resolve")
|
||||
if failedCalls != 1 {
|
||||
t.Fatalf("expected one resolve attempt, got %d", failedCalls)
|
||||
}
|
||||
|
||||
// Immediately after a failure the fact is in backoff: no second call.
|
||||
w.tick(ctx)
|
||||
if nexus.Count("POST", "/api/v1/resolve") != failedCalls {
|
||||
t.Fatal("a fact in backoff must not be retried on the very next tick")
|
||||
}
|
||||
if s := w.status(ctx); s.Pending != 1 || s.InBackoff != 1 || s.MaxAttempts != 1 {
|
||||
t.Fatalf("degradation must be reported, got %+v", s)
|
||||
}
|
||||
|
||||
// Once the window elapses and Nexus recovers, the fact resolves.
|
||||
clock.Advance(2 * time.Minute)
|
||||
nexus.SetFault(0)
|
||||
w.tick(ctx)
|
||||
facts, err := st.FactsByEntity(ctx, "ent_espresso", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FactsByEntity: %v", err)
|
||||
}
|
||||
if len(facts) != 1 {
|
||||
t.Fatalf("expected the fact resolved after recovery, got %+v", facts)
|
||||
}
|
||||
if s := w.status(ctx); s.Pending != 0 || s.MaxAttempts != 0 {
|
||||
t.Fatalf("recovery must clear the degradation report, got %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichmentBackoff_GrowsAndIsCapped(t *testing.T) {
|
||||
if enrichmentBackoff(1) != time.Minute {
|
||||
t.Fatalf("first retry should be a minute, got %v", enrichmentBackoff(1))
|
||||
}
|
||||
if enrichmentBackoff(3) != 4*time.Minute {
|
||||
t.Fatalf("third retry should be four minutes, got %v", enrichmentBackoff(3))
|
||||
}
|
||||
if enrichmentBackoff(50) != time.Hour {
|
||||
t.Fatalf("backoff must cap at an hour, got %v", enrichmentBackoff(50))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichment_BackedOffFactsDoNotStallTheQueue: the pending queue is ordered
|
||||
// by id, so the oldest facts are pulled first whether or not they are eligible.
|
||||
// A batch of facts in backoff at the head must not hold every slot and stop
|
||||
// enrichment for everything younger.
|
||||
func TestEnrichment_BackedOffFactsDoNotStallTheQueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
total := 5
|
||||
for i := 0; i < total; i++ {
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"subject-"+string(rune('a'+i)), `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
// A batch smaller than the queue, so with no scan the last fact never
|
||||
// reaches the head while the first ones are backed off.
|
||||
w.batch = total - 1
|
||||
|
||||
nexus.SetFault(503)
|
||||
w.tick(ctx)
|
||||
if got := nexus.Count("POST", "/api/v1/resolve"); got != total-1 {
|
||||
t.Fatalf("expected the first batch attempted, got %d calls", got)
|
||||
}
|
||||
|
||||
// Second tick with Nexus healthy: the backed-off head must be skipped and
|
||||
// the fact behind it resolved, not the same batch pulled and dropped.
|
||||
nexus.SetFault(0)
|
||||
w.tick(ctx)
|
||||
facts, err := st.FactsByEntity(ctx, "ent_x", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FactsByEntity: %v", err)
|
||||
}
|
||||
if len(facts) == 0 {
|
||||
t.Fatal("a due fact behind a backed-off batch must still be resolved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichment_StoreWriteFailureBacksOffToo: the one failure mode where the
|
||||
// resolve worked and the write did not must be paced like any other, not
|
||||
// retried at full rate forever.
|
||||
func TestEnrichment_StoreWriteFailureBacksOffToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
st := newTestStore(t)
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
pending, err := st.PendingFactResolutions(ctx, 10)
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("setup: pending = %+v, %v", pending, err)
|
||||
}
|
||||
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
|
||||
// Closing the store makes the resolution write fail while the Nexus call
|
||||
// still succeeds — the split this path gets wrong.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
if w.resolveOne(ctx, pending[0]) {
|
||||
t.Fatal("a failed store write must not report success")
|
||||
}
|
||||
if w.due(pending[0].ID) {
|
||||
t.Fatal("a failed store write must back the fact off like a failed resolve")
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,10 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: rtr,
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -24,10 +25,88 @@ type factEnrichmentWorker struct {
|
||||
eco *ecosystemWiring
|
||||
interval time.Duration
|
||||
batch int // facts resolved per tick; keeps a single slow tick bounded
|
||||
now func() time.Time
|
||||
|
||||
// Retry state for facts whose resolution failed transiently. Kept in
|
||||
// memory rather than in the DB: a restart legitimately retries
|
||||
// everything, and the backoff exists to spare a struggling Nexus, not
|
||||
// to be durable. A fact is never given up on — degraded means slower,
|
||||
// not dropped.
|
||||
mu sync.Mutex
|
||||
attempt map[int64]int // fact id → consecutive failures
|
||||
nextTry map[int64]time.Time // fact id → earliest retry
|
||||
}
|
||||
|
||||
// enrichmentScanLimit bounds how deep a single tick (or status report) walks
|
||||
// the pending queue looking for facts whose backoff has elapsed. The queue is
|
||||
// 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.
|
||||
const enrichmentScanLimit = 1000
|
||||
|
||||
// enrichmentBackoff is the wait before retrying a fact after n consecutive
|
||||
// failures, capped so a long Nexus outage still retries about hourly.
|
||||
func enrichmentBackoff(n int) time.Duration {
|
||||
d := time.Minute
|
||||
for i := 1; i < n && d < time.Hour; i++ {
|
||||
d *= 2
|
||||
}
|
||||
if d > time.Hour {
|
||||
d = time.Hour
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval time.Duration) *factEnrichmentWorker {
|
||||
return &factEnrichmentWorker{store: st, eco: eco, interval: interval, batch: 20}
|
||||
return &factEnrichmentWorker{
|
||||
store: st,
|
||||
eco: eco,
|
||||
interval: interval,
|
||||
batch: 20,
|
||||
now: time.Now,
|
||||
attempt: map[int64]int{},
|
||||
nextTry: map[int64]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// enrichmentStatus is what the worker reports about its own health: how many
|
||||
// facts are waiting, how many of those are currently in backoff, and the worst
|
||||
// retry count among them. Degradation is reported, never hidden — a Nexus that
|
||||
// has been down all day must be visible as a backlog, not as facts that
|
||||
// silently never got tagged.
|
||||
//
|
||||
// All three numbers describe the same set of rows, the first
|
||||
// enrichmentScanLimit pending facts. Counting Pending over a thousand rows
|
||||
// while counting InBackoff over the twenty that reached the head of a batch
|
||||
// described two different populations under one struct.
|
||||
type enrichmentStatus struct {
|
||||
Pending int
|
||||
InBackoff int
|
||||
MaxAttempts int
|
||||
Scanned int // rows the other three counts were taken over
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: status: %v", err)
|
||||
return st
|
||||
}
|
||||
st.Pending = len(pending)
|
||||
st.Scanned = len(pending)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := w.now()
|
||||
for _, f := range pending {
|
||||
if next, ok := w.nextTry[f.ID]; ok && now.Before(next) {
|
||||
st.InBackoff++
|
||||
}
|
||||
if n := w.attempt[f.ID]; n > st.MaxAttempts {
|
||||
st.MaxAttempts = n
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) run(ctx context.Context) {
|
||||
@@ -52,22 +131,86 @@ func (w *factEnrichmentWorker) run(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
pending, err := w.store.PendingFactResolutions(ctx, w.batch)
|
||||
// Scan past the facts that are still in backoff instead of letting them
|
||||
// occupy the batch. The queue is ordered by id, so the oldest facts are
|
||||
// pulled first whether or not they are eligible: twenty facts Nexus keeps
|
||||
// rejecting would otherwise hold every slot forever and enrichment would
|
||||
// stop with no error and no log line, because a tick that skips everything
|
||||
// fails nothing.
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: list pending: %v", err)
|
||||
return
|
||||
}
|
||||
w.forgetDeparted(pending)
|
||||
skipped, failed, attempted := 0, 0, 0
|
||||
for _, f := range pending {
|
||||
w.resolveOne(ctx, f)
|
||||
if attempted >= w.batch {
|
||||
break
|
||||
}
|
||||
if !w.due(f.ID) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
if !w.resolveOne(ctx, f) {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
log.Printf("factenrichment: %d/%d resolutions failed this tick, %d held in backoff",
|
||||
failed, attempted, skipped)
|
||||
}
|
||||
// Report the backlog every tick, not only when something failed: the
|
||||
// stalled state worth seeing is the one where nothing failed because
|
||||
// nothing was attempted.
|
||||
if st := w.status(ctx); st.Pending > 0 {
|
||||
log.Printf("factenrichment: %d facts pending entity resolution, %d in backoff, worst attempt %d (scanned %d)",
|
||||
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) {
|
||||
// forgetDeparted drops retry state for facts that are no longer pending. A
|
||||
// fact can leave the queue without ever resolving here — voided, or resolved
|
||||
// by a later write — and its entries would otherwise live as long as the
|
||||
// process does.
|
||||
func (w *factEnrichmentWorker) forgetDeparted(pending []store.Fact) {
|
||||
live := make(map[int64]struct{}, len(pending))
|
||||
for _, f := range pending {
|
||||
live[f.ID] = struct{}{}
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for id := range w.attempt {
|
||||
if _, ok := live[id]; !ok {
|
||||
delete(w.attempt, id)
|
||||
}
|
||||
}
|
||||
for id := range w.nextTry {
|
||||
if _, ok := live[id]; !ok {
|
||||
delete(w.nextTry, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// due reports whether a fact's backoff window has elapsed.
|
||||
func (w *factEnrichmentWorker) due(id int64) bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
next, ok := w.nextTry[id]
|
||||
return !ok || !w.now().Before(next)
|
||||
}
|
||||
|
||||
// resolveOne resolves one pending fact. It returns false when the attempt
|
||||
// failed transiently: the fact stays pending and is retried on a backoff.
|
||||
func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) bool {
|
||||
entityID, _, ambiguous, err := w.eco.resolveEntityReference(ctx, f.Subject, nil)
|
||||
if err != nil {
|
||||
// Transient (Nexus unreachable) — leave pending, retry next tick.
|
||||
log.Printf("factenrichment: resolve fact %d subject %q: %v", f.ID, f.Subject, err)
|
||||
return
|
||||
// Transient (Nexus unreachable) — leave pending, back off, retry later.
|
||||
// The subject is his words: log its length, the way the trace does.
|
||||
log.Printf("factenrichment: resolve fact %d subject %s: %v", f.ID, redactSubject(f.Subject), err)
|
||||
w.backOff(f.ID)
|
||||
return false
|
||||
}
|
||||
state := store.ResolutionNotFound
|
||||
switch {
|
||||
@@ -77,6 +220,25 @@ func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) {
|
||||
state = store.ResolutionAmbiguous
|
||||
}
|
||||
if err := w.store.ResolveFactEntity(ctx, f.ID, entityID, state); err != nil {
|
||||
// A failed write leaves the fact pending exactly like a failed resolve
|
||||
// does, so it gets the same pacing. Clearing the counters first meant
|
||||
// this one path retried every tick, at full rate, with no ceiling.
|
||||
log.Printf("factenrichment: record resolution for fact %d: %v", f.ID, err)
|
||||
w.backOff(f.ID)
|
||||
return false
|
||||
}
|
||||
w.mu.Lock()
|
||||
delete(w.attempt, f.ID)
|
||||
delete(w.nextTry, f.ID)
|
||||
w.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// backOff records one more consecutive failure for a fact and pushes its next
|
||||
// attempt out accordingly.
|
||||
func (w *factEnrichmentWorker) backOff(id int64) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.attempt[id]++
|
||||
w.nextTry[id] = w.now().Add(enrichmentBackoff(w.attempt[id]))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ungroundedConfidence — what a self fact is worth when its value appears
|
||||
// nowhere in what he said. Below `query_min_score` is not the point (recall
|
||||
// gates on vector distance, not on this number); the point is that
|
||||
// `/history` and every future reader can tell a value he said from a value
|
||||
// the model supplied.
|
||||
const ungroundedConfidence = 0.6
|
||||
|
||||
// factConfidence scores a self fact by whether its value is grounded in the
|
||||
// utterance it came from. Grounded stays 1.00, which is what a tapped fact
|
||||
// has always been worth. Ungrounded drops, and says so in the log.
|
||||
//
|
||||
// An empty value is grounded by definition: the key alone carries the fact
|
||||
// ("поужинал"), and there is nothing for the model to have invented.
|
||||
func factConfidence(utterance, value string) float64 {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return 1.0
|
||||
}
|
||||
if valueGrounded(utterance, value) {
|
||||
return 1.0
|
||||
}
|
||||
log.Printf("voice: fact value %q is not in %q — writing at confidence %.2f",
|
||||
value, utterance, ungroundedConfidence)
|
||||
return ungroundedConfidence
|
||||
}
|
||||
|
||||
// valueGrounded reports whether every word of value traces back to a word he
|
||||
// actually said. The comparison is on a 4-rune prefix, so the model's
|
||||
// normalization survives ("пил воду" → "вода") while an invented value
|
||||
// ("1.20" for a question about Go) does not.
|
||||
func valueGrounded(utterance, value string) bool {
|
||||
said := factTokens(utterance)
|
||||
words := factTokens(value)
|
||||
if len(words) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, w := range words {
|
||||
if !anyTokenMatches(said, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func anyTokenMatches(said []string, w string) bool {
|
||||
for _, s := range said {
|
||||
if s == w || sameStem(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sameStem is inflection tolerance and nothing more: it compares all but the
|
||||
// last rune of the shorter word, and never fewer than three. Russian marks
|
||||
// case on the ending, so "пил воду" and the stored "вода" are the same word he
|
||||
// said, while "1.20" and "версия" are not. A word of three runes or fewer must
|
||||
// match outright, where a shorter prefix would match half the language.
|
||||
func sameStem(a, b string) bool {
|
||||
ar, br := []rune(a), []rune(b)
|
||||
shorter := min(len(ar), len(br))
|
||||
n := shorter - 1
|
||||
if n < 3 || len(ar) < n || len(br) < n {
|
||||
return false
|
||||
}
|
||||
return string(ar[:n]) == string(br[:n])
|
||||
}
|
||||
|
||||
// factTokens lowercases and splits on everything that is not a letter or a
|
||||
// digit, the same shape planTokens uses in the router.
|
||||
func factTokens(s string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.CoreAPI) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
}
|
||||
return h, api
|
||||
}
|
||||
|
||||
// The write half of #470: a question routed to IntentFact must not become a
|
||||
// fact about him, and must not leave a vector behind for recall to serve.
|
||||
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
reply := h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "какая последняя версия языка Go?",
|
||||
Slots: router.Slots{Key: "go_version", HasKey: true, Value: `"1.20"`},
|
||||
})
|
||||
|
||||
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
||||
t.Fatal("a question was stored as a fact about him")
|
||||
}
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("the question was indexed for recall: %+v", hits)
|
||||
}
|
||||
// It went down the query chain instead. Nothing is configured to answer a
|
||||
// world question in this harness, so "не знаю." is the honest outcome —
|
||||
// what matters is that the turn was answered, not stored.
|
||||
if reply == "" {
|
||||
t.Fatal("the turn was neither stored nor answered")
|
||||
}
|
||||
}
|
||||
|
||||
// The capture that must survive the gate: an explicit instruction to record,
|
||||
// even though it contains an interrogative.
|
||||
func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"вода"`},
|
||||
})
|
||||
|
||||
f, err := api.LatestFact(ctx, "water")
|
||||
if err != nil {
|
||||
t.Fatalf("an explicit capture was refused: %v", err)
|
||||
}
|
||||
if f.Confidence != 1.0 {
|
||||
t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence)
|
||||
}
|
||||
// #493: what recall reads back is the fact, not the sentence he said.
|
||||
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
||||
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("the fact was not indexed once: %+v", hits)
|
||||
}
|
||||
if got := hits[0].Meta["text"]; got != "water — вода" {
|
||||
t.Errorf("indexed text = %q, want the fact", got)
|
||||
}
|
||||
if got := hits[0].Meta["utterance"]; got != "запиши что я пил воду" {
|
||||
t.Errorf("utterance provenance = %q, want it kept alongside", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactConfidence(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance, value string
|
||||
want float64
|
||||
}{
|
||||
{"запиши что я пил воду", `"вода"`, 1.0},
|
||||
{"я выпил кофе", `"кофе"`, 1.0},
|
||||
{"поужинал", "", 1.0},
|
||||
{"отметь что я полил кактус", `"полил кактус"`, 1.0},
|
||||
{"какая последняя версия языка Go", `"1.20"`, ungroundedConfidence},
|
||||
{"кто премьер Японии", `"Тонио Озаки"`, ungroundedConfidence},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := factConfidence(c.utterance, c.value); got != c.want {
|
||||
t.Errorf("factConfidence(%q, %q) = %v, want %v", c.utterance, c.value, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||
t.Helper()
|
||||
vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", text, err)
|
||||
}
|
||||
return vec
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Body []byte
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
// fakeServer is the common shell behind fakeNexus/fakePraxis/fakeHexis: an
|
||||
@@ -25,9 +27,12 @@ type capturedRequest struct {
|
||||
type fakeServer struct {
|
||||
*httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
fault int // non-zero: every request gets this HTTP status instead of routing
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
fault int // non-zero: every request gets this HTTP status instead of routing
|
||||
routeFaults map[string]int // path prefix → status, for one endpoint failing alone
|
||||
garbage string // non-empty: returned 200 verbatim instead of routing (malformed-contract lever)
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
// newFakeServer starts a server dispatching to routes keyed by "METHOD
|
||||
@@ -47,14 +52,42 @@ func newFakeServer(t *testing.T, routes map[string]http.HandlerFunc) *fakeServer
|
||||
}
|
||||
}
|
||||
fs.mu.Lock()
|
||||
fs.requests = append(fs.requests, capturedRequest{Method: r.Method, Path: r.URL.Path, Body: body})
|
||||
fs.requests = append(fs.requests, capturedRequest{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
Body: body,
|
||||
Header: r.Header.Clone(),
|
||||
})
|
||||
fault := fs.fault
|
||||
if fault == 0 {
|
||||
for prefix, status := range fs.routeFaults {
|
||||
if hasPrefix(r.URL.Path, prefix) {
|
||||
fault = status
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
garbage := fs.garbage
|
||||
delay := fs.delay
|
||||
fs.mu.Unlock()
|
||||
|
||||
if delay > 0 {
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if fault != 0 {
|
||||
http.Error(w, "injected fault", fault)
|
||||
return
|
||||
}
|
||||
if garbage != "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(garbage))
|
||||
return
|
||||
}
|
||||
|
||||
for key, handler := range routes {
|
||||
method, prefix := splitRouteKey(key)
|
||||
@@ -90,6 +123,52 @@ func (fs *fakeServer) SetFault(status int) {
|
||||
fs.fault = status
|
||||
}
|
||||
|
||||
// SetRouteFault fails one endpoint while the rest of the server stays healthy,
|
||||
// which is the shape most real outages take: attention answers and pin is
|
||||
// down. Pass 0 to clear that route. A server-wide SetFault still wins.
|
||||
func (fs *fakeServer) SetRouteFault(pathPrefix string, status int) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.routeFaults == nil {
|
||||
fs.routeFaults = map[string]int{}
|
||||
}
|
||||
if status == 0 {
|
||||
delete(fs.routeFaults, pathPrefix)
|
||||
return
|
||||
}
|
||||
fs.routeFaults[pathPrefix] = status
|
||||
}
|
||||
|
||||
// SetBody makes every subsequent request answer 200 with the given body,
|
||||
// bypassing the route table. Used to serve a malformed or contract-violating
|
||||
// payload where the transport itself is healthy. Pass "" to clear it.
|
||||
func (fs *fakeServer) SetBody(body string) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.garbage = body
|
||||
}
|
||||
|
||||
// SetDelay stalls every subsequent request for d before answering, so callers
|
||||
// can drive client timeouts and context cancellation deterministically. The
|
||||
// delay is abandoned as soon as the client hangs up.
|
||||
func (fs *fakeServer) SetDelay(d time.Duration) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.delay = d
|
||||
}
|
||||
|
||||
// Count returns how many captured requests used the given method and path
|
||||
// prefix. "" matches any method.
|
||||
func (fs *fakeServer) Count(method, prefix string) int {
|
||||
n := 0
|
||||
for _, r := range fs.Requests() {
|
||||
if (method == "" || r.Method == method) && hasPrefix(r.Path, prefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Requests returns a snapshot of captured requests, in arrival order.
|
||||
func (fs *fakeServer) Requests() []capturedRequest {
|
||||
fs.mu.Lock()
|
||||
@@ -118,6 +197,40 @@ func fixtureNexusResolved(entityID, displayName, entityType string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedFlat is the flat resolve shape documented in
|
||||
// ECOSYSTEM-SPEC.md §1.5 (entity_id/entity_type/display_name at the top
|
||||
// level) rather than the nested "entity" object — the older of the two
|
||||
// wire shapes Maven must keep accepting.
|
||||
func fixtureNexusResolvedFlat(entityID, displayName, entityType string) string {
|
||||
return mustJSON(map[string]any{
|
||||
"status": "resolved",
|
||||
"entity_id": entityID,
|
||||
"entity_type": entityType,
|
||||
"display_name": displayName,
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedFuture is a resolved response from a hypothetical newer
|
||||
// Nexus: same required fields plus unknown ones. Decoding must ignore the
|
||||
// extras, not fail — forward compatibility is what lets the ecosystem be
|
||||
// upgraded one service at a time.
|
||||
func fixtureNexusResolvedFuture(entityID, displayName, entityType string) string {
|
||||
return mustJSON(map[string]any{
|
||||
"status": "resolved",
|
||||
"entity": map[string]any{"id": entityID, "display_name": displayName, "type": entityType, "tenant": "home"},
|
||||
"provenance": map[string]any{"resolver": "v3", "graph_epoch": 42},
|
||||
"score_breakdown": []any{map[string]any{"signal": "alias", "weight": 0.9}},
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedEmpty is the contract violation that decodes cleanly:
|
||||
// Nexus claims a resolve and delivers no entity. It must not read as "no such
|
||||
// entity", which would let the caller fall through to local execution with the
|
||||
// user's verb intact.
|
||||
func fixtureNexusResolvedEmpty() string {
|
||||
return `{"status":"resolved"}`
|
||||
}
|
||||
|
||||
func fixtureNexusNotFound() string {
|
||||
return `{"status":"not_found"}`
|
||||
}
|
||||
@@ -138,6 +251,23 @@ func fixtureHexisExecuted(id, status string) string {
|
||||
return mustJSON(map[string]any{"id": id, "status": status})
|
||||
}
|
||||
|
||||
// fixtureHexisExecutionFailed is a well-formed Hexis response reporting that
|
||||
// the command itself failed: the call succeeded, the execution did not. Maven
|
||||
// must distinguish this from a transport failure and from success.
|
||||
func fixtureHexisExecutionFailed(id, message string) string {
|
||||
return mustJSON(map[string]any{"id": id, "status": "failed", "error": message})
|
||||
}
|
||||
|
||||
// fixturePraxisAttentionScoped tags each item with an entity_id, which is what
|
||||
// a Praxis that understands the entity_id query parameter returns. A Praxis
|
||||
// that ignores it answers with untagged items from every entity.
|
||||
func fixturePraxisAttentionScoped(entityID string, items ...map[string]any) string {
|
||||
for _, item := range items {
|
||||
item["entity_id"] = entityID
|
||||
}
|
||||
return mustJSON(items)
|
||||
}
|
||||
|
||||
func fixturePraxisAttentionItems(items ...map[string]any) string {
|
||||
return mustJSON(items)
|
||||
}
|
||||
@@ -156,18 +286,28 @@ func mustJSON(v any) string {
|
||||
// (e.g. asserting age-based digest ordering without sleeping).
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
step time.Duration // advanced on every read, so elapsed time is measurable
|
||||
}
|
||||
|
||||
func newFakeClock(start time.Time) *fakeClock {
|
||||
return &fakeClock{t: start}
|
||||
}
|
||||
|
||||
// newTickingClock advances by step on every read. Durations measured across
|
||||
// hops are then non-zero without sleeping, which is what lets a test tell a
|
||||
// trace that measured something from one that measured nothing.
|
||||
func newTickingClock(start time.Time, step time.Duration) *fakeClock {
|
||||
return &fakeClock{t: start, step: step}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
now := c.t
|
||||
c.t = c.t.Add(c.step)
|
||||
return now
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
@@ -191,8 +331,13 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
|
||||
// fault is injected via SetFault.
|
||||
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
||||
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
"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, `{}`),
|
||||
"POST /api/v1/tools/acknowledge": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/resolve": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/ignore": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/pin": jsonHandler(http.StatusOK, `{}`),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// mavend/feeds.go — the driver for RSS/Atom reading (Vikunja #258,
|
||||
// docs/plans/13-rss-news-feeds.md). The reader itself is pure and lives in
|
||||
// internal/rss; this is the impure half: a ticker, the guarded fetcher, and the
|
||||
// two adapters that let a pure package talk to the store.
|
||||
//
|
||||
// Why in-core rather than its own daemon like mavmaild and mavpoll: those two
|
||||
// hold a CREDENTIAL (an IMAP password, a zenmoney token), and the reason they
|
||||
// are separate processes is that core must never see it. A feed URL is public,
|
||||
// there is no secret to isolate, and a whole extra binary and compose service
|
||||
// would buy nothing. The other half of the mavpoll precedent — off unless
|
||||
// configured — is kept: no `feeds` block, no poller, no outbound request.
|
||||
//
|
||||
// It is its own goroutine, not a step on the tick: the tick has a delivery
|
||||
// deadline behind it, and a feed read is a network round-trip that nobody is
|
||||
// waiting on.
|
||||
//
|
||||
// Nothing here dispatches. A feed that announced itself would be a nag, so the
|
||||
// only output is notes with source "rss:<feed>", which the answer path reads
|
||||
// when he asks ("что нового в лентах?" — see queryFeeds in actions_query.go).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/stt"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// feedWorker — ticker + poller.
|
||||
type feedWorker struct {
|
||||
poller *rss.Poller
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// feedTickInterval — how often the worker asks the poller what is due. Per-feed
|
||||
// cadence is the poller's business; this is just the granularity.
|
||||
const feedTickInterval = 5 * time.Minute
|
||||
|
||||
// newFeedWorker wires feed reading, or returns nil when it must not run:
|
||||
// no `feeds` block (the normal case), or nothing valid in it. Every caller
|
||||
// checks for nil.
|
||||
func newFeedWorker(api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *feedWorker {
|
||||
if cfg.Feeds == nil {
|
||||
return nil
|
||||
}
|
||||
fc := cfg.Feeds
|
||||
|
||||
feeds := make([]rss.FeedConfig, 0, len(fc.Sources))
|
||||
hosts := append([]string(nil), fc.AllowHosts...)
|
||||
for _, s := range fc.Sources {
|
||||
feeds = append(feeds, rss.FeedConfig{
|
||||
Name: s.Name,
|
||||
URL: s.URL,
|
||||
Category: s.Category,
|
||||
Interval: time.Duration(s.Interval),
|
||||
Include: s.Include,
|
||||
Exclude: s.Exclude,
|
||||
})
|
||||
// Each configured feed's own host is allowed. The allowlist is then
|
||||
// exactly "the feeds he asked for", so a redirect off to somewhere else
|
||||
// is refused by the fetcher rather than followed.
|
||||
if u, err := url.Parse(s.URL); err == nil && u.Hostname() != "" {
|
||||
hosts = append(hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
fetcher := webfetch.New(webfetch.Config{
|
||||
AllowHosts: hosts,
|
||||
Timeout: time.Duration(fc.Timeout),
|
||||
MaxBytes: fc.MaxBytes,
|
||||
})
|
||||
poller := rss.NewPoller(feeds, &feedFetcher{f: fetcher}, api, &factMarks{api: api},
|
||||
embedderFor(emb), nil, rss.Config{
|
||||
DefaultInterval: time.Duration(fc.PollInterval),
|
||||
MaxItems: fc.MaxItems,
|
||||
MaxAge: time.Duration(fc.MaxAge),
|
||||
})
|
||||
if poller == nil {
|
||||
log.Printf("feeds: configured but nothing pollable — feed reading disabled")
|
||||
return nil
|
||||
}
|
||||
log.Printf("feeds: reading %d feed(s), checking what is due every %s", len(feeds), feedTickInterval)
|
||||
return &feedWorker{poller: poller, interval: feedTickInterval}
|
||||
}
|
||||
|
||||
// run polls what is due until ctx is canceled. The first round runs immediately
|
||||
// so a restart does not blind her for the first interval; it writes notes only,
|
||||
// so an early round cannot startle anyone.
|
||||
func (w *feedWorker) run(ctx context.Context) {
|
||||
w.poller.PollDue(ctx, time.Now())
|
||||
t := time.NewTicker(w.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
w.poller.PollDue(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embedderOf — the voice wiring's embedder, or nil when voice is not wired.
|
||||
// Feed notes are embedded with the SAME model the rest of the store uses, or not
|
||||
// at all; a second embedder would write vectors nothing can search.
|
||||
func embedderOf(w *voiceWiring) router.Embedder {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.embedder
|
||||
}
|
||||
|
||||
// transcriberOf — the STT the voice path is using, or nil when voice is off.
|
||||
// The meeting recorder reuses it rather than dialling mavsttd a second time:
|
||||
// Maven has one speech-to-text engine and adding a second would mean two
|
||||
// whisper contexts competing for the same iGPU.
|
||||
func transcriberOf(w *voiceWiring) stt.Transcriber {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.transcriber
|
||||
}
|
||||
|
||||
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
|
||||
// fields it needs and stays free of net/http.
|
||||
type feedFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
func (a *feedFetcher) Get(ctx context.Context, u string) (*rss.Body, error) {
|
||||
resp, err := a.f.Get(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rss.Body{Bytes: resp.Body}, nil
|
||||
}
|
||||
|
||||
// factMarks stores "how far this feed was read" as a config fact, the same
|
||||
// mechanism the plan named and the same one the pattern tick uses for its own
|
||||
// bookkeeping. Durable, inspectable on /dash, and cheap.
|
||||
type factMarks struct{ api ipc.CoreAPI }
|
||||
|
||||
func markKey(feed string) string { return "rss:latest:" + feed }
|
||||
|
||||
func (m *factMarks) LastMark(ctx context.Context, feed string) (time.Time, error) {
|
||||
f, err := m.api.LatestFact(ctx, markKey(feed))
|
||||
if err != nil {
|
||||
// No mark yet is not an error worth propagating: the poller treats a
|
||||
// zero time as a cold start.
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, f.Value)
|
||||
if err != nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (m *factMarks) SetMark(ctx context.Context, feed string, at time.Time) error {
|
||||
_, err := m.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "config",
|
||||
Key: markKey(feed),
|
||||
Value: at.UTC().Format(time.RFC3339),
|
||||
Source: "poll:rss",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// embedderFor adapts router.Embedder to rss.Embedder, and returns nil when
|
||||
// there is none — a note without a vector is still a note the recent-notes path
|
||||
// can read.
|
||||
//
|
||||
// EmbedPassage, not Embed: a feed item is text being searched FOR, and the e5
|
||||
// embedder is asymmetric. Getting this backwards makes the item unfindable by
|
||||
// the question that should have matched it.
|
||||
func embedderFor(emb router.Embedder) rss.Embedder {
|
||||
if emb == nil {
|
||||
return nil
|
||||
}
|
||||
return passageEmbedder{emb}
|
||||
}
|
||||
|
||||
type passageEmbedder struct{ e router.Embedder }
|
||||
|
||||
func (p passageEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
return router.EmbedPassage(ctx, p.e, text)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// buildFeedHandler — a handler with the given feed notes already stored. No
|
||||
// embedder: the feed source answers from recent notes by source, which is what
|
||||
// makes it work for notes written before an embedder existed.
|
||||
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
for i, n := range notes {
|
||||
ts := now.Add(time.Duration(i) * time.Minute)
|
||||
if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
recall: recallWiring{embedder: nil},
|
||||
}
|
||||
}
|
||||
|
||||
func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
|
||||
t.Helper()
|
||||
return h.queryFeeds(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryFeedsReadsFeedNotes(t *testing.T) {
|
||||
h := buildFeedHandler(t, true,
|
||||
ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
|
||||
ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
|
||||
)
|
||||
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
||||
if !ok {
|
||||
t.Fatal("the feed source did not claim the question")
|
||||
}
|
||||
if !strings.Contains(reply, "уязвимость") {
|
||||
t.Errorf("reply = %q, want the headline", reply)
|
||||
}
|
||||
if strings.Contains(reply, "он сам сказал") {
|
||||
t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
|
||||
}
|
||||
// She reads the headline, not the summary and not the URL.
|
||||
if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
|
||||
t.Errorf("reply = %q, want the title line only", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsByCategory(t *testing.T) {
|
||||
h := buildFeedHandler(t, true,
|
||||
ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
|
||||
ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
|
||||
)
|
||||
reply, ok := askFeeds(t, h, "что нового по технологиям?")
|
||||
if !ok {
|
||||
t.Fatal("not claimed")
|
||||
}
|
||||
if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||
if !strings.Contains(reply, "ничего") {
|
||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||
// answered by the model inventing a bulletin.
|
||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||
off := buildFeedHandler(t, false)
|
||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||
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 || !strings.Contains(reply, "ничего нового") {
|
||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||
h := buildFeedHandler(t, true)
|
||||
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
|
||||
t.Fatalf("claimed an unrelated question with %q", reply)
|
||||
}
|
||||
// The bare greeting is not a request for headlines. It used to be answered
|
||||
// with a configuration status.
|
||||
if reply, ok := askFeeds(t, h, "что нового?"); ok {
|
||||
t.Fatalf("claimed a greeting with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A busy day of his own notes must not push the newest headline out of the
|
||||
// window the feed answer scans.
|
||||
func TestQueryFeedsIsNotCrowdedOutByHisOwnNotes(t *testing.T) {
|
||||
notes := []ipc.Note{{Text: "Релиз ядра [технологии]", Source: "rss:habr"}}
|
||||
for i := 0; i < feedNoteWindow+10; i++ {
|
||||
notes = append(notes, ipc.Note{Text: "мысль вслух", Source: "tap:voice"})
|
||||
}
|
||||
h := buildFeedHandler(t, true, notes...)
|
||||
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "ядра") {
|
||||
t.Fatalf("reply = %q, ok = %v; the headline fell out of the window", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// The mark is what stops a restart from re-noting yesterday's headlines, so the
|
||||
// fact round-trip is worth a test of its own.
|
||||
func TestFactMarksRoundTrip(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
m := &factMarks{api: ipc.NewStoreAPI(st)}
|
||||
ctx := context.Background()
|
||||
|
||||
at, err := m.LastMark(ctx, "habr")
|
||||
if err != nil || !at.IsZero() {
|
||||
t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
|
||||
}
|
||||
want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
|
||||
if err := m.SetMark(ctx, "habr", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := m.LastMark(ctx, "habr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("mark = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
|
||||
// worker ⇒ no outbound request is possible.
|
||||
func TestNewFeedWorkerOffByDefault(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
|
||||
t.Fatal("a config with no feeds block wired a feed worker")
|
||||
}
|
||||
// An empty sources list is normalised to "off" by config.Load; the worker
|
||||
// refuses it too, so a hand-built Config cannot switch it on by accident.
|
||||
if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
|
||||
t.Fatal("an empty sources list wired a feed worker")
|
||||
}
|
||||
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||
{Name: "habr", URL: "https://example.org/rss"},
|
||||
}}}
|
||||
w := newFeedWorker(api, nil, cfg)
|
||||
if w == nil {
|
||||
t.Fatal("a configured feed did not wire a worker")
|
||||
}
|
||||
if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
|
||||
t.Fatalf("feeds = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The fetcher the worker builds must be allowlisted to the configured feeds and
|
||||
// nothing else — the crawler's SSRF guards are only worth as much as the
|
||||
// allowlist handed to them.
|
||||
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
||||
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||
{Name: "habr", URL: "https://feeds.example.org/rss"},
|
||||
}}}
|
||||
w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
|
||||
if w == nil {
|
||||
t.Fatal("no worker")
|
||||
}
|
||||
// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
|
||||
// itself must fail rather than be read.
|
||||
_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
|
||||
Name: "evil", URL: "http://127.0.0.1:9100/mcp",
|
||||
}, time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("the poller fetched a private address")
|
||||
}
|
||||
}
|
||||
+62
-8
@@ -1,24 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// 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).
|
||||
// voiceDialogueID — the dialogue-session key for the microphone, and the
|
||||
// clarify key for it too. 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"
|
||||
|
||||
// toDialogueSlots projects the router's slots onto the dialogue layer's subset
|
||||
// (everything except the fact Value, which the dialogue layer doesn't carry).
|
||||
// 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
|
||||
// carried by hand here. Adding a field to either struct without adding it to
|
||||
// BOTH functions loses a slot silently — nothing fails to build. The tests in
|
||||
// slotsparity_test.go fail when the field sets or the converters stop matching;
|
||||
// when they do, fix these two functions, not the tests.
|
||||
|
||||
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
|
||||
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
||||
return dialogue.Slots{
|
||||
Time: s.Time,
|
||||
HasTime: s.HasTime,
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
HasKey: s.HasKey,
|
||||
Text: s.Text,
|
||||
Fn: s.Fn,
|
||||
@@ -27,11 +82,10 @@ func toDialogueSlots(s router.Slots) dialogue.Slots {
|
||||
}
|
||||
}
|
||||
|
||||
// applyDialogueSlots writes inherited dialogue slots back onto router slots,
|
||||
// preserving router-only fields (Value) the dialogue layer never touched.
|
||||
// applyDialogueSlots writes dialogue slots back onto router slots.
|
||||
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
||||
base.Time, base.HasTime = d.Time, d.HasTime
|
||||
base.Key, base.HasKey = d.Key, d.HasKey
|
||||
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
|
||||
base.Text = d.Text
|
||||
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
|
||||
return base
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// mavend/intake.go — the unified event intake envelope, wired (Vikunja #283).
|
||||
//
|
||||
// internal/event defines the envelope and the bounded in-memory journal. This
|
||||
// file is the one place that FILLS it, and the reason it is one place is worth
|
||||
// stating, because the alternative was eight patches:
|
||||
//
|
||||
// Every intake path in Maven already converges on three writes, and all three
|
||||
// are ipc.CoreAPI methods —
|
||||
//
|
||||
// WriteFact ← POST /api/ambient, mavcaldav, mavpoll's zenmoney + wg reads,
|
||||
// /api/signal presence probes, the RSS/crawl watermarks
|
||||
// WriteNote ← the RSS poller, the page crawler, meeting transcripts,
|
||||
// image descriptions
|
||||
// CaptureTask ← the voice path, the web form, and the mail reader
|
||||
//
|
||||
// — so decorating that ONE interface with a publish covers the lot without a
|
||||
// caller knowing about events at all. cmd/mavmaild, cmd/mavcaldav, cmd/mavpoll,
|
||||
// cmd/mavweb and the in-core feed/crawl/capture/vision workers are unchanged:
|
||||
// they call the same interface they always called, and it now also narrates.
|
||||
//
|
||||
// The exception is cmd/mavend/mail.go, which reaches past the interface to
|
||||
// st.CaptureTask directly. It publishes explicitly; see mailIntake.ingest.
|
||||
//
|
||||
// # Production behaviour when nobody is watching
|
||||
//
|
||||
// A nil *event.Bus makes Publish a no-op, and newIntakeAPI with a nil bus
|
||||
// returns the wrapped API unchanged, so there is not even a decorator on the
|
||||
// call path. The journal is memory-only and is never consulted by the tick
|
||||
// loop, the router, or delivery — nothing Maven says depends on it. It is a
|
||||
// read surface (`/events`, `recent_events`) and an observation seam for the
|
||||
// simulator.
|
||||
//
|
||||
// # What is deliberately NOT here
|
||||
//
|
||||
// No dispatch. An event is a report that something arrived, never an
|
||||
// instruction to speak: "a feed item appeared" becoming a notification is the
|
||||
// nag this repo refuses. Digestion may one day read the journal; it will still
|
||||
// go through internal/loop's rules and the severity/presence routing table.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// newEventBus builds the journal, or returns nil when the operator turned it
|
||||
// off (a negative config.intake_journal). nil is the "behave exactly as before"
|
||||
// value all the way down: no decorator, no ring, no /events rows.
|
||||
func newEventBus(cfg *config.Config) *event.Bus {
|
||||
if cfg == nil {
|
||||
// No config at all is a test, not an operator decision. Saying "off"
|
||||
// here was noise in every suite that passes nil.
|
||||
return nil
|
||||
}
|
||||
if cfg.IntakeJournal < 0 {
|
||||
log.Printf("intake journal: off (intake_journal < 0)")
|
||||
return nil
|
||||
}
|
||||
n := cfg.IntakeJournal
|
||||
if n == 0 {
|
||||
n = config.DefaultIntakeJournal
|
||||
}
|
||||
log.Printf("intake journal: keeping the last %d intake events in memory", n)
|
||||
return event.NewBus(n)
|
||||
}
|
||||
|
||||
// intakeEventsFn is the daemonAPI.getEvents closure: the bus's ring rendered as
|
||||
// the wire type. Returns nil for a nil bus, which the daemonAPI reports as an
|
||||
// empty journal rather than an error.
|
||||
func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent {
|
||||
if bus == nil {
|
||||
return nil
|
||||
}
|
||||
return func(n int) []ipc.IntakeEvent {
|
||||
evs := bus.Recent(n)
|
||||
out := make([]ipc.IntakeEvent, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
out = append(out, ipc.IntakeEvent{
|
||||
Source: e.Source,
|
||||
Kind: e.Kind,
|
||||
EntityIDs: e.EntityIDs,
|
||||
Title: e.Title,
|
||||
Body: e.Body,
|
||||
Priority: e.Priority,
|
||||
OccurredAt: e.OccurredAt,
|
||||
NoticedAt: e.NoticedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// intakeAPI decorates a CoreAPI, publishing one envelope per successful
|
||||
// intake write. Embedding the interface means every other method passes
|
||||
// through untouched, and a new CoreAPI method is inherited rather than
|
||||
// silently dropped.
|
||||
type intakeAPI struct {
|
||||
ipc.CoreAPI
|
||||
bus *event.Bus
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newIntakeAPI wraps api so its intake writes are journalled. A nil bus
|
||||
// returns api itself — no decorator, no allocation, no behaviour change.
|
||||
func newIntakeAPI(api ipc.CoreAPI, bus *event.Bus, now func() time.Time) ipc.CoreAPI {
|
||||
if bus == nil || api == nil {
|
||||
return api
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &intakeAPI{CoreAPI: api, bus: bus, now: now}
|
||||
}
|
||||
|
||||
// WriteFact journals the fact after it lands. Order matters: an event is a
|
||||
// report of something that HAPPENED, so a failed write publishes nothing.
|
||||
func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
id, err := a.CoreAPI.WriteFact(ctx, req)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
if selfWrite(req) {
|
||||
// Maven's own bookkeeping is not something that arrived. The feed
|
||||
// watermark, the crawl hash, the praxis trace of an act she performed
|
||||
// and a quiet-hours toggle he pressed all used to sit on a page headed
|
||||
// "everything that arrived", and on a cold start a handful of feeds
|
||||
// could evict real intake behind their marks.
|
||||
return id, nil
|
||||
}
|
||||
// OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake
|
||||
// instant and the ambient path carries the meeting's start. Flattening
|
||||
// those to notice-time would make the journal lie about when things
|
||||
// happened, which is the one thing it is for.
|
||||
title := req.Key
|
||||
if req.VoidsID != nil {
|
||||
// A retraction is not a reading. Without this it published an envelope
|
||||
// indistinguishable from a fresh value for the same key, on a page
|
||||
// whose whole job is "what came in".
|
||||
title = "отмена: " + req.Key
|
||||
}
|
||||
a.bus.Publish(event.Event{
|
||||
Source: req.Source,
|
||||
Kind: event.SourceKind(req.Source, event.KindFact),
|
||||
Title: title,
|
||||
Body: req.Value,
|
||||
Priority: factPriority(req),
|
||||
OccurredAt: req.Ts,
|
||||
EntityIDs: entityIDs(req.Subject),
|
||||
Payload: factPayload(req),
|
||||
}, a.now())
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// WriteNote journals a note. This is the RSS and crawler path, and also the
|
||||
// meeting transcript and image description paths, which write their derived
|
||||
// text as ordinary notes.
|
||||
func (a *intakeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
id, err := a.CoreAPI.WriteNote(ctx, ts, text, embedding, source)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
title, body := splitFirstLine(text)
|
||||
a.bus.Publish(event.Event{
|
||||
Source: source,
|
||||
Kind: event.SourceKind(source, event.KindNote),
|
||||
Title: title,
|
||||
Body: body,
|
||||
Priority: event.PriorityLow,
|
||||
OccurredAt: ts,
|
||||
}, a.now())
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CaptureTask journals a captured task, but only when a row was actually
|
||||
// created. CaptureTask dedupes on normalised text among live rows, so a
|
||||
// mailbox re-read after a restart must not refill the journal with tasks that
|
||||
// were already there.
|
||||
func (a *intakeAPI) CaptureTask(ctx context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
resp, err := a.CoreAPI.CaptureTask(ctx, req)
|
||||
if err != nil || !resp.Created {
|
||||
return resp, err
|
||||
}
|
||||
a.bus.Publish(publishableTask(store.Task{
|
||||
CreatedTs: req.Ts,
|
||||
Text: req.Text,
|
||||
Source: req.Source,
|
||||
Evidence: req.Evidence,
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
}, a.now()), a.now())
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// publishableTask is the task→envelope shape, shared with mail.go, which
|
||||
// captures through the store directly rather than through the interface.
|
||||
//
|
||||
// Priority is high for a candidate with a due date and normal otherwise. That
|
||||
// is the only place this file makes a judgement, and it is a display hint on a
|
||||
// review page — nothing routes on it.
|
||||
func publishableTask(t store.Task, now time.Time) event.Event {
|
||||
occurred := t.CreatedTs
|
||||
if occurred.IsZero() {
|
||||
occurred = now
|
||||
}
|
||||
prio := event.PriorityNormal
|
||||
if t.Due != nil {
|
||||
prio = event.PriorityHigh
|
||||
}
|
||||
return event.Event{
|
||||
Source: t.Source,
|
||||
Kind: event.KindTask,
|
||||
Title: t.Text,
|
||||
Body: t.Evidence,
|
||||
Priority: prio,
|
||||
OccurredAt: occurred,
|
||||
}
|
||||
}
|
||||
|
||||
// selfWrite reports whether a fact write is Maven describing her own state
|
||||
// rather than something arriving from outside. The store's fact kinds are
|
||||
// 'self', 'env' and 'config'; 'config' is where every watermark and toggle
|
||||
// lands, and the praxis trace is an audit record of an act she performed, which
|
||||
// is the same class of thing under an 'env' kind.
|
||||
func selfWrite(req ipc.WriteFactReq) bool {
|
||||
switch req.Kind {
|
||||
case "config", "system":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(req.Source, "praxis:trace")
|
||||
}
|
||||
|
||||
// factPriority is the attention hint for a fact write. Deliberately crude:
|
||||
// a low-confidence inference (the ambient notification path writes below 1.0)
|
||||
// is worth less attention than a read he or a credentialled poller made, and a
|
||||
// retraction is a correction rather than news.
|
||||
//
|
||||
// Confidence is NOT recoverable from this, which is why the number itself goes
|
||||
// into Payload: three display buckets must not be the only surviving trace of
|
||||
// the distinction internal/calendar went out of its way to keep.
|
||||
func factPriority(req ipc.WriteFactReq) string {
|
||||
if req.VoidsID != nil {
|
||||
return event.PriorityLow
|
||||
}
|
||||
if req.Confidence > 0 && req.Confidence < 1.0 {
|
||||
return event.PriorityLow
|
||||
}
|
||||
return event.PriorityNormal
|
||||
}
|
||||
|
||||
// factDetail is the fact-shaped Payload: the fields the envelope's own flat
|
||||
// shape cannot carry, kept so a reader can tell an inference from a
|
||||
// credentialled read, and "nobody said" from "certain".
|
||||
type factDetail struct {
|
||||
// FactKind — the fact's own kind ('self', 'env', 'config'), a different
|
||||
// taxonomy from Event.Kind.
|
||||
FactKind string `json:"fact_kind,omitempty"`
|
||||
// Confidence — the number itself, so an inference stays distinguishable
|
||||
// from a credentialled read. nil when the writer set none, which the ipc
|
||||
// layer rejects today; the pointer keeps "nobody said" and "certain" from
|
||||
// collapsing into each other the way the priority bucket does.
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
// VoidsID — the fact this one retracts.
|
||||
VoidsID *int64 `json:"voids_id,omitempty"`
|
||||
}
|
||||
|
||||
func factPayload(req ipc.WriteFactReq) json.RawMessage {
|
||||
d := factDetail{FactKind: req.Kind, VoidsID: req.VoidsID}
|
||||
if req.Confidence != 0 {
|
||||
c := req.Confidence
|
||||
d.Confidence = &c
|
||||
}
|
||||
if d.FactKind == "" && d.Confidence == nil && d.VoidsID == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// entityIDs turns a fact's free-text Subject into the EntityIDs slot when it
|
||||
// already looks resolved. Intake runs BEFORE the fact enrichment worker
|
||||
// resolves a subject against Nexus, so this is almost always empty — the slot
|
||||
// exists for the paths that do know (the ecosystem acts), not for guessing.
|
||||
func entityIDs(subject string) []string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" || !strings.HasPrefix(subject, "entity:") {
|
||||
return nil
|
||||
}
|
||||
return []string{strings.TrimPrefix(subject, "entity:")}
|
||||
}
|
||||
|
||||
// splitFirstLine renders a note as title + body. Feed and crawl notes are
|
||||
// written "headline\nsummary\nlink", so the first line is already the title.
|
||||
func splitFirstLine(text string) (title, body string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
||||
return strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+1:])
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
var intakeNow = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
func intakeClock() time.Time { return intakeNow }
|
||||
|
||||
// failingAPI wraps the store adapter, failing the three intake writes on
|
||||
// demand, so the "a failed write publishes nothing" invariant is testable.
|
||||
type failingAPI struct {
|
||||
ipc.CoreAPI
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *failingAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
if f.fail {
|
||||
return 0, errors.New("injected")
|
||||
}
|
||||
return f.CoreAPI.WriteFact(ctx, req)
|
||||
}
|
||||
|
||||
func newIntakeTestAPI(t *testing.T) (ipc.CoreAPI, *event.Bus) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
bus := event.NewBus(32)
|
||||
return newIntakeAPI(ipc.NewStoreAPI(st), bus, intakeClock), bus
|
||||
}
|
||||
|
||||
func TestIntakeAPIWithoutBusIsTheBareAPI(t *testing.T) {
|
||||
// The adoption invariant: with the journal off there is not even a
|
||||
// decorator on the intake path, so production behaves exactly as before.
|
||||
st := newTestStore(t)
|
||||
bare := ipc.NewStoreAPI(st)
|
||||
if got := newIntakeAPI(bare, nil, intakeClock); got != ipc.CoreAPI(bare) {
|
||||
t.Errorf("newIntakeAPI with a nil bus returned a wrapper, want the bare API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventBusOffWhenNegative(t *testing.T) {
|
||||
if b := newEventBus(&config.Config{IntakeJournal: -1}); b != nil {
|
||||
t.Error("intake_journal = -1 still built a bus")
|
||||
}
|
||||
if b := newEventBus(&config.Config{IntakeJournal: 4}); b == nil {
|
||||
t.Error("intake_journal = 4 built no bus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsAFactWrite(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
// The ambient path's shape: an env fact below full confidence, timestamped
|
||||
// at the meeting's start rather than at notice time.
|
||||
start := intakeNow.Add(2 * time.Hour)
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: start, Kind: "env", Key: "calendar_event_20260801_планёрка",
|
||||
Value: "10:00-11:00 планёрка", Source: "ambient:notif", Confidence: 0.6,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
got := bus.Recent(0)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||
}
|
||||
e := got[0]
|
||||
if e.Source != "ambient:notif" || e.Kind != event.KindFact {
|
||||
t.Errorf("source/kind = %q/%q", e.Source, e.Kind)
|
||||
}
|
||||
if e.Title != "calendar_event_20260801_планёрка" {
|
||||
t.Errorf("title = %q, want the fact key", e.Title)
|
||||
}
|
||||
if !e.OccurredAt.Equal(start) {
|
||||
t.Errorf("occurred_at = %v, want the fact's Ts %v — the journal must not flatten intake to notice time", e.OccurredAt, start)
|
||||
}
|
||||
if e.Priority != event.PriorityLow {
|
||||
t.Errorf("priority = %q, want %q for a sub-1.0 confidence read", e.Priority, event.PriorityLow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeDoesNotJournalAFailedWrite(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
bus := event.NewBus(8)
|
||||
api := newIntakeAPI(&failingAPI{CoreAPI: ipc.NewStoreAPI(st), fail: true}, bus, intakeClock)
|
||||
if _, err := api.WriteFact(context.Background(), ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "k", Value: "v", Source: "poll:zenmoney", Confidence: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("expected the injected error")
|
||||
}
|
||||
if bus.Len() != 0 {
|
||||
t.Errorf("journal has %d entries after a failed write, want 0 — an event reports something that happened", bus.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsANoteAsTitlePlusBody(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
// The RSS shape: "headline\nsummary\nlink".
|
||||
if _, err := api.WriteNote(context.Background(), intakeNow,
|
||||
"Вышло ядро 6.19\nкраткое содержание\nhttps://example.org/a", nil, "rss:tech"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
got := bus.Recent(1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||
}
|
||||
if got[0].Title != "Вышло ядро 6.19" {
|
||||
t.Errorf("title = %q, want the headline", got[0].Title)
|
||||
}
|
||||
if got[0].Kind != event.KindNote {
|
||||
t.Errorf("kind = %q, want %q", got[0].Kind, event.KindNote)
|
||||
}
|
||||
if got[0].Body == "" {
|
||||
t.Error("body is empty, want the rest of the note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsOnlyCreatedTasks(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
req := ipc.CaptureTaskReq{Text: "оплатить интернет", Source: "email:inbox", Status: "candidate", Ts: intakeNow}
|
||||
if _, err := api.CaptureTask(ctx, req); err != nil {
|
||||
t.Fatalf("CaptureTask: %v", err)
|
||||
}
|
||||
// Same text again: CaptureTask dedupes among live rows, and a re-read of a
|
||||
// mailbox must not refill the journal.
|
||||
resp, err := api.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureTask (repeat): %v", err)
|
||||
}
|
||||
if resp.Created {
|
||||
t.Fatal("store did not dedupe; the test cannot check what it means to")
|
||||
}
|
||||
if bus.Len() != 1 {
|
||||
t.Errorf("journal has %d entries, want 1 — a deduped capture must not publish", bus.Len())
|
||||
}
|
||||
if got := bus.Recent(1)[0]; got.Kind != event.KindTask || got.Title != "оплатить интернет" {
|
||||
t.Errorf("entry = %+v, want the captured task", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeEventsFnRendersNewestFirst(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
for _, key := range []string{"a", "b", "c"} {
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: key, Value: "1", Source: "poll:zenmoney", Confidence: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteFact %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
fn := intakeEventsFn(bus)
|
||||
got := fn(2)
|
||||
if len(got) != 2 || got[0].Title != "c" || got[1].Title != "b" {
|
||||
t.Errorf("intakeEventsFn(2) = %+v, want the two newest, newest first", got)
|
||||
}
|
||||
if intakeEventsFn(nil) != nil {
|
||||
t.Error("intakeEventsFn(nil) returned a closure, want nil so daemonAPI reports an empty journal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||
got, err := d.RecentEvents(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentEvents with no journal errored: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %d events, want none", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Maven's own bookkeeping is not intake. The feed watermark, the crawl hash,
|
||||
// the praxis trace of an act she performed and the quiet-hours toggle he
|
||||
// pressed all landed on a page headed "everything that arrived", and on a cold
|
||||
// start a handful of feeds could evict real intake behind their marks.
|
||||
func TestIntakeSkipsHerOwnBookkeeping(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
for _, req := range []ipc.WriteFactReq{
|
||||
{Ts: intakeNow, Kind: "config", Key: "rss:latest:tech", Value: "2026-08-01T09:00:00Z", Source: "poll:rss", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "config", Key: "crawl:hash:kernel", Value: "deadbeef", Source: "poll:crawl", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "config", Key: "quiet_hours", Value: "true", Source: "tap:voice", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "env", Key: "praxis:list_attention", Value: "ok", Source: "praxis:trace", Confidence: 1.0},
|
||||
} {
|
||||
if _, err := api.WriteFact(ctx, req); err != nil {
|
||||
t.Fatalf("WriteFact(%s): %v", req.Key, err)
|
||||
}
|
||||
}
|
||||
if n := bus.Len(); n != 0 {
|
||||
t.Fatalf("journalled %d bookkeeping writes, want 0: %+v", n, bus.Recent(0))
|
||||
}
|
||||
// A real arrival under the same decorator still lands.
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "spend_today", Value: "1200",
|
||||
Source: "poll:zenmoney", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bus.Len() != 1 {
|
||||
t.Fatalf("a real intake write was dropped: %+v", bus.Recent(0))
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence is the distinction between an inference and a credentialled read,
|
||||
// and the three-value priority bucket cannot carry it: unset and 1.0 land in
|
||||
// the same bucket, and 0.6 is gone entirely once mapped. Payload keeps it.
|
||||
func TestIntakeCarriesConfidenceAndFactKind(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "calendar_event_x", Value: "18:00 планёрка",
|
||||
Source: "ambient:notif", Confidence: 0.6,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "self", Key: "mood", Value: "ok", Source: "tap:web", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := bus.Recent(0)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("journal has %d entries, want 2", len(got))
|
||||
}
|
||||
var relayed, stated factDetail
|
||||
if err := json.Unmarshal(got[1].Payload, &relayed); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if relayed.Confidence == nil || *relayed.Confidence != 0.6 {
|
||||
t.Errorf("confidence = %v, want 0.6 recoverable from the payload", relayed.Confidence)
|
||||
}
|
||||
if relayed.FactKind != "env" {
|
||||
t.Errorf("fact_kind = %q, want env", relayed.FactKind)
|
||||
}
|
||||
// Both writes land in PriorityNormal or PriorityLow buckets that cannot be
|
||||
// told apart from the outside; the payload is where the two numbers stay
|
||||
// distinguishable.
|
||||
if err := json.Unmarshal(got[0].Payload, &stated); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if stated.Confidence == nil || *stated.Confidence != 1.0 || stated.FactKind != "self" {
|
||||
t.Errorf("payload = %+v, want confidence 1.0 and fact_kind self", stated)
|
||||
}
|
||||
}
|
||||
|
||||
// A retraction is not an observation. It used to publish an envelope
|
||||
// indistinguishable from a fresh reading of the same key.
|
||||
func TestIntakeMarksARetraction(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
id, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "weight", Value: "82", Source: "tap:web", Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "weight", Value: "81", Source: "tap:web",
|
||||
Confidence: 1.0, VoidsID: &id,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := bus.Recent(1)[0]
|
||||
if e.Priority != event.PriorityLow {
|
||||
t.Errorf("priority = %q, want low for a correction", e.Priority)
|
||||
}
|
||||
if !strings.HasPrefix(e.Title, "отмена:") {
|
||||
t.Errorf("title = %q, want it marked as a retraction", e.Title)
|
||||
}
|
||||
var d factDetail
|
||||
if err := json.Unmarshal(e.Payload, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.VoidsID == nil || *d.VoidsID != id {
|
||||
t.Errorf("voids_id = %v, want %d", d.VoidsID, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
// Writing the wrapped-key blob (Vikunja #14).
|
||||
//
|
||||
// The blob is the only thing that opens the database on a cold-started box, so
|
||||
// the two rules here are about not losing it.
|
||||
//
|
||||
// # It is rewritten on every assertion, so the write must be atomic
|
||||
//
|
||||
// mavweb calls StoreEncryptionKey after every successful assertion, not only
|
||||
// after enrolment. os.WriteFile truncates in place: a power cut or an OOM kill
|
||||
// between the truncate and the write left a zero-length blob and no previous
|
||||
// contents, on the path of every routine step-up. Write to a temp file in the
|
||||
// same directory, fsync it, rename over the target, then fsync the directory.
|
||||
//
|
||||
// # Only one authenticator can hold the cold-start key
|
||||
//
|
||||
// A blob is wrapped under one credential's PRF output and nothing else opens
|
||||
// it. mavweb sends an empty allowCredentials list and the credential store
|
||||
// keeps more than one passkey, so an unconditional rewrite meant the last
|
||||
// authenticator to assert silently locked out every other one — including the
|
||||
// backup hardware key enrolled for exactly the cold-start case. So: a blob
|
||||
// that already opens under this secret and already wraps this key is left
|
||||
// alone, a v1 blob is upgraded in place, and a v2 blob belonging to a
|
||||
// different credential is refused rather than overwritten.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// errForeignBlob — the wrapped key on disk belongs to another credential.
|
||||
// Refusing is the point: overwriting would lock that authenticator out.
|
||||
var errForeignBlob = errors.New("wrapped key belongs to a different credential")
|
||||
|
||||
// wrapKeyToFile wraps key under secret and persists it at path, unless the
|
||||
// blob already there says not to. Reports whether it wrote anything.
|
||||
func wrapKeyToFile(path string, key, secret []byte) (wrote bool, err error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
plain, version, uerr := webauthn.UnwrapKey(existing, secret)
|
||||
switch {
|
||||
case uerr == nil && version == webauthn.BlobV2 && bytes.Equal(plain, key):
|
||||
// Already wrapped under this secret, around this key. The
|
||||
// common case on every assertion after the first.
|
||||
return false, nil
|
||||
case uerr != nil && version == webauthn.BlobV2:
|
||||
return false, fmt.Errorf("%w: %s does not open under this assertion's PRF output, so another passkey holds the cold-start key; delete it deliberately to re-wrap", errForeignBlob, path)
|
||||
}
|
||||
// A v1 blob (upgrade it), or a v2 blob wrapping a stale key under
|
||||
// this same secret (the key was rotated). Both are rewrites.
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
// First wrap.
|
||||
default:
|
||||
return false, fmt.Errorf("read wrapped key: %w", err)
|
||||
}
|
||||
|
||||
blob, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("wrap encryption key: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(path, blob, 0o600); err != nil {
|
||||
return false, fmt.Errorf("write wrapped key: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path so that a reader sees either the whole
|
||||
// new file or the whole old one, never a truncated blob.
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp) // no-op once the rename succeeded
|
||||
|
||||
if err := f.Chmod(perm); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return err
|
||||
}
|
||||
// The rename itself needs to reach the disk, or a crash can resurrect the
|
||||
// old directory entry pointing at a file that is gone.
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
func wrapPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "db_key.wrapped")
|
||||
}
|
||||
|
||||
// The first wrap writes a v2 blob that opens under the same secret.
|
||||
func TestWrapKeyToFileWritesAnOpenableBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{1}, 32)
|
||||
secret := bytes.Repeat([]byte{2}, 32)
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want a write", wrote, err)
|
||||
}
|
||||
blob, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read blob: %v", err)
|
||||
}
|
||||
plain, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil || version != webauthn.BlobV2 || !bytes.Equal(plain, key) {
|
||||
t.Fatalf("UnwrapKey = %x, %v, %v", plain, version, err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode = %v (%v), want 0600", fi.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A blob that already wraps this key under this secret is left alone. Without
|
||||
// this every assertion rewrote the one file that opens the database.
|
||||
func TestWrapKeyToFileSkipsAnIdenticalBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{3}, 32)
|
||||
secret := bytes.Repeat([]byte{4}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, key, secret); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("second wrap: %v", err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("rewrote a blob that already opens under this secret")
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Error("the blob changed on a no-op wrap")
|
||||
}
|
||||
}
|
||||
|
||||
// Two enrolled authenticators, two PRF secrets, one blob. The second must not
|
||||
// silently lock the first one out — the backup passkey enrolled for exactly
|
||||
// the cold-start case is the one thing that used to stop working.
|
||||
func TestWrapKeyToFileRefusesAnotherCredentialsBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{5}, 32)
|
||||
phone := bytes.Repeat([]byte{6}, 32)
|
||||
yubikey := bytes.Repeat([]byte{7}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, key, phone); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
before, _ := os.ReadFile(path)
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, yubikey)
|
||||
if !errors.Is(err, errForeignBlob) {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want errForeignBlob", wrote, err)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("the second authenticator overwrote the first one's blob")
|
||||
}
|
||||
if _, _, err := webauthn.UnwrapKey(after, phone); err != nil {
|
||||
t.Fatalf("the first authenticator can no longer open the blob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A v1 blob is the pre-#14 format. It is upgraded in place rather than
|
||||
// refused, because that is the only way off a format that protects nothing.
|
||||
func TestWrapKeyToFileUpgradesALegacyBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{8}, 32)
|
||||
secret := bytes.Repeat([]byte{9}, 32)
|
||||
|
||||
// A v1 blob is a v2 blob with the magic stripped and the v1 info string;
|
||||
// the package writes no v1, so build one the only way a test can: wrap
|
||||
// v2 under a public key, then hand the file a body with no magic. What
|
||||
// matters here is only that UnwrapKey classifies it as v1.
|
||||
v2, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
legacy := v2[7:] // drop the magic
|
||||
if err := os.WriteFile(path, legacy, 0o600); err != nil {
|
||||
t.Fatalf("write legacy blob: %v", err)
|
||||
}
|
||||
if _, version, _ := webauthn.UnwrapKey(legacy, secret); version != webauthn.BlobV1 {
|
||||
t.Fatalf("fixture is not read as v1 (got %v)", version)
|
||||
}
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want the legacy blob upgraded", wrote, err)
|
||||
}
|
||||
blob, _ := os.ReadFile(path)
|
||||
if _, version, err := webauthn.UnwrapKey(blob, secret); err != nil || version != webauthn.BlobV2 {
|
||||
t.Fatalf("after upgrade: version %v, err %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A rotated at-rest key under the same credential is a rewrite, not a no-op.
|
||||
func TestWrapKeyToFileRewritesARotatedKey(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
secret := bytes.Repeat([]byte{10}, 32)
|
||||
old := bytes.Repeat([]byte{11}, 32)
|
||||
fresh := bytes.Repeat([]byte{12}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, old, secret); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
wrote, err := wrapKeyToFile(path, fresh, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want the rotated key written", wrote, err)
|
||||
}
|
||||
blob, _ := os.ReadFile(path)
|
||||
plain, _, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil || !bytes.Equal(plain, fresh) {
|
||||
t.Fatalf("blob still wraps the old key (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The write never truncates the target in place, so a crash mid-write cannot
|
||||
// leave a zero-length blob where the only copy of the wrapped key was.
|
||||
func TestWriteFileAtomicLeavesNoTempFilesAndReplacesWhole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "db_key.wrapped")
|
||||
|
||||
if err := os.WriteFile(path, bytes.Repeat([]byte{0xaa}, 67), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
// Hold the old inode. A rename gives it a new one; a truncating write
|
||||
// would keep it.
|
||||
oldInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
||||
want := bytes.Repeat([]byte{0xbb}, 67)
|
||||
if err := writeFileAtomic(path, want, 0o600); err != nil {
|
||||
t.Fatalf("writeFileAtomic: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("content = %x (%v)", got, err)
|
||||
}
|
||||
newInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if os.SameFile(oldInfo, newInfo) {
|
||||
t.Error("the target was written in place, not renamed over")
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("directory holds %d entries, want just the blob (a temp file leaked)", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/kiwix"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// kiwixWiring — the offline encyclopedia, assembled. nil ⇒ off, which is the
|
||||
// default: the query chain simply has no ZIM source.
|
||||
//
|
||||
// The rewriter is separately optional. Searching without one is legal and
|
||||
// mostly useless against English ZIMs, but it is the honest degraded mode when
|
||||
// there is no llama-server to rewrite with, and it is what `rewrite: false`
|
||||
// asks for.
|
||||
type kiwixWiring struct {
|
||||
client *kiwix.Client
|
||||
rewriter *kiwix.Rewriter // nil ⇒ the question is searched verbatim
|
||||
book string
|
||||
max int
|
||||
runes int
|
||||
}
|
||||
|
||||
// wireKiwix builds the ZIM reader from the `kiwix` block, or returns nil when
|
||||
// there is none. config.Normalise has already dropped a block with no URL and
|
||||
// filled the two size defaults, so this does no validation of its own.
|
||||
//
|
||||
// The llm client is the phraser's swap-aware one (llmClientFor), so a model
|
||||
// swap re-points the rewriter with everything else. A nil client means there is
|
||||
// no resident model at all; that degrades the rewriter, not the source.
|
||||
func wireKiwix(cfg *config.Config, c *llm.Client) *kiwixWiring {
|
||||
if cfg.Kiwix == nil {
|
||||
return nil
|
||||
}
|
||||
kc := cfg.Kiwix
|
||||
w := &kiwixWiring{
|
||||
client: kiwix.New(kc.URL),
|
||||
book: kc.Book,
|
||||
max: kc.MaxResults,
|
||||
runes: kc.SnippetRunes,
|
||||
}
|
||||
switch {
|
||||
case !kc.RewriteEnabled():
|
||||
log.Printf("voice: kiwix at %s (book %q, query rewriting off by config)", kc.URL, kc.Book)
|
||||
case c == nil:
|
||||
log.Printf("voice: kiwix at %s (book %q, no llama-server: searching questions verbatim)", kc.URL, kc.Book)
|
||||
default:
|
||||
w.rewriter = kiwix.NewRewriter(c)
|
||||
log.Printf("voice: kiwix at %s (book %q)", kc.URL, kc.Book)
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/kiwix"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// searchRSS is what kiwix-serve answers a /search with, trimmed to the fields
|
||||
// ParseSearchRSS reads.
|
||||
func searchRSS(items ...string) string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel>` +
|
||||
strings.Join(items, "") + `</channel></rss>`
|
||||
}
|
||||
|
||||
func rssItem(title, snippet string) string {
|
||||
return "<item><title>" + title + "</title><link>/x</link><description>" + snippet + "</description></item>"
|
||||
}
|
||||
|
||||
// stubKiwixServer answers every search with the given body and records the
|
||||
// pattern it was asked for, so a test can assert on what left the process.
|
||||
type stubKiwixServer struct {
|
||||
*httptest.Server
|
||||
lastPattern string
|
||||
lastBook string
|
||||
}
|
||||
|
||||
func newStubKiwix(t *testing.T, body string, status int) *stubKiwixServer {
|
||||
t.Helper()
|
||||
s := &stubKiwixServer{}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if status != 0 && status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
// Two endpoints on one server: /search answers the RSS, everything else
|
||||
// is an article read. Only the search is recorded — an article fetch
|
||||
// carries no query string and would blank the assertions.
|
||||
if r.URL.Path != "/search" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte("<html><title>Article</title><body><p>the lead paragraph</p></body></html>"))
|
||||
return
|
||||
}
|
||||
s.lastPattern = r.URL.Query().Get("pattern")
|
||||
s.lastBook = r.URL.Query().Get("books.name")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// buildKiwixHandler wires the source with no rewriter: the question is searched
|
||||
// verbatim, which keeps the assertion about what was sent unambiguous.
|
||||
func buildKiwixHandler(base string) *reactiveHandler {
|
||||
return &reactiveHandler{
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
kiwix: &kiwixWiring{
|
||||
client: kiwix.New(base),
|
||||
book: "wikipedia_en_all_maxi",
|
||||
max: config.DefaultKiwixResults,
|
||||
runes: config.DefaultKiwixSnippetRunes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func askKiwix(h *reactiveHandler, q string) (string, bool) {
|
||||
return h.queryKiwix(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
}
|
||||
|
||||
// The default daemon has no `kiwix` block, and a source that is off must not
|
||||
// claim the turn — the model answers next, exactly as it did before.
|
||||
func TestQueryKiwixOffPassesThrough(t *testing.T) {
|
||||
h := &reactiveHandler{replier: voice.NewStubReplier(), phraser: phraser.NewStub()}
|
||||
if reply, ok := askKiwix(h, "почему небо синее?"); ok {
|
||||
t.Errorf("an unconfigured kiwix claimed the turn: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryKiwixAnswersFromSnippets(t *testing.T) {
|
||||
s := newStubKiwix(t, searchRSS(rssItem("Rayleigh scattering", "shorter wavelengths scatter more")), 0)
|
||||
h := buildKiwixHandler(s.URL)
|
||||
|
||||
reply, ok := askKiwix(h, "почему небо синее?")
|
||||
if !ok {
|
||||
t.Fatal("kiwix found a hit and did not claim the turn")
|
||||
}
|
||||
if reply == "" {
|
||||
t.Error("claimed the turn with an empty reply")
|
||||
}
|
||||
if s.lastBook != "wikipedia_en_all_maxi" {
|
||||
t.Errorf("books.name = %q, want the configured book", s.lastBook)
|
||||
}
|
||||
}
|
||||
|
||||
// No hit is not a failure worth announcing: the ZIM does not cover it, and the
|
||||
// model answering next beats "ничего не нашла".
|
||||
func TestQueryKiwixNoHitsPassesThrough(t *testing.T) {
|
||||
s := newStubKiwix(t, searchRSS(), 0)
|
||||
if reply, ok := askKiwix(buildKiwixHandler(s.URL), "почему небо синее?"); ok {
|
||||
t.Errorf("an empty result set claimed the turn: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A dead or misconfigured server must degrade to the model, not to an error
|
||||
// spoken out loud. A turn never breaks on a capability.
|
||||
func TestQueryKiwixServerErrorPassesThrough(t *testing.T) {
|
||||
s := newStubKiwix(t, "", http.StatusBadRequest)
|
||||
if reply, ok := askKiwix(buildKiwixHandler(s.URL), "почему небо синее?"); ok {
|
||||
t.Errorf("a 400 claimed the turn: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The privacy rule in CLAUDE.md, asserted rather than assumed: only the
|
||||
// utterance is searched. No note, no fact, no persona block travels with it.
|
||||
func TestQueryKiwixSendsOnlyTheQuestion(t *testing.T) {
|
||||
s := newStubKiwix(t, searchRSS(rssItem("X", "y")), 0)
|
||||
h := buildKiwixHandler(s.URL)
|
||||
// A turn carrying notes an earlier source already pulled. They must not
|
||||
// reach the query string.
|
||||
_, _ = h.queryKiwix(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "почему небо синее?"},
|
||||
notes: []ipc.Note{{Text: "пароль от роутера hunter2"}},
|
||||
})
|
||||
if strings.Contains(s.lastPattern, "hunter2") {
|
||||
t.Fatalf("a stored note leaked into the search query: %q", s.lastPattern)
|
||||
}
|
||||
if s.lastPattern != "почему небо синее?" {
|
||||
t.Errorf("pattern = %q, want the utterance verbatim", s.lastPattern)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireKiwixOffWithoutABlock(t *testing.T) {
|
||||
if w := wireKiwix(&config.Config{}, nil); w != nil {
|
||||
t.Error("wireKiwix built a source with no config block")
|
||||
}
|
||||
}
|
||||
|
||||
// No llama-server means no rewriter, but the source still works: searching the
|
||||
// question verbatim is the honest degraded mode, not a reason to stay dark.
|
||||
func TestWireKiwixWithoutAnLLMHasNoRewriter(t *testing.T) {
|
||||
w := wireKiwix(&config.Config{Kiwix: &config.KiwixConfig{
|
||||
URL: "http://kiwix:8080", Book: "b", MaxResults: 5, SnippetRunes: 1500,
|
||||
}}, nil)
|
||||
if w == nil {
|
||||
t.Fatal("wireKiwix returned nil for a configured block")
|
||||
}
|
||||
if w.rewriter != nil {
|
||||
t.Error("built a rewriter with no llm client")
|
||||
}
|
||||
if w.book != "b" {
|
||||
t.Errorf("book = %q", w.book)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of reading the article: kiwix's own snippet is usually the
|
||||
// navigation box at the foot of the page, so the lead paragraph must be what
|
||||
// reaches the phraser.
|
||||
func TestQueryKiwixReadsTheArticleNotTheSnippet(t *testing.T) {
|
||||
junk := "Ecological economics Ecological footprint Ecological forecasting"
|
||||
s := newStubKiwix(t, searchRSS(rssItem("Photosynthesis", junk)), 0)
|
||||
h := buildKiwixHandler(s.URL)
|
||||
h.phraser = nil // no phraser ⇒ the fallback reads back what it was given
|
||||
|
||||
reply, ok := askKiwix(h, "что такое фотосинтез?")
|
||||
if !ok {
|
||||
t.Fatal("did not claim the turn")
|
||||
}
|
||||
if !strings.Contains(reply, "the lead paragraph") {
|
||||
t.Errorf("reply did not come from the article: %q", reply)
|
||||
}
|
||||
if strings.Contains(reply, "Ecological economics") {
|
||||
t.Errorf("recited the navigation-box snippet: %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// mavend/mail.go — core's half of the email reader (Vikunja #246,
|
||||
// docs/plans/01-email-reader.md).
|
||||
//
|
||||
// The split: cmd/mavmaild holds the IMAP credential, connects to the mailbox
|
||||
// and converts messages to plaintext; it hands each message to core over
|
||||
// ipc.MethodIngestMail. Core runs the extraction on the resident model —
|
||||
// llama-server lives in this process, spawned by the phraser — and writes what
|
||||
// comes back through the one task intake seam.
|
||||
//
|
||||
// What this file may produce is exactly one thing: rows in `tasks` with status
|
||||
// "candidate". No fact, no reminder, no note, no nudge, no calendar event. A
|
||||
// 1.7B misreading a mail can therefore put a wrong line on a review page and
|
||||
// nothing else; it can never make Maven speak, and it can never make her
|
||||
// recite something out of an advert as true.
|
||||
//
|
||||
// Off unless configured twice over: no `email` block in mavend.json ⇒ the IPC
|
||||
// method does not exist; no llama-server phraser ⇒ same. A reader pointed at a
|
||||
// core that is not set up for mail gets ErrUnknownMethod rather than silence.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// evidenceMaxChars — how much of the subject line is kept as a candidate's
|
||||
// evidence. Enough to recognise the mail on /tasks, not enough to turn the task
|
||||
// list into a copy of his mailbox.
|
||||
const evidenceMaxChars = 160
|
||||
|
||||
// captureTimeout — how long the capture writes get, separately from the
|
||||
// extraction budget. A candidate the model already produced must not be lost
|
||||
// because the model was slow.
|
||||
const captureTimeout = 30 * time.Second
|
||||
|
||||
// maxMailboxChars — a mailbox name is an IMAP folder, not free text. It ends up
|
||||
// in the provenance string, which is a small controlled vocabulary.
|
||||
const maxMailboxChars = 64
|
||||
|
||||
// validMailbox checks the name this method is willing to write provenance for.
|
||||
// Empty is refused: "email:" is not a source. So is anything with a control
|
||||
// character or a space-only value, so the source string stays greppable and
|
||||
// stays one token.
|
||||
func validMailbox(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("mail intake: mailbox is required")
|
||||
}
|
||||
if len([]rune(s)) > maxMailboxChars {
|
||||
return "", fmt.Errorf("mail intake: mailbox name too long")
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
|
||||
return "", fmt.Errorf("mail intake: mailbox name has whitespace or a control character")
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// mailIntake — extraction + capture for one message at a time.
|
||||
type mailIntake struct {
|
||||
st *store.Store
|
||||
ex *email.Extractor
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
// bus — the unified intake journal (Vikunja #283). This path captures
|
||||
// through the store directly rather than through ipc.CoreAPI, so the
|
||||
// decorator in intake.go does not see it and the publish is explicit here.
|
||||
// nil is a working no-op.
|
||||
bus *event.Bus
|
||||
}
|
||||
|
||||
// newMailIntake returns nil when mail ingestion must not be available, which is
|
||||
// the default. Both preconditions are real:
|
||||
//
|
||||
// - no cfg.Email ⇒ not configured, and a capability is off unless configured;
|
||||
// - no llama-server phraser ⇒ nothing to extract with. There is deliberately
|
||||
// no keyword fallback: "the subject line became a task" is not extraction,
|
||||
// it is a mailbox rendered as a to-do list, and it would fill the review
|
||||
// page faster than he could clear it.
|
||||
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) *mailIntake {
|
||||
if cfg.Email == nil {
|
||||
return nil
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
// The phraser is not an *LLMPhraser. Today that means there is no
|
||||
// llama-server; if anything ever WRAPS the phraser it will mean that
|
||||
// instead, so the line names the assertion rather than guessing why.
|
||||
log.Printf("mail intake: configured but the phraser is not an *phraser.LLMPhraser (%T) — mail ingestion disabled", phr)
|
||||
return nil
|
||||
}
|
||||
timeout := time.Duration(cfg.Email.Timeout)
|
||||
if timeout <= 0 {
|
||||
timeout = config.DefaultEmailTimeout
|
||||
}
|
||||
// Background client: extraction is a job nobody is waiting on, and it shares
|
||||
// one llama-server slot with the voice turn. Through the gate it yields to
|
||||
// anything he is waiting for and only one extraction runs at a time, so a
|
||||
// first poll of 25 unseen messages cannot queue 25 model calls in front of
|
||||
// him. See llm.Gate.
|
||||
ex := email.NewExtractor(llmBackgroundClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||
// The NORMALISED bound, not the configured one: with "email": {} in
|
||||
// mavend.json the configured value is 0 and the daemon allows three.
|
||||
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", ex.Max(), timeout)
|
||||
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now, bus: bus}
|
||||
}
|
||||
|
||||
// ingest handles one ipc.MethodIngestMail call.
|
||||
//
|
||||
// Junk and empty messages are answered Skipped without touching the model — the
|
||||
// reader's header filter is what keeps the resident model off newsletters.
|
||||
//
|
||||
// Every candidate is captured with Status "candidate", Source "email:<mailbox>"
|
||||
// and the subject as Evidence, under an ExternalID naming the message and the
|
||||
// span it was extracted from. That key is unique over every row whatever its
|
||||
// status, so a mailbox re-read after a restart produces Created=0 — and, more
|
||||
// to the point, a task he already marked done is not re-proposed the next time
|
||||
// the same unread message is read again.
|
||||
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||
// The mailbox name becomes provenance ("email:INBOX"), and the source
|
||||
// vocabulary is what the loop's rules trust. An empty name gave "email:" and
|
||||
// an arbitrary string gave an arbitrary source under that namespace.
|
||||
mailbox, err := validMailbox(req.Mailbox)
|
||||
if err != nil {
|
||||
return ipc.IngestMailResp{}, err
|
||||
}
|
||||
msg := email.Message{
|
||||
UID: req.UID,
|
||||
From: req.From,
|
||||
Subject: req.Subject,
|
||||
Date: req.Date,
|
||||
Body: req.Body,
|
||||
Junk: req.Junk,
|
||||
}
|
||||
if msg.Junk || (msg.Subject == "" && msg.Body == "") {
|
||||
return ipc.IngestMailResp{Skipped: true}, nil
|
||||
}
|
||||
|
||||
// The timeout scopes the EXTRACTION and nothing else. It used to wrap the
|
||||
// capture writes too, so a model that answered at 119 seconds of a 120
|
||||
// second budget left the first CaptureTask one second and the third none:
|
||||
// the work was done, the answer was good, and it was dropped with a
|
||||
// deadline error. Config calls this a per-message extraction budget, and now
|
||||
// it is one.
|
||||
exCtx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
cands, err := m.ex.Extract(exCtx, msg)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// The error from internal/email never carries mail text; keep it that way
|
||||
// by not adding the subject here.
|
||||
return ipc.IngestMailResp{}, fmt.Errorf("mail intake: uid %d: %w", req.UID, err)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return ipc.IngestMailResp{}, nil
|
||||
}
|
||||
|
||||
// A fresh budget for the writes, derived from the caller's context rather
|
||||
// than from the extraction's. Encrypted-store writes are fast; what this
|
||||
// bounds is a stuck store, not the model.
|
||||
ctx, cancel = context.WithTimeout(ctx, captureTimeout)
|
||||
defer cancel()
|
||||
|
||||
source := email.SourcePrefix + mailbox
|
||||
evidence := truncateRunes(req.Subject, evidenceMaxChars)
|
||||
now := m.now()
|
||||
var resp ipc.IngestMailResp
|
||||
for _, c := range cands {
|
||||
t := store.Task{
|
||||
CreatedTs: now,
|
||||
Text: c.Text,
|
||||
Source: source,
|
||||
Evidence: evidence,
|
||||
// The one status this path may ever write. Anything Maven derived from
|
||||
// something she read is a suggestion until he confirms it on /tasks.
|
||||
Status: store.TaskCandidate,
|
||||
}
|
||||
t.ExternalID = mailExternalID(source, req.UID, c.Text)
|
||||
if due, ok := email.ParseDue(c.Due); ok {
|
||||
t.Due = &due
|
||||
}
|
||||
res, err := m.st.CaptureTask(ctx, t)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("mail intake: capture: %w", err)
|
||||
}
|
||||
resp.TaskIDs = append(resp.TaskIDs, res.ID)
|
||||
if res.Created {
|
||||
resp.Created++
|
||||
// Only a row that was actually created. CaptureTask dedupes on
|
||||
// normalised text among live rows, so a mailbox re-read after a
|
||||
// restart must not refill the journal with tasks already in it.
|
||||
m.bus.Publish(publishableTask(t, now), now)
|
||||
}
|
||||
}
|
||||
// Counts only: the log line names the mailbox and the UID, never the subject,
|
||||
// the sender or the task text. Reviewing a candidate is what /tasks is for.
|
||||
log.Printf("mail intake: %s uid %d → %d candidate(s), %d new", source, req.UID, len(cands), resp.Created)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// wireMailIntake installs the IPC hook, or leaves it nil so the method reports
|
||||
// ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey
|
||||
// unlock) so mail behaves the same either way.
|
||||
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) {
|
||||
mi := newMailIntake(st, phr, cfg, bus)
|
||||
if mi == nil {
|
||||
return
|
||||
}
|
||||
srv.IngestMailFn = mi.ingest
|
||||
}
|
||||
|
||||
// truncateRunes cuts a string to n runes, marking the cut.
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// mailExternalID names the message and the span a candidate was extracted
|
||||
// from. The mailbox and UID identify the message; the normalised text
|
||||
// identifies which of the candidates in it this is, so a message yielding two
|
||||
// tasks gets two keys and a re-read of it gets neither twice.
|
||||
//
|
||||
// UIDs are stable per mailbox, and a mailbox that renumbers (UIDVALIDITY
|
||||
// changing) re-proposes its tasks once, which is the safe direction.
|
||||
func mailExternalID(source string, uid uint32, text string) string {
|
||||
return fmt.Sprintf("%s#%d:%s", source, uid, store.NormalizeTaskText(text))
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// mailLLM — a canned extraction reply.
|
||||
type mailLLM struct {
|
||||
reply string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
|
||||
m.calls++
|
||||
return m.reply, nil
|
||||
}
|
||||
|
||||
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
fake := &mailLLM{reply: reply}
|
||||
return &mailIntake{
|
||||
st: st,
|
||||
ex: email.NewExtractor(fake, 0, nil),
|
||||
timeout: 5 * time.Second,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
||||
}, st, fake
|
||||
}
|
||||
|
||||
func ingestReq() ipc.IngestMailReq {
|
||||
return ipc.IngestMailReq{
|
||||
Mailbox: "INBOX", UID: 42,
|
||||
From: "billing@isp.example",
|
||||
Subject: "Счёт за интернет",
|
||||
Body: "Оплатите счёт до 5 августа.",
|
||||
}
|
||||
}
|
||||
|
||||
// The one property that matters: a mail-derived task is a candidate, attributed
|
||||
// to the mailbox, with the subject as reviewable evidence — and nothing else is
|
||||
// written.
|
||||
func TestIngestCapturesCandidates(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
|
||||
t.Fatalf("resp = %+v, want one created task", resp)
|
||||
}
|
||||
tasks, err := st.ListTasks(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
got := tasks[0]
|
||||
if got.Status != store.TaskCandidate {
|
||||
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
|
||||
}
|
||||
if got.Source != "email:INBOX" {
|
||||
t.Errorf("source = %q, want email:INBOX", got.Source)
|
||||
}
|
||||
if got.Evidence != "Счёт за интернет" {
|
||||
t.Errorf("evidence = %q, want the subject line", got.Evidence)
|
||||
}
|
||||
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||
t.Errorf("due = %v, want 2026-08-05", got.Due)
|
||||
}
|
||||
// Nothing else may have been written: no reminder, no fact.
|
||||
rem, err := st.ListReminders(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rem) != 0 {
|
||||
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
|
||||
// rows, and the intake relies on exactly that.
|
||||
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
|
||||
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
|
||||
t.Fatalf("first ingest: %v", err)
|
||||
}
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("second ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
|
||||
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
|
||||
}
|
||||
tasks, _ := st.ListTasks(context.Background(), "")
|
||||
if len(tasks) != 1 {
|
||||
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestJunkSkipsTheModel(t *testing.T) {
|
||||
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Junk = true
|
||||
resp, err := mi.ingest(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if !resp.Skipped || resp.Created != 0 {
|
||||
t.Errorf("resp = %+v, want skipped", resp)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("model called %d times for junk, want 0", fake.calls)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("junk produced %d tasks, want 0", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestEmptyMessageSkipped(t *testing.T) {
|
||||
mi, _, fake := newTestIntake(t, "[]")
|
||||
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
|
||||
if err != nil || !resp.Skipped {
|
||||
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestNoTasksWritesNothing(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, "[]")
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
|
||||
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("got %d tasks, want 0", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTruncatesEvidence(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Subject = strings.Repeat("щ", 400)
|
||||
if _, err := mi.ingest(context.Background(), req); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
tasks, _ := st.ListTasks(context.Background(), "")
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
|
||||
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
|
||||
// exist at all.
|
||||
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
if mi := newMailIntake(st, nil, &config.Config{}, nil); mi != nil {
|
||||
t.Error("no email block must mean no mail intake")
|
||||
}
|
||||
// Configured but with a non-LLM phraser: still off — there is no fallback
|
||||
// extraction, by design.
|
||||
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}, nil); mi != nil {
|
||||
t.Error("without a llama-server phraser there is nothing to extract with")
|
||||
}
|
||||
}
|
||||
|
||||
// The mailbox name becomes the provenance string, which is the vocabulary the
|
||||
// loop's rules trust. "email:" is not a source and neither is "email:anything
|
||||
// he could post at the socket".
|
||||
func TestIngestRejectsBadMailbox(t *testing.T) {
|
||||
for _, name := range []string{"", " ", "IN BOX", "IN\nBOX", "IN\x00BOX", strings.Repeat("щ", maxMailboxChars+1)} {
|
||||
mi, st, fake := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Mailbox = name
|
||||
if _, err := mi.ingest(context.Background(), req); err == nil {
|
||||
t.Errorf("mailbox %q was accepted", name)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("mailbox %q reached the model", name)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("mailbox %q wrote %d tasks", name, len(tasks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slowLLM burns most of the extraction budget before answering, the way a
|
||||
// Thinking 1.7B does on a long mail.
|
||||
type slowLLM struct {
|
||||
reply string
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (s *slowLLM) Complete(ctx context.Context, _ llm.Req) (string, error) {
|
||||
select {
|
||||
case <-time.After(s.delay):
|
||||
return s.reply, nil
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// The extraction budget must not also bound the writes. It used to be one
|
||||
// context, so a model answering near the deadline lost the candidates it had
|
||||
// just produced.
|
||||
func TestIngestCapturesAfterASlowExtraction(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
mi := &mailIntake{
|
||||
st: st,
|
||||
ex: email.NewExtractor(&slowLLM{reply: `[{"text":"оплатить счёт","due":""}]`, delay: 90 * time.Millisecond}, 0, nil),
|
||||
timeout: 100 * time.Millisecond,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 1 {
|
||||
t.Fatalf("resp = %+v, want the candidate captured", resp)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 1 {
|
||||
t.Errorf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
+306
-36
@@ -25,7 +25,7 @@
|
||||
// When a passkey credential is enrolled AND no env key is set, the daemon
|
||||
// starts in LOCKED mode: the IPC server runs but rejects all store methods
|
||||
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
|
||||
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
|
||||
// by MethodUnlock (with that credential's WebAuthn PRF output) unwraps the at-rest
|
||||
// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens
|
||||
// the encrypted store. After unlock, the daemon wires voice, loop, and
|
||||
// delivery and runs normally.
|
||||
@@ -34,10 +34,13 @@
|
||||
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
|
||||
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
|
||||
// persist the wrapped blob — enabling cold-start unlock on the next boot
|
||||
// after the env key is removed.
|
||||
// after the env key is removed. That write happens once, when no blob
|
||||
// exists; replacing an existing one takes an explicit request, see
|
||||
// cmd/mavend/keyfile.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -48,6 +51,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -66,12 +70,19 @@ import (
|
||||
|
||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode.
|
||||
// In locked mode, all CoreAPI methods return errLocked. The unlock path
|
||||
// replaces the CoreAPI with the real store adapter and flips the flag.
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||
// owns the store handle the unlock path creates.
|
||||
//
|
||||
// The store matters here because of who runs when. In locked mode there is no
|
||||
// store at boot; one is opened inside UnlockFn, on an IPC goroutine, minutes
|
||||
// or days later. Shutdown runs on the main goroutine. Without a handoff the
|
||||
// main goroutine has nothing to close, and store.Close is what re-encrypts
|
||||
// the tmpfs working copy back over the ciphertext file — so a daemon that
|
||||
// cold-started lost every write of that session, silently, on the next boot.
|
||||
type daemonLock struct {
|
||||
mu sync.Mutex
|
||||
locked bool
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
func newDaemonLock(locked bool) *daemonLock {
|
||||
@@ -84,10 +95,25 @@ func (l *daemonLock) isLocked() bool {
|
||||
return l.locked
|
||||
}
|
||||
|
||||
func (l *daemonLock) unlock() {
|
||||
// unlock flips the flag and takes ownership of the store opened by UnlockFn.
|
||||
func (l *daemonLock) unlock(st *store.Store) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.locked = false
|
||||
l.st = st
|
||||
}
|
||||
|
||||
// closeStore seals the store the unlock path opened, if any. Safe to call
|
||||
// when the daemon never unlocked, and safe to call twice.
|
||||
func (l *daemonLock) closeStore() error {
|
||||
l.mu.Lock()
|
||||
st := l.st
|
||||
l.st = nil
|
||||
l.mu.Unlock()
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return st.Close()
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -100,7 +126,7 @@ func main() {
|
||||
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)")
|
||||
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)")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
@@ -142,11 +168,28 @@ func run(args []string) error {
|
||||
var st *store.Store
|
||||
var envKeyBytes []byte // kept for WrapKeyFn (enrollment wraps this key)
|
||||
|
||||
// dbKey — the plaintext at-rest key, once the daemon has one. Set at boot
|
||||
// in env-key mode and inside UnlockFn after a cold start. WrapKeyFn reads
|
||||
// it from an IPC goroutine, hence the atomic: srv's function fields are
|
||||
// installed before Serve and must not be reassigned afterwards.
|
||||
var dbKey atomic.Pointer[[]byte]
|
||||
|
||||
// wrappedPath resolves the blob location the same way for both the read
|
||||
// at boot and every write, so a default-path deployment cannot wrap to
|
||||
// one file and unwrap from another.
|
||||
wrappedPath := func() string {
|
||||
if *wrappedKeyPath != "" {
|
||||
return *wrappedKeyPath
|
||||
}
|
||||
return cfg.DefaultWrappedKeyPath()
|
||||
}
|
||||
|
||||
if !locked {
|
||||
// Normal boot: env key or plaintext (dev/CI)
|
||||
if envKey != nil {
|
||||
envKeyBytes = make([]byte, len(envKey))
|
||||
copy(envKeyBytes, envKey)
|
||||
dbKey.Store(&envKeyBytes)
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, envKey)
|
||||
} else {
|
||||
st, err = store.Open(ctx, cfg.DBPath)
|
||||
@@ -155,6 +198,14 @@ func run(args []string) error {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
} else {
|
||||
// Locked boot: the store does not exist yet. Seal whatever UnlockFn
|
||||
// opened, at shutdown, on this goroutine.
|
||||
defer func() {
|
||||
if err := dl.closeStore(); err != nil {
|
||||
log.Printf("mavend: seal store on shutdown: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
@@ -170,10 +221,22 @@ func run(args []string) error {
|
||||
eco *ecosystemWiring
|
||||
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)
|
||||
)
|
||||
|
||||
// The unified intake journal (Vikunja #283). Built before anything else
|
||||
// that holds a CoreAPI, because intakeAPI wraps that one interface and
|
||||
// every intake path in the daemon reaches its sink through it. nil (the
|
||||
// operator set intake_journal negative) means no decorator at all.
|
||||
evBus := newEventBus(cfg)
|
||||
// coreFor is what every in-process holder of a CoreAPI now takes, instead
|
||||
// of a bare ipc.NewStoreAPI(st). Identical behaviour plus one published
|
||||
// envelope per successful intake write.
|
||||
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||
|
||||
if !locked {
|
||||
rules = loop.DefaultRules()
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
@@ -188,6 +251,7 @@ func run(args []string) error {
|
||||
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),
|
||||
@@ -215,7 +279,7 @@ func run(args []string) error {
|
||||
eco = wireEcosystem(cfg)
|
||||
|
||||
// voice
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
||||
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -265,16 +329,25 @@ func run(args []string) error {
|
||||
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 = &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
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
|
||||
@@ -308,7 +381,11 @@ func run(args []string) error {
|
||||
if locked {
|
||||
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
|
||||
switch m {
|
||||
case ipc.MethodAssertStepUp, ipc.MethodUnlock:
|
||||
case ipc.MethodAssertStepUp, ipc.MethodUnlock, ipc.MethodPing:
|
||||
// Ping is allowed for the same reason the two unlock methods
|
||||
// are: it never reaches CoreAPI. It answers "she is up and
|
||||
// locked", which is what mavupdate needs to tell a daemon
|
||||
// waiting for a passkey apart from one that failed to start.
|
||||
return nil // allowed in locked mode
|
||||
default:
|
||||
return errLocked
|
||||
@@ -319,42 +396,115 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
||||
srv.LockedFn = dl.isLocked
|
||||
|
||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
||||
// persists the wrapped blob. Only wired when the daemon has the key in
|
||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
||||
if envKeyBytes != nil {
|
||||
srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error {
|
||||
blob, err := webauthn.WrapKey(envKeyBytes, publicKey)
|
||||
// wg is declared here rather than next to srv.Serve because the media
|
||||
// retention loop starts on this path too, and shutdown has to wait for a
|
||||
// prune in flight: it deletes files.
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
|
||||
// configured and there is a llama-server to extract with, in which case
|
||||
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, 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)
|
||||
// The meeting recorder (Vikunja #253) shares that blob store and its
|
||||
// retention loop. Off unless a capture block enables it, in which case
|
||||
// all four capture methods answer ErrUnknownMethod.
|
||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
}
|
||||
|
||||
// WrapKeyFn — wraps the at-rest key under the passkey PRF secret and
|
||||
// persists the wrapped blob. Called by mavweb after every assertion.
|
||||
//
|
||||
// It is wired in locked mode too, not only in env-key mode, and that is
|
||||
// what makes a v1 blob recoverable. A box enrolled before Vikunja #14
|
||||
// cold-starts through the legacy public-key retry in mavweb, and the
|
||||
// StoreEncryptionKey that follows rewrites the blob as v2. Without this
|
||||
// the only escape from a v1 blob was putting MAVEN_DB_KEY back in the
|
||||
// environment, which is the thing cold-start unlock exists to avoid.
|
||||
//
|
||||
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
|
||||
// an authenticator without PRF support produces no wrapped file at all
|
||||
// rather than a file that looks protected and is not.
|
||||
if envKeyBytes != nil || locked {
|
||||
srv.WrapKeyFn = func(ctx context.Context, secret []byte, explicit bool) error {
|
||||
kp := dbKey.Load()
|
||||
if kp == nil {
|
||||
return errors.New("wrap encryption key: the daemon is locked and has no key yet (unlock first)")
|
||||
}
|
||||
wp := wrappedPath()
|
||||
// Asserting a passkey is not a request to rewrite the cold-start
|
||||
// key. Without this an assertion carrying a substituted PRF value
|
||||
// re-wrapped the real database key under it, and a second
|
||||
// authenticator silently replaced the first one's blob.
|
||||
if !explicit {
|
||||
if _, err := os.Stat(wp); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("check wrapped key: %w", err)
|
||||
}
|
||||
}
|
||||
wrote, err := wrapKeyToFile(wp, *kp, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wrap encryption key: %w", err)
|
||||
return err
|
||||
}
|
||||
wp := *wrappedKeyPath
|
||||
if wp == "" {
|
||||
wp = cfg.DefaultWrappedKeyPath()
|
||||
if wrote {
|
||||
log.Printf("mavend: wrapped encryption key under this passkey's PRF output → %s", wp)
|
||||
}
|
||||
if err := os.WriteFile(wp, blob, 0o600); err != nil {
|
||||
return fmt.Errorf("write wrapped key: %w", err)
|
||||
}
|
||||
log.Printf("mavend: wrapped encryption key with passkey credential (%d bytes)", len(blob))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped
|
||||
// blob using the passkey credential public key, opens the store, wires all
|
||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the
|
||||
// wrapped blob using the passkey PRF secret, opens the store, wires all
|
||||
// daemon components, and replaces the locked API.
|
||||
if locked {
|
||||
srv.UnlockFn = func(ctx context.Context, publicKey []byte) error {
|
||||
wp := *wrappedKeyPath
|
||||
var unlockMu sync.Mutex
|
||||
srv.UnlockFn = func(ctx context.Context, secret []byte) error {
|
||||
// One unlock at a time, and never a second one. Without this a
|
||||
// concurrent pair of Unlock calls would each open a store and
|
||||
// wire a full daemon, and the loser's goroutines would run
|
||||
// against a store nobody closes.
|
||||
unlockMu.Lock()
|
||||
defer unlockMu.Unlock()
|
||||
if !dl.isLocked() {
|
||||
return nil // already unlocked; the caller does not need to know
|
||||
}
|
||||
|
||||
// Depth, not a boundary. MethodAssertStepUp is AuthRead, so
|
||||
// anything that can open the same-uid socket can flip the
|
||||
// session and reach MethodUnlock. What actually stops a local
|
||||
// attacker is the 32-byte PRF output they do not have, and that
|
||||
// was true before this check. What this check stops is an
|
||||
// accidental unlock attempt from an unrelated local caller.
|
||||
if !passkeySess.IsStepUp() {
|
||||
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||
}
|
||||
|
||||
wp := wrappedPath()
|
||||
blob, err := os.ReadFile(wp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read wrapped key: %w", err)
|
||||
}
|
||||
key, err := webauthn.UnwrapKey(blob, publicKey)
|
||||
key, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unwrap key: %w", err)
|
||||
}
|
||||
if version == webauthn.BlobV1 {
|
||||
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Use the \"rewrite cold-start key\" button on /auth/webauthn with a PRF-capable authenticator to replace it with a v2 blob.", wp, version)
|
||||
}
|
||||
// WrapKeyFn needs the key to be able to rewrite the blob later.
|
||||
keyCopy := bytes.Clone(key)
|
||||
dbKey.Store(&keyCopy)
|
||||
// Open the store with the unwrapped key.
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
||||
if err != nil {
|
||||
@@ -362,7 +512,7 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
// Wire everything.
|
||||
rules = loop.DefaultRules()
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
@@ -376,6 +526,7 @@ func run(args []string) error {
|
||||
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),
|
||||
@@ -400,7 +551,7 @@ func run(args []string) error {
|
||||
|
||||
eco = wireEcosystem(cfg)
|
||||
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
||||
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -444,19 +595,31 @@ func run(args []string) error {
|
||||
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 := &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
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)
|
||||
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
|
||||
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
@@ -488,13 +651,36 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
dl.unlock()
|
||||
// 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")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -533,6 +719,34 @@ func run(args []string) error {
|
||||
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()
|
||||
@@ -543,7 +757,15 @@ func run(args []string) error {
|
||||
if voiceW != nil {
|
||||
voiceW.close()
|
||||
}
|
||||
wg.Wait()
|
||||
// Bounded. Every worker below watches ctx, but one parked in a model call
|
||||
// or an HTTP fetch can outlast the supervisor's patience, and run() has to
|
||||
// return for `defer st.Close()` to seal the database. A worker abandoned
|
||||
// mid-tick loses one tick; a shutdown that never returns loses every write
|
||||
// since the last clean stop — which is how the deployed ciphertext went
|
||||
// eleven days stale in July 2026.
|
||||
if !waitWorkers(&wg, workerGrace) {
|
||||
log.Printf("mavend: workers still running after %s, sealing anyway", workerGrace)
|
||||
}
|
||||
log.Printf("mavend: bye")
|
||||
return nil
|
||||
}
|
||||
@@ -568,9 +790,57 @@ 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 {
|
||||
f := personaFacts(cfg)
|
||||
return func() string { return f.Block(now()) }
|
||||
}
|
||||
|
||||
// workerGrace — how long shutdown waits for the background workers before it
|
||||
// goes ahead and seals without them. Comfortably inside docker's ten-second
|
||||
// default so the seal still lands before SIGKILL.
|
||||
const workerGrace = 4 * time.Second
|
||||
|
||||
// waitWorkers waits on wg for at most d. Reports whether they all finished.
|
||||
func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-time.After(d):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// should find the answer in the boot log.
|
||||
func wireRules(cfg *config.Config) []loop.Rule {
|
||||
rules, dropped := loop.RulesExcept(cfg.DisabledRules)
|
||||
for _, name := range dropped {
|
||||
log.Printf("loop: rule %q disabled by config", name)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// mcpRefreshInterval — how often the manager is asked to re-dial servers that
|
||||
// are down. It is a tick, not a retry rate: mcp.Manager holds a per-server
|
||||
// backoff that starts at DefaultReconnectEvery and doubles to
|
||||
// MaxReconnectEvery, so a permanently misconfigured stdio server is not
|
||||
// re-exec'd once a minute forever.
|
||||
const mcpRefreshInterval = time.Minute
|
||||
|
||||
// mcpWiring — the MCP client, when the `mcp` block configures at least one
|
||||
// enabled server. nil ⇒ nothing was configured, nothing is connected, and an
|
||||
// allowlist row that happens to look like an MCP row refuses to run.
|
||||
//
|
||||
// It lives on the voice wiring because MCP tools ARE acts: they run through
|
||||
// tool.Executor, the enabled allowlist and the confirm turn, which only exist
|
||||
// on the voice/chat path. No voice surface ⇒ nothing that could call a tool.
|
||||
type mcpWiring struct {
|
||||
mgr *mcp.Manager
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
// wireMCP builds the manager. It does NOT dial: run does that, on its own
|
||||
// goroutine, which is what makes "Maven starting is not contingent on someone
|
||||
// else's process" true rather than merely intended.
|
||||
//
|
||||
// Dialing here used to be synchronous with a 30s budget, from wireVoice, from
|
||||
// run. Connect dials serially and each HTTP dial is three requests against
|
||||
// that server's timeout, so one black-holed endpoint cost 15s of boot and two
|
||||
// cost the whole budget. On the passkey path wireVoice runs inside the unlock
|
||||
// handler, so it delayed the answer to an unlock as well. Not failing and not
|
||||
// blocking are different properties and only the first one held.
|
||||
func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
|
||||
servers := cfg.MCPServers()
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
limits := webfetch.Config{}
|
||||
if cfg.MCP != nil {
|
||||
limits.AllowHosts = cfg.MCP.AllowHosts
|
||||
limits.DenyHosts = cfg.MCP.DenyHosts
|
||||
limits.MaxBytes = cfg.MCP.MaxBytes
|
||||
limits.Timeout = time.Duration(cfg.MCP.Timeout)
|
||||
limits.HostInterval = time.Duration(cfg.MCP.HostInterval)
|
||||
}
|
||||
mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers)
|
||||
if err != nil {
|
||||
// Validation already ran in config.validate, so this is a programming
|
||||
// error rather than a config one. Still not fatal: MCP off is a working
|
||||
// Maven.
|
||||
log.Printf("mcp: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &mcpWiring{mgr: mgr, st: st}
|
||||
}
|
||||
|
||||
// connect dials every server and reconciles what came back. Called from run,
|
||||
// under the daemon's context, so a shutdown during a slow dial is observed.
|
||||
func (w *mcpWiring) connect(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mgr.Connect(ctx)
|
||||
w.propose(ctx)
|
||||
}
|
||||
|
||||
// propose writes a 'proposed' allowlist row for every discovered tool, and
|
||||
// reconciles the rows that already exist against what the server offers today.
|
||||
// It does NOT enable anything: a configured server is a place Maven may look,
|
||||
// not a capability she has. Kami enables what he wants on /tools, behind
|
||||
// step-up, which is the same gate a shell tool goes through.
|
||||
//
|
||||
// Three things happen per discovered tool.
|
||||
//
|
||||
// A name not in the store becomes a proposal, carrying the tool's fingerprint.
|
||||
//
|
||||
// A name already in the store is reconciled against that fingerprint. A tool
|
||||
// whose description, schema or readOnlyHint changed since it was approved drops
|
||||
// back to 'proposed' and, if it stopped claiming read-only, to destructive=1.
|
||||
// Insert-or-skip was not enough on its own: the cmd is a late-bound reference
|
||||
// to a name the far end owns, so the server can redefine list_tasks into
|
||||
// something that writes without the row changing at all.
|
||||
//
|
||||
// A row whose server is connected and no longer offers the tool is withdrawn.
|
||||
func (w *mcpWiring) propose(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
fresh, changed := 0, 0
|
||||
seen := map[string]string{} // local name → "server/tool", for collisions
|
||||
for _, t := range w.mgr.Tools() {
|
||||
name := mcp.LocalName(t.Server, t.Name)
|
||||
remote := t.Server + "/" + t.Name
|
||||
// Two different tools can flatten to one local name: server "vik" with
|
||||
// tool "list_tasks" and server "vik_list" with tool "tasks" both give
|
||||
// "vik_list_tasks". The store keys rows by name, so the second would
|
||||
// land on the first one's row. Config-controlled and therefore rare,
|
||||
// but silently reusing a row is the wrong way to lose that race.
|
||||
if prev, dup := seen[name]; dup {
|
||||
log.Printf("mcp: %s and %s both map to the allowlist name %q — skipping the second, rename a server",
|
||||
prev, remote, name)
|
||||
continue
|
||||
}
|
||||
seen[name] = remote
|
||||
// No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong
|
||||
// in this direction only costs a question.
|
||||
destructive := !t.ReadOnly
|
||||
provenance := fmt.Sprintf("mcp %s/%s", t.Server, t.Name)
|
||||
if t.Description != "" {
|
||||
provenance += ": " + t.Description
|
||||
}
|
||||
fp := mcp.Fingerprint(t)
|
||||
ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server),
|
||||
mcp.Cmd(t.Server, t.Name), destructive, provenance, fp, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: propose %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
fresh++
|
||||
continue
|
||||
}
|
||||
// The row already existed. Its provenance is whatever the server said
|
||||
// the first time; reconciling rewrites it, so what /tools shows is what
|
||||
// the server says now.
|
||||
ch, err := w.st.ReconcileMCPTool(ctx, name, fp, destructive, provenance, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: reconcile %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if !ch.Changed {
|
||||
continue
|
||||
}
|
||||
changed++
|
||||
switch {
|
||||
case ch.Demoted && ch.Escalated:
|
||||
log.Printf("mcp: %s changed on the server and no longer claims read-only — disabled and marked destructive, re-approve it on /tools", name)
|
||||
case ch.Demoted:
|
||||
log.Printf("mcp: %s changed on the server since it was enabled — disabled, re-approve it on /tools", name)
|
||||
default:
|
||||
log.Printf("mcp: %s changed on the server; the proposal now shows the new description", name)
|
||||
}
|
||||
}
|
||||
w.withdrawGone(ctx, seen, now)
|
||||
if fresh > 0 {
|
||||
log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh)
|
||||
}
|
||||
if changed > 0 {
|
||||
log.Printf("mcp: %d tool(s) changed since approval and need another look", changed)
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawGone disarms rows whose tool the server stopped offering. Only
|
||||
// servers that are CONNECTED are considered: a tool missing because its server
|
||||
// is down is not a tool that was withdrawn, and disabling a capability every
|
||||
// time a process restarts would be worse than the problem.
|
||||
func (w *mcpWiring) withdrawGone(ctx context.Context, seen map[string]string, now time.Time) {
|
||||
live := map[string]bool{}
|
||||
for _, name := range w.mgr.Connected() {
|
||||
live[name] = true
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return
|
||||
}
|
||||
rows, err := w.st.ListTools(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("mcp: list tools: %v", err)
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
server, remote, ok := mcp.ParseCmd(row.Cmd)
|
||||
if !ok || !live[server] {
|
||||
continue
|
||||
}
|
||||
if _, still := seen[row.Name]; still {
|
||||
continue
|
||||
}
|
||||
note := fmt.Sprintf("mcp %s/%s: no longer offered by the server", server, remote)
|
||||
wasEnabled, err := w.st.WithdrawTool(ctx, row.Name, note, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: withdraw %s: %v", row.Name, err)
|
||||
continue
|
||||
}
|
||||
if wasEnabled {
|
||||
log.Printf("mcp: %s was enabled but %s no longer offers it — disabled", row.Name, server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run re-dials downed servers and picks up tools that appeared, until ctx is
|
||||
// canceled.
|
||||
func (w *mcpWiring) run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
// The first dial happens here rather than at wiring time, so boot never
|
||||
// waits on someone else's process.
|
||||
w.connect(ctx)
|
||||
t := time.NewTicker(mcpRefreshInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.mgr.Refresh(ctx)
|
||||
w.propose(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// status maps the manager's view onto the wire type the web surface reads.
|
||||
func (w *mcpWiring) status() []ipc.MCPServerStatus {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
in := w.mgr.Status()
|
||||
out := make([]ipc.MCPServerStatus, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, ipc.MCPServerStatus{
|
||||
Name: s.Name,
|
||||
Transport: s.Transport,
|
||||
Target: s.Target,
|
||||
Connected: s.Connected,
|
||||
Server: s.Server,
|
||||
Tools: s.Tools,
|
||||
Err: s.Err,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *mcpWiring) close() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
_ = w.mgr.Close()
|
||||
}
|
||||
|
||||
// caller is the tool.MCPCaller the executor gets, or nil when MCP is off.
|
||||
func (w *mcpWiring) caller() *mcp.Manager {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.mgr
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
func TestWireMCPOffWhenUnconfigured(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"nothing enabled": {MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{
|
||||
{Name: "vikunja", URL: "http://192.168.1.104:9100/mcp"},
|
||||
}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireMCP(cfg, st); w != nil {
|
||||
t.Fatal("MCP must be off unless a server is configured AND enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
// nil wiring must be safe to use everywhere it is reachable.
|
||||
var w *mcpWiring
|
||||
w.close()
|
||||
w.propose(context.Background())
|
||||
if w.status() != nil || w.caller() != nil {
|
||||
t.Fatal("a nil wiring must report nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Wiring must not dial. Boot used to block for the whole per-server timeout
|
||||
// budget on a black-holed endpoint, and on the passkey path that delay landed
|
||||
// inside the unlock handler.
|
||||
func TestWireMCPDoesNotDial(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured server should wire")
|
||||
}
|
||||
defer w.close()
|
||||
if s := w.status(); len(s) != 1 || s[0].Err != "" {
|
||||
t.Fatalf("wireMCP dialled: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable server must not stop the daemon, must be reported as down, and
|
||||
// must propose nothing.
|
||||
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured server should still wire")
|
||||
}
|
||||
defer w.close()
|
||||
w.connect(context.Background())
|
||||
st2 := w.status()
|
||||
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
|
||||
t.Fatalf("status = %+v", st2)
|
||||
}
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Fatalf("a server that never answered must propose nothing, got %+v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// A url server whose address is private is refused by webfetch unless that
|
||||
// server sets allow_private. This is the guard the whole MCP path rides on, so
|
||||
// it is asserted here too, at the wiring level.
|
||||
func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "lan", URL: "http://127.0.0.1:9100/mcp", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("should wire")
|
||||
}
|
||||
defer w.close()
|
||||
w.connect(context.Background())
|
||||
s := w.status()[0]
|
||||
if s.Connected {
|
||||
t.Fatal("a loopback server must not connect without allow_private")
|
||||
}
|
||||
if !strings.Contains(s.Err, "private address") {
|
||||
t.Fatalf("err = %q, want the private-address refusal", s.Err)
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,25 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/memeval"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// memoryEvalTimeout — the per-request deadline on one evaluation.
|
||||
//
|
||||
// It used to be five minutes, on the grounds that nobody waits for the answer.
|
||||
// Nobody waits for the evaluation, but there is ONE resident model behind one
|
||||
// llama-server, so a voice turn arriving mid-evaluation waited behind it: five
|
||||
// minutes of evaluation was five minutes of a mute assistant.
|
||||
//
|
||||
// The background client now yields the slot while a turn is in flight, so the
|
||||
// collision is solved where it belongs and this is a prompt budget again. Five
|
||||
// minutes is safe once more, and it is back: 60s truncated a Thinking model
|
||||
// mid-synthesis, which costs an observation for no latency saved. The gate, not
|
||||
// this number, is what keeps a voice turn from waiting.
|
||||
const memoryEvalTimeout = 5 * time.Minute
|
||||
|
||||
// memoryEvalWorker — ticker + evaluator.
|
||||
type memoryEvalWorker struct {
|
||||
eval *memeval.Evaluator
|
||||
@@ -48,9 +61,9 @@ func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Confi
|
||||
if interval <= 0 {
|
||||
interval = config.DefaultMemoryEvalInterval
|
||||
}
|
||||
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||
// and nobody is waiting on the answer.
|
||||
client := llm.New(lp.BaseURL(), 5*time.Minute)
|
||||
// Background: nobody is waiting on an observation, and it must not sit in
|
||||
// front of a voice turn on the single llama-server slot.
|
||||
client := llmBackgroundClientFor(lp, memoryEvalTimeout)
|
||||
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||
MaxItems: cfg.MemoryEval.MaxItems,
|
||||
MinConfidence: cfg.MemoryEval.MinConfidence,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// No `workstation` block is the shipping deploy. The seam must then be the
|
||||
// resident client itself, with nothing probing anything.
|
||||
func TestModelSeamUnconfiguredIsResidentOnly(t *testing.T) {
|
||||
resident := llm.New("http://127.0.0.1:1", time.Second)
|
||||
hot, pair := modelSeam(&config.Config{}, resident)
|
||||
if pair != nil {
|
||||
t.Error("built a pair with no workstation configured")
|
||||
}
|
||||
if hot == nil {
|
||||
t.Fatal("no seam at all, so the cascade would route with the classifier")
|
||||
}
|
||||
}
|
||||
|
||||
// A workstation with no resident model behind it has no floor, and a Pair with
|
||||
// no floor is a configuration mistake rather than a degraded mode.
|
||||
func TestModelSeamWithoutResidentIsNil(t *testing.T) {
|
||||
cfg := &config.Config{Workstation: &config.WorkstationConfig{URL: "http://127.0.0.1:1"}}
|
||||
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||
hot, pair := modelSeam(cfg, nil)
|
||||
if hot != nil || pair != nil {
|
||||
t.Errorf("built a seam with no floor: hot=%v pair=%v", hot, pair)
|
||||
}
|
||||
}
|
||||
|
||||
// The configured case: the seam is the pair, and the pair notices a workstation
|
||||
// that answers /health.
|
||||
func TestModelSeamPrefersAnAnsweringWorkstation(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
cfg := &config.Config{Workstation: &config.WorkstationConfig{
|
||||
URL: up.URL,
|
||||
Probe: config.Duration(10 * time.Millisecond),
|
||||
}}
|
||||
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||
|
||||
hot, pair := modelSeam(cfg, llm.New("http://127.0.0.1:1", time.Second))
|
||||
if pair == nil || hot == nil {
|
||||
t.Fatal("no pair built for a configured workstation")
|
||||
}
|
||||
defer pair.Stop()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !pair.Available() && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if !pair.Available() {
|
||||
t.Fatal("the pair never saw a workstation that answers /health")
|
||||
}
|
||||
}
|
||||
|
||||
// A card held by a CPT run answers 503, and that must read as unavailable
|
||||
// rather than as an error a turn has to handle.
|
||||
func TestModelSeamHeldCardIsUnavailable(t *testing.T) {
|
||||
busy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer busy.Close()
|
||||
|
||||
cfg := &config.Config{Workstation: &config.WorkstationConfig{
|
||||
URL: busy.URL,
|
||||
Probe: config.Duration(10 * time.Millisecond),
|
||||
}}
|
||||
cfg.Workstation.Health = strings.TrimRight(cfg.Workstation.URL, "/") + "/health"
|
||||
|
||||
_, pair := modelSeam(cfg, llm.New("http://127.0.0.1:1", time.Second))
|
||||
if pair == nil {
|
||||
t.Fatal("no pair built for a configured workstation")
|
||||
}
|
||||
defer pair.Stop()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if pair.Available() {
|
||||
t.Error("a 503 from the supervisor read as available")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// Swapping the resident model while the daemon runs (Vikunja #250).
|
||||
//
|
||||
// Off unless configured: with no phraser.swap_models allowlist the two IPC
|
||||
// methods are never wired, so they answer ErrUnknownMethod. When it is wired the
|
||||
// swap method is AuthStepUp (internal/auth), which means an authed human surface
|
||||
// only — there is no act, no intent and no timer that reaches it. The daemon
|
||||
// never decides to change its own brain.
|
||||
//
|
||||
// The allowlist is exact-match against paths a human wrote in mavend.json. The
|
||||
// request carries a path and llama-server is started with it as `-m`, so
|
||||
// anything looser would turn "swap the model" into "load any file on my disk".
|
||||
func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
|
||||
if cfg.Phraser == nil || len(cfg.Phraser.SwapModels) == 0 {
|
||||
return
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
log.Printf("model swap: phraser.swap_models is set but there is no llama-server phraser — swap disabled")
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, m := range cfg.Phraser.SwapModels {
|
||||
allowed[filepath.Clean(m)] = true
|
||||
}
|
||||
// The configured model is always swappable back to, listed or not: the way
|
||||
// out of a bad swap must not depend on remembering to allowlist the model
|
||||
// you are already running.
|
||||
allowed[filepath.Clean(cfg.Phraser.ModelPath)] = true
|
||||
|
||||
srv.SwapModelFn = func(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
||||
path := filepath.Clean(req.ModelPath)
|
||||
if !allowed[path] {
|
||||
log.Printf("model swap: REFUSED %q — not in phraser.swap_models", req.ModelPath)
|
||||
return ipc.SwapModelResp{}, fmt.Errorf("%w: %q is not in phraser.swap_models", ipc.ErrForbidden, req.ModelPath)
|
||||
}
|
||||
res, err := lp.Swap(ctx, phraser.SwapSpec{
|
||||
ModelPath: path,
|
||||
NGpuLayers: req.NGpuLayers,
|
||||
NCtx: req.NCtx,
|
||||
})
|
||||
resp := ipc.SwapModelResp{
|
||||
Model: res.Model,
|
||||
ModelPath: res.ModelPath,
|
||||
BaseURL: res.BaseURL,
|
||||
RolledBack: res.RolledBack,
|
||||
NoBackend: res.NoBackend,
|
||||
TookMs: res.Took.Milliseconds(),
|
||||
}
|
||||
if err != nil {
|
||||
// A rolled-back swap is a failure that left a working daemon behind.
|
||||
// Both halves matter to the caller, so the response is filled in even
|
||||
// though the error is returned.
|
||||
log.Printf("model swap: %v", err)
|
||||
return resp, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
srv.ModelStatusFn = func(ctx context.Context) (ipc.ModelStatusResp, error) {
|
||||
path, ngl, nctx := lp.LiveModel()
|
||||
base := lp.BaseURL()
|
||||
resp := ipc.ModelStatusResp{
|
||||
ModelPath: path,
|
||||
BaseURL: base,
|
||||
NGpuLayers: ngl,
|
||||
NCtx: nctx,
|
||||
Swappable: cfg.Phraser.SwapModels,
|
||||
}
|
||||
if base == "" {
|
||||
resp.Model = llm.UnknownModel
|
||||
return resp, nil
|
||||
}
|
||||
id, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
// Report the honest "I could not confirm it" rather than echoing the
|
||||
// configured filename as if the server had said it.
|
||||
resp.Model = llm.UnknownModel
|
||||
return resp, nil
|
||||
}
|
||||
resp.Model = id
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
log.Printf("model swap: enabled, %d allowlisted model(s) — step-up required", len(cfg.Phraser.SwapModels))
|
||||
}
|
||||
|
||||
// llmClientFor builds a completion client on the phraser's llama-server and
|
||||
// keeps it pointed at the right one across a model swap.
|
||||
//
|
||||
// Without the OnSwap registration every holder of a base URL — the LLM router,
|
||||
// the replier, the mail extractor, the memory evaluator — would keep talking to
|
||||
// the port of a server that no longer exists, and the daemon would degrade to
|
||||
// the classifier permanently after the first swap. The client is re-pointed, not
|
||||
// rebuilt, so nothing that holds it has to know a swap happened.
|
||||
// SetSwapGate is the other half, and on the deploy shape it is the load-bearing
|
||||
// one:
|
||||
// llama-server is relaunched on the same fixed port, so SetBaseURL is usually a
|
||||
// no-op, while the gate is what makes the swap's drain count these callers at
|
||||
// all. Without it a swap can kill the server mid-routing-decision.
|
||||
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||
c := llm.New(lp.BaseURL(), timeout)
|
||||
c.SetGate(residentGate, false)
|
||||
c.SetSwapGate(lp)
|
||||
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||
return c
|
||||
}
|
||||
|
||||
// backgroundQuiet — how long background work stays off the resident model after
|
||||
// a foreground request. Long enough to cover the gap between the router call and
|
||||
// the phraser call of one turn (router p50 is ~2.7s on this box), short enough
|
||||
// that a quiet mailbox is still read promptly.
|
||||
const backgroundQuiet = 10 * time.Second
|
||||
|
||||
// residentGate — the priority gate on the one llama-server slot, shared by every
|
||||
// client llmClientFor builds. Package level because the daemon owns exactly one
|
||||
// llama-server: two gates would be two opinions about one queue.
|
||||
//
|
||||
// The problem it solves: llama-server runs a single slot, so requests queue. Mail
|
||||
// extraction is allowed two minutes, and a first poll can hand core 25 messages
|
||||
// back to back. Without a gate a voice turn arriving mid-extraction waits for
|
||||
// whatever is left of that budget, the router times out into the classifier
|
||||
// cascade at its 36.8% floor, and the phraser just waits.
|
||||
var residentGate = llm.NewGate(backgroundQuiet)
|
||||
|
||||
// llmBackgroundClientFor is llmClientFor for work nobody is waiting on: mail
|
||||
// extraction and memory evaluation. Same swap-following client, but it yields
|
||||
// to voice turns and only one such request runs at a time.
|
||||
func llmBackgroundClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||
c := llm.New(lp.BaseURL(), timeout)
|
||||
c.SetGate(residentGate, true)
|
||||
c.SetSwapGate(lp)
|
||||
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/morning"
|
||||
)
|
||||
|
||||
// TestMorningNudgeBodySeparatesOptional — the one message a routine is allowed
|
||||
// per day says what was not done, then what he could still do (Vikunja #473).
|
||||
func TestMorningNudgeBodySeparatesOptional(t *testing.T) {
|
||||
cand := morning.Candidate{
|
||||
Routine: morning.Routine{Name: "утро"},
|
||||
Missing: []morning.Item{
|
||||
{Key: "meds", Label: "таблетки"},
|
||||
{Key: "stretch", Label: "растяжка", Optional: true},
|
||||
},
|
||||
}
|
||||
body := morningNudgeBody(cand)
|
||||
if !strings.Contains(body, "не сделано — таблетки") {
|
||||
t.Fatalf("the required item must be named as not done: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "если будет время — растяжка") {
|
||||
t.Fatalf("the optional item must read softer: %q", body)
|
||||
}
|
||||
if strings.Contains(body, "не сделано — таблетки, растяжка") {
|
||||
t.Fatalf("optional must not be folded into the required list: %q", body)
|
||||
}
|
||||
|
||||
// Nothing optional missing: the sentence is what it always was.
|
||||
only := morning.Candidate{
|
||||
Routine: morning.Routine{Name: "утро"},
|
||||
Missing: []morning.Item{{Key: "meds", Label: "таблетки"}},
|
||||
}
|
||||
if got, want := morningNudgeBody(only), "утро: не сделано — таблетки"; got != want {
|
||||
t.Fatalf("morningNudgeBody = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
)
|
||||
|
||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||
// longer than this has already failed as a turn, so the scan returns whatever
|
||||
// it found rather than keeping him waiting.
|
||||
//
|
||||
// It has to be consistent with the shipped defaults or every scan is truncated:
|
||||
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
|
||||
// second is a little over ten seconds plus the tail dials. 30s leaves room for
|
||||
// that without pretending a slower rate would fit.
|
||||
const scanBudget = 30 * time.Second
|
||||
|
||||
// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
|
||||
// to be two full sweeps of the LAN, up to a thousand connections each. The
|
||||
// network does not change on the scale of a follow-up question, and the cheapest
|
||||
// packet is the one not sent.
|
||||
const scanCacheTTL = 2 * time.Minute
|
||||
|
||||
// scanReadOut — how many hosts go into the written record's first lines before
|
||||
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
|
||||
const scanReadOut = 20
|
||||
|
||||
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
|
||||
// never puts a discovery packet on the network.
|
||||
//
|
||||
// Unlike the house, a scan is a READ, so it is a query source rather than an
|
||||
// act: there is no allowlist row and no confirm turn, because nothing changes.
|
||||
// What makes that safe is that the range is not an argument — see
|
||||
// internal/netscan's package comment.
|
||||
type netWiring struct {
|
||||
scanner *netscan.Scanner
|
||||
subnets []string
|
||||
// api — where the address list is WRITTEN. The spoken answer is a count
|
||||
// and a shape, so the detail has to land somewhere readable; a note under
|
||||
// source "scan:lan" puts it on /history and, through the intake decorator,
|
||||
// on /events. It is also the only record that Maven put packets on the LAN
|
||||
// at all. nil ⇒ nothing is written, which is what the tests use.
|
||||
api ipc.CoreAPI
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cached netscan.Result
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
|
||||
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring {
|
||||
nc, ok := cfg.NetScanner()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := netscan.Validate(nc); err != nil {
|
||||
// config.validate already ran this, so reaching here is a programming
|
||||
// error rather than a config one. Not fatal: the scanner off is a
|
||||
// working Maven.
|
||||
log.Printf("netscan: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now}
|
||||
}
|
||||
|
||||
// scan runs a scan, or reuses one younger than scanCacheTTL.
|
||||
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := w.now()
|
||||
if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
|
||||
return w.cached, nil
|
||||
}
|
||||
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
defer cancel()
|
||||
res, err := w.scanner.Scan(scanCtx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
w.cached, w.cachedAt = res, now
|
||||
// Written on a fresh scan only: the record is a trace of packets going out,
|
||||
// so a cached answer must not forge a second one.
|
||||
w.writeScanRecord(ctx, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// scanSummary answers "какие устройства в сети?" in one spoken line.
|
||||
//
|
||||
// It does NOT read addresses out. This is the query path, so the reply goes to
|
||||
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
|
||||
// a digit stream nobody can follow through a speaker. She says how many and
|
||||
// what shape they are; the addresses go into a note (see writeScanRecord).
|
||||
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
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
|
||||
// false claim, and the addresses at the end are the ones that go missing.
|
||||
tail := ""
|
||||
if res.Truncated {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
if len(res.Hosts) == 0 {
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
out += ", " + shape
|
||||
}
|
||||
out += tail
|
||||
if w.api != nil {
|
||||
out += ". список записала"
|
||||
}
|
||||
return out + ".", true
|
||||
}
|
||||
|
||||
// scanShape describes the hosts by what they answer on, which is the part of
|
||||
// the answer that carries meaning out loud: "два с вебом" says more about the
|
||||
// flat than four octets do.
|
||||
func scanShape(hosts []netscan.Host) string {
|
||||
var web, ssh, quiet int
|
||||
for _, h := range hosts {
|
||||
hasWeb, hasSSH := false, false
|
||||
for _, p := range h.Ports {
|
||||
switch p {
|
||||
case 80, 443, 8080:
|
||||
hasWeb = true
|
||||
case 22:
|
||||
hasSSH = true
|
||||
}
|
||||
}
|
||||
if hasWeb {
|
||||
web++
|
||||
}
|
||||
if hasSSH {
|
||||
ssh++
|
||||
}
|
||||
// No open port at all: seen only through the ARP cache.
|
||||
if len(h.Ports) == 0 {
|
||||
quiet++
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if web > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с вебом", web))
|
||||
}
|
||||
if ssh > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
|
||||
}
|
||||
if quiet > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d молча", quiet))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "из них " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// writeScanRecord stores the address list as a note. This is both where the
|
||||
// detail becomes readable and the only trace that a scan happened at all: a
|
||||
// scan is a read, but "when did she last put packets on the LAN" deserves an
|
||||
// answer.
|
||||
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
if w.api == nil {
|
||||
return
|
||||
}
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if res.Truncated {
|
||||
head += " (не вся сеть)"
|
||||
}
|
||||
lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
|
||||
shown := res.Hosts
|
||||
if len(shown) > scanReadOut {
|
||||
shown = shown[:scanReadOut]
|
||||
}
|
||||
for _, h := range shown {
|
||||
s := h.Addr
|
||||
if len(h.Ports) > 0 {
|
||||
ps := make([]string, 0, len(h.Ports))
|
||||
for _, p := range h.Ports {
|
||||
ps = append(ps, fmt.Sprintf("%d", p))
|
||||
}
|
||||
s += " (" + strings.Join(ps, ", ") + ")"
|
||||
}
|
||||
if h.MAC != "" {
|
||||
s += " " + h.MAC
|
||||
}
|
||||
lines = append(lines, s)
|
||||
}
|
||||
if len(res.Hosts) > len(shown) {
|
||||
lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
|
||||
}
|
||||
if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
|
||||
log.Printf("netscan: write scan note: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// bounds exist to prevent.
|
||||
func isNetworkQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Whole tokens for the network nouns: the bare substring "сети" is inside
|
||||
// "посетил", so "сколько машин я посетил?" used to read as a request to
|
||||
// scan the LAN. The prefix forms below are stems that have no such
|
||||
// collisions.
|
||||
network := false
|
||||
for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
|
||||
if homeWord(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} {
|
||||
if strings.Contains(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
return false
|
||||
}
|
||||
// An explicit ask to scan, or a phrase that can only be about the LAN.
|
||||
// "кто в сети" carries no device noun but means nothing else.
|
||||
for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
|
||||
homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
|
||||
if !ask {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"written but dark": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"},
|
||||
}},
|
||||
"enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}},
|
||||
"enabled but public": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"8.8.8.0/24"}, Enabled: true,
|
||||
}},
|
||||
"enabled but far too wide": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"10.0.0.0/8"}, Enabled: true,
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireNetScan(cfg, nil); w != nil {
|
||||
t.Fatal("the scanner must not wire for this config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var w *netWiring
|
||||
if _, ok := w.scanSummary(context.Background()); ok {
|
||||
t.Fatal("a nil wiring must not claim a query")
|
||||
}
|
||||
|
||||
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||
}}, nil)
|
||||
if ok == nil {
|
||||
t.Fatal("a valid enabled block should wire")
|
||||
}
|
||||
}
|
||||
|
||||
// A loopback /32 with nothing listening on the scanned port: the summary must
|
||||
// come back honest rather than inventing a host. This also exercises the real
|
||||
// dialer end to end without touching anything outside this box.
|
||||
func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
// Port 1 on loopback: nothing listens and the connection is refused
|
||||
// immediately, so the scan is fast and touches only this machine.
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||
}}, nil)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("empty summary")
|
||||
}
|
||||
// Persona: feminine self-reference, informal address, no pet names.
|
||||
low := strings.ToLower(out)
|
||||
for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(low, bad) {
|
||||
t.Errorf("persona violation %q in %q", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
"какие устройства в сети?",
|
||||
"кто в сети?",
|
||||
"просканируй сеть",
|
||||
"покажи устройства в локальной сети",
|
||||
"сколько машин в сети",
|
||||
}
|
||||
no := []string{
|
||||
"",
|
||||
"интернет не работает",
|
||||
"сеть какая-то медленная",
|
||||
"я в сети инстаграма",
|
||||
"что включено дома?",
|
||||
"напомни оплатить интернет",
|
||||
}
|
||||
for _, u := range yes {
|
||||
if !isNetworkQuery(u) {
|
||||
t.Errorf("isNetworkQuery(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range no {
|
||||
if isNetworkQuery(u) {
|
||||
t.Errorf("isNetworkQuery(%q) = true, want false", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notingAPI counts the notes a scan writes, and remembers the last one.
|
||||
type notingAPI struct {
|
||||
ipc.CoreAPI
|
||||
n int
|
||||
last string
|
||||
}
|
||||
|
||||
func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) {
|
||||
a.n++
|
||||
a.last = text
|
||||
return int64(a.n), nil
|
||||
}
|
||||
|
||||
// The spoken answer must not be a list of IP addresses. It goes to piper as
|
||||
// well as to /chat, and six dotted quads read out as a digit stream is not an
|
||||
// answer anybody can use. The addresses belong in the written record.
|
||||
func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
api := ¬ingAPI{}
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true,
|
||||
}}, api)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) {
|
||||
t.Errorf("the spoken reply reads addresses out loud: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "нашла 1 устройство") {
|
||||
t.Errorf("reply = %q, want a count", out)
|
||||
}
|
||||
if api.n != 1 {
|
||||
t.Fatalf("wrote %d notes, want 1", api.n)
|
||||
}
|
||||
if !strings.Contains(api.last, "127.0.0.1") {
|
||||
t.Errorf("the written record has no addresses: %q", api.last)
|
||||
}
|
||||
|
||||
// A follow-up question inside the TTL reuses the answer: two questions in
|
||||
// a row must not be two sweeps of the LAN.
|
||||
if _, _ = w.scanSummary(context.Background()); api.n != 1 {
|
||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -283,3 +284,81 @@ func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceYesDoesNotAcceptRoutine — Vikunja #367. Accepting a routine hands
|
||||
// the tick loop a standing new reason to speak, which DESIGN.md puts at layer
|
||||
// 3, and voice is structurally incapable of layer 3. A spoken "да" must park
|
||||
// the decision for the authed page, not flip the row itself.
|
||||
func TestVoiceYesDoesNotAcceptRoutine(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1)
|
||||
|
||||
h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }}
|
||||
|
||||
// The MinEvents'th event is the one that makes the pattern detectable, and
|
||||
// it goes through the voice path so the proposal is parked for a y/n.
|
||||
last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour)
|
||||
factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("write fact: %v", err)
|
||||
}
|
||||
if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" {
|
||||
t.Fatal("expected a parked routine proposal")
|
||||
}
|
||||
|
||||
reply, handled := h.resolveConfirm(ctx, "да")
|
||||
if !handled {
|
||||
t.Fatal("the spoken yes should be consumed by the routine confirm")
|
||||
}
|
||||
if !strings.Contains(reply, "рутин") {
|
||||
t.Fatalf("reply should send him to the routines page, got %q", reply)
|
||||
}
|
||||
|
||||
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineAccepted)
|
||||
if err != nil {
|
||||
t.Fatalf("list accepted: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("voice accepted a routine: %+v", rows)
|
||||
}
|
||||
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed: %v", err)
|
||||
}
|
||||
if len(proposed) != 1 {
|
||||
t.Fatalf("proposed routines = %d, want 1 (still waiting for the page)", len(proposed))
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceNoStillDismissesRoutine — declining does not move the boundary
|
||||
// outward, so voice keeps it. Only acceptance is gated.
|
||||
func TestVoiceNoStillDismissesRoutine(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1)
|
||||
|
||||
h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }}
|
||||
|
||||
last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour)
|
||||
factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("write fact: %v", err)
|
||||
}
|
||||
if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" {
|
||||
t.Fatal("expected a parked routine proposal")
|
||||
}
|
||||
|
||||
if _, handled := h.resolveConfirm(ctx, "нет"); !handled {
|
||||
t.Fatal("the spoken no should be consumed by the routine confirm")
|
||||
}
|
||||
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineDismissed)
|
||||
if err != nil {
|
||||
t.Fatalf("list dismissed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("dismissed routines = %d, want 1", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The personal boundary decides one thing: is this question about him. It used
|
||||
// to decide it by matching possession words, and that was the whole defect
|
||||
// behind Vikunja #495. "что я говорил про бэкапы?" is his data by definition —
|
||||
// nothing outside the box has ever heard him say anything — and it carried no
|
||||
// possession word, so it walked past the boundary into SearXNG and came back
|
||||
// answered out of a Habr article about somebody else's backups.
|
||||
//
|
||||
// The first fix was one more marker class, `я говорил|сказал|писал|…`, plus a
|
||||
// carve-out so "как я говорил, почему небо синее" stayed a world question. Both
|
||||
// halves are a lexicon, and a lexicon is the wrong instrument here: Russian
|
||||
// gives every verb a dozen surface forms, the preamble list has no end, and
|
||||
// every utterance the list misses is one that reaches the world. It also drifts
|
||||
// silently — a missing verb looks exactly like no bug.
|
||||
//
|
||||
// So the boundary asks the embedder instead. Two frozen seed sets — questions
|
||||
// about him, questions about the world — are embedded once, and the turn's own
|
||||
// query vector, already computed by queryEmbed upstream, is scored against
|
||||
// both. Nearest side wins. Word order, verb form and unseen phrasing stop
|
||||
// mattering, which is exactly what a lexicon could not do.
|
||||
//
|
||||
// Measured 03-08-2026 against multilingual-e5-small on 19 held-out utterances,
|
||||
// none of them a seed: 19 right (TestONNXPersonalBoundary). A 20th, "as i said,
|
||||
// what is the population of india", missed by +0.008 during the first pass and
|
||||
// is a world seed now, which is why it is not in the held-out set. True
|
||||
// positives clear the world side by +0.014 to +0.089 and the nearest true
|
||||
// negative sits at -0.005, so the gate is the sign of the difference and
|
||||
// nothing tighter: the margins are too thin to justify a threshold, and the
|
||||
// asymmetry favours claiming anyway. A false claim costs one honest "не знаю";
|
||||
// a false pass sends his life to an upstream engine.
|
||||
//
|
||||
// The embedder is the one model CLAUDE.md pins to homesrv permanently, and it
|
||||
// is what makes this affordable: no llama-server call, no network, one cosine
|
||||
// per seed against a vector the turn already has.
|
||||
|
||||
// personalSeeds — questions about him. Frozen: they are scoring data, so
|
||||
// editing one moves the boundary and must be re-measured, not eyeballed. Cover
|
||||
// both classes the boundary owns, possession and first-person speech, in both
|
||||
// languages.
|
||||
var personalSeeds = []string{
|
||||
"что я говорил про это",
|
||||
"я тебе рассказывал об этом?",
|
||||
"что я записал про врача",
|
||||
"я упоминал эту тему?",
|
||||
"что у меня сегодня",
|
||||
"когда моя встреча",
|
||||
"what did i say about this",
|
||||
"did i mention this to you",
|
||||
}
|
||||
|
||||
// worldSeeds — questions the world can answer, including the two shapes that
|
||||
// look personal and are not: a first-person preamble on a world question ("как
|
||||
// я говорил, ..."), and first person without possession ("что я могу
|
||||
// посмотреть вечером"). Refusing those is the opposite mistake and the older
|
||||
// comment on personalMarkers already named it.
|
||||
var worldSeeds = []string{
|
||||
"почему небо синее",
|
||||
"какая столица франции",
|
||||
"как сварить борщ",
|
||||
"кто написал эту книгу",
|
||||
"what is the capital of france",
|
||||
"how do i boil an egg",
|
||||
"как я говорил, почему небо синее",
|
||||
"as i said, why is the sky blue",
|
||||
"as i said, what is the population of india",
|
||||
"что я могу посмотреть вечером",
|
||||
"что мне почитать про историю",
|
||||
"что я должен знать про питон",
|
||||
"what can i watch tonight",
|
||||
}
|
||||
|
||||
// personalBoundary holds the embedded seeds. Zero value is usable and means
|
||||
// "not loaded yet"; a handler built without an embedder never loads and the
|
||||
// boundary falls back to personalMarkers.
|
||||
type personalBoundary struct {
|
||||
once sync.Once
|
||||
personal [][]float32
|
||||
world [][]float32
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// load embeds both seed sets, once per process. Seeds are embedded on the QUERY
|
||||
// side, like the utterance they are compared with — a question against a
|
||||
// question. Mixing sides would measure the e5 prefix, not the meaning.
|
||||
func (b *personalBoundary) load(ctx context.Context, emb router.Embedder) {
|
||||
b.once.Do(func() {
|
||||
if emb == nil {
|
||||
return
|
||||
}
|
||||
embedAll := func(ss []string) [][]float32 {
|
||||
out := make([][]float32, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
v, err := router.EmbedQuery(ctx, emb, s)
|
||||
if err != nil {
|
||||
log.Printf("voice: personal boundary seeds unavailable (%v); falling back to possession markers", err)
|
||||
return nil
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
p, w := embedAll(personalSeeds), embedAll(worldSeeds)
|
||||
if p == nil || w == nil {
|
||||
return
|
||||
}
|
||||
b.personal, b.world, b.loaded = p, w, true
|
||||
})
|
||||
}
|
||||
|
||||
// score returns the best similarity to each side. ok is false when the seeds
|
||||
// are not loaded, which is the caller's signal to use the markers instead.
|
||||
func (b *personalBoundary) score(vec []float32) (personal, world float64, ok bool) {
|
||||
if !b.loaded || len(vec) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
best := func(seeds [][]float32) float64 {
|
||||
m := -1.0
|
||||
for _, s := range seeds {
|
||||
if c := cosine(vec, s); c > m {
|
||||
m = c
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
return best(b.personal), best(b.world), true
|
||||
}
|
||||
|
||||
// cosine — same math as internal/router and internal/memory, small enough that
|
||||
// importing one of them for it would be the larger coupling.
|
||||
func cosine(a, b []float32) float64 {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
dot += float64(a[i]) * float64(b[i])
|
||||
na += float64(a[i]) * float64(a[i])
|
||||
nb += float64(b[i]) * float64(b[i])
|
||||
}
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// A handler with no embedder never loads the seeds, so the boundary falls back
|
||||
// to the possession markers. That is the offline floor and it must keep working
|
||||
// — an embedder that fails to load must not open the boundary.
|
||||
func TestBoundaryFallsBackToMarkersWithNoEmbedder(t *testing.T) {
|
||||
h := personalHandler()
|
||||
if !h.isPersonalTurn(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "во сколько у меня встреча"},
|
||||
}) {
|
||||
t.Error("no embedder: a possession question must still be personal")
|
||||
}
|
||||
if h.isPersonalTurn(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "почему небо синее"},
|
||||
}) {
|
||||
t.Error("no embedder: a world question must still pass")
|
||||
}
|
||||
}
|
||||
|
||||
// TestONNXPersonalBoundary — the number that matters, scored against the
|
||||
// embedder homesrv actually runs. Opt-in via MAVEN_ONNX_LIB, exactly like
|
||||
// TestONNXRecall in internal/memory/recalleval.
|
||||
//
|
||||
// Every case here is held out: none of these strings is a seed. The #495
|
||||
// regression is the first row — "что я говорил про бэкапы?" reached SearXNG and
|
||||
// was answered from a Habr article, and no possession word appears in it.
|
||||
func TestONNXPersonalBoundary(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
|
||||
cases := []struct {
|
||||
utterance string
|
||||
personal bool
|
||||
}{
|
||||
{"что я говорил про бэкапы?", true},
|
||||
{"что я сказал вчера про отпуск", true},
|
||||
{"я писал что-нибудь про сервер", true},
|
||||
{"я упоминал про конференцию?", true},
|
||||
{"что я отмечал по поводу переезда", true},
|
||||
{"я рассказывал тебе про новую работу?", true},
|
||||
{"во сколько у меня встреча", true},
|
||||
{"когда мой следующий отпуск", true},
|
||||
{"what did i say about backups", true},
|
||||
{"did i tell you about the doctor", true},
|
||||
{"как я говорил, почему небо синее", false},
|
||||
{"как уже я говорил, какая столица франции", false},
|
||||
{"почему трава зелёная", false},
|
||||
{"столица франции", false},
|
||||
{"как мне сварить борщ", false},
|
||||
{"что мне посмотреть вечером", false},
|
||||
{"я хочу узнать про рим", false},
|
||||
{"кто такой гагарин", false},
|
||||
{"how do i boil an egg", false},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
ctx := context.Background()
|
||||
wrong := 0
|
||||
for _, c := range cases {
|
||||
vec, err := router.EmbedQuery(ctx, emb, c.utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", c.utterance, err)
|
||||
}
|
||||
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
|
||||
got := h.isPersonalTurn(ctx, turn)
|
||||
p, w, ok := h.recall.boundary.score(vec)
|
||||
if !ok {
|
||||
t.Fatal("seeds did not load with a working embedder")
|
||||
}
|
||||
if got != c.personal {
|
||||
wrong++
|
||||
t.Errorf("%q: personal=%v want %v (personal %.4f world %.4f)", c.utterance, got, c.personal, p, w)
|
||||
}
|
||||
t.Logf("personal=%-5v personal %.4f world %.4f delta %+.4f %s", got, p, w, p-w, c.utterance)
|
||||
}
|
||||
t.Logf("personal boundary: %d/%d held-out utterances correct", len(cases)-wrong, len(cases))
|
||||
}
|
||||
@@ -85,15 +85,17 @@ func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reac
|
||||
|
||||
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
||||
h := &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
embedder: emb,
|
||||
api: ipc.NewStoreAPI(st),
|
||||
recall: recallWiring{
|
||||
embedder: emb,
|
||||
memStore: mem,
|
||||
minScore: 0.55,
|
||||
minMargin: 0.008,
|
||||
},
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phr,
|
||||
now: func() time.Time { return now },
|
||||
memStore: mem,
|
||||
dataStore: st,
|
||||
queryMinScore: 0.55,
|
||||
queryMinMargin: 0.008,
|
||||
weatherProvider: nil,
|
||||
}
|
||||
return h, phr
|
||||
|
||||
@@ -19,7 +19,14 @@ import (
|
||||
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
||||
// so a false positive here is a network-reachable way to flip a daemon-wide
|
||||
// setting. See classifyQuietToggle for the matching rule.
|
||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
|
||||
//
|
||||
// src is the channel the utterance arrived on, and it is written straight into
|
||||
// the fact. Every toggle used to be stored as "tap:voice", including the ones
|
||||
// typed into the web UI, which left the facts table claiming a microphone flipped
|
||||
// a setting nobody spoke to. This is the one function where that matters most:
|
||||
// when he goes looking at why quiet mode is on, provenance is the first column
|
||||
// he reads.
|
||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
on, off := classifyQuietToggle(text)
|
||||
if !on && !off {
|
||||
return "", false
|
||||
@@ -35,7 +42,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (
|
||||
Kind: "config",
|
||||
Key: "quiet_hours",
|
||||
Value: val,
|
||||
Source: "tap:voice",
|
||||
Source: string(src),
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("voice: write quiet_hours: %v", err)
|
||||
@@ -110,24 +117,75 @@ func quietPhrase(tokens, pattern []string) bool {
|
||||
}
|
||||
|
||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
||||
//
|
||||
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
||||
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
||||
// and negation is not an adjacency phenomenon. "не надо тихий режим" put two
|
||||
// tokens between "не" and "тих", so the OFF pattern missed, the ON pattern
|
||||
// {"тих","режим"} matched, and asking for quiet mode to stop turned it on.
|
||||
// Negation is handled by quietNegators below, over the whole utterance.
|
||||
var (
|
||||
quietOffPhrases = [][]string{
|
||||
{"quiet", "off"}, {"quiet", "end"},
|
||||
{"громк", "режим"}, {"шумн", "режим"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"},
|
||||
}
|
||||
quietOnPhrases = [][]string{
|
||||
{"quiet", "on"}, {"quiet", "mode"},
|
||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||||
{"тих"},
|
||||
// The noun form and the comparative. "режим тишины" is how the
|
||||
// setting is named half the time, and "сделай потише" is how it is
|
||||
// actually asked for out loud. Both used to fall through to the
|
||||
// router, which has no quiet intent, so the command did nothing.
|
||||
{"режим", "тишин"}, {"сделай", "тише"}, {"сделай", "потише"},
|
||||
{"говори", "тише"}, {"будь", "потише"},
|
||||
{"тих"}, {"потише"},
|
||||
}
|
||||
)
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
|
||||
// resolved before ON for the same reason classifyConfirm checks negatives
|
||||
// first: the OFF phrases are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them and "выключи
|
||||
// тихий режим" would turn quiet mode on. Negation wins.
|
||||
// quietWordStems — every stem that names the setting. Used by the
|
||||
// negated-but-unmatched fallback in classifyQuietToggle, which has to
|
||||
// recognise "хватит тишины" without an ON phrase having matched.
|
||||
var quietWordStems = []string{"тих", "тишин", "потише"}
|
||||
|
||||
// quietNegatorWords — negators that are whole words with no useful stem.
|
||||
var quietNegatorWords = map[string]bool{
|
||||
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
||||
}
|
||||
|
||||
// quietNegatorStems — negators that inflect. Matched through quietStem, the
|
||||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
||||
// and "выключай" all count and "выключатель" does not.
|
||||
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
|
||||
|
||||
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
||||
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
||||
// requests FOR quiet, so they are excluded before the scan: a negator only
|
||||
// counts when it is not part of the phrase that matched.
|
||||
func quietNegated(tokens []string, matched []string) bool {
|
||||
if len(matched) > 0 && matched[0] == "не" {
|
||||
return false
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if quietNegatorWords[t] {
|
||||
return true
|
||||
}
|
||||
for _, stem := range quietNegatorStems {
|
||||
if quietStem(t, stem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command.
|
||||
//
|
||||
// Explicit OFF phrases resolve first, for the same reason classifyConfirm
|
||||
// checks negatives first: they are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them. An ON phrase that
|
||||
// matches is then checked for negation across the whole utterance, so any way
|
||||
// of saying "not quiet mode" turns it off rather than on.
|
||||
func classifyQuietToggle(text string) (on, off bool) {
|
||||
tokens := quietTokens(text)
|
||||
for _, p := range quietOffPhrases {
|
||||
@@ -137,8 +195,25 @@ func classifyQuietToggle(text string) (on, off bool) {
|
||||
}
|
||||
for _, p := range quietOnPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
if quietNegated(tokens, p) {
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
// No ON phrase matched, but he negated a quiet word: "не тихо", "хватит
|
||||
// тихого режима". The ON vocabulary cannot see these — bare "тих" only
|
||||
// matches a one-token utterance, by design, so the negator pushes the token
|
||||
// count past it — and reading them as "no command" would leave quiet mode
|
||||
// on after he asked for it to stop.
|
||||
if quietNegated(tokens, nil) {
|
||||
for _, t := range tokens {
|
||||
for _, stem := range quietWordStems {
|
||||
if quietStem(t, stem) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -47,6 +47,15 @@ func TestResolveQuietToggle(t *testing.T) {
|
||||
{"побудь в тихом режиме", quietOn},
|
||||
{"Тихий Режим!", quietOn},
|
||||
{"тихая", quietOn},
|
||||
// The noun form and the comparative.
|
||||
{"включи режим тишины", quietOn},
|
||||
{"режим тишины", quietOn},
|
||||
{"сделай потише", quietOn},
|
||||
{"сделай тише", quietOn},
|
||||
{"потише", quietOn},
|
||||
// English, as the fixture phrases it.
|
||||
{"turn quiet mode back on", quietOn},
|
||||
{"enable quiet mode", quietOn},
|
||||
|
||||
// OFF vocabulary — all seven, incl. the three that used to say ON.
|
||||
{"quiet off", quietOff},
|
||||
@@ -60,6 +69,12 @@ func TestResolveQuietToggle(t *testing.T) {
|
||||
{"выключи тихий режим", quietOff},
|
||||
{"отмени тихий режим пожалуйста", quietOff},
|
||||
{"верни громкий режим", quietOff},
|
||||
{"выключи режим тишины", quietOff},
|
||||
{"хватит тишины", quietOff},
|
||||
{"turn off quiet mode", quietOff},
|
||||
{"quiet mode off", quietOff},
|
||||
{"stop quiet mode", quietOff},
|
||||
{"disable quiet mode", quietOff},
|
||||
|
||||
// False positives: "тихо"/"тихий" as ordinary Russian.
|
||||
{"очень тихий сегодня день", quietNone},
|
||||
@@ -68,6 +83,8 @@ func TestResolveQuietToggle(t *testing.T) {
|
||||
{"потихоньку", quietNone},
|
||||
{"тихонько", quietNone},
|
||||
{"он говорил тихим голосом весь вечер", quietNone},
|
||||
{"в тишине лучше думается", quietNone},
|
||||
{"на улице стало потише", quietNone},
|
||||
|
||||
// Unrelated.
|
||||
{"напомни завтра позвонить маме", quietNone},
|
||||
@@ -79,7 +96,7 @@ func TestResolveQuietToggle(t *testing.T) {
|
||||
t.Run(tc.text, func(t *testing.T) {
|
||||
api := &quietFakeAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||
reply, handled := h.resolveQuietToggle(context.Background(), tc.text)
|
||||
reply, handled := h.resolveQuietToggle(context.Background(), tc.text, sourceVoice)
|
||||
|
||||
if tc.want == quietNone {
|
||||
if handled || reply != "" {
|
||||
@@ -112,3 +129,57 @@ func TestResolveQuietToggle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietToggleNegationIsNotAdjacency — negation used to be an adjacency
|
||||
// pattern ({"не","тих"} in the OFF list), so any word between the negator and
|
||||
// the quiet word made the ON pattern win and asking for quiet mode to STOP
|
||||
// turned it on. Negation is scanned over the whole utterance now.
|
||||
func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
|
||||
off := []string{
|
||||
"не надо тихий режим",
|
||||
"не хочу тихий режим",
|
||||
"тихий режим выключи",
|
||||
"убери тихий режим",
|
||||
"хватит тихого режима",
|
||||
"прекрати тихий режим",
|
||||
"тихий режим отмени пожалуйста",
|
||||
}
|
||||
for _, text := range off {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
on, isOff := classifyQuietToggle(text)
|
||||
if on || !isOff {
|
||||
t.Fatalf("%q: want OFF, got on=%v off=%v", text, on, isOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The two ON phrases that are themselves built on "не" must stay ON: they
|
||||
// are requests FOR quiet, not negations of one.
|
||||
for _, text := range []string{"не шуми", "не беспокоить"} {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
on, isOff := classifyQuietToggle(text)
|
||||
if !on || isOff {
|
||||
t.Fatalf("%q: want ON, got on=%v off=%v", text, on, isOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietToggleRecordsTheChannelItArrivedOn — the toggle is reachable from
|
||||
// mavweb /api/chat and telegram, not only the microphone. Every write used to
|
||||
// be stamped "tap:voice", so a toggle typed into the web UI claimed a mic wrote
|
||||
// it and the provenance column lied about a daemon-wide setting.
|
||||
func TestQuietToggleRecordsTheChannelItArrivedOn(t *testing.T) {
|
||||
for _, src := range []turnSource{sourceVoice, sourceText} {
|
||||
t.Run(string(src), func(t *testing.T) {
|
||||
api := &quietFakeAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||
if _, handled := h.resolveQuietToggle(context.Background(), "тихий режим", src); !handled {
|
||||
t.Fatal("expected the toggle to match")
|
||||
}
|
||||
if api.got.Source != string(src) {
|
||||
t.Errorf("source = %q, want %q", api.got.Source, src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
@@ -24,11 +26,10 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: rtr,
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
@@ -85,3 +86,37 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
|
||||
// task table. It went dead when the router started claiming the marker as an
|
||||
// act: capture rides the note intent, so nothing below actionNote was ever
|
||||
// reached and every capture answered "Что сделать?" (Vikunja #467).
|
||||
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
matcher := tool.NewMatcher(api)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, matcher, 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Fatalf("capture did not claim the turn: %q", reply)
|
||||
}
|
||||
open, err := st.ListTasks(ctx, store.TaskOpen)
|
||||
if err != nil || len(open) != 1 {
|
||||
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
|
||||
}
|
||||
// The words he said, not the model's rewrite of them.
|
||||
if open[0].Text != "купить молоко" {
|
||||
t.Fatalf("task text was rewritten: %q", open[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "github.com/kami/maven/internal/memory"
|
||||
import (
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// bestRecall is the read side of the long-term memory store: the top hit when
|
||||
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
||||
@@ -23,3 +26,34 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re
|
||||
}
|
||||
return results[0], true
|
||||
}
|
||||
|
||||
// recallWiring — the recall subsystem's dependencies, held as one group on
|
||||
// reactiveHandler (Vikunja #433). It is the worked example for the wiring
|
||||
// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty
|
||||
// loose ones, so a handler names what it needs and the package can be split
|
||||
// later without exporting the whole struct.
|
||||
//
|
||||
// The zero value is usable and means "no recall": no embedder, no vector
|
||||
// store, and a gate that is never consulted because nothing is ever searched.
|
||||
type recallWiring struct {
|
||||
// embedder — reused for note write/query (same model as the classifier).
|
||||
embedder router.Embedder
|
||||
|
||||
// memStore — the vector index over notes and facts.
|
||||
memStore memory.Store
|
||||
|
||||
// boundary — the embedded seed sets behind the personal boundary
|
||||
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||
boundary personalBoundary
|
||||
|
||||
// minScore — the note-recall confidence gate. Top cosine below this ⇒
|
||||
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob,
|
||||
// not load-bearing math (same posture as the presence thresholds). Set by
|
||||
// wireVoice from VoiceConfig; default 0.55.
|
||||
minScore float64
|
||||
|
||||
// minMargin — the second half of that gate: how far the top hit must beat
|
||||
// the runner-up. 0 ⇒ margin off.
|
||||
minMargin float64
|
||||
}
|
||||
|
||||
+12
-99
@@ -2,120 +2,33 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// completer is the LLM seam for the replier (subset of router.Completer).
|
||||
// *llm.Client satisfies it.
|
||||
type completer interface {
|
||||
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||
}
|
||||
|
||||
// llmReplier phrases reactive confirmations with the resident model
|
||||
// (Qwen3-1.7B). Stub is the
|
||||
// floor on any error (offline-safe). Maven speaks as "she", feminine RU.
|
||||
// llmReplier is the daemon-side wiring around phraser.Replier: it owns the
|
||||
// deterministic floor, and nothing else. The phrasing itself, the prompt and the
|
||||
// output parsing live in internal/phraser so the eval can score them (#396).
|
||||
type llmReplier struct {
|
||||
c completer
|
||||
p *phraser.Replier
|
||||
stub *voice.StubReplier
|
||||
|
||||
// block renders the shared context block per turn (who he is, the time).
|
||||
// nil ⇒ the prompt stands alone.
|
||||
block func() string
|
||||
}
|
||||
|
||||
func newLLMReplier(c completer, block func() string) *llmReplier {
|
||||
return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block}
|
||||
func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
||||
return &llmReplier{p: phraser.NewReplier(c, block), stub: voice.NewStubReplier()}
|
||||
}
|
||||
|
||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
||||
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
|
||||
Никогда не пиши "..." в поле response.`
|
||||
|
||||
// Reply never fails: a clarify, a model error and an unusable generation all
|
||||
// answer from the stub, which is what keeps a turn from breaking on the model.
|
||||
func (r *llmReplier) Reply(d router.Decision) string {
|
||||
if d.Clarify {
|
||||
return r.stub.Reply(d)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
out, err := r.c.Complete(ctx, llm.Req{
|
||||
System: persona.Prepend(r.block, replySystem),
|
||||
User: replyContext(d),
|
||||
MaxTokens: 512,
|
||||
RepeatPenalty: 1.3,
|
||||
})
|
||||
if err != nil {
|
||||
out, err := r.p.PhraseReply(context.Background(), d)
|
||||
if err != nil || out == "" {
|
||||
return r.stub.Reply(d)
|
||||
}
|
||||
out = stripThink(out)
|
||||
if response, _ := parseResponseMood(out); response != "" {
|
||||
return response
|
||||
}
|
||||
// fallback: try plain-text parsing
|
||||
if out = firstSentence(out); out != "" {
|
||||
return out
|
||||
}
|
||||
return r.stub.Reply(d)
|
||||
}
|
||||
|
||||
// firstSentence trims the model's output to a single clean confirmation: first
|
||||
// line, first sentence, whitespace-normalized — the last-line defense against a
|
||||
// small model that rambles past the first period despite the prompt + stop.
|
||||
// stripThink removes the <think> block that Thinking-variant models emit.
|
||||
func stripThink(s string) string {
|
||||
if i := strings.LastIndex(s, "</think>"); i >= 0 {
|
||||
s = strings.TrimSpace(s[i+8:])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstSentence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
// keep up to and including the first sentence-ending punctuation.
|
||||
if i := strings.IndexAny(s, ".!?"); i >= 0 {
|
||||
s = s[:i+1]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
||||
// of thinking tokens and extra text before/after the JSON block.
|
||||
func parseResponseMood(raw string) (response, mood string) {
|
||||
cleaned := strings.TrimSpace(raw)
|
||||
start := strings.Index(cleaned, "{")
|
||||
end := strings.LastIndex(cleaned, "}")
|
||||
if start < 0 || end < 0 || end <= start {
|
||||
return "", ""
|
||||
}
|
||||
var parsed struct {
|
||||
Response string `json:"response"`
|
||||
Mood string `json:"mood"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return parsed.Response, parsed.Mood
|
||||
}
|
||||
|
||||
// replyContext renders the decision into a compact RU description for the model.
|
||||
func replyContext(d router.Decision) string {
|
||||
switch d.Intent {
|
||||
case router.IntentFact:
|
||||
return "записала факт: " + d.Slots.Key + " " + d.Slots.Value
|
||||
case router.IntentNote:
|
||||
return "сохранила заметку: " + d.Slots.Text
|
||||
case router.IntentReminder:
|
||||
return "поставила напоминание: " + d.Slots.Text
|
||||
default:
|
||||
return string(d.Intent) + ": " + d.Slots.Text
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -9,23 +9,18 @@ import (
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
type mockCompleter struct {
|
||||
// The phrasing itself is tested in internal/phraser. What is left here is the
|
||||
// only thing the daemon adds: the stub floor, on the three ways a reply can
|
||||
// fail to arrive.
|
||||
type stubCompleter struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
||||
func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return s.out, s.err }
|
||||
|
||||
func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"}, nil)
|
||||
func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||
@@ -33,36 +28,29 @@ func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{err: errTestLLMDown}, nil)
|
||||
noteDec := router.Decision{Intent: router.IntentNote}
|
||||
got := r.Reply(noteDec)
|
||||
want := voice.NewStubReplier().Reply(noteDec)
|
||||
if got != want {
|
||||
t.Errorf("on llm error: got %q, want stub %q", got, want)
|
||||
}
|
||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: ""}, nil)
|
||||
noteDec := router.Decision{Intent: router.IntentNote}
|
||||
got := r.Reply(noteDec)
|
||||
want := voice.NewStubReplier().Reply(noteDec)
|
||||
if got != want {
|
||||
t.Errorf("on empty llm: got %q, want stub %q", got, want)
|
||||
}
|
||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
||||
}
|
||||
|
||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil)
|
||||
clarifyDec := router.Decision{Clarify: true}
|
||||
got := r.Reply(clarifyDec)
|
||||
want := voice.NewStubReplier().Reply(clarifyDec)
|
||||
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
||||
}
|
||||
|
||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||
t.Helper()
|
||||
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
||||
if got != want {
|
||||
t.Errorf("on clarify: got %q, want stub %q", got, want)
|
||||
t.Errorf("on %s: got %q, want stub %q", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
var errTestLLMDown = errTest("llm down")
|
||||
var errReplierTest = errTest("llm down")
|
||||
|
||||
type errTest string
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/websearch"
|
||||
)
|
||||
|
||||
// searchWiring — the metasearch source, assembled. nil ⇒ off, which is the
|
||||
// default: no `search` block, no query ever leaves the LAN.
|
||||
//
|
||||
// Thinner than kiwixWiring because there is nothing to rewrite. SearXNG ranks
|
||||
// with real engines, so the question goes out as he asked it, and that is the
|
||||
// reason this source sits ahead of the ZIMs rather than behind them.
|
||||
type searchWiring struct {
|
||||
client *websearch.Client
|
||||
max int
|
||||
runes int
|
||||
}
|
||||
|
||||
// wireSearch builds the search client from the `search` block, or returns nil
|
||||
// when there is none. config.Normalise has already dropped a block with no URL
|
||||
// and filled the two size defaults, so this does no validation of its own.
|
||||
func wireSearch(cfg *config.Config) *searchWiring {
|
||||
if cfg.Search == nil {
|
||||
return nil
|
||||
}
|
||||
sc := cfg.Search
|
||||
log.Printf("voice: web search at %s (language %q, engines %q)", sc.URL, sc.Language, sc.Engines)
|
||||
return &searchWiring{
|
||||
client: websearch.New(sc.URL, websearch.Options{
|
||||
Language: sc.Language,
|
||||
Engines: sc.Engines,
|
||||
Timeout: time.Duration(sc.Timeout),
|
||||
}),
|
||||
max: sc.MaxResults,
|
||||
runes: sc.SnippetRunes,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestSlotsParity — dialogue.Slots is a hand-kept copy of router.Slots
|
||||
// (dialogue must not import router: import cycle). Drift is silent, so this
|
||||
// test compares the two field sets by name and type. If it fails, add the new
|
||||
// field to both structs AND to toDialogueSlots/applyDialogueSlots in
|
||||
// followup.go — do not relax the test.
|
||||
func TestSlotsParity(t *testing.T) {
|
||||
fields := func(v any) map[string]string {
|
||||
rt := reflect.TypeOf(v)
|
||||
out := make(map[string]string, rt.NumField())
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
f := rt.Field(i)
|
||||
out[f.Name] = f.Type.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
rf, df := fields(router.Slots{}), fields(dialogue.Slots{})
|
||||
for name, typ := range rf {
|
||||
dt, ok := df[name]
|
||||
if !ok {
|
||||
t.Errorf("router.Slots.%s (%s) missing from dialogue.Slots", name, typ)
|
||||
continue
|
||||
}
|
||||
if dt != typ {
|
||||
t.Errorf("field %s: router has %s, dialogue has %s", name, typ, dt)
|
||||
}
|
||||
}
|
||||
for name, typ := range df {
|
||||
if _, ok := rf[name]; !ok {
|
||||
t.Errorf("dialogue.Slots.%s (%s) missing from router.Slots", name, typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlotsRoundTrip — the converters carry every field. A field the parity
|
||||
// test accepts can still be dropped in transit, so round-trip a fully
|
||||
// populated value and compare.
|
||||
func TestSlotsRoundTrip(t *testing.T) {
|
||||
full := router.Slots{
|
||||
Time: time.Date(2026, 8, 2, 11, 0, 0, 0, time.UTC),
|
||||
HasTime: true,
|
||||
Fn: "restart",
|
||||
Args: []string{"nginx"},
|
||||
HasFn: true,
|
||||
Key: "water",
|
||||
Value: `"drank"`,
|
||||
HasKey: true,
|
||||
Text: "выпил воды",
|
||||
}
|
||||
// Every field must be non-zero, or the round-trip proves nothing.
|
||||
rv := reflect.ValueOf(full)
|
||||
for i := 0; i < rv.NumField(); i++ {
|
||||
if rv.Field(i).IsZero() {
|
||||
t.Fatalf("field %s is zero: extend this fixture so the round-trip covers it",
|
||||
rv.Type().Field(i).Name)
|
||||
}
|
||||
}
|
||||
if got := applyDialogueSlots(router.Slots{}, toDialogueSlots(full)); !reflect.DeepEqual(got, full) {
|
||||
t.Errorf("round-trip lost a slot:\n got %+v\nwant %+v", got, full)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// homeWiring — the Home Assistant client, when the `smarthome` block is present
|
||||
// AND enabled. nil ⇒ the house is not wired, nothing was proposed, and an
|
||||
// allowlist row that happens to look like a house row refuses to run.
|
||||
//
|
||||
// It lives on the voice wiring for the same reason MCP does: a house control IS
|
||||
// an act. It goes through tool.Executor, the enabled allowlist and the confirm
|
||||
// turn, all of which only exist on the voice/chat path.
|
||||
type homeWiring struct {
|
||||
client *smarthome.Client
|
||||
st *store.Store
|
||||
refresh time.Duration
|
||||
}
|
||||
|
||||
// wireSmartHome builds the client and proposes what it found. It never fails
|
||||
// the daemon: an instance that is down at boot is logged and retried, because
|
||||
// Maven starting is not contingent on someone else's process.
|
||||
func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring {
|
||||
hc, ok := cfg.SmartHomeClient()
|
||||
if !ok || st == nil {
|
||||
return nil
|
||||
}
|
||||
if err := smarthome.Validate(hc); err != nil {
|
||||
// config.validate already ran this, so reaching here is a programming
|
||||
// error rather than a config one. Still not fatal: the house off is a
|
||||
// working Maven.
|
||||
log.Printf("smarthome: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
w := &homeWiring{
|
||||
client: smarthome.NewClient(hc),
|
||||
st: st,
|
||||
refresh: time.Duration(cfg.SmartHome.Refresh),
|
||||
}
|
||||
// No first propose here. This runs inside wireVoice, inside run, before the
|
||||
// IPC socket is serving, and on the locked path inside the passkey unlock
|
||||
// handler. A Home Assistant box that is powered off but still on a routed
|
||||
// subnet black-holes the connection rather than refusing it, so a
|
||||
// synchronous enumeration held the daemon's start for the per-call timeout.
|
||||
// run does the first propose off the ticker instead.
|
||||
return w
|
||||
}
|
||||
|
||||
// caller is the tool.HomeCaller seam.
|
||||
func (w *homeWiring) caller() *smarthome.Client {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.client
|
||||
}
|
||||
|
||||
// propose writes a 'proposed' allowlist row for every controllable device. It
|
||||
// does NOT enable anything: a reachable house is a place Maven may look, not a
|
||||
// set of switches she may flip. Kami enables what he wants on /tools, behind
|
||||
// step-up, which is the same gate a shell tool goes through.
|
||||
//
|
||||
// Sensors are read but never proposed — there is nothing to call on them.
|
||||
func (w *homeWiring) propose(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
ents, err := w.client.States(ctx)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: read states: %v", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
fresh, devices := 0, 0
|
||||
for _, e := range ents {
|
||||
svcs := smarthome.Services(e.Domain)
|
||||
if len(svcs) == 0 {
|
||||
continue
|
||||
}
|
||||
devices++
|
||||
for _, s := range svcs {
|
||||
name := smarthome.LocalName(e.ID, s.Verb)
|
||||
provenance := "дом: " + s.Name + " → " + e.Name + " (" + e.ID + ")"
|
||||
ok, err := w.st.ProposeSmartHomeTool(ctx, name, smarthome.Scope(e.Domain),
|
||||
smarthome.Cmd(e.ID, s.Name), provenance, now)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: propose %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
fresh++
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("smarthome: %d entities, %d controllable", len(ents), devices)
|
||||
if fresh > 0 {
|
||||
log.Printf("smarthome: %d new device proposal(s) waiting on /tools", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// run re-enumerates the house and picks up devices that appeared, until ctx is
|
||||
// canceled.
|
||||
func (w *homeWiring) run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
iv := w.refresh
|
||||
if iv <= 0 {
|
||||
iv = config.DefaultSmartHomeRefresh
|
||||
}
|
||||
t := time.NewTicker(iv)
|
||||
defer t.Stop()
|
||||
// The first enumeration, off the daemon's start path. wireSmartHome used to
|
||||
// do it synchronously and a dead house delayed the socket coming up.
|
||||
w.propose(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.propose(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// homeSummary answers "что дома?" — a read of the current entity states, one
|
||||
// short line. Read-only: it can never call a service, so it needs no confirm
|
||||
// and no allowlist row.
|
||||
func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
ents, err := w.client.States(ctx)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: summary: %v", err)
|
||||
return "не смогла достучаться до дома.", true
|
||||
}
|
||||
if len(ents) == 0 {
|
||||
return "дом ничего не отдаёт.", true
|
||||
}
|
||||
var on []string
|
||||
var sensors []string
|
||||
dark := 0
|
||||
for _, e := range ents {
|
||||
switch {
|
||||
case e.Domain == "sensor" || e.Domain == "binary_sensor":
|
||||
if e.State == "" || e.State == "unavailable" {
|
||||
dark++
|
||||
continue
|
||||
}
|
||||
if len(sensors) < 3 {
|
||||
sensors = append(sensors, e.Name+" "+e.State+e.Unit)
|
||||
}
|
||||
case e.State == "unavailable" || e.State == "unknown" || e.State == "":
|
||||
// A lamp that is not reachable is not a lamp that is off. Counting
|
||||
// it as neither used to make "всё выключено" and "one device is
|
||||
// unreachable" read identically.
|
||||
dark++
|
||||
case e.State == "on" || e.State == "open" || e.State == "unlocked":
|
||||
on = append(on, e.Name)
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
switch {
|
||||
case len(on) > 0:
|
||||
shown, rest := on, 0
|
||||
if len(shown) > 5 {
|
||||
rest = len(shown) - 5
|
||||
shown = shown[:5]
|
||||
}
|
||||
// Silent truncation on a status read is the same failure as the cap
|
||||
// one layer up: she has to say the list is not the whole list.
|
||||
line := "включено: " + strings.Join(shown, ", ")
|
||||
if rest > 0 {
|
||||
line += fmt.Sprintf(" и ещё %d", rest)
|
||||
}
|
||||
parts = append(parts, line)
|
||||
case dark > 0 && len(sensors) == 0:
|
||||
// Nothing is on and everything she can see is unreachable. "всё
|
||||
// выключено" would be a claim about the house she cannot make.
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
||||
default:
|
||||
parts = append(parts, "всё выключено")
|
||||
}
|
||||
if len(sensors) > 0 {
|
||||
parts = append(parts, strings.Join(sensors, ", "))
|
||||
}
|
||||
if dark > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
|
||||
}
|
||||
return strings.Join(parts, "; ") + ".", true
|
||||
}
|
||||
|
||||
// isHomeQuery recognises a question about the house, narrowly. "дома" on its
|
||||
// own is not enough — "я дома" is a fact, not a question — so it takes a house
|
||||
// marker AND an ask AND either a device word or the word "включ…". Weather
|
||||
// wording bails out first: "какая температура на улице?" belongs to the weather
|
||||
// source, and both questions contain "температура".
|
||||
func isHomeQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"погод", "на улице", "прогноз"} {
|
||||
if strings.Contains(s, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, phrase := range []string{"что включено", "что выключено", "умный дом", "что в доме включено"} {
|
||||
if strings.Contains(s, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
house := homeWord(s, "дома") || strings.Contains(s, "в доме") || strings.Contains(s, "в квартире")
|
||||
if !house {
|
||||
return false
|
||||
}
|
||||
ask := strings.Contains(s, "?") || homeWord(s, "что") || homeWord(s, "какая") ||
|
||||
homeWord(s, "какой") || homeWord(s, "сколько")
|
||||
if !ask {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"свет", "лампа", "лампы", "розетк", "датчик", "температур", "включ", "выключ"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// homeWord — whole-token membership, so "дома" does not fire on "домашний".
|
||||
// Punctuation is trimmed off each token because a spoken question arrives with
|
||||
// a question mark glued to the last word.
|
||||
func homeWord(s, w string) bool {
|
||||
for _, tok := range strings.Fields(s) {
|
||||
if strings.Trim(tok, ".,!?;:") == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
const haStatesFixture = `[
|
||||
{"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}},
|
||||
{"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}},
|
||||
{"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"friendly_name":"Спальня","unit_of_measurement":"°C"}}
|
||||
]`
|
||||
|
||||
func TestWireSmartHomeOffUnlessEnabled(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"written but dark": {SmartHome: &config.SmartHomeConfig{
|
||||
URL: "http://ha.lan:8123", Token: "t",
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireSmartHome(cfg, st); w != nil {
|
||||
t.Fatal("the house must be off unless the block is enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
// nil wiring must be safe everywhere it is reachable.
|
||||
var w *homeWiring
|
||||
w.propose(context.Background())
|
||||
w.run(context.Background())
|
||||
if w.caller() != nil {
|
||||
t.Fatal("a nil wiring must have no caller")
|
||||
}
|
||||
if _, ok := w.homeSummary(context.Background()); ok {
|
||||
t.Fatal("a nil wiring must not claim a query")
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable instance must not stop the daemon and must propose nothing.
|
||||
func TestWireSmartHomeUnreachableIsNotFatal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
// Port 1 on loopback: nothing listens, and it fails fast.
|
||||
URL: "http://127.0.0.1:1", Token: "t", Enabled: true,
|
||||
}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured house should still wire")
|
||||
}
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Fatalf("an instance that never answered must propose nothing, got %+v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery proposes one row per controllable service, always destructive,
|
||||
// always 'proposed'. A sensor gets no row: there is nothing to call on it.
|
||||
func TestProposeOnlyProposesControllableDevices(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(haStatesFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
st := newTestStore(t)
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
|
||||
}
|
||||
// Wiring alone must not have touched the house: enumeration happens off
|
||||
// the ticker, not on the daemon's start path.
|
||||
if pre, err := st.ListTools(context.Background(), ""); err != nil || len(pre) != 0 {
|
||||
t.Fatalf("wireSmartHome enumerated the house synchronously: %+v (%v)", pre, err)
|
||||
}
|
||||
w.propose(context.Background())
|
||||
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, tl := range tools {
|
||||
got[tl.Name] = true
|
||||
if tl.Status != "proposed" {
|
||||
t.Errorf("%s status = %q: discovery must never enable", tl.Name, tl.Status)
|
||||
}
|
||||
if !tl.Destructive {
|
||||
t.Errorf("%s is not destructive: every house control needs the confirm turn", tl.Name)
|
||||
}
|
||||
if len(tl.Cmd) == 0 || tl.Cmd[0] != "smarthome" {
|
||||
t.Errorf("%s cmd = %v", tl.Name, tl.Cmd)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"home_light_living_room_on", "home_light_living_room_off",
|
||||
"home_switch_kettle_on", "home_switch_kettle_off",
|
||||
} {
|
||||
if !got[want] {
|
||||
t.Errorf("missing proposal %q (have %v)", want, got)
|
||||
}
|
||||
}
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("got %d rows, want 4 — the sensor must not be proposed: %+v", len(tools), tools)
|
||||
}
|
||||
|
||||
// A second pass must be idempotent: re-discovery duplicates nothing and
|
||||
// never rewrites a row Kami already enabled.
|
||||
if err := st.EnableTool(context.Background(), "home_switch_kettle_on",
|
||||
[]string{"smarthome", "switch.kettle", "turn_on"}, true, "smarthome:switch", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.propose(context.Background())
|
||||
again, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 4 {
|
||||
t.Fatalf("re-discovery duplicated rows: %d", len(again))
|
||||
}
|
||||
for _, tl := range again {
|
||||
if tl.Name == "home_switch_kettle_on" && tl.Status != "enabled" {
|
||||
t.Errorf("re-discovery un-enabled a device he had enabled: %q", tl.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeSummaryReadsState(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(haStatesFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, ok := w.homeSummary(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("summary did not claim the turn")
|
||||
}
|
||||
if !strings.Contains(out, "Гостиная") {
|
||||
t.Errorf("the lamp that is on should be named: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "Чайник") {
|
||||
t.Errorf("a device that is off should not be listed as on: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "22.5") {
|
||||
t.Errorf("the sensor reading should be there: %q", out)
|
||||
}
|
||||
// Persona: no masculine self-reference, no "вы", no pet names.
|
||||
for _, bad := range []string{"рад ", "готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(strings.ToLower(out), bad) {
|
||||
t.Errorf("persona violation %q in %q", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHomeQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"что включено дома?",
|
||||
"что выключено",
|
||||
"какой свет горит дома",
|
||||
"свет в доме включен?",
|
||||
"какая температура в квартире?",
|
||||
"покажи умный дом",
|
||||
}
|
||||
no := []string{
|
||||
"",
|
||||
"я дома",
|
||||
"буду дома в семь",
|
||||
"какая погода дома", // weather wording wins
|
||||
"какая температура на улице?",
|
||||
"домашние дела", // "дома" must not fire on "домашние"
|
||||
"что мне нужно сделать?",
|
||||
"напомни выключить чайник в семь", // a reminder, not a house read
|
||||
}
|
||||
for _, u := range yes {
|
||||
if !isHomeQuery(u) {
|
||||
t.Errorf("isHomeQuery(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range no {
|
||||
if isHomeQuery(u) {
|
||||
t.Errorf("isHomeQuery(%q) = true, want false", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A house that black-holes the connection must not hold the daemon's start.
|
||||
// wireSmartHome used to enumerate synchronously with a 30s context, inside
|
||||
// wireVoice, inside run, before the IPC socket was serving — and on the locked
|
||||
// path, inside the passkey unlock handler.
|
||||
func TestWireSmartHomeDoesNotBlockOnTheHouse(t *testing.T) {
|
||||
// A handler that never answers: the client's own timeout is the only way
|
||||
// out, and it is ten seconds.
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-block
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
done := make(chan *homeWiring, 1)
|
||||
go func() {
|
||||
done <- wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
}()
|
||||
select {
|
||||
case w := <-done:
|
||||
if w == nil {
|
||||
t.Fatal("a configured house should still wire")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("wireSmartHome waited on the house")
|
||||
}
|
||||
}
|
||||
|
||||
// A lamp that is unreachable is not a lamp that is off, and a list she cut
|
||||
// short has to say so. Both used to read as plain statements about the house.
|
||||
func TestHomeSummaryDoesNotCallUnreachableDevicesOff(t *testing.T) {
|
||||
const fixture = `[
|
||||
{"entity_id":"light.a","state":"unavailable","attributes":{"friendly_name":"Прихожая"}},
|
||||
{"entity_id":"light.b","state":"unavailable","attributes":{"friendly_name":"Кухня"}}
|
||||
]`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(fixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, ok := w.homeSummary(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "всё выключено") {
|
||||
t.Errorf("two unreachable lamps were reported as off: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "не отвечают") {
|
||||
t.Errorf("the unreachable devices are not mentioned: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeSummarySaysWhenTheListIsCutShort(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i := 0; i < 8; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"entity_id":"light.l%d","state":"on","attributes":{"friendly_name":"лампа%d"}}`, i, i)
|
||||
}
|
||||
b.WriteString("]")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, _ := w.homeSummary(context.Background())
|
||||
if !strings.Contains(out, "и ещё 3") {
|
||||
t.Errorf("eight lamps on, five named, and nothing said about the rest: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Spoken snooze — "не сейчас", "потом", "отложи" said out loud after a nudge
|
||||
// resolves it as `snoozed`, the same outcome the Telegram buttons and the web
|
||||
// UI write. Until this existed, a nudge could only be deferred by touching a
|
||||
// screen: the voice path had no way to reach store.ResolveNudge at all, so the
|
||||
// one channel she nudges on hardest was the one channel he could not answer.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// snoozeWindow — how long after a send "потом" still means "that nudge".
|
||||
//
|
||||
// A window is what makes this safe to run before the router. "потом" is an
|
||||
// ordinary Russian word; eating every one of them would break real sentences.
|
||||
// Bounded to the minutes right after she spoke, the word is almost always an
|
||||
// answer to what she just said, and outside the window the utterance falls
|
||||
// through and routes normally.
|
||||
//
|
||||
// Twenty minutes rather than the two hours of store.SnoozeDuration: those
|
||||
// measure different things. SnoozeDuration is how long the quiet lasts,
|
||||
// snoozeWindow is how long an unanswered nudge stays the topic of the
|
||||
// conversation.
|
||||
const snoozeWindow = 20 * time.Minute
|
||||
|
||||
// snoozeScan — how many recent nudges to look at when finding the target. The
|
||||
// newest pending one is nearly always the first row; a handful of resolved
|
||||
// rows can sit in front of it when he acked a few in a row.
|
||||
const snoozeScan = 10
|
||||
|
||||
// resolveSnooze — pre-route keyword check, run after the quiet toggle. Returns
|
||||
// (reply, true) when the utterance defers a nudge she recently sent.
|
||||
//
|
||||
// It returns ("", false) in two different situations, on purpose: the words do
|
||||
// not read as a deferral, or they do but there is nothing pending to defer. In
|
||||
// both the turn keeps routing, so "потом посмотрю что там с бэкапом" is still
|
||||
// a query when no nudge is outstanding.
|
||||
func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
if !classifySnooze(text) {
|
||||
return "", false
|
||||
}
|
||||
now := h.now()
|
||||
target, ok := h.pendingNudge(ctx, now)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
||||
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||
return "не получилось отложить.", true
|
||||
}
|
||||
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return "хорошо, вернусь к этому позже.", true
|
||||
}
|
||||
|
||||
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
||||
//
|
||||
// Channel is deliberately not filtered. A nudge that went to Telegram is still
|
||||
// the thing he is answering when he says "потом" at the microphone, and making
|
||||
// the reply channel decide which nudges are answerable would mean the ops page
|
||||
// he actually read could not be dismissed by voice.
|
||||
func (h *reactiveHandler) pendingNudge(ctx context.Context, now time.Time) (ipc.Nudge, bool) {
|
||||
recent, err := h.api.RecentNudges(ctx, snoozeScan)
|
||||
if err != nil {
|
||||
log.Printf("voice: recent nudges for snooze: %v", err)
|
||||
return ipc.Nudge{}, false
|
||||
}
|
||||
for _, n := range recent {
|
||||
if n.Outcome != store.NudgePending {
|
||||
continue
|
||||
}
|
||||
if now.Sub(n.Ts) > snoozeWindow || n.Ts.After(now) {
|
||||
continue
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
return ipc.Nudge{}, false
|
||||
}
|
||||
|
||||
// snoozePhrases — the deferral vocabulary, as stem sequences. Matched by
|
||||
// quietPhrase (quiet_toggle.go), which carries the rule that matters here:
|
||||
// a single-word pattern matches only a single-word utterance. Bare "потом" is
|
||||
// an answer; "потом схожу за водой" is a plan, and reporting a plan must not
|
||||
// silence the rule that prompted it.
|
||||
var snoozePhrases = [][]string{
|
||||
{"не", "сейчас"}, {"не", "могу", "сейчас"}, {"не", "до", "этого"},
|
||||
{"напомн", "позже"}, {"напомн", "потом"}, {"спрос", "позже"},
|
||||
{"отлож"}, {"позже"}, {"потом"}, {"попозже"}, {"погоди"},
|
||||
{"not", "now"}, {"later"}, {"snooze"}, {"remind", "me", "later"},
|
||||
}
|
||||
|
||||
// classifySnooze reads an utterance as a deferral. Unlike the quiet toggle
|
||||
// there is no negation arm: "не потом" is not something anyone says, and the
|
||||
// leading "не" of "не сейчас" is part of the phrase itself.
|
||||
func classifySnooze(text string) bool {
|
||||
tokens := quietTokens(text)
|
||||
for _, p := range snoozePhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// snoozeFakeAPI serves a fixed nudge list and records the resolution.
|
||||
type snoozeFakeAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
nudges []ipc.Nudge
|
||||
|
||||
gotID int64
|
||||
gotOutcome string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *snoozeFakeAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) {
|
||||
return a.nudges, nil
|
||||
}
|
||||
|
||||
func (a *snoozeFakeAPI) ResolveNudge(_ context.Context, id int64, outcome string, _ time.Time) error {
|
||||
a.gotID, a.gotOutcome, a.calls = id, outcome, a.calls+1
|
||||
return nil
|
||||
}
|
||||
|
||||
var snoozeNow = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func snoozeHandler(nudges []ipc.Nudge) (*reactiveHandler, *snoozeFakeAPI) {
|
||||
api := &snoozeFakeAPI{nudges: nudges}
|
||||
return &reactiveHandler{api: api, now: func() time.Time { return snoozeNow }}, api
|
||||
}
|
||||
|
||||
func pendingNudgeAt(id int64, ago time.Duration) ipc.Nudge {
|
||||
return ipc.Nudge{ID: id, Ts: snoozeNow.Add(-ago), Rule: "water", Channel: "voice", Outcome: store.NudgePending}
|
||||
}
|
||||
|
||||
func TestClassifySnooze(t *testing.T) {
|
||||
yes := []string{
|
||||
"не сейчас", "потом", "позже", "попозже", "отложи", "погоди",
|
||||
"напомни позже", "напомни потом", "не могу сейчас",
|
||||
"not now", "later", "snooze",
|
||||
}
|
||||
for _, s := range yes {
|
||||
if !classifySnooze(s) {
|
||||
t.Errorf("classifySnooze(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
no := []string{
|
||||
// A single-word pattern must not eat the sentence it appears in.
|
||||
"потом схожу за водой", "позже посмотрю что там с бэкапом",
|
||||
"напомни завтра позвонить маме", "какая погода", "погода на завтра",
|
||||
"я отложил деньги", "", "тихий режим",
|
||||
}
|
||||
for _, s := range no {
|
||||
if classifySnooze(s) {
|
||||
t.Errorf("classifySnooze(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSnoozeDefersTheNewestPendingNudge(t *testing.T) {
|
||||
h, api := snoozeHandler([]ipc.Nudge{
|
||||
{ID: 9, Ts: snoozeNow.Add(-time.Minute), Rule: "meal", Outcome: store.NudgeActed},
|
||||
pendingNudgeAt(8, 3*time.Minute),
|
||||
pendingNudgeAt(7, 10*time.Minute),
|
||||
})
|
||||
reply, handled := h.resolveSnooze(context.Background(), "не сейчас", sourceVoice)
|
||||
if !handled || reply == "" {
|
||||
t.Fatalf("got (%q, %v), want a reply", reply, handled)
|
||||
}
|
||||
if api.gotID != 8 || api.gotOutcome != store.NudgeSnoozed {
|
||||
t.Fatalf("resolved (%d, %q), want (8, %q)", api.gotID, api.gotOutcome, store.NudgeSnoozed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSnoozeFallsThroughWithNothingPending(t *testing.T) {
|
||||
// The whole point of the window: with no live nudge, "потом" is just a
|
||||
// word and must keep routing.
|
||||
for _, name := range []string{"stale", "resolved", "empty"} {
|
||||
var nudges []ipc.Nudge
|
||||
switch name {
|
||||
case "stale":
|
||||
nudges = []ipc.Nudge{pendingNudgeAt(3, snoozeWindow+time.Minute)}
|
||||
case "resolved":
|
||||
nudges = []ipc.Nudge{{ID: 4, Ts: snoozeNow, Rule: "water", Outcome: store.NudgeActed}}
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
h, api := snoozeHandler(nudges)
|
||||
reply, handled := h.resolveSnooze(context.Background(), "потом", sourceVoice)
|
||||
if handled || reply != "" {
|
||||
t.Fatalf("got (%q, %v), want fall-through", reply, handled)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Fatalf("resolved a nudge with nothing pending")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSnoozeIgnoresAFutureNudge(t *testing.T) {
|
||||
// Clock skew between the tick and the turn must not let a send from the
|
||||
// future be answered before it happened.
|
||||
h, api := snoozeHandler([]ipc.Nudge{pendingNudgeAt(5, -time.Minute)})
|
||||
if _, handled := h.resolveSnooze(context.Background(), "потом", sourceVoice); handled {
|
||||
t.Fatalf("snoozed a nudge dated in the future")
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Fatalf("resolved a future nudge")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// mavend/speaker.go — core's half of voice identification (Vikunja #255,
|
||||
// docs/plans/10-speaker-recognition.md).
|
||||
//
|
||||
// # What is actually wired here, and what is not
|
||||
//
|
||||
// Nothing is, on this box. There is no speaker-embedding model on disk — no
|
||||
// ECAPA, no x-vector, no titanet, no wespeaker, nothing in /mnt/hdd1/llms but
|
||||
// text ggufs. Until one is downloaded, newSpeakerEmbedder returns nil.
|
||||
//
|
||||
// Without an embedder the capability has no runnable half. This comment used to
|
||||
// say enrolment was real and only recognition was blocked, and the startup log
|
||||
// said the same. Both were wrong: Recognizer.Enroll embeds every sample before
|
||||
// it stores anything, so with no model it fails on the first sample and nothing
|
||||
// is ever stored, which leaves List empty forever and Forget with nothing to
|
||||
// delete. So the gate is cfg.Speaker.Recognizes() — enabled AND a model path —
|
||||
// and a box without one gets no speaker methods, not three no-ops.
|
||||
//
|
||||
// This is deliberately not papered over with a hand-rolled MFCC floor. A
|
||||
// biometric that is confidently wrong writes false claims about named people
|
||||
// into his memory, and that is worse than a capability that is honestly absent.
|
||||
//
|
||||
// # Off unless configured
|
||||
//
|
||||
// No speaker block, or one without enabled, ⇒ the three methods do not exist and
|
||||
// answer ErrUnknownMethod. On an unconfigured box there is no wire path that
|
||||
// takes a voiceprint at all.
|
||||
//
|
||||
// # The refused design step
|
||||
//
|
||||
// The plan asks for unknown speakers to be enrolled on first interaction. That
|
||||
// is refused in internal/speaker/enroll.go and there is no handler for it here:
|
||||
// no request shape in the protocol enrols whoever just spoke. Taking a biometric
|
||||
// of a guest who walked past the microphone is not something this daemon does.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/speaker"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// speakerWiring holds the recognizer behind the three IPC handlers.
|
||||
type speakerWiring struct {
|
||||
rec *speaker.Recognizer
|
||||
}
|
||||
|
||||
// newSpeakerEmbedder loads the speaker-embedding model named by the config.
|
||||
//
|
||||
// It always returns nil today. The seam exists so that wiring a real model is a
|
||||
// change to this one function and nothing else: give it a loader, and Identify
|
||||
// starts working with no change to the store, the protocol, the auth table or
|
||||
// the handlers. See the plan document for what to download.
|
||||
func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder {
|
||||
_ = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSpeakerWiring builds the recognizer, or nil when the capability is off.
|
||||
func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
|
||||
if cfg == nil || cfg.Speaker == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Speaker.Recognizes() {
|
||||
// Recognizes() was written as the gate and documented as one, and then
|
||||
// never called. "enabled": true with no model_path used to wire all
|
||||
// three methods and log "enrolment on", which is the one config shape
|
||||
// where the operator most needs to be told otherwise.
|
||||
if cfg.Speaker.Enabled {
|
||||
log.Print("speaker: enabled but no model_path, so there is nothing to embed with; " +
|
||||
"enrol, list and forget would all be no-ops, staying off " +
|
||||
"(see docs/plans/10-speaker-recognition.md)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if st == nil {
|
||||
log.Print("speaker: enabled but there is no store to keep profiles in; staying off")
|
||||
return nil
|
||||
}
|
||||
rec, err := speaker.New(newSpeakerEmbedder(cfg.Speaker), st.VectorMemory(), speaker.Config{
|
||||
Threshold: cfg.Speaker.Threshold,
|
||||
MinSeconds: cfg.Speaker.MinSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("speaker: %v; staying off", err)
|
||||
return nil
|
||||
}
|
||||
if rec.Enabled() {
|
||||
log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold())
|
||||
} else {
|
||||
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet, "+
|
||||
"so enrol, list and forget are all no-ops (Vikunja #255)", cfg.Speaker.ModelPath)
|
||||
}
|
||||
return &speakerWiring{rec: rec}
|
||||
}
|
||||
|
||||
func (w *speakerWiring) enroll(ctx context.Context, req ipc.EnrollSpeakerReq) (ipc.EnrollSpeakerResp, error) {
|
||||
p, err := w.rec.Enroll(ctx, req.ID, req.Name, req.Samples)
|
||||
if err != nil {
|
||||
return ipc.EnrollSpeakerResp{}, speakerErr(err)
|
||||
}
|
||||
return ipc.EnrollSpeakerResp{Speaker: toWireSpeaker(p)}, nil
|
||||
}
|
||||
|
||||
func (w *speakerWiring) list(ctx context.Context) (ipc.ListSpeakersResp, error) {
|
||||
ps, err := w.rec.List(ctx)
|
||||
if err != nil {
|
||||
return ipc.ListSpeakersResp{}, speakerErr(err)
|
||||
}
|
||||
out := make([]ipc.Speaker, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, toWireSpeaker(p))
|
||||
}
|
||||
return ipc.ListSpeakersResp{Speakers: out, Enabled: w.rec.Enabled()}, nil
|
||||
}
|
||||
|
||||
func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) error {
|
||||
return speakerErr(w.rec.Forget(ctx, req.ID))
|
||||
}
|
||||
|
||||
// toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does
|
||||
// not hand the biometric back out over the socket.
|
||||
func toWireSpeaker(p speaker.Profile) ipc.Speaker {
|
||||
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples, Damaged: p.Damaged}
|
||||
}
|
||||
|
||||
// speakerErr maps the package sentinels onto the wire vocabulary so a surface
|
||||
// can tell "you asked wrong" from "core broke".
|
||||
func speakerErr(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, speaker.ErrDisabled):
|
||||
// Not a core failure. The capability is present on the wire but has no
|
||||
// embedding model behind it, which is the same thing an unconfigured
|
||||
// method says, so say it the same way.
|
||||
return ipc.ErrUnknownMethod
|
||||
case errors.Is(err, speaker.ErrNotFound):
|
||||
return ipc.ErrNoFact
|
||||
case errors.Is(err, speaker.ErrBadID),
|
||||
errors.Is(err, speaker.ErrBadFormat),
|
||||
errors.Is(err, speaker.ErrTooShort):
|
||||
return errors.Join(ipc.ErrBadParams, err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// wireSpeaker attaches the three handlers when the capability is configured.
|
||||
func wireSpeaker(srv *ipc.Server, st *store.Store, cfg *config.Config) {
|
||||
w := newSpeakerWiring(st, cfg)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
srv.EnrollSpeakerFn = w.enroll
|
||||
srv.ListSpeakersFn = w.list
|
||||
srv.ForgetSpeakerFn = w.forget
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/speaker"
|
||||
)
|
||||
|
||||
// "enabled": true with no model_path used to wire all three methods and log
|
||||
// "enrolment on". Nothing behind them works without an embedder, so the
|
||||
// capability stays off and the socket answers "no such method".
|
||||
func TestSpeakerStaysOffWithoutAModelPath(t *testing.T) {
|
||||
srv := &ipc.Server{}
|
||||
cfg := &config.Config{Speaker: &config.SpeakerConfig{Enabled: true}}
|
||||
|
||||
wireSpeaker(srv, nil, cfg)
|
||||
|
||||
if srv.EnrollSpeakerFn != nil || srv.ListSpeakersFn != nil || srv.ForgetSpeakerFn != nil {
|
||||
t.Error("speaker methods were wired with nothing to embed with")
|
||||
}
|
||||
}
|
||||
|
||||
// The gate is Recognizes(), so a disabled block with a model path is off too.
|
||||
func TestSpeakerStaysOffWhenDisabled(t *testing.T) {
|
||||
srv := &ipc.Server{}
|
||||
cfg := &config.Config{Speaker: &config.SpeakerConfig{ModelPath: "/nope/ecapa.onnx"}}
|
||||
|
||||
wireSpeaker(srv, nil, cfg)
|
||||
|
||||
if srv.EnrollSpeakerFn != nil {
|
||||
t.Error("speaker methods were wired for a disabled block")
|
||||
}
|
||||
}
|
||||
|
||||
// ErrDisabled is "this capability is off", not "core broke". It used to fall
|
||||
// through speakerErr's default and reach the surface as an opaque failure.
|
||||
func TestSpeakerErrMapsDisabledToUnknownMethod(t *testing.T) {
|
||||
if got := speakerErr(speaker.ErrDisabled); !errors.Is(got, ipc.ErrUnknownMethod) {
|
||||
t.Errorf("speakerErr(ErrDisabled) = %v, want ErrUnknownMethod", got)
|
||||
}
|
||||
if got := speakerErr(speaker.ErrNotFound); !errors.Is(got, ipc.ErrNoFact) {
|
||||
t.Errorf("speakerErr(ErrNotFound) = %v, want ErrNoFact", got)
|
||||
}
|
||||
if got := speakerErr(nil); got != nil {
|
||||
t.Errorf("speakerErr(nil) = %v", got)
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "act_degraded",
|
||||
"description": "The act path against a Praxis that goes down and comes back. This is the case the harness promised and did not have: the other two scenarios never produce an act, so the ecosystem fakes saw zero requests and the fault lever was inert. Here a scripted act reaches an enabled allowlist row, the row is a Praxis verb, and the same utterance runs healthy, then at 503, then healthy again. The degraded turn must say she cannot reach it and must not send anything at him off the back of it.",
|
||||
"start": "2026-08-01T09:00:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||
"tools": [{ "name": "list_attention" }],
|
||||
"script": [
|
||||
{
|
||||
"match": "требует внимания",
|
||||
"route": "[{\"intent\":\"act\",\"verb\":\"list_attention\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "09:00",
|
||||
"note": "a healthy act reaches Praxis and speaks what it found",
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["medicine not taken"],
|
||||
"expect_called": ["/api/v1/tools/attention"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "09:05",
|
||||
"note": "the ecosystem goes down",
|
||||
"fault": 503
|
||||
},
|
||||
{
|
||||
"at": "09:10",
|
||||
"note": "the same act against a 503. She says she cannot reach it. She does not invent an answer and she does not push anything at him.",
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["не могу сейчас узнать"],
|
||||
"expect_reply_lacks": ["medicine not taken"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "09:15",
|
||||
"note": "a tick while the ecosystem is down touches nothing out there — the proactive loop has no business calling Praxis",
|
||||
"tick": true,
|
||||
"expect_not_called": ["/api/v1"],
|
||||
"expect_no_send": true,
|
||||
"expect_no_events": true
|
||||
},
|
||||
{
|
||||
"at": "09:20",
|
||||
"note": "recovery: the same act works again, so the degraded turn left no sticky state",
|
||||
"clear_fault": true,
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["medicine not taken"],
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "evening_degraded",
|
||||
"description": "The tier-2 pipeline case #288 deferred here, plus degraded mode. A golden WAV goes in at the microphone end and comes out as a written fact, and then the ecosystem starts answering 503 and the proactive loop has to stay quiet instead of falling over. The audio step asserts the PIPELINE — mic to STT seam to router to store to TTS — not whisper's accuracy; cmd/mavsttd/golden_test.go owns accuracy.",
|
||||
"start": "2026-08-01T21:00:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"evening_medicine\"}]",
|
||||
"script": [
|
||||
{
|
||||
"match": "выпил воды",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "записала факт: water",
|
||||
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "21:00",
|
||||
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||
"audio": "ru_fact",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
"at": "21:05",
|
||||
"note": "a healthy tick with him just having spoken stays silent",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "21:10",
|
||||
"note": "the ecosystem goes down",
|
||||
"fault": 503
|
||||
},
|
||||
{
|
||||
"at": "21:15",
|
||||
"note": "a tick against a dead ecosystem must degrade, not send half a thought",
|
||||
"tick": true,
|
||||
"expect_no_send": true,
|
||||
"expect_no_events": true
|
||||
},
|
||||
{
|
||||
"at": "21:20",
|
||||
"note": "intake keeps working while the ecosystem is down — a write does not depend on it",
|
||||
"arrive": {
|
||||
"source": "rss:tech",
|
||||
"note": { "text": "Патч 6.19.1 [tech]\nисправления\nhttps://example.org/b" }
|
||||
},
|
||||
"expect_events": ["rss:tech"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "21:25",
|
||||
"note": "recovery",
|
||||
"clear_fault": true,
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "morning_missed",
|
||||
"description": "The scenario from Vikunja #284's description, replayed. He appears at 08:30, things arrive through the morning while he is at the desk, and at 08:50 he asks what he missed. The assertions are as much about what did NOT happen — nothing was sent at him unprompted — as about what she said.",
|
||||
"start": "2026-08-01T08:30:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||
"script": [
|
||||
{
|
||||
"match": "выпил воды",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "записала факт: water",
|
||||
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "что я пропустил",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"что я пропустил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "08:30",
|
||||
"note": "he appears at the desk",
|
||||
"signal": { "key": "desk_active", "value": "true", "source": "infer:hyprland" },
|
||||
"expect_events": ["infer:hyprland"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:32",
|
||||
"note": "a feed item arrives, published half an hour ago",
|
||||
"arrive": {
|
||||
"source": "rss:tech",
|
||||
"as_of": "08:02",
|
||||
"note": { "text": "Вышло ядро 6.19 [tech]\nкраткое содержание\nhttps://example.org/a" }
|
||||
},
|
||||
"expect_events": ["rss:tech"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:35",
|
||||
"note": "the mail reader extracts a candidate — a candidate is never spoken",
|
||||
"arrive": {
|
||||
"source": "email:inbox",
|
||||
"task": { "text": "продлить домен", "evidence": "Домен истекает через 7 дней" }
|
||||
},
|
||||
"expect_events": ["email:inbox", "продлить домен"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:40",
|
||||
"note": "the work calendar signal — a relayed notification, at the ambient path's own 0.6 rather than an observation she made herself. That is the branch factPriority takes, so the journal must file it low.",
|
||||
"arrive": {
|
||||
"source": "ambient:notif",
|
||||
"fact": {
|
||||
"key": "calendar_event_20260801_планёрка",
|
||||
"value": "10:00-11:00 планёрка",
|
||||
"confidence": 0.6
|
||||
}
|
||||
},
|
||||
"expect_events": ["планёрка", "ambient:notif/fact pri=low"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:45",
|
||||
"note": "a tick with him present and nothing wrong must stay silent",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:50",
|
||||
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
|
||||
"say": "что я пропустил?",
|
||||
"expect_reply_contains": ["не знаю"],
|
||||
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||
},
|
||||
{
|
||||
"at": "08:55",
|
||||
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
|
||||
"say": "я выпил воды",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
"at": "09:00",
|
||||
"note": "a second tick, still nothing unprompted",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+30
-655
@@ -10,21 +10,17 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -249,6 +245,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
log.Printf("tick: unacked telegram rules: %v", err)
|
||||
return
|
||||
}
|
||||
keys = t.repeatableRules(keys)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -260,6 +257,35 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// repeatableRules drops keys whose rule is not wired any more.
|
||||
//
|
||||
// The repeat path reads the nudges table, not the rule set: any sev4 telegram
|
||||
// row still at outcome=pending is re-sent every repeat_interval until it is
|
||||
// acked. So turning a rule off in `disabled_rules` silenced new nudges and left
|
||||
// the last un-acked one re-sending every five minutes, forever — a knob that
|
||||
// stops the cause and not the symptom is worse than no knob. Found the evening
|
||||
// of 2026-08-01, two messages after the rule was supposedly off.
|
||||
//
|
||||
// Filtering on the wired set rather than on the disabled list also covers the
|
||||
// rule that was deleted from the code entirely: its orphan rows go quiet
|
||||
// instead of nagging about a rule nobody can ack from the UI any more.
|
||||
func (t *tickLoop) repeatableRules(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
wired := make(map[string]bool, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
wired[r.Name] = true
|
||||
}
|
||||
out := keys[:0:0]
|
||||
for _, k := range keys {
|
||||
if wired[k] {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cachePhrase keeps the latest phrased nudge per rule for the sev4-repeat
|
||||
// path. writing under a mutex; the repeat path reads under the same. the
|
||||
// cache is bounded by the rule count (≤ ~30 per spec) so eviction is not a
|
||||
@@ -284,582 +310,6 @@ func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
|
||||
return pn.Body, pn.Summary
|
||||
}
|
||||
|
||||
// shouldQueue — true when digest is enabled and the candidate's severity is
|
||||
// at or below the configured ceiling.
|
||||
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
|
||||
return t.digestCfg != nil && t.digestCfg.Enabled &&
|
||||
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
|
||||
}
|
||||
|
||||
// queueNudge — phrases the candidate and appends it to the digest queue.
|
||||
// Deduplicates by rule name: if the same rule is already queued, this is a
|
||||
// no-op (the first fire within the window is the one that counts).
|
||||
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
|
||||
for _, q := range t.digestQ {
|
||||
if q.Rule == cand.Rule.Name {
|
||||
return // already queued
|
||||
}
|
||||
}
|
||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||
return
|
||||
}
|
||||
t.digestQ = append(t.digestQ, QueuedNudge{
|
||||
Rule: cand.Rule.Name,
|
||||
Severity: int(cand.Severity),
|
||||
Body: pn.Body,
|
||||
Key: cand.Rule.Name,
|
||||
QueuedAt: now,
|
||||
})
|
||||
t.cachePhrase(pn)
|
||||
}
|
||||
|
||||
// maybeFlush — flushes the digest queue if the window has elapsed since the
|
||||
// first item or the queue reached MaxItems.
|
||||
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
|
||||
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
|
||||
return
|
||||
}
|
||||
first := t.digestQ[0]
|
||||
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
|
||||
len(t.digestQ) >= t.digestCfg.MaxItems {
|
||||
t.flushDigest(ctx, now, state)
|
||||
}
|
||||
}
|
||||
|
||||
// flushDigest — concatenates queued nudge bodies into a single digest
|
||||
// notification and dispatches it. Clears the queue after a successful send.
|
||||
// The digest uses the max severity among queued items for routing.
|
||||
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
|
||||
if len(t.digestQ) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
maxSev := 0
|
||||
for i, q := range t.digestQ {
|
||||
if i > 0 {
|
||||
b.WriteString(" · ")
|
||||
}
|
||||
b.WriteString(q.Body)
|
||||
if q.Severity > maxSev {
|
||||
maxSev = q.Severity
|
||||
}
|
||||
}
|
||||
body := b.String()
|
||||
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{
|
||||
Name: "digest",
|
||||
Severity: loop.Severity(maxSev),
|
||||
},
|
||||
Severity: loop.Severity(maxSev),
|
||||
State: state,
|
||||
}
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: cand,
|
||||
Body: body,
|
||||
Summary: summary,
|
||||
}
|
||||
t.cachePhrase(pn)
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
// keep the queue — the next tick's maybeFlush re-attempts.
|
||||
log.Printf("tick: dispatch digest: %v", err)
|
||||
return
|
||||
}
|
||||
t.digestQ = nil
|
||||
}
|
||||
|
||||
// detectPatterns runs the pattern detector proactively over every
|
||||
// action+object pair that has ever produced an event, independent of
|
||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||
// own schedule reading accumulated history, not only as a side effect of a
|
||||
// live voice turn.
|
||||
//
|
||||
// Idempotence and noise are handled by the store, not here — this function
|
||||
// is safe to call every tick:
|
||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||
// already has a row — in ANY status — produces no second row and no log
|
||||
// spam beyond the one line at genuine creation.
|
||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||
// status in place; the row is never deleted. So the same Lookup check
|
||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||
// calling the same shared path the voice route already used.
|
||||
//
|
||||
// By default this only creates a row for the /routines page to show: it does
|
||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||
// for the restraints that apply even then. A proposal only starts producing
|
||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: distinct event pairs: %v", err)
|
||||
return
|
||||
}
|
||||
announced := false
|
||||
for _, p := range pairs {
|
||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||
continue
|
||||
}
|
||||
if r == nil {
|
||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||
}
|
||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
// One announcement per tick at most, whatever the scan turned up. The
|
||||
// rest are on /routines; they are not lost, they are just not shouted.
|
||||
if announced {
|
||||
continue
|
||||
}
|
||||
announced = t.announceProposal(ctx, r, now, state)
|
||||
}
|
||||
}
|
||||
|
||||
// announceProposal offers a freshly inferred routine through the ordinary
|
||||
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||
// something was actually sent.
|
||||
//
|
||||
// Everything here is restraint. The feature is off unless configured; when on
|
||||
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||
// real send, but the proposal row already exists, so the next tick will not
|
||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||
// he reads it on /routines instead, which is the whole point of the page.
|
||||
//
|
||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||
if !t.proposalCfg.AnnounceProposals() {
|
||||
return false
|
||||
}
|
||||
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||
if cooldown <= 0 {
|
||||
cooldown = config.DefaultProposalCooldown
|
||||
}
|
||||
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||
if !loop.Gate(state, rule) {
|
||||
return false
|
||||
}
|
||||
body := pattern.PhraseRoutine(r)
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||
return false
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
return false // routing dropped it — /routines still has it.
|
||||
}
|
||||
t.lastProposalAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||
// digest entry that outlives one full day is describing a day that's already
|
||||
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
||||
// news. Bounding at one day also means a digest can never silently span a
|
||||
// weekend of quiet hours into an unbounded backlog.
|
||||
const digestExpiry = 24 * time.Hour
|
||||
|
||||
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
||||
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
||||
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
||||
// Anything beyond the cap is still marked drained (it did get its moment;
|
||||
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
||||
// count instead of being spoken in full.
|
||||
const maxDigestSpokenItems = 3
|
||||
|
||||
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
||||
// gate blocked for a genuine restraint reason and durably records the
|
||||
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
||||
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
||||
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
||||
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
||||
// makes repeat calls here harmless, but skipping the phrase call entirely
|
||||
// when a pending entry already exists avoids the LLM round-trip too).
|
||||
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
for _, tr := range trace.RuleTraces {
|
||||
if !tr.PredicateResult || tr.GateResult {
|
||||
continue // didn't want to fire, or wasn't suppressed
|
||||
}
|
||||
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
||||
continue
|
||||
}
|
||||
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
||||
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
||||
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
||||
continue
|
||||
}
|
||||
expires := now.Add(digestExpiry)
|
||||
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
||||
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
||||
} else if deduped {
|
||||
// same suppressed nudge already pending — nothing new to say.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
||||
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
||||
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
||||
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: expire stale digest entries: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
||||
}
|
||||
}
|
||||
|
||||
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
||||
// suppression reasons have actually cleared — quiet hours over, back from
|
||||
// away, out of the meeting. Draining while still suppressed would just be a
|
||||
// second way to nag through quiet hours; the bundle waits for the same "is
|
||||
// it allowed right now" condition a live nudge already waits for.
|
||||
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
||||
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
||||
return
|
||||
}
|
||||
entries, err := t.store.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: pending digest entries: %v", err)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
spoken := entries
|
||||
extra := 0
|
||||
if len(spoken) > maxDigestSpokenItems {
|
||||
spoken = entries[:maxDigestSpokenItems]
|
||||
extra = len(entries) - maxDigestSpokenItems
|
||||
}
|
||||
var b strings.Builder
|
||||
maxSev := 0
|
||||
for i, e := range spoken {
|
||||
if i > 0 {
|
||||
b.WriteString(" · ")
|
||||
}
|
||||
b.WriteString(e.Body)
|
||||
if e.Severity > maxSev {
|
||||
maxSev = e.Severity
|
||||
}
|
||||
}
|
||||
if extra > 0 {
|
||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||
}
|
||||
body := b.String()
|
||||
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||
Severity: loop.Severity(maxSev),
|
||||
State: state,
|
||||
}
|
||||
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
||||
t.cachePhrase(pn)
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch digest bundle: %v", err)
|
||||
return // leave entries pending; retried next tick
|
||||
}
|
||||
ids := make([]int64, len(entries))
|
||||
for i, e := range entries {
|
||||
ids[i] = e.ID
|
||||
}
|
||||
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
||||
log.Printf("tick: drain digest entries: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||
// in config.Load, so this is a pure field copy.
|
||||
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
||||
if len(rc) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]routine.Routine, len(rc))
|
||||
for i, r := range rc {
|
||||
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
||||
// last fire. Each is delivered as a nudge through the normal routing table
|
||||
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
||||
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
||||
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
||||
// already advanced the last-fire time so a transient failure drops that fire
|
||||
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
||||
// no repeat-til-ack).
|
||||
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{
|
||||
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
||||
Severity: loop.Severity(r.Severity),
|
||||
State: state,
|
||||
},
|
||||
Body: r.Body,
|
||||
Summary: r.Body,
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireAcceptedRoutines nudges about the routines the user accepted, once per
|
||||
// interval (Vikunja #366). Accepting used to create a single reminder, so a
|
||||
// non-weekly routine fired once and went quiet forever; the schedule lives in
|
||||
// the proposed_routines row now and the loop re-reads it every tick.
|
||||
//
|
||||
// A routine is a care-class nudge and goes through the restraint gate like any
|
||||
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
|
||||
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
|
||||
// goes out on the next tick that the gate allows — one nudge, held, not dropped
|
||||
// and not repeated.
|
||||
//
|
||||
// The body is literal text built from the detected action and object, not
|
||||
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
|
||||
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
rows, err := t.store.ListAcceptedRoutines(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: list accepted routines: %v", err)
|
||||
return
|
||||
}
|
||||
accepted := make([]routine.Accepted, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.AcceptedTs == nil {
|
||||
continue // accepted before the schedule column existed — no clock to start from.
|
||||
}
|
||||
accepted = append(accepted, routine.Accepted{
|
||||
ID: r.ID,
|
||||
Name: r.Action + " " + r.Object,
|
||||
IntervalDays: r.IntervalDays,
|
||||
Accepted: *r.AcceptedTs,
|
||||
LastFired: r.LastFiredTs,
|
||||
})
|
||||
}
|
||||
|
||||
for _, a := range routine.DueAccepted(accepted, now) {
|
||||
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
|
||||
if !loop.Gate(state, rule) {
|
||||
continue
|
||||
}
|
||||
body := "пора: " + a.Name
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
|
||||
continue
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
continue // routing dropped it — leave it due.
|
||||
}
|
||||
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
|
||||
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireMorningRoutines checks each configured checklist against today's facts
|
||||
// and dispatches a nag listing exactly what's still missing, at most once per
|
||||
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
||||
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
||||
// concern — pulling it into the shared gather path would leak that mapping
|
||||
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
||||
// operator text (item labels joined), not LLM-phrased, same rationale as
|
||||
// cron routines: deterministic, can't hallucinate a checklist item.
|
||||
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
if len(t.morningRoutines) == 0 {
|
||||
return
|
||||
}
|
||||
facts := t.gatherMorningFacts(ctx)
|
||||
|
||||
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||
labels := make([]string, len(cand.Missing))
|
||||
for i, it := range cand.Missing {
|
||||
labels[i] = it.Label
|
||||
}
|
||||
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", "))
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{
|
||||
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||
Severity: loop.Severity(cand.Routine.Severity),
|
||||
State: state,
|
||||
},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
||||
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||
// decision) and morningStatus (read-only query) so the two paths can never
|
||||
// disagree about what evidence exists.
|
||||
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
||||
keys := make(map[string]struct{})
|
||||
for _, r := range t.morningRoutines {
|
||||
for _, it := range r.Items {
|
||||
keys[it.FactKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
facts := make(map[string]store.Fact, len(keys))
|
||||
for k := range keys {
|
||||
f, err := t.store.LatestFact(ctx, k)
|
||||
if err == nil {
|
||||
facts[k] = f
|
||||
continue
|
||||
}
|
||||
if err != store.ErrNoFact {
|
||||
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
// morningStatus is the read-only "what's missing" query the web UI (and
|
||||
// eventually a voice query) calls. Pure recompute over the current facts —
|
||||
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
||||
// "state right now," not "should we nag."
|
||||
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
||||
if len(t.morningRoutines) == 0 {
|
||||
return nil
|
||||
}
|
||||
facts := t.gatherMorningFacts(ctx)
|
||||
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
||||
for _, r := range t.morningRoutines {
|
||||
st := morning.Evaluate(r, facts, now)
|
||||
done := make(map[string]bool, len(st.Completed))
|
||||
for _, it := range st.Completed {
|
||||
done[it.Key] = true
|
||||
}
|
||||
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
||||
for i, it := range r.Items {
|
||||
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
||||
}
|
||||
out = append(out, ipc.MorningRoutineStatus{
|
||||
Name: r.Name,
|
||||
Active: st.Active,
|
||||
WindowStart: r.WindowStart,
|
||||
WindowEnd: r.WindowEnd,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||
//
|
||||
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||
// unprompted delivery in maven stays with the morning nudge and the
|
||||
// dispatcher's policy.
|
||||
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
y, m, d := now.Date()
|
||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
var events []morning.PlanEntry
|
||||
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: calendar events: %v", err)
|
||||
}
|
||||
for _, f := range facts {
|
||||
events = append(events, morning.PlanEntry{
|
||||
At: f.Ts,
|
||||
Text: f.Value,
|
||||
Kind: morning.PlanEvent,
|
||||
// Provenance below a calendar read (an ambient relay, #126) is
|
||||
// hedged rather than recited as fact.
|
||||
Uncertain: f.Confidence < 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
var reminders []morning.PlanEntry
|
||||
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: list reminders: %v", err)
|
||||
}
|
||||
for _, r := range rems {
|
||||
if r.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
fire := r.NextFireTs
|
||||
if fire.IsZero() {
|
||||
fire = r.FireTs
|
||||
}
|
||||
reminders = append(reminders, morning.PlanEntry{
|
||||
At: fire,
|
||||
Text: strings.TrimSpace(r.Payload),
|
||||
Kind: morning.PlanReminder,
|
||||
})
|
||||
}
|
||||
|
||||
var checklistFacts map[string]store.Fact
|
||||
if len(t.morningRoutines) > 0 {
|
||||
checklistFacts = t.gatherMorningFacts(ctx)
|
||||
}
|
||||
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||
|
||||
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||
for i, it := range plan.Items {
|
||||
out.Items[i] = ipc.DayPlanItem{
|
||||
At: it.At,
|
||||
Text: it.Text,
|
||||
Kind: string(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
|
||||
// pending queue longer than this is a bug elsewhere, not a plan to recite.
|
||||
const dayPlanMaxReminders = 500
|
||||
|
||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||
// rule:
|
||||
@@ -932,78 +382,3 @@ func (t *tickLoop) trace() *loop.TickTrace {
|
||||
defer t.mu.Unlock()
|
||||
return t.lastTrace
|
||||
}
|
||||
|
||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||
// daemon's in-memory tick trace cache.
|
||||
type daemonAPI struct {
|
||||
ipc.CoreAPI
|
||||
getTrace func() *loop.TickTrace
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, text string) string
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
if d.chatFn == nil {
|
||||
return "", errors.New("mavend: chat not available")
|
||||
}
|
||||
return d.chatFn(ctx, text), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
trace := d.getTrace()
|
||||
if trace == nil {
|
||||
return ipc.TickTrace{}, nil
|
||||
}
|
||||
return toIPCTickTrace(*trace), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
if d.getMorningStatus == nil {
|
||||
return nil, errors.New("mavend: morning status not available")
|
||||
}
|
||||
return d.getMorningStatus(ctx), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||
if d.getDayPlan == nil {
|
||||
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||
}
|
||||
return d.getDayPlan(ctx), nil
|
||||
}
|
||||
|
||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||
for i, r := range t.RuleTraces {
|
||||
rules[i] = toIPCRuleTrace(r)
|
||||
}
|
||||
return ipc.TickTrace{
|
||||
Now: t.Now,
|
||||
Winner: t.Winner,
|
||||
Rules: rules,
|
||||
}
|
||||
}
|
||||
|
||||
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
|
||||
return ipc.RuleTrace{
|
||||
RuleName: r.RuleName,
|
||||
Severity: int(r.Severity),
|
||||
PredicateResult: r.PredicateResult,
|
||||
GateResult: r.GateResult,
|
||||
GateBlockedBy: r.GateBlockedBy,
|
||||
GateDetail: toIPCGateDetail(r.GateDetail),
|
||||
WasSelected: r.WasSelected,
|
||||
LostTo: r.LostTo,
|
||||
}
|
||||
}
|
||||
|
||||
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
|
||||
return ipc.GateDetail{
|
||||
SnoozeUntil: d.SnoozeUntil,
|
||||
CooldownUntil: d.CooldownUntil,
|
||||
QuietHours: d.QuietHours,
|
||||
CalendarBusy: d.CalendarBusy,
|
||||
Presence: d.Presence,
|
||||
InertKeysMissing: d.InertKeysMissing,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user