Compare commits
119 Commits
c586346a60
...
12667fd3b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 12667fd3b8 | |||
| e4fd6140a9 | |||
| 2ba5d0a60e | |||
| c6b11a6d1d | |||
| 45622eff3d | |||
| 5815f0b8f3 | |||
| f68d49d9e2 | |||
| 36bc603f52 | |||
| 59214b4fdd | |||
| e94c868160 | |||
| de4c47459a | |||
| 27bb9119fb | |||
| 0ab5dc1482 | |||
| 35ae1f41da | |||
| a9db82b04c | |||
| 278eeeffdf | |||
| b23596f54f | |||
| 6e3bb3be97 | |||
| aa7ef33bbf | |||
| f2851b3729 | |||
| d49067f7dd | |||
| b528a8f5c9 | |||
| 44320ee496 | |||
| 337a777d2e | |||
| 576dfd8b4c | |||
| ed9db8dc44 | |||
| a8710c859b | |||
| 1bacbb7952 | |||
| 82d9a3324e | |||
| 03d48ab789 | |||
| 570b204571 | |||
| cb350efb19 | |||
| 43b0c32154 | |||
| b95a0278a4 | |||
| a6b17ada8b | |||
| 92949e886f | |||
| 6b2667b7af | |||
| 494a7721e0 | |||
| 46b58f0278 | |||
| a88c984d16 | |||
| 496559c9dd | |||
| d21b4a65da | |||
| be62660be9 | |||
| cd6549fa51 | |||
| c259ed6c73 | |||
| 8f2377d27d | |||
| d850f1f5fd | |||
| 4ed951040f | |||
| 8d2c1b6f99 | |||
| ee4c26f13c | |||
| 49cadcf1b7 | |||
| 988c2ae981 | |||
| 982c25118a | |||
| de10d7664f | |||
| a08403087f | |||
| 3e87ad7eb6 | |||
| 47128bb1ca | |||
| 8e3b288858 | |||
| 52a4772962 | |||
| 52d80394ec | |||
| 42a7bd88b2 | |||
| b789676244 | |||
| b621c477a0 | |||
| fa67dd82fe | |||
| 52fd218c70 | |||
| 33032b859a | |||
| 0d8cbaec01 | |||
| 1f38e71d1a | |||
| 2de5a339fb | |||
| 1ee9a930d3 | |||
| f48c2280bd | |||
| 888c1c6768 | |||
| 7cacbc8b21 | |||
| dc3cda666e | |||
| 1b354e9b39 | |||
| c07722266a | |||
| be758d9a59 | |||
| 8bbdcd2727 | |||
| f8947bef5a | |||
| b752ec037e | |||
| 4dbeca5a2e | |||
| 9e15ff36aa | |||
| 7955a41105 | |||
| a93a16d7b3 | |||
| dd63180e44 | |||
| 9d80a39a30 | |||
| b5b599e287 | |||
| bb51c28a19 | |||
| 549d4c8380 | |||
| 4766167c3a | |||
| f6f9e75eac | |||
| 1524991adc | |||
| 9da468810e | |||
| 7b4fb6229a | |||
| cdd81e2ad5 | |||
| 32d5f68710 | |||
| fdc18edd87 | |||
| a98d25ecac | |||
| 113508eaac | |||
| 6dc2622596 | |||
| dd91c6961c | |||
| 0560684b35 | |||
| c4cf06d610 | |||
| acd985323e | |||
| 598f4fc011 | |||
| 69db1cf849 | |||
| f5480e281b | |||
| cc72f69769 | |||
| b954e0cea6 | |||
| c82dbd1e65 | |||
| 69ecea19d5 | |||
| 8a13d189bb | |||
| 8e7aa0d451 | |||
| 00595c2211 | |||
| b54ccccd0a | |||
| 4cfef41541 | |||
| 0793955896 | |||
| 71a9a59403 | |||
| d3c63e6493 |
@@ -43,7 +43,7 @@ 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
|
||||
free — `worldGap` in `cmd/mavend/worldmodel.go`, which the owner 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.
|
||||
|
||||
@@ -90,7 +90,7 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
|
||||
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
||||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
||||
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
|
||||
daemon there listens to nobody. They belong on a client machine where he is standing.
|
||||
daemon there listens to nobody. They belong on a client machine where the owner is standing.
|
||||
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
|
||||
needs building to allow it, but no such machine exists yet. **The consequence: the wake
|
||||
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
|
||||
@@ -165,12 +165,32 @@ that stood here until 2026-08-02 was contention, not the model.** See `docs/eval
|
||||
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.
|
||||
|
||||
**Re-measured 2026-08-05 on the fixture as it now stands, 91 cases** (V-320 item 2,
|
||||
`docs/evals/2026-08-05-routing-resident-model.md`): cascade + resident model scores
|
||||
**75.8% full / 80.2% intent-only at p50 1.19s / p95 1.65s**. That is a new baseline and not
|
||||
a movement, because 14 cases were added since the 77-case number above. The model alone
|
||||
scores 37.4% full against 61.5% intent-only, and the gap is slots rather than routing: it
|
||||
routes `reminder` and leaves the time to the daemon, which is what the contract asks. To
|
||||
re-run it, start a **second** llama-server on a fixed host port — the resident one binds
|
||||
`--port 0` inside the container and no host process can reach it.
|
||||
|
||||
**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.
|
||||
|
||||
**The intended third engine is not a generative model** (owner's call, 05-08-2026, V-546,
|
||||
`docs/plans/18-routing-heads-on-e5-small.md`). Routing has a bounded output space, so it is
|
||||
classification, and the 118M multilingual-e5-small is already resident. Three heads on one
|
||||
forward pass: intent, mood, and BIO slot tags. Roughly 5e15 FLOPs to train, so 10 to 30
|
||||
minutes on the workstation. A 100M decoder from scratch is 10 to 20 GPU hours. Two things
|
||||
it buys that a decoder cannot. No grammar is needed, because a softmax cannot emit a value
|
||||
that does not exist. And max softmax is a calibratable confidence, where `Confidence: 1.0`
|
||||
was a hardcode. **Fine-tune a copy of the weights.** The resident embedder backs memory
|
||||
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
|
||||
suite to name the trade.
|
||||
|
||||
`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
|
||||
@@ -214,6 +234,26 @@ New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56
|
||||
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
|
||||
llama-server in that run), so judge it again before quoting a cascade number.
|
||||
|
||||
Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()`
|
||||
(`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because
|
||||
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name.
|
||||
**These grammars are the only path to Praxis, not a faster one.** Measured
|
||||
2026-08-05 with the resident model as router (V-517,
|
||||
`docs/evals/2026-08-05-reach-llm-router.md`): the model alone reaches Praxis
|
||||
**0/12**, the same as the classifier alone, because nothing in the router
|
||||
prompt names a Praxis capability and there is no string for it to write.
|
||||
Through the cascade it is 11/12. Deleting these rules costs every point. Praxis reach
|
||||
was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability
|
||||
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
|
||||
alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5**
|
||||
(`docs/evals/2026-08-05-praxis-reach.md`). Two rules to know before editing: a **stative**
|
||||
lifecycle word ("готово", "принято") needs an item named beside it, while a bare
|
||||
**imperative** ("закрывай") may ask which one. The bare arm additionally requires that
|
||||
the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead
|
||||
of the house. A demonstrative ("отметь это как сделанное") resolves against
|
||||
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
|
||||
the cascade rather than transitioning the wrong item.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
@@ -256,7 +296,7 @@ Seeds are scoring data. Editing one moves a recogniser and must be re-measured a
|
||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||||
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
|
||||
(she talks TO the owner, not about the owner). Pet names ("милый", "дорогой") are forbidden; the name
|
||||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||||
|
||||
@@ -266,18 +306,35 @@ world questions, so she needs to read external sources. What replaces it:
|
||||
|
||||
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
|
||||
about Maven is reported to anyone, and inference stays on the box.
|
||||
- **His data first, then the world.** Every source that reads his facts, notes, calendar,
|
||||
- **The owner's data first, then the world.** Every source that reads the owner's 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.
|
||||
**Verified with the line down on 2026-08-05** (V-508,
|
||||
`docs/evals/2026-08-05-kiwix-offline-fallback.md`): a stopped SearXNG costs nothing,
|
||||
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds the owner waited
|
||||
through. So the connect phase alone is capped at `dialTimeout` (1.5s), while a slow
|
||||
instance that did connect keeps the full 8. **A Russian question reads
|
||||
`wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter
|
||||
is the workaround for an English book and is skipped there. Kiwix catalog names come
|
||||
from the filename, not the `<name>` field.
|
||||
`Response.Empty()` is the whole gate and there is no quality threshold in front of it:
|
||||
the three signals one could read were measured on 2026-08-05 and none of them separate a
|
||||
real question from an invented one. Token overlap would cost "столица Франции" its
|
||||
answer, because the answer is Париж and that word is not in the question. See
|
||||
`docs/evals/2026-08-05-search-quality-signals.md` (V-539). **Which query source claimed
|
||||
a turn is readable on `/chat`** as a badge beside the reply, carried on
|
||||
`ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It
|
||||
rides the context, so `handleText` keeps the one string signature the mic, telegram and
|
||||
the web share.
|
||||
- **External search is allowed and off unless configured**, like the weather and telegram
|
||||
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
|
||||
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
|
||||
it off again.
|
||||
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
||||
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||
- **The owner's notes and facts are never search input.** Looking up why the sky is blue and
|
||||
sending the owner's 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
|
||||
@@ -299,6 +356,16 @@ Vikunja is the durable task store. A task holds the goal, the constraints and th
|
||||
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.
|
||||
|
||||
The MCP tool schemas are deferred, so load the four you actually use in ONE call at the
|
||||
start of a session rather than one lookup per first use:
|
||||
|
||||
```text
|
||||
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
|
||||
```
|
||||
|
||||
`update_task` carrying a `description` resets `done` to false, so closing a task with a
|
||||
write-up takes two calls: the description, then `done: true`.
|
||||
|
||||
## Session workflow
|
||||
|
||||
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
|
||||
|
||||
+11
@@ -82,11 +82,22 @@ FROM debian:trixie-slim AS runtime
|
||||
# tzdata so the TZ env (set in compose) resolves — otherwise Go can't load the
|
||||
# zone and time.Now() stays UTC, and mavend answers clock/date queries and
|
||||
# evaluates quiet-hours in UTC.
|
||||
#
|
||||
# TZ is a build arg as well as an env because the image was self-inconsistent
|
||||
# without it (V-545): compose set TZ=Europe/Samara and Go read it, but
|
||||
# /etc/localtime still pointed at Etc/UTC, so anything asking the system zone
|
||||
# instead of the environment answered UTC. The reminder path shells out to
|
||||
# python dateparser, which is exactly such a caller. Compose passes the same
|
||||
# zone it already declares, so the zone is written in one place.
|
||||
ARG TZ=Etc/UTC
|
||||
ENV TZ=$TZ
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata \
|
||||
python3 python3-pip && \
|
||||
pip3 install --no-cache-dir --break-system-packages 'dateparser==1.4.1' && \
|
||||
apt-get purge -y --auto-remove python3-pip && \
|
||||
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && \
|
||||
echo "$TZ" > /etc/timezone && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# runtime native libs: whisper/ggml (incl. vulkan) are real files in deps/lib.
|
||||
|
||||
@@ -61,7 +61,7 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
|
||||
if h.phraser == nil {
|
||||
return "поговорили."
|
||||
}
|
||||
history := h.chatHistory()
|
||||
history := h.chatHistory(ctx)
|
||||
// The phraser hands back its own fallback text alongside the error, so the
|
||||
// turn survives a dead server and the failure still reaches the log.
|
||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||
|
||||
@@ -24,6 +24,14 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
}
|
||||
}
|
||||
|
||||
// The board is Maven's own store, so a spoken status change is answered here
|
||||
// and never offered to an ecosystem client (Vikunja #512). First, because
|
||||
// task_status is on no allowlist and no capability registry: reaching either
|
||||
// of them would answer a turn about his own task list with a gap.
|
||||
if dec.Slots.Fn == router.TaskStatusFn {
|
||||
return h.resolveTaskStatus(ctx, dec)
|
||||
}
|
||||
|
||||
// Praxis ecosystem tools: intercept before the system command executor.
|
||||
if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn {
|
||||
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -89,7 +90,15 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
// hears; storing the utterance meant recall answered with his own sentence
|
||||
// rather than the value. The utterance stays alongside as provenance —
|
||||
// readable on /trace, never the answer and never embedded.
|
||||
//
|
||||
// The vector id carries a timestamp, so a second tap of the same key adds a
|
||||
// row rather than replacing one, and recall then scores the superseded
|
||||
// value against the current one. CorrectValue and VoidLatestFact already
|
||||
// drop the key's vectors; an ordinary re-tap is the third way a value is
|
||||
// superseded and it did not (#493). Dropping first keeps exactly one vector
|
||||
// per key, which is what "recall answers with the current value" means.
|
||||
if h.recall.memStore != nil {
|
||||
pruneFactVectors(ctx, h.recall.memStore, dec.Slots.Key)
|
||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||
log.Printf("voice: embed fact for memory: %v", err)
|
||||
@@ -114,3 +123,29 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
}
|
||||
return "" // replier phrases the success reply
|
||||
}
|
||||
|
||||
// vectorPruner — the part of the vector index this file needs and memory.Store
|
||||
// does not carry. store.MemoryStore implements it; the in-memory test double
|
||||
// may not, and a double that cannot prune is not a reason to fail a fact write.
|
||||
type vectorPruner interface {
|
||||
DeletePrefix(ctx context.Context, prefix string) (int64, error)
|
||||
}
|
||||
|
||||
// pruneFactVectors drops every vector for one fact key, so the insert that
|
||||
// follows is the only one left. Best-effort and silent on a store that cannot
|
||||
// prune: the fact row is the truth, and a stale vector costs a wrong recall,
|
||||
// not a lost fact.
|
||||
func pruneFactVectors(ctx context.Context, ms memory.Store, key string) {
|
||||
p, ok := ms.(vectorPruner)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := p.DeletePrefix(ctx, "fact:"+key+":")
|
||||
if err != nil {
|
||||
log.Printf("voice: prune memory vectors for %q: %v", key, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("voice: %q superseded, dropped %d stale memory vector(s)", key, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
|
||||
return "", false
|
||||
}
|
||||
|
||||
// listFloor — the keyword test behind topicList, in the shape turnIsAbout takes.
|
||||
func listFloor(u string) bool {
|
||||
_, ok := router.ParseListQuery(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// queryList — "что в списке покупок?", "что мне купить?".
|
||||
//
|
||||
// A query source, so it sits in querySources and either claims the turn or
|
||||
@@ -102,10 +108,15 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
|
||||
// source is: the notes pass would otherwise answer a list question with
|
||||
// whatever note is nearest.
|
||||
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
list, ok := router.ParseListQuery(t.dec.Utterance)
|
||||
if !ok || h.dataStore == nil {
|
||||
if h.dataStore == nil {
|
||||
return "", false
|
||||
}
|
||||
// The seeds decide the subject and listQueryPrefixes is the floor behind
|
||||
// them (V-522). Which list he named is a noun lookup either way.
|
||||
if !h.turnIsAbout(ctx, t, topicList, listFloor) {
|
||||
return "", false
|
||||
}
|
||||
list := router.ListNamedIn(t.dec.Utterance)
|
||||
items, err := h.dataStore.ListItems(ctx, list, "")
|
||||
if err != nil {
|
||||
log.Printf("voice: list items: %v", err)
|
||||
|
||||
+68
-11
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -121,6 +122,12 @@ var querySources = []querySource{
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
||||
// A question about her, above the three sources that search his own data
|
||||
// (Vikunja #555). It has no answer anywhere else: below the boundary
|
||||
// SearXNG answers about somebody else's assistant, and above it his notes
|
||||
// answer by proximity — "кто ты" came back from a note of his, measured on
|
||||
// the box, because the recall index has no idea the subject is her.
|
||||
{name: "self", answer: (*reactiveHandler).querySelf},
|
||||
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
||||
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
||||
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
||||
@@ -161,8 +168,10 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// no query-source field, so a wrong answer could not be told from a
|
||||
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
|
||||
// the utterance and the answer are already on the voice lines above
|
||||
// and below this one.
|
||||
// and below this one. The same name goes to the turn's sink when the
|
||||
// caller asked for one, so /chat can show it (V-539).
|
||||
log.Printf("voice: query claimed by source %q", src.name)
|
||||
noteQuerySource(ctx, src.name)
|
||||
return reply
|
||||
}
|
||||
}
|
||||
@@ -289,6 +298,14 @@ const (
|
||||
feedReadOut = 3
|
||||
)
|
||||
|
||||
// feedFloor — the keyword test behind topicFeed, in the one-string shape
|
||||
// turnIsAbout takes. router.ParseFeedQuery returns the category too, which the
|
||||
// gate has no use for; the caller reads it separately.
|
||||
func feedFloor(u string) bool {
|
||||
_, ok := router.ParseFeedQuery(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
@@ -296,10 +313,13 @@ const (
|
||||
// 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 {
|
||||
// The seeds decide the subject; router.ParseFeedQuery is the floor behind
|
||||
// them (V-522). The category still comes from the utterance either way,
|
||||
// because a topic is marked by a preposition and needs no recogniser.
|
||||
if !h.turnIsAbout(ctx, t, topicFeed, feedFloor) {
|
||||
return "", false
|
||||
}
|
||||
category := router.FeedCategoryOf(t.dec.Utterance)
|
||||
if !h.feedsOn {
|
||||
// Claim only when nothing below can read the world. The reason this
|
||||
// source used to claim unconditionally was that general knowledge would
|
||||
@@ -324,7 +344,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary, category tag and link; she reads the
|
||||
@@ -336,7 +356,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
if category != "" {
|
||||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||
}
|
||||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||
@@ -357,6 +377,19 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
if isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
// Weather was one instance of a wider class (Vikunja #552). Naming a day
|
||||
// does not make a question his agenda: "какой сегодня курс доллара" and
|
||||
// "во сколько закат сегодня" both answered "ничего нет", which reads as an
|
||||
// answer about a subject she never looked at. All of them have an answer
|
||||
// in search, and search sits below this source. So the question must ask
|
||||
// about his schedule, not merely name a day.
|
||||
//
|
||||
// A continuation is exempt. "а завтра?" names no agenda and cannot: the
|
||||
// subject was in the turn before it, and this is the only date-aware
|
||||
// source there is.
|
||||
if !t.dec.Continued && !router.IsAgendaQuestion(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||
if !ok {
|
||||
return "", false
|
||||
@@ -454,6 +487,10 @@ 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) {
|
||||
// A topic source above already paid for this one; see turnVector.
|
||||
if len(t.vec) > 0 {
|
||||
return "", false
|
||||
}
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
@@ -693,11 +730,19 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
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.
|
||||
// A Russian question reads the Russian ZIM verbatim when there is one
|
||||
// (V-508). Kiwix ranks by keyword overlap rather than meaning, so an English
|
||||
// book matches a Russian sentence not at all, and the rewriter exists to
|
||||
// turn the question into English keywords with the resident model. Against a
|
||||
// Russian book that is a translation of his own words back at him: it costs
|
||||
// a model call and drops whatever the keywords do not carry.
|
||||
book, verbatim := h.kiwix.book, false
|
||||
if h.kiwix.bookRU != "" && hasCyrillic(t.dec.Utterance) {
|
||||
book, verbatim = h.kiwix.bookRU, true
|
||||
}
|
||||
|
||||
pattern := t.dec.Utterance
|
||||
if h.kiwix.rewriter != nil {
|
||||
if h.kiwix.rewriter != nil && !verbatim {
|
||||
q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// Fall through to the verbatim question rather than give up. It
|
||||
@@ -708,7 +753,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, h.kiwix.book, h.kiwix.max)
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, book, h.kiwix.max)
|
||||
if err != nil {
|
||||
log.Printf("voice: kiwix: search %q: %v", pattern, err)
|
||||
return "", false
|
||||
@@ -720,7 +765,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
// Logged on the way through, not only on failure. Without this there is no
|
||||
// way to tell from the outside whether an answer came off a ZIM or out of
|
||||
// the model's weights, and those are the two cases worth telling apart.
|
||||
log.Printf("voice: kiwix: %q → %d hits, top %q", pattern, len(hits), top.Title)
|
||||
log.Printf("voice: kiwix: %q in %q → %d hits, top %q", pattern, book, 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
|
||||
@@ -752,6 +797,18 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// hasCyrillic reports whether the text carries a Cyrillic letter, which is the
|
||||
// whole test for "he asked this in Russian". A question mixing a Latin proper
|
||||
// noun into a Russian sentence is still Russian, so one letter is enough.
|
||||
func hasCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Cyrillic, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queryPersonal — stop the walk on a question about him that his own data did
|
||||
// not answer.
|
||||
//
|
||||
|
||||
@@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
@@ -33,5 +36,24 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
}
|
||||
return ""
|
||||
// Phrased from the row, never from the utterance (Vikunja #507). The
|
||||
// replier only ever saw Slots.Text, so it named whatever hour the sentence
|
||||
// contained — including one the parser had rejected or read differently.
|
||||
// A confirmation naming an hour no row holds is worse than a clarify,
|
||||
// because he stops thinking about it.
|
||||
return reminderConfirm(dec.Slots.Time, h.now())
|
||||
}
|
||||
|
||||
// reminderConfirm — the confirmation for a reminder that exists, naming the
|
||||
// stored fire time. Deterministic on purpose: the one sentence that must match
|
||||
// a database row is not one to hand to a 1.7B.
|
||||
func reminderConfirm(fire, now time.Time) string {
|
||||
when := dayPrefix(now, fire)
|
||||
if when == "это" {
|
||||
// Further out than the day words reach — say the date instead of a
|
||||
// word that would be wrong.
|
||||
date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
|
||||
return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -80,8 +81,96 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
||||
for _, r := range spoken {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||
}
|
||||
h.offerCandidates(cands)
|
||||
return tasks.FormatRU(ranked), true
|
||||
h.offerCandidates(ctx, cands)
|
||||
reply := tasks.FormatRU(ranked)
|
||||
// The counted shapes, after the list and only when there are any (V-512).
|
||||
// They answer "what is going wrong with this list" without assessing any of
|
||||
// it, and they are said here rather than announced: no tick rule reads them.
|
||||
if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" {
|
||||
if !strings.HasSuffix(reply, ".") {
|
||||
reply += "."
|
||||
}
|
||||
reply += " " + stalls
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// resolveTaskStatus moves a task he named out loud (Vikunja #512).
|
||||
//
|
||||
// The position path already worked: resolveCandidate answers "первую сделал"
|
||||
// against the list she just read. This is the other half — naming the task
|
||||
// instead of its position, which reached no code at all before the stage-0 rule
|
||||
// in internal/router/taskstatus.go filled the fn slot.
|
||||
//
|
||||
// Three answers besides the move, and none of them guesses. No match says so. A
|
||||
// match on more than one asks which, because closing the wrong task is work he
|
||||
// never finished being marked done. No task named asks which too, since the
|
||||
// router claims the turn without the referent and the list lives here.
|
||||
func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string {
|
||||
live, err := h.api.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
log.Printf("voice: task status: list: %v", err)
|
||||
return "не получилось посмотреть задачи."
|
||||
}
|
||||
if dec.Slots.Text == "" {
|
||||
return "какую задачу?"
|
||||
}
|
||||
match := matchTaskText(live, dec.Slots.Text)
|
||||
switch len(match) {
|
||||
case 0:
|
||||
return "не нашла такой задачи."
|
||||
case 1:
|
||||
default:
|
||||
return "у тебя несколько подходящих — какую именно?"
|
||||
}
|
||||
pick := match[0]
|
||||
status := dec.Slots.Value
|
||||
// A candidate is work Maven proposed and he never confirmed, and the store
|
||||
// refuses candidate → done: the legal move is to open it first. Saying it is
|
||||
// done IS the confirmation, so both writes happen rather than the turn
|
||||
// naming a gap about a distinction he did not make.
|
||||
if pick.Status == store.TaskCandidate && status == store.TaskDone {
|
||||
if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil {
|
||||
log.Printf("voice: task status: promote %d: %v", pick.ID, err)
|
||||
return "не получилось изменить задачу."
|
||||
}
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil {
|
||||
log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err)
|
||||
return "не получилось изменить задачу."
|
||||
}
|
||||
log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status)
|
||||
if status == store.TaskDropped {
|
||||
return "убрала: " + pick.Text
|
||||
}
|
||||
return "закрыла: " + pick.Text
|
||||
}
|
||||
|
||||
// matchTaskText finds the live tasks he could have meant.
|
||||
//
|
||||
// Normalised containment, either direction, over store.NormalizeTaskText — the
|
||||
// same key capture dedupes on, so a task he can file twice is a task he can name
|
||||
// twice. Either direction because he shortens what he said ("молоко" for
|
||||
// "купить молоко") as often as he pads it.
|
||||
//
|
||||
// Deliberately not fuzzy. A ranked best guess would always return exactly one
|
||||
// answer, and the one thing this must be able to say is that it is not sure.
|
||||
func matchTaskText(live []ipc.Task, named string) []ipc.Task {
|
||||
want := store.NormalizeTaskText(named)
|
||||
if want == "" {
|
||||
return nil
|
||||
}
|
||||
var out []ipc.Task
|
||||
for _, t := range live {
|
||||
have := store.NormalizeTaskText(t.Text)
|
||||
if have == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(have, want) || strings.Contains(want, have) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
|
||||
@@ -26,6 +26,9 @@ type taskAPI struct {
|
||||
tasks []ipc.Task
|
||||
listArg string
|
||||
listErr error
|
||||
|
||||
moved []setStatusCall
|
||||
moveErr error
|
||||
}
|
||||
|
||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
@@ -253,3 +256,87 @@ func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he
|
||||
// says is done can be shown to take both legal moves.
|
||||
type setStatusCall struct {
|
||||
id int64
|
||||
status string
|
||||
by string
|
||||
}
|
||||
|
||||
func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
||||
a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by})
|
||||
return a.moveErr
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||
{ID: 8, Text: "оплатить интернет", Status: "open"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
|
||||
})
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
|
||||
}
|
||||
if len(api.moved) != 1 {
|
||||
t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved)
|
||||
}
|
||||
if api.moved[0].id != 7 || api.moved[0].status != "done" {
|
||||
t.Errorf("moved %+v, want id 7 → done", api.moved[0])
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want the task named back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tasks []ipc.Task
|
||||
named string
|
||||
want string
|
||||
}{
|
||||
{"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"},
|
||||
{"two matches", []ipc.Task{
|
||||
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||
{ID: 8, Text: "купить молоко и хлеб", Status: "open"},
|
||||
}, "купить молоко", "несколько"},
|
||||
{"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
api := &taskAPI{tasks: c.tasks}
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
|
||||
})
|
||||
if len(api.moved) != 0 {
|
||||
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
|
||||
}
|
||||
if !strings.Contains(reply, c.want) {
|
||||
t.Errorf("reply = %q, want it to contain %q", reply, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
|
||||
// The store refuses candidate → done. Saying it is done is the confirmation
|
||||
// the candidate was waiting for, so the arm makes both legal moves.
|
||||
api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}}
|
||||
h := taskHandler(api)
|
||||
h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
|
||||
})
|
||||
if len(api.moved) != 2 {
|
||||
t.Fatalf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
if api.moved[0].status != "open" || api.moved[1].status != "done" {
|
||||
t.Errorf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
|
||||
// pending telegram nudge every repeat_interval, and nothing in the tree could
|
||||
// ever mark one acked. None of what follows can be reproduced by hand without
|
||||
// sitting in front of the box for hours, so it is covered here or nowhere.
|
||||
|
||||
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
|
||||
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
|
||||
t.Helper()
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
|
||||
t.Fatalf("seed service_down:db=%s: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
|
||||
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
|
||||
// repeat these tests are about never happens. The daemon wires it (main.go).
|
||||
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
|
||||
t.Helper()
|
||||
rules := loop.DefaultRules()
|
||||
g := loop.NewGatherer(st, rules)
|
||||
d := delivery.NewDispatcher(delivery.Config{
|
||||
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||
Ack: st, Nudges: st, Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// telegramSends counts sends that went out on the telegram reach.
|
||||
func telegramSends(sink *fakeSink, rule string) int {
|
||||
n := 0
|
||||
for _, s := range sink.sends {
|
||||
if s.RuleName == rule {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// outcomes returns the outcome of every nudge row for a rule, newest first.
|
||||
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
|
||||
t.Helper()
|
||||
rows, err := st.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("recent nudges: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, n := range rows {
|
||||
if n.Rule == rule {
|
||||
out = append(out, n.Outcome)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
|
||||
// The condition clearing is the ending that should happen. StillTrue reads
|
||||
// the same DownServices helper the phraser reads, so the repeat stops on
|
||||
// exactly the monitor he was told about.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
if telegramSends(sink, "service_down") == 0 {
|
||||
t.Fatal("the alarm never went out; the rest of this test proves nothing")
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
|
||||
}
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeResolved {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
|
||||
// Still down, still un-acked, and nobody has answered in two hours. That is
|
||||
// not one more repeat away from being answered.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
|
||||
}
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
|
||||
}
|
||||
// Ignored, not resolved: nothing says the service got better.
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeIgnored {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
|
||||
// Down, up, down again. Closing the first run must not make the second run
|
||||
// unreportable, and must not silently reopen the closed rows either.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
first := len(outcomes(t, st, ctx, "service_down"))
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
tl.tick(ctx, now.Add(2*time.Minute))
|
||||
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
|
||||
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "down", now.Add(25*time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(31*time.Minute))
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
|
||||
}
|
||||
got := outcomes(t, st, ctx, "service_down")
|
||||
if len(got) <= first {
|
||||
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
|
||||
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
|
||||
// that says nothing must keep its alarm until the age cap, or a rule author
|
||||
// silences their own alarm by omission.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
|
||||
|
||||
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
|
||||
t.Fatalf("record nudge: %v", err)
|
||||
}
|
||||
|
||||
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
|
||||
}
|
||||
|
||||
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
|
||||
if len(live) != 0 {
|
||||
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An empty attention list used to be answered "ничего не требует внимания"
|
||||
// unconditionally, which is an all-clear Maven had no way to know was true
|
||||
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("healthy and quiet should be an all-clear, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[
|
||||
{"source_id":"src_ntfy","health":"ok"},
|
||||
{"source_id":"src_llamacpp","health":"failed"},
|
||||
{"source_id":"src_imap","health":"stale"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a failed source must not read as all-clear, got %q", reply)
|
||||
}
|
||||
for _, want := range []string{"src_llamacpp", "src_imap"} {
|
||||
if !strings.Contains(reply, want) {
|
||||
t.Errorf("reply names no %s: %q", want, reply)
|
||||
}
|
||||
}
|
||||
if strings.Contains(reply, "src_ntfy") {
|
||||
t.Errorf("the healthy source is named as a problem: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A Praxis that polls nothing knows nothing, which is the state the box is in.
|
||||
func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "источник") {
|
||||
t.Errorf("reply does not say why she cannot tell: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The spec's own mechanism, which the deployed Praxis does not send yet: the
|
||||
// envelope's degraded array is believed without a second call.
|
||||
func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t,
|
||||
`{"items":[],"degraded":["src_metrics"]}`,
|
||||
`[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "src_metrics") {
|
||||
t.Fatalf("the envelope's degraded source is not named: %q", reply)
|
||||
}
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Path == "/api/v1/sources" {
|
||||
t.Error("sources was read even though the response carried degraded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A sources endpoint that errors is not evidence of a fault: the attention call
|
||||
// itself succeeded, and hedging on it would make her permanently uncertain.
|
||||
func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
praxis.SetRouteFault("/api/v1/sources", 500)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Both response shapes decode, because the spec says one and the box sends the
|
||||
// other.
|
||||
func TestPraxisAttentionDecodesBothShapes(t *testing.T) {
|
||||
var bare praxisAttention
|
||||
if err := json.Unmarshal([]byte(`[{"id":"item_1"}]`), &bare); err != nil {
|
||||
t.Fatalf("bare array: %v", err)
|
||||
}
|
||||
if len(bare.Items) != 1 || len(bare.Degraded) != 0 {
|
||||
t.Errorf("bare array decoded as %+v", bare)
|
||||
}
|
||||
|
||||
var env praxisAttention
|
||||
if err := json.Unmarshal([]byte(`{"items":[{"id":"item_2"}],"degraded":["src_a"]}`), &env); err != nil {
|
||||
t.Fatalf("envelope: %v", err)
|
||||
}
|
||||
if len(env.Items) != 1 || len(env.Degraded) != 1 || env.Degraded[0] != "src_a" {
|
||||
t.Errorf("envelope decoded as %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// A source that reports no health at all counts as healthy. A Praxis that never
|
||||
// fills the field would otherwise make every quiet turn a hedge.
|
||||
func TestUnhealthySourcesTreatsAnAbsentHealthFieldAsHealthy(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_a"},{"id":"src_b","health":"stale"}]`)
|
||||
bad, total, err := newPraxisClient(praxis.URL).UnhealthySources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("UnhealthySources: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("total = %d, want 2", total)
|
||||
}
|
||||
if len(bad) != 1 || bad[0] != "src_b" {
|
||||
t.Errorf("bad = %v, want [src_b]", bad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestCalendarStepsAsideForTheWorld — the defect (Vikunja #552). Weather was
|
||||
// one instance of a wider class, and V-474 fixed only that instance. Every one
|
||||
// of these answered "на 05.08.2026 ничего нет" on the deployed daemon, and
|
||||
// every one of them has an answer in search, which sits below the calendar.
|
||||
func TestCalendarStepsAsideForTheWorld(t *testing.T) {
|
||||
h, api := contQueryHandler()
|
||||
for _, u := range []string{
|
||||
"во сколько закат сегодня",
|
||||
"какой сегодня курс доллара",
|
||||
"какой сегодня праздник",
|
||||
"что интересного произошло сегодня в мире",
|
||||
} {
|
||||
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); ok {
|
||||
t.Errorf("the calendar claimed %q with %q", u, reply)
|
||||
}
|
||||
}
|
||||
if api.events != 0 {
|
||||
t.Errorf("CalendarEvents called %d times for world questions, want 0", api.events)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same narrowing: a question about his own day still
|
||||
// reaches the calendar, including the one that names no subject at all.
|
||||
func TestCalendarStillAnswersHisDay(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"что у меня сегодня",
|
||||
"во сколько у меня встреча сегодня",
|
||||
"какие встречи завтра",
|
||||
"что в календаре на завтра",
|
||||
"что сегодня?",
|
||||
} {
|
||||
h, _ := contQueryHandler()
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); !ok {
|
||||
t.Errorf("the calendar passed on %q", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation carries its subject in the turn before it, and the calendar
|
||||
// is the only date-aware source, so the narrowing must not reach it.
|
||||
func TestCalendarStillAnswersAContinuation(t *testing.T) {
|
||||
h, _ := contQueryHandler()
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "а завтра?", Continued: true},
|
||||
}); !ok {
|
||||
t.Error("the calendar passed on a continuation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The chat path must answer when llama-server is down (Vikunja #45 step 4).
|
||||
// Both halves of a turn call the model — the router and the replier — and each
|
||||
// has its own floor: the cascade falls to the classifier, the replier falls to
|
||||
// the stub. This wires a client at a closed port so both floors are exercised
|
||||
// by a dial error rather than by a stubbed error value.
|
||||
func TestChatAnswersWithNoLlamaServer(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead))
|
||||
h.replier = newLLMReplier(dead, nil)
|
||||
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
for _, utt := range []string{
|
||||
"привет",
|
||||
"запиши что кофе закончился",
|
||||
"что у меня сегодня",
|
||||
} {
|
||||
reply := h.handleText(ctx, "web", utt)
|
||||
if reply == "" {
|
||||
t.Errorf("%q answered with nothing; a dead model must degrade to the stub", utt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// daemonAPI.Chat reports an error only when the voice path was never wired.
|
||||
// A turn that reaches handleText always carries text, which is what keeps
|
||||
// mavweb's /api/chat off its error branch when the model is down.
|
||||
func TestChatAPIErrsOnlyWhenUnwired(t *testing.T) {
|
||||
d := &daemonAPI{}
|
||||
if _, err := d.Chat(context.Background(), "web", "привет"); err == nil {
|
||||
t.Fatal("an unwired daemon must say so")
|
||||
}
|
||||
d.chatFn = func(context.Context, string, string) string { return "" }
|
||||
if _, err := d.Chat(context.Background(), "web", "привет"); err != nil {
|
||||
t.Fatalf("a wired daemon must not error: %v", err)
|
||||
}
|
||||
}
|
||||
+35
-4
@@ -179,6 +179,20 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
// she parked — no second parser. If it still does not fill the gap she asks
|
||||
// again, up to MaxAttempts; after that she says out loud that she did not
|
||||
// understand. She never drops the request in silence.
|
||||
// isOwnRequest reports whether an utterance asks for something in its own
|
||||
// right, which is what a clarify answer never does. Two offline tests over
|
||||
// tokens, both already written for other callers: a question shape, and a
|
||||
// capture verb. Cheap on purpose — this runs on the answer to every parked
|
||||
// question, and it must not cost a model call.
|
||||
//
|
||||
// It is not a general relevance test. A bare noun that answers nothing ("синий"
|
||||
// after "Что сделать?") is still treated as an answer and still re-asked, and
|
||||
// that is the intended shape: only an utterance that carries its own request
|
||||
// wins over the question in front of it.
|
||||
func isOwnRequest(text string) bool {
|
||||
return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text)
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
@@ -191,6 +205,23 @@ 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))
|
||||
// He moved on. A parked question used to swallow whatever came next, so one
|
||||
// act she could not fulfil ate the following three turns: "выключи свет в
|
||||
// спальне" asked "Что сделать?", and "кто изобрёл телефон" was scored as an
|
||||
// answer to it, then "как дела" after that (Vikunja #554). Nothing checked
|
||||
// whether the words could be an answer at all.
|
||||
//
|
||||
// Deliberately narrow. It only fires where the answer filled nothing, so a
|
||||
// turn that closes the gap is still an answer whatever shape it has, and
|
||||
// the retry budget is untouched — the count was never the problem. Dropping
|
||||
// the question and routing the utterance as itself is what he meant either
|
||||
// way: if he really was answering, he can say it again, and if he was not,
|
||||
// he gets the thing he asked for instead of being asked a third time.
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 && isOwnRequest(text) {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
log.Printf("voice: clarify — %q is its own request, not an answer to %v; dropping the question", text, q.Missing)
|
||||
return "", false
|
||||
}
|
||||
// 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 "напомни"
|
||||
@@ -305,9 +336,9 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
|
||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
||||
if h.dialogueSessions != nil {
|
||||
now := h.now()
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
h.rememberTurn(prev, dec, now)
|
||||
h.rememberTurn(ctx, prev, dec, now)
|
||||
}
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply == "" {
|
||||
@@ -323,7 +354,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
||||
// rememberTurn stores this turn as the dialogue session the next follow-up
|
||||
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
|
||||
// one long conversation can't grow the session unboundedly.
|
||||
func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
var history []dialogue.Turn
|
||||
if prev != nil {
|
||||
history = append(history, dialogue.Turn{
|
||||
@@ -359,7 +390,7 @@ func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decisi
|
||||
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
|
||||
slots.Text = dec.Utterance
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
||||
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: slots,
|
||||
Timestamp: now,
|
||||
|
||||
@@ -564,3 +564,59 @@ func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStepsAsideForItsOwnRequest — Vikunja #554. An act she could not
|
||||
// fulfil parked "Что сделать?", and the three turns after it were scored as
|
||||
// answers to that question: a world question, then "как дела", then the give-up
|
||||
// line. None of them was ever an answer.
|
||||
func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "выключи свет в спальне"}, "выключи свет в спальне")); !asked {
|
||||
t.Fatal("an act with no fn should be asked about")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled {
|
||||
t.Fatalf("a world question must route as itself, got %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Error("the parked question must be dropped, not left to eat the turn after this one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStillRetriesOnAnAnswerThatMissed — the other half of #554, and the
|
||||
// reason the test above is narrow. A bare noun answers nothing either, but it
|
||||
// carries no request of its own, so she asks again as before.
|
||||
func TestClarifyStillRetriesOnAnAnswerThatMissed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "ага")
|
||||
if !handled || reply == "" {
|
||||
t.Fatalf("a missed answer must still be re-asked, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
t.Error("the question must survive a missed answer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands — the guard runs only
|
||||
// where nothing was filled. "во сколько?" is question-shaped and is also how a
|
||||
// time gets said back, so an answer that closes the gap wins whatever its shape.
|
||||
func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("reminder was not created: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -118,13 +119,13 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
//
|
||||
// Acceptance itself is recorded by /routines, and the tick
|
||||
// loop nudges on the interval from there (Vikunja #366).
|
||||
return "поняла — подтверди на странице рутин, и начну напоминать."
|
||||
return phraser.C(phraser.ConfirmRoutineAuthed, nil)
|
||||
},
|
||||
no: func() string {
|
||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||
log.Printf("voice: dismiss proposed routine: %v", err)
|
||||
}
|
||||
return "хорошо, не буду."
|
||||
return phraser.C(phraser.ConfirmRoutineNo, nil)
|
||||
},
|
||||
},
|
||||
// Hexis execution confirm. Bound to the exact capability + target that
|
||||
@@ -137,7 +138,7 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
yes: func() string {
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return "отменила." },
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
// Tool confirm.
|
||||
{
|
||||
@@ -150,16 +151,16 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
if err != nil {
|
||||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
||||
if out != "" {
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "не получилось выполнить команду."
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
}
|
||||
if out != "" {
|
||||
return "готово: " + firstLine(out)
|
||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "готово."
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
},
|
||||
no: func() string { return "отменила." },
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -170,17 +171,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
|
||||
name := firstWord(stripWake(dec.Utterance))
|
||||
if name == "" {
|
||||
return "не разобрала команду — попробуй иначе."
|
||||
return phraser.C(phraser.ProposeNoVerb, nil)
|
||||
}
|
||||
vars := map[string]string{"name": name}
|
||||
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
|
||||
if err != nil {
|
||||
log.Printf("voice: propose tool %q: %v", name, err)
|
||||
return "команды «" + name + "» нет в списке разрешённых."
|
||||
return phraser.C(phraser.ProposeFailed, vars)
|
||||
}
|
||||
if newly {
|
||||
return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент."
|
||||
return phraser.C(phraser.ProposeNew, vars)
|
||||
}
|
||||
return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент."
|
||||
return phraser.C(phraser.ProposeAlready, vars)
|
||||
}
|
||||
|
||||
// confirmVerdict — the parse of a y/n confirm answer.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -149,12 +150,13 @@ func TestRememberTurnRefreshesTheTopic(t *testing.T) {
|
||||
now: func() time.Time { return contNow },
|
||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||
}
|
||||
h.rememberTurn(nil, router.Decision{
|
||||
ctx := context.Background()
|
||||
h.rememberTurn(ctx, 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{
|
||||
h.rememberTurn(ctx, prev, router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня планы",
|
||||
Slots: router.Slots{Text: "во сколько у меня встреча"},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The list she read at the mic is not the list a browser is looking at
|
||||
// (Vikunja #45 step 3). The clarify store was keyed per reach in #466; the
|
||||
// dialogue session was still one slot for the box, so "второй" typed on the web
|
||||
// closed the second task she had recited out loud.
|
||||
func TestCandidatesDoNotCrossReaches(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, voiceCtx, ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
if reply, handled := h.resolveCandidate(webCtx, "первую сделал", sourceText); handled {
|
||||
t.Fatalf("a web turn picked from the list she read aloud: %q", reply)
|
||||
}
|
||||
live, err := st.ListTasks(context.Background(), "live")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(live) != 2 {
|
||||
t.Fatalf("%d tasks live, want 2 — the web turn moved one", len(live))
|
||||
}
|
||||
// The reach that was offered the list still owns it.
|
||||
if _, handled := h.resolveCandidate(voiceCtx, "первую сделал", sourceVoice); !handled {
|
||||
t.Fatal("the mic lost its own list")
|
||||
}
|
||||
}
|
||||
|
||||
// A selection writes a fact, so the fact must name the reach the words arrived
|
||||
// on. It said "tap:voice" for a typed turn.
|
||||
func TestCandidateProvenanceFollowsTheReach(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ctx, ids, "купить хлеб")
|
||||
|
||||
if _, handled := h.resolveCandidate(ctx, "первую сделал", sourceText); !handled {
|
||||
t.Fatal("the pick was not acted on")
|
||||
}
|
||||
done, err := st.ListTasks(context.Background(), "done")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(done) != 1 {
|
||||
t.Fatalf("%d tasks done, want 1", len(done))
|
||||
}
|
||||
if by := done[0].ResolvedBy; by != string(sourceText) {
|
||||
t.Errorf("resolved_by = %q, want %q", by, sourceText)
|
||||
}
|
||||
}
|
||||
|
||||
// Anaphora is per reach too: an ellipsis typed on the web must not continue the
|
||||
// question he asked at the mic. Both surfaces stay usable at once, which is the
|
||||
// case a single-owner box actually hits — a phone open while he talks.
|
||||
func TestAnaphoraDoesNotCrossReaches(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
now := h.now()
|
||||
|
||||
h.rememberTurn(voiceCtx, nil, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||||
}, now)
|
||||
|
||||
if sess := h.dialogueSessions.Get(dialogueIDOf(webCtx), now); sess != nil {
|
||||
t.Fatalf("the web reach inherited the mic's turn: %+v", sess)
|
||||
}
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(voiceCtx), now)
|
||||
if sess == nil || !strings.Contains(sess.Slots.Text, "встреча") {
|
||||
t.Fatalf("the mic lost its own turn: %+v", sess)
|
||||
}
|
||||
}
|
||||
+71
-4
@@ -263,18 +263,85 @@ func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
// praxisAttention — an attention response in either of the two shapes Praxis
|
||||
// may send (Vikunja #540).
|
||||
//
|
||||
// ECOSYSTEM-SPEC §2.6 says the response carries `degraded: [source_ids]` when a
|
||||
// source is failed or stale, and that Maven is required to say so rather than
|
||||
// report all-clear. The deployed Praxis answers with a bare JSON array and no
|
||||
// envelope at all, so both are decoded here: an array is the items, an object is
|
||||
// the spec envelope. This lands the Maven half without waiting on the server,
|
||||
// and the sources read below is what makes the hedge work meanwhile.
|
||||
type praxisAttention struct {
|
||||
Items []map[string]any
|
||||
Degraded []string
|
||||
}
|
||||
|
||||
func (a *praxisAttention) UnmarshalJSON(data []byte) error {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||
return json.Unmarshal(trimmed, &a.Items)
|
||||
}
|
||||
var env struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Degraded []string `json:"degraded"`
|
||||
}
|
||||
if err := json.Unmarshal(trimmed, &env); err != nil {
|
||||
return err
|
||||
}
|
||||
a.Items, a.Degraded = env.Items, env.Degraded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) (praxisAttention, error) {
|
||||
var out praxisAttention
|
||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// praxisSource — one polled source, as much of it as the hedge needs. The tools
|
||||
// API does not expose sources, so this decodes the plain `/api/v1/sources` rows.
|
||||
type praxisSource struct {
|
||||
ID string `json:"id"`
|
||||
SourceID string `json:"source_id"`
|
||||
Health string `json:"health"`
|
||||
}
|
||||
|
||||
func (s praxisSource) name() string {
|
||||
if s.SourceID != "" {
|
||||
return s.SourceID
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
// UnhealthySources reports which sources cannot be trusted to have reported,
|
||||
// and how many sources Praxis has at all (Vikunja #540).
|
||||
//
|
||||
// Only read when the attention list came back empty, which is the one turn where
|
||||
// an all-clear is at stake. A source whose health field is absent counts as
|
||||
// healthy: a Praxis that never reports health would otherwise make every quiet
|
||||
// turn a hedge, and an unreported field is not evidence of a fault. Everything it
|
||||
// does report other than "ok" — failed, stale, degraded, unknown — counts as
|
||||
// cannot-tell, because none of them mean the source has spoken.
|
||||
func (c *praxisClient) UnhealthySources(ctx context.Context) (bad []string, total int, err error) {
|
||||
var out []praxisSource
|
||||
if err := c.getJSON(ctx, "sources", "/api/v1/sources", &out); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for _, s := range out {
|
||||
if s.Health != "" && s.Health != "ok" {
|
||||
bad = append(bad, s.name())
|
||||
}
|
||||
}
|
||||
return bad, len(out), nil
|
||||
}
|
||||
|
||||
// ListAttentionForEntity is ListAttention scoped to a single canonical Nexus
|
||||
// entity, so callers already holding a resolved entity_id (e.g. after
|
||||
// resolveEntityReference) can ask "what needs attention for this entity"
|
||||
// instead of filtering the unscoped list client-side.
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) (praxisAttention, error) {
|
||||
var out praxisAttention
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -105,6 +106,13 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
px := h.ecosystem.praxis
|
||||
dec, ok := h.resolveSurfacedPosition(dec)
|
||||
if !ok {
|
||||
// A demonstrative with no digest behind it. "я это сделал" is a sentence
|
||||
// about his day, so the rest of the cascade gets it back rather than
|
||||
// hearing "какой пункт?" for something that was never about a пункт.
|
||||
return ""
|
||||
}
|
||||
for _, capability := range praxisCapabilities {
|
||||
for _, alias := range capability.aliases() {
|
||||
if alias == dec.Slots.Fn {
|
||||
@@ -154,18 +162,23 @@ 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)
|
||||
att, err := px.ListAttention(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return phraser.A(phraser.AttentionFail, nil)
|
||||
}
|
||||
items := att.Items
|
||||
if len(items) == 0 {
|
||||
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, started); hedge != "" {
|
||||
return hedge
|
||||
}
|
||||
return phraser.A(phraser.AttentionNone, nil)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
|
||||
@@ -190,11 +203,15 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
|
||||
// a failed surface call must not block delivering the digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
// Recorded in the order she says them, and only for items she could
|
||||
// say: an item skipped above has no position in what he heard (#516).
|
||||
spoken = append(spoken, id)
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.rememberSurfaced(spoken)
|
||||
if len(parts) == 0 {
|
||||
// Praxis returned items and not one of them could be said. "ничего не
|
||||
// требует внимания" is the honest answer; the list line would render as
|
||||
@@ -299,14 +316,14 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
}
|
||||
|
||||
queried := h.now()
|
||||
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
att, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
items, scoped := scopedToEntity(att.Items, entityID)
|
||||
if !scoped {
|
||||
// A Praxis old enough to ignore an unknown query parameter answers the
|
||||
// scoped question with the unscoped list. Reading that back as "по
|
||||
@@ -339,6 +356,11 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
// The scoped list is as exposed to a silent source as the unscoped one,
|
||||
// and a per-entity all-clear is the more convincing of the two (#540).
|
||||
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, queried); hedge != "" {
|
||||
return hedge
|
||||
}
|
||||
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
@@ -783,3 +805,105 @@ func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Dec
|
||||
}
|
||||
return h.handleHexisAct(ctx, dec)
|
||||
}
|
||||
|
||||
// attentionCannotTell returns the hedge to say instead of an all-clear, or ""
|
||||
// when an empty attention list really does mean nothing needs looking at
|
||||
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
//
|
||||
// "Nothing needs attention" and "I cannot currently tell" are different answers
|
||||
// and only one of them was ever said. The spec's mechanism is a `degraded` array
|
||||
// on the attention response, which the deployed Praxis does not send, so the
|
||||
// source health read is the half that works today. It costs one HTTP call and
|
||||
// only on the empty-list turn, which is the only turn where an all-clear is at
|
||||
// stake.
|
||||
//
|
||||
// A failed sources read is deliberately NOT a hedge. The attention call itself
|
||||
// succeeded, and not being able to ask about health is not evidence of a fault —
|
||||
// hedging on it would turn one flaky endpoint into a permanently uncertain
|
||||
// assistant.
|
||||
func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string {
|
||||
if len(degraded) > 0 {
|
||||
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||
"degraded": strings.Join(degraded, ","), "source": "response",
|
||||
})
|
||||
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")})
|
||||
}
|
||||
bad, total, err := px.UnhealthySources(ctx)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis sources: %v", err)
|
||||
return ""
|
||||
}
|
||||
if total == 0 {
|
||||
// A Praxis that polls nothing knows nothing, so its silence is not an
|
||||
// all-clear either. This is the state the box is in as of 2026-08-05:
|
||||
// /api/v1/sources answers with an empty array.
|
||||
h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0})
|
||||
return phraser.A(phraser.AttentionNoSources, nil)
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||
"degraded": strings.Join(bad, ","), "sources": total, "source": "health",
|
||||
})
|
||||
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")})
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rememberSurfaced records the item ids she just read out, replacing whatever the
|
||||
// previous digest left. Called with the ids in speaking order (Vikunja #516).
|
||||
func (h *reactiveHandler) rememberSurfaced(ids []string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.surfacedItems = ids
|
||||
}
|
||||
|
||||
// resolveSurfacedPosition turns a positional item reference into a Praxis item
|
||||
// id, using the list she last read out.
|
||||
//
|
||||
// The router names a position and not an id, because only the daemon has the
|
||||
// list: PraxisGrammars fills the value slot with "2", "last" or "this". An id is
|
||||
// left alone, since "item_ab12" is already one.
|
||||
//
|
||||
// The second return says whether the turn is still Praxis's. A position that
|
||||
// names nothing keeps the turn and clears the slot, so the capability answers its
|
||||
// own "какой пункт?" — he said "второй пункт" and deserves to hear that there is
|
||||
// no second one. A demonstrative that resolves to nothing gives the turn BACK,
|
||||
// because "я это сделал" was probably never about a пункт at all. "это" also
|
||||
// needs the list to hold exactly one item: pointing at one of five is a guess,
|
||||
// and a wrong guess here transitions the wrong item.
|
||||
func (h *reactiveHandler) resolveSurfacedPosition(dec router.Decision) (router.Decision, bool) {
|
||||
ref := dec.Slots.Value
|
||||
if ref == "" || strings.HasPrefix(ref, "item") {
|
||||
return dec, true
|
||||
}
|
||||
h.mu.Lock()
|
||||
ids := h.surfacedItems
|
||||
h.mu.Unlock()
|
||||
|
||||
idx := -1
|
||||
switch {
|
||||
case ref == "this":
|
||||
if len(ids) != 1 {
|
||||
log.Printf("ecosystem: praxis \"это\" has no single item (%d surfaced)", len(ids))
|
||||
return dec, false
|
||||
}
|
||||
idx = 0
|
||||
case ref == "last":
|
||||
idx = len(ids) - 1
|
||||
default:
|
||||
n, err := strconv.Atoi(ref)
|
||||
if err != nil || n < 1 {
|
||||
// Neither a position nor an id: leave it for the capability to
|
||||
// reject rather than silently rewriting what he said.
|
||||
return dec, true
|
||||
}
|
||||
idx = n - 1
|
||||
}
|
||||
if idx < 0 || idx >= len(ids) {
|
||||
log.Printf("ecosystem: praxis position %q has no item (%d surfaced)", ref, len(ids))
|
||||
dec.Slots.Value = ""
|
||||
return dec, true
|
||||
}
|
||||
dec.Slots.Value = ids[idx]
|
||||
return dec, true
|
||||
}
|
||||
|
||||
@@ -167,3 +167,69 @@ func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||
t.Fatalf("an explicit capture was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A second tap of the same key supersedes the first, so recall must hold one
|
||||
// vector and it must be the new value (Vikunja #493). Before this the id
|
||||
// carried a timestamp, both rows stayed, and the superseded value went on
|
||||
// competing for the turn.
|
||||
func TestActionFact_ARetapSupersedesTheOldVector(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
||||
})
|
||||
// A later tap of the same key. The clock moves, so the old id and the new
|
||||
// one differ — which is exactly what used to leave two rows behind.
|
||||
h.now = func() time.Time { return time.Now().Add(time.Hour) }
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"500мл"`},
|
||||
})
|
||||
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want one vector for the key, got %d: %+v", len(hits), hits)
|
||||
}
|
||||
if got := hits[0].Meta["text"]; got != "water — 500мл" {
|
||||
t.Errorf("indexed text = %q, want the current value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Another key is not this key. A prefix delete that widened would take the
|
||||
// whole index with it.
|
||||
func TestActionFact_ARetapLeavesOtherKeysAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я обедал",
|
||||
Slots: router.Slots{Key: "meal", HasKey: true, Value: `"суп"`},
|
||||
})
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
||||
})
|
||||
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "обед"), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, hit := range hits {
|
||||
if hit.Meta["text"] == "meal — суп" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("writing water dropped the meal vector: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,14 @@ func (fs *fakeServer) Requests() []capturedRequest {
|
||||
return out
|
||||
}
|
||||
|
||||
// ResetRequests drops the captured requests, so a test can assert about one
|
||||
// turn without subtracting the setup turn's calls.
|
||||
func (fs *fakeServer) ResetRequests() {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.requests = nil
|
||||
}
|
||||
|
||||
func jsonHandler(status int, body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -330,7 +338,17 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
|
||||
// Maven's praxisClient calls. Every route returns its fixed body until a
|
||||
// fault is injected via SetFault.
|
||||
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
||||
// One healthy source by default: an empty attention list only means
|
||||
// all-clear when something is actually polling (Vikunja #540), and the
|
||||
// other tests here are about attention rather than about source health.
|
||||
return newFakePraxisWithSources(t, attentionBody, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
}
|
||||
|
||||
// newFakePraxisWithSources is newFakePraxis with the /api/v1/sources body
|
||||
// under the test's control, for the degraded and no-sources hedges.
|
||||
func newFakePraxisWithSources(t *testing.T, attentionBody, sourcesBody string) *fakeServer {
|
||||
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||
"GET /api/v1/sources": jsonHandler(http.StatusOK, sourcesBody),
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
|
||||
+20
-5
@@ -8,10 +8,10 @@ import (
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// 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).
|
||||
// voiceDialogueID — the dialogue-session and clarify key for the microphone.
|
||||
// This is a single-user box (ponytail), so one slot per reach suffices; a
|
||||
// second speaker would need per-speaker ids, which waits on voice-print
|
||||
// attribution (see PROGRESS multi-user deferral).
|
||||
const voiceDialogueID = "voice"
|
||||
|
||||
// textDialogueID — the clarify key for a text turn that named no conversation.
|
||||
@@ -97,7 +97,8 @@ var anaphoraResolver router.AnaphoraResolver
|
||||
// followUpMerge fills the current turn's missing slots from a prior
|
||||
// non-expired session — the multi-turn seam. It handles three cases:
|
||||
//
|
||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior).
|
||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior),
|
||||
// except a reminder time the current sentence named and the parser missed.
|
||||
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
|
||||
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
|
||||
// the key for fact-lookup queries and reminder creation.
|
||||
@@ -113,8 +114,22 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r
|
||||
|
||||
// Case 1: same-intent inheritance (existing).
|
||||
if prev.Intent == dialogue.Intent(dec.Intent) {
|
||||
// A reminder that named an hour nobody could read must not borrow the
|
||||
// last one's. Two reminders in a row and the second landed at the
|
||||
// first's time, confirmed as if it had been read from the sentence:
|
||||
// "напомни без четверти восемь выходить" fired at 07:30 (V-543). The
|
||||
// hour is also what fills before the action's own fallback parse can
|
||||
// run, so inheriting it hid a time that did parse.
|
||||
//
|
||||
// Inheriting is still right when the sentence names no time at all,
|
||||
// which is the follow-up this seam exists for.
|
||||
blockTime := dec.Intent == router.IntentReminder &&
|
||||
!dec.Slots.HasTime && router.MentionsTime(dec.Utterance)
|
||||
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
|
||||
dec.Slots = applyDialogueSlots(dec.Slots, merged)
|
||||
if blockTime {
|
||||
dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false
|
||||
}
|
||||
return dec
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,42 @@ func TestFollowUpMerge(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// V-543, measured on the box: four reminders in a row all landed at the
|
||||
// first one's hour, each confirmed as if it had been read from the sentence.
|
||||
// A sentence that names a time and fails to parse must ask, not borrow.
|
||||
t.Run("a named time that did not parse is not inherited", func(t *testing.T) {
|
||||
for _, utt := range []string{
|
||||
"напомни без четверти восемь выходить",
|
||||
"напомни в половине первого пообедать",
|
||||
"напомни завтра принять лекарство",
|
||||
"remind me at noon to stretch",
|
||||
} {
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentReminder,
|
||||
Utterance: utt,
|
||||
Slots: router.Slots{Text: utt},
|
||||
}
|
||||
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
||||
if got.Slots.HasTime {
|
||||
t.Errorf("%q borrowed the previous hour %v", utt, got.Slots.Time)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The follow-up this seam exists for still works: the sentence names no
|
||||
// time, so the previous one is the only one it could mean.
|
||||
t.Run("a follow-up naming no time still inherits", func(t *testing.T) {
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentReminder,
|
||||
Utterance: "и ещё полить цветы",
|
||||
Slots: router.Slots{Text: "полить цветы"},
|
||||
}
|
||||
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
||||
if !got.Slots.HasTime || !got.Slots.Time.Equal(fireAt) {
|
||||
t.Errorf("time not inherited: HasTime=%v Time=%v", got.Slots.HasTime, got.Slots.Time)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("current slot wins over prior (gaps only)", func(t *testing.T) {
|
||||
own := base.Add(48 * time.Hour)
|
||||
cur := router.Decision{
|
||||
|
||||
+40
-7
@@ -54,30 +54,56 @@ var historyMarkersEn = [][2]string{
|
||||
// answers a topic far better than a list of the last five facts does.
|
||||
var historyRecall = []string{" про ", " об ", " о ", " about "}
|
||||
|
||||
// historySide — whose turn the question asks about. The rows read are the same
|
||||
// either way, because a tapped fact is one act seen from two sides, but the
|
||||
// sentence is not: answering "что ты записала сегодня?" with "ты говорил…"
|
||||
// hands the question back instead of answering it (Vikunja #456).
|
||||
type historySide int
|
||||
|
||||
const (
|
||||
historyAskedHim historySide = iota // "что я тебе говорил"
|
||||
historyAskedHer // "что ты записала сегодня"
|
||||
)
|
||||
|
||||
// isHistoryQuery reports whether he is asking what he told her.
|
||||
func isHistoryQuery(u string) bool {
|
||||
_, ok := historyAsks(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// historyAsks reports whether this is a history question, and whose turn it is
|
||||
// about.
|
||||
func historyAsks(u string) (historySide, bool) {
|
||||
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
|
||||
if s == " " {
|
||||
return false
|
||||
return historyAskedHim, false
|
||||
}
|
||||
for _, r := range historyRecall {
|
||||
if strings.Contains(s, r) {
|
||||
return false
|
||||
return historyAskedHim, false
|
||||
}
|
||||
}
|
||||
for _, pair := range historyMarkersEn {
|
||||
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||
return true
|
||||
if strings.Contains(pair[0], "you") {
|
||||
return historyAskedHer, true
|
||||
}
|
||||
return historyAskedHim, true
|
||||
}
|
||||
}
|
||||
toks := historyTokens(s)
|
||||
if !hasAny(toks, "что", "чего") {
|
||||
return false
|
||||
return historyAskedHim, false
|
||||
}
|
||||
// His side is tested first: "отмечать" is on both verb lists, so "что я
|
||||
// отметил" must not read as a question about her.
|
||||
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
|
||||
return true
|
||||
return historyAskedHim, true
|
||||
}
|
||||
return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs)
|
||||
if hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) {
|
||||
return historyAskedHer, true
|
||||
}
|
||||
return historyAskedHim, false
|
||||
}
|
||||
|
||||
// historyTokens splits an utterance into bare words. The punctuation goes
|
||||
@@ -142,7 +168,8 @@ const historyWindow = 24 * time.Hour
|
||||
// pass: the notes pass would otherwise answer this from whatever note happens
|
||||
// to be nearest, which reads as an answer and is not one.
|
||||
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isHistoryQuery(t.dec.Utterance) {
|
||||
side, ok := historyAsks(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, historyScan)
|
||||
@@ -164,8 +191,14 @@ func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (strin
|
||||
if len(said) == 0 {
|
||||
// Claim the turn rather than fall through. "ничего не говорил" is the
|
||||
// true answer, and recall would answer it with an old note instead.
|
||||
if side == historyAskedHer {
|
||||
return "за последние сутки я ничего с твоих слов не записывала.", true
|
||||
}
|
||||
return "за последние сутки ты мне ничего такого не говорил.", true
|
||||
}
|
||||
if side == historyAskedHer {
|
||||
return "я записала: " + strings.Join(said, "; "), true
|
||||
}
|
||||
return "ты говорил: " + strings.Join(said, "; "), true
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,33 @@ func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The rows are the same either way, because a tapped fact is one act seen from
|
||||
// two sides. The sentence is not: "что ты записала" answered with "ты говорил"
|
||||
// hands the question back (Vikunja #456).
|
||||
func TestHistoryAnswersTheSideItWasAsked(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
h, _ := historyHandler(now, ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)})
|
||||
|
||||
his, ok := askHistory(h, "что я тебе говорил?")
|
||||
if !ok || !strings.HasPrefix(his, "ты говорил") {
|
||||
t.Errorf("reply = %q, ok = %v, want his side", his, ok)
|
||||
}
|
||||
hers, ok := askHistory(h, "что ты записала сегодня?")
|
||||
if !ok || !strings.HasPrefix(hers, "я записала") {
|
||||
t.Errorf("reply = %q, ok = %v, want her side", hers, ok)
|
||||
}
|
||||
// "отмечать" is on both verb lists, so his subject has to win.
|
||||
if side, ok := historyAsks("что я отметил?"); !ok || side != historyAskedHim {
|
||||
t.Errorf("historyAsks(что я отметил) = %v, %v", side, ok)
|
||||
}
|
||||
|
||||
empty, _ := historyHandler(now)
|
||||
none, ok := askHistory(empty, "что ты записала сегодня?")
|
||||
if !ok || !strings.Contains(none, "не записывала") {
|
||||
t.Errorf("empty reply = %q, ok = %v, want her side", none, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing said is an answer of its own. Falling through would hand the question
|
||||
// to recall, which answers it with an old note.
|
||||
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasCyrillic(t *testing.T) {
|
||||
for _, s := range []string{"что такое фотосинтез", "кто такой Elon Musk", "фотосинтез"} {
|
||||
if !hasCyrillic(s) {
|
||||
t.Errorf("hasCyrillic(%q) = false; it is a Russian question", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"what is photosynthesis", "", "3:2"} {
|
||||
if hasCyrillic(s) {
|
||||
t.Errorf("hasCyrillic(%q) = true; there is no Cyrillic in it", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The book choice and the rewrite decision are the same decision: a Russian
|
||||
// book reads his question as he asked it, an English one needs it translated
|
||||
// into keywords first (V-508).
|
||||
func TestKiwixBookChoice(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wiring kiwixWiring
|
||||
utterance string
|
||||
wantBook string
|
||||
wantVerb bool
|
||||
}{
|
||||
{"a russian question reads the russian book verbatim",
|
||||
kiwixWiring{book: "en", bookRU: "ru"}, "что такое фотосинтез", "ru", true},
|
||||
{"an english question reads the english book",
|
||||
kiwixWiring{book: "en", bookRU: "ru"}, "what is photosynthesis", "en", false},
|
||||
{"no russian book configured leaves every question on the english one",
|
||||
kiwixWiring{book: "en"}, "что такое фотосинтез", "en", false},
|
||||
} {
|
||||
book, verbatim := tc.wiring.book, false
|
||||
if tc.wiring.bookRU != "" && hasCyrillic(tc.utterance) {
|
||||
book, verbatim = tc.wiring.bookRU, true
|
||||
}
|
||||
if book != tc.wantBook || verbatim != tc.wantVerb {
|
||||
t.Errorf("%s: book=%q verbatim=%v, want %q/%v", tc.name, book, verbatim, tc.wantBook, tc.wantVerb)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -19,8 +19,12 @@ type kiwixWiring struct {
|
||||
client *kiwix.Client
|
||||
rewriter *kiwix.Rewriter // nil ⇒ the question is searched verbatim
|
||||
book string
|
||||
max int
|
||||
runes int
|
||||
// bookRU — searched instead of book when the question is Cyrillic, and
|
||||
// searched verbatim because it is in his language already (V-508). Empty ⇒
|
||||
// every question goes to book.
|
||||
bookRU string
|
||||
max int
|
||||
runes int
|
||||
}
|
||||
|
||||
// wireKiwix builds the ZIM reader from the `kiwix` block, or returns nil when
|
||||
@@ -38,9 +42,13 @@ func wireKiwix(cfg *config.Config, c *llm.Client) *kiwixWiring {
|
||||
w := &kiwixWiring{
|
||||
client: kiwix.New(kc.URL),
|
||||
book: kc.Book,
|
||||
bookRU: kc.BookRU,
|
||||
max: kc.MaxResults,
|
||||
runes: kc.SnippetRunes,
|
||||
}
|
||||
if kc.BookRU != "" {
|
||||
log.Printf("voice: kiwix: russian questions read %q verbatim", kc.BookRU)
|
||||
}
|
||||
switch {
|
||||
case !kc.RewriteEnabled():
|
||||
log.Printf("voice: kiwix at %s (book %q, query rewriting off by config)", kc.URL, kc.Book)
|
||||
|
||||
@@ -127,8 +127,12 @@ func run(args []string) error {
|
||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||
wipe := flag.Bool("wipe", false, "print every table and its row count, then exit without serving; add -confirm-wipe to delete all of it")
|
||||
confirmWipe := flag.Bool("confirm-wipe", false, "with -wipe, actually remove every piece of personal data (facts, notes, vectors, events, tasks, sessions, traces, voiceprints). config, models, passkeys and the encryption key are files and survive")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
allowSeedOnStart = *allowSeed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -208,6 +212,14 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- wipe: never serves, exits when it is done (Vikunja #494) -----
|
||||
if *wipe {
|
||||
if locked {
|
||||
return fmt.Errorf("wipe: the store is locked and there is no key to open it with")
|
||||
}
|
||||
return runWipe(ctx, st, os.Stdout, *confirmWipe)
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
// Pre-declare so the unlock path can wire them later.
|
||||
var (
|
||||
@@ -338,6 +350,8 @@ func run(args []string) error {
|
||||
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),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
nexus: nexusOf(voiceW),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
@@ -605,6 +619,7 @@ func run(args []string) error {
|
||||
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),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
|
||||
+29
-31
@@ -8,6 +8,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -26,44 +27,41 @@ import (
|
||||
// does not say what to do with it. Acting on the bare word would guess, and a
|
||||
// wrong guess here closes work he never finished.
|
||||
|
||||
// candidateOrdinals — the words that pick a position, by index. Prefix match,
|
||||
// because Russian declines them: "первый", "первую", "первое".
|
||||
var candidateOrdinals = []struct {
|
||||
word string
|
||||
nth int
|
||||
}{
|
||||
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
|
||||
{"first", 1}, {"second", 2}, {"third", 3},
|
||||
}
|
||||
// The position words come from the lexicon, which lists every form with its
|
||||
// position and "последний" as -1 (V-522). They used to be stem prefixes here —
|
||||
// {"перв", 1}, {"втор", 2} — which is the shape that sweep removed: a stem
|
||||
// decides meaning by guessing where a word ends, and "трет" also opens
|
||||
// "third-party". The lexicon runs to twelve rather than five, so he can pick
|
||||
// past the fifth of a longer list; resolveCandidate already answers a position
|
||||
// she did not read.
|
||||
|
||||
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
|
||||
// "15" starts with "1" and is a time, not a position.
|
||||
// "15" starts with "1" and is a time, not a position. Digits are not a Russian
|
||||
// word list, so they stay here rather than in the lexicon.
|
||||
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
|
||||
|
||||
// candidateLast — "последний" picks the end of the list whatever its length.
|
||||
var candidateLast = []string{"последн", "last"}
|
||||
|
||||
// parseOrdinal reads which position he named. 0 and false when he named none.
|
||||
// A negative result means the last one.
|
||||
func parseOrdinal(text string) (int, bool) {
|
||||
// Token by token, not substring: " 1" would otherwise match inside
|
||||
// "напомни в 15:00" and turn a reminder into a selection.
|
||||
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
toks := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}) {
|
||||
for _, w := range candidateLast {
|
||||
if strings.HasPrefix(tok, w) {
|
||||
return -1, true
|
||||
}
|
||||
}
|
||||
})
|
||||
for i, tok := range toks {
|
||||
if n, ok := candidateDigits[tok]; ok {
|
||||
return n, true
|
||||
}
|
||||
for _, o := range candidateOrdinals {
|
||||
// Prefix, because Russian declines them: "первый", "первую".
|
||||
if strings.HasPrefix(tok, o.word) {
|
||||
return o.nth, true
|
||||
}
|
||||
// A spoken half hour names the hour it is entering with the same
|
||||
// genitive ordinal: "в половине восьмого" is 07:30, not the eighth
|
||||
// thing she read out. She reads a list and he answers with a time
|
||||
// often enough that this has to be declined here, or the reminder
|
||||
// becomes a selection.
|
||||
if i > 0 && lexicon.IsHalfHour(toks[i-1]) {
|
||||
continue
|
||||
}
|
||||
if n, ok := lexicon.Ordinal(tok); ok {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
@@ -97,20 +95,20 @@ func parseCandidateVerb(text string) (status, say string, ok bool) {
|
||||
// offerCandidates records the list she just read, so his next words can pick
|
||||
// from it. Best effort: no session store, or a session that expired between the
|
||||
// question and the answer, means the words route normally.
|
||||
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
|
||||
func (h *reactiveHandler) offerCandidates(ctx context.Context, cands []dialogue.Candidate) {
|
||||
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||
return
|
||||
}
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), cands)
|
||||
}
|
||||
|
||||
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
||||
// the list she just read.
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
if h.dialogueSessions == nil {
|
||||
return "", false
|
||||
}
|
||||
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(ctx), h.now())
|
||||
if sess == nil || len(sess.Candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
@@ -133,13 +131,13 @@ func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (st
|
||||
// a sentence, and the second half is the next turn.
|
||||
return pick.Label, true
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
|
||||
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), string(src)); err != nil {
|
||||
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
|
||||
return "не получилось изменить задачу.", true
|
||||
}
|
||||
// Spent: the list she read is no longer the list, and a second ordinal
|
||||
// against it would close the wrong task.
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
|
||||
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||
return say + ": " + pick.Label, true
|
||||
}
|
||||
|
||||
+24
-12
@@ -27,6 +27,16 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||
{"", 0, false},
|
||||
// A digit inside a time is not a position.
|
||||
{"напомни в 15:00", 0, false},
|
||||
// Forms the stem list used to miss, and positions past its fifth.
|
||||
{"вторым", 2, true},
|
||||
{"седьмую", 7, true},
|
||||
{"одиннадцатый", 11, true},
|
||||
// A spoken half hour names its hour with the same genitive ordinal, so
|
||||
// this is 07:30 and not the eighth thing she read out (V-522).
|
||||
{"напомни в половине восьмого", 0, false},
|
||||
{"полвосьмого", 0, false},
|
||||
// The ordinal still wins when the half word is not in front of it.
|
||||
{"восьмую сделал", 8, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseOrdinal(c.text)
|
||||
@@ -38,7 +48,7 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||
|
||||
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
|
||||
if _, handled := h.resolveCandidate(context.Background(), "второй", sourceVoice); handled {
|
||||
t.Error("an ordinal with no list behind it was claimed")
|
||||
}
|
||||
}
|
||||
@@ -47,14 +57,14 @@ func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "второй")
|
||||
reply, handled := h.resolveCandidate(ctx, "второй", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "позвонить маме") {
|
||||
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
|
||||
}
|
||||
// Still live: naming one is often the first half of a sentence.
|
||||
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
|
||||
if _, handled := h.resolveCandidate(ctx, "первый", sourceVoice); !handled {
|
||||
t.Error("the list was spent by a read-back")
|
||||
}
|
||||
}
|
||||
@@ -63,9 +73,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал")
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "купить хлеб") {
|
||||
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
|
||||
}
|
||||
@@ -80,7 +90,7 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
}
|
||||
// Spent: a second ordinal against a list that no longer holds would close
|
||||
// the wrong task.
|
||||
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
|
||||
if _, handled := h.resolveCandidate(ctx, "второй", sourceVoice); handled {
|
||||
t.Error("the list survived the pick it was spent on")
|
||||
}
|
||||
}
|
||||
@@ -88,9 +98,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ids, "купить хлеб")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб")
|
||||
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий")
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "1") {
|
||||
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
|
||||
}
|
||||
@@ -118,11 +128,13 @@ func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
|
||||
return ids
|
||||
}
|
||||
|
||||
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
|
||||
// putCandidates binds a list to the reach the ctx names, the way queryTasks
|
||||
// does when she recites one.
|
||||
func putCandidates(h *reactiveHandler, ctx context.Context, ids []int64, labels ...string) {
|
||||
cands := make([]dialogue.Candidate, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(cands)
|
||||
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(ctx, cands)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,32 @@ var worldSeeds = []string{
|
||||
"что мне почитать про историю",
|
||||
"что я должен знать про питон",
|
||||
"what can i watch tonight",
|
||||
// A third shape that looks personal and is not: asking when something
|
||||
// happens (Vikunja #553). "во сколько закат сегодня" scored personal,
|
||||
// because "что у меня сегодня" and "когда моя встреча" put that frame on
|
||||
// the personal side and nothing here answered it. The sunset is the one
|
||||
// thing on his list that is the same for everybody standing outside.
|
||||
// "сегодня" is carried on purpose. Without it these caught nothing: the
|
||||
// day word is most of what pulls the frame personal, because "что у меня
|
||||
// сегодня" is a personal seed and the day word is the half it shares.
|
||||
"во сколько сегодня открывается магазин",
|
||||
"когда сегодня начинается матч",
|
||||
"во сколько сегодня восход солнца",
|
||||
// The other frame a day word carries, and the same story: "что у меня
|
||||
// сегодня" is a personal seed, so "какой сегодня праздник" and "что
|
||||
// интересного произошло сегодня в мире" were refused as his after the
|
||||
// topic seeds had already let them past the weather source.
|
||||
"какой сегодня курс валют",
|
||||
"что сегодня происходит в мире",
|
||||
// The narrative shape (Vikunja #554). "расскажи про Байкал" was refused as
|
||||
// his by 0.0052, and nothing here was phrased as an order rather than a
|
||||
// question: every world seed above opens with an interrogative. So a world
|
||||
// question that names its subject and asks for prose landed nearer "я тебе
|
||||
// рассказывал об этом?", which is the same verb about his own words.
|
||||
"расскажи про байкал",
|
||||
"расскажи про древний рим",
|
||||
"объясни как работает двигатель",
|
||||
"tell me about the roman empire",
|
||||
}
|
||||
|
||||
// personalBoundary holds the embedded seeds. Zero value is usable and means
|
||||
|
||||
@@ -68,6 +68,27 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
||||
{"я хочу узнать про рим", false},
|
||||
{"кто такой гагарин", false},
|
||||
{"how do i boil an egg", false},
|
||||
// Asking when a public thing happens (Vikunja #553). "во сколько закат
|
||||
// сегодня" was answered "не знаю — не нашла у тебя такой записи",
|
||||
// because the frame lived only on the personal side. The pair above it
|
||||
// is the control: "во сколько у меня встреча" is the same frame about
|
||||
// something that IS his, and it has to stay personal.
|
||||
{"во сколько закат сегодня", false},
|
||||
{"когда сегодня заканчивается концерт", false},
|
||||
{"во сколько завтра открывается аптека", false},
|
||||
// The "какой сегодня X" frame. These clear the weather topic after the
|
||||
// V-553 seeds and were then refused here, which is the same defect one
|
||||
// source further down the chain.
|
||||
{"какой сегодня праздник", false},
|
||||
{"что интересного произошло сегодня в мире", false},
|
||||
{"кто выиграл вчера матч", false},
|
||||
// The narrative shape, held out from the seeds above (Vikunja #554).
|
||||
// The control is the row after them: the same verb about his own words
|
||||
// is still his.
|
||||
{"расскажи про эверест", false},
|
||||
{"расскажи про войну 1812 года", false},
|
||||
{"объясни что такое инфляция", false},
|
||||
{"я рассказывал тебе про байкал?", true},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "отметь второй пункт" names a position, and only the daemon knows which item
|
||||
// that is. The router fills the value slot with "2"; this is where it becomes an
|
||||
// item id (Vikunja #516).
|
||||
func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_a","title":"диск заканчивается"},
|
||||
{"id":"item_b","title":"бэкап не прошёл"},
|
||||
{"id":"item_c","title":"сертификат истекает"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")); reply == "" {
|
||||
t.Fatal("attention returned nothing")
|
||||
}
|
||||
|
||||
cases := []struct{ ref, wantItem string }{
|
||||
{"2", "item_b"},
|
||||
{"1", "item_a"},
|
||||
{"last", "item_c"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", c.ref))
|
||||
if !strings.Contains(reply, "принято") {
|
||||
t.Errorf("ref %q: reply %q", c.ref, reply)
|
||||
}
|
||||
if !requestedPathContaining(praxis, c.wantItem) {
|
||||
t.Errorf("ref %q did not acknowledge %s; paths %v", c.ref, c.wantItem, paths(praxis))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A position past the end must not acknowledge the wrong item. It asks.
|
||||
func TestPositionPastTheEndAsksInsteadOfGuessing(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск заканчивается"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"))
|
||||
if !strings.Contains(reply, "какой пункт") {
|
||||
t.Errorf("a position with no item should ask, got %q", reply)
|
||||
}
|
||||
if requestedPathContaining(praxis, "item_a") {
|
||||
t.Error("the only surfaced item was resolved for a position that did not name it")
|
||||
}
|
||||
}
|
||||
|
||||
// No digest yet means no positions. Nothing is mutated.
|
||||
func TestPositionWithNoSpokenListAsks(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||
if !strings.Contains(reply, "какой пункт") {
|
||||
t.Errorf("want the ask, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit id is not a position and passes through untouched.
|
||||
func TestExplicitItemIDIsNotRewritten(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"))
|
||||
if !requestedPathContaining(praxis, "item_zz") {
|
||||
t.Errorf("the id he gave was not the one called; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// An item Praxis sent without a title is never spoken, so it holds no position.
|
||||
func TestUnspokenItemsHoldNoPosition(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_silent"},
|
||||
{"id":"item_said","title":"бэкап не прошёл"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||
if !requestedPathContaining(praxis, "item_said") {
|
||||
t.Errorf("position 1 is the first item she SAID; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// The item id travels in the POST body, so that is what these read.
|
||||
func paths(f *fakeServer) []string {
|
||||
var out []string
|
||||
for _, r := range f.Requests() {
|
||||
out = append(out, r.Path+" "+string(r.Body))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requestedPathContaining(f *fakeServer, want string) bool {
|
||||
for _, r := range f.Requests() {
|
||||
if strings.Contains(string(r.Body), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// "отметь это как сделанное" after a one-item digest points at that item.
|
||||
func TestDemonstrativeResolvesWhenOneItemWasSpoken(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_only","title":"бэкап не прошёл"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"))
|
||||
if !strings.Contains(reply, "принято") {
|
||||
t.Errorf("reply %q", reply)
|
||||
}
|
||||
if !requestedPathContaining(praxis, "item_only") {
|
||||
t.Errorf("the one surfaced item was not acknowledged; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// Pointing at one of several is a guess, and a wrong guess transitions the wrong
|
||||
// item. The turn goes back to the cascade instead.
|
||||
func TestDemonstrativeWithSeveralItemsGivesTheTurnBack(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_a","title":"диск"},
|
||||
{"id":"item_b","title":"бэкап"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||
t.Errorf("want a fall-through, got %q", reply)
|
||||
}
|
||||
for _, p := range paths(praxis) {
|
||||
if strings.Contains(p, "resolve") {
|
||||
t.Error("an ambiguous demonstrative resolved an item anyway")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "я это сделал" with no digest behind it is a sentence about his day.
|
||||
func TestDemonstrativeWithNoDigestGivesTheTurnBack(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||
t.Errorf("want a fall-through, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The row is the whole mechanism, and nothing wrote it (Vikunja #532).
|
||||
//
|
||||
// The existing hysteresis test in internal/store scores the pure function and
|
||||
// passed throughout, which is exactly why this went unnoticed: Resolve was
|
||||
// always correct and was always handed the cold-start Away. So this test asserts
|
||||
// the round trip — the tick writes what gather resolved, and the next load
|
||||
// reads it back — rather than re-testing the function.
|
||||
func TestTickPersistsTheResolvedBucket(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
|
||||
// Cold start: no row, so a load must say Away and the zero time.
|
||||
b, score, updated, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load before: %v", err)
|
||||
}
|
||||
if b != store.Away || score != 0 || !updated.IsZero() {
|
||||
t.Fatalf("cold start = %s/%v/%v, want away/0/zero", b, score, updated)
|
||||
}
|
||||
|
||||
tl := &tickLoop{store: st}
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
|
||||
|
||||
b, score, updated, err = st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load after: %v", err)
|
||||
}
|
||||
if b != store.Present {
|
||||
t.Errorf("bucket = %s, want present", b)
|
||||
}
|
||||
if score != 0.9 {
|
||||
t.Errorf("score = %v, want 0.9", score)
|
||||
}
|
||||
if updated.IsZero() {
|
||||
t.Error("updated_ts was not written, so /dash still reads (never)")
|
||||
}
|
||||
}
|
||||
|
||||
// The singleton stays a singleton, and a later tick overwrites rather than
|
||||
// accumulating. A row per tick would make LoadPresenceState's single-row query
|
||||
// return whichever one SQLite felt like.
|
||||
func TestPresenceStateIsOverwrittenNotAppended(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
tl := &tickLoop{store: st}
|
||||
now := time.Now()
|
||||
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Away, PresenceScore: 0.1}, now.Add(time.Minute))
|
||||
|
||||
b, score, _, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if b != store.Away || score != 0.1 {
|
||||
t.Fatalf("got %s/%v, want the second write (away/0.1)", b, score)
|
||||
}
|
||||
}
|
||||
|
||||
// What the persisted row buys: the hold band. A score sitting between Exit and
|
||||
// Enter holds Present when the last bucket was Present, and stays Away when it
|
||||
// was Away. Before the write existed the second arm was the only one that could
|
||||
// ever run, so presence dropped at roughly four minutes of idle instead of
|
||||
// holding to the exit threshold at about nine.
|
||||
func TestPersistedBucketIsWhatFeedsHysteresis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
tl := &tickLoop{store: st}
|
||||
|
||||
mid := (store.PresenceExit + store.PresenceEnter) / 2
|
||||
if mid <= store.PresenceExit || mid >= store.PresenceEnter {
|
||||
t.Fatalf("%v is not inside the hold band", mid)
|
||||
}
|
||||
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, time.Now())
|
||||
last, _, _, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got := store.Resolve(mid, last); got != store.Present {
|
||||
t.Errorf("Resolve(%v, %s) = %s, want present — the hold band did not apply", mid, last, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The query source that claimed a turn was visible in the daemon log and
|
||||
// nowhere else (V-539). A QA step reading /chat could see a wrong answer but
|
||||
// not tell a wrong answer from a wrongly ordered chain: "почему небо голубое"
|
||||
// answered badly reads the same whether search claimed it, the ZIM did, or the
|
||||
// resident model answered from memory.
|
||||
//
|
||||
// It rides the context rather than a return value because handleText answers
|
||||
// every reach through one string, and threading a second value through the
|
||||
// whole action dispatch would change a signature the mic, telegram and the web
|
||||
// all share. The sink is per turn, created by the caller that wants to read it;
|
||||
// a turn with no sink notes nothing, which is what the mic path does.
|
||||
type querySourceKey struct{}
|
||||
|
||||
// querySourceSink holds the name of the source that claimed one turn. The mutex
|
||||
// is there because a query source may fan out to goroutines of its own, not
|
||||
// because two turns share a sink.
|
||||
type querySourceSink struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *querySourceSink) note(name string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.name = name
|
||||
}
|
||||
|
||||
// Name is the source that claimed, or empty when nothing did or the turn was
|
||||
// not a query at all.
|
||||
func (s *querySourceSink) Name() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.name
|
||||
}
|
||||
|
||||
// withQuerySourceSink returns a context that collects the claiming source, and
|
||||
// the sink to read after the turn has answered.
|
||||
func withQuerySourceSink(ctx context.Context) (context.Context, *querySourceSink) {
|
||||
sink := &querySourceSink{}
|
||||
return context.WithValue(ctx, querySourceKey{}, sink), sink
|
||||
}
|
||||
|
||||
// noteQuerySource records which source claimed the turn. It is a no-op when the
|
||||
// caller did not ask for one.
|
||||
func noteQuerySource(ctx context.Context, name string) {
|
||||
if sink, ok := ctx.Value(querySourceKey{}).(*querySourceSink); ok {
|
||||
sink.note(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuerySourceSinkCollectsTheClaimingName(t *testing.T) {
|
||||
ctx, sink := withQuerySourceSink(context.Background())
|
||||
if sink.Name() != "" {
|
||||
t.Fatalf("a fresh sink names a source: %q", sink.Name())
|
||||
}
|
||||
noteQuerySource(ctx, "kiwix")
|
||||
if got := sink.Name(); got != "kiwix" {
|
||||
t.Errorf("sink.Name() = %q, want kiwix", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A turn with no sink must not panic. The mic path asks for no source, and a
|
||||
// query source calls noteQuerySource unconditionally.
|
||||
func TestNoteQuerySourceWithoutASinkIsSilent(t *testing.T) {
|
||||
noteQuerySource(context.Background(), "search")
|
||||
}
|
||||
|
||||
// The last source to claim wins, because only one does: actionQuery returns on
|
||||
// the first claim. This pins that the sink overwrites rather than appends, so a
|
||||
// second turn on the same context could not read a stale name.
|
||||
func TestQuerySourceSinkKeepsTheLastNote(t *testing.T) {
|
||||
ctx, sink := withQuerySourceSink(context.Background())
|
||||
noteQuerySource(ctx, "search")
|
||||
noteQuerySource(ctx, "kiwix")
|
||||
if got := sink.Name(); got != "kiwix" {
|
||||
t.Errorf("sink.Name() = %q, want kiwix", got)
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,12 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
HasTime: true,
|
||||
},
|
||||
}
|
||||
// The confirmation is phrased from the row now (Vikunja #507), so it
|
||||
// names the stored hour rather than leaving the replier to read one
|
||||
// out of the sentence.
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply != "" {
|
||||
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
||||
if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
reminders, err := st.ListReminders(ctx, 10)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A reminder confirmation is the one sentence that must match a database row.
|
||||
// It used to be phrased by the replier from Slots.Text, which meant it named
|
||||
// whatever hour the sentence contained — including an hour the parser had
|
||||
// rejected or read differently (Vikunja #507).
|
||||
|
||||
func TestReminderConfirmNamesTheStoredHour(t *testing.T) {
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*time.Hour), now) // 19:00 today
|
||||
if !strings.Contains(got, "19:00") {
|
||||
t.Fatalf("confirmation = %q, want the stored 19:00 in it", got)
|
||||
}
|
||||
if !strings.Contains(got, "сегодня") {
|
||||
t.Fatalf("confirmation = %q, want it to say сегодня", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmUsesADateBeyondTheDayWords(t *testing.T) {
|
||||
// dayPrefix answers "это" past послезавтра, and "напомню это в 09:00" is
|
||||
// not a sentence. A date is.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*24*time.Hour), now)
|
||||
if strings.Contains(got, "это") {
|
||||
t.Fatalf("confirmation = %q, want a date rather than the fallback day word", got)
|
||||
}
|
||||
if !strings.Contains(got, "15 августа") {
|
||||
t.Fatalf("confirmation = %q, want the date in it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmIsFeminineAndInformal(t *testing.T) {
|
||||
// The persona checks the phrasing eval enforces apply here too, and this
|
||||
// sentence never passes through a phraser.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(time.Hour), now)
|
||||
for _, bad := range []string{"вы", "ваш", "напомнил ", "рад "} {
|
||||
if strings.Contains(strings.ToLower(got), bad) {
|
||||
t.Fatalf("confirmation = %q contains %q", got, bad)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(got, "хорошо, напомню") {
|
||||
t.Fatalf("confirmation = %q, want it to open with the promise", got)
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,8 @@ func formatTime(t time.Time) string {
|
||||
n := int(diff.Hours())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||
default:
|
||||
return t.Format("2 января 15:04")
|
||||
// Not t.Format("2 января …"): Go reads that as a literal, so every
|
||||
// fact older than a day used to read as January (Vikunja #507).
|
||||
return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// mavend/seed.go — the backdated-fact seam (Vikunja #518).
|
||||
//
|
||||
// The pattern detector needs four events for one action+object, spread by at
|
||||
// least pattern.MinIntervalDays, before it proposes a routine. Nothing could
|
||||
// produce that against a running daemon in one sitting: the only writer is a
|
||||
// fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
|
||||
// same missing step and had been stopped there since they were filed.
|
||||
//
|
||||
// This is the write path that unblocks them, and it is deliberately the narrow
|
||||
// one. It takes a fact, not an event, so pattern.Extract runs for real and a
|
||||
// key the extractor ignores seeds nothing. It runs detectAndPropose, so what a
|
||||
// seed proves is the daemon's own wiring rather than the detector in isolation
|
||||
// — which is what an eval-lab fixture would have proved, and is not what those
|
||||
// four tasks doubt.
|
||||
//
|
||||
// It is off unless mavend was started with -allow-seed, and AuthStepUp in the
|
||||
// authority table besides. See ipc.SeedEventReq and auth.Requirement.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// errSeedDisabled — what a caller gets on an ordinary box. Named rather than
|
||||
// inline so the mavweb route can tell "not allowed here" apart from "the seed
|
||||
// ran and the extractor declined", which look the same to a reader otherwise.
|
||||
var errSeedDisabled = errors.New("mavend: seeding is off (start with -allow-seed)")
|
||||
|
||||
// seedSource — every seeded fact carries this, and no other writer uses it.
|
||||
// The point is that seeded data stays identifiable forever: a fact that came
|
||||
// from a QA sitting must never be mistaken for something he said, either by a
|
||||
// person reading /history or by the wipe in V-494 when it lands.
|
||||
const seedSource = "seed:qa"
|
||||
|
||||
// seedStoreIfAllowed returns st only when -allow-seed was passed, and logs the
|
||||
// fact loudly when it does. A box that can rewrite its own past should say so
|
||||
// in its boot log, so nobody reads a seeded routine months later as evidence of
|
||||
// something he actually did.
|
||||
func seedStoreIfAllowed(st *store.Store) *store.Store {
|
||||
if !allowSeedOnStart {
|
||||
return nil
|
||||
}
|
||||
log.Printf("seed: -allow-seed is ON — backdated fact writes are permitted under source %q (Vikunja #518)", seedSource)
|
||||
return st
|
||||
}
|
||||
|
||||
// SeedEvent writes the fact at the caller's timestamp, extracts an event from
|
||||
// it, and runs the same detect-and-propose step the voice path runs.
|
||||
//
|
||||
// Best-effort is NOT the shape here, unlike detectPattern: a seed that half
|
||||
// worked is a QA result nobody can trust, so every step reports its own
|
||||
// failure. Extraction declining is not a failure — it is the extractor's
|
||||
// documented answer for a value outside its lexicon, and Extracted says so.
|
||||
func (d *daemonAPI) SeedEvent(ctx context.Context, req ipc.SeedEventReq) (ipc.SeedEventResp, error) {
|
||||
if d.seedStore == nil {
|
||||
return ipc.SeedEventResp{}, errSeedDisabled
|
||||
}
|
||||
if req.Key == "" || req.Value == "" {
|
||||
return ipc.SeedEventResp{}, errors.New("mavend: seed needs a key and a value")
|
||||
}
|
||||
if req.Ts.IsZero() {
|
||||
return ipc.SeedEventResp{}, errors.New("mavend: seed needs an explicit timestamp")
|
||||
}
|
||||
|
||||
// No Subject, unlike the voice path: a seeded key must not queue a Nexus
|
||||
// resolution. QA data has no business reaching the ecosystem.
|
||||
factID, err := d.seedStore.WriteFact(ctx, req.Ts, store.KindSelf, req.Key, req.Value, seedSource, 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
return ipc.SeedEventResp{}, fmt.Errorf("seed write fact: %w", err)
|
||||
}
|
||||
resp := ipc.SeedEventResp{FactID: factID}
|
||||
|
||||
ev := pattern.Extract(factID, req.Key, req.Value, req.Ts)
|
||||
if ev == nil {
|
||||
// The fact is written and stays written. Saying so matters: a caller
|
||||
// that assumed a seed always produces an event would otherwise read
|
||||
// four silent successes and conclude the detector is broken.
|
||||
log.Printf("seed: %s=%s wrote fact %d, no event (value outside the action lexicon)", req.Key, req.Value, factID)
|
||||
return resp, nil
|
||||
}
|
||||
resp.Extracted, resp.Action, resp.Object = true, ev.Action, ev.Object
|
||||
|
||||
eventID, err := d.seedStore.CreateEvent(ctx, factID, ev.Action, ev.Object, req.Ts)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("seed create event: %w", err)
|
||||
}
|
||||
resp.EventID = eventID
|
||||
|
||||
r, routineID, err := detectAndPropose(ctx, d.seedStore, ev.Action, ev.Object, req.Ts)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("seed detect: %w", err)
|
||||
}
|
||||
if r == nil {
|
||||
return resp, nil // too few events yet, too irregular, or already decided
|
||||
}
|
||||
resp.Proposed, resp.RoutineID, resp.IntervalDays = true, routineID, r.IntervalDays
|
||||
log.Printf("seed: proposed routine %d — %s/%s every %.1f days", routineID, r.Action, r.Object, r.IntervalDays)
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// Off is the default and it must mean "nothing to write with", not "permission
|
||||
// to refuse later". A daemonAPI with no seedStore writes no fact at all.
|
||||
func TestSeedRefusedWithoutTheFlag(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||
_, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain", Value: "заправил", Ts: time.Now(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("seed succeeded with no seedStore")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "-allow-seed") {
|
||||
t.Errorf("error does not name the flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the task: four seeds spread past the detector's floor
|
||||
// produce a proposal against the real daemon path, which is what nobody could
|
||||
// do before (Vikunja #518). Three seeds must NOT propose — MinEvents is four,
|
||||
// and a test that only checked the happy end would pass on an off-by-one.
|
||||
func TestSeedFourEventsProposesARoutine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
now := time.Now()
|
||||
|
||||
var last ipc.SeedEventResp
|
||||
// Oldest first, three hours apart — past MinIntervalDays (two hours).
|
||||
for i := 3; i >= 0; i-- {
|
||||
var err error
|
||||
last, err = d.SeedEvent(ctx, ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain",
|
||||
Value: "заправил",
|
||||
Ts: now.Add(-time.Duration(i) * 3 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
if !last.Extracted {
|
||||
t.Fatalf("seed %d: no event extracted from a lexicon verb", i)
|
||||
}
|
||||
if i > 0 && last.Proposed {
|
||||
t.Fatalf("proposed after only %d events, MinEvents is 4", 4-i)
|
||||
}
|
||||
}
|
||||
if !last.Proposed {
|
||||
t.Fatal("four spaced events did not propose a routine")
|
||||
}
|
||||
if last.Action != "refill" || last.Object != "cat_water_fountain" {
|
||||
t.Errorf("wrong pair: %s/%s", last.Action, last.Object)
|
||||
}
|
||||
if last.IntervalDays < 0.1 {
|
||||
t.Errorf("interval %v — the detector saw a burst, not a rhythm", last.IntervalDays)
|
||||
}
|
||||
|
||||
// The proposal is readable through the same list the /routines page uses,
|
||||
// which is the wiring an eval-lab fixture would not have proved.
|
||||
proposed, err := d.seedStore.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(proposed) != 1 {
|
||||
t.Fatalf("expected 1 proposed routine, got %d", len(proposed))
|
||||
}
|
||||
}
|
||||
|
||||
// A value outside the action lexicon writes the fact and says it seeded
|
||||
// nothing. Silence here would read as four working seeds and a broken
|
||||
// detector.
|
||||
func TestSeedReportsWhenExtractionDeclines(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
resp, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "mood", Value: "ok", Ts: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if resp.FactID == 0 {
|
||||
t.Error("fact was not written")
|
||||
}
|
||||
if resp.Extracted || resp.EventID != 0 || resp.Proposed {
|
||||
t.Errorf("claimed an event for a non-action value: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// A seed with no timestamp is refused rather than defaulting to now: the only
|
||||
// reason this seam exists is the caller choosing when, so a zero Ts is a bug in
|
||||
// the caller and must not silently write a fact at the wrong time.
|
||||
func TestSeedRequiresAnExplicitTimestamp(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
if _, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain", Value: "заправил",
|
||||
}); err == nil {
|
||||
t.Fatal("seed accepted a zero timestamp")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"regexp"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A question about her — "что ты умеешь", "кто ты" — used to have no answer at
|
||||
// all (Vikunja #555). It reached the personal boundary, which claimed it as his
|
||||
// and said "не знаю — не нашла у тебя такой записи", because the boundary knows
|
||||
// two sides and this is neither: her own description is not his data and it is
|
||||
// not the world's either. Letting it past the boundary is no better, because
|
||||
// then SearXNG answers about somebody else's assistant.
|
||||
//
|
||||
// The description does NOT live in the note store. Notes are his. A note about
|
||||
// her sitting in his index would come back for "что я записал", would be fed to
|
||||
// the digestion worker as something he said, and would be recalled by vector
|
||||
// proximity for questions that are not about her at all. It is her own text, so
|
||||
// it lives here, in one place, and it is the only copy.
|
||||
//
|
||||
// This source sits ABOVE the boundary, because a question about her never had
|
||||
// an answer below it.
|
||||
|
||||
// selfDescription — what she is and what this box actually does. Frozen text,
|
||||
// and the one rule for editing it: name only what is really wired. Anything
|
||||
// that depends on config — the house, the LAN, the feeds, telegram, search — is
|
||||
// named as depending on what he allowed, never claimed outright. Inventing a
|
||||
// capability here is the same defect as inventing a fact, and it is worse than
|
||||
// silence because he would plan around it.
|
||||
//
|
||||
// Written in her own voice, feminine, addressing him informally, because it is
|
||||
// handed to the phraser as the evidence for the answer and the phraser will
|
||||
// keep the words it is given.
|
||||
const selfDescription = `Я Мэйвен, твоя помощница. Я живу на твоём сервере, ` +
|
||||
`и наружу уходит только поисковый запрос — больше ничего.
|
||||
|
||||
Что я делаю сама: запоминаю, что ты мне говоришь, и потом отвечаю на вопросы ` +
|
||||
`об этом; веду заметки; ставлю напоминания; читаю твой календарь и задачи; ` +
|
||||
`отвечаю на вопросы о мире — сначала поиском, а если сети нет, то по ` +
|
||||
`офлайновой энциклопедии.
|
||||
|
||||
Что зависит от того, что ты мне разрешил: дом, локальная сеть, ленты, ` +
|
||||
`список покупок, погода, телеграм. Если что-то из этого не настроено, я ` +
|
||||
`скажу об этом прямо, а не буду выдумывать ответ.
|
||||
|
||||
Говорю по-русски и по-английски.`
|
||||
|
||||
// selfSeeds — the questions this source claims. Scoring data like every other
|
||||
// topic set: editing one moves the recogniser and has to be re-measured against
|
||||
// TestONNXTopics.
|
||||
//
|
||||
// All of them are about HER — what she is, what she can do, who made her. The
|
||||
// neighbouring set is topicAttend, "что требует внимания", which asks about the
|
||||
// state of his things; the two share almost nothing but the second person.
|
||||
var selfSeeds = []string{
|
||||
"что ты умеешь",
|
||||
"что ты можешь делать",
|
||||
"кто ты такая",
|
||||
"расскажи о себе",
|
||||
"какие у тебя возможности",
|
||||
// Added after measuring: it won self by 0.0002, under the margin, and the
|
||||
// floor does not carry it — "способна" names no verb the floor matches.
|
||||
"на что ты способна",
|
||||
"чем ты можешь помочь",
|
||||
"what can you do",
|
||||
"who are you",
|
||||
}
|
||||
|
||||
// selfFloor — the offline floor, for a handler with no embedder or a turn whose
|
||||
// vector never got computed. Narrow on purpose, like every other floor here: it
|
||||
// answers only when the seeds cannot, and a broad guess made blind is worse
|
||||
// than a narrow one.
|
||||
//
|
||||
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the
|
||||
// Russian patterns spell the boundary out.
|
||||
var selfPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])ты\s+(умеешь|можешь)([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])кто\s+ты([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])(расскажи|поведай)\s+о\s+себе([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)\bwhat\s+can\s+you\s+do\b`),
|
||||
regexp.MustCompile(`(?i)\bwho\s+are\s+you\b`),
|
||||
}
|
||||
|
||||
func selfFloor(utterance string) bool {
|
||||
for _, re := range selfPatterns {
|
||||
if re.MatchString(utterance) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// querySelf answers a question about her from selfDescription. The description
|
||||
// goes through the phraser as evidence so the answer is shaped to what he
|
||||
// asked — "что ты умеешь" and "кто ты" want different halves of it — and falls
|
||||
// back to the text itself, which is already readable, if the model is down.
|
||||
func (h *reactiveHandler) querySelf(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicSelf, selfFloor) {
|
||||
return "", false
|
||||
}
|
||||
var reply string
|
||||
if h.phraser != nil {
|
||||
var err error
|
||||
reply, err = h.phraser.PhraseSelf(ctx, t.dec.Utterance, selfDescription)
|
||||
if err != nil {
|
||||
log.Printf("voice: phrase self: %v", err)
|
||||
}
|
||||
}
|
||||
if reply == "" {
|
||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": selfDescription})
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestSelfFloorClaimsAQuestionAboutHerAndNothingElse — the offline floor, which
|
||||
// is what answers with no embedder. Narrow on purpose, so the rows that must
|
||||
// NOT match are the point.
|
||||
func TestSelfFloorClaimsAQuestionAboutHerAndNothingElse(t *testing.T) {
|
||||
claimed := []string{
|
||||
"что ты умеешь",
|
||||
"что ты можешь",
|
||||
"а что ты умеешь?",
|
||||
"кто ты",
|
||||
"кто ты такая?",
|
||||
"расскажи о себе",
|
||||
"what can you do",
|
||||
"who are you",
|
||||
}
|
||||
for _, u := range claimed {
|
||||
if !selfFloor(u) {
|
||||
t.Errorf("%q is a question about her and the floor missed it", u)
|
||||
}
|
||||
}
|
||||
declined := []string{
|
||||
"что у меня сегодня",
|
||||
"расскажи про байкал",
|
||||
"кто изобрёл телефон",
|
||||
"что требует внимания",
|
||||
"запиши что я пил воду",
|
||||
// The floor spells its own word boundaries out, because Go's \b never
|
||||
// fires next to a Cyrillic letter. Without that these would match.
|
||||
"кто тыкал в розетку",
|
||||
"расскажи о себестоимости",
|
||||
}
|
||||
for _, u := range declined {
|
||||
if selfFloor(u) {
|
||||
t.Errorf("%q is not about her and the floor claimed it", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfSourceAnswersFromTheDescription — with no embedder the source falls
|
||||
// to the floor, and the answer has to be the description rather than silence.
|
||||
func TestSelfSourceAnswersFromTheDescription(t *testing.T) {
|
||||
h := personalHandler()
|
||||
reply, claimed := h.querySelf(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "что ты умеешь"},
|
||||
})
|
||||
if !claimed {
|
||||
t.Fatal("a question about her must be claimed above the boundary")
|
||||
}
|
||||
if !strings.Contains(reply, "напоминания") {
|
||||
t.Errorf("the answer must come from the description: %q", reply)
|
||||
}
|
||||
if _, claimed := h.querySelf(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "почему небо синее"},
|
||||
}); claimed {
|
||||
t.Error("a world question must pass this source")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfDescriptionHoldsThePersona — it is her own text and she reads it out,
|
||||
// so the same rules the phrasing eval enforces apply to it. Feminine
|
||||
// self-reference, informal address, no pet names.
|
||||
func TestSelfDescriptionHoldsThePersona(t *testing.T) {
|
||||
lower := strings.ToLower(selfDescription)
|
||||
for _, bad := range []string{"я рад ", "я готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(lower, bad) {
|
||||
t.Errorf("the description breaks the persona on %q", bad)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"тво", "ты"} {
|
||||
if !strings.Contains(lower, want) {
|
||||
t.Errorf("the description must address him directly, missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfDescriptionClaimsNothingUnconditionally — the constraint that makes
|
||||
// this text safe to read out. Every capability that depends on config has to be
|
||||
// named as depending on it, and inventing one here is the same defect as
|
||||
// inventing a fact.
|
||||
func TestSelfDescriptionClaimsNothingUnconditionally(t *testing.T) {
|
||||
conditional := selfDescription[strings.Index(selfDescription, "Что зависит"):]
|
||||
for _, cap := range []string{"дом", "локальная сеть", "ленты", "список покупок", "погода", "телеграм"} {
|
||||
if !strings.Contains(conditional, cap) {
|
||||
t.Errorf("%q is configured, not wired — it must sit under the conditional half", cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,6 +347,55 @@ func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
|
||||
}
|
||||
|
||||
// scriptedPhraser answers the chat path from the same script the router reads.
|
||||
//
|
||||
// It exists because actionChat calls h.phraser.PhraseChat, and the production
|
||||
// implementation posts raw HTTP to /v1/chat/completions rather than going
|
||||
// through the llm client scriptedLLM stands in for. So until this, no scenario
|
||||
// could script what she SAYS on a chat turn: the simulator wired phraser.NewStub()
|
||||
// and every chat reply came back as a pick from fallbacks_ru_v1.json, four
|
||||
// variants deep, which varied between two runs of one scenario (V-542 item 4).
|
||||
//
|
||||
// Everything except PhraseChat is the Stub's, by embedding. A nudge and a
|
||||
// reminder are phrased by the tick loop, which has its own phraser and its own
|
||||
// assertions; this seam is only about the conversation.
|
||||
type scriptedPhraser struct {
|
||||
*phraser.Stub
|
||||
entries []scriptEntry
|
||||
}
|
||||
|
||||
// PhraseChat returns the scripted reply for the utterance, or an error when the
|
||||
// scenario scripted none. The error rather than a fallback is deliberate and
|
||||
// matches scriptedLLM: actionChat logs it and falls back to ChatFallback(), so a
|
||||
// scenario that never meant to assert on a chat reply behaves exactly as it did
|
||||
// before, and one that DID means to is told its script has a hole.
|
||||
func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, _ []dialogue.Turn) (string, error) {
|
||||
for _, e := range p.entries {
|
||||
if e.Reply == "" {
|
||||
continue
|
||||
}
|
||||
if e.Match != "" && !strings.Contains(strings.ToLower(utterance), strings.ToLower(e.Match)) {
|
||||
continue
|
||||
}
|
||||
return chatReplyText(e.Reply), nil
|
||||
}
|
||||
return "", fmt.Errorf("simulator: no scripted chat reply for %q", truncateRunes(utterance, 60))
|
||||
}
|
||||
|
||||
// chatReplyText reads a scripted reply in either shape the phrasing contract
|
||||
// allows: the {"response","mood"} object the model emits, or plain text.
|
||||
// LLMPhraser does this parse itself, so a scenario writes one thing and both
|
||||
// paths understand it.
|
||||
func chatReplyText(reply string) string {
|
||||
var out struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reply), &out); err == nil && out.Response != "" {
|
||||
return out.Response
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Building the world
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -440,7 +489,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
api: api,
|
||||
matcher: matcher,
|
||||
tools: tool.NewExecutor(api, 5*time.Second),
|
||||
phraser: phraser.NewStub(),
|
||||
phraser: &scriptedPhraser{Stub: phraser.NewStub(), entries: sc.Script},
|
||||
replier: newLLMReplier(scripted, nil),
|
||||
now: clock.Now,
|
||||
dataStore: st,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "conversation_anaphora",
|
||||
"description": "Five consecutive Russian turns about one object, replayed from the run that found V-542 on the box on 05-08-2026. He names a monitor, then asks four questions that all say \"он\" and never name it again.\n\nThis scenario exists because the shape had nowhere to fail. The routing fixture scores one utterance at a time, so a conversation that breaks on its second turn cannot lose a point there, and V-44 step 2 could only be verified by hand. That is item 3 of V-542.\n\nFour of the five replies below are WRONG, and the assertions pin them anyway. Read them as the recorded defect rather than the contract: she has the last four turns in front of her and never once names the thing he is asking about. Every wrong assertion is marked in its step note with what it must become. When V-542 lands, those flip and the ones marked correct do not move.\n\nWhat the four assert is that the reply LACKS \"монитор\". Absence is the defect itself: she is answering a question about a thing she wrote down two minutes ago and cannot name it. It also survives the fallback picker, which matters on the three query turns — they refuse from internal/phraser/fallbacks_ru_v1.json, four variants deep, and the same scenario returned \"тут я пас.\" one run and \"не знаю, честно.\" the next, so a string assertion there would pin the picker rather than the daemon.\n\nTurn 4 asserts its text as well, because that turn goes through the chat path and the chat path is now scriptable. scriptedPhraser in simulator_test.go answers PhraseChat from the same script entries the router reads (V-542 item 4); before it, the simulator wired phraser.NewStub() and no scenario could say what she SAYS on a chat turn at all.\n\nThe routes are scripted exactly as the box produced them, because the failure is not the model's. Turn 1 went to fact despite \"давай поболтаем\", every question after it went to query, and turn 4 went to chat. A scripted route is what lets this scenario pin the daemon's half without a llama-server in the loop.",
|
||||
"start": "2026-08-05T14:00:00+03:00",
|
||||
"script": [
|
||||
{
|
||||
"match": "купил новый монитор",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"purchase\",\"value\":\"новый монитор\"}]",
|
||||
"reply": "{\"response\":\"записала: новый монитор.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "он большой",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"а он большой?\"}]"
|
||||
},
|
||||
{
|
||||
"match": "сколько он примерно стоит",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"сколько он примерно стоит по-твоему?\"}]"
|
||||
},
|
||||
{
|
||||
"match": "переплатил",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"мне кажется я переплатил\"}]",
|
||||
"reply": "{\"response\":\"я не знаю, о каком именно устройстве ты говоришь.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "стоит его вернуть",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"стоит его вернуть?\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"\"}]",
|
||||
"reply": "{\"response\":\"я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "14:00",
|
||||
"note": "CORRECT, and it is the first half of the defect. \"давай поболтаем\" is an explicit request to converse and the turn is filed as a fact anyway. Storing what he said is not wrong on its own — he did buy a monitor — but the object then lives in the fact store and never enters the transcript PhraseChat reads. That is V-542 decision 2: either the marker claims the turn at stage 0, or it means nothing and comes out of the fixture.",
|
||||
"say": "давай поболтаем: я вчера купил новый монитор",
|
||||
"expect_events": ["purchase"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:01",
|
||||
"note": "WRONG. \"он\" is the monitor from one turn ago, and she says she has no record of it. followUpMerge inherits prev.Slots.Key, and a query turn asking about a pronoun has no key to merge, so the question reaches the query sources naked and the notes source answers the only way it can. Must become: an answer about the monitor, or a route to chat where the transcript is.",
|
||||
"say": "а он большой?",
|
||||
"expect_reply_lacks": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:02",
|
||||
"note": "WRONG, and it rules out one explanation. This is not the previous turn failing to stick — it is the same wall a second time, two turns from where the monitor was named. Nothing accumulates across query turns.",
|
||||
"say": "сколько он примерно стоит по-твоему?",
|
||||
"expect_reply_lacks": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:03",
|
||||
"note": "WRONG, and it is the same wall from the other side. This turn routed chat, so it HAD the history that Session.History holds, and it asks which device he means anyway — because turn 1's object went to the fact store rather than the transcript. So a source reading the conversation is not sufficient on its own; decision 1 has to say which store the referent comes from. This is the one step whose text is pinned: the reply is scripted and reaches PhraseChat, so it is the box's own words rather than a fallback pick. Must become: a reply that names the monitor.",
|
||||
"say": "мне кажется я переплатил",
|
||||
"expect_reply_contains": ["о каком именно устройстве"],
|
||||
"expect_reply_lacks": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:04",
|
||||
"note": "WRONG. The fifth turn is the one that shows the cost. A returns question about a purchase two minutes old is answered with \"не нашла у тебя такой записи\", which is wrong in kind rather than merely unhelpful: the record exists, she wrote it herself at 14:00 under the key purchase.",
|
||||
"say": "стоит его вернуть?",
|
||||
"expect_reply_lacks": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:05",
|
||||
"note": "CORRECT, and it is the control. Nothing in five conversational turns was sent at him unprompted, and a tick with him mid-conversation stays silent. Whatever V-542 changes must not change this.",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -74,9 +74,9 @@
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"note": "he asks what he missed, and Praxis holds one unresolved item — the morning medicine — so she reads that back. This step pinned \"не знаю\" until 05-08-2026, and that was the keyword floor's blind spot rather than a rule: isAttentionQuery does not match \"что я пропустил\", while the topicAttend seeds carry \"что важное я пропустил\" almost verbatim. The seeds only started deciding when turnVector fixed the empty query vector every topic source was reading (V-547). Reading a surfaced item aloud is not inventing a morning summary, so the no-hallucination floor still holds; what moved is which source answers. 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_contains": ["требует внимания", "morning_medicine"],
|
||||
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -160,6 +161,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
log.Printf("tick: gather: %v", err)
|
||||
return
|
||||
}
|
||||
t.savePresence(ctx, state, now)
|
||||
|
||||
// proactive: at most one candidate, max severity.
|
||||
cand, trace := loop.ExplainTick(state, t.rules)
|
||||
@@ -249,6 +251,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
return
|
||||
}
|
||||
keys = t.repeatableRules(keys)
|
||||
keys = t.stopFinishedAlarms(ctx, keys, state, now)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -260,6 +263,108 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// savePresence writes back the bucket GatherState just resolved.
|
||||
//
|
||||
// It lives here and not in GatherState because that method holds a read-only
|
||||
// transaction on purpose — one consistent snapshot per tick — and a write
|
||||
// inside it would either break that guarantee or quietly upgrade the
|
||||
// transaction. The tick is the layer that already owns writes.
|
||||
//
|
||||
// Nothing wrote this row before (Vikunja #532), and the row is the whole
|
||||
// mechanism, so two things were broken at once. Hysteresis was dead: lastBucket
|
||||
// read the cold-start Away on every tick, so store.Resolve only ever took the
|
||||
// `last == Away` arm and demanded a full PresenceEnter score to say he is
|
||||
// there. The 0.30-0.55 hold band the function exists to provide never applied
|
||||
// once. And every readout lied: /dash and ipc.Presence read this row, so they
|
||||
// showed "away — score 0.00 (never)" while desk_active facts were arriving
|
||||
// every sixty seconds.
|
||||
//
|
||||
// A failure logs and the tick continues. The gate reads the in-memory bucket,
|
||||
// which is why nudge routing kept working through all of this — losing the
|
||||
// write costs the next tick's hysteresis, not this tick's decisions.
|
||||
func (t *tickLoop) savePresence(ctx context.Context, state loop.State, now time.Time) {
|
||||
if err := t.store.SavePresenceState(ctx, state.Presence, state.PresenceScore, now); err != nil {
|
||||
log.Printf("tick: save presence state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// maxAlarmAge — how long one un-acked telegram alarm may keep repeating.
|
||||
//
|
||||
// This is the floor brake and it applies to every rule, including one that
|
||||
// says nothing about its own condition (Vikunja #535). Nothing in the tree can
|
||||
// ack a telegram nudge: MarkAcked has no caller outside internal/store, and the
|
||||
// only ack that exists is a voice "готово" on a box that runs no voice loop. So
|
||||
// "repeat until acked" meant "repeat forever", and it did — every five minutes
|
||||
// for over two hours.
|
||||
//
|
||||
// Two hours at the five-minute default is about 24 messages, which is already
|
||||
// past the point of being read. An alarm nobody answered in two hours is not
|
||||
// one more repeat away from being answered, and the right move is to stop
|
||||
// talking, not to talk louder.
|
||||
const maxAlarmAge = 2 * time.Hour
|
||||
|
||||
// stopFinishedAlarms returns the keys that may still repeat, and closes the
|
||||
// rest.
|
||||
//
|
||||
// Two ways an alarm ends without him. The condition cleared, which the rule
|
||||
// answers through StillTrue — deliberately NOT Predicate, which is
|
||||
// edge-triggered and reads false one tick after the alarm is raised, so using
|
||||
// it would cancel every alarm immediately. Or the alarm simply got old, which
|
||||
// is the bound that does not need the rule's cooperation.
|
||||
//
|
||||
// A rule with no StillTrue is not treated as resolved. Silence about the
|
||||
// condition is not evidence the condition cleared, so those keys only ever stop
|
||||
// on age.
|
||||
func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state loop.State, now time.Time) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
live := keys[:0:0]
|
||||
for _, key := range keys {
|
||||
outcome := ""
|
||||
switch r := byName[key]; {
|
||||
case r.StillTrue != nil && !r.StillTrue(state):
|
||||
outcome = store.NudgeResolved
|
||||
case t.alarmIsOlderThan(ctx, key, maxAlarmAge, now):
|
||||
// Not "resolved": nothing says the thing got better. This is her
|
||||
// giving up on being answered, and /notifications should say so.
|
||||
outcome = store.NudgeIgnored
|
||||
}
|
||||
if outcome == "" {
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
n, err := t.store.ResolvePendingTelegram(ctx, key, outcome, now)
|
||||
if err != nil {
|
||||
// Could not close it, so do not drop it either: repeating is the
|
||||
// lesser fault against losing the alarm entirely.
|
||||
log.Printf("tick: stop alarm %s: %v", key, err)
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
log.Printf("tick: alarm %s ended (%s), %d pending nudge(s) closed", key, outcome, n)
|
||||
}
|
||||
return live
|
||||
}
|
||||
|
||||
// alarmIsOlderThan reports whether the oldest un-acked send for this rule is
|
||||
// past the cap. A read failure answers false: an alarm that repeats one more
|
||||
// time is better than one silenced by a transient store error.
|
||||
func (t *tickLoop) alarmIsOlderThan(ctx context.Context, rule string, age time.Duration, now time.Time) bool {
|
||||
oldest, err := t.store.OldestPendingTelegram(ctx, rule)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNudgeNotFound) {
|
||||
log.Printf("tick: oldest pending %s: %v", rule, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return now.Sub(oldest) >= age
|
||||
}
|
||||
|
||||
// repeatableRules drops keys whose rule is not wired any more.
|
||||
//
|
||||
// The repeat path reads the nudges table, not the rule set: any sev4 telegram
|
||||
|
||||
+62
-4
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||
@@ -22,6 +23,16 @@ type daemonAPI struct {
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
// nexus — the identity client, nil when no nexus block is configured. It
|
||||
// is what makes ResolveEntity answerable at all; without it the store
|
||||
// adapter's refusal stands, and a surface that wanted an entity id says so
|
||||
// instead of storing a name.
|
||||
nexus *nexusClient
|
||||
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
|
||||
// the whole off-switch for the backdated write path (Vikunja #518), and it
|
||||
// is a store rather than a bool so that leaving the flag off means the
|
||||
// method has nothing to write with, not merely permission to refuse.
|
||||
seedStore *store.Store
|
||||
}
|
||||
|
||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||
@@ -35,11 +46,58 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
if d.chatFn == nil {
|
||||
return "", errors.New("mavend: chat not available")
|
||||
// nexusOf — the identity client the voice wiring built, or nil. Same shape as
|
||||
// embedderOf: a wiring that is absent and a wiring with no nexus block are one
|
||||
// answer here.
|
||||
func nexusOf(w *voiceWiring) *nexusClient {
|
||||
if w == nil || w.handler == nil || w.handler.ecosystem == nil {
|
||||
return nil
|
||||
}
|
||||
return d.chatFn(ctx, conversation, text), nil
|
||||
return w.handler.ecosystem.nexus
|
||||
}
|
||||
|
||||
// ResolveEntity asks Nexus for the canonical id behind a name (Vikunja #511).
|
||||
//
|
||||
// Three outcomes, kept apart on purpose. No nexus block is ErrNotImplemented,
|
||||
// so a surface can say "identity is not configured here" rather than invent an
|
||||
// id. A miss is ipc.ErrNoEntity. A match against several entities comes back
|
||||
// Ambiguous with the names, because picking one is how a task ends up blocked
|
||||
// on the wrong person and nobody can see it happened.
|
||||
func (d *daemonAPI) ResolveEntity(ctx context.Context, query string, types []string) (ipc.EntityRef, error) {
|
||||
if d.nexus == nil {
|
||||
return ipc.EntityRef{}, ipc.ErrNotImplemented
|
||||
}
|
||||
res, err := d.nexus.Resolve(ctx, query, types)
|
||||
if err != nil {
|
||||
return ipc.EntityRef{}, err
|
||||
}
|
||||
if len(res.Candidates) > 1 {
|
||||
names := make([]string, 0, len(res.Candidates))
|
||||
for _, c := range res.Candidates {
|
||||
names = append(names, c.DisplayName)
|
||||
}
|
||||
return ipc.EntityRef{Ambiguous: true, Candidates: names}, nil
|
||||
}
|
||||
if res.Entity == nil || res.Entity.ID == "" {
|
||||
return ipc.EntityRef{}, ipc.ErrNoEntity
|
||||
}
|
||||
return ipc.EntityRef{
|
||||
ID: res.Entity.ID,
|
||||
Type: res.Entity.Type,
|
||||
DisplayName: res.Entity.DisplayName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chat runs one text turn and reports which query source claimed it. The sink
|
||||
// rides the context so handleText keeps the one string signature the mic,
|
||||
// telegram and the web all call it through (V-539).
|
||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.ChatReply, error) {
|
||||
if d.chatFn == nil {
|
||||
return ipc.ChatReply{}, errors.New("mavend: chat not available")
|
||||
}
|
||||
ctx, sink := withQuerySourceSink(ctx)
|
||||
reply := d.chatFn(ctx, conversation, text)
|
||||
return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
|
||||
@@ -584,7 +584,9 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
markPresent(t, st, ctx, now)
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
|
||||
// Older than loop.MinDownAge, so this tests the digest bypass and not the
|
||||
// flap debounce (Vikunja #536).
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now.Add(-5*time.Minute)); err != nil {
|
||||
t.Fatalf("seed service_down: %v", err)
|
||||
}
|
||||
sink := &fakeSink{}
|
||||
|
||||
+121
-4
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// Which subject is this question about — the weather, the house, the LAN, what
|
||||
// needs looking at, or none of them. Third of the three mechanisms replacing hand-written Russian
|
||||
// needs looking at, his feeds, or none of them. Third of the three mechanisms replacing hand-written Russian
|
||||
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
|
||||
// sets that can be finished and internal/morph answers the grammar questions;
|
||||
// this is for the sets that can never be finished, because "is this about the
|
||||
@@ -37,6 +37,11 @@ import (
|
||||
// внимания", which is Praxis's operational state and reached the web search
|
||||
// before the source existed (Vikunja #475).
|
||||
//
|
||||
// A fifth joined on 05-08-2026: the feeds, "что нового в лентах". Its word lists
|
||||
// were the last pair of hand-written Russian stem lists in the router (V-522),
|
||||
// and they carried the same admission in their own comments — vagueNouns exists
|
||||
// because "что нового?" is a greeting that matched a feed noun.
|
||||
//
|
||||
// The regexes stay as the offline floor, unchanged, for a handler with no
|
||||
// embedder or a turn whose vector never got computed. They are allowed to remain
|
||||
// narrow now precisely because they are no longer the only answer.
|
||||
@@ -52,6 +57,9 @@ const (
|
||||
topicHome topicLabel = "home"
|
||||
topicNetwork topicLabel = "network"
|
||||
topicAttend topicLabel = "attention"
|
||||
topicFeed topicLabel = "feeds"
|
||||
topicList topicLabel = "list"
|
||||
topicSelf topicLabel = "self"
|
||||
topicOther topicLabel = "other"
|
||||
)
|
||||
|
||||
@@ -117,7 +125,56 @@ var topicSeedSets = map[topicLabel][]string{
|
||||
"what needs attention",
|
||||
"what needs looking at right now",
|
||||
},
|
||||
topicFeed: {
|
||||
"что нового в лентах",
|
||||
"какие новости",
|
||||
"что нового по технологиям",
|
||||
"почитай заголовки",
|
||||
// Two seeds carrying a day word beside the headlines. Without them
|
||||
// "какие сегодня заголовки" read as weather, because "какая сегодня
|
||||
// погода" is the nearest thing in the whole set with "сегодня" in it.
|
||||
"заголовки за сегодня",
|
||||
"какие главные новости за день",
|
||||
"покажи новости за сегодня",
|
||||
"что пишут в новостях",
|
||||
"что нового про политику",
|
||||
"расскажи что нового в ленте",
|
||||
"what is new in the feeds",
|
||||
"any news headlines today",
|
||||
},
|
||||
// Reading a standing list back, and only that. Adding to one and clearing
|
||||
// one stay on the phrase tables in internal/router/list.go — see its header
|
||||
// for why a span and a delete are not seed-shaped work.
|
||||
topicList: {
|
||||
"что в списке покупок",
|
||||
"что мне нужно купить",
|
||||
"прочитай список покупок",
|
||||
"покажи что в списке",
|
||||
"что осталось купить в магазине",
|
||||
"что мне нужно в аптеке",
|
||||
"какой у меня список покупок",
|
||||
"what is on my shopping list",
|
||||
"read me the grocery list",
|
||||
},
|
||||
// Questions about her (Vikunja #555). The set lives in self.go beside the
|
||||
// description it unlocks, so the two are edited together — a seed claiming
|
||||
// a question the description does not answer is the failure mode.
|
||||
//
|
||||
// "что ты умеешь" was a topicOther seed until this existed, put there so an
|
||||
// attention question had something to lose to. It is a self seed now, and
|
||||
// it cannot be both: a phrasing on two sides never clears the margin.
|
||||
topicSelf: selfSeeds,
|
||||
topicOther: {
|
||||
// A task question is not a list read-back. They collide on "что у меня",
|
||||
// and the list has its own table to lose to as well.
|
||||
"какие у меня задачи",
|
||||
"что у меня в делах",
|
||||
// The bare newness opener, which is a greeting and not a request for
|
||||
// headlines. It sits here on purpose: it is close enough to the feed
|
||||
// seeds that it will not clear topicMargin, and a thin call goes to
|
||||
// ParseFeedQuery, which declines a vague noun with no topic beside it.
|
||||
"что нового",
|
||||
"как дела",
|
||||
// Complaints, which are not requests to scan or to read the house.
|
||||
// isNetworkQuery's comment names this one: a scan she runs unasked is
|
||||
// the noisy behaviour the bounds exist to prevent.
|
||||
@@ -137,9 +194,40 @@ var topicSeedSets = map[topicLabel][]string{
|
||||
"что я говорил про бэкапы",
|
||||
"что у меня сегодня по календарю",
|
||||
"напомни мне позвонить маме",
|
||||
// An attention question is about the state of his things; this is not.
|
||||
"что ты умеешь",
|
||||
"what did i say about backups",
|
||||
// World questions that name a day (Vikunja #553). Weather was the only
|
||||
// topic whose seeds carry a day word — four of its eight do — so every
|
||||
// "какой сегодня X" landed nearest it and cleared the margin: the
|
||||
// dollar rate by 0.0220 and a public holiday by 0.0398, against 0.0883
|
||||
// for a real weather question. The gate then asked "для какого города?"
|
||||
// about the dollar.
|
||||
//
|
||||
// The margin was not the knob. 0.0398 is not a coin flip, and raising
|
||||
// the bar far enough to catch it would take real weather questions with
|
||||
// it. What was missing is the negative class: a day word means the
|
||||
// question is about a day, and says nothing about whether it is about
|
||||
// the sky.
|
||||
"сколько стоит биткоин сегодня",
|
||||
"какой завтра праздник в стране",
|
||||
"во сколько сегодня восход солнца",
|
||||
"кто вчера победил в чемпионате",
|
||||
// The frame itself, twice. "какая сегодня погода" is a weather seed,
|
||||
// and the four above did not move "какой сегодня курс доллара" or
|
||||
// "что интересного произошло сегодня в мире" off weather, because what
|
||||
// pulls them is the frame and not the noun. A frame that both topics
|
||||
// use has to sit on both sides, or the side that owns it wins every
|
||||
// noun it has never seen.
|
||||
"какой сегодня курс валют",
|
||||
"что сегодня происходит в мире",
|
||||
// The same story one topic over, found while verifying V-554 on the
|
||||
// box: "кто изобрёл телефон" ran a LAN scan and answered "нашла 3
|
||||
// устройства". The network set opens with "кто в сети сейчас" and
|
||||
// names devices throughout, so a "кто ..." question about any device
|
||||
// noun landed there. A device has a history, and asking about it is
|
||||
// not asking what is plugged in.
|
||||
"кто изобрёл телефон",
|
||||
"когда появился первый компьютер",
|
||||
"как работает роутер",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -201,6 +289,35 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b
|
||||
return label, first - second, true
|
||||
}
|
||||
|
||||
// turnVector returns the turn's query vector, computing it on first ask and
|
||||
// caching it on the turn.
|
||||
//
|
||||
// It exists because every topic source sits ABOVE the "embed" source in
|
||||
// querySources, and that source was the only thing that ever set t.vec. So
|
||||
// turnIsAbout was reading an empty vector on every deployed turn, best returned
|
||||
// ok=false, and all six recognisers ran on their keyword floors — the seeds
|
||||
// decided nothing outside the tests, which embed the utterance themselves and
|
||||
// call best directly. Found on the box on 05-08-2026: "что мне нужно купить" was
|
||||
// answered from an old note, and the seeds place it as the list by 0.0841.
|
||||
//
|
||||
// Computing here rather than moving the embed source up: the cost is paid by the
|
||||
// turns that ask, the cache means queryEmbed below reuses this one, and the
|
||||
// order of querySources stays what its comments argue for.
|
||||
func (h *reactiveHandler) turnVector(ctx context.Context, t *queryTurn) []float32 {
|
||||
if len(t.vec) > 0 || h.recall.embedder == nil {
|
||||
return t.vec
|
||||
}
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// The floor answers. A topic source is not the place to fail a turn:
|
||||
// the recall sources below hit the same embedder and report it there.
|
||||
log.Printf("voice: topic vector for %q: %v", t.dec.Utterance, err)
|
||||
return nil
|
||||
}
|
||||
t.vec = vec
|
||||
return vec
|
||||
}
|
||||
|
||||
// turnIsAbout — the recogniser every topic source calls. The seeds decide when
|
||||
// the embedder is there, which is every deployed box; floor is the source's own
|
||||
// keyword test, which answers when they are not.
|
||||
@@ -213,7 +330,7 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b
|
||||
// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is.
|
||||
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
|
||||
h.recall.topics.load(ctx, h.recall.embedder)
|
||||
label, margin, ok := h.recall.topics.best(t.vec)
|
||||
label, margin, ok := h.recall.topics.best(h.turnVector(ctx, t))
|
||||
if !ok {
|
||||
return floor(t.dec.Utterance)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
|
||||
{"что включено в доме?", topicHome, isHomeQuery, true},
|
||||
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
|
||||
{"что требует внимания?", topicAttend, isAttentionQuery, true},
|
||||
{"что нового в лентах?", topicFeed, feedFloor, true},
|
||||
{"что в списке покупок?", topicList, listFloor, true},
|
||||
{"почему небо синее", topicWeather, isWeatherQuery, false},
|
||||
{"я дома", topicHome, isHomeQuery, false},
|
||||
{"интернет не работает", topicNetwork, isNetworkQuery, false},
|
||||
@@ -86,6 +88,50 @@ func TestONNXTopics(t *testing.T) {
|
||||
{"что требует моего внимания сейчас", topicAttend, isAttentionQuery},
|
||||
{"что не так с базой данных", topicAttend, isAttentionQuery},
|
||||
{"есть что-то срочное на сегодня", topicAttend, isAttentionQuery},
|
||||
{"что нового в ленте за сегодня", topicFeed, feedFloor},
|
||||
{"какие сегодня заголовки", topicFeed, feedFloor},
|
||||
{"что нового про искусственный интеллект", topicFeed, feedFloor},
|
||||
// The greeting. It has to lose to topicOther, or fall thin enough that
|
||||
// ParseFeedQuery — which declines a vague noun with no topic — answers.
|
||||
{"что нового?", topicOther, feedFloor},
|
||||
{"что мне надо купить в магазине", topicList, listFloor},
|
||||
{"прочитай мне список", topicList, listFloor},
|
||||
{"что там в аптеке нужно взять", topicList, listFloor},
|
||||
// A task read-back is not a list read-back, and the two collide on
|
||||
// "что у меня".
|
||||
{"какие у меня сейчас задачи", topicOther, listFloor},
|
||||
// World questions that name a day (Vikunja #553). Weather was the only
|
||||
// topic carrying day words, so all of these read as weather and two of
|
||||
// them cleared the margin: the gate asked "для какого города?" about
|
||||
// the dollar. The last two are far from any seed on purpose — the
|
||||
// first three are close enough to the new topicOther seeds that they
|
||||
// would pass on similarity alone.
|
||||
{"какой сегодня курс доллара", topicOther, isWeatherQuery},
|
||||
{"какой сегодня праздник", topicOther, isWeatherQuery},
|
||||
{"что интересного произошло сегодня в мире", topicOther, isWeatherQuery},
|
||||
{"во сколько завтра открывается музей", topicOther, isWeatherQuery},
|
||||
{"кто сегодня играет в лиге чемпионов", topicOther, isWeatherQuery},
|
||||
// The control the seeds above must not cost: real weather still reads
|
||||
// as weather, including the two that lean on the keyword floor.
|
||||
{"будет ли завтра дождь в москве", topicWeather, isWeatherQuery},
|
||||
{"какая температура завтра утром", topicWeather, isWeatherQuery},
|
||||
// The same shape one topic over, seen on the box (Vikunja #554): a
|
||||
// device has a history, and asking about it is not asking what is
|
||||
// plugged in. "кто изобрёл телефон" answered "нашла 3 устройства".
|
||||
// Held out from the seeds, which name the telephone and the computer.
|
||||
{"кто придумал радио", topicOther, isNetworkQuery},
|
||||
{"когда изобрели телевизор", topicOther, isNetworkQuery},
|
||||
{"как устроен телефон внутри", topicOther, isNetworkQuery},
|
||||
// The control: a real scan is still a scan.
|
||||
{"какие устройства подключены к вайфаю", topicNetwork, isNetworkQuery},
|
||||
// Questions about her (Vikunja #555), held out from selfSeeds.
|
||||
{"а что ты вообще умеешь делать", topicSelf, selfFloor},
|
||||
{"какие у тебя навыки", topicSelf, selfFloor},
|
||||
{"расскажи мне о себе", topicSelf, selfFloor},
|
||||
{"what are you able to do", topicSelf, selfFloor},
|
||||
// The control: an attention question is about the state of his things,
|
||||
// and it is the neighbour these seeds could have taken.
|
||||
{"что требует внимания у меня в сервисах", topicAttend, isAttentionQuery},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
|
||||
+17
-7
@@ -132,8 +132,9 @@ type reactiveHandler struct {
|
||||
// The production dateparser will replace StubDateTimeParser here too.
|
||||
timeParser router.DateTimeParser
|
||||
|
||||
// dialogueSessions carries slots across turns for follow-ups (single-user
|
||||
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
||||
// dialogueSessions carries slots across turns for follow-ups. Keyed by the
|
||||
// reach the turn arrived on (dialogueIDOf), like the clarify store: one
|
||||
// slot per reach, not one for the box. nil ⇒ no carry-over.
|
||||
dialogueSessions *dialogue.SessionStore
|
||||
|
||||
// clarifyStore parks the request behind an open question she asked (see
|
||||
@@ -158,6 +159,15 @@ type reactiveHandler struct {
|
||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||||
|
||||
// surfacedItems — the Praxis item ids she last read out, in the order she
|
||||
// read them, so "отметь второй пункт" has a second pункт to mean (Vikunja
|
||||
// #516). Same single-slot posture as pending above: the next attention digest
|
||||
// replaces the list, because a position only refers to the last one spoken.
|
||||
// No TTL — a stale position resolves to an item that Praxis will report as
|
||||
// already acknowledged, which is a harmless answer, unlike a stale
|
||||
// confirmation that would execute something.
|
||||
surfacedItems []string
|
||||
|
||||
ecosystem *ecosystemWiring // nexus + hexis + praxis clients
|
||||
}
|
||||
|
||||
@@ -304,7 +314,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||
// and keeps routing.
|
||||
if reply, handled := h.resolveCandidate(ctx, text); handled {
|
||||
if reply, handled := h.resolveCandidate(ctx, text, src); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
@@ -320,7 +330,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
)
|
||||
now := h.now()
|
||||
if h.dialogueSessions != nil {
|
||||
prev = h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
}
|
||||
cont := false
|
||||
if dec, cont = continuationDecision(prev, text, now); cont {
|
||||
@@ -352,7 +362,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
}
|
||||
if !dec.Clarify {
|
||||
h.rememberTurn(prev, dec, now)
|
||||
h.rememberTurn(ctx, prev, dec, now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,12 +493,12 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
||||
// chatHistory collects dialogue turns from the session store for the current
|
||||
// conversation. Returns prior user utterances (newest last) up to a depth of
|
||||
// 4 turns. Returns nil when there's no session or no history.
|
||||
func (h *reactiveHandler) chatHistory() []dialogue.Turn {
|
||||
func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn {
|
||||
if h.dialogueSessions == nil {
|
||||
return nil
|
||||
}
|
||||
now := h.now()
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
if prev == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -392,10 +392,18 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
grammars = append(grammars, router.TaskListGrammar())
|
||||
grammars = append(grammars, router.ListGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
// Before the capture marker, because "отметь" is a capture verb and "отметь
|
||||
// второй пункт" is not a note. The Praxis rules are the narrower claim — a
|
||||
// lifecycle verb AND an item named — so they get first refusal (Vikunja #516).
|
||||
grammars = append(grammars, router.PraxisGrammars()...)
|
||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||
// explicit capture marker beats the model, which called it an act and
|
||||
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||
// marker never collides with a clock or agenda question.
|
||||
// After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the
|
||||
// board noun), and before the capture marker, which would otherwise read
|
||||
// "убери из задач купить молоко" as a new task (Vikunja #512).
|
||||
grammars = append(grammars, router.TaskStatusGrammar())
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||
// last overall because it matches on the first word alone: "расскажи про
|
||||
@@ -552,6 +560,11 @@ func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
|
||||
// runReembed.
|
||||
var reembedOnStart bool
|
||||
|
||||
// allowSeedOnStart is the -allow-seed flag (set in run()). Opt-in, and the
|
||||
// default is the one that matters: a box nobody is testing has no live path to
|
||||
// write a fact into the past. See seed.go and Vikunja #518.
|
||||
var allowSeedOnStart bool
|
||||
|
||||
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
||||
// wrote the vectors already in the DB (Vikunja #378).
|
||||
//
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// runWipe implements the -wipe flag: it prints what the database holds, and
|
||||
// removes it only when the operator also passed -confirm-wipe (Vikunja #494).
|
||||
//
|
||||
// Two flags rather than one, because the destructive reading of a single flag
|
||||
// is the reading a mistyped command gets. Without the confirmation this is a
|
||||
// dry run that costs nothing and answers the question a QA session actually
|
||||
// has — what is on this box right now.
|
||||
//
|
||||
// It runs before any daemon component is wired, so nothing is writing while
|
||||
// the tables go. The daemon exits afterwards rather than serving a store it
|
||||
// just emptied, because every component that read the old rows at boot would
|
||||
// still be holding them.
|
||||
func runWipe(ctx context.Context, st *store.Store, out io.Writer, confirmed bool) error {
|
||||
counts, err := st.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wipe: read counts: %w", err)
|
||||
}
|
||||
total := 0
|
||||
for _, c := range counts {
|
||||
total += c.Rows
|
||||
fmt.Fprintf(out, " %-24s %d\n", c.Table, c.Rows)
|
||||
}
|
||||
fmt.Fprintf(out, " %-24s %d rows in %d tables\n", "TOTAL", total, len(counts))
|
||||
|
||||
if !confirmed {
|
||||
fmt.Fprintln(out, "\nnothing was deleted. pass -confirm-wipe to delete all of it.")
|
||||
fmt.Fprintln(out, "config, models, passkeys and the encryption key are files and are never touched.")
|
||||
return nil
|
||||
}
|
||||
if err := st.Wipe(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "\nwiped. %d rows gone, the schema is intact, mavend knows nobody.\n", total)
|
||||
return nil
|
||||
}
|
||||
@@ -31,12 +31,11 @@ import (
|
||||
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||
// is worse than a gap.
|
||||
//
|
||||
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
|
||||
// meeting is good enough to recite and not good enough to stop a nudge —
|
||||
// calendar_busy is still written only by the CalDAV poller. That is backwards,
|
||||
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
|
||||
// calendar_busy is a level rather than an event, so an ambient writer needs an
|
||||
// expiry, which is its own task and not a change here.
|
||||
// This writes calendar_event_* and nothing else, and since Vikunja #513 that is
|
||||
// enough to stop a nudge as well as to recite: the loop gatherer reads the event
|
||||
// family and asks whether any span covers the instant. So there is no ambient
|
||||
// calendar_busy and no expiry to pick — a level needs one and an event carries
|
||||
// its own. calendar_busy stays the CalDAV poller's key.
|
||||
|
||||
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||
const ambientMaxBody = 8 << 10
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
||||
<div class="scroll chat-scroll" id=chatHistory>
|
||||
{{range .Messages}}
|
||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}{{if .Source}} <span class="badge badge-accent" title="the query source that claimed this turn">{{.Source}}</span>{{end}}</div>
|
||||
{{else}}
|
||||
<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||
|
||||
@@ -67,8 +67,9 @@ type fakeCore struct {
|
||||
traceErr error
|
||||
|
||||
// for handleChatAPI tests
|
||||
chatText string
|
||||
chatErr error
|
||||
chatText string
|
||||
chatSource string
|
||||
chatErr error
|
||||
|
||||
// for the MCP section of /tools
|
||||
mcpServers []ipc.MCPServerStatus
|
||||
@@ -79,12 +80,12 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
return f.mcpServers, f.mcpErr
|
||||
}
|
||||
|
||||
func (f *fakeCore) Chat(_ context.Context, _, text string) (string, error) {
|
||||
func (f *fakeCore) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) {
|
||||
f.chatText = text
|
||||
if f.chatErr != nil {
|
||||
return "", f.chatErr
|
||||
return ipc.ChatReply{}, f.chatErr
|
||||
}
|
||||
return "поняла", nil
|
||||
return ipc.ChatReply{Reply: "поняла", Source: f.chatSource}, nil
|
||||
}
|
||||
|
||||
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
||||
@@ -1285,3 +1286,37 @@ func TestHandleNotifications_ShowsTheOutbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the query source badge (V-539) ---
|
||||
//
|
||||
// Which query source claimed a turn was readable in the daemon log and nowhere
|
||||
// else, so a QA step could not tell a wrong answer from a wrongly ordered
|
||||
// chain. It now rides the redirect and renders beside the reply.
|
||||
|
||||
func TestHandleChatAPI_CarriesTheClaimingSource(t *testing.T) {
|
||||
core := &fakeCore{chatSource: "kiwix"}
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatAPI(rr, postChat("почему небо голубое"), core, nil, false)
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.Contains(loc, "s=kiwix") {
|
||||
t.Errorf("redirect = %q; want the claiming source in it", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChatAPI_OmitsTheSourceWhenNothingClaimed(t *testing.T) {
|
||||
core := &fakeCore{}
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatAPI(rr, postChat("запиши что я пил воду"), core, nil, false)
|
||||
if loc := rr.Header().Get("Location"); strings.Contains(loc, "s=") {
|
||||
t.Errorf("redirect = %q; a turn no source claimed carries no badge", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatPageRendersTheSourceBadge(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/chat?q=%D1%82%D0%B5%D1%81%D1%82&r=%D0%BE%D1%82%D0%B2%D0%B5%D1%82&s=search", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatPage(rr, req, &fakeCore{})
|
||||
if body := rr.Body.String(); !strings.Contains(body, ">search</span>") {
|
||||
t.Errorf("chat page does not render the source badge; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
+283
-46
@@ -883,14 +883,19 @@ type taskRow struct {
|
||||
Created string
|
||||
Resolved string
|
||||
ResolvedBy string
|
||||
// DueValue and Weight are the raw values the edit form posts back
|
||||
// (Vikunja #509). Due above is for reading and says "—" for no date; a
|
||||
// date input needs "2026-08-07" or the empty string.
|
||||
DueValue string
|
||||
Weight int
|
||||
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||
// rendering: he never said this one mattered more.
|
||||
Why string
|
||||
}
|
||||
|
||||
// handleTasks serves the task review surface (GET) and the four writes it
|
||||
// offers (POST): add, confirm, done, drop.
|
||||
// handleTasks serves the task review surface (GET) and the five writes it
|
||||
// offers (POST): add, edit, confirm, done, drop.
|
||||
//
|
||||
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||
@@ -900,6 +905,13 @@ type taskRow struct {
|
||||
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||
// page.
|
||||
//
|
||||
// "edit" was re-argued on the same terms rather than inheriting the exemption
|
||||
// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads
|
||||
// himself, the same blast radius "drop" already has on this page, and the store
|
||||
// refuses the two edits that would cost something: a resolved task keeps the
|
||||
// text it was finished under, and a text collision with another live row is
|
||||
// named instead of merged.
|
||||
//
|
||||
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||
// from something she read into work he owns. That review step is why derived
|
||||
// tasks are captured as candidates in the first place.
|
||||
@@ -965,6 +977,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
DueValue: fmtTaskDateValue(t.Due), Weight: t.Weight,
|
||||
Why: r.Reason,
|
||||
}
|
||||
if t.Status == "candidate" {
|
||||
@@ -979,11 +992,12 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tasksTmpl.Execute(w, struct {
|
||||
Msg, Err string
|
||||
Stalls []tasks.Stall
|
||||
Candidates []taskRow
|
||||
Open []taskRow
|
||||
Resolved []taskRow
|
||||
ResolvedMore bool
|
||||
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
|
||||
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
|
||||
log.Printf("tasks render: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -999,27 +1013,16 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
||||
// Importance is his, stated on the form. Out-of-range values are
|
||||
// clamped rather than rejected — a bad select is not worth a 400.
|
||||
if v := r.FormValue("weight"); v != "" {
|
||||
// strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
|
||||
// form value is not a place to accept trailing garbage.
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return "", fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
req.Weight = wgt
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if d := r.FormValue("due"); d != "" {
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now().Location())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
req.Due = &due
|
||||
req.Weight = wgt
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Due = due
|
||||
resp, err := core.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1037,6 +1040,44 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
if err != nil {
|
||||
return "", errors.New("invalid id")
|
||||
}
|
||||
|
||||
if action == "promote" {
|
||||
msg, err := promoteCandidate(ctx, core, r, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
if action == "edit" {
|
||||
// The three fields capture set, and only those (Vikunja #509). Status
|
||||
// is not editable here: that ladder is one-way and has its own buttons.
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch err := core.EditTask(ctx, id, text, due, wgt); {
|
||||
case err == nil:
|
||||
return "saved task", nil
|
||||
case errors.Is(err, ipc.ErrTaskDuplicate):
|
||||
// Naming the collision instead of merging: two live rows carry two
|
||||
// provenances, and picking one is not the page's call.
|
||||
return "", errors.New("another open task already says this — drop one of the two")
|
||||
case errors.Is(err, ipc.ErrTaskResolved):
|
||||
return "", errors.New("a resolved task keeps the text it was finished under")
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
var status, msg string
|
||||
switch action {
|
||||
case "confirm":
|
||||
@@ -1049,11 +1090,134 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
||||
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
|
||||
// The refusal has to name what is missing, or the button looks
|
||||
// broken. The field it asks for arrives with the intake form
|
||||
// (Vikunja #511).
|
||||
return "", errors.New("write a definition of done before confirming this candidate")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// fmtTaskDateValue renders a due date the way <input type=date> requires, or
|
||||
// "" for no date. Separate from fmtTaskDate, which renders it for reading.
|
||||
// promoteCandidate turns a candidate into open work with the three things the
|
||||
// board needs (Vikunja #511): a definition of done, an optional blocker, and an
|
||||
// optional date.
|
||||
//
|
||||
// The definition of done is required, and the refusal is the store's — this
|
||||
// only reaches it in a readable order. The blocker is a NAME here and an entity
|
||||
// id in the row: identity lives in Nexus, so the name is resolved first and a
|
||||
// name Nexus cannot resolve stops the promotion instead of being stored.
|
||||
//
|
||||
// A date set here writes a reminder, which is the one unprompted delivery the
|
||||
// persona allows: he asked to be told, on a day he named.
|
||||
func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) {
|
||||
doneWhen := strings.TrimSpace(r.FormValue("done_when"))
|
||||
if doneWhen == "" {
|
||||
return "", errors.New("write a definition of done — what has to be true for this to be finished")
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
blockedOn := ""
|
||||
if name := strings.TrimSpace(r.FormValue("blocked_on")); name != "" {
|
||||
ref, err := core.ResolveEntity(ctx, name, []string{"person"})
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrNotImplemented):
|
||||
return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty")
|
||||
case errors.Is(err, ipc.ErrNoEntity):
|
||||
return "", fmt.Errorf("nexus does not know %q", name)
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("resolving %q: %w", name, err)
|
||||
case ref.Ambiguous:
|
||||
// Asking, not picking: a task blocked on the wrong person is a
|
||||
// mistake nobody can see afterwards.
|
||||
return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
|
||||
}
|
||||
blockedOn = ref.ID
|
||||
}
|
||||
|
||||
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if due != nil {
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil {
|
||||
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
|
||||
return "", errors.New("write a definition of done before confirming this candidate")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if due == nil {
|
||||
return "confirmed", nil
|
||||
}
|
||||
// A date-only field has no hour. Nine in the morning, because the reminder
|
||||
// is about a day's work and being told at midnight is being told the night
|
||||
// before.
|
||||
fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location())
|
||||
if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil {
|
||||
// The task IS promoted; only the reminder failed. Saying "confirmed"
|
||||
// and nothing else would leave him expecting a nudge that will not come.
|
||||
return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err)
|
||||
}
|
||||
return "confirmed, and maven will remind you that morning", nil
|
||||
}
|
||||
|
||||
// formWeight reads the importance select. Out-of-range clamps rather than
|
||||
// rejects — a bad select is not worth a 400 — but trailing garbage is refused,
|
||||
// because strconv is not Sscanf and "3junk" is not a 3.
|
||||
func formWeight(r *http.Request) (int, error) {
|
||||
v := r.FormValue("weight")
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return 0, fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
return wgt, nil
|
||||
}
|
||||
|
||||
// formDue reads the date input. An empty field is nil, which on an edit means
|
||||
// "clear the date" — the form has no other way to say it.
|
||||
func formDue(r *http.Request, now time.Time) (*time.Time, error) {
|
||||
d := r.FormValue("due")
|
||||
if d == "" {
|
||||
return nil, nil
|
||||
}
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
return &due, nil
|
||||
}
|
||||
|
||||
func fmtTaskDateValue(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func fmtTaskTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
@@ -1094,34 +1258,53 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
action := r.FormValue("action")
|
||||
idStr := r.FormValue("id")
|
||||
var rid int64
|
||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "accept":
|
||||
// "seed" is the one action with no routine to act on — it is what
|
||||
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
|
||||
// lives on this route rather than a page of its own because it is
|
||||
// already the step-up-gated surface for this table, and a second gated
|
||||
// surface is a second thing to get wrong.
|
||||
if action == "seed" {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
||||
log.Printf("routines: accept %d: %v", rid, err)
|
||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
||||
out, err := seedRoutineEvent(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("routines: seed: %v", err)
|
||||
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "accepted routine — maven will remind you"
|
||||
case "dismiss":
|
||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
msg = out
|
||||
} else {
|
||||
idStr := r.FormValue("id")
|
||||
var rid int64
|
||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "accept":
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
||||
log.Printf("routines: accept %d: %v", rid, err)
|
||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "accepted routine — maven will remind you"
|
||||
case "dismiss":
|
||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "dismissed routine"
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
msg = "dismissed routine"
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
@@ -1158,6 +1341,45 @@ func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
||||
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
||||
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
||||
// behind step-up. Voice can park the question and dismiss, never accept.
|
||||
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
|
||||
// so the pattern detector can be exercised against a running daemon instead of
|
||||
// over real days. Refused unless mavend was started with -allow-seed; on an
|
||||
// ordinary box the error says so and nothing is written.
|
||||
//
|
||||
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
|
||||
// so a QA sitting can space four seeds three hours apart without doing clock
|
||||
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
|
||||
// meaning now.
|
||||
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||
key := strings.TrimSpace(r.FormValue("key"))
|
||||
value := strings.TrimSpace(r.FormValue("value"))
|
||||
if key == "" || value == "" {
|
||||
return "", errors.New("seed needs a key and a value")
|
||||
}
|
||||
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
|
||||
}
|
||||
if agoHours < 0 {
|
||||
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
|
||||
}
|
||||
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.Extracted {
|
||||
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
|
||||
}
|
||||
if !resp.Proposed {
|
||||
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
|
||||
}
|
||||
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
|
||||
}
|
||||
|
||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -1540,7 +1762,13 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
|
||||
w.Header().Set("X-Reply-Text", url.QueryEscape(pttResp.ReplyText))
|
||||
// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
|
||||
// as "+", which is form encoding, and the client decodes this header
|
||||
// with decodeURIComponent, which only knows "%20" — so every space in a
|
||||
// spoken reply reached the on-page log as a plus sign. PathEscape is the
|
||||
// flavour decodeURIComponent actually reverses, which keeps the encoding
|
||||
// a property of the header rather than something the client has to know.
|
||||
w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
|
||||
w.Write(pttResp.ReplyAudio.Bytes)
|
||||
return
|
||||
}
|
||||
@@ -1563,8 +1791,8 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if q := r.URL.Query().Get("q"); q != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "user", Text: q})
|
||||
}
|
||||
if r := r.URL.Query().Get("r"); r != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "assistant", Text: r})
|
||||
if reply := r.URL.Query().Get("r"); reply != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := chatTmpl.Execute(w, struct {
|
||||
@@ -1613,13 +1841,22 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/chat?q="+url.QueryEscape(text)+"&r="+url.QueryEscape(reply), http.StatusSeeOther)
|
||||
// The claiming query source rides back on the redirect so the page can show
|
||||
// it. Empty for a turn no source claimed, which is most of them.
|
||||
dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply)
|
||||
if reply.Source != "" {
|
||||
dest += "&s=" + url.QueryEscape(reply.Source)
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// chatMsg — one message in the conversation history.
|
||||
type chatMsg struct {
|
||||
Role string // "user" | "assistant"
|
||||
Text string
|
||||
// Source — the query source that claimed the turn, shown as a badge beside
|
||||
// the reply. Empty for a turn no source claimed (V-539).
|
||||
Source string
|
||||
}
|
||||
|
||||
func mustMarshal(v any) json.RawMessage {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// decodeURIComponent is what static/app.js calls on X-Reply-Text. PathUnescape
|
||||
// is its Go equivalent for this purpose: both turn %XX into bytes and both
|
||||
// leave a literal "+" alone. That last part is the whole defect — QueryEscape
|
||||
// wrote spaces as "+" and the client had no way to tell those from a plus the
|
||||
// speaker actually said.
|
||||
func decodeURIComponent(t *testing.T, s string) string {
|
||||
t.Helper()
|
||||
out, err := url.PathUnescape(s)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeURIComponent(%q): %v", s, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The reply the QA session actually saw was "на+04.08.2026+ничего+нет."
|
||||
// (Vikunja #533). Round-tripping through the client's decoder is the assertion
|
||||
// that matters — checking the encoder in isolation would have passed with
|
||||
// QueryEscape too.
|
||||
func TestReplyTextSurvivesTheClientDecoder(t *testing.T) {
|
||||
cases := []string{
|
||||
"на 04.08.2026 ничего нет.",
|
||||
"Я поставила тебе напоминание позвонить маме через час.",
|
||||
// A literal plus must stay a plus, which is the case that makes
|
||||
// "just replace + with space on the JS side" the wrong fix.
|
||||
"два плюс два = 2+2",
|
||||
// Headers cannot carry a raw newline. PathEscape writes %0A.
|
||||
"первая строка\nвторая строка",
|
||||
"", // no reply text at all
|
||||
}
|
||||
for _, want := range cases {
|
||||
encoded := url.PathEscape(want)
|
||||
if strings.ContainsAny(encoded, "\r\n") {
|
||||
t.Errorf("encoded %q contains a raw newline, which is not a legal header value", want)
|
||||
}
|
||||
if got := decodeURIComponent(t, encoded); got != want {
|
||||
t.Errorf("round trip: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The specific regression, named. QueryEscape is form encoding and this header
|
||||
// is not a form.
|
||||
func TestReplyTextDoesNotUseFormEncoding(t *testing.T) {
|
||||
const spoken = "на 04.08.2026 ничего нет."
|
||||
if got := decodeURIComponent(t, url.QueryEscape(spoken)); got == spoken {
|
||||
t.Skip("QueryEscape round-trips here, so this test proves nothing — check the decoder stand-in")
|
||||
}
|
||||
if strings.Contains(url.PathEscape(spoken), "+") {
|
||||
t.Errorf("PathEscape(%q) still writes a plus", spoken)
|
||||
}
|
||||
}
|
||||
+44
-6
@@ -21,20 +21,43 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{if .Stalls}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>shapes</h2>
|
||||
<!-- Counts, and nothing about what they mean (V-512). Whether a task should be
|
||||
dropped is his call and Maven does not have an opinion to show here. -->
|
||||
<div class=scroll><table>
|
||||
<tr><th>count</th><th>shape</th></tr>
|
||||
{{range .Stalls}}<tr><td>{{.N}}</td><td>{{.Line}}</td></tr>{{end}}
|
||||
</table></div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Candidates}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
|
||||
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
|
||||
<div class=hint>confirming asks for a definition of done: what has to be true for this to be finished. a task without one can never leave the board. a date here also books a reminder that morning.</div>
|
||||
<div class=scroll><table>
|
||||
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
<tr><th>task</th><th>where from</th><th>captured</th><th>confirm</th><th></th></tr>
|
||||
{{range .Candidates}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=confirm>
|
||||
<input type=hidden name=action value=promote>
|
||||
<input type=hidden name=text value="{{.Text}}">
|
||||
<input type=text name=done_when placeholder="готово, когда…" size=26 required>
|
||||
<!-- A name, not an id. It is resolved against nexus before anything is
|
||||
stored, and a name nexus cannot place stops the confirmation. -->
|
||||
<input type=text name=blocked_on placeholder="ждёт кого-то" size=14>
|
||||
<input type=date name=due value="{{.DueValue}}" title="due date">
|
||||
<select name=weight title=importance>
|
||||
<option value=0 {{if eq .Weight 0}}selected{{end}}>normal</option>
|
||||
<option value=2 {{if eq .Weight 2}}selected{{end}}>важно</option>
|
||||
<option value=3 {{if eq .Weight 3}}selected{{end}}>срочно</option>
|
||||
</select>
|
||||
<button class=btn>confirm</button></form></td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
@@ -48,12 +71,27 @@
|
||||
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.</div>
|
||||
{{if .Open}}<div class=scroll><table>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Open}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<!-- The text, the date and the importance are editable in place (V-509): a
|
||||
dictated task can carry a typo, and a deadline moves. The status is not
|
||||
here — that ladder is one-way and has its own two buttons. -->
|
||||
<td class=text-max><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=edit>
|
||||
<input type=text name=text value="{{.Text}}" size=30 required>
|
||||
<input type=date name=due value="{{.DueValue}}" title="due date">
|
||||
<select name=weight title=importance>
|
||||
<!-- Any weight that is not one of the three rungs keeps its own option, or
|
||||
saving an unrelated edit would silently reset it to normal. -->
|
||||
{{if and (ne .Weight 0) (ne .Weight 2) (ne .Weight 3)}}<option value={{.Weight}} selected>{{.Weight}}</option>{{end}}
|
||||
<option value=0 {{if eq .Weight 0}}selected{{end}}>normal</option>
|
||||
<option value=2 {{if eq .Weight 2}}selected{{end}}>важно</option>
|
||||
<option value=3 {{if eq .Weight 3}}selected{{end}}>срочно</option>
|
||||
</select>
|
||||
<button class="btn btn-muted">save</button></form></td>
|
||||
<td class=hint>{{.Why}}</td>
|
||||
<td class=hint>{{.Source}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
|
||||
@@ -77,10 +77,14 @@ func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
// The candidate must offer confirm, and the open task must not.
|
||||
if !strings.Contains(body, "value=confirm") {
|
||||
// The candidate must offer the intake form, and it asks for a definition of
|
||||
// done before it will confirm anything (V-511).
|
||||
if !strings.Contains(body, "value=promote") {
|
||||
t.Error("candidate row has no confirm action")
|
||||
}
|
||||
if !strings.Contains(body, "name=done_when") {
|
||||
t.Error("the confirm form does not ask for a definition of done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksAddCaptures(t *testing.T) {
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"kiwix": {
|
||||
"url": "http://kiwix-server:8080",
|
||||
"book": "wikipedia_en_all_maxi_2026-02",
|
||||
"book_ru": "wikipedia_ru_all_maxi_2026-02",
|
||||
"max_results": 5,
|
||||
"snippet_runes": 1500
|
||||
},
|
||||
|
||||
+6
-1
@@ -12,7 +12,12 @@ x-image: &image
|
||||
# build on EVERY service (same image name ⇒ built once) so `docker compose
|
||||
# build <anyservice>` actually rebuilds. With build on only one service, the
|
||||
# others silently no-op and you deploy a stale binary.
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
# the zone is declared once, here. The image points /etc/localtime at it so
|
||||
# a caller reading the system zone agrees with one reading TZ (V-545).
|
||||
args:
|
||||
TZ: Europe/Samara
|
||||
pull_policy: never # only ever the locally-built image
|
||||
restart: unless-stopped
|
||||
# local time for clock/date replies AND quiet-hours evaluation. Change to
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Routing from audio: four paths, one fixture
|
||||
|
||||
**05-08-2026. Vikunja #486.** Workstation `gemma-4-12B-it-qat-UD-Q4_K_XL` with
|
||||
`mmproj-F16.gguf`, homesrv whisper `ggml-small`, piper `ru_RU-irina-medium`.
|
||||
|
||||
**Verdict: transcribe, then route.** One call from audio straight to a route loses 36
|
||||
points, so it is not a candidate. Moving speech-to-text to the workstation buys 375ms and
|
||||
better transcripts at no measurable accuracy cost. So #486 proceeds on the two-call shape.
|
||||
|
||||
## The numbers
|
||||
|
||||
72 Russian cases from `internal/router/eval/ru_routing_v1.json`, rendered by piper at
|
||||
16kHz mono, 153.9s of audio, mean 2.14s per clip. Every path used the daemon's own
|
||||
`routeSystem` prompt and `routeGrammar`, read out of `internal/router/llmrouter.go` at run
|
||||
time, at `temperature 0` and `enable_thinking:false`.
|
||||
|
||||
| Path | Intent-only | Verbatim transcripts | p50 | p95 |
|
||||
|---|---|---|---|---|
|
||||
| text in, the ceiling | **90.3%** (65/72) | — | 361ms | 495ms |
|
||||
| whisper on homesrv, then route | **84.7%** (61/72) | 29/72 | 1372ms | 1546ms |
|
||||
| workstation transcribes, then routes | **83.3%** (60/72) | 48/72 | 997ms | 1177ms |
|
||||
| workstation, one call from audio | **54.2%** (39/72) | — | 425ms | 756ms |
|
||||
|
||||
The 90.3% ceiling is the same model on the same 72 cases with the utterance as text. It is
|
||||
not the 93.5% in `docs/evals/2026-08-02-workstation-gemma4-12b.md`, which scored all 87
|
||||
cases including the English ones.
|
||||
|
||||
The two speech-to-text paths differ by one case, which is noise on 72. So the choice
|
||||
between them is latency and transcript quality, and the workstation wins both.
|
||||
|
||||
## One call from audio is not a transcription failure
|
||||
|
||||
The obvious reading of 54.2% is that the audio encoder cannot hear Russian. It can. Eight
|
||||
of the failing clips were sent back with a transcribe instruction instead of the router
|
||||
prompt:
|
||||
|
||||
| Clip | Said | Heard, transcribing | Routing from audio |
|
||||
|---|---|---|---|
|
||||
| ru-sys-002 | какое число завтра | Какое число завтра? | `unknown` |
|
||||
| ru-sys-003 | переходи в тихий режим | Переходи в тихий режим. | `unknown` |
|
||||
| ru-query-001 | сколько воды я выпил с утра | Сколько воды я выпил с утра? | `fact`, value "выпил с утра" |
|
||||
| ru-act-002 | выключи свет в спальне | Выключи свет в спальне. | `fact`, value "включен" |
|
||||
|
||||
Four clips it transcribes word for word, and routes wrong or refuses. The `ru-query-001`
|
||||
row shows the mechanism: the emitted slot holds the tail of the sentence and the
|
||||
interrogative head is gone. The model is not deaf, it stops attending to the audio once it
|
||||
is also holding a 3.5k-character classification prompt.
|
||||
|
||||
That pattern decides the whole task. A long system prompt and an audio part compete, so the
|
||||
transcription has to be its own call with a short instruction. It also means the number
|
||||
would not be rescued by a better prompt, a longer clip, or a bigger `mmproj`.
|
||||
|
||||
The failures cluster where the head of the sentence carries the intent: `ru-act` 1/6,
|
||||
`ru-sys` 2/5, `ru-query` 12/25. Reminders scored 10/10, because "напомни" is the first word
|
||||
and nothing after it changes the answer.
|
||||
|
||||
## Transcript quality and routing accuracy come apart
|
||||
|
||||
The workstation transcribes 48 of 72 verbatim against whisper's 29, and routes one case
|
||||
worse. Both directions of that appear in the same run:
|
||||
|
||||
- `ru-chat-002`: whisper heard "Кто думаешь про переезд", the workstation heard "Что ты
|
||||
думаешь про переезд". The correct transcript routed to `chat`, the broken one to `query`.
|
||||
- `ru-query-020`: whisper heard "Кто дальше?", the workstation heard the correct "Что
|
||||
дальше?". The **broken** transcript routed correctly and the correct one missed.
|
||||
|
||||
A word error rate is not a proxy for routing accuracy here. Judge a speech-to-text change
|
||||
on the routing fixture, not on transcripts.
|
||||
|
||||
Three cases only the text path gets right. No speech-to-text path recovers them, so they
|
||||
are lost in the rendering rather than in the model.
|
||||
|
||||
## Latency
|
||||
|
||||
Whisper `ggml-small` on homesrv CPU costs p50 998ms for a 2.14s clip, which is nearly
|
||||
all of that path's 1372ms. The workstation does the same job inside its 997ms end-to-end
|
||||
total for two calls. So the transfer is worth about 375ms per turn at p50, and more at p95.
|
||||
|
||||
Both are above the one-call 425ms, and that is the trade the table settles: 29 points of
|
||||
accuracy for 572ms.
|
||||
|
||||
## Notes for the next run
|
||||
|
||||
- `--mmproj /mnt/D/AI/gemma4/mmproj-F16.gguf` has to be in `llama_args` in
|
||||
`~/.config/mavgpud.json`, or `/props` reports `modalities.audio: false` and every audio
|
||||
part is dropped silently. It was added for this measurement and removed afterwards, so
|
||||
the box is back to the text-only config.
|
||||
- `enable_thinking:false` is mandatory. It was set for all 224 calls here.
|
||||
- The degenerate `<|channel>thought` output recorded against #486 did not reproduce, in 80
|
||||
transcribe calls or in 144 routing calls.
|
||||
- Piper renders at 22050Hz mono. Every clip was resampled with
|
||||
`ffmpeg -ar 16000 -ac 1 -c:a pcm_s16le`, because 16kHz is what `audio.PCM16kMono`
|
||||
declares and what the earlier measurement used.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Half-past and quarter-to hours, 2026-08-05
|
||||
|
||||
Vikunja V-538. `rewriteHalfPast` in `internal/router/halfpast.go`, run in front of
|
||||
the token pass inside `SpellOutDigits`, so both date parsers see digits.
|
||||
|
||||
## What the shapes are
|
||||
|
||||
Russian names a half hour by the hour being ENTERED, in the genitive. "половина
|
||||
восьмого" is 07:30. "без четверти восемь" counts the other way, from a cardinal,
|
||||
and is 07:45. Both are minus one from the word in the sentence, and the arithmetic
|
||||
lives in one function, `clockHourBefore`.
|
||||
|
||||
## Result
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| classifier + onnx over the routing fixture | 58/82 (70.7%) | 62/87 (71.3%) |
|
||||
| new fixture cases passing | — | 2 of 3 |
|
||||
| stub parser reads a half hour | no | yes |
|
||||
|
||||
The three new cases are ru-rem-008, ru-rem-009 and ru-rem-010. No existing case
|
||||
regressed and no new clarify appeared.
|
||||
|
||||
ru-rem-009, "разбуди меня полвосьмого", still misses the intent. Its two siblings
|
||||
without a half hour miss it the same way. ru-rem-005 "разбуди меня в 6:30" routes
|
||||
to `fact`, and en-rem-002 "wake me at 6:15" does too. So the miss is the "разбуди"
|
||||
phrasing against the classifier, not the half hour. The time slot now fills.
|
||||
|
||||
## Not measured here
|
||||
|
||||
Python dateparser. It is not installed on this host, so only the stub was run.
|
||||
The rewrite emits "в 7:30 вечера". The script's own qualifier rewrite turns that
|
||||
trailing "вечера" into "pm", which is the shape it already reads for a whole hour.
|
||||
Judge it on the box.
|
||||
|
||||
The LLM arm. No llama-server in this run, so the cascade number is the classifier
|
||||
floor.
|
||||
|
||||
## Left out on purpose
|
||||
|
||||
Minutes a spoken clock does not use. "без семи восемь" is not rewritten, because
|
||||
nobody says it and a guess in this shape is a missed dose. The parsers fail on it
|
||||
as they did before.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Does the ZIM answer when the line is down? (V-508)
|
||||
|
||||
Measured 2026-08-05 on the deploy, through `POST /api/chat`. The question was
|
||||
`что такое фотосинтез` in every run. Which source claimed is read off
|
||||
`voice: query claimed by source` and off the badge V-539 added.
|
||||
|
||||
## It fires, and it is fast when the host is gone
|
||||
|
||||
`docker stop searxng`, then one question:
|
||||
|
||||
| | Claimed by | Turn |
|
||||
|---|---|---|
|
||||
| Search reachable | search | 3.5 s |
|
||||
| Container stopped | kiwix | 3.5 s |
|
||||
| Host blackholed | kiwix | 15.4 s |
|
||||
|
||||
With the container stopped, DNS failed and the ZIM answered inside the same
|
||||
second:
|
||||
|
||||
```
|
||||
15:40:51 voice: search "что такое фотосинтез": ... lookup searxng: no such host
|
||||
15:40:51 voice: kiwix: "photosynthesis" → 5 hits, top "Photosynthesis"
|
||||
15:40:53 voice: query claimed by source "kiwix"
|
||||
```
|
||||
|
||||
The rewrite, the search and the reply all fit in the same turn budget as a live
|
||||
search. The fallback works.
|
||||
|
||||
## The blackhole is the case that hurts
|
||||
|
||||
192.0.2.1 is reserved and routed nowhere. Pointing `search.url` at it is the
|
||||
shape of a real outage: the router drops the packet instead of refusing it. The
|
||||
search sat for its full 8-second budget before the ZIM was asked. The turn took
|
||||
15.4 seconds against 3.5. He waits through all of it with nothing
|
||||
being said.
|
||||
|
||||
Fixed by capping the connect phase alone at 1.5 s (`dialTimeout` in
|
||||
`internal/websearch/searxng.go`). The instance is on the LAN, so a connection it
|
||||
will ever accept is accepted in milliseconds. A reachable instance that is
|
||||
merely slow still gets the whole 8 seconds. It is fanning out to real engines,
|
||||
which is worth waiting for.
|
||||
|
||||
## The Russian ZIM is now on the box and is read directly
|
||||
|
||||
`wikipedia_ru_all_maxi_2026-02` (41 GB) was copied to the kiwix zims directory
|
||||
and kiwix-serve picked it up. Note that the catalog name is derived from the
|
||||
filename. `books.name=wikipedia_ru_all_maxi_2026-02` returns Фотосинтез,
|
||||
С4-фотосинтез and Википедия. The `<name>` field in the catalog says
|
||||
`wikipedia_ru_all`, which returns nothing.
|
||||
|
||||
A Cyrillic question now searches that book verbatim (`book_ru` in the `kiwix`
|
||||
block). The rewriter was never a feature. An English ZIM cannot match a Russian
|
||||
sentence, so the resident model translated the question into English keywords
|
||||
first. That costs a model call. It also drops whatever the keywords do not carry.
|
||||
Against a Russian book it is a translation of his own words back at him.
|
||||
|
||||
## Not measured here
|
||||
|
||||
- The Russian book answering a driven turn. The `book_ru` field is a binary
|
||||
change, so it needs a rebuild the owner runs. The book itself was verified by
|
||||
querying kiwix-serve directly.
|
||||
- Recall against the Russian book compared with the rewrite path. Reading his
|
||||
own language directly should win, and it was not scored.
|
||||
- `ru.stackoverflow.com_mul_all_2026-02.zim` is still in the staging directory
|
||||
and is wired to nothing.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Praxis reach at stage 0, 2026-08-05
|
||||
|
||||
Vikunja #516. Measured with `make eval-reach` on the held-out ecosystem fixture
|
||||
(`internal/router/eval/ru_ecosystem_v1.json`, 30 cases), classifier + ONNX embedder,
|
||||
no llama-server in the run. The LLM arm was not measured, so judge a cascade
|
||||
number again before quoting one.
|
||||
|
||||
## Result
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| overall | 16/30 (53.3%) | 27/30 (90.0%) |
|
||||
| by want: praxis | 0/12 | 11/12 |
|
||||
| by want: hexis | 9/10 | 9/10 |
|
||||
| by want: none | 7/8 | 7/8 |
|
||||
| by tag: lifecycle | 0/5 | 5/5 |
|
||||
| by tag: attention | 0/7 | 6/7 |
|
||||
| by tag: reading | 0/7 | 6/7 |
|
||||
| wrong praxis arm | 0 | 0 |
|
||||
| p50 latency | 20.6ms | 16.5ms |
|
||||
|
||||
## Why it was zero
|
||||
|
||||
Not a tuning gap. `handlePraxisAct` dispatches on exact equality between
|
||||
`Slots.Fn` and a capability alias, and the fn slot is filled by `DefaultActMatcher`
|
||||
from the deployment's enabled tool names. No Praxis alias is on that list, so no
|
||||
utterance could put one in the slot. The Russian aliases in `praxisCapabilities`
|
||||
read as if they matched speech. They are compared against a fn slot and never
|
||||
against an utterance.
|
||||
|
||||
`PraxisGrammars()` (`internal/router/praxis.go`) fills the slot at stage 0, wired in
|
||||
`buildRouter` before the capture marker because "отметь" is a capture verb.
|
||||
|
||||
## The three misses that remain
|
||||
|
||||
- `eco-ru-006` "запусти бэкап на нексусе", a Hexis case, routed note. Pre-existing.
|
||||
- `eco-ru-028` "выключи", reached Hexis, should have asked. Pre-existing.
|
||||
- `eco-ru-021` "что там с нексусом" wants scoped attention. Deliberately not
|
||||
claimed. "что там с X" also opens "что там с погодой". Routing a weather
|
||||
question to Nexus is worse than one missed fixture case.
|
||||
|
||||
## Two judgement calls worth re-arguing
|
||||
|
||||
**A lifecycle word alone does not transition an item.** "готово" is what he says
|
||||
about the thing he just finished. So the rules split lifecycle words by mood. An
|
||||
imperative he says to her ("закрывай") claims the turn bare, and the capability
|
||||
asks which пункт. A stative ("готово", "принято") needs an item named beside it.
|
||||
|
||||
The bare-imperative arm also requires that nothing else in the sentence is being
|
||||
acted on. "закрой шторы в комнате" is an imperative too. Without that guard it took
|
||||
a house command to Praxis, measured at hexis 8/10 mid-change.
|
||||
|
||||
**A demonstrative resolves only against a one-item digest.** "отметь это как
|
||||
сделанное" points at what she just read. `resolveSurfacedPosition` maps it to an id
|
||||
only when exactly one item was spoken. With two or more it gives the turn back to
|
||||
the cascade rather than transitioning one of them at random. With no digest at all
|
||||
it gives the turn back too, because "я это сделал" was never about a пункт.
|
||||
|
||||
## Routing fixture
|
||||
|
||||
`make eval-router`, same run: classifier + ONNX 60/84 (71.4% full and intent-only),
|
||||
0 false clarifies, 6 missed clarifies (the known `amb-*` set). No failure in that
|
||||
list comes from a stage-0 decision. Every one carries a classifier confidence score.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Ecosystem reach with the resident model as router
|
||||
|
||||
Date: 2026-08-05. Vikunja #517, split out of #405.
|
||||
Fixture: `internal/router/eval/ru_ecosystem_v1.json`, 30 held-out Russian cases.
|
||||
Model: Qwen3-1.7B-UD-Q4_K_XL, llama-server on the host at 127.0.0.1:8899.
|
||||
Harness: `TestReachWithLLMRouter` in `internal/router/eval/llmrouter_test.go`.
|
||||
|
||||
## The numbers
|
||||
|
||||
| configuration | reached the right place | praxis | hexis | none |
|
||||
|---|---|---|---|---|
|
||||
| classifier + hash (V-405 floor) | 16/30 | 0/12 | — | — |
|
||||
| classifier + ONNX, after the V-516 grammars | 27/30 | 11/12 | — | — |
|
||||
| **llm-only** (resident model alone) | **17/30 (56.7%)** | **0/12** | 10/10 | 7/8 |
|
||||
| **cascade + llm** + hash fallback | **28/30 (93.3%)** | **11/12** | 10/10 | 7/8 |
|
||||
|
||||
Latency: llm-only p50 1.29s, p95 1.65s. Cascade p50 1.11s, p95 1.64s.
|
||||
No case errored in either configuration.
|
||||
|
||||
## The open question is answered: the model never reaches Praxis
|
||||
|
||||
The route grammar lets the model write any string into the `fn` slot. So it
|
||||
could in principle emit a literal Praxis capability name, and reach a service
|
||||
the classifier structurally cannot. It does not. **Praxis is 0/12 with the
|
||||
model alone.** That is exactly what the classifier alone scores. Every one of
|
||||
the twelve fails the same way: the utterance stays local with an empty `fn`.
|
||||
|
||||
So the stage-0 Praxis grammars from V-516 are not a determinism argument. They
|
||||
are the only path to Praxis that exists. Deleting them takes reach from 11/12
|
||||
back to 0/12 whichever engine is answering.
|
||||
|
||||
The failure is not that the model routes these badly in its own terms. It
|
||||
spreads them across `query`, `fact`, `system` and `chat`. Those are reasonable
|
||||
readings of "что требует внимания" and "готово, закрывай" for a model that has
|
||||
never been told Praxis exists. Nothing in the prompt names a Praxis capability,
|
||||
so there is no string for it to write.
|
||||
|
||||
## What the model does buy
|
||||
|
||||
Hexis is 10/10 with the model alone, and the mutating tag is 10/15 llm-only
|
||||
against 15/15 through the cascade. The model reaches everything Hexis owns
|
||||
without help, which is the half the act allowlist already names in the prompt.
|
||||
|
||||
Cascade + llm scores one point above the classifier baseline: 28/30 against
|
||||
27/30, the difference being one attention case. That is the same shape as the
|
||||
routing fixture, where the router buys about 4 points rather than a doubling.
|
||||
|
||||
## The two that still miss
|
||||
|
||||
- `eco-ru-021 "что там с нексусом"`. Routes `query`, stays local, wants Praxis.
|
||||
Asking after a named service reads as a question about a thing, and no
|
||||
grammar claims a service name.
|
||||
- `eco-ru-029 "сделай это"`. Routes `act` and reaches Hexis. The fixture wants
|
||||
nothing reached, because "это" names no target. This is the overreach case
|
||||
and it is the one direction worth failing on. The confirmation binding
|
||||
downstream still resolves a canonical entity id before anything executes.
|
||||
The fixture is right that the turn should have asked.
|
||||
|
||||
Overreach is 1 in both configurations, under the 4 the harness asserts.
|
||||
|
||||
## How to re-run
|
||||
|
||||
```sh
|
||||
MAVEN_LLM_URL=http://127.0.0.1:8899 \
|
||||
deps/go/go/bin/go test -v -count=1 -timeout 40m \
|
||||
-run TestReachWithLLMRouter ./internal/router/eval/
|
||||
```
|
||||
|
||||
The host `http_proxy` answers 503 for 127.0.0.1. `noProxyLoopback` in the test
|
||||
excludes it. A run that scores every case as a route error measured the proxy.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Routing with the resident model, re-measured
|
||||
|
||||
Date: 2026-08-05. Vikunja #320 items 2 and 3.
|
||||
Fixture: `internal/router/eval/ru_routing_v1.json`, now **91 cases** (76 ru, 15 en).
|
||||
Model: Qwen3-1.7B-UD-Q4_K_XL, llama-server on the host at 127.0.0.1:8899.
|
||||
Harness: `TestLLMRouterBaseline`, `make eval-models`.
|
||||
|
||||
## How the block was cleared
|
||||
|
||||
Item 2 was blocked because the resident llama-server binds `--host 127.0.0.1
|
||||
--port 0` inside `maven-mavend-1`. The port is kernel-assigned, scraped from
|
||||
stderr and never published, so no `go test` on the host can reach it. The task
|
||||
listed three ways out. This run took the first: a **second** llama-server on
|
||||
the same gguf, on a fixed host port. The Vega takes the second copy of a 1.7B
|
||||
without complaint.
|
||||
|
||||
## The numbers
|
||||
|
||||
| configuration | full | intent-only | p50 | p95 |
|
||||
|---|---|---|---|---|
|
||||
| llm-only | 34/91 (37.4%) | 61.5% | 1.24s | 1.65s |
|
||||
| cascade + llm + hash fallback | 69/91 (75.8%) | 80.2% | 1.19s | 1.65s |
|
||||
|
||||
By language, through the cascade: ru 57/76, en 12/15.
|
||||
Clarify: 3 false, 1 missed. No errors. Six slots deferred to the daemon.
|
||||
|
||||
For comparison, the figures that stood in CLAUDE.md were 72.7% full and 77.9%
|
||||
intent-only, measured on 77 cases. The fixture has grown by 14 cases since, so
|
||||
this is a new baseline rather than a movement.
|
||||
|
||||
## llm-only is low for a reason that is not routing
|
||||
|
||||
37.4% full against 61.5% intent-only is the gap, and it is almost entirely
|
||||
slots. Every reminder case fails with "no time slot, want one". The model
|
||||
routes `reminder` correctly and leaves the time to the daemon, which is what
|
||||
the contract asks of it. The cascade fills those slots. That is why the same
|
||||
model scores 38 points higher inside it.
|
||||
|
||||
Three cases errored in the llm-only arm and none in the cascade, which is the
|
||||
fallback working as designed.
|
||||
|
||||
## Item 3: latency
|
||||
|
||||
Router p50 1.19s, p95 1.65s, max 1.79s through the cascade. The one earlier
|
||||
data point in the task, roughly 6s wall clock for `привет` through
|
||||
`POST /api/chat`, was the whole path and not the router. It is not comparable
|
||||
and should not be quoted as a routing number.
|
||||
|
||||
These numbers are the homesrv floor. With the workstation up, routing completes
|
||||
against gemma-4-12b at p50 329ms, measured separately in
|
||||
`docs/evals/2026-08-02-workstation-gemma4-12b.md`.
|
||||
|
||||
## What still misses
|
||||
|
||||
The confusion is concentrated in one direction: `query→fact ×4`,
|
||||
`query→note ×3`, `query→system ×3`. A question about his own rows that carries
|
||||
no interrogative reads as a statement to the model. Ten of the twenty-two
|
||||
failures are that shape, including "я сегодня вообще пил воду" and "чем я
|
||||
занимался в среду". This is the case V-546's three-head classifier is aimed at.
|
||||
|
||||
The two `разбуди меня` cases clarify at 0.300 instead of routing `reminder`.
|
||||
|
||||
## Item 4 is still not run
|
||||
|
||||
Killing the resident llama-server to confirm the classifier floor needs a
|
||||
permission this session does not have. The test is otherwise ready. It now has
|
||||
a second half. With the workstation up, killing the resident server should
|
||||
still complete a turn through `modelSeam`. Only killing both proves the
|
||||
classifier answers.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Does SearXNG claim a question it cannot answer? (V-539)
|
||||
|
||||
Measured 2026-08-05 against the configured instance, `http://127.0.0.1:9563`,
|
||||
`max_results: 4`, `language: auto`. Sixteen Russian questions: eight real, eight
|
||||
invented from non-words. The probe read SearXNG's JSON directly, so this measures
|
||||
the search, not the cascade around it.
|
||||
|
||||
## The premise no longer reproduces
|
||||
|
||||
V-539 was filed on the 2026-08-02 measurement, where SearXNG returned four
|
||||
results for every query including `зыркабулентный флогистон Мшанского`, and no
|
||||
`voice: kiwix:` line ever appeared. Today the same shape of query returns
|
||||
nothing:
|
||||
|
||||
| Query set | Zero results | Four results claimed |
|
||||
|---|---|---|
|
||||
| Eight real questions | 0 | 8 |
|
||||
| Eight invented questions | 7 | 1 |
|
||||
|
||||
`Response.Empty()` is already the gate. Seven of eight invented questions now
|
||||
pass the turn to the ZIM with no code change at all. What changed is upstream.
|
||||
Every real answer today comes from `google cse`. It answers a non-word with an
|
||||
empty result set, where the engine set of three days ago answered with
|
||||
something.
|
||||
|
||||
## The one that still claims
|
||||
|
||||
`трюмбальная нидроскопия` returned four results, all about a lumbar puncture:
|
||||
|
||||
```
|
||||
Люмбальная пункция - адреса и стоимость в больницах в СПб
|
||||
Пункция спинного мозга - Больница «Шиба
|
||||
Педиатрический фантом люмбальной пункции новорожденного
|
||||
```
|
||||
|
||||
The engine read the invented word as a misspelling of a real one and answered
|
||||
the real one. That is the whole remaining failure, and it is a near-miss
|
||||
spelling rather than a catch-all.
|
||||
|
||||
## The three candidate signals do not separate the sets
|
||||
|
||||
V-539 named three signals a quality gate could read. Each was recorded per
|
||||
query:
|
||||
|
||||
- **No result title shares a token with the query.** Useless. It is true of the
|
||||
one bad claim, and also true of `столица Франции`, whose four titles are
|
||||
`Париж`, `Франция`, `Париж — Путеводитель`, `Париж - Море Трэвел`. The right
|
||||
answer to a capital-city question is the city, which is not a word in the
|
||||
question. Two more real questions score 3 of 4 rather than 4.
|
||||
- **Every snippet is empty.** Never fired. Zero empty snippets across all
|
||||
sixteen queries, real or invented. `ParseResponse` already drops a hit with no
|
||||
text, so this signal cannot fire by construction.
|
||||
- **A spelling-suggestion or catch-all engine answered.** Never fired. SearXNG
|
||||
returned no `corrections` and no `suggestions` for any query, including the one
|
||||
that silently corrected the spelling itself.
|
||||
|
||||
## Decision: do not build the threshold
|
||||
|
||||
A gate on token overlap would cost `столица Франции` a correct answer to save
|
||||
one invented word, and the other two signals cannot fire. The task said a wrong
|
||||
threshold costs a real answer and needs measuring first. It was measured and it
|
||||
loses.
|
||||
|
||||
What ships instead is the second half of V-539. The claiming query source now
|
||||
crosses the IPC seam on `ipc.ChatReply.Source`. It renders as a badge beside the
|
||||
reply on `/chat`. The only evidence before it was a `voice:` log line, which is
|
||||
why this was hard to judge. The next occurrence is readable off the UI rather
|
||||
than off the box.
|
||||
|
||||
## Not measured here
|
||||
|
||||
- The cascade. This probe read SearXNG directly. It says nothing about how
|
||||
`querySearch` phrases what it gets, or whether the resident model turns four
|
||||
weak snippets into a confident wrong sentence.
|
||||
- Kiwix. It was healthy on 2026-08-02 and was not re-probed today.
|
||||
- English questions. The premise was about Russian, where the invented words are.
|
||||
- Whether the engine set is stable. The whole finding is that it moved in three
|
||||
days, so this table is a reading of one day.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Talk fixture against the resident model, 2026-08-05
|
||||
|
||||
Vikunja #44 step 1. `MAVEN_LLM_URL=http://127.0.0.1:8899 make eval-phrasing`,
|
||||
Qwen3-1.7B-UD-Q4_K_XL on the host, no workstation in the run. The fixture holds
|
||||
36 cases now, against 27 when the bakeoff measured it. So the old score is not
|
||||
a column in this table.
|
||||
|
||||
## Result
|
||||
|
||||
| | before the escape fix | after |
|
||||
|---|---|---|
|
||||
| talk, passes every check | 2/36 (5.6%) | 25/36 (69.4%) |
|
||||
| failed generations | 31 | 0 |
|
||||
| by path: chat | 0/9 | 4/9 |
|
||||
| by path: knowledge | 1/9 | 6/9 |
|
||||
| by path: query | 0/9 | 9/9 |
|
||||
| by path: reply | 1/9 | 6/9 |
|
||||
| feminine | 5/36 | 36/36 |
|
||||
| address | 5/36 | 33/36 |
|
||||
| ontopic | 2/36 | 28/36 |
|
||||
| p50 latency | 3.05s | 2.97s |
|
||||
| nudges (15 cases) | 15/15 | 15/15 |
|
||||
|
||||
## What the 31 errors were
|
||||
|
||||
Not the model. `escapeRawControls` in `internal/phraser/llmphraser.go`, added
|
||||
for #537 to repair a raw newline written inside a string, escaped the whole
|
||||
object. Qwen3-1.7B pretty-prints: it opens `{` and writes three newlines before
|
||||
the first key. Those newlines became a literal backslash-n, which is legal
|
||||
nowhere outside a string, so the object stopped parsing and `parseResponseMood`
|
||||
reported `errBrokenJSON`.
|
||||
|
||||
The comment said escaping unconditionally could not turn valid JSON into
|
||||
anything else, because JSON permits no control character outside a string. It
|
||||
permits three. Newline, tab and return are whitespace between tokens, and that
|
||||
is what pretty-printing is made of.
|
||||
|
||||
Every chat reply and every knowledge answer the resident model wrote was being
|
||||
discarded for a stub line. The nudge path never showed it, because the nudge
|
||||
prompt gets compact JSON back.
|
||||
|
||||
## The 11 that still fail
|
||||
|
||||
Eight are `ontopic`, three are `address`.
|
||||
|
||||
The address failures are all plural imperatives written to a formal listener:
|
||||
`держите`, `уточните`, `попробуйте`. Feminine self-reference held in all 36,
|
||||
which is the half #122 is training for. So the persona gap the CPT is aimed at
|
||||
is now the address half, not the gender half.
|
||||
|
||||
The ontopic failures are the resident model answering next to the question
|
||||
rather than in it. `chat-joke` describes crying dolls instead of telling one,
|
||||
`know-hiccups` calls hiccups an icon, `know-boil-egg` answers about an omelette.
|
||||
`chat-about-me` answers "Я - записка", which is the same confabulation the
|
||||
bakeoff recorded.
|
||||
|
||||
## Not measured here
|
||||
|
||||
The workstation. Every number above is the homesrv floor. `make eval-phrasing`
|
||||
points at one URL, so a gemma-4-12b column needs its own run.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Talk temperature sweep: Qwen3-1.7B, 4 temperatures × 3 runs
|
||||
|
||||
Date: 05-08-2026. Model: Qwen3-1.7B-UD-Q4_K_XL, the resident model, on homesrv.
|
||||
Harness: `TestTalkTemperatureSweep` (`internal/phraser/eval/temperature_test.go`),
|
||||
gated on `MAVEN_LLM_URL` + `MAVEN_TEMP_SWEEP`. Fixture: the 36-case talk set.
|
||||
Wall clock: 3394s for all twelve runs. Vikunja #402.
|
||||
|
||||
## What was asked
|
||||
|
||||
Whether 0.7 is the right sampling temperature for phrasing, and whether a lower
|
||||
one buys persona compliance.
|
||||
|
||||
## Numbers
|
||||
|
||||
| temp | run 1 | run 2 | run 3 | mean | errors |
|
||||
|---|---|---|---|---|---|
|
||||
| 0.70 | 24/36 | 24/36 | 22/36 | 23.3 (64.8%) | 7, 4, 6 |
|
||||
| 0.40 | 25/36 | 25/36 | 26/36 | 25.3 (70.4%) | 3, 4, 5 |
|
||||
| 0.20 | 22/36 | 23/36 | 26/36 | 23.7 (65.7%) | 6, 5, 4 |
|
||||
| 0.05 | 23/36 | 25/36 | 23/36 | 23.7 (65.7%) | 5, 5, 6 |
|
||||
|
||||
## What it says
|
||||
|
||||
**The sweep does not separate the temperatures.** 0.40 leads by 5.6 points on
|
||||
the mean. The spread inside a single temperature is 11 points: 0.20 ranges 22 to
|
||||
26 across three runs of the same setting. Three runs cannot tell a 5.6-point
|
||||
effect from that noise. Lowering the temperature to 0.05 does not help either.
|
||||
That is the result that would have been most useful if it had.
|
||||
|
||||
**So the default stays 0.7.** `Config.Temperature` is now a config field, so
|
||||
setting it is a one-line change. No measurement here justifies moving it. Anyone
|
||||
re-running this needs more runs per setting, not more settings.
|
||||
|
||||
## The finding that is not about temperature
|
||||
|
||||
Sixty of the failures across twelve runs are one error:
|
||||
`phraser: model output starts as JSON but does not parse`. The case fails with
|
||||
an empty string, so it costs a whole case rather than one check.
|
||||
|
||||
They are not spread evenly. Every one lands in the `reply` family, and the
|
||||
distribution is:
|
||||
|
||||
| case | runs failed (of 12) |
|
||||
|---|---|
|
||||
| reply-reminder-tomorrow | 12 |
|
||||
| reply-reminder-evening | 12 |
|
||||
| reply-question-bait | 11 |
|
||||
| reply-formality-bait | 9 |
|
||||
| reply-note-router | 8 |
|
||||
| reply-fact-weight | 6 |
|
||||
|
||||
Two cases fail in every single run at every temperature. That is not sampling
|
||||
noise, and no temperature will fix it. It is a defect in the reply phrasing
|
||||
path. It caps the talk fixture at 30/36 before persona is scored at all. Filed
|
||||
as Vikunja #537.
|
||||
|
||||
The talk score of 27/36 recorded on 2026-08-04 went through a different call
|
||||
path. It is not comparable to the numbers above.
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
# Offloading model work to the workstation
|
||||
|
||||
*Last verified: 2026-08-03 @ 12530c8. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-05 @ b789676. Living doc: correct it in place, do not append.*
|
||||
|
||||
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
|
||||
work, and this file holds the shape and the rules all four must obey.
|
||||
@@ -149,6 +149,13 @@ flips. It is wired anyway: `PhraseReminder` is on the same transport and is on.
|
||||
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
|
||||
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
|
||||
|
||||
Speech-to-text stays two stages when it moves. One call carrying both a clip and the router
|
||||
prompt was measured on 05-08-2026. It scores 54.2% intent-only against 84.7% for whisper on
|
||||
homesrv, on the same 72 cases. The model transcribes clips it then routes wrong, so a long
|
||||
classification prompt and an audio part compete for attention. Transcribing on the
|
||||
workstation and routing the text scores 83.3% at p50 997ms. So the transfer buys 375ms and
|
||||
cleaner transcripts, not accuracy. See `docs/evals/2026-08-05-audio-in-routing.md`.
|
||||
|
||||
## Order
|
||||
|
||||
1. **Transport** (#484). Nothing else is possible until a seam can cross a host.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Plan: route with heads on e5-small, not with a generative model
|
||||
|
||||
**Owner's call, 05-08-2026. Vikunja #546.**
|
||||
|
||||
**Verdict: the routing model is the 118M multilingual-e5-small already resident on
|
||||
homesrv.** It gets one classification head per output. No LoRA on a decoder, no 100M model
|
||||
trained from scratch. Routing has a bounded output space, so it is classification. A model
|
||||
that generates is being asked to do the wrong job.
|
||||
|
||||
Last verified: 05-08-2026 @ 52fd218
|
||||
|
||||
## The two options this rules out
|
||||
|
||||
**A LoRA on Qwen3-0.6B or 1.7B.** About 1 to 4 GPU hours on the workstation, for 20k
|
||||
examples over three epochs. It works, and it still generates. So the output still needs a
|
||||
GBNF grammar in front of it. The confidence still has to be rebuilt from structure, the way
|
||||
`gateLLMDecision` does today.
|
||||
|
||||
**A 100M decoder from scratch.** It needs roughly 2B tokens to be a usable language model.
|
||||
That is 6 × 1e8 × 2e9, about 1.2e18 FLOPs. A 16GB card
|
||||
does that in 10 to 20 GPU hours at its effective throughput. It also needs a Russian tokenizer built and a corpus assembled.
|
||||
What it buys is a small model generating Russian, and `CLAUDE.md` already records that as
|
||||
the thing that does not work. LFM2.5-350M routes at 5.2% and answered "столица Франции?"
|
||||
with the invented non-word "Сторзит".
|
||||
|
||||
That finding is about generation, not about size. A 118M encoder classifying Russian is a
|
||||
different job with a bounded output space. The measured recall of e5-small on this box is
|
||||
the evidence it reads the language well enough.
|
||||
|
||||
## What the model becomes
|
||||
|
||||
The encoder body stays as it is. Three heads sit on top of one forward pass:
|
||||
|
||||
| Output | Head | Reads |
|
||||
|---|---|---|
|
||||
| intent | `Linear(384, 7)` | the mean-pooled vector |
|
||||
| mood | `Linear(384, 5)` | the mean-pooled vector |
|
||||
| slots | `Linear(384, 9)` per token | `last_hidden_state` |
|
||||
|
||||
Seven intents are the existing enum: fact, reminder, note, query, act, chat, system. Five
|
||||
moods are the existing enum: neutral, happy, thinking, tired, confused. Nine slot tags are
|
||||
BIO over `key`, `value`, `when` and `fn`, plus outside.
|
||||
|
||||
Total head size is about 12k parameters. That number decides how this is served. See the
|
||||
serving section below.
|
||||
|
||||
## Two things this buys that the current router cannot
|
||||
|
||||
**Constrained output stops being a grammar problem.** There is no free generation, so the
|
||||
heads can only emit values that exist. Four mechanisms exist today because a decoder can
|
||||
write anything. The GBNF grammar, the JSON parse, the fallback to plain text, the legacy
|
||||
`{"body","summary"}` path. A softmax cannot write anything.
|
||||
|
||||
**Confidence becomes a real number.** `Confidence: 1.0` was hardcoded in `llmrouter.go`,
|
||||
so the router could never ask for clarification. V-359 had to rebuild a signal out of
|
||||
structure: single-token utterance, keyless fact, act with no allowlisted fn. Max softmax
|
||||
over the intent head is calibratable against the fixture. `r.threshold` and the stage-3
|
||||
gate would read a probability instead of a proxy. Two false clarifies survived the V-359
|
||||
fix, both in the act-with-no-allowlisted-fn arm. That is the arm a calibrated score
|
||||
replaces.
|
||||
|
||||
## Cost
|
||||
|
||||
About 20k examples at 64 tokens over five epochs is 6.4M tokens. So 6 × 1.2e8 × 6.4e6,
|
||||
roughly 5e15 FLOPs. **10 to 30 minutes on the workstation. Under 2GB of VRAM.** It also
|
||||
finishes overnight on homesrv's CPU when the card is busy. That matters, because the
|
||||
workstation is never assumed up.
|
||||
|
||||
Freeze the embedding table. The XLM-R vocabulary is about 96M of the 118M parameters, and
|
||||
it is the part that overfits 20k examples. Train the twelve layers and the heads, at 2e-5
|
||||
on the body and 1e-3 on the heads, batch 32, sequence 64. Loss is cross-entropy on intent
|
||||
plus mood plus per-token tags, with the tag term down-weighted.
|
||||
|
||||
## The trap: fine-tune a copy
|
||||
|
||||
The resident embedder backs memory recall. `docs/evals/2026-08-04-recall-e5-small.md`
|
||||
measured ten points of recall@1 above MiniLM, at 2.5× the speed. `CLAUDE.md` says it stays
|
||||
on homesrv permanently, because it backs the floor.
|
||||
|
||||
Training it in place couples routing accuracy to recall. Say a run gains four points of
|
||||
intent accuracy and quietly loses six of recall@1. It would look like a win, and nothing
|
||||
in the test suite would name the trade. So the routing weights are a second file. About
|
||||
45MB extra, quantized. `modelIDFromPath` already derives the DB marker from the filename.
|
||||
So two files means two ids, and no ambiguity about which model wrote an embedding.
|
||||
|
||||
## Serving: the heads do not need a runtime
|
||||
|
||||
`internal/router/onnxembedder.go` already asks the ONNX session for `last_hidden_state` at
|
||||
`[1, 128, 384]` and mean-pools in Go. Both tensors the heads need already cross into Go on
|
||||
every call.
|
||||
|
||||
At 12k parameters the heads are three dot products. They can be plain Go over a weights
|
||||
file rather than a second ONNX graph. Then the export step covers the encoder only, and
|
||||
the head weights are data. That keeps the whole thing inside the existing session, the
|
||||
existing vendored tokenizer and the existing `TestONNX*` tests.
|
||||
|
||||
Wire it where the router sits. `pickLLMRouter` becomes a three-way choice. The classifier
|
||||
stays underneath as the floor. The rule that keeps it there now still holds: a turn must
|
||||
never break on a model. The resident model keeps chat, world answers and phrasing. None of that
|
||||
is classification, and this cannot do it.
|
||||
|
||||
## The labeled set is the whole project
|
||||
|
||||
There are 77 RU routing cases and 30 Praxis cases today. That is a test set, not a
|
||||
training set.
|
||||
|
||||
The stage 0 grammars are high-precision label functions. `AgendaQueryGrammars`,
|
||||
`NarrativeQueryGrammar` and `PraxisGrammars` each decide a shape deterministically, so
|
||||
running them over the turn history self-labels it. Training on their output distils the
|
||||
rules into the model. That is the point rather than a compromise: the model generalizes
|
||||
past a regex, where Go's `\b` never fires after a Cyrillic letter.
|
||||
|
||||
Two rules for the data:
|
||||
|
||||
- **The fixtures stay out of training.** Otherwise the measurement reads the rules and
|
||||
reports them as the model.
|
||||
- **Keep cases the grammars do not cover.** A set labeled only by the rules teaches only
|
||||
the rules. The hard cases carry no interrogative and no question mark, which is why
|
||||
V-498 existed.
|
||||
|
||||
Expanding to 20k runs through gemma-4-12b on the workstation. `docs/evals/2026-08-02-workstation-gemma4-12b.md`
|
||||
measured 329ms per call. So that is 2 to 4 hours of the card, plus a day of the owner
|
||||
reading it. That is the real cost of this plan.
|
||||
|
||||
## How it gets judged
|
||||
|
||||
The 77-case RU routing fixture, against the two numbers that always answer:
|
||||
|
||||
| Path | Full | Intent-only | p50 |
|
||||
|---|---|---|---|
|
||||
| classifier | 68.8% | — | 16.6µs |
|
||||
| resident Qwen3-1.7B, through the cascade | 72.7% | 77.9% | 0.80 to 1.04s |
|
||||
| heads on e5-small | to measure | to measure | to measure |
|
||||
|
||||
Latency should land near the classifier rather than near the router. This is one encoder
|
||||
pass and three dot products, against the classifier's one encoder pass and a
|
||||
nearest-neighbour scan. If it does, V-464 answers itself, and the latency trade the LLM
|
||||
router asks for stops being a trade.
|
||||
|
||||
## Not decided here
|
||||
|
||||
- Whether mood belongs on this model at all. Mood is a phrasing property, and the router
|
||||
emitting it is a convenience. A fifth head is cheap, so this is a question about where
|
||||
the value is read, not about cost.
|
||||
- Whether the slot head replaces the stage 0 grammars or sits behind them. Cascade order
|
||||
is a separate measurement, and the rules are currently faster and exact.
|
||||
- The confidence calibration method. Temperature scaling on a held-out split is the
|
||||
obvious first try, and it has not been measured.
|
||||
@@ -348,8 +348,8 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if reply != "echo: привет" {
|
||||
t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет")
|
||||
if reply.Reply != "echo: привет" {
|
||||
t.Fatalf("Chat reply = %q; want %q", reply.Reply, "echo: привет")
|
||||
}
|
||||
if fake.chats != 1 {
|
||||
t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats)
|
||||
@@ -373,9 +373,9 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
|
||||
return int64(r.writes), nil
|
||||
}
|
||||
|
||||
func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) {
|
||||
func (r *recordingAPI) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) {
|
||||
r.chats++
|
||||
return "echo: " + text, nil
|
||||
return ipc.ChatReply{Reply: "echo: " + text}, nil
|
||||
}
|
||||
|
||||
// mustWriteFactParams — minimal WriteFactReq JSON with only the source field,
|
||||
|
||||
@@ -91,6 +91,19 @@ func Requirement(m ipc.Method) Authority {
|
||||
// can do is make Maven stop recognising someone, which is the state the
|
||||
// box ships in anyway.
|
||||
return AuthWrite
|
||||
case ipc.MethodSeedEvent:
|
||||
// The one backdating write path in the tree (Vikunja #518). AuthStepUp,
|
||||
// the same rung as mutating the tool allowlist, and for a reason that is
|
||||
// not about privilege: every other write records when something actually
|
||||
// happened, and this one asserts it. A caller who can place a fact in the
|
||||
// past can manufacture a routine Maven will then act on forever, which is
|
||||
// the tick loop obeying evidence nobody produced.
|
||||
//
|
||||
// Step-up is not the real gate and is not meant to be. mavend refuses the
|
||||
// method entirely unless started with -allow-seed, so the ordinary state
|
||||
// of the box is that no gesture reaches it. This rung is what stops a
|
||||
// module from calling it on a box where QA left the flag on.
|
||||
return AuthStepUp
|
||||
case ipc.MethodWriteFact:
|
||||
return AuthWrite
|
||||
case ipc.MethodIngestMail:
|
||||
|
||||
@@ -16,6 +16,7 @@ package calendar
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -125,6 +126,69 @@ func FactSummary(value string) string {
|
||||
return value[:i]
|
||||
}
|
||||
|
||||
// EventKeyPrefix — every calendar event fact starts with this. The loop scans
|
||||
// the family to work out whether a meeting covers right now (Vikunja #513).
|
||||
const EventKeyPrefix = "calendar_event_"
|
||||
|
||||
// FactSpan reads an event fact back into the instants it covers, against loc.
|
||||
// The day comes from the key and the two clock readings from the value's
|
||||
// "@ HH:MM-HH:MM" tail, which is everything FactValue wrote.
|
||||
//
|
||||
// ok is false for anything that does not parse. A fact whose span cannot be
|
||||
// read tells you nothing about now, and guessing a span is how a signal that
|
||||
// was meant to suppress one nudge starts suppressing all of them.
|
||||
//
|
||||
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
|
||||
// meeting covers the quarter hour it actually covers.
|
||||
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
|
||||
if !strings.HasPrefix(key, EventKeyPrefix) {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
rest := key[len(EventKeyPrefix):]
|
||||
if len(rest) < 8 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
day, err := time.ParseInLocation("20060102", rest[:8], loc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
// SetValue stores a string fact JSON-encoded, so the value comes back
|
||||
// quoted. Reading the tail off the quote is how this returned false for
|
||||
// every real event the first time it ran.
|
||||
if unq, err := strconv.Unquote(value); err == nil {
|
||||
value = unq
|
||||
}
|
||||
i := strings.LastIndex(value, " @ ")
|
||||
if i < 0 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
tail := value[i+len(" @ "):]
|
||||
from, to, found := strings.Cut(tail, "-")
|
||||
if !found {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
sh, sm, ok1 := parseHM(strings.TrimSpace(from))
|
||||
eh, em, ok2 := parseHM(strings.TrimSpace(to))
|
||||
if !ok1 || !ok2 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
|
||||
end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
|
||||
if !end.After(start) {
|
||||
end = end.Add(24 * time.Hour)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// parseHM reads "15:04" and nothing else.
|
||||
func parseHM(s string) (h, m int, ok bool) {
|
||||
t, err := time.Parse("15:04", s)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return t.Hour(), t.Minute(), true
|
||||
}
|
||||
|
||||
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
||||
// range-scans between two of these.
|
||||
func KeyPrefixForDay(day time.Time) string {
|
||||
|
||||
@@ -1085,6 +1085,17 @@ type KiwixConfig struct {
|
||||
// query at a time.
|
||||
Book string `json:"book,omitempty"`
|
||||
|
||||
// BookRU — the ZIM to search when the question is in Russian, by the same
|
||||
// catalog name. Empty ⇒ every question goes to Book.
|
||||
//
|
||||
// It exists because the rewriter is a workaround, not a feature (V-508). An
|
||||
// English ZIM cannot match a Russian sentence, so the resident model turns
|
||||
// the question into English keywords first, and that costs a model call and
|
||||
// loses whatever the keywords drop. A Russian ZIM matches the question as he
|
||||
// asked it. So a Cyrillic question searches this book verbatim and skips the
|
||||
// rewrite, and the English book keeps answering English ones.
|
||||
BookRU string `json:"book_ru,omitempty"`
|
||||
|
||||
// MaxResults — how many hits are asked for. 0 ⇒ DefaultKiwixResults.
|
||||
// Only the top few reach the phraser regardless; the rest are context the
|
||||
// snippet ranking throws away.
|
||||
|
||||
+128
-1
@@ -142,6 +142,11 @@ type Task struct {
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Resolved *time.Time `json:"resolved,omitempty"`
|
||||
ResolvedBy string `json:"resolved_by,omitempty"`
|
||||
// DoneWhen — the acceptance criterion. Empty until he writes one, and a
|
||||
// candidate with no criterion cannot be promoted to open (Vikunja #510).
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
// BlockedOn — a canonical Nexus entity id, never a name.
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
||||
@@ -170,6 +175,11 @@ type CaptureTaskReq struct {
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Ts time.Time `json:"ts"`
|
||||
// DoneWhen and BlockedOn are optional at intake. A derived source leaves
|
||||
// both empty: mail says what to do, not what finishing means, and guessing
|
||||
// a criterion would put Maven's reading in the field he is meant to write.
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskResp — Created is false when the same live task already existed,
|
||||
@@ -184,6 +194,48 @@ type CaptureTaskResp struct {
|
||||
Promoted bool `json:"promoted,omitempty"`
|
||||
}
|
||||
|
||||
// SeedEventReq — write one fact at a caller-supplied timestamp and run the
|
||||
// pattern path over it, so a recurring routine can be produced on demand
|
||||
// instead of over real days (Vikunja #518).
|
||||
//
|
||||
// This is the ONLY backdating write path in the tree, and it exists for one
|
||||
// reason: the detector needs four events spread over hours before it proposes
|
||||
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
|
||||
// running daemon at all. A store fixture would have exercised the detector
|
||||
// without the wiring those tasks doubt.
|
||||
//
|
||||
// Two things hold it shut. It is AuthStepUp in the authority table, the same
|
||||
// rung as mutating the tool allowlist. And mavend refuses it outright unless
|
||||
// started with -allow-seed, so a box nobody is testing carries no live
|
||||
// backdating path even for a caller who cleared the gate.
|
||||
//
|
||||
// Key and Value are a fact, not an event: extraction runs for real, so a key
|
||||
// the extractor ignores seeds nothing and says so. That is deliberate — a
|
||||
// seam that accepted action and object directly would let QA prove a detector
|
||||
// against events no utterance could ever produce.
|
||||
type SeedEventReq struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// SeedEventResp — what the seed produced. Extracted is false when the fact was
|
||||
// written but yielded no event, which is the extractor declining rather than a
|
||||
// failure. Proposed is true only when this seed completed a pattern; the first
|
||||
// three seeds of a run return false with no routine.
|
||||
type SeedEventResp struct {
|
||||
FactID int64 `json:"fact_id"`
|
||||
EventID int64 `json:"event_id,omitempty"`
|
||||
Extracted bool `json:"extracted"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Object string `json:"object,omitempty"`
|
||||
Proposed bool `json:"proposed"`
|
||||
RoutineID int64 `json:"routine_id,omitempty"`
|
||||
// IntervalDays — the median the detector settled on, echoed so QA can
|
||||
// check it against the spacing it asked for.
|
||||
IntervalDays float64 `json:"interval_days,omitempty"`
|
||||
}
|
||||
|
||||
// IngestMailReq — one message a mail reader has fetched, handed to core for
|
||||
// extraction (Vikunja #246).
|
||||
//
|
||||
@@ -497,6 +549,51 @@ type setTaskStatusReq struct {
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// EntityRef — one canonical entity from Nexus. Maven never mints these: an id
|
||||
// exists because Nexus resolved a name to it.
|
||||
//
|
||||
// Ambiguous is the answer when the name matched more than one entity. It is a
|
||||
// separate state from "not found" because the surface handles them
|
||||
// differently: an unknown name may be a typo, and an ambiguous one has to be
|
||||
// asked about, never picked (ECOSYSTEM-SPEC, and the same rule the mutating
|
||||
// Hexis path follows).
|
||||
type EntityRef struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Ambiguous bool `json:"ambiguous,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
}
|
||||
|
||||
type resolveEntityReq struct {
|
||||
Query string `json:"query"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
}
|
||||
|
||||
type resolveEntityResp struct {
|
||||
Ref EntityRef `json:"ref"`
|
||||
}
|
||||
|
||||
// editTaskReq — the rewrite of the three fields capture set (Vikunja #509).
|
||||
// Due nil clears the date, so "no date given" and "remove the date" cannot be
|
||||
// the same request.
|
||||
type editTaskReq struct {
|
||||
ID int64 `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
// setTaskFieldsReq — the write for the two board columns. Both are sent every
|
||||
// time and both may be empty: clearing a blocker is as ordinary as setting one,
|
||||
// so an omitted field cannot mean "leave it alone" without a second way to say
|
||||
// "make it empty".
|
||||
type setTaskFieldsReq struct {
|
||||
ID int64 `json:"id"`
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// idReq — methods keyed by a single id.
|
||||
type idReq struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -622,7 +719,20 @@ type chatReq struct {
|
||||
Conversation string `json:"conversation,omitempty"`
|
||||
}
|
||||
type chatResp struct {
|
||||
Reply string `json:"reply"`
|
||||
Reply string `json:"reply"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// ChatReply — one text turn's answer plus which query source claimed it.
|
||||
//
|
||||
// Source is diagnostic and is empty unless the turn was a question a source
|
||||
// claimed: a fact write, an act or a chat turn names none. It exists because
|
||||
// the claiming source was readable only in the daemon log, so a QA step could
|
||||
// not tell a wrong answer from a wrongly ordered chain (V-539). It is not
|
||||
// authorization and nothing routes on it.
|
||||
type ChatReply struct {
|
||||
Reply string
|
||||
Source string
|
||||
}
|
||||
|
||||
type proposeToolReq struct {
|
||||
@@ -792,6 +902,23 @@ type unlockReq struct {
|
||||
// wire round-tripping via errors.Is).
|
||||
var ErrToolNotFound = errors.New("ipc: tool not found")
|
||||
|
||||
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open with no
|
||||
// definition of done (Vikunja #510). Carried across the wire so the /tasks
|
||||
// form can say which refusal it hit rather than "не найдено".
|
||||
var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done")
|
||||
|
||||
// ErrTaskDuplicate — an edit would collide with another live task's normalised
|
||||
// text (Vikunja #509). The surface says which row holds it rather than merging.
|
||||
var ErrTaskDuplicate = errors.New("ipc: another live task already has this text")
|
||||
|
||||
// ErrNoEntity — Nexus resolved the name to nothing. Distinct from an outage,
|
||||
// which surfaces as the transport error: "there is no such person" and "Nexus
|
||||
// is down" must not read the same to a caller deciding whether to store an id.
|
||||
var ErrNoEntity = errors.New("ipc: no such entity")
|
||||
|
||||
// ErrTaskResolved — a resolved task is not editable.
|
||||
var ErrTaskResolved = errors.New("ipc: task is resolved")
|
||||
|
||||
// callerKey — context key for the authenticated caller. Server sets it from
|
||||
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
|
||||
// a missing Caller as "trusted same-process", the equivalent of the socket's
|
||||
|
||||
+27
-3
@@ -487,6 +487,14 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
|
||||
return r.Routines, nil
|
||||
}
|
||||
|
||||
func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||
var r SeedEventResp
|
||||
if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil {
|
||||
return SeedEventResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
var r CaptureTaskResp
|
||||
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
||||
@@ -507,6 +515,22 @@ func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts
|
||||
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
|
||||
var r resolveEntityResp
|
||||
if err := c.call(ctx, MethodResolveEntity, resolveEntityReq{Query: query, Types: types}, &r); err != nil {
|
||||
return EntityRef{}, err
|
||||
}
|
||||
return r.Ref, nil
|
||||
}
|
||||
|
||||
func (c *Client) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return c.call(ctx, MethodEditTask, editTaskReq{ID: id, Text: text, Due: due, Weight: weight}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil)
|
||||
}
|
||||
|
||||
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
|
||||
// means core has no email block configured — the caller should stop asking, not
|
||||
// retry.
|
||||
@@ -632,12 +656,12 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
|
||||
var r chatResp
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
|
||||
return "", err
|
||||
return ChatReply{}, err
|
||||
}
|
||||
return r.Reply, nil
|
||||
return ChatReply{Reply: r.Reply, Source: r.Source}, nil
|
||||
}
|
||||
|
||||
func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
|
||||
+25
-1
@@ -110,6 +110,13 @@ type RoutineAPI interface {
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// SeedEvent writes a backdated fact and runs extraction and detection over
|
||||
// it, so a proposal can be produced in one sitting rather than over real
|
||||
// days (Vikunja #518). See SeedEventReq for why this exists and what keeps
|
||||
// it shut. Daemon-computed, like MorningStatus — the store adapter refuses
|
||||
// it, because the detect-and-propose step lives in mavend.
|
||||
SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error)
|
||||
|
||||
// MorningStatus returns each configured morning routine's current
|
||||
// checklist state (see internal/morning): active today/now, which items
|
||||
// are done, which are still missing.
|
||||
@@ -135,6 +142,23 @@ type TaskAPI interface {
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||
// ResolveEntity asks Nexus for the canonical id behind a name. It is how a
|
||||
// surface turns "Kate" into an entity id before storing one, because
|
||||
// identity lives in Nexus and a local name is a second answer to a
|
||||
// question Nexus already owns. ErrNotImplemented when no Nexus is
|
||||
// configured, ErrNoEntity when the name matched nothing, and an Ambiguous
|
||||
// ref when it matched several — the caller asks, it does not pick.
|
||||
ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error)
|
||||
// EditTask rewrites the three fields capture set: text, due date and
|
||||
// weight. Status is not among them — that ladder is one-way and belongs to
|
||||
// SetTaskStatus. A resolved task is refused, and a text edit that would
|
||||
// duplicate another live task is refused rather than merged.
|
||||
EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error
|
||||
// SetTaskFields writes the definition of done and the blocker. Not a
|
||||
// status move, so it is not one-way: he may sharpen a criterion, and a
|
||||
// blocker clears when the person answers. blockedOn is a canonical Nexus
|
||||
// entity id or empty, never a name the caller had lying around.
|
||||
SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error
|
||||
}
|
||||
|
||||
// SystemAPI — what the daemon knows about itself, plus the one method that
|
||||
@@ -169,7 +193,7 @@ type SystemAPI interface {
|
||||
// conversation, so an unanswered question on one reach cannot eat the next
|
||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||
// tap and is still one conversation of its own, separate from the mic.
|
||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||
Chat(ctx context.Context, conversation, text string) (ChatReply, error)
|
||||
}
|
||||
|
||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||
|
||||
@@ -405,8 +405,17 @@ func TestChatViaClient(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if reply != "и тебе привет!" {
|
||||
t.Fatalf("Chat = %q, want %q", reply, "и тебе привет!")
|
||||
if reply.Reply != "и тебе привет!" {
|
||||
t.Fatalf("Chat = %q, want %q", reply.Reply, "и тебе привет!")
|
||||
}
|
||||
|
||||
// The claiming query source crosses the wire beside the reply (V-539).
|
||||
sourced, err := cli.Chat(context.Background(), "web", "почему небо голубое")
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if sourced.Source != "kiwix" {
|
||||
t.Fatalf("Chat source = %q, want kiwix", sourced.Source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,11 +426,11 @@ type chatTestAPI struct {
|
||||
UnimplementedCoreAPI
|
||||
}
|
||||
|
||||
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) {
|
||||
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (ChatReply, error) {
|
||||
if text == "привет" {
|
||||
return "и тебе привет!", nil
|
||||
return ChatReply{Reply: "и тебе привет!"}, nil
|
||||
}
|
||||
return "поговорили.", nil
|
||||
return ChatReply{Reply: "поговорили.", Source: "kiwix"}, nil
|
||||
}
|
||||
|
||||
// TestDispatch_UnknownMethod — an unknown method over the wire comes back as
|
||||
|
||||
@@ -31,6 +31,9 @@ var mapErrPairs = []struct {
|
||||
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
||||
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
|
||||
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
|
||||
}
|
||||
|
||||
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
||||
|
||||
+17
-1
@@ -524,6 +524,9 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return api.CaptureTask(ctx, p)
|
||||
}),
|
||||
MethodSeedEvent: withParams(func(ctx context.Context, api CoreAPI, p SeedEventReq) (SeedEventResp, error) {
|
||||
return api.SeedEvent(ctx, p)
|
||||
}),
|
||||
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
||||
out, err := api.ListTasks(ctx, p.Status)
|
||||
if err != nil {
|
||||
@@ -537,6 +540,19 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
||||
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
|
||||
}),
|
||||
MethodResolveEntity: withParams(func(ctx context.Context, api CoreAPI, p resolveEntityReq) (resolveEntityResp, error) {
|
||||
ref, err := api.ResolveEntity(ctx, p.Query, p.Types)
|
||||
if err != nil {
|
||||
return resolveEntityResp{}, err
|
||||
}
|
||||
return resolveEntityResp{Ref: ref}, nil
|
||||
}),
|
||||
MethodEditTask: withParamsVoid(func(ctx context.Context, api CoreAPI, p editTaskReq) error {
|
||||
return api.EditTask(ctx, p.ID, p.Text, p.Due, p.Weight)
|
||||
}),
|
||||
MethodSetTaskFields: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskFieldsReq) error {
|
||||
return api.SetTaskFields(ctx, p.ID, p.DoneWhen, p.BlockedOn)
|
||||
}),
|
||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||
out, err := api.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -562,7 +578,7 @@ var methodTable = map[Method]handlerFunc{
|
||||
}),
|
||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||
return chatResp{Reply: reply}, err
|
||||
return chatResp{Reply: reply.Reply, Source: reply.Source}, err
|
||||
}),
|
||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||
return api.TickTrace(ctx)
|
||||
|
||||
@@ -257,14 +257,22 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
return newID, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
return "", errors.New("store: chat not available via direct store API")
|
||||
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
|
||||
return ChatReply{}, errors.New("store: chat not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
||||
}
|
||||
|
||||
// SeedEvent — same shape as MorningStatus: writing the fact is a store call,
|
||||
// but extraction and detect-and-propose live in mavend, and a seed that wrote
|
||||
// the fact without running them would be the one thing this seam must not be,
|
||||
// a way to prove a detector that never ran (Vikunja #518).
|
||||
func (a *storeAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||
return SeedEventResp{}, errors.New("store: seed event not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||
return nil, errors.New("store: morning status not available via direct store API")
|
||||
}
|
||||
@@ -309,6 +317,8 @@ func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (Capture
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
Weight: req.Weight,
|
||||
DoneWhen: req.DoneWhen,
|
||||
BlockedOn: req.BlockedOn,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptureTaskResp{}, mapErr(err)
|
||||
@@ -335,6 +345,8 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
Weight: t.Weight,
|
||||
Resolved: t.ResolvedTs,
|
||||
ResolvedBy: t.ResolvedBy,
|
||||
DoneWhen: t.DoneWhen,
|
||||
BlockedOn: t.BlockedOn,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -344,6 +356,21 @@ func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, t
|
||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
||||
}
|
||||
|
||||
// ResolveEntity is not the store's to answer: identity lives in Nexus and this
|
||||
// adapter has no client. The daemon overrides it (cmd/mavend/tick_api.go), and
|
||||
// a deployment with no nexus block keeps this refusal.
|
||||
func (a *storeAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
|
||||
return EntityRef{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (a *storeAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return mapErr(a.s.EditTask(ctx, id, text, due, weight))
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return mapErr(a.s.SetTaskFields(ctx, id, doneWhen, blockedOn))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -450,6 +477,12 @@ func mapErr(err error) error {
|
||||
return ErrReminderState
|
||||
case errors.Is(err, store.ErrToolNotFound):
|
||||
return ErrToolNotFound
|
||||
case errors.Is(err, store.ErrTaskNoDoneWhen):
|
||||
return ErrTaskNoDoneWhen
|
||||
case errors.Is(err, store.ErrTaskDuplicate):
|
||||
return ErrTaskDuplicate
|
||||
case errors.Is(err, store.ErrTaskResolved):
|
||||
return ErrTaskResolved
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -105,12 +105,24 @@ func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return CaptureTaskResp{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||
return SeedEventResp{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
|
||||
return EntityRef{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
@@ -144,6 +156,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus,
|
||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
|
||||
return ChatReply{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ const (
|
||||
MethodCaptureTask Method = "capture_task"
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
MethodSetTaskFields Method = "set_task_fields"
|
||||
MethodEditTask Method = "edit_task"
|
||||
MethodResolveEntity Method = "resolve_entity"
|
||||
MethodIngestMail Method = "ingest_mail"
|
||||
MethodSwapModel Method = "swap_model"
|
||||
MethodModelStatus Method = "model_status"
|
||||
@@ -67,6 +70,7 @@ const (
|
||||
MethodListSpeakers Method = "list_speakers"
|
||||
MethodForgetSpeaker Method = "forget_speaker"
|
||||
MethodRecentEvents Method = "recent_events"
|
||||
MethodSeedEvent Method = "seed_event"
|
||||
|
||||
// MethodPing — liveness, and the only method that answers in locked mode
|
||||
// without a passkey assertion. It reaches no store, takes no arguments and
|
||||
|
||||
+118
-2
@@ -61,9 +61,10 @@ func mustLoad() lexiconFile {
|
||||
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
|
||||
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
|
||||
"filler_particles", "task_done_words", "task_drop_words",
|
||||
} {
|
||||
s, ok := f.Sets[name]
|
||||
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
||||
@@ -112,6 +113,47 @@ func PartsOfDay() []string { return words("parts_of_day") }
|
||||
// ReminderVerbs returns the imperatives that open a reminder.
|
||||
func ReminderVerbs() []string { return words("reminder_verbs") }
|
||||
|
||||
// TaskDoneWords returns the words that finish a task, and TaskDropWords the
|
||||
// words that abandon one. Two sets rather than one with a value, because the
|
||||
// store records which of the two happened and the caller has to say so.
|
||||
//
|
||||
// Both mix moods on purpose, and the caller must match them the way the sets'
|
||||
// notes say: an imperative exactly, a stative by lemma.
|
||||
func TaskDoneWords() []string { return words("task_done_words") }
|
||||
|
||||
// TaskDropWords — see TaskDoneWords.
|
||||
func TaskDropWords() []string { return words("task_drop_words") }
|
||||
|
||||
// IsFillerParticle reports whether a word can never be the subject of a
|
||||
// request: a particle, a politeness word, or the first-person object. See the
|
||||
// set's own note for why this is not a stopword list.
|
||||
func IsFillerParticle(word string) bool {
|
||||
w := norm(word)
|
||||
for _, p := range ru.Sets["filler_particles"].Words {
|
||||
if w == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HalfHourWords returns those forms, for a caller folding every time word into
|
||||
// one set rather than asking about one word.
|
||||
func HalfHourWords() []string { return words("half_hour") }
|
||||
|
||||
// IsHalfHour reports whether a word introduces a spoken half hour, so the
|
||||
// ordinal after it is an hour rather than a position. One caller reads that
|
||||
// ordinal as the hour and another has to decline it; both ask here.
|
||||
func IsHalfHour(word string) bool {
|
||||
w := norm(word)
|
||||
for _, h := range ru.Sets["half_hour"].Words {
|
||||
if w == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Cardinal reports the value of a spoken number word. The word is compared
|
||||
// lowercased and trimmed, because it arrives from a tokenizer that may not have
|
||||
// done either.
|
||||
@@ -120,6 +162,58 @@ func Cardinal(word string) (int, bool) {
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Ordinal reports the 1-based position a position word names, with -1 for the
|
||||
// last one. Same lookup shape as Cardinal, and the same reason: "второй" and
|
||||
// "вторым" are one position, and a caller matching stems would also match
|
||||
// "вторник".
|
||||
func Ordinal(word string) (int, bool) {
|
||||
n, ok := ru.Sets["ordinals"].Values[norm(word)]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Ordinals returns the position words with their positions, sorted, so a caller
|
||||
// that needs a form this set does not list can ask a morphological dictionary
|
||||
// whether one of these is the same word. Sorted because map order is not stable
|
||||
// and a caller folding these into a pattern would otherwise build a different one
|
||||
// every run.
|
||||
func Ordinals() []struct {
|
||||
Word string
|
||||
N int
|
||||
} {
|
||||
vals := ru.Sets["ordinals"].Values
|
||||
out := make([]struct {
|
||||
Word string
|
||||
N int
|
||||
}, 0, len(vals))
|
||||
for w, n := range vals {
|
||||
out = append(out, struct {
|
||||
Word string
|
||||
N int
|
||||
}{w, n})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Word < out[j].Word })
|
||||
return out
|
||||
}
|
||||
|
||||
// OrdinalIn reports the position word that comes FIRST in a sentence, so a
|
||||
// caller does not have to tokenize before asking. Word-boundary matched for the
|
||||
// reason above, and earliest-wins rather than first-found: map iteration order
|
||||
// would otherwise answer "отметь первый и второй" differently between runs.
|
||||
func OrdinalIn(text string) (int, bool) {
|
||||
lower := norm(text)
|
||||
best, at := 0, -1
|
||||
for w, n := range ru.Sets["ordinals"].Values {
|
||||
i := indexWord(lower, w)
|
||||
if i < 0 || (at >= 0 && i > at) {
|
||||
continue
|
||||
}
|
||||
// Two different words cannot match at one offset: both ends are
|
||||
// boundary-checked, so no key is a prefix of another as matched.
|
||||
best, at = n, i
|
||||
}
|
||||
return best, at >= 0
|
||||
}
|
||||
|
||||
// DayOffset reports how many days a relative day word moves from today.
|
||||
//
|
||||
// The zero value is a real answer here — "сегодня" is offset 0 — so the second
|
||||
@@ -192,6 +286,28 @@ func abs(n int) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// indexWord is containsWord returning where the match starts, or -1.
|
||||
func indexWord(haystack, needle string) int {
|
||||
if needle == "" {
|
||||
return -1
|
||||
}
|
||||
from := 0
|
||||
for {
|
||||
i := strings.Index(haystack[from:], needle)
|
||||
if i < 0 {
|
||||
return -1
|
||||
}
|
||||
i += from
|
||||
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
|
||||
return i
|
||||
}
|
||||
from = i + len(needle)
|
||||
if from >= len(haystack) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsWord reports whether haystack holds needle on word boundaries. Go's
|
||||
// \b is ASCII-only and never fires after a Cyrillic letter, so the boundary is
|
||||
// checked here instead: a rune on either side must not be a letter or a digit.
|
||||
|
||||
@@ -37,6 +37,24 @@
|
||||
"tell", "explain", "describe", "list"
|
||||
]
|
||||
},
|
||||
"ordinals": {
|
||||
"note": "Position words, as spoken, with the gender and oblique forms Russian requires: \"отметь второй пункт\" and \"закрепи вторым\" name one position (Vikunja #516). Values are the 1-based position, and -1 is the last one, which is a position rather than a count. The genitive forms here are also what a half-past hour needs (\"в половине восьмого\", V-538), so this set is written for two callers. It runs to twelve rather than ten because a clock does: \"в половине двенадцатого\" is 11:30.",
|
||||
"values": {
|
||||
"первый": 1, "первая": 1, "первое": 1, "первого": 1, "первую": 1, "первым": 1, "первой": 1, "first": 1,
|
||||
"второй": 2, "вторая": 2, "второе": 2, "второго": 2, "вторую": 2, "вторым": 2, "second": 2,
|
||||
"третий": 3, "третья": 3, "третье": 3, "третьего": 3, "третью": 3, "третьим": 3, "третьей": 3, "third": 3,
|
||||
"четвёртый": 4, "четвертый": 4, "четвёртая": 4, "четвертая": 4, "четвёртого": 4, "четвертого": 4, "четвёртую": 4, "четвертую": 4, "четвёртым": 4, "четвертым": 4, "fourth": 4,
|
||||
"пятый": 5, "пятая": 5, "пятое": 5, "пятого": 5, "пятую": 5, "пятым": 5, "пятой": 5, "fifth": 5,
|
||||
"шестой": 6, "шестая": 6, "шестое": 6, "шестого": 6, "шестую": 6, "шестым": 6, "sixth": 6,
|
||||
"седьмой": 7, "седьмая": 7, "седьмое": 7, "седьмого": 7, "седьмую": 7, "седьмым": 7, "seventh": 7,
|
||||
"восьмой": 8, "восьмая": 8, "восьмое": 8, "восьмого": 8, "восьмую": 8, "восьмым": 8, "eighth": 8,
|
||||
"девятый": 9, "девятая": 9, "девятое": 9, "девятого": 9, "девятую": 9, "девятым": 9, "ninth": 9,
|
||||
"десятый": 10, "десятая": 10, "десятое": 10, "десятого": 10, "десятую": 10, "десятым": 10, "tenth": 10,
|
||||
"одиннадцатый": 11, "одиннадцатая": 11, "одиннадцатое": 11, "одиннадцатого": 11, "одиннадцатую": 11, "одиннадцатым": 11, "eleventh": 11,
|
||||
"двенадцатый": 12, "двенадцатая": 12, "двенадцатое": 12, "двенадцатого": 12, "двенадцатую": 12, "двенадцатым": 12, "twelfth": 12,
|
||||
"последний": -1, "последняя": -1, "последнее": -1, "последнего": -1, "последнюю": -1, "последним": -1, "last": -1
|
||||
}
|
||||
},
|
||||
"cardinals": {
|
||||
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
|
||||
"values": {
|
||||
@@ -148,6 +166,37 @@
|
||||
"напомни", "напомните", "напомнить", "напоминай",
|
||||
"remind"
|
||||
]
|
||||
},
|
||||
"half_hour": {
|
||||
"note": "The forms of \"половина\" that introduce a spoken half hour: \"в половине восьмого\", \"к половине\", the bare \"пол\" of \"полвосьмого\". The set matters to two callers and for opposite reasons (V-522). The clock rewrite reads the ordinal after one of these as the hour being entered, and the ordinal-selection turn has to REFUSE that ordinal, because \"в половине восьмого\" names a time and not the eighth thing she read out.",
|
||||
"words": [
|
||||
"половина", "половине", "половину", "половины", "пол",
|
||||
"half"
|
||||
]
|
||||
},
|
||||
"filler_particles": {
|
||||
"note": "Words that carry no subject of their own: particles, the politeness words, and the first-person object he addresses her with. A caller asking \"did he say WHAT to remind him about\" has to discount these, or \"ну напомни же\" and \"напомни мне пожалуйста\" both read as a reminder whose subject is the particle. Closed in the sense that matters: these are function words, and the language is not adding any. Not a stopword list — a stopword list is a scoring convenience and may be as long as it likes, while every word here has to be one that cannot BE a reminder's subject.",
|
||||
"words": [
|
||||
"ну", "же", "уж", "там", "вот", "пожалуйста", "плиз", "ка",
|
||||
"давай", "давай-ка", "а", "и", "бы", "мне", "меня", "мной",
|
||||
"please", "just", "hey", "me"
|
||||
]
|
||||
},
|
||||
"task_done_words": {
|
||||
"note": "The ways he says a task is finished, split by mood the way the Praxis lifecycle words are (Vikunja #512). The imperatives are addressed to her and are matched exactly, because morph.SameWord makes \"закрой\" and \"закрыл\" one word and only one of them is an instruction. The statives report his own day and are matched by lemma, since \"сделано\", \"сделана\" and \"сделанную\" are one state. Closed because these are her vocabulary for one transition, not a discovery about Russian.",
|
||||
"words": [
|
||||
"закрой", "закройте", "закрыть", "заверши", "завершить", "close", "finish",
|
||||
"сделано", "сделал", "сделала", "выполнено", "выполнил", "выполнила",
|
||||
"готово", "готова", "закрыл", "закрыла", "done", "finished"
|
||||
]
|
||||
},
|
||||
"task_drop_words": {
|
||||
"note": "The ways he abandons a task rather than finishing it (Vikunja #512). Same two moods as task_done_words and the same matching rule. Separate from the done words because the store records which of the two happened and /tasks shows it: dropped work he chose to stop is not work he did.",
|
||||
"words": [
|
||||
"убери", "уберите", "убрать", "удали", "удалить", "отмени", "отменить",
|
||||
"drop", "remove", "cancel",
|
||||
"передумал", "передумала", "неактуально"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,3 +112,51 @@ func TestCallerCannotEditTheLexicon(t *testing.T) {
|
||||
t.Fatal("the lexicon handed out its own backing array")
|
||||
}
|
||||
}
|
||||
|
||||
// The positions carry gender and oblique forms, because "второй пункт" and
|
||||
// "закрепи вторым" name one position (Vikunja #516). "last" is a position and not
|
||||
// a count, so it is -1 rather than a large number.
|
||||
func TestOrdinalsSpanGenderAndCase(t *testing.T) {
|
||||
for _, w := range []string{"второй", "вторая", "второе", "вторым", "второго", "second"} {
|
||||
n, ok := Ordinal(w)
|
||||
if !ok || n != 2 {
|
||||
t.Errorf("Ordinal(%q) = %d, %v; want 2, true", w, n, ok)
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"последний", "последнюю", "last"} {
|
||||
if n, ok := Ordinal(w); !ok || n != -1 {
|
||||
t.Errorf("Ordinal(%q) = %d, %v; want -1, true", w, n, ok)
|
||||
}
|
||||
}
|
||||
// A weekday shares a stem with a position and is not one.
|
||||
if n, ok := Ordinal("вторник"); ok {
|
||||
t.Errorf("Ordinal(\"вторник\") = %d; a weekday is not a position", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Earliest wins, not map order: the same sentence must answer the same way twice.
|
||||
func TestOrdinalInTakesTheFirstPosition(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
n, ok := OrdinalIn("отметь первый и второй пункт")
|
||||
if !ok || n != 1 {
|
||||
t.Fatalf("run %d: OrdinalIn = %d, %v; want 1, true", i, n, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := OrdinalIn("отметь пункт"); ok {
|
||||
t.Error("a sentence with no position reported one")
|
||||
}
|
||||
}
|
||||
|
||||
// Ordinals is the escape hatch for the cases the file does not list, so it must
|
||||
// hand out every entry and hand out the same order twice.
|
||||
func TestOrdinalsListIsCompleteAndStable(t *testing.T) {
|
||||
a, b := Ordinals(), Ordinals()
|
||||
if len(a) != len(ru.Sets["ordinals"].Values) {
|
||||
t.Errorf("Ordinals returned %d of %d entries", len(a), len(ru.Sets["ordinals"].Values))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("Ordinals order is not stable at %d: %v vs %v", i, a[i], b[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -153,6 +154,11 @@ type body struct {
|
||||
type resp struct {
|
||||
Choices []struct {
|
||||
Message msg `json:"message"`
|
||||
// FinishReason — "length" means the token cap cut the generation off.
|
||||
// Worth a log line on every path (Vikunja #531): a grammar-constrained
|
||||
// generation that runs to the cap can still parse, so nothing above
|
||||
// this struct can tell a truncated answer from a whole one.
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
@@ -205,6 +211,9 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("llm: no choices")
|
||||
}
|
||||
if out.Choices[0].FinishReason == "length" {
|
||||
log.Printf("llm: generation hit the %d-token cap (finish_reason=length) — output truncated, or the model was looping", r.MaxTokens)
|
||||
}
|
||||
content := out.Choices[0].Message.Content
|
||||
if content == "" {
|
||||
content = out.Choices[0].Message.ReasoningContent
|
||||
|
||||
+10
-2
@@ -159,21 +159,28 @@ func (p *Pair) set(up bool) {
|
||||
// admission answer is a cache and can be one interval out of date, so an error
|
||||
// here is expected rather than exceptional.
|
||||
//
|
||||
// This is the silent half of the degradation rule. It must be indistinguishable
|
||||
// from today's behaviour when the workstation is down.
|
||||
// This is the silent half of the degradation rule: he is not told which model
|
||||
// phrased his reply. The log is told, one line per call, because the seam was
|
||||
// otherwise unreadable after the fact — the transition lines say the card was
|
||||
// free at 11:27, not which side answered the turn at 13:24. QA had no way to
|
||||
// tell an offloaded turn from a floor one.
|
||||
func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
|
||||
if p.floor == nil {
|
||||
return "", ErrNoFloor
|
||||
}
|
||||
why := "workstation down"
|
||||
if p.Available() {
|
||||
out, err := p.remote.Complete(ctx, r)
|
||||
if err == nil {
|
||||
log.Print("llm: served by the workstation model")
|
||||
return out, nil
|
||||
}
|
||||
// The cached answer was wrong. Correct it now rather than sending the
|
||||
// next request into the same hole, then fall back.
|
||||
p.set(false)
|
||||
why = "workstation failed mid-request"
|
||||
}
|
||||
log.Printf("llm: served by the resident model (%s)", why)
|
||||
return p.floor.Complete(ctx, r)
|
||||
}
|
||||
|
||||
@@ -189,5 +196,6 @@ func (p *Pair) CompleteRemote(ctx context.Context, r Req) (string, error) {
|
||||
p.set(false)
|
||||
return "", errors.Join(ErrRemoteUnavailable, err)
|
||||
}
|
||||
log.Print("llm: served by the workstation model, no floor for this caller")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// An ambient meeting suppresses a nudge for its own span and no longer
|
||||
// (Vikunja #513). The span is read back off the event fact, so there is no
|
||||
// expiry to configure and no way for it to outlive the meeting.
|
||||
func TestAmbientEventSuppressesNudgesForItsOwnSpan(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{
|
||||
Summary: "Встреча с Аней",
|
||||
Start: day.Add(14 * time.Hour),
|
||||
End: day.Add(15 * time.Hour),
|
||||
}
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.SourceAmbient, calendar.FactValue(ev), day); err != nil {
|
||||
t.Fatalf("SetValue: %v", err)
|
||||
}
|
||||
|
||||
g := NewGatherer(s, nil)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
now time.Time
|
||||
busy bool
|
||||
}{
|
||||
{"before it starts", day.Add(13*time.Hour + 59*time.Minute), false},
|
||||
{"at the first minute", day.Add(14 * time.Hour), true},
|
||||
{"in the middle", day.Add(14*time.Hour + 30*time.Minute), true},
|
||||
{"at the end instant", day.Add(15 * time.Hour), false},
|
||||
{"an hour after", day.Add(16 * time.Hour), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st, _, err := g.GatherState(ctx, tc.now)
|
||||
if err != nil {
|
||||
t.Fatalf("GatherState: %v", err)
|
||||
}
|
||||
if st.CalendarBusy != tc.busy {
|
||||
t.Fatalf("CalendarBusy = %v at %s, want %v", st.CalendarBusy, tc.now.Format("15:04"), tc.busy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A meeting on another day must not make today busy at the same clock reading.
|
||||
// The day comes from the key, which is what makes this hold.
|
||||
func TestAnEventOnAnotherDayDoesNotSuppress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy_day.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
yesterday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{Summary: "Standup", Start: yesterday.Add(14 * time.Hour), End: yesterday.Add(15 * time.Hour)}
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.SourceAmbient, calendar.FactValue(ev), yesterday); err != nil {
|
||||
t.Fatalf("SetValue: %v", err)
|
||||
}
|
||||
|
||||
today := time.Date(2026, 8, 5, 14, 30, 0, 0, time.Local)
|
||||
st, _, err := NewGatherer(s, nil).GatherState(ctx, today)
|
||||
if err != nil {
|
||||
t.Fatalf("GatherState: %v", err)
|
||||
}
|
||||
if st.CalendarBusy {
|
||||
t.Fatal("yesterday's meeting suppressed a nudge today")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactSpanReadsBackWhatFactValueWrote(t *testing.T) {
|
||||
day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(13*time.Hour + 45*time.Minute)}
|
||||
start, end, ok := calendar.FactSpan(calendar.FactKey(ev), calendar.FactValue(ev), time.Local)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan could not read its own encoding")
|
||||
}
|
||||
if !start.Equal(ev.Start) || !end.Equal(ev.End) {
|
||||
t.Fatalf("span = %s-%s, want %s-%s", start, end, ev.Start, ev.End)
|
||||
}
|
||||
}
|
||||
|
||||
// An end at or before the start is a meeting crossing midnight, not a zero-length
|
||||
// one. Reading it as zero-length would silently drop the suppression.
|
||||
func TestFactSpanCrossesMidnight(t *testing.T) {
|
||||
start, end, ok := calendar.FactSpan("calendar_event_20260805_Night", "Night @ 23:30-00:15", time.Local)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan rejected a midnight-crossing event")
|
||||
}
|
||||
if got := end.Sub(start); got != 45*time.Minute {
|
||||
t.Fatalf("span length = %s, want 45m", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A fact that does not parse says nothing about now. Guessing a span here is
|
||||
// how one suppressed nudge becomes all of them.
|
||||
func TestFactSpanRejectsWhatItCannotRead(t *testing.T) {
|
||||
for _, tc := range []struct{ key, value string }{
|
||||
{"other_key_20260805_x", "x @ 10:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x"},
|
||||
{"calendar_event_notadate_x", "x @ 10:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x @ 25:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x @ 10:00"},
|
||||
} {
|
||||
if _, _, ok := calendar.FactSpan(tc.key, tc.value, time.Local); ok {
|
||||
t.Errorf("FactSpan(%q, %q) parsed, want rejected", tc.key, tc.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestExplainGate_PresenceAway(t *testing.T) {
|
||||
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{Now: now, Presence: store.Away,
|
||||
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
|
||||
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute))},
|
||||
}
|
||||
r := ServiceDownRule() // Sev4 ops
|
||||
passed, blocked, d := ExplainGate(s, r)
|
||||
@@ -260,7 +260,7 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
|
||||
},
|
||||
}
|
||||
cand, trace := ExplainTick(s, DefaultRules())
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, 5*time.Minute),
|
||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -158,6 +159,19 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
if f, ok := readFact(ctx, g.store, "calendar_busy"); ok {
|
||||
calBusy = f.Value == "true" || f.Value == `"true"`
|
||||
}
|
||||
// An ambient meeting suppresses a nudge too (Vikunja #513). It writes
|
||||
// calendar_event_* and never calendar_busy, which is the CalDAV poller's
|
||||
// level, so before this a low-confidence meeting was good enough to recite
|
||||
// out loud and not good enough to stop a nudge during it. That is
|
||||
// backwards: being wrong here costs one nudge he did not get.
|
||||
//
|
||||
// The expiry is the event's own span, which is why there is no new level
|
||||
// and no interval to choose. A poller re-asserts a level every cycle and a
|
||||
// notification arrives once; an event that already ended covers nothing,
|
||||
// and one that has not started yet covers nothing either.
|
||||
if !calBusy {
|
||||
calBusy = g.eventCoversNow(ctx, now)
|
||||
}
|
||||
|
||||
// due reminders — gate-bypassing class. read here, the daemon emits them.
|
||||
due, err := g.store.DueReminders(ctx, now)
|
||||
@@ -212,6 +226,29 @@ func parseHHMM(s string) (hour, min int, ok bool) {
|
||||
return h, m, true
|
||||
}
|
||||
|
||||
// eventCoversNow reports whether any stored calendar event covers this instant.
|
||||
// Read from the event facts themselves, so it holds for exactly as long as the
|
||||
// meeting does — see the note at the call site.
|
||||
//
|
||||
// A read failure answers false: a meeting nobody can read about is not a reason
|
||||
// to go quiet.
|
||||
func (g *Gatherer) eventCoversNow(ctx context.Context, now time.Time) bool {
|
||||
fam, err := g.store.LatestFactsByPrefix(ctx, calendar.EventKeyPrefix)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, f := range fam {
|
||||
start, end, ok := calendar.FactSpan(f.Key, f.Value, now.Location())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !now.Before(start) && now.Before(end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) {
|
||||
f, err := s.LatestFact(ctx, key)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,7 +25,9 @@ func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
|
||||
"service_down:db": "down",
|
||||
"service_down:web": "up",
|
||||
} {
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-time.Minute)); err != nil {
|
||||
// Older than MinDownAge, so this tests the gather path and not the
|
||||
// flap debounce.
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-5*time.Minute)); err != nil {
|
||||
t.Fatalf("SetValue %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
|
||||
Presence: store.Away,
|
||||
QuietHours: true,
|
||||
Facts: map[string]store.Fact{
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
|
||||
},
|
||||
}
|
||||
got := Tick(s, DefaultRules())
|
||||
@@ -99,7 +99,7 @@ func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-5*time.Minute)),
|
||||
},
|
||||
}
|
||||
if got := Tick(s, DefaultRules()); got != nil {
|
||||
@@ -116,7 +116,7 @@ func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
|
||||
},
|
||||
}
|
||||
got := Tick(s, DefaultRules())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user