Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0793955896 | |||
| 71a9a59403 | |||
| d3c63e6493 | |||
| c586346a60 | |||
| 23d89b2831 | |||
| 06ddf41228 | |||
| 2e64c8ce94 | |||
| 7695620a96 | |||
| 758fb6a3f0 | |||
| 0e75245205 | |||
| 8d816f47e9 | |||
| 4425ba112b | |||
| a4d5155029 | |||
| c62c7034fa | |||
| 58b546a27e | |||
| 997f92f5c4 | |||
| d9ef9ecef2 | |||
| be3e5cea25 | |||
| 1da3aa39e8 | |||
| bee3ef80b4 | |||
| fc9538d07f | |||
| bb8bd608da | |||
| db0752223f | |||
| 97fc786acf | |||
| 54f13466c9 | |||
| 3bbffa4f37 | |||
| 8b76dc50d3 | |||
| a1324e679f | |||
| 82ef1b0110 | |||
| 897dcf847a | |||
| 8b9e8e9f4e | |||
| b338d9bb40 | |||
| 6878e12d37 | |||
| 8d46ee39e0 | |||
| 6eba79b332 | |||
| 3f60ec3994 | |||
| 14ea06712e | |||
| 42d3feadd2 | |||
| 2a2f706b74 | |||
| e082e06868 | |||
| 88dc4e1383 | |||
| 60759a991e | |||
| 9537346441 | |||
| c6be818f13 | |||
| b5575a9402 | |||
| fcda5e3d2c | |||
| b1420acb94 | |||
| 70fb7c030b | |||
| fe489dff6d | |||
| d8da529be0 | |||
| 0258a40b0d | |||
| f6a8752d00 | |||
| bdafc82e35 | |||
| c69023c310 | |||
| c5e993fc55 | |||
| a2081d8227 | |||
| 82bd160c0d | |||
| 1fe03f7a51 | |||
| d773f1f72b | |||
| 80ac7579fb | |||
| bb6cb6d185 | |||
| a9067a5754 | |||
| 787cc56522 | |||
| 86dcd99de2 | |||
| 554181ccbd | |||
| 58635f1a69 | |||
| fd3d063e02 | |||
| ed491e23fc | |||
| 246db4e609 | |||
| b6abb19090 | |||
| 1f7fd476ec | |||
| 08512ad58b | |||
| ff71d981ef | |||
| 4d83f8c785 | |||
| a439117995 | |||
| 2689715c2d | |||
| 05f47aef4b | |||
| 45a5e37963 | |||
| 6d8a95095a | |||
| 7e21cd06b3 | |||
| 09c648b934 | |||
| 4f516657da | |||
| 8a21478f36 | |||
| 958d2a2fc8 | |||
| 77206f298e | |||
| a1f811d4c8 | |||
| 7ab38cd7f7 | |||
| 6a85e71077 | |||
| bf6c2bf1a6 | |||
| 1558233665 | |||
| 5afa2dfb38 | |||
| 0f361d2034 | |||
| 19242ef73b | |||
| 26d6d71588 | |||
| df58710141 | |||
| 442d3ec08e | |||
| e481ad4930 | |||
| 3d8224fb04 |
@@ -32,7 +32,11 @@ See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for t
|
||||
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
|
||||
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
|
||||
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
|
||||
stays on homesrv permanently, because it backs that floor. Read `docs/offload.md` before
|
||||
stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small,
|
||||
quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:`
|
||||
prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced
|
||||
MiniLM and bought ten points of recall@1 and 2.5× the speed; see
|
||||
`docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before
|
||||
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||
are the work.
|
||||
|
||||
@@ -73,8 +77,8 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
||||
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
||||
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
||||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||||
| `mavwaked` | Wake-word / VAD gate. |
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
||||
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
|
||||
| `mavpoll` | Telegram long-poll reach. |
|
||||
| `mavcaldav` | CalDAV calendar sync. |
|
||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||
@@ -83,6 +87,15 @@ Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/ser
|
||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
||||
|
||||
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
||||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
||||
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
|
||||
daemon there listens to nobody. They belong on a client machine where he is standing.
|
||||
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
|
||||
needs building to allow it, but no such machine exists yet. **The consequence: the wake
|
||||
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
|
||||
sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers.
|
||||
|
||||
## The ecosystem: Nexus, Praxis, Hexis
|
||||
|
||||
Maven is one of four services. It owns conversation and personal memory. It does not
|
||||
@@ -189,6 +202,18 @@ fixture had said `query` since ru-query-019 was written. Measured: **full accura
|
||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||||
explicit `(\s|[?!.]|$)`.
|
||||
|
||||
Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside
|
||||
`AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar`
|
||||
(`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про
|
||||
X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model
|
||||
called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a
|
||||
latency and fixture defect, not a correctness one. The narrative rule reads the same
|
||||
`narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a
|
||||
joke, a bedtime story, herself — because the query chain has no source that answers those.
|
||||
New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) →
|
||||
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
|
||||
llama-server in that run), so judge it again before quoting a cascade number.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
@@ -198,6 +223,34 @@ All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_ll
|
||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||
workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
## Russian patterns — three mechanisms, no fourth
|
||||
|
||||
Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a
|
||||
pattern, and the resident model cannot be asked per turn either). A regex whose output is a
|
||||
fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an
|
||||
argv list — is not. Before writing a Russian word list, pick one of these:
|
||||
|
||||
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
||||
capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months,
|
||||
spoken hours. Editing a word is a data change, and there is exactly one copy: months used
|
||||
to live in three files. Cardinals carry the oblique forms, because a spoken time declines
|
||||
and `в семь` / `к семи` are one hour.
|
||||
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
|
||||
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
|
||||
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
||||
lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`).
|
||||
- **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a
|
||||
turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the
|
||||
turn's own query vector. Same shape as the personal boundary in `personalboundary.go`,
|
||||
with one difference: a topic must clear the runner-up by `topicMargin`, because a false
|
||||
claim here spends a network scan rather than one honest "не знаю". The old keyword tests
|
||||
stay as the offline floor and may remain narrow, since they are no longer the only answer.
|
||||
- **The ecosystem trio** — when the answer is not in the utterance at all. Identity is
|
||||
Nexus's, never a local pattern.
|
||||
|
||||
Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the
|
||||
`TestONNX*` tests, not eyeballed.
|
||||
|
||||
## Non-goals (hard constraints)
|
||||
|
||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
@@ -229,8 +282,11 @@ world questions, so she needs to read external sources. What replaces it:
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
||||
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
|
||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
|
||||
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
|
||||
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
|
||||
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
|
||||
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
|
||||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
|
||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
|
||||
all: build
|
||||
|
||||
@@ -74,7 +74,7 @@ run-web: build-web
|
||||
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
||||
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||
deps-go:
|
||||
deps-go: deps-sentinel
|
||||
@mkdir -p deps/go
|
||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
||||
@@ -84,6 +84,25 @@ deps-go:
|
||||
done
|
||||
$(GO) version
|
||||
|
||||
# deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454).
|
||||
# The vendored toolchain lives inside the module tree, so `go mod tidy` walked
|
||||
# Go's own compiler-error fixtures and died on files that are malformed on
|
||||
# purpose ("unicode//utf8": double slash). A nested module is not part of the
|
||||
# parent, so one three-line file ends the walk. deps/ is gitignored, so it is
|
||||
# generated here rather than committed, and every target that populates deps/
|
||||
# writes it.
|
||||
deps-sentinel:
|
||||
@mkdir -p deps
|
||||
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
|
||||
|
||||
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
|
||||
# go.mod, and a build target that edits the module file is a surprise.
|
||||
# vendor/ is committed, so a tidy that drops a requirement must be followed by
|
||||
# a re-vendor or the next build fails on "inconsistent vendoring".
|
||||
tidy: deps-sentinel
|
||||
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy
|
||||
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor
|
||||
|
||||
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
||||
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
||||
# Run `gofmt -w` on whatever this prints.
|
||||
@@ -119,6 +138,14 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt
|
||||
eval-router:
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
|
||||
|
||||
# eval-reach — score the held-out ecosystem reach fixture (internal/router/eval,
|
||||
# ru_ecosystem_v1.json). Answers "does a real Russian utterance actually arrive
|
||||
# at Praxis or Hexis", which routing accuracy alone does not say. Same
|
||||
# MAVEN_ONNX_LIB deal as eval-router; without it only the deterministic hash
|
||||
# ratchet runs. Vikunja #405.
|
||||
eval-reach:
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 -run 'Reach|Praxis' ./internal/router/eval/
|
||||
|
||||
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
|
||||
# Answers "can she find the note again when it matters": recall@1, recall@3,
|
||||
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
|
||||
@@ -178,7 +205,7 @@ run-tts: build-tts
|
||||
./mavttsd -socket /tmp/maven/tts.sock \
|
||||
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
||||
|
||||
deps: deps-whisper deps-piper
|
||||
deps: deps-sentinel deps-whisper deps-piper
|
||||
|
||||
deps-whisper:
|
||||
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||
@@ -197,7 +224,7 @@ deps-piper:
|
||||
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
||||
# a short question against a longer passage, which is what note recall is.
|
||||
# The quantized file is the one we download, deploy and measure — see
|
||||
# docs/evals/2026-07-31-recall.md.
|
||||
# docs/evals/2026-08-04-recall-e5-small.md for what the swap bought.
|
||||
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
||||
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
||||
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
||||
|
||||
@@ -58,6 +58,9 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r
|
||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||
// Conversational: build history from dialogue session (prior user turns)
|
||||
// and let the LLM respond from general knowledge + context.
|
||||
if h.phraser == nil {
|
||||
return "поговорили."
|
||||
}
|
||||
history := h.chatHistory()
|
||||
// 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.
|
||||
|
||||
@@ -39,6 +39,21 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
q.Slots.Value = ""
|
||||
return h.actionQuery(ctx, q)
|
||||
}
|
||||
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
||||
// "интернет не работает" were stored as `self` rows at confidence 1.00, and
|
||||
// recall reads a self row back later as if it were still true — the same
|
||||
// class of row that outranked live search in #470. The sentence describes a
|
||||
// moment, so she answers it and stores nothing. An explicit "запомни ..."
|
||||
// and anything about him are both left alone by the test.
|
||||
if router.IsTransientComplaint(dec.Utterance) {
|
||||
log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat",
|
||||
dec.Utterance, dec.Slots.Key)
|
||||
c := dec
|
||||
c.Intent = router.IntentChat
|
||||
c.Slots.Key, c.Slots.HasKey = "", false
|
||||
c.Slots.Value = ""
|
||||
return h.actionChat(ctx, c)
|
||||
}
|
||||
now := h.now()
|
||||
req := ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
@@ -74,11 +89,11 @@ 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.
|
||||
if h.memStore != nil {
|
||||
if h.recall.memStore != nil {
|
||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||
if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil {
|
||||
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||
log.Printf("voice: embed fact for memory: %v", err)
|
||||
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||
} else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "fact",
|
||||
"text": text,
|
||||
|
||||
@@ -27,7 +27,7 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
// embed the note text with the same model the classifier uses, persist
|
||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||
// facts — no predicate reads it (spec's two-memory split).
|
||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||
vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
@@ -40,8 +40,8 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
}
|
||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||
if h.memStore != nil {
|
||||
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
if h.recall.memStore != nil {
|
||||
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "note",
|
||||
"text": dec.Utterance,
|
||||
|
||||
+48
-13
@@ -81,6 +81,11 @@ var querySources = []querySource{
|
||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||
// date-bearing question still reaches the calendar.
|
||||
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
||||
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||||
// question about the operational state Praxis holds, and it used to fall
|
||||
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||
// an attention marker, and it falls through when Praxis is not configured.
|
||||
{name: "attention", answer: (*reactiveHandler).queryAttention},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
@@ -91,6 +96,12 @@ var querySources = []querySource{
|
||||
// marker, so "надо бы съездить в магазин" is untouched.
|
||||
{name: "list", answer: (*reactiveHandler).queryList},
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||
// the facts he tapped in, and the notes pass would answer it with whatever
|
||||
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||||
// history phrase and bails out when he names a topic, so "что я говорил
|
||||
// про сервер" is still recall.
|
||||
{name: "history", answer: (*reactiveHandler).queryHistory},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||
@@ -145,6 +156,13 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
continue
|
||||
}
|
||||
if reply, ok := src.answer(h, ctx, t); ok {
|
||||
// Which source claimed is the one thing about a query turn that was
|
||||
// invisible from outside: /trace is the nudge-rule trace and carries
|
||||
// no query-source field, so a wrong answer could not be told from a
|
||||
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
|
||||
// the utterance and the answer are already on the voice lines above
|
||||
// and below this one.
|
||||
log.Printf("voice: query claimed by source %q", src.name)
|
||||
return reply
|
||||
}
|
||||
}
|
||||
@@ -283,9 +301,17 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
return "", false
|
||||
}
|
||||
if !h.feedsOn {
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
// Claim only when nothing below can read the world. The reason this
|
||||
// source used to claim unconditionally was that general knowledge would
|
||||
// answer "что нового?" with an invented news bulletin — true, and it
|
||||
// stopped being the only alternative on 2026-08-02, when live search
|
||||
// took the lead. With SearXNG or the ZIMs configured, "что происходит
|
||||
// в новостях про искусственный интеллект?" has a real answer below,
|
||||
// and a configuration status is the wrong thing to say instead
|
||||
// (Vikunja #474).
|
||||
if h.search != nil || h.kiwix != nil {
|
||||
return "", false
|
||||
}
|
||||
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
||||
}
|
||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||
@@ -322,6 +348,15 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||
// source can be tested at a fixed time like the rest.
|
||||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
// A day word is all this source matches on, so any question that merely
|
||||
// names a day reached it first. "какая сегодня погода в Москве?" answered
|
||||
// "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day
|
||||
// far more often than the calendar is, and the weather source sits right
|
||||
// below, so the calendar steps aside on weather wording — the same bail-out
|
||||
// queryHome already does for the same reason.
|
||||
if isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||
if !ok {
|
||||
return "", false
|
||||
@@ -346,7 +381,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
// calls States and nothing else, so there is no confirm turn here — the only
|
||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isHomeQuery(t.dec.Utterance) {
|
||||
if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) {
|
||||
return "", false
|
||||
}
|
||||
if h.home == nil {
|
||||
@@ -368,7 +403,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
// because Scan takes no target — the utterance selects the question, never the
|
||||
// subnet.
|
||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isNetworkQuery(t.dec.Utterance) {
|
||||
if !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) {
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
@@ -383,7 +418,7 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isWeatherQuery(t.dec.Utterance) {
|
||||
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||
return "", false
|
||||
}
|
||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||
@@ -419,7 +454,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
// sources below both need, run once, in the position it always ran in. It
|
||||
// only claims the turn when the embedder fails.
|
||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
@@ -439,15 +474,15 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
||||
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
||||
// which memory gets to answer them.
|
||||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if h.memStore == nil {
|
||||
if h.recall.memStore == nil {
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
|
||||
if herr != nil {
|
||||
log.Printf("voice: memory search: %v", herr)
|
||||
return "", false
|
||||
}
|
||||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
||||
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
@@ -498,7 +533,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
for i, n := range notes {
|
||||
noteScores[i] = n.Score
|
||||
}
|
||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
||||
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||
return "", false
|
||||
}
|
||||
// Same topic veto as queryMemory above: the best note must be about what
|
||||
@@ -784,8 +819,8 @@ func isPersonalQuery(utterance string) bool {
|
||||
// computed. Same shape as the cascade: the better test leads, the offline one
|
||||
// always answers.
|
||||
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
|
||||
h.boundary.load(ctx, h.embedder)
|
||||
if personal, world, ok := h.boundary.score(t.vec); ok {
|
||||
h.recall.boundary.load(ctx, h.recall.embedder)
|
||||
if personal, world, ok := h.recall.boundary.score(t.vec); ok {
|
||||
if personal > world {
|
||||
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
|
||||
return true
|
||||
|
||||
@@ -25,7 +25,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||
}
|
||||
}
|
||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||
// The body is what she says at the hour, so the marker and the time come
|
||||
// out of it: the fire time is already a column, and "напомни" is an
|
||||
// instruction that has been carried out (Vikunja #469).
|
||||
payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}`
|
||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
@@ -71,7 +72,16 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: list tasks: %v", err)
|
||||
return "не получилось посмотреть задачи.", true
|
||||
}
|
||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
||||
ranked := tasks.Rank(taskItems(live), h.now())
|
||||
// Bind what she is about to say, in the order she says it, so "второй"
|
||||
// means the second task he heard (ordinal.go).
|
||||
spoken := tasks.Spoken(ranked)
|
||||
cands := make([]dialogue.Candidate, 0, len(spoken))
|
||||
for _, r := range spoken {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||
}
|
||||
h.offerCandidates(cands)
|
||||
return tasks.FormatRU(ranked), true
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// attentionMarkers — the offline floor under the attention topic (topics.go).
|
||||
// The seeds decide when the embedder is there; this answers when it is not, and
|
||||
// it stays a substring list on purpose for the reason the other floors do: a
|
||||
// narrow test made blind beats a broad guess made blind.
|
||||
//
|
||||
// "что нового" is deliberately absent: the feeds source claims it, and it
|
||||
// still should — a question about news is a question about the feeds she
|
||||
// reads. This list is about the operational state of his things.
|
||||
var attentionMarkers = []string{
|
||||
"внимани", "что требует", "что не так", "что важн", "что срочн",
|
||||
"needs attention", "what needs looking",
|
||||
}
|
||||
|
||||
// isAttentionQuery reports whether the utterance asks what needs looking at.
|
||||
// Called through turnIsAbout, never directly.
|
||||
func isAttentionQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, m := range attentionMarkers {
|
||||
if strings.Contains(s, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queryAttention answers "что требует внимания?" from Praxis.
|
||||
//
|
||||
// The capability was already built and already degraded correctly, and no
|
||||
// utterance could reach it (Vikunja #475). Its aliases live on the act
|
||||
// dispatch, and the question routes to IntentQuery, so it fell through every
|
||||
// source to the web search and came back with an encyclopedia article about
|
||||
// the concept of attention — worse than silence, because it reads as an
|
||||
// answer.
|
||||
//
|
||||
// Placed above the recall sources and well above the personal boundary: this
|
||||
// is operational state about his things, and a notes pass would otherwise
|
||||
// answer it from whatever he once wrote about a server. An unconfigured or
|
||||
// absent Praxis falls through rather than claiming the turn, the same
|
||||
// convention queryHome and queryNetwork follow. A Praxis that is configured
|
||||
// and down does claim it, and says it cannot reach the service — that is the
|
||||
// degradation the ecosystem contract asks for, and it comes from the same
|
||||
// handler the act path uses.
|
||||
func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicAttend, isAttentionQuery) {
|
||||
return "", false
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||
return "", false
|
||||
}
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Utterance: t.dec.Utterance,
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "list_attention", HasFn: true},
|
||||
})
|
||||
if reply == "" {
|
||||
return "", false
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestIsAttentionQuery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"что требует внимания", true},
|
||||
{"на что обратить внимание?", true},
|
||||
{"что не так?", true},
|
||||
{"что важного?", true},
|
||||
// The feeds source owns this one, and should keep owning it.
|
||||
{"что нового?", false},
|
||||
{"какая погода?", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := isAttentionQuery(tc.text); got != tc.want {
|
||||
t.Errorf("isAttentionQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttentionQuestionReachesPraxis — the defect (Vikunja #475). The question
|
||||
// routes to IntentQuery, and every source used to pass, so a web search about
|
||||
// the concept of attention answered it.
|
||||
func TestAttentionQuestionReachesPraxis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||
}})
|
||||
if !ok {
|
||||
t.Fatal("the attention question must be claimed before the world sources")
|
||||
}
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("reply = %q, want the praxis item", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A configured Praxis that is down claims the turn and says so. Falling
|
||||
// through here would answer an outage with an encyclopedia article.
|
||||
func TestAttentionQuestionSaysWhenPraxisIsDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
praxis.SetFault(503)
|
||||
|
||||
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||
}})
|
||||
if !ok || !strings.Contains(reply, "не могу") {
|
||||
t.Fatalf("an outage must name the gap, got ok=%v reply=%q", ok, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// No Praxis configured means no claim: the rest of the chain still runs.
|
||||
func TestAttentionQuestionFallsThroughWithoutPraxis(t *testing.T) {
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
if _, ok := h.queryAttention(context.Background(), &queryTurn{dec: router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||
}}); ok {
|
||||
t.Fatal("an unconfigured praxis must not claim the turn")
|
||||
}
|
||||
}
|
||||
+9
-16
@@ -33,19 +33,10 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
router.IntentAct: {dialogue.SlotFn},
|
||||
}
|
||||
|
||||
// clarifyQuestions — one short question per missing slot.
|
||||
//
|
||||
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
||||
// would wander, and a question whose wording changes every time is harder to
|
||||
// answer than a blunt one that always reads the same. They are infinitive
|
||||
// questions, so there is no gender agreement to get wrong; the feminine
|
||||
// self-reference lives in the reply she gives when she drops the request.
|
||||
var clarifyQuestions = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "Когда?",
|
||||
dialogue.SlotText: "О чём напомнить?",
|
||||
dialogue.SlotKey: "Что записать?",
|
||||
dialogue.SlotFn: "Что сделать?",
|
||||
}
|
||||
// The questions themselves live in clarifytemplates.go, one list per slot,
|
||||
// picked by attempt (Vikunja #457). The first ask is the short one this map
|
||||
// used to hold; a re-ask says it differently, because a question he already
|
||||
// failed to answer is the worst one to repeat unchanged.
|
||||
|
||||
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
||||
// says so out loud: dropping the request in silence would leave him thinking it
|
||||
@@ -147,7 +138,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
if len(missing) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
q, ok := clarifyQuestions[missing[0]]
|
||||
q, ok := clarifyQuestionFor(missing[0], 1)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
@@ -266,7 +257,9 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
question, ok := clarifyQuestions[remaining[0]]
|
||||
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||
// with the re-ask path, so the second gap is worded like a second try.
|
||||
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
}
|
||||
@@ -290,7 +283,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question = clarifyQuestions[q.Missing[0]]
|
||||
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
|
||||
@@ -156,8 +156,14 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||
}
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
||||
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||
// question he already failed to answer is the worst way to ask it.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||||
if reply != want {
|
||||
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||
}
|
||||
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||||
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
t.Fatalf("attempt %d must leave the question armed", i)
|
||||
@@ -299,7 +305,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
h, _, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.embedder = emb
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
@@ -349,8 +355,11 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed as an answer")
|
||||
}
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
||||
// Second gap, second attempt, so it is the second wording of the time
|
||||
// question — the attempt budget is shared between the two paths.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||||
if reply != want {
|
||||
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
@@ -411,8 +420,9 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||
for _, q := range clarifyQuestions {
|
||||
lines = append(lines, q)
|
||||
lines = append(lines, clarifyMissedVariants...)
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
lines = append(lines, variants...)
|
||||
}
|
||||
for _, line := range lines {
|
||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||
@@ -497,8 +507,12 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
||||
}
|
||||
if !strings.Contains(reminders[0].Payload, "11") {
|
||||
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
||||
// The hour is the fire time, not a word in the body: the body is what she
|
||||
// says at the hour, and the time expression is stripped out of it
|
||||
// (Vikunja #469). Clobbering the parked request would show up here as a
|
||||
// reminder that fires at some other time than the one he asked for.
|
||||
if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
|
||||
t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The clarify copy deck (Vikunja #457).
|
||||
//
|
||||
// Every clarify turn used to say one sentence per gap, and a re-ask repeated
|
||||
// that sentence word for word. A question he already failed to answer is the
|
||||
// worst one to ask again unchanged: the second wording is the one that tells
|
||||
// him which part she missed.
|
||||
//
|
||||
// Fixed templates, not model output, for the reason clarifyQuestions has always
|
||||
// given: the resident model would wander, and a question whose wording changes
|
||||
// at random is harder to answer than a blunt one. What changes here is that the
|
||||
// wording varies with the attempt rather than with a die roll — the first ask is
|
||||
// short, the second names the gap, the third spells it out.
|
||||
//
|
||||
// No schema_version, unlike internal/phraser/nudge_templates.go. These are Go
|
||||
// constants compiled into the daemon, so there is no file that can drift out of
|
||||
// step with the code that reads it.
|
||||
//
|
||||
// Persona holds: infinitive and imperative questions, so there is no gender
|
||||
// agreement to get wrong, "ты" throughout, and no pet names.
|
||||
var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||
dialogue.SlotTime: {
|
||||
"Когда?",
|
||||
"Во сколько напомнить?",
|
||||
"Скажи время — например, «в семь вечера» или «через час».",
|
||||
},
|
||||
dialogue.SlotText: {
|
||||
"О чём напомнить?",
|
||||
"Что сказать тебе в это время?",
|
||||
"Скажи одной фразой, о чём напомнить.",
|
||||
},
|
||||
dialogue.SlotKey: {
|
||||
"Что записать?",
|
||||
"Что именно отметить?",
|
||||
"Назови, что записать — например, «выпил воды».",
|
||||
},
|
||||
dialogue.SlotFn: {
|
||||
"Что сделать?",
|
||||
"Какое действие выполнить?",
|
||||
"Назови действие — я умею только то, что ты мне разрешил.",
|
||||
},
|
||||
}
|
||||
|
||||
// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as
|
||||
// PendingQuestion.Attempts counts it; anything past the list uses the last and
|
||||
// most explicit phrasing rather than wrapping round to the short one, because
|
||||
// wrapping would ask the same short question he has already not answered.
|
||||
//
|
||||
// Deterministic on purpose, unlike clarifyExpiredLine: an expiry notice is the
|
||||
// same statement however it is worded, and a re-ask is not.
|
||||
func clarifyQuestionFor(slot dialogue.Slot, attempt int) (string, bool) {
|
||||
variants, ok := clarifyQuestionVariants[slot]
|
||||
if !ok || len(variants) == 0 {
|
||||
return "", false
|
||||
}
|
||||
i := attempt - 1
|
||||
if i < 0 {
|
||||
i = 0
|
||||
}
|
||||
if i >= len(variants) {
|
||||
i = len(variants) - 1
|
||||
}
|
||||
return variants[i], true
|
||||
}
|
||||
|
||||
// clarifyMissedVariants — she is asking for the whole utterance again, because
|
||||
// the gate fired on an intent with nothing identifiable to ask about (note,
|
||||
// query, chat, system are not in wantedSlots).
|
||||
//
|
||||
// Rotated like the expiry lines and for the same reason: this is the line he
|
||||
// hears whenever she misses him completely, so it is a line that repeats, and
|
||||
// the same sentence every time is what makes a house assistant sound like a
|
||||
// kiosk. All of them say the same two things — she did not catch it, and he
|
||||
// should say it again — because the wording may vary and the meaning may not.
|
||||
var clarifyMissedVariants = []string{
|
||||
"Не совсем поняла — скажи, пожалуйста, ещё раз.",
|
||||
"Я тебя не разобрала. Повтори, пожалуйста.",
|
||||
"Не уловила. Скажи это по-другому?",
|
||||
"Прости, не поняла — попробуй сказать иначе.",
|
||||
}
|
||||
|
||||
// clarifyMissedFor picks a wording by the utterance itself, so the same words
|
||||
// asked twice get the same answer and two different misses sound different.
|
||||
//
|
||||
// A hash, not rand: a test that drives an utterance twice must not depend on a
|
||||
// die roll, and the point of rotating is only that consecutive misses differ.
|
||||
func clarifyMissedFor(utterance string) string {
|
||||
var sum int
|
||||
for _, r := range utterance {
|
||||
sum += int(r)
|
||||
}
|
||||
return clarifyMissedVariants[sum%len(clarifyMissedVariants)]
|
||||
}
|
||||
|
||||
// clarifyMissedLine is the canned reply for a clarify decision she cannot turn
|
||||
// into a question. Returns "" for a decision that is not a clarify, so the
|
||||
// caller keeps its own reply.
|
||||
func clarifyMissedLine(dec router.Decision) string {
|
||||
if !dec.Clarify {
|
||||
return ""
|
||||
}
|
||||
return clarifyMissedFor(dec.Utterance)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Every slot she can ask about has a wording for every attempt she is allowed,
|
||||
// and no two attempts on one slot read the same. A deck with a repeated line is
|
||||
// the defect this deck exists to fix (Vikunja #457).
|
||||
func TestClarifyQuestionsVaryByAttempt(t *testing.T) {
|
||||
for slot, variants := range clarifyQuestionVariants {
|
||||
seen := map[string]bool{}
|
||||
for _, v := range variants {
|
||||
if v == "" {
|
||||
t.Errorf("%s: empty wording in the deck", slot)
|
||||
}
|
||||
if seen[v] {
|
||||
t.Errorf("%s: repeated wording %q", slot, v)
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
for attempt := 1; attempt <= len(variants); attempt++ {
|
||||
got, ok := clarifyQuestionFor(slot, attempt)
|
||||
if !ok || got != variants[attempt-1] {
|
||||
t.Errorf("%s attempt %d = %q ok=%v, want %q", slot, attempt, got, ok, variants[attempt-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Past the end she keeps the most explicit wording. Wrapping round would ask
|
||||
// the short question he has already not answered twice.
|
||||
func TestClarifyQuestionPastTheEndKeepsTheLastWording(t *testing.T) {
|
||||
last := clarifyQuestionVariants[dialogue.SlotTime][len(clarifyQuestionVariants[dialogue.SlotTime])-1]
|
||||
for _, attempt := range []int{0, 4, 9} {
|
||||
if got, _ := clarifyQuestionFor(dialogue.SlotTime, attempt); attempt > 1 && got != last {
|
||||
t.Errorf("attempt %d = %q, want the last wording %q", attempt, got, last)
|
||||
}
|
||||
}
|
||||
if _, ok := clarifyQuestionFor("nonesuch", 1); ok {
|
||||
t.Error("an unknown slot must have no question")
|
||||
}
|
||||
}
|
||||
|
||||
// The missed line is stable for one utterance and absent for a decision that is
|
||||
// not a clarify.
|
||||
func TestClarifyMissedLine(t *testing.T) {
|
||||
d := router.Decision{Clarify: true, Utterance: "мгм"}
|
||||
first := clarifyMissedLine(d)
|
||||
if first == "" || first != clarifyMissedLine(d) {
|
||||
t.Fatalf("the missed line must be stable for one utterance, got %q", first)
|
||||
}
|
||||
if got := clarifyMissedLine(router.Decision{Intent: router.IntentNote}); got != "" {
|
||||
t.Errorf("a decision that is not a clarify got %q", got)
|
||||
}
|
||||
// The empty utterance still gets a line: she has to say something.
|
||||
if got := clarifyMissedLine(router.Decision{Clarify: true}); got == "" {
|
||||
t.Error("an empty utterance must still be answered out loud")
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,10 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: rtr,
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
return h, api
|
||||
@@ -44,7 +43,7 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
||||
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
||||
t.Fatal("a question was stored as a fact about him")
|
||||
}
|
||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
@@ -81,7 +80,7 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
||||
// #493: what recall reads back is the fact, not the sentence he said.
|
||||
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
||||
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
@@ -117,9 +116,54 @@ func TestFactConfidence(t *testing.T) {
|
||||
|
||||
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||
t.Helper()
|
||||
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
|
||||
vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", text, err)
|
||||
}
|
||||
return vec
|
||||
}
|
||||
|
||||
// The write half of #481: a complaint about a thing is a state of the
|
||||
// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00
|
||||
// it comes back on recall as if the network were still down.
|
||||
func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
reply := h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "сеть какая-то медленная",
|
||||
Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"},
|
||||
})
|
||||
|
||||
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
|
||||
t.Fatal("a passing complaint was stored as a fact about him")
|
||||
}
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("the complaint was indexed for recall: %+v", hits)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("the turn was neither stored nor answered")
|
||||
}
|
||||
}
|
||||
|
||||
// And the complaint he asked her to keep: the capture verb wins, as it does
|
||||
// over the question gate.
|
||||
func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запомни что интернет не работает",
|
||||
Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"},
|
||||
})
|
||||
|
||||
if _, err := api.LatestFact(ctx, "internet"); err != nil {
|
||||
t.Fatalf("an explicit capture was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHa
|
||||
}
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
embedder: nil,
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
recall: recallWiring{embedder: nil},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,3 +200,22 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
||||
t.Fatal("the poller fetched a private address")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The
|
||||
// deployed box has no feeds block and does have SearXNG, and "что происходит
|
||||
// сейчас в новостях про искусственный интеллект?" got a configuration status
|
||||
// instead of the live answer sitting one source below.
|
||||
func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) {
|
||||
h := buildFeedHandler(t, false)
|
||||
h.search = &searchWiring{max: 3, runes: 1500}
|
||||
|
||||
if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok {
|
||||
t.Fatalf("feeds off with a search configured must fall through, got %q", reply)
|
||||
}
|
||||
// With nothing below that reads the world, the honest status is still said:
|
||||
// general knowledge would otherwise answer with an invented bulletin.
|
||||
h.search = nil
|
||||
if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") {
|
||||
t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
|
||||
// (Vikunja #456).
|
||||
//
|
||||
// Read-only over the facts that already exist. No new mechanism and no new
|
||||
// storage: everything he tapped in is already a row with a source and a
|
||||
// timestamp, and this only reads them back.
|
||||
|
||||
// A history question needs three things in one utterance: the interrogative,
|
||||
// whose turn is being asked about, and a verb of saying or recording. Any two of
|
||||
// them are a different question. "что я говорил про сервер" names a topic and
|
||||
// the notes pass answers it better; "записал молоко" is a capture.
|
||||
//
|
||||
// The verbs are matched by lemma through internal/morph, not by a truncated
|
||||
// prefix (Vikunja #530). The pairs here used to hold "рассказ" and "записал",
|
||||
// which is the defect V-528 fixed in complaint.go: "рассказ" is also the noun,
|
||||
// so "что я рассказал ей" and "что я читал рассказ" were the same string test.
|
||||
// Aspect pairs are separate lemmas in the dictionary, so both members are listed.
|
||||
var (
|
||||
// historySpokenVerbs — what HE did. "что я тебе говорил".
|
||||
historySpokenVerbs = []string{"говорить", "сказать", "рассказать", "рассказывать", "отметить", "отмечать"}
|
||||
|
||||
// historyRecordedVerbs — what SHE did with it. "что ты записала сегодня".
|
||||
historyRecordedVerbs = []string{"записать", "запомнить", "отметить", "отмечать"}
|
||||
|
||||
// firstPersonSubjects and secondPersonSubjects — whose turn the question is
|
||||
// about. Only the subject forms: "что я тебе говорил" is his turn, and the
|
||||
// dative "тебе" in it is not the subject.
|
||||
firstPersonSubjects = []string{"я"}
|
||||
secondPersonSubjects = []string{"ты"}
|
||||
)
|
||||
|
||||
// historyMarkersEn — the English pairs, kept as substrings because the
|
||||
// dictionary is Russian. Each half alone is a different question, the same way
|
||||
// the Russian test needs all three parts.
|
||||
var historyMarkersEn = [][2]string{
|
||||
{"what did i", "tell"},
|
||||
{"what did you", "record"},
|
||||
}
|
||||
|
||||
// historyRecall — the word that turns a history question into a recall
|
||||
// question. "что я говорил про сервер" names a topic, and the notes pass
|
||||
// answers a topic far better than a list of the last five facts does.
|
||||
var historyRecall = []string{" про ", " об ", " о ", " about "}
|
||||
|
||||
// isHistoryQuery reports whether he is asking what he told her.
|
||||
func isHistoryQuery(u string) bool {
|
||||
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
|
||||
if s == " " {
|
||||
return false
|
||||
}
|
||||
for _, r := range historyRecall {
|
||||
if strings.Contains(s, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, pair := range historyMarkersEn {
|
||||
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
toks := historyTokens(s)
|
||||
if !hasAny(toks, "что", "чего") {
|
||||
return false
|
||||
}
|
||||
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
|
||||
return true
|
||||
}
|
||||
return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs)
|
||||
}
|
||||
|
||||
// historyTokens splits an utterance into bare words. The punctuation goes
|
||||
// because "говорил?" is the same word as "говорил".
|
||||
func historyTokens(s string) []string {
|
||||
toks := strings.Fields(s)
|
||||
out := make([]string, 0, len(toks))
|
||||
for _, t := range toks {
|
||||
if t = strings.Trim(t, ".,!?;:—–-()\"'«»"); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasAny(toks []string, want ...string) bool {
|
||||
for _, t := range toks {
|
||||
for _, w := range want {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasVerbForm reports whether any token is a form of any of the lemmas. Both
|
||||
// sides go through the dictionary, so a caller may name the infinitive and he
|
||||
// may say the past tense.
|
||||
func hasVerbForm(toks []string, lemmas []string) bool {
|
||||
for _, t := range toks {
|
||||
for _, l := range lemmas {
|
||||
if morph.SameWord(t, l) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// historyScan — how many recent facts are read before filtering. Deliberately
|
||||
// larger than historyReadOut: a poller writing every few minutes would
|
||||
// otherwise push everything he said out of the window, the same way his own
|
||||
// notes used to crowd out the feed headlines.
|
||||
const historyScan = 100
|
||||
|
||||
// historyReadOut — how many she says out loud. Five is what fits in one spoken
|
||||
// breath; the rest are on /history, which is the surface for reading a list.
|
||||
const historyReadOut = 5
|
||||
|
||||
// historyWindow — how far back "recently" reaches. A day, because the question
|
||||
// is about this conversation and not about the archive.
|
||||
const historyWindow = 24 * time.Hour
|
||||
|
||||
// queryHistory answers what he told her, from the facts he tapped in.
|
||||
//
|
||||
// Only "tap:" sources. A fact written by a poller, an inference or the ambient
|
||||
// relay is a thing she learned, not a thing he said, and reading those back
|
||||
// under "что я тебе говорил?" would put words in his mouth.
|
||||
//
|
||||
// Placed with the other sources that read his own rows and above the recall
|
||||
// pass: the notes pass would otherwise answer this from whatever note happens
|
||||
// to be nearest, which reads as an answer and is not one.
|
||||
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isHistoryQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, historyScan)
|
||||
if err != nil {
|
||||
log.Printf("voice: history: recent facts: %v", err)
|
||||
return "не получилось посмотреть, что ты говорил.", true
|
||||
}
|
||||
cutoff := h.now().Add(-historyWindow)
|
||||
var said []string
|
||||
for _, f := range facts {
|
||||
if !strings.HasPrefix(f.Source, "tap:") || f.Ts.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
said = append(said, historyLine(f.Key, f.Value, f.Ts))
|
||||
if len(said) == historyReadOut {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(said) == 0 {
|
||||
// Claim the turn rather than fall through. "ничего не говорил" is the
|
||||
// true answer, and recall would answer it with an old note instead.
|
||||
return "за последние сутки ты мне ничего такого не говорил.", true
|
||||
}
|
||||
return "ты говорил: " + strings.Join(said, "; "), true
|
||||
}
|
||||
|
||||
// historyLine — one fact as she says it. The hour and minute, because the day
|
||||
// is already bounded by historyWindow and a date would be noise.
|
||||
func historyLine(key, value string, ts time.Time) string {
|
||||
what := key
|
||||
if value != "" {
|
||||
what = key + " — " + value
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", what, ts.Local().Format("15:04"))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// historyAPI serves a fixed set of recent facts.
|
||||
type historyAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts []ipc.Fact
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) {
|
||||
a.calls++
|
||||
return a.facts, nil
|
||||
}
|
||||
|
||||
func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) {
|
||||
api := &historyAPI{facts: facts}
|
||||
return &reactiveHandler{api: api, now: func() time.Time { return now }}, api
|
||||
}
|
||||
|
||||
func askHistory(h *reactiveHandler, u string) (string, bool) {
|
||||
return h.queryHistory(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsHistoryQuery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"что я тебе говорил?", true},
|
||||
{"что ты записала сегодня?", true},
|
||||
{"что я отмечал?", true},
|
||||
// Forms the truncated prefixes did not reach. The dictionary answers
|
||||
// these because it lemmatises both sides (V-530).
|
||||
{"что я тебе рассказывал?", true},
|
||||
{"что я сказала вчера", true},
|
||||
{"что ты запомнила?", true},
|
||||
// The noun, not the verb. "рассказ" was a prefix of the old pair, so
|
||||
// this read as a history question — the same defect V-528 fixed in
|
||||
// complaint.go, where "лаг" matched "лагерь".
|
||||
{"что я читал рассказ", false},
|
||||
// A verb of saying with nobody saying it.
|
||||
{"что записать?", false},
|
||||
// A named topic is a recall question, and the notes pass answers it
|
||||
// better than a list of the last five facts does.
|
||||
{"что я говорил про сервер?", false},
|
||||
{"что у меня сегодня?", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := isHistoryQuery(tc.text); got != tc.want {
|
||||
t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
h, api := historyHandler(now,
|
||||
ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)},
|
||||
// Learned, not said: a poller writing this back under "что я тебе
|
||||
// говорил?" would put words in his mouth.
|
||||
ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)},
|
||||
// Older than the window.
|
||||
ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)},
|
||||
)
|
||||
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||
if !ok {
|
||||
t.Fatal("the history question must be claimed before the recall sources")
|
||||
}
|
||||
if !strings.Contains(reply, "water") {
|
||||
t.Errorf("reply = %q, want the fact he tapped in", reply)
|
||||
}
|
||||
if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") {
|
||||
t.Errorf("reply = %q, want only what he said inside the window", reply)
|
||||
}
|
||||
if api.calls != 1 {
|
||||
t.Errorf("RecentFacts called %d times, want 1", api.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing said is an answer of its own. Falling through would hand the question
|
||||
// to recall, which answers it with an old note.
|
||||
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
h, _ := historyHandler(now)
|
||||
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||
if !ok || !strings.Contains(reply, "ничего") {
|
||||
t.Fatalf("reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Five is what fits in one spoken breath; the rest are on /history.
|
||||
func TestHistoryStopsAtFive(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
var facts []ipc.Fact
|
||||
for i := 0; i < 12; i++ {
|
||||
facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)})
|
||||
}
|
||||
h, _ := historyHandler(now, facts...)
|
||||
reply, _ := askHistory(h, "что ты записала?")
|
||||
if got := strings.Count(reply, ";"); got != historyReadOut-1 {
|
||||
t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1)
|
||||
}
|
||||
}
|
||||
@@ -127,8 +127,10 @@ 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)")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
allowSeedOnStart = *allowSeed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -338,6 +340,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 {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
@@ -605,6 +608,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
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Ordinal selection over a list she just read (Vikunja #448).
|
||||
//
|
||||
// The dialogue session already carried the intent, the slots and the history.
|
||||
// What it did not carry was the list: she recited five tasks, he said "второй",
|
||||
// and there was nothing for that word to point at, so it routed as a fresh
|
||||
// utterance and meant nothing.
|
||||
//
|
||||
// Candidates are bound when she speaks the list, in the order she spoke it (see
|
||||
// tasks.Spoken). Binding afterwards would resolve "второй" against a fresh
|
||||
// query, and the list can change between two turns.
|
||||
//
|
||||
// An ordinal with no verb is read back, not acted on: "второй" names a task, it
|
||||
// does not say what to do with it. Acting on the bare word would guess, and a
|
||||
// wrong guess here closes work he never finished.
|
||||
|
||||
// candidateOrdinals — the words that pick a position, by index. Prefix match,
|
||||
// because Russian declines them: "первый", "первую", "первое".
|
||||
var candidateOrdinals = []struct {
|
||||
word string
|
||||
nth int
|
||||
}{
|
||||
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
|
||||
{"first", 1}, {"second", 2}, {"third", 3},
|
||||
}
|
||||
|
||||
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
|
||||
// "15" starts with "1" and is a time, not a position.
|
||||
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
|
||||
|
||||
// candidateLast — "последний" picks the end of the list whatever its length.
|
||||
var candidateLast = []string{"последн", "last"}
|
||||
|
||||
// parseOrdinal reads which position he named. 0 and false when he named none.
|
||||
// A negative result means the last one.
|
||||
func parseOrdinal(text string) (int, bool) {
|
||||
// Token by token, not substring: " 1" would otherwise match inside
|
||||
// "напомни в 15:00" and turn a reminder into a selection.
|
||||
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}) {
|
||||
for _, w := range candidateLast {
|
||||
if strings.HasPrefix(tok, w) {
|
||||
return -1, true
|
||||
}
|
||||
}
|
||||
if n, ok := candidateDigits[tok]; ok {
|
||||
return n, true
|
||||
}
|
||||
for _, o := range candidateOrdinals {
|
||||
// Prefix, because Russian declines them: "первый", "первую".
|
||||
if strings.HasPrefix(tok, o.word) {
|
||||
return o.nth, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// candidateVerbs — what he wants done with the one he picked. Nothing here is
|
||||
// destructive: a task moves forward or is dropped, and both are recorded with a
|
||||
// provenance the /tasks page shows.
|
||||
var candidateVerbs = []struct {
|
||||
words []string
|
||||
status string
|
||||
say string
|
||||
}{
|
||||
{[]string{"готов", "сделал", "выполнил", "закрыл", "done"}, store.TaskDone, "закрыла"},
|
||||
{[]string{"не надо", "убери", "отмени", "не буду", "drop"}, store.TaskDropped, "убрала"},
|
||||
{[]string{"подтвержда", "беру", "да,", "буду делать"}, store.TaskOpen, "взяла в работу"},
|
||||
}
|
||||
|
||||
func parseCandidateVerb(text string) (status, say string, ok bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(text))
|
||||
for _, v := range candidateVerbs {
|
||||
for _, w := range v.words {
|
||||
if strings.Contains(s, w) {
|
||||
return v.status, v.say, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// offerCandidates records the list she just read, so his next words can pick
|
||||
// from it. Best effort: no session store, or a session that expired between the
|
||||
// question and the answer, means the words route normally.
|
||||
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
|
||||
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||
return
|
||||
}
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
|
||||
}
|
||||
|
||||
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
||||
// the list she just read.
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
|
||||
if h.dialogueSessions == nil {
|
||||
return "", false
|
||||
}
|
||||
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
|
||||
if sess == nil || len(sess.Candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
nth, ok := parseOrdinal(text)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if nth < 0 {
|
||||
nth = len(sess.Candidates)
|
||||
}
|
||||
if nth > len(sess.Candidates) {
|
||||
// Claim the turn: he is picking from her list and named a position she
|
||||
// did not read. Routing it fresh would answer something else entirely.
|
||||
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
|
||||
}
|
||||
pick := sess.Candidates[nth-1]
|
||||
status, say, hasVerb := parseCandidateVerb(text)
|
||||
if !hasVerb || pick.Kind != "task" {
|
||||
// Read it back and keep the list: naming one is often the first half of
|
||||
// a sentence, and the second half is the next turn.
|
||||
return pick.Label, true
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
|
||||
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
|
||||
return "не получилось изменить задачу.", true
|
||||
}
|
||||
// Spent: the list she read is no longer the list, and a second ordinal
|
||||
// against it would close the wrong task.
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
|
||||
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||
return say + ": " + pick.Label, true
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"второй", 2, true},
|
||||
{"вторую сделал", 2, true},
|
||||
{"первую убери", 1, true},
|
||||
{"последнюю не надо", -1, true},
|
||||
{"3", 3, true},
|
||||
{"the second one", 2, true},
|
||||
// No position named.
|
||||
{"какие у меня задачи", 0, false},
|
||||
{"", 0, false},
|
||||
// A digit inside a time is not a position.
|
||||
{"напомни в 15:00", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseOrdinal(c.text)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("parseOrdinal(%q) = %d,%v; want %d,%v", c.text, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
|
||||
t.Error("an ordinal with no list behind it was claimed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "второй")
|
||||
if !handled || !strings.Contains(reply, "позвонить маме") {
|
||||
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
|
||||
}
|
||||
// Still live: naming one is often the first half of a sentence.
|
||||
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
|
||||
t.Error("the list was spent by a read-back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал")
|
||||
if !handled || !strings.Contains(reply, "купить хлеб") {
|
||||
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
|
||||
}
|
||||
live, err := st.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
for _, task := range live {
|
||||
if task.ID == ids[0] {
|
||||
t.Fatalf("task %d is still live after he closed it", task.ID)
|
||||
}
|
||||
}
|
||||
// Spent: a second ordinal against a list that no longer holds would close
|
||||
// the wrong task.
|
||||
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
|
||||
t.Error("the list survived the pick it was spent on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ids, "купить хлеб")
|
||||
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий")
|
||||
if !handled || !strings.Contains(reply, "1") {
|
||||
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
|
||||
}
|
||||
}
|
||||
|
||||
// ordinalNow — a fixed capture time; the ranker only needs the rows to exist.
|
||||
var ordinalNow = time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var ids []int64
|
||||
for _, text := range texts {
|
||||
res, err := st.CaptureTask(ctx, store.Task{
|
||||
Text: text,
|
||||
Source: "tap:voice",
|
||||
Status: store.TaskOpen,
|
||||
CreatedTs: ordinalNow,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("capture task: %v", err)
|
||||
}
|
||||
ids = append(ids, res.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
|
||||
cands := make([]dialogue.Candidate, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(cands)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
||||
{"how do i boil an egg", false},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{embedder: emb}
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
ctx := context.Background()
|
||||
wrong := 0
|
||||
for _, c := range cases {
|
||||
@@ -80,7 +80,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
||||
}
|
||||
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
|
||||
got := h.isPersonalTurn(ctx, turn)
|
||||
p, w, ok := h.boundary.score(vec)
|
||||
p, w, ok := h.recall.boundary.score(vec)
|
||||
if !ok {
|
||||
t.Fatal("seeds did not load with a working embedder")
|
||||
}
|
||||
|
||||
@@ -85,15 +85,17 @@ func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reac
|
||||
|
||||
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
||||
h := &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
embedder: emb,
|
||||
api: ipc.NewStoreAPI(st),
|
||||
recall: recallWiring{
|
||||
embedder: emb,
|
||||
memStore: mem,
|
||||
minScore: 0.55,
|
||||
minMargin: 0.008,
|
||||
},
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phr,
|
||||
now: func() time.Time { return now },
|
||||
memStore: mem,
|
||||
dataStore: st,
|
||||
queryMinScore: 0.55,
|
||||
queryMinMargin: 0.008,
|
||||
weatherProvider: nil,
|
||||
}
|
||||
return h, phr
|
||||
|
||||
+56
-36
@@ -11,6 +11,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
@@ -52,29 +53,39 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// quietInflections — the inflectional endings a stem may carry and still be
|
||||
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
||||
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
||||
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
||||
// ending, and "потихоньку" doesn't start with the stem at all.
|
||||
var quietInflections = []string{
|
||||
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
||||
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
||||
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
||||
}
|
||||
|
||||
// quietStem reports whether tok is the given stem carrying at most one
|
||||
// inflectional ending. Word boundaries come from tokenisation (see
|
||||
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
||||
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
||||
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
||||
func quietStem(tok, stem string) bool {
|
||||
if !strings.HasPrefix(tok, stem) {
|
||||
return false
|
||||
}
|
||||
suffix := tok[len(stem):]
|
||||
for _, e := range quietInflections {
|
||||
if suffix == e {
|
||||
// quietStem reports whether tok is one of the words a vocabulary slot accepts.
|
||||
// A slot is written as alternatives joined by "|", and an alternative comes in
|
||||
// two flavours:
|
||||
//
|
||||
// - a dictionary form, matched through the dictionary, so every case and
|
||||
// gender of it counts. This is what the nouns and adjectives want: "тихий",
|
||||
// "тихом", "тихо" and "тише" are one word.
|
||||
// - a form prefixed with "=", matched as the exact token. This is what the
|
||||
// VERBS want, and it is not a shortcut. A command is an imperative, and the
|
||||
// dictionary quite correctly files "говори" and "говорил" under one lemma —
|
||||
// so lemma-matching a verb slot read "он говорил тихим голосом весь вечер",
|
||||
// a remark about his evening, as an order to go quiet. Aspect pairs are two
|
||||
// separate verbs, which is why several imperatives are listed by hand.
|
||||
//
|
||||
// Word boundaries come from tokenisation (see quietTokens), not from a regexp —
|
||||
// Go's \b is ASCII-oriented and treats every Cyrillic letter as a non-word
|
||||
// character, so `\bтих\b` would happily match inside "тихонько". Comparing whole
|
||||
// tokens sidesteps that entirely.
|
||||
//
|
||||
// The comparison is a dictionary lookup, not a stem plus a list of 36 endings
|
||||
// (Vikunja #526). The distinction the old comment described is exactly the one a
|
||||
// dictionary makes: "тихий", "тихом", "тихо" and "тише" are one word inflected,
|
||||
// while "тихонько" and "потихоньку" are different words — and the dictionary
|
||||
// knows that without anybody deciding that "онько" is not an ending.
|
||||
func quietStem(tok, slot string) bool {
|
||||
for _, form := range strings.Split(slot, "|") {
|
||||
if exact, ok := strings.CutPrefix(form, "="); ok {
|
||||
if tok == exact {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if morph.SameWord(tok, form) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -117,7 +128,10 @@ func quietPhrase(tokens, pattern []string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as sequences of
|
||||
// dictionary forms. They used to be truncated stems ("тих", "выключ"), which is
|
||||
// what the ending list existed to complete; a dictionary form needs no
|
||||
// completing (Vikunja #526).
|
||||
//
|
||||
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
||||
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
||||
@@ -128,36 +142,42 @@ func quietPhrase(tokens, pattern []string) bool {
|
||||
var (
|
||||
quietOffPhrases = [][]string{
|
||||
{"quiet", "off"}, {"quiet", "end"},
|
||||
{"громк", "режим"}, {"шумн", "режим"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"},
|
||||
{"громкий", "режим"}, {"шумный", "режим"},
|
||||
{"=отмени|=отменяй|=отменить", "тихий"},
|
||||
{"=выключи|=выключай|=выключить", "тихий"},
|
||||
}
|
||||
quietOnPhrases = [][]string{
|
||||
{"quiet", "on"}, {"quiet", "mode"},
|
||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||||
{"тихий", "режим"}, {"не", "=шуми|=шумите"}, {"не", "=беспокой|=беспокоить"},
|
||||
// The noun form and the comparative. "режим тишины" is how the
|
||||
// setting is named half the time, and "сделай потише" is how it is
|
||||
// actually asked for out loud. Both used to fall through to the
|
||||
// router, which has no quiet intent, so the command did nothing.
|
||||
{"режим", "тишин"}, {"сделай", "тише"}, {"сделай", "потише"},
|
||||
{"говори", "тише"}, {"будь", "потише"},
|
||||
{"тих"}, {"потише"},
|
||||
{"режим", "тишина"}, {"=сделай", "тихий"}, {"=сделай", "потише"},
|
||||
{"=говори", "тихий"}, {"=будь", "потише"},
|
||||
{"тихий"}, {"потише"},
|
||||
}
|
||||
)
|
||||
|
||||
// quietWordStems — every stem that names the setting. Used by the
|
||||
// quietWordStems — every word that names the setting. Used by the
|
||||
// negated-but-unmatched fallback in classifyQuietToggle, which has to
|
||||
// recognise "хватит тишины" without an ON phrase having matched.
|
||||
var quietWordStems = []string{"тих", "тишин", "потише"}
|
||||
var quietWordStems = []string{"тихий", "тишина", "потише"}
|
||||
|
||||
// quietNegatorWords — negators that are whole words with no useful stem.
|
||||
var quietNegatorWords = map[string]bool{
|
||||
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
||||
}
|
||||
|
||||
// quietNegatorStems — negators that inflect. Matched through quietStem, the
|
||||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
||||
// and "выключай" all count and "выключатель" does not.
|
||||
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
|
||||
// quietNegatorStems — negators that inflect. Imperatives, matched exactly for
|
||||
// the reason quietStem gives: "выключи" is a command and "выключил" is a report
|
||||
// about earlier, and one lemma covers both. "выключатель" was never a negator
|
||||
// and is not one now.
|
||||
var quietNegatorStems = []string{
|
||||
"=выключи|=выключай|=выключить", "=отмени|=отменяй|=отменить",
|
||||
"=прекрати|=прекращай|=прекратить", "=убери|=убирай|=убрать",
|
||||
"stop", "cancel", "disable",
|
||||
}
|
||||
|
||||
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
||||
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
||||
|
||||
@@ -26,11 +26,10 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: rtr,
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
@@ -101,11 +100,10 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||
matcher := tool.NewMatcher(api)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, matcher, 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
|
||||
+41
-1
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "github.com/kami/maven/internal/memory"
|
||||
import (
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// bestRecall is the read side of the long-term memory store: the top hit when
|
||||
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
||||
@@ -23,3 +26,40 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re
|
||||
}
|
||||
return results[0], true
|
||||
}
|
||||
|
||||
// recallWiring — the recall subsystem's dependencies, held as one group on
|
||||
// reactiveHandler (Vikunja #433). It is the worked example for the wiring
|
||||
// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty
|
||||
// loose ones, so a handler names what it needs and the package can be split
|
||||
// later without exporting the whole struct.
|
||||
//
|
||||
// The zero value is usable and means "no recall": no embedder, no vector
|
||||
// store, and a gate that is never consulted because nothing is ever searched.
|
||||
type recallWiring struct {
|
||||
// embedder — reused for note write/query (same model as the classifier).
|
||||
embedder router.Embedder
|
||||
|
||||
// memStore — the vector index over notes and facts.
|
||||
memStore memory.Store
|
||||
|
||||
// topics — the embedded seed sets behind the weather, house and LAN
|
||||
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
|
||||
// usable, loads on first query, and with no embedder it never loads and
|
||||
// each source falls back to its own keyword test.
|
||||
topics topicIndex
|
||||
|
||||
// boundary — the embedded seed sets behind the personal boundary
|
||||
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||
boundary personalBoundary
|
||||
|
||||
// minScore — the note-recall confidence gate. Top cosine below this ⇒
|
||||
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob,
|
||||
// not load-bearing math (same posture as the presence thresholds). Set by
|
||||
// wireVoice from VoiceConfig; default 0.55.
|
||||
minScore float64
|
||||
|
||||
// minMargin — the second half of that gate: how far the top hit must beat
|
||||
// the runner-up. 0 ⇒ margin off.
|
||||
minMargin float64
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
)
|
||||
|
||||
// reminderMarker — the words that open a reminder. Stripped because they are
|
||||
// the instruction, not the thing to say at the hour.
|
||||
//
|
||||
// The verbs come from the lexicon (Vikunja #530). They are a closed set of the
|
||||
// commands she answers to, exactly like capture_verbs, and the literal that
|
||||
// stood here knew four of them.
|
||||
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:` + alternation(lexicon.ReminderVerbs()) +
|
||||
`)\s*(?:мне|me)?[\s,:—-]*`)
|
||||
|
||||
// reminderTimeWords — the time expressions a reminder carries, removed from
|
||||
// the body because the fire time is already a column. Ordered longest-first
|
||||
// where two could match the same words, so "через полтора часа" does not leave
|
||||
// "полтора" behind.
|
||||
//
|
||||
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
|
||||
// boundaries here are written out as whitespace or an end of string — the same
|
||||
// trap the agenda grammars hit.
|
||||
//
|
||||
// The Russian word lists are gone (Vikunja #530). The day words are
|
||||
// lexicon.DayOffsetWords, which is why "вчера" and "позавчера" are stripped now
|
||||
// and were not before, and the times of day are lexicon.PartsOfDay. What is
|
||||
// still written out here is the shape of a clock reading — a preposition, digits,
|
||||
// a colon — which is structured input rather than a claim about Russian.
|
||||
var reminderTimeWords = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
|
||||
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
|
||||
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.DayOffsetWords()) + `)(\s|$)`),
|
||||
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.PartsOfDay()) + `)(\s|$)`),
|
||||
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
|
||||
}
|
||||
|
||||
// alternation folds a lexicon set into one regexp branch, longest member first
|
||||
// so "послезавтра" is not matched as "завтра" with a tail left behind. Sorted
|
||||
// rather than taken as given, because two members of equal length must still
|
||||
// produce the same pattern on every build.
|
||||
func alternation(set []string) string {
|
||||
out := make([]string, 0, len(set))
|
||||
for _, w := range set {
|
||||
out = append(out, regexp.QuoteMeta(w))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if len(out[i]) != len(out[j]) {
|
||||
return len(out[i]) > len(out[j])
|
||||
}
|
||||
return out[i] < out[j]
|
||||
})
|
||||
return strings.Join(out, "|")
|
||||
}
|
||||
|
||||
// reminderBody is what she says at the hour.
|
||||
//
|
||||
// The whole utterance used to be stored, so /reminders read "напомни завтра в
|
||||
// 9 утра выпить таблетки" where it should read "выпить таблетки", and the
|
||||
// agenda recited the marker back at him (Vikunja #469). The fire time is
|
||||
// already a column, and the marker is an instruction that was carried out.
|
||||
//
|
||||
// Falls back to the fuller text whenever stripping would leave nothing: an
|
||||
// empty body is a reminder that fires and says nothing, which is worse than a
|
||||
// wordy one.
|
||||
func reminderBody(utterance, text string) string {
|
||||
body := strings.TrimSpace(text)
|
||||
if body == "" {
|
||||
body = strings.TrimSpace(utterance)
|
||||
}
|
||||
stripped := reminderMarker.ReplaceAllString(body, "")
|
||||
for _, re := range reminderTimeWords {
|
||||
stripped = re.ReplaceAllString(stripped, " ")
|
||||
}
|
||||
stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " "))
|
||||
stripped = strings.Trim(stripped, " ,;:—-")
|
||||
if stripped == "" {
|
||||
return body
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReminderBody(t *testing.T) {
|
||||
for _, tc := range []struct{ utterance, text, want string }{
|
||||
// The row from the QA sitting: the whole utterance was the body.
|
||||
{"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"},
|
||||
{"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"},
|
||||
{"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"},
|
||||
{"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"},
|
||||
// Nothing left after stripping ⇒ keep what there was. A reminder that
|
||||
// fires and says nothing is worse than a wordy one.
|
||||
{"напомни завтра", "завтра", "завтра"},
|
||||
{"", "", ""},
|
||||
} {
|
||||
if got := reminderBody(tc.utterance, tc.text); got != tc.want {
|
||||
t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Conversation repair (Vikunja #455).
|
||||
//
|
||||
// The classifier has been able to learn from a correction since it was
|
||||
// written — CorrectMisroute appends the utterance as a new example for the
|
||||
// intent he names, append-only, no retrain. Nothing in the daemon could reach
|
||||
// it: the only caller was a test. So the mechanism existed and the behaviour
|
||||
// did not.
|
||||
//
|
||||
// This is the reachable half. He says she got it wrong and names what it
|
||||
// should have been, she redoes the previous utterance under that intent, and
|
||||
// she says out loud that the correction landed — because a correction he
|
||||
// cannot see is indistinguishable from one that was dropped.
|
||||
//
|
||||
// Taken before routing, like the confirm and clarify turns: "нет, это была
|
||||
// заметка" is an answer to the previous turn, not a fresh command, and routing
|
||||
// it as one files the correction itself as a note.
|
||||
|
||||
// routedTurn — the previous utterance and where it went, which is all a
|
||||
// correction needs to point at.
|
||||
type routedTurn struct {
|
||||
utterance string
|
||||
intent router.Intent
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||
// hear the wrong answer, think, and say so; short enough that "это заметка"
|
||||
// half an hour later is a fresh sentence and not a verdict on something he has
|
||||
// forgotten.
|
||||
const repairWindow = 5 * time.Minute
|
||||
|
||||
// repairMarkers — the ways he says she got it wrong. One of these must appear:
|
||||
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
|
||||
// treating it as a correction would rewrite the last turn every time he used
|
||||
// the word.
|
||||
//
|
||||
// From the lexicon, and staying a list rather than becoming seeds (Vikunja
|
||||
// #528). This runs pre-route, before the turn vector exists, and a correction
|
||||
// redoes the previous request — so a near-miss would act on something he never
|
||||
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
|
||||
var repairMarkers = lexicon.RepairMarkers()
|
||||
|
||||
// repairIntents — the words he uses for each intent, as dictionary forms. They
|
||||
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
|
||||
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
|
||||
// compares the words themselves (Vikunja #528).
|
||||
var repairIntents = []struct {
|
||||
word string
|
||||
intent router.Intent
|
||||
say string
|
||||
}{
|
||||
{"заметка", router.IntentNote, "заметка"},
|
||||
{"напоминание", router.IntentReminder, "напоминание"},
|
||||
{"напомнить", router.IntentReminder, "напоминание"},
|
||||
{"факт", router.IntentFact, "факт"},
|
||||
{"вопрос", router.IntentQuery, "вопрос"},
|
||||
{"команда", router.IntentAct, "команда"},
|
||||
{"note", router.IntentNote, "заметка"},
|
||||
{"reminder", router.IntentReminder, "напоминание"},
|
||||
{"fact", router.IntentFact, "факт"},
|
||||
{"question", router.IntentQuery, "вопрос"},
|
||||
}
|
||||
|
||||
// parseRepair reads a spoken correction: a marker saying she was wrong, plus
|
||||
// the intent it should have been.
|
||||
//
|
||||
// The negated half is skipped. "это заметка, а не напоминание" names both
|
||||
// intents, and the one he is correcting TO is the one he did not put "не" in
|
||||
// front of.
|
||||
func parseRepair(utterance string) (router.Intent, string, bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
if s == "" {
|
||||
return "", "", false
|
||||
}
|
||||
// A leading "нет" is a marker on its own — "нет, это заметка" is the
|
||||
// shortest correction he actually says. Only leading: "нет" in the middle
|
||||
// of a sentence is an ordinary word.
|
||||
marked := strings.HasPrefix(s, "нет") || strings.HasPrefix(s, "no,")
|
||||
for _, m := range repairMarkers {
|
||||
if marked || strings.Contains(s, m) {
|
||||
marked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !marked {
|
||||
return "", "", false
|
||||
}
|
||||
// Over tokens, not byte offsets. The negation test used to read the string
|
||||
// immediately before a match, which meant it could only see "не" spelled
|
||||
// exactly there; a token list makes the previous word plain to read.
|
||||
toks := repairTokens(s)
|
||||
best, say, at := router.Intent(""), "", -1
|
||||
for i, tok := range toks {
|
||||
if at >= 0 && i > at {
|
||||
break
|
||||
}
|
||||
for _, w := range repairIntents {
|
||||
if !morph.SameWord(tok, w.word) {
|
||||
continue
|
||||
}
|
||||
if i > 0 && (toks[i-1] == "не" || toks[i-1] == "not") {
|
||||
// The one he is ruling out: "не напоминание, а заметка".
|
||||
continue
|
||||
}
|
||||
// Leftmost wins: "это заметка, а не напоминание" corrects to the
|
||||
// first.
|
||||
if at < 0 || i < at {
|
||||
best, say, at = w.intent, w.say, i
|
||||
}
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return best, say, true
|
||||
}
|
||||
|
||||
// repairTokens splits a correction into lowercase word tokens. Punctuation goes,
|
||||
// because "это заметка, а не напоминание" glues a comma to the word the negation
|
||||
// test has to look past.
|
||||
func repairTokens(s string) []string {
|
||||
return strings.FieldsFunc(s, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// recordTurn keeps the utterance a correction would point at. Only turns she
|
||||
// acted on: a clarify asked instead of acting, so there is nothing yet to be
|
||||
// wrong about.
|
||||
func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
last := h.lastRouted
|
||||
// Taken, not read: one utterance is corrected once. Saying "нет, не так"
|
||||
// twice would otherwise redo the same request twice.
|
||||
h.lastRouted = nil
|
||||
return last
|
||||
}
|
||||
|
||||
// resolveRepair handles a spoken correction of the previous turn: teach the
|
||||
// classifier, redo the request under the corrected intent, and say so.
|
||||
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
||||
corrected, say, ok := parseRepair(text)
|
||||
if !ok || h.router == nil {
|
||||
return "", false
|
||||
}
|
||||
last := h.takeLastTurn()
|
||||
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||
return "", false
|
||||
}
|
||||
if last.intent == corrected {
|
||||
// She already did what he is asking for. Correcting the classifier
|
||||
// here would teach it the label it produced, and redoing the request
|
||||
// would file it twice.
|
||||
return "", false
|
||||
}
|
||||
learned := true
|
||||
if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil {
|
||||
// The redo is still worth doing: he asked for something and it did not
|
||||
// happen. Only the learning half is lost, and he is told so.
|
||||
log.Printf("voice: repair: could not learn %q as %s: %v", last.utterance, corrected, err)
|
||||
learned = false
|
||||
}
|
||||
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
||||
|
||||
dec := router.Decision{
|
||||
Utterance: last.utterance,
|
||||
Stage: 2,
|
||||
Intent: corrected,
|
||||
Slots: h.extractor.Extract(ctx, corrected, last.utterance, h.now()),
|
||||
}
|
||||
// A reminder's Text is what she says at the hour and stays empty when it
|
||||
// was not spoken, so the gap is asked about rather than filled with the
|
||||
// whole sentence. Everywhere else the utterance IS the payload.
|
||||
if dec.Slots.Text == "" && corrected != router.IntentReminder {
|
||||
dec.Slots.Text = last.utterance
|
||||
}
|
||||
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
|
||||
}
|
||||
|
||||
// repairLine — what she says before redoing it, so the correction is visible
|
||||
// and not just filed. Feminine, informal, no apology: he corrected a routing
|
||||
// call, he did not complain about her.
|
||||
func repairLine(say string, learned bool) string {
|
||||
if !learned {
|
||||
return "поняла, это " + say + " — переделываю, но запомнить поправку не вышло."
|
||||
}
|
||||
return "поняла, это " + say + " — запомнила."
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want router.Intent
|
||||
ok bool
|
||||
}{
|
||||
{"нет, ты не поняла, это заметка", router.IntentNote, true},
|
||||
{"нет, это заметка", router.IntentNote, true},
|
||||
{"это не напоминание, а заметка", router.IntentNote, true},
|
||||
{"это заметка, а не напоминание", router.IntentNote, true},
|
||||
{"ты не так поняла — это факт", router.IntentFact, true},
|
||||
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
|
||||
{"you got it wrong, that was a note", router.IntentNote, true},
|
||||
// No marker: an ordinary request that happens to name an intent.
|
||||
{"запиши заметку купить хлеб", "", false},
|
||||
{"напомни мне про заметку", "", false},
|
||||
// A marker with no intent named: nothing to correct to.
|
||||
{"ты не так поняла", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, _, ok := parseRepair(c.utterance)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
|
||||
// previous utterance is filed under the intent he named, the classifier keeps
|
||||
// it as an example, and he hears that it landed.
|
||||
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
cls := router.NewClassifier(emb)
|
||||
h.recall.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
|
||||
if !handled {
|
||||
t.Fatal("a spoken correction was not handled")
|
||||
}
|
||||
if !strings.Contains(reply, "заметка") {
|
||||
t.Errorf("the correction is not named out loud: %q", reply)
|
||||
}
|
||||
if strings.Contains(reply, "не вышло") {
|
||||
t.Errorf("learning failed unexpectedly: %q", reply)
|
||||
}
|
||||
|
||||
ex := cls.Examples(router.IntentNote)
|
||||
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
|
||||
t.Fatalf("the classifier did not learn the correction: %+v", ex)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("recent notes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
|
||||
t.Fatalf("the request was not redone as a note: %+v", notes)
|
||||
}
|
||||
_ = now
|
||||
}
|
||||
|
||||
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
|
||||
h, _, now := newClarifyHandler(t)
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||
ctx := context.Background()
|
||||
|
||||
// Nothing said yet.
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("a correction with no previous turn was handled")
|
||||
}
|
||||
// Said, but long ago.
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
*now = now.Add(repairWindow + time.Minute)
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("a correction outside the window was handled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairIsSpentOnce(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
h.recall.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||
t.Fatal("the first correction was not handled")
|
||||
}
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("the same turn was corrected twice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
|
||||
// is nothing to teach and redoing it would file the request a second time.
|
||||
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||
h.recordTurn("купить хлеб", router.IntentNote)
|
||||
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||||
t.Error("a correction to the intent she already used was handled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairIntentWordCollisions — the prefix list matched more than the word
|
||||
// (Vikunja #528). "команд" is inside "командировка" and "факт" inside
|
||||
// "фактически", and either one used to name an intent she would redo the turn
|
||||
// under.
|
||||
func TestRepairIntentWordCollisions(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"нет, это про командировку",
|
||||
"нет, фактически всё нормально",
|
||||
} {
|
||||
if _, _, ok := parseRepair(s); ok {
|
||||
t.Errorf("parseRepair(%q) claimed a correction", s)
|
||||
}
|
||||
}
|
||||
// The declined forms the prefixes existed to cover still work, and the
|
||||
// negated half is still skipped.
|
||||
for _, tc := range []struct {
|
||||
utterance string
|
||||
want router.Intent
|
||||
}{
|
||||
{"нет, это заметка", router.IntentNote},
|
||||
{"ты не так поняла, это заметку надо было", router.IntentNote},
|
||||
{"нет, это напоминание, а не заметка", router.IntentReminder},
|
||||
{"нет, не напоминание, а заметка", router.IntentNote},
|
||||
{"нет, это командой было", router.IntentAct},
|
||||
} {
|
||||
got, _, ok := parseRepair(tc.utterance)
|
||||
if !ok || got != tc.want {
|
||||
t.Errorf("parseRepair(%q) = %q, %v; want %q, true", tc.utterance, got, ok, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
||||
// answer from the stub, which is what keeps a turn from breaking on the model.
|
||||
func (r *llmReplier) Reply(d router.Decision) string {
|
||||
if d.Clarify {
|
||||
return r.stub.Reply(d)
|
||||
// The deck, not the stub's single sentence: a clarify she cannot turn
|
||||
// into a question is the line he hears most often when she misses him,
|
||||
// and it used to be the same words every time (Vikunja #457). Still no
|
||||
// model call — this text has to be right every time, and it is not worth
|
||||
// a generation to say something this small.
|
||||
return clarifyMissedLine(d)
|
||||
}
|
||||
out, err := r.p.PhraseReply(context.Background(), d)
|
||||
if err != nil || out == "" {
|
||||
|
||||
@@ -38,9 +38,21 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
||||
}
|
||||
|
||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
// A clarify never reaches the model, and since Vikunja #457 it is answered from
|
||||
// the clarify deck rather than the stub's single sentence.
|
||||
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
||||
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
|
||||
if got == "я всё поняла" {
|
||||
t.Fatal("a clarify must not be phrased by the model")
|
||||
}
|
||||
if want := clarifyMissedFor("мгм"); got != want {
|
||||
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||
}
|
||||
// Two different misses do not sound identical.
|
||||
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
||||
}
|
||||
}
|
||||
|
||||
// assertAck — the stub picks between variants now, so two calls to it are not
|
||||
|
||||
+14
-16
@@ -16,18 +16,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
var ruWeekdays = []string{
|
||||
"воскресенье", "понедельник", "вторник", "среда",
|
||||
"четверг", "пятница", "суббота",
|
||||
}
|
||||
|
||||
var ruMonths = []string{
|
||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
||||
}
|
||||
// Weekday and month names are a closed class — the language has seven and
|
||||
// twelve — so they live complete in internal/lexicon, where internal/ttsnorm
|
||||
// reads the same twelve month names instead of keeping a second copy
|
||||
// (Vikunja #525).
|
||||
|
||||
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
||||
// other than here. She only keeps one clock, and saying so is better than
|
||||
@@ -39,13 +35,15 @@ var ruMonths = []string{
|
||||
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
||||
|
||||
// notPlaceAfterV — words that follow "в" without naming a place, so
|
||||
// mentionsUnknownPlace does not mistake them for a city.
|
||||
var notPlaceAfterV = map[string]bool{
|
||||
"данный": true, "данную": true, "этот": true, "эту": true,
|
||||
"котором": true, "какое": true, "какой": true, "который": true,
|
||||
"общем": true, "точности": true, "курсе": true, "сутках": true,
|
||||
"часах": true, "минутах": true, "секундах": true, "неделе": true,
|
||||
}
|
||||
// mentionsUnknownPlace does not mistake them for a city. Closed set, kept
|
||||
// complete in internal/lexicon.
|
||||
var notPlaceAfterV = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, w := range lexicon.NotPlaceAfterV() {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
||||
// that looks like a place we do not know ("который час в киеве"). Used only to
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -428,20 +428,22 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||
|
||||
w.handler = &reactiveHandler{
|
||||
stt: simTranscriber{},
|
||||
tts: simSynthesizer{},
|
||||
router: rtr,
|
||||
embedder: emb,
|
||||
stt: simTranscriber{},
|
||||
tts: simSynthesizer{},
|
||||
router: rtr,
|
||||
recall: recallWiring{
|
||||
embedder: emb,
|
||||
memStore: st.VectorMemory(),
|
||||
minScore: config.DefaultQueryMinScore,
|
||||
minMargin: config.DefaultQueryMinMargin,
|
||||
},
|
||||
api: api,
|
||||
matcher: matcher,
|
||||
tools: tool.NewExecutor(api, 5*time.Second),
|
||||
phraser: phraser.NewStub(),
|
||||
replier: newLLMReplier(scripted, nil),
|
||||
now: clock.Now,
|
||||
memStore: st.VectorMemory(),
|
||||
dataStore: st,
|
||||
queryMinScore: config.DefaultQueryMinScore,
|
||||
queryMinMargin: config.DefaultQueryMinMargin,
|
||||
timeParser: router.StubDateTimeParser{},
|
||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||
|
||||
+12
-4
@@ -82,15 +82,23 @@ func (h *reactiveHandler) pendingNudge(ctx context.Context, now time.Time) (ipc.
|
||||
return ipc.Nudge{}, false
|
||||
}
|
||||
|
||||
// snoozePhrases — the deferral vocabulary, as stem sequences. Matched by
|
||||
// quietPhrase (quiet_toggle.go), which carries the rule that matters here:
|
||||
// snoozePhrases — the deferral vocabulary. Matched by quietPhrase and quietStem
|
||||
// (quiet_toggle.go), so an adverb is matched through the dictionary and a verb
|
||||
// exactly, prefixed with "=": "напомни" is a request and "напомнил" is a report
|
||||
// about earlier, and the dictionary files both under напомнить (Vikunja #526).
|
||||
// The verbs used to be truncated stems — "напомн", "отлож" — which is what the
|
||||
// deleted ending list existed to complete.
|
||||
//
|
||||
// quietPhrase carries the rule that matters here:
|
||||
// a single-word pattern matches only a single-word utterance. Bare "потом" is
|
||||
// an answer; "потом схожу за водой" is a plan, and reporting a plan must not
|
||||
// silence the rule that prompted it.
|
||||
var snoozePhrases = [][]string{
|
||||
{"не", "сейчас"}, {"не", "могу", "сейчас"}, {"не", "до", "этого"},
|
||||
{"напомн", "позже"}, {"напомн", "потом"}, {"спрос", "позже"},
|
||||
{"отлож"}, {"позже"}, {"потом"}, {"попозже"}, {"погоди"},
|
||||
{"=напомни|=напоминай", "позже"}, {"=напомни|=напоминай", "потом"},
|
||||
{"=спроси|=спрашивай", "позже"},
|
||||
{"=отложи|=отложим|=откладывай"}, {"позже"}, {"потом"}, {"попозже"},
|
||||
{"=погоди|=погодите"},
|
||||
{"not", "now"}, {"later"}, {"snooze"}, {"remind", "me", "later"},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,25 +10,19 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/say"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -319,617 +313,6 @@ func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
|
||||
return pn.Body, pn.Summary
|
||||
}
|
||||
|
||||
// shouldQueue — true when digest is enabled and the candidate's severity is
|
||||
// at or below the configured ceiling.
|
||||
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
|
||||
return t.digestCfg != nil && t.digestCfg.Enabled &&
|
||||
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
|
||||
}
|
||||
|
||||
// queueNudge — phrases the candidate and appends it to the digest queue.
|
||||
// Deduplicates by rule name: if the same rule is already queued, this is a
|
||||
// no-op (the first fire within the window is the one that counts).
|
||||
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
|
||||
for _, q := range t.digestQ {
|
||||
if q.Rule == cand.Rule.Name {
|
||||
return // already queued
|
||||
}
|
||||
}
|
||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||
return
|
||||
}
|
||||
t.digestQ = append(t.digestQ, QueuedNudge{
|
||||
Rule: cand.Rule.Name,
|
||||
Severity: int(cand.Severity),
|
||||
Body: pn.Body,
|
||||
Key: cand.Rule.Name,
|
||||
QueuedAt: now,
|
||||
})
|
||||
t.cachePhrase(pn)
|
||||
}
|
||||
|
||||
// maybeFlush — flushes the digest queue if the window has elapsed since the
|
||||
// first item or the queue reached MaxItems.
|
||||
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
|
||||
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
|
||||
return
|
||||
}
|
||||
first := t.digestQ[0]
|
||||
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
|
||||
len(t.digestQ) >= t.digestCfg.MaxItems {
|
||||
t.flushDigest(ctx, now, state)
|
||||
}
|
||||
}
|
||||
|
||||
// flushDigest — concatenates queued nudge bodies into a single digest
|
||||
// notification and dispatches it. Clears the queue after a successful send.
|
||||
// The digest uses the max severity among queued items for routing.
|
||||
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
|
||||
if len(t.digestQ) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
maxSev := 0
|
||||
for i, q := range t.digestQ {
|
||||
if i > 0 {
|
||||
b.WriteString(" · ")
|
||||
}
|
||||
b.WriteString(q.Body)
|
||||
if q.Severity > maxSev {
|
||||
maxSev = q.Severity
|
||||
}
|
||||
}
|
||||
body := b.String()
|
||||
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{
|
||||
Name: "digest",
|
||||
Severity: loop.Severity(maxSev),
|
||||
},
|
||||
Severity: loop.Severity(maxSev),
|
||||
State: state,
|
||||
}
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: cand,
|
||||
Body: body,
|
||||
Summary: summary,
|
||||
}
|
||||
t.cachePhrase(pn)
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
// keep the queue — the next tick's maybeFlush re-attempts.
|
||||
log.Printf("tick: dispatch digest: %v", err)
|
||||
return
|
||||
}
|
||||
t.digestQ = nil
|
||||
}
|
||||
|
||||
// detectPatterns runs the pattern detector proactively over every
|
||||
// action+object pair that has ever produced an event, independent of
|
||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||
// own schedule reading accumulated history, not only as a side effect of a
|
||||
// live voice turn.
|
||||
//
|
||||
// Idempotence and noise are handled by the store, not here — this function
|
||||
// is safe to call every tick:
|
||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||
// already has a row — in ANY status — produces no second row and no log
|
||||
// spam beyond the one line at genuine creation.
|
||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||
// status in place; the row is never deleted. So the same Lookup check
|
||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||
// calling the same shared path the voice route already used.
|
||||
//
|
||||
// By default this only creates a row for the /routines page to show: it does
|
||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||
// for the restraints that apply even then. A proposal only starts producing
|
||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: distinct event pairs: %v", err)
|
||||
return
|
||||
}
|
||||
announced := false
|
||||
for _, p := range pairs {
|
||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||
continue
|
||||
}
|
||||
if r == nil {
|
||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||
}
|
||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
// One announcement per tick at most, whatever the scan turned up. The
|
||||
// rest are on /routines; they are not lost, they are just not shouted.
|
||||
// Nor are they queued: the row now exists, so no later tick re-detects
|
||||
// them and they are never announced. See announceProposal.
|
||||
if announced {
|
||||
continue
|
||||
}
|
||||
announced = t.announceProposal(ctx, r, now, state)
|
||||
}
|
||||
}
|
||||
|
||||
// announceProposal offers a freshly inferred routine through the ordinary
|
||||
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||
// something was actually sent.
|
||||
//
|
||||
// Everything here is restraint. The feature is off unless configured; when on
|
||||
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||
// real send, but the proposal row already exists, so the next tick will not
|
||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||
// he reads it on /routines instead, which is the whole point of the page.
|
||||
//
|
||||
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
||||
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
||||
// that first proposes it. Combined with one announcement per tick, the first
|
||||
// tick over a populated history announces one pattern and permanently silences
|
||||
// every other pattern found in the same pass. That is the intent, not an
|
||||
// oversight — an inferred routine is not worth a second attempt at his
|
||||
// attention, and /routines lists all of them. So the cooldown does not drain a
|
||||
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
||||
// later ticks. If it should ever become "one per day until each is mentioned",
|
||||
// that needs a queue rather than this counter.
|
||||
//
|
||||
// Cooldown gets its default here as well as in applyDefaults. That is
|
||||
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
||||
// and an unspaced announcer is not what those tests mean to exercise.
|
||||
//
|
||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||
if !t.proposalCfg.AnnounceProposals() {
|
||||
return false
|
||||
}
|
||||
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||
if cooldown <= 0 {
|
||||
cooldown = config.DefaultProposalCooldown
|
||||
}
|
||||
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||
if !loop.Gate(state, rule) {
|
||||
return false
|
||||
}
|
||||
body := pattern.PhraseRoutine(r)
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||
return false
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
return false // routing dropped it — /routines still has it.
|
||||
}
|
||||
t.lastProposalAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||
// digest entry that outlives one full day is describing a day that's already
|
||||
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
||||
// news. Bounding at one day also means a digest can never silently span a
|
||||
// weekend of quiet hours into an unbounded backlog.
|
||||
const digestExpiry = 24 * time.Hour
|
||||
|
||||
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
||||
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
||||
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
||||
// Anything beyond the cap is still marked drained (it did get its moment;
|
||||
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
||||
// count instead of being spoken in full.
|
||||
const maxDigestSpokenItems = 3
|
||||
|
||||
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
||||
// gate blocked for a genuine restraint reason and durably records the
|
||||
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
||||
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
||||
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
||||
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
||||
// makes repeat calls here harmless, but skipping the phrase call entirely
|
||||
// when a pending entry already exists avoids the LLM round-trip too).
|
||||
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
for _, tr := range trace.RuleTraces {
|
||||
if !tr.PredicateResult || tr.GateResult {
|
||||
continue // didn't want to fire, or wasn't suppressed
|
||||
}
|
||||
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
||||
continue
|
||||
}
|
||||
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
||||
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
||||
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
||||
continue
|
||||
}
|
||||
expires := now.Add(digestExpiry)
|
||||
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
||||
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
||||
} else if deduped {
|
||||
// same suppressed nudge already pending — nothing new to say.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
||||
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
||||
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
||||
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: expire stale digest entries: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
||||
}
|
||||
}
|
||||
|
||||
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
||||
// suppression reasons have actually cleared — quiet hours over, back from
|
||||
// away, out of the meeting. Draining while still suppressed would just be a
|
||||
// second way to nag through quiet hours; the bundle waits for the same "is
|
||||
// it allowed right now" condition a live nudge already waits for.
|
||||
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
||||
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
||||
return
|
||||
}
|
||||
entries, err := t.store.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: pending digest entries: %v", err)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
spoken := entries
|
||||
extra := 0
|
||||
if len(spoken) > maxDigestSpokenItems {
|
||||
spoken = entries[:maxDigestSpokenItems]
|
||||
extra = len(entries) - maxDigestSpokenItems
|
||||
}
|
||||
var b strings.Builder
|
||||
maxSev := 0
|
||||
for i, e := range spoken {
|
||||
if i > 0 {
|
||||
b.WriteString(" · ")
|
||||
}
|
||||
b.WriteString(e.Body)
|
||||
if e.Severity > maxSev {
|
||||
maxSev = e.Severity
|
||||
}
|
||||
}
|
||||
if extra > 0 {
|
||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||
}
|
||||
body := b.String()
|
||||
// The adjective declines with the noun, so the count picks the whole
|
||||
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
|
||||
// отложенных уведомлений.
|
||||
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
|
||||
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||
Severity: loop.Severity(maxSev),
|
||||
State: state,
|
||||
}
|
||||
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
||||
t.cachePhrase(pn)
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch digest bundle: %v", err)
|
||||
return // leave entries pending; retried next tick
|
||||
}
|
||||
ids := make([]int64, len(entries))
|
||||
for i, e := range entries {
|
||||
ids[i] = e.ID
|
||||
}
|
||||
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
||||
log.Printf("tick: drain digest entries: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||
// in config.Load, so this is a pure field copy.
|
||||
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
||||
if len(rc) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]routine.Routine, len(rc))
|
||||
for i, r := range rc {
|
||||
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
||||
// last fire. Each is delivered as a nudge through the normal routing table
|
||||
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
||||
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
||||
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
||||
// already advanced the last-fire time so a transient failure drops that fire
|
||||
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
||||
// no repeat-til-ack).
|
||||
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{
|
||||
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
||||
Severity: loop.Severity(r.Severity),
|
||||
State: state,
|
||||
},
|
||||
Body: r.Body,
|
||||
Summary: r.Body,
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireAcceptedRoutines nudges about the routines the user accepted, once per
|
||||
// interval (Vikunja #366). Accepting used to create a single reminder, so a
|
||||
// non-weekly routine fired once and went quiet forever; the schedule lives in
|
||||
// the proposed_routines row now and the loop re-reads it every tick.
|
||||
//
|
||||
// A routine is a care-class nudge and goes through the restraint gate like any
|
||||
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
|
||||
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
|
||||
// goes out on the next tick that the gate allows — one nudge, held, not dropped
|
||||
// and not repeated.
|
||||
//
|
||||
// The body is literal text built from the detected action and object, not
|
||||
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
|
||||
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
rows, err := t.store.ListAcceptedRoutines(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: list accepted routines: %v", err)
|
||||
return
|
||||
}
|
||||
accepted := make([]routine.Accepted, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.AcceptedTs == nil {
|
||||
continue // accepted before the schedule column existed — no clock to start from.
|
||||
}
|
||||
accepted = append(accepted, routine.Accepted{
|
||||
ID: r.ID,
|
||||
Name: r.Action + " " + r.Object,
|
||||
IntervalDays: r.IntervalDays,
|
||||
Accepted: *r.AcceptedTs,
|
||||
LastFired: r.LastFiredTs,
|
||||
})
|
||||
}
|
||||
|
||||
for _, a := range routine.DueAccepted(accepted, now) {
|
||||
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
|
||||
if !loop.Gate(state, rule) {
|
||||
continue
|
||||
}
|
||||
body := "пора: " + a.Name
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
|
||||
continue
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
continue // routing dropped it — leave it due.
|
||||
}
|
||||
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
|
||||
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireMorningRoutines checks each configured checklist against today's facts
|
||||
// and dispatches a nag listing exactly what's still missing, at most once per
|
||||
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
||||
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
||||
// concern — pulling it into the shared gather path would leak that mapping
|
||||
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
||||
// operator text (item labels joined), not LLM-phrased, same rationale as
|
||||
// cron routines: deterministic, can't hallucinate a checklist item.
|
||||
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
if len(t.morningRoutines) == 0 {
|
||||
return
|
||||
}
|
||||
facts := t.gatherMorningFacts(ctx)
|
||||
|
||||
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||
body := morningNudgeBody(cand)
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{
|
||||
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||
Severity: loop.Severity(cand.Routine.Severity),
|
||||
State: state,
|
||||
},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// morningNudgeBody words the one message a routine gets per day. Required
|
||||
// items are what she says was not done; optional ones follow, worded as
|
||||
// something he could still do rather than something he owes (Vikunja #473).
|
||||
// Operator text, not phrased by the model, for the same reason it always was:
|
||||
// a checklist item must not be invented.
|
||||
func morningNudgeBody(cand morning.Candidate) string {
|
||||
labels := func(items []morning.Item) string {
|
||||
out := make([]string, len(items))
|
||||
for i, it := range items {
|
||||
out[i] = it.Label
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
|
||||
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
|
||||
body += fmt.Sprintf(". если будет время — %s", labels(opt))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
||||
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||
// decision) and morningStatus (read-only query) so the two paths can never
|
||||
// disagree about what evidence exists.
|
||||
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
||||
keys := make(map[string]struct{})
|
||||
for _, r := range t.morningRoutines {
|
||||
for _, it := range r.Items {
|
||||
keys[it.FactKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
facts := make(map[string]store.Fact, len(keys))
|
||||
for k := range keys {
|
||||
f, err := t.store.LatestFact(ctx, k)
|
||||
if err == nil {
|
||||
facts[k] = f
|
||||
continue
|
||||
}
|
||||
if err != store.ErrNoFact {
|
||||
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
// morningStatus is the read-only "what's missing" query the web UI (and
|
||||
// eventually a voice query) calls. Pure recompute over the current facts —
|
||||
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
||||
// "state right now," not "should we nag."
|
||||
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
||||
if len(t.morningRoutines) == 0 {
|
||||
return nil
|
||||
}
|
||||
facts := t.gatherMorningFacts(ctx)
|
||||
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
||||
for _, r := range t.morningRoutines {
|
||||
st := morning.Evaluate(r, facts, now)
|
||||
done := make(map[string]bool, len(st.Completed))
|
||||
for _, it := range st.Completed {
|
||||
done[it.Key] = true
|
||||
}
|
||||
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
||||
for i, it := range r.Items {
|
||||
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
||||
}
|
||||
out = append(out, ipc.MorningRoutineStatus{
|
||||
Name: r.Name,
|
||||
Active: st.Active,
|
||||
WindowStart: r.WindowStart,
|
||||
WindowEnd: r.WindowEnd,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||
//
|
||||
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||
// unprompted delivery in maven stays with the morning nudge and the
|
||||
// dispatcher's policy.
|
||||
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
y, m, d := now.Date()
|
||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
var events []morning.PlanEntry
|
||||
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: calendar events: %v", err)
|
||||
}
|
||||
for _, f := range facts {
|
||||
events = append(events, morning.PlanEntry{
|
||||
At: f.Ts,
|
||||
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||
// fact value carries would say it twice.
|
||||
Text: calendar.FactSummary(f.Value),
|
||||
Kind: morning.PlanEvent,
|
||||
// Provenance below a calendar read (an ambient relay, #126) is
|
||||
// hedged rather than recited as fact.
|
||||
Uncertain: f.Confidence < 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
var reminders []morning.PlanEntry
|
||||
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||
}
|
||||
for _, r := range rems {
|
||||
if r.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
fire := r.NextFireTs
|
||||
if fire.IsZero() {
|
||||
fire = r.FireTs
|
||||
}
|
||||
reminders = append(reminders, morning.PlanEntry{
|
||||
At: fire,
|
||||
Text: r.Text(),
|
||||
Kind: morning.PlanReminder,
|
||||
})
|
||||
}
|
||||
|
||||
var checklistFacts map[string]store.Fact
|
||||
if len(t.morningRoutines) > 0 {
|
||||
checklistFacts = t.gatherMorningFacts(ctx)
|
||||
}
|
||||
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||
|
||||
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||
for i, it := range plan.Items {
|
||||
out.Items[i] = ipc.DayPlanItem{
|
||||
At: it.At,
|
||||
Text: it.Text,
|
||||
Kind: string(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||
// rule:
|
||||
@@ -1002,101 +385,3 @@ func (t *tickLoop) trace() *loop.TickTrace {
|
||||
defer t.mu.Unlock()
|
||||
return t.lastTrace
|
||||
}
|
||||
|
||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||
// daemon's in-memory tick trace cache.
|
||||
type daemonAPI struct {
|
||||
ipc.CoreAPI
|
||||
getTrace func() *loop.TickTrace
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
}
|
||||
|
||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||
// look the same to a reader on purpose, because neither is a fault and the
|
||||
// page renders both as an empty table.
|
||||
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||
if d.getEvents == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
if d.chatFn == nil {
|
||||
return "", errors.New("mavend: chat not available")
|
||||
}
|
||||
return d.chatFn(ctx, conversation, text), nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||
// default state and the web surface renders it as such.
|
||||
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
if d.getMCPServers == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getMCPServers(), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
trace := d.getTrace()
|
||||
if trace == nil {
|
||||
return ipc.TickTrace{}, nil
|
||||
}
|
||||
return toIPCTickTrace(*trace), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
if d.getMorningStatus == nil {
|
||||
return nil, errors.New("mavend: morning status not available")
|
||||
}
|
||||
return d.getMorningStatus(ctx), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||
if d.getDayPlan == nil {
|
||||
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||
}
|
||||
return d.getDayPlan(ctx), nil
|
||||
}
|
||||
|
||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||
for i, r := range t.RuleTraces {
|
||||
rules[i] = toIPCRuleTrace(r)
|
||||
}
|
||||
return ipc.TickTrace{
|
||||
Now: t.Now,
|
||||
Winner: t.Winner,
|
||||
Rules: rules,
|
||||
}
|
||||
}
|
||||
|
||||
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
|
||||
return ipc.RuleTrace{
|
||||
RuleName: r.RuleName,
|
||||
Severity: int(r.Severity),
|
||||
PredicateResult: r.PredicateResult,
|
||||
GateResult: r.GateResult,
|
||||
GateBlockedBy: r.GateBlockedBy,
|
||||
GateDetail: toIPCGateDetail(r.GateDetail),
|
||||
WasSelected: r.WasSelected,
|
||||
LostTo: r.LostTo,
|
||||
}
|
||||
}
|
||||
|
||||
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
|
||||
return ipc.GateDetail{
|
||||
SnoozeUntil: d.SnoozeUntil,
|
||||
CooldownUntil: d.CooldownUntil,
|
||||
QuietHours: d.QuietHours,
|
||||
CalendarBusy: d.CalendarBusy,
|
||||
Presence: d.Presence,
|
||||
InertKeysMissing: d.InertKeysMissing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// mavend/tick_api.go — the daemonAPI read surface over the tick loop.
|
||||
//
|
||||
// Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon
|
||||
// for, and the loop-to-ipc conversions those answers need.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||
// daemon's in-memory tick trace cache.
|
||||
type daemonAPI struct {
|
||||
ipc.CoreAPI
|
||||
getTrace func() *loop.TickTrace
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
// 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
|
||||
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||
// look the same to a reader on purpose, because neither is a fault and the
|
||||
// page renders both as an empty table.
|
||||
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||
if d.getEvents == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
if d.chatFn == nil {
|
||||
return "", errors.New("mavend: chat not available")
|
||||
}
|
||||
return d.chatFn(ctx, conversation, text), nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||
// default state and the web surface renders it as such.
|
||||
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
if d.getMCPServers == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getMCPServers(), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
trace := d.getTrace()
|
||||
if trace == nil {
|
||||
return ipc.TickTrace{}, nil
|
||||
}
|
||||
return toIPCTickTrace(*trace), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
if d.getMorningStatus == nil {
|
||||
return nil, errors.New("mavend: morning status not available")
|
||||
}
|
||||
return d.getMorningStatus(ctx), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||
if d.getDayPlan == nil {
|
||||
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||
}
|
||||
return d.getDayPlan(ctx), nil
|
||||
}
|
||||
|
||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||
for i, r := range t.RuleTraces {
|
||||
rules[i] = toIPCRuleTrace(r)
|
||||
}
|
||||
return ipc.TickTrace{
|
||||
Now: t.Now,
|
||||
Winner: t.Winner,
|
||||
Rules: rules,
|
||||
}
|
||||
}
|
||||
|
||||
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
|
||||
return ipc.RuleTrace{
|
||||
RuleName: r.RuleName,
|
||||
Severity: int(r.Severity),
|
||||
PredicateResult: r.PredicateResult,
|
||||
GateResult: r.GateResult,
|
||||
GateBlockedBy: r.GateBlockedBy,
|
||||
GateDetail: toIPCGateDetail(r.GateDetail),
|
||||
WasSelected: r.WasSelected,
|
||||
LostTo: r.LostTo,
|
||||
}
|
||||
}
|
||||
|
||||
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
|
||||
return ipc.GateDetail{
|
||||
SnoozeUntil: d.SnoozeUntil,
|
||||
CooldownUntil: d.CooldownUntil,
|
||||
QuietHours: d.QuietHours,
|
||||
CalendarBusy: d.CalendarBusy,
|
||||
Presence: d.Presence,
|
||||
InertKeysMissing: d.InertKeysMissing,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// mavend/tick_digest.go — the digest queue.
|
||||
//
|
||||
// Split out of tick.go, move-only (Vikunja #422). A nudge below the severity
|
||||
// ceiling is held here instead of spoken, flushed as one batch on the window,
|
||||
// and drained by hand when he asks. Everything about batching lives here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/say"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// shouldQueue — true when digest is enabled and the candidate's severity is
|
||||
// at or below the configured ceiling.
|
||||
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
|
||||
return t.digestCfg != nil && t.digestCfg.Enabled &&
|
||||
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
|
||||
}
|
||||
|
||||
// queueNudge — phrases the candidate and appends it to the digest queue.
|
||||
// Deduplicates by rule name: if the same rule is already queued, this is a
|
||||
// no-op (the first fire within the window is the one that counts).
|
||||
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
|
||||
for _, q := range t.digestQ {
|
||||
if q.Rule == cand.Rule.Name {
|
||||
return // already queued
|
||||
}
|
||||
}
|
||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||
return
|
||||
}
|
||||
t.digestQ = append(t.digestQ, QueuedNudge{
|
||||
Rule: cand.Rule.Name,
|
||||
Severity: int(cand.Severity),
|
||||
Body: pn.Body,
|
||||
Key: cand.Rule.Name,
|
||||
QueuedAt: now,
|
||||
})
|
||||
t.cachePhrase(pn)
|
||||
}
|
||||
|
||||
// maybeFlush — flushes the digest queue if the window has elapsed since the
|
||||
// first item or the queue reached MaxItems.
|
||||
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
|
||||
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
|
||||
return
|
||||
}
|
||||
first := t.digestQ[0]
|
||||
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
|
||||
len(t.digestQ) >= t.digestCfg.MaxItems {
|
||||
t.flushDigest(ctx, now, state)
|
||||
}
|
||||
}
|
||||
|
||||
// flushDigest — concatenates queued nudge bodies into a single digest
|
||||
// notification and dispatches it. Clears the queue after a successful send.
|
||||
// The digest uses the max severity among queued items for routing.
|
||||
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
|
||||
if len(t.digestQ) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
maxSev := 0
|
||||
for i, q := range t.digestQ {
|
||||
if i > 0 {
|
||||
b.WriteString(" · ")
|
||||
}
|
||||
b.WriteString(q.Body)
|
||||
if q.Severity > maxSev {
|
||||
maxSev = q.Severity
|
||||
}
|
||||
}
|
||||
body := b.String()
|
||||
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{
|
||||
Name: "digest",
|
||||
Severity: loop.Severity(maxSev),
|
||||
},
|
||||
Severity: loop.Severity(maxSev),
|
||||
State: state,
|
||||
}
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: cand,
|
||||
Body: body,
|
||||
Summary: summary,
|
||||
}
|
||||
t.cachePhrase(pn)
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
// keep the queue — the next tick's maybeFlush re-attempts.
|
||||
log.Printf("tick: dispatch digest: %v", err)
|
||||
return
|
||||
}
|
||||
t.digestQ = nil
|
||||
}
|
||||
|
||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||
// digest entry that outlives one full day is describing a day that's already
|
||||
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
||||
// news. Bounding at one day also means a digest can never silently span a
|
||||
// weekend of quiet hours into an unbounded backlog.
|
||||
const digestExpiry = 24 * time.Hour
|
||||
|
||||
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
||||
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
||||
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
||||
// Anything beyond the cap is still marked drained (it did get its moment;
|
||||
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
||||
// count instead of being spoken in full.
|
||||
const maxDigestSpokenItems = 3
|
||||
|
||||
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
||||
// gate blocked for a genuine restraint reason and durably records the
|
||||
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
||||
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
||||
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
||||
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
||||
// makes repeat calls here harmless, but skipping the phrase call entirely
|
||||
// when a pending entry already exists avoids the LLM round-trip too).
|
||||
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
for _, tr := range trace.RuleTraces {
|
||||
if !tr.PredicateResult || tr.GateResult {
|
||||
continue // didn't want to fire, or wasn't suppressed
|
||||
}
|
||||
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
||||
continue
|
||||
}
|
||||
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
||||
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
||||
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
||||
continue
|
||||
}
|
||||
expires := now.Add(digestExpiry)
|
||||
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
||||
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
||||
} else if deduped {
|
||||
// same suppressed nudge already pending — nothing new to say.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
||||
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
||||
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
||||
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: expire stale digest entries: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
||||
}
|
||||
}
|
||||
|
||||
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
||||
// suppression reasons have actually cleared — quiet hours over, back from
|
||||
// away, out of the meeting. Draining while still suppressed would just be a
|
||||
// second way to nag through quiet hours; the bundle waits for the same "is
|
||||
// it allowed right now" condition a live nudge already waits for.
|
||||
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
||||
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
||||
return
|
||||
}
|
||||
entries, err := t.store.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: pending digest entries: %v", err)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
spoken := entries
|
||||
extra := 0
|
||||
if len(spoken) > maxDigestSpokenItems {
|
||||
spoken = entries[:maxDigestSpokenItems]
|
||||
extra = len(entries) - maxDigestSpokenItems
|
||||
}
|
||||
var b strings.Builder
|
||||
maxSev := 0
|
||||
for i, e := range spoken {
|
||||
if i > 0 {
|
||||
b.WriteString(" · ")
|
||||
}
|
||||
b.WriteString(e.Body)
|
||||
if e.Severity > maxSev {
|
||||
maxSev = e.Severity
|
||||
}
|
||||
}
|
||||
if extra > 0 {
|
||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||
}
|
||||
body := b.String()
|
||||
// The adjective declines with the noun, so the count picks the whole
|
||||
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
|
||||
// отложенных уведомлений.
|
||||
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
|
||||
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||
Severity: loop.Severity(maxSev),
|
||||
State: state,
|
||||
}
|
||||
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
||||
t.cachePhrase(pn)
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch digest bundle: %v", err)
|
||||
return // leave entries pending; retried next tick
|
||||
}
|
||||
ids := make([]int64, len(entries))
|
||||
for i, e := range entries {
|
||||
ids[i] = e.ID
|
||||
}
|
||||
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
||||
log.Printf("tick: drain digest entries: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// mavend/tick_morning.go — the morning checklist and the day plan.
|
||||
//
|
||||
// Split out of tick.go, move-only (Vikunja #422). One window per configured
|
||||
// routine, nudging once at the end for what is still open, plus the read
|
||||
// surfaces /morning renders.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// fireMorningRoutines checks each configured checklist against today's facts
|
||||
// and dispatches a nag listing exactly what's still missing, at most once per
|
||||
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
||||
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
||||
// concern — pulling it into the shared gather path would leak that mapping
|
||||
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
||||
// operator text (item labels joined), not LLM-phrased, same rationale as
|
||||
// cron routines: deterministic, can't hallucinate a checklist item.
|
||||
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
if len(t.morningRoutines) == 0 {
|
||||
return
|
||||
}
|
||||
facts := t.gatherMorningFacts(ctx)
|
||||
|
||||
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||
body := morningNudgeBody(cand)
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{
|
||||
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||
Severity: loop.Severity(cand.Routine.Severity),
|
||||
State: state,
|
||||
},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// morningNudgeBody words the one message a routine gets per day. Required
|
||||
// items are what she says was not done; optional ones follow, worded as
|
||||
// something he could still do rather than something he owes (Vikunja #473).
|
||||
// Operator text, not phrased by the model, for the same reason it always was:
|
||||
// a checklist item must not be invented.
|
||||
func morningNudgeBody(cand morning.Candidate) string {
|
||||
labels := func(items []morning.Item) string {
|
||||
out := make([]string, len(items))
|
||||
for i, it := range items {
|
||||
out[i] = it.Label
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
|
||||
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
|
||||
body += fmt.Sprintf(". если будет время — %s", labels(opt))
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
||||
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||
// decision) and morningStatus (read-only query) so the two paths can never
|
||||
// disagree about what evidence exists.
|
||||
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
||||
keys := make(map[string]struct{})
|
||||
for _, r := range t.morningRoutines {
|
||||
for _, it := range r.Items {
|
||||
keys[it.FactKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
facts := make(map[string]store.Fact, len(keys))
|
||||
for k := range keys {
|
||||
f, err := t.store.LatestFact(ctx, k)
|
||||
if err == nil {
|
||||
facts[k] = f
|
||||
continue
|
||||
}
|
||||
if err != store.ErrNoFact {
|
||||
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
// morningStatus is the read-only "what's missing" query the web UI (and
|
||||
// eventually a voice query) calls. Pure recompute over the current facts —
|
||||
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
||||
// "state right now," not "should we nag."
|
||||
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
||||
if len(t.morningRoutines) == 0 {
|
||||
return nil
|
||||
}
|
||||
facts := t.gatherMorningFacts(ctx)
|
||||
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
||||
for _, r := range t.morningRoutines {
|
||||
st := morning.Evaluate(r, facts, now)
|
||||
done := make(map[string]bool, len(st.Completed))
|
||||
for _, it := range st.Completed {
|
||||
done[it.Key] = true
|
||||
}
|
||||
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
||||
for i, it := range r.Items {
|
||||
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
||||
}
|
||||
out = append(out, ipc.MorningRoutineStatus{
|
||||
Name: r.Name,
|
||||
Active: st.Active,
|
||||
WindowStart: r.WindowStart,
|
||||
WindowEnd: r.WindowEnd,
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||
//
|
||||
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||
// unprompted delivery in maven stays with the morning nudge and the
|
||||
// dispatcher's policy.
|
||||
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
y, m, d := now.Date()
|
||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
var events []morning.PlanEntry
|
||||
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: calendar events: %v", err)
|
||||
}
|
||||
for _, f := range facts {
|
||||
events = append(events, morning.PlanEntry{
|
||||
At: f.Ts,
|
||||
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||
// fact value carries would say it twice.
|
||||
Text: calendar.FactSummary(f.Value),
|
||||
Kind: morning.PlanEvent,
|
||||
// Provenance below a calendar read (an ambient relay, #126) is
|
||||
// hedged rather than recited as fact.
|
||||
Uncertain: f.Confidence < 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
var reminders []morning.PlanEntry
|
||||
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||
}
|
||||
for _, r := range rems {
|
||||
if r.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
fire := r.NextFireTs
|
||||
if fire.IsZero() {
|
||||
fire = r.FireTs
|
||||
}
|
||||
reminders = append(reminders, morning.PlanEntry{
|
||||
At: fire,
|
||||
Text: r.Text(),
|
||||
Kind: morning.PlanReminder,
|
||||
})
|
||||
}
|
||||
|
||||
var checklistFacts map[string]store.Fact
|
||||
if len(t.morningRoutines) > 0 {
|
||||
checklistFacts = t.gatherMorningFacts(ctx)
|
||||
}
|
||||
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||
|
||||
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||
for i, it := range plan.Items {
|
||||
out.Items[i] = ipc.DayPlanItem{
|
||||
At: it.At,
|
||||
Text: it.Text,
|
||||
Kind: string(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// mavend/tick_routines.go — pattern detection and the routines that fire.
|
||||
//
|
||||
// Split out of tick.go, move-only (Vikunja #422). Configured routines, the
|
||||
// routines he accepted on /routines, and the tick-side detector that proposes
|
||||
// new ones.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
)
|
||||
|
||||
// detectPatterns runs the pattern detector proactively over every
|
||||
// action+object pair that has ever produced an event, independent of
|
||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||
// own schedule reading accumulated history, not only as a side effect of a
|
||||
// live voice turn.
|
||||
//
|
||||
// Idempotence and noise are handled by the store, not here — this function
|
||||
// is safe to call every tick:
|
||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||
// already has a row — in ANY status — produces no second row and no log
|
||||
// spam beyond the one line at genuine creation.
|
||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||
// status in place; the row is never deleted. So the same Lookup check
|
||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||
// calling the same shared path the voice route already used.
|
||||
//
|
||||
// By default this only creates a row for the /routines page to show: it does
|
||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||
// for the restraints that apply even then. A proposal only starts producing
|
||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: distinct event pairs: %v", err)
|
||||
return
|
||||
}
|
||||
announced := false
|
||||
for _, p := range pairs {
|
||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||
continue
|
||||
}
|
||||
if r == nil {
|
||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||
}
|
||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
// One announcement per tick at most, whatever the scan turned up. The
|
||||
// rest are on /routines; they are not lost, they are just not shouted.
|
||||
// Nor are they queued: the row now exists, so no later tick re-detects
|
||||
// them and they are never announced. See announceProposal.
|
||||
if announced {
|
||||
continue
|
||||
}
|
||||
announced = t.announceProposal(ctx, r, now, state)
|
||||
}
|
||||
}
|
||||
|
||||
// announceProposal offers a freshly inferred routine through the ordinary
|
||||
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||
// something was actually sent.
|
||||
//
|
||||
// Everything here is restraint. The feature is off unless configured; when on
|
||||
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||
// real send, but the proposal row already exists, so the next tick will not
|
||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||
// he reads it on /routines instead, which is the whole point of the page.
|
||||
//
|
||||
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
||||
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
||||
// that first proposes it. Combined with one announcement per tick, the first
|
||||
// tick over a populated history announces one pattern and permanently silences
|
||||
// every other pattern found in the same pass. That is the intent, not an
|
||||
// oversight — an inferred routine is not worth a second attempt at his
|
||||
// attention, and /routines lists all of them. So the cooldown does not drain a
|
||||
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
||||
// later ticks. If it should ever become "one per day until each is mentioned",
|
||||
// that needs a queue rather than this counter.
|
||||
//
|
||||
// Cooldown gets its default here as well as in applyDefaults. That is
|
||||
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
||||
// and an unspaced announcer is not what those tests mean to exercise.
|
||||
//
|
||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||
if !t.proposalCfg.AnnounceProposals() {
|
||||
return false
|
||||
}
|
||||
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||
if cooldown <= 0 {
|
||||
cooldown = config.DefaultProposalCooldown
|
||||
}
|
||||
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||
if !loop.Gate(state, rule) {
|
||||
return false
|
||||
}
|
||||
body := pattern.PhraseRoutine(r)
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||
return false
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
return false // routing dropped it — /routines still has it.
|
||||
}
|
||||
t.lastProposalAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||
// in config.Load, so this is a pure field copy.
|
||||
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
||||
if len(rc) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]routine.Routine, len(rc))
|
||||
for i, r := range rc {
|
||||
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
||||
// last fire. Each is delivered as a nudge through the normal routing table
|
||||
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
||||
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
||||
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
||||
// already advanced the last-fire time so a transient failure drops that fire
|
||||
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
||||
// no repeat-til-ack).
|
||||
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{
|
||||
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
||||
Severity: loop.Severity(r.Severity),
|
||||
State: state,
|
||||
},
|
||||
Body: r.Body,
|
||||
Summary: r.Body,
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fireAcceptedRoutines nudges about the routines the user accepted, once per
|
||||
// interval (Vikunja #366). Accepting used to create a single reminder, so a
|
||||
// non-weekly routine fired once and went quiet forever; the schedule lives in
|
||||
// the proposed_routines row now and the loop re-reads it every tick.
|
||||
//
|
||||
// A routine is a care-class nudge and goes through the restraint gate like any
|
||||
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
|
||||
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
|
||||
// goes out on the next tick that the gate allows — one nudge, held, not dropped
|
||||
// and not repeated.
|
||||
//
|
||||
// The body is literal text built from the detected action and object, not
|
||||
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
|
||||
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||
rows, err := t.store.ListAcceptedRoutines(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: list accepted routines: %v", err)
|
||||
return
|
||||
}
|
||||
accepted := make([]routine.Accepted, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.AcceptedTs == nil {
|
||||
continue // accepted before the schedule column existed — no clock to start from.
|
||||
}
|
||||
accepted = append(accepted, routine.Accepted{
|
||||
ID: r.ID,
|
||||
Name: r.Action + " " + r.Object,
|
||||
IntervalDays: r.IntervalDays,
|
||||
Accepted: *r.AcceptedTs,
|
||||
LastFired: r.LastFiredTs,
|
||||
})
|
||||
}
|
||||
|
||||
for _, a := range routine.DueAccepted(accepted, now) {
|
||||
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
|
||||
if !loop.Gate(state, rule) {
|
||||
continue
|
||||
}
|
||||
body := "пора: " + a.Name
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||
Body: body,
|
||||
Summary: body,
|
||||
}
|
||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
|
||||
continue
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
continue // routing dropped it — leave it due.
|
||||
}
|
||||
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
|
||||
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,7 +584,7 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
markPresent(t, st, ctx, now)
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
|
||||
t.Fatalf("seed service_down: %v", err)
|
||||
}
|
||||
sink := &fakeSink{}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Which subject is this question about — the weather, the house, the LAN, what
|
||||
// needs looking at, or none of them. Third of the three mechanisms replacing hand-written Russian
|
||||
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
|
||||
// sets that can be finished and internal/morph answers the grammar questions;
|
||||
// this is for the sets that can never be finished, because "is this about the
|
||||
// house" is a question about meaning and no word list closes it.
|
||||
//
|
||||
// The recognisers this replaces were each built the same way: a stem list,
|
||||
// an ask test, a device-noun list, and a bail-out list for the neighbouring
|
||||
// topic. Every one of their own comments admits the shape. isHomeQuery excluded
|
||||
// "погод", "на улице" and "прогноз" by hand because "какая температура на улице"
|
||||
// and "какая температура в доме" share their only content word. isNetworkQuery
|
||||
// matched "сети" as a whole token because the substring lives inside "посетил",
|
||||
// so "сколько машин я посетил" read as a request to scan the LAN. Those are not
|
||||
// bugs in the lists, they are the lists being asked to do semantics.
|
||||
//
|
||||
// So the seeds decide, the same way the personal boundary does
|
||||
// (personalboundary.go), against the same embedder and the same query vector the
|
||||
// turn already carries. One difference in the gate, and it is deliberate. The
|
||||
// boundary claims on the sign of the difference, because there a false claim
|
||||
// costs one honest "не знаю". Here a false claim runs a network scan, or names a
|
||||
// capability as off on a box where it is simply not the subject — so a topic has
|
||||
// to win by a margin, and the losing side of a thin call falls through to the
|
||||
// next query source, which is what the narrow regexes were achieving.
|
||||
//
|
||||
// A fourth subject joined on the same day: isAttentionQuery, "что требует
|
||||
// внимания", which is Praxis's operational state and reached the web search
|
||||
// before the source existed (Vikunja #475).
|
||||
//
|
||||
// The regexes stay as the offline floor, unchanged, for a handler with no
|
||||
// embedder or a turn whose vector never got computed. They are allowed to remain
|
||||
// narrow now precisely because they are no longer the only answer.
|
||||
|
||||
// topicLabel — the subjects worth telling apart, plus the one that means none of
|
||||
// them. topicOther is a real class and not a threshold: a question needs
|
||||
// somewhere to lose TO, and "интернет не работает" losing to a set that contains
|
||||
// complaints is a better statement than it failing a number.
|
||||
type topicLabel string
|
||||
|
||||
const (
|
||||
topicWeather topicLabel = "weather"
|
||||
topicHome topicLabel = "home"
|
||||
topicNetwork topicLabel = "network"
|
||||
topicAttend topicLabel = "attention"
|
||||
topicOther topicLabel = "other"
|
||||
)
|
||||
|
||||
// topicMargin — how far a topic must clear the runner-up. Small, because the
|
||||
// margins between neighbouring topics are small: measured on held-out
|
||||
// utterances, a true weather question clears the home set by roughly 0.02 to
|
||||
// 0.09 and the nearest wrong call sits under 0.01. It exists at all for the
|
||||
// asymmetry named above — this gate spends a scan, so a coin-flip falls
|
||||
// through rather than acts.
|
||||
const topicMargin = 0.01
|
||||
|
||||
// topicSeedSets — frozen scoring data, like personalSeeds. Editing one moves a
|
||||
// recogniser and has to be re-measured against TestONNXTopics, not eyeballed.
|
||||
//
|
||||
// Each set covers the phrasings its old regex covered, INCLUDING the ones it
|
||||
// needed a bail-out list for: the weather set carries "какая температура на
|
||||
// улице" and the home set "какая температура в доме", so the pair that forced
|
||||
// isHomeQuery to exclude weather words by hand is now just two seeds sitting on
|
||||
// their own sides.
|
||||
var topicSeedSets = map[topicLabel][]string{
|
||||
topicWeather: {
|
||||
"какая сегодня погода",
|
||||
"какая температура на улице",
|
||||
"будет дождь сегодня",
|
||||
"на улице холодно",
|
||||
"прогноз погоды на завтра",
|
||||
"сколько градусов сейчас",
|
||||
"what is the weather like",
|
||||
"is it going to rain today",
|
||||
},
|
||||
topicHome: {
|
||||
"что включено в доме",
|
||||
"какая температура в доме",
|
||||
"свет в квартире горит",
|
||||
"сколько лампочек включено дома",
|
||||
"что у меня дома с датчиками",
|
||||
"умный дом что сейчас работает",
|
||||
"розетки в доме включены",
|
||||
"what is on in the house",
|
||||
},
|
||||
topicNetwork: {
|
||||
"какие устройства в сети",
|
||||
"кто в сети сейчас",
|
||||
"просканируй локальную сеть",
|
||||
"сколько машин в сетке",
|
||||
"покажи хосты в сети",
|
||||
"какие адреса заняты в локальной сети",
|
||||
"кто подключён к вайфаю",
|
||||
"what devices are on the network",
|
||||
},
|
||||
topicAttend: {
|
||||
"что требует внимания",
|
||||
"что не так сейчас",
|
||||
"на что мне посмотреть",
|
||||
"что важное я пропустил",
|
||||
"есть что-то срочное",
|
||||
"что там висит нерешённое",
|
||||
// With a thing named. Every other seed asks in the abstract, and the
|
||||
// attention question he actually asks names his services or his
|
||||
// projects.
|
||||
"что не так с сервисами",
|
||||
"что там с моими проектами",
|
||||
"what needs attention",
|
||||
"what needs looking at right now",
|
||||
},
|
||||
topicOther: {
|
||||
// Complaints, which are not requests to scan or to read the house.
|
||||
// isNetworkQuery's comment names this one: a scan she runs unasked is
|
||||
// the noisy behaviour the bounds exist to prevent.
|
||||
"интернет не работает",
|
||||
"вайфай тормозит",
|
||||
"свет погас",
|
||||
// Statements. "я дома" was the reason isHomeQuery needed an ask test.
|
||||
"я дома",
|
||||
"я уже дома",
|
||||
// The collision that made "сети" a whole-token match.
|
||||
"сколько машин я посетил",
|
||||
"сколько домов мы посмотрели",
|
||||
// Ordinary questions, his and the world's, so a topic has something
|
||||
// real to lose to rather than an arbitrary floor.
|
||||
"почему небо синее",
|
||||
"какая столица франции",
|
||||
"что я говорил про бэкапы",
|
||||
"что у меня сегодня по календарю",
|
||||
"напомни мне позвонить маме",
|
||||
// An attention question is about the state of his things; this is not.
|
||||
"что ты умеешь",
|
||||
"what did i say about backups",
|
||||
},
|
||||
}
|
||||
|
||||
// topicIndex holds the embedded seeds. Zero value is usable and means "not
|
||||
// loaded yet"; a handler built without an embedder never loads and every caller
|
||||
// uses its own floor instead.
|
||||
type topicIndex struct {
|
||||
once sync.Once
|
||||
vecs map[topicLabel][][]float32
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// load embeds every set once per process, on the QUERY side — a question
|
||||
// compared with a question, for the reason personalBoundary.load gives.
|
||||
func (x *topicIndex) load(ctx context.Context, emb router.Embedder) {
|
||||
x.once.Do(func() {
|
||||
if emb == nil {
|
||||
return
|
||||
}
|
||||
vecs := make(map[topicLabel][][]float32, len(topicSeedSets))
|
||||
for label, seeds := range topicSeedSets {
|
||||
out := make([][]float32, 0, len(seeds))
|
||||
for _, s := range seeds {
|
||||
v, err := router.EmbedQuery(ctx, emb, s)
|
||||
if err != nil {
|
||||
log.Printf("voice: topic seeds unavailable (%v); falling back to keyword matching", err)
|
||||
return
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
vecs[label] = out
|
||||
}
|
||||
x.vecs, x.loaded = vecs, true
|
||||
})
|
||||
}
|
||||
|
||||
// best returns the nearest label, how far it cleared the runner-up, and whether
|
||||
// the seeds answered at all. ok is false when they are not loaded, which is the
|
||||
// caller's signal to use its floor.
|
||||
func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok bool) {
|
||||
if !x.loaded || len(vec) == 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
first, second := -1.0, -1.0
|
||||
for l, seeds := range x.vecs {
|
||||
top := -1.0
|
||||
for _, s := range seeds {
|
||||
if c := cosine(vec, s); c > top {
|
||||
top = c
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case top > first:
|
||||
label, first, second = l, top, first
|
||||
case top > second:
|
||||
second = top
|
||||
}
|
||||
}
|
||||
return label, first - second, true
|
||||
}
|
||||
|
||||
// turnIsAbout — the recogniser every topic source calls. The seeds decide when
|
||||
// the embedder is there, which is every deployed box; floor is the source's own
|
||||
// keyword test, which answers when they are not.
|
||||
//
|
||||
// A topic that wins WITHOUT the margin is handed to the floor rather than
|
||||
// claimed or dropped, and the near-miss is logged. That is the cascade shape
|
||||
// again: the better test leads, the offline one always answers, and a thin call
|
||||
// is exactly where the cheap high-precision test earns its place. Measured, the
|
||||
// one held-out case that lands there is "вайфай опять отвалился", which reads as
|
||||
// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is.
|
||||
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
|
||||
h.recall.topics.load(ctx, h.recall.embedder)
|
||||
label, margin, ok := h.recall.topics.best(t.vec)
|
||||
if !ok {
|
||||
return floor(t.dec.Utterance)
|
||||
}
|
||||
if label != want {
|
||||
return false
|
||||
}
|
||||
if margin < topicMargin {
|
||||
claimed := floor(t.dec.Utterance)
|
||||
log.Printf("voice: %q reads as %s by only %.4f; the keyword floor says %v", t.dec.Utterance, want, margin, claimed)
|
||||
return claimed
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestTopicFloorAnswersWithoutSeeds — a handler with no embedder never loads the
|
||||
// seeds, and every topic source has to keep working. This is the case that used
|
||||
// to be the only one, so a regression here is all four recognisers going silent
|
||||
// on a box with no embedder at all.
|
||||
func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
|
||||
h := &reactiveHandler{}
|
||||
for _, tc := range []struct {
|
||||
utterance string
|
||||
label topicLabel
|
||||
floor func(string) bool
|
||||
want bool
|
||||
}{
|
||||
{"какая сегодня погода", topicWeather, isWeatherQuery, true},
|
||||
{"что включено в доме?", topicHome, isHomeQuery, true},
|
||||
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
|
||||
{"что требует внимания?", topicAttend, isAttentionQuery, true},
|
||||
{"почему небо синее", topicWeather, isWeatherQuery, false},
|
||||
{"я дома", topicHome, isHomeQuery, false},
|
||||
{"интернет не работает", topicNetwork, isNetworkQuery, false},
|
||||
} {
|
||||
turn := &queryTurn{dec: router.Decision{Utterance: tc.utterance}}
|
||||
if got := h.turnIsAbout(context.Background(), turn, tc.label, tc.floor); got != tc.want {
|
||||
t.Errorf("turnIsAbout(%q, %s) = %v, want %v", tc.utterance, tc.label, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestONNXTopics — the number that matters, scored against the embedder homesrv
|
||||
// actually runs. Opt-in via MAVEN_ONNX_LIB, like TestONNXPersonalBoundary.
|
||||
//
|
||||
// Every case is held out: none of these strings is a seed. It asserts what the
|
||||
// gate does, not what the raw scorer says — a label under topicMargin is handed
|
||||
// to the keyword floor, and one case turns on exactly that.
|
||||
//
|
||||
// The first three rows are the collisions the old regexes needed hand-written
|
||||
// bail-outs for: the temperature pair that made isHomeQuery exclude weather
|
||||
// words, and the "посетил" substring that made isNetworkQuery match "сети" as a
|
||||
// whole token.
|
||||
func TestONNXTopics(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want topicLabel
|
||||
floor func(string) bool
|
||||
}{
|
||||
{"какая температура на улице?", topicWeather, isWeatherQuery},
|
||||
{"какая температура в доме?", topicHome, isHomeQuery},
|
||||
{"сколько машин я посетил?", topicOther, nil},
|
||||
{"сколько сейчас градусов", topicWeather, isWeatherQuery},
|
||||
{"дождь будет вечером?", topicWeather, isWeatherQuery},
|
||||
{"тепло сегодня на улице?", topicWeather, isWeatherQuery},
|
||||
{"свет на кухне включен?", topicHome, isHomeQuery},
|
||||
{"что сейчас включено дома", topicHome, isHomeQuery},
|
||||
{"датчики в квартире что показывают", topicHome, isHomeQuery},
|
||||
{"просканируй сеть", topicNetwork, isNetworkQuery},
|
||||
{"сколько устройств в локальной сети", topicNetwork, isNetworkQuery},
|
||||
{"кто сейчас в сетке", topicNetwork, isNetworkQuery},
|
||||
// The case the margin exists for. It reads as network by 0.0055, and
|
||||
// isNetworkQuery says no, so it stays the complaint it is — a scan she
|
||||
// runs unasked is the behaviour the bounds prevent.
|
||||
{"вайфай опять отвалился", topicOther, isNetworkQuery},
|
||||
{"я уже приехал домой", topicOther, nil},
|
||||
{"что я говорил про погоду в москве", topicOther, nil},
|
||||
{"напомни полить цветы", topicOther, nil},
|
||||
{"что требует моего внимания сейчас", topicAttend, isAttentionQuery},
|
||||
{"что не так с базой данных", topicAttend, isAttentionQuery},
|
||||
{"есть что-то срочное на сегодня", topicAttend, isAttentionQuery},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
ctx := context.Background()
|
||||
h.recall.topics.load(ctx, emb)
|
||||
if !h.recall.topics.loaded {
|
||||
t.Fatal("topic seeds did not load")
|
||||
}
|
||||
right := 0
|
||||
for _, tc := range cases {
|
||||
vec, err := router.EmbedQuery(ctx, emb, tc.utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", tc.utterance, err)
|
||||
}
|
||||
label, margin, ok := h.recall.topics.best(vec)
|
||||
if !ok {
|
||||
t.Fatalf("best(%q) not ok", tc.utterance)
|
||||
}
|
||||
// What the gate would do, which is the thing under test: a label that
|
||||
// does not clear the margin is handed to that source's keyword floor.
|
||||
got := label
|
||||
if margin < topicMargin {
|
||||
got = topicOther
|
||||
if tc.floor != nil && tc.floor(tc.utterance) {
|
||||
got = label
|
||||
}
|
||||
}
|
||||
if got == tc.want {
|
||||
right++
|
||||
} else {
|
||||
t.Errorf("%q: %s by %.4f, want %s", tc.utterance, label, margin, tc.want)
|
||||
}
|
||||
t.Logf(" %-40s -> %-8s margin %.4f", tc.utterance, label, margin)
|
||||
}
|
||||
t.Logf("topics: %d/%d", right, len(cases))
|
||||
}
|
||||
+37
-21
@@ -55,7 +55,7 @@ import (
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -72,14 +72,16 @@ import (
|
||||
// safe (the wired stt/tts/router/api all are); called from per-conn
|
||||
// goroutines on the voice.Server.
|
||||
type reactiveHandler struct {
|
||||
stt stt.Transcriber
|
||||
tts tts.Synthesizer
|
||||
router *router.Router
|
||||
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
||||
// boundary — the embedded seed sets behind the personal boundary
|
||||
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||
boundary personalBoundary
|
||||
stt stt.Transcriber
|
||||
tts tts.Synthesizer
|
||||
router *router.Router
|
||||
|
||||
// recall — the note-and-fact recall subsystem: the embedder, the vector
|
||||
// store it writes into, the personal boundary, and the two numbers that
|
||||
// gate an answer. Grouped rather than spread across the handler because a
|
||||
// handler that recalls needs all five and a handler that does not needs
|
||||
// none of them (Vikunja #433, docs/handler-wiring.md).
|
||||
recall recallWiring
|
||||
// api — the CoreAPI the handler reads and writes through. Wired with the
|
||||
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
|
||||
// upgradeAPI.
|
||||
@@ -123,18 +125,8 @@ type reactiveHandler struct {
|
||||
weatherProvider weather.Provider
|
||||
weatherLocation string // default location for weather queries
|
||||
|
||||
memStore memory.Store
|
||||
dataStore *store.Store // direct store access for event extraction + pattern detection
|
||||
|
||||
// queryMinScore — the note-recall confidence gate. Top cosine below this ⇒
|
||||
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not
|
||||
// load-bearing math (same posture as the presence thresholds). Set by
|
||||
// wireVoice from VoiceConfig; default 0.55.
|
||||
queryMinScore float64
|
||||
// queryMinMargin — the second half of that gate: how far the top hit must
|
||||
// beat the runner-up. 0 ⇒ margin off.
|
||||
queryMinMargin float64
|
||||
|
||||
// timeParser — used as a fallback for stage-0 reminder grammar matches
|
||||
// (where the extractor didn't run). Shared with the router's extractor.
|
||||
// The production dateparser will replace StubDateTimeParser here too.
|
||||
@@ -161,6 +153,7 @@ type reactiveHandler struct {
|
||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||||
mu sync.Mutex
|
||||
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
|
||||
pending *pendingAct
|
||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||||
@@ -299,6 +292,22 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4d. spoken correction — "нет, это была заметка" points at the previous
|
||||
// turn and names what it should have been (repair.go). Before routing,
|
||||
// like the confirm and clarify turns: routing the correction as a fresh
|
||||
// utterance files the correction itself instead of fixing anything.
|
||||
if reply, handled := h.resolveRepair(ctx, text); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
|
||||
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||
// and keeps routing.
|
||||
if reply, handled := h.resolveCandidate(ctx, text); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
||||
// previous turn instead (continuation.go): the intent is the part it is
|
||||
// missing, so no amount of routing recovers it, and the model's guess
|
||||
@@ -359,6 +368,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
}
|
||||
}
|
||||
|
||||
// Remember what this turn was routed as, so the next utterance can correct
|
||||
// it. Only turns she acts on: a clarify asked instead of acting, so there
|
||||
// is nothing yet to be wrong about.
|
||||
if !dec.Clarify {
|
||||
h.recordTurn(text, dec.Intent)
|
||||
}
|
||||
|
||||
// 8. action — execute the decision's intent. errors here surface as
|
||||
// short reply text (the user wants to know the action didn't land);
|
||||
// the round-trip stays alive.
|
||||
@@ -452,8 +468,8 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
||||
// this arm was fixed for, so say what she can do instead.
|
||||
return onlyNearDaysReply
|
||||
}
|
||||
dow := ruWeekdays[day.Weekday()]
|
||||
month := ruMonths[day.Month()-1]
|
||||
dow := lexicon.Weekday(int(day.Weekday()))
|
||||
month := lexicon.MonthGenitive(int(day.Month()))
|
||||
return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year())
|
||||
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
||||
return "присутствие пока не подключено к голосовому запросу."
|
||||
|
||||
+36
-22
@@ -262,19 +262,18 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
|
||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||
h := &reactiveHandler{
|
||||
stt: transcriber,
|
||||
tts: synthesizer,
|
||||
router: rtr,
|
||||
embedder: emb,
|
||||
api: coreAPI,
|
||||
tools: exec,
|
||||
matcher: matcher,
|
||||
replier: replier,
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
home: w.home,
|
||||
netscan: w.netscan,
|
||||
stt: transcriber,
|
||||
tts: synthesizer,
|
||||
router: rtr,
|
||||
api: coreAPI,
|
||||
tools: exec,
|
||||
matcher: matcher,
|
||||
replier: replier,
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
home: w.home,
|
||||
netscan: w.netscan,
|
||||
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||
// capability, and capabilities are off unless configured.
|
||||
crawler: onDemandCrawler(cfg),
|
||||
@@ -283,18 +282,21 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
search: wireSearch(cfg),
|
||||
// nil unless a `kiwix` block names a server. Same swap-aware client the
|
||||
// router and replier use, so the rewriter follows a model swap.
|
||||
kiwix: wireKiwix(cfg, llmClient),
|
||||
weatherProvider: weatherProvider,
|
||||
weatherLocation: weatherLocation,
|
||||
memStore: memStore,
|
||||
kiwix: wireKiwix(cfg, llmClient),
|
||||
weatherProvider: weatherProvider,
|
||||
weatherLocation: weatherLocation,
|
||||
recall: recallWiring{
|
||||
embedder: emb,
|
||||
memStore: memStore,
|
||||
minScore: cfg.Voice.QueryMinScore,
|
||||
minMargin: cfg.Voice.QueryMinMargin,
|
||||
},
|
||||
dataStore: dataStore,
|
||||
dialogueSessions: dialogueSessions,
|
||||
clarifyStore: clarifyStore,
|
||||
// 0 here (unset config) ⇒ the dialogue default.
|
||||
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||
queryMinScore: cfg.Voice.QueryMinScore,
|
||||
queryMinMargin: cfg.Voice.QueryMinMargin,
|
||||
timeParser: timeParser,
|
||||
ecosystem: eco,
|
||||
}
|
||||
@@ -382,9 +384,12 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||
// is an agenda question and must not.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
|
||||
// question first and a narrative request second (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||
// routed system and answered "пока не умею" (Vikunja #474).
|
||||
grammars = append(grammars, router.FeedQueryGrammar())
|
||||
// The list side of the same exposure: a phrasing with no possessive in it
|
||||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||
grammars = append(grammars, router.TaskListGrammar())
|
||||
grammars = append(grammars, router.ListGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||
@@ -392,6 +397,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||
// marker never collides with a clock or agenda question.
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||
// last overall because it matches on the first word alone: "расскажи про
|
||||
// X" is a world question the model called a fact (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
Classifier: cls,
|
||||
@@ -543,6 +552,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).
|
||||
//
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestExtractWeatherLocation — any place he names comes through, not just the
|
||||
// six that used to be in a table (Vikunja #421).
|
||||
@@ -31,3 +36,30 @@ func TestExtractWeatherLocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalendarStepsAsideForWeather — the defect (Vikunja #474). "какая сегодня
|
||||
// погода в Москве?" answered "на 02.08.2026 ничего нет.": the calendar matches
|
||||
// on a day word alone, and it sits above the weather source.
|
||||
func TestCalendarStepsAsideForWeather(t *testing.T) {
|
||||
h, api := contQueryHandler()
|
||||
for _, u := range []string{
|
||||
"какая сегодня погода в Москве?",
|
||||
"будет дождь завтра?",
|
||||
"сколько градусов сегодня?",
|
||||
} {
|
||||
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); ok {
|
||||
t.Errorf("the calendar claimed %q with %q", u, reply)
|
||||
}
|
||||
}
|
||||
if api.events != 0 {
|
||||
t.Errorf("CalendarEvents called %d times for weather questions, want 0", api.events)
|
||||
}
|
||||
// The agenda question it exists for still reaches it.
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что у меня сегодня?"},
|
||||
}); !ok {
|
||||
t.Fatal("the calendar stopped answering the agenda question")
|
||||
}
|
||||
}
|
||||
|
||||
+71
-21
@@ -140,6 +140,10 @@ type poller struct {
|
||||
wgIface string
|
||||
wgCmd string
|
||||
|
||||
// kumaSeen — monitor name → state as of the last poll, so a monitor that
|
||||
// disappears from the gauge can be marked unknown instead of staying down.
|
||||
kumaSeen map[string]string
|
||||
|
||||
// zen is nil unless a token file was configured — money tracking is a
|
||||
// capability, off by default like weather and telegram.
|
||||
zen *zenmoney.Client
|
||||
@@ -333,50 +337,96 @@ func maxSeverity(a netdataAlarms) string {
|
||||
return sev
|
||||
}
|
||||
|
||||
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
|
||||
// ---- kuma: monitor_status gauge → one fact per monitor ---------------------
|
||||
|
||||
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
||||
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
|
||||
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
|
||||
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
|
||||
// only needs "something is down".
|
||||
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
||||
// V is 1=up 0=down 2=pending 3=maintenance. We write one fact per monitor,
|
||||
// keyed `service_down:<monitor name>`, because the nudge has to say WHICH
|
||||
// service is down. The aggregate this used to write could not, which is why
|
||||
// the rule shipped disabled.
|
||||
var (
|
||||
kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
||||
kumaName = regexp.MustCompile(`monitor_name="([^"]*)"`)
|
||||
)
|
||||
|
||||
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
||||
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
down, seen := kumaAnyDown(body)
|
||||
if !seen {
|
||||
states := kumaMonitors(body)
|
||||
if len(states) == 0 {
|
||||
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
||||
}
|
||||
val := "up"
|
||||
if down {
|
||||
val = "down"
|
||||
var firstErr error
|
||||
for name, val := range states {
|
||||
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, val, now); err != nil && firstErr == nil {
|
||||
firstErr = err // one bad monitor must not blind the rest
|
||||
}
|
||||
}
|
||||
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
|
||||
// A monitor deleted in kuma stops appearing in the gauge, and its last fact
|
||||
// would otherwise read "down" forever. Mark it unknown, which no rule fires
|
||||
// on. The seen-set is in memory, so a restart forgets it — harmless, since
|
||||
// the next poll that still lacks the monitor says nothing new either.
|
||||
for name := range p.kumaSeen {
|
||||
if _, still := states[name]; !still {
|
||||
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, "unknown", now); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
p.kumaSeen = states
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
|
||||
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
|
||||
// lines matched at all (wrong endpoint or auth rejected before the body).
|
||||
func kumaAnyDown(body []byte) (down, seen bool) {
|
||||
// kumaSource — the provenance the loop rule requires. Written here, checked in
|
||||
// loop.ServiceDownRule; a poller under any other source cannot fire it.
|
||||
const kumaSource = "poll:uptimekuma"
|
||||
|
||||
// kumaFactKey — the fact key for one monitor. The suffix is the name he hears,
|
||||
// so it stays as kuma spells it rather than being slugged into something else.
|
||||
func kumaFactKey(name string) string { return "service_down:" + name }
|
||||
|
||||
// kumaMonitors parses kuma's Prometheus text into monitor name → state
|
||||
// ("up"/"down"/"pending"/"maintenance"). An empty map means no monitor_status
|
||||
// line matched at all (wrong endpoint, or auth rejected before the body).
|
||||
// A line with no monitor_name label is skipped: a fact nobody can name is
|
||||
// exactly the thing this replaced.
|
||||
func kumaMonitors(body []byte) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
nm := kumaName.FindStringSubmatch(m[1])
|
||||
if nm == nil || strings.TrimSpace(nm[1]) == "" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseFloat(m[2], 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v == 0 {
|
||||
down = true
|
||||
}
|
||||
out[strings.TrimSpace(nm[1])] = kumaState(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// kumaState — the gauge's four values. pending and maintenance are not "down":
|
||||
// a monitor paused in kuma should silence that monitor, not page him.
|
||||
func kumaState(v float64) string {
|
||||
switch v {
|
||||
case 0:
|
||||
return "down"
|
||||
case 1:
|
||||
return "up"
|
||||
case 2:
|
||||
return "pending"
|
||||
case 3:
|
||||
return "maintenance"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
return down, seen
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
+36
-12
@@ -35,22 +35,46 @@ func TestMaxSeverity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKumaAnyDown(t *testing.T) {
|
||||
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
|
||||
cases := []struct {
|
||||
body string
|
||||
down, seen bool
|
||||
name string
|
||||
body string
|
||||
want map[string]string
|
||||
}{
|
||||
{"", false, false},
|
||||
{`monitor_status{monitor_name="web"} 1`, false, true},
|
||||
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
|
||||
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
|
||||
{`# HELP monitor_status ...`, false, false},
|
||||
{"empty body", "", map[string]string{}},
|
||||
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
|
||||
{
|
||||
"one up one down",
|
||||
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
|
||||
map[string]string{"web": "up", "db": "down"},
|
||||
},
|
||||
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
|
||||
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
|
||||
{
|
||||
"other labels do not hide the name",
|
||||
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
|
||||
map[string]string{"ci": "down"},
|
||||
},
|
||||
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
down, seen := kumaAnyDown([]byte(c.body))
|
||||
if down != c.down || seen != c.seen {
|
||||
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
|
||||
}
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := kumaMonitors([]byte(c.body))
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
|
||||
}
|
||||
for k, v := range c.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKumaFactKeyCarriesTheName(t *testing.T) {
|
||||
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
|
||||
t.Errorf("kumaFactKey = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-1
@@ -23,6 +23,7 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
@@ -39,15 +40,27 @@ func run(args []string) error {
|
||||
model := flag.String("model", "", "path to piper onnx model file")
|
||||
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
|
||||
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
|
||||
lexiconPath := flag.String("lexicon", "", "path to the pronunciation dictionary (json, name to spelling)")
|
||||
|
||||
flag.CommandLine.Parse(args)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
defer stop()
|
||||
|
||||
// Read before the handler is built: a dictionary he asked for and that
|
||||
// cannot be read is a startup failure, not a warning. Saying names wrong
|
||||
// in silence is the thing it exists to stop.
|
||||
lex, err := tts.LoadLexicon(*lexiconPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lex.Size() > 0 {
|
||||
log.Printf("mavttsd: pronunciation dictionary: %d names from %s", lex.Size(), *lexiconPath)
|
||||
}
|
||||
|
||||
var s worker.Synthesizer
|
||||
if *piperBin != "" && *model != "" {
|
||||
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel)
|
||||
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel, lex)
|
||||
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
|
||||
} else {
|
||||
log.Printf("mavttsd: no piper/model specified, using stub handler")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
@@ -117,3 +118,18 @@ func abs(n int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// The dictionary that ships in deploy/ must parse and must be non-empty. It is
|
||||
// data, so nothing else would catch a trailing comma before the voice did.
|
||||
func TestShippedLexiconLoads(t *testing.T) {
|
||||
lex, err := tts.LoadLexicon(filepath.Join("..", "..", "deploy", "tts-lexicon.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("deploy/tts-lexicon.json: %v", err)
|
||||
}
|
||||
if lex.Size() < 10 {
|
||||
t.Errorf("shipped dictionary holds %d names, want the full list", lex.Size())
|
||||
}
|
||||
if got := lex.Apply("задача в Vikunja"); got == "задача в Vikunja" {
|
||||
t.Error("the shipped dictionary did not rewrite a name it lists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
@@ -18,15 +19,20 @@ type piperHandler struct {
|
||||
configPath string
|
||||
espeakData string
|
||||
tashkeelModel string
|
||||
// lexicon rewrites service ids and Latin names into the spelling the
|
||||
// Russian voice reads correctly (Vikunja #458). Nil-safe: an unconfigured
|
||||
// dictionary rewrites nothing.
|
||||
lexicon *tts.Lexicon
|
||||
}
|
||||
|
||||
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string) *piperHandler {
|
||||
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string, lexicon *tts.Lexicon) *piperHandler {
|
||||
return &piperHandler{
|
||||
piperPath: piperPath,
|
||||
modelPath: modelPath,
|
||||
configPath: modelPath + ".json",
|
||||
espeakData: espeakData,
|
||||
tashkeelModel: tashkeelModel,
|
||||
lexicon: lexicon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +72,14 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
|
||||
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(stdin, req.Text); err != nil {
|
||||
// The dictionary is applied here, at the last edge before the voice: every
|
||||
// caller's text passes through this one point, and nothing upstream has to
|
||||
// know how a name is spelled out loud.
|
||||
text := req.Text
|
||||
if h.lexicon != nil {
|
||||
text = h.lexicon.Apply(text)
|
||||
}
|
||||
if _, err := io.WriteString(stdin, text); err != nil {
|
||||
stdin.Close()
|
||||
stdout.Close()
|
||||
_ = cmd.Wait()
|
||||
|
||||
@@ -139,7 +139,10 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleAmbientAuth(t *testing.T) {
|
||||
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||
// "завтра" so the meeting is in the future in every zone: the clock in the
|
||||
// text is a local wall clock and posted_at is a Z instant, so a bare "10:00"
|
||||
// is already stale on a box east of UTC and stores nothing (Vikunja #482).
|
||||
body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||
|
||||
newReq := func(hdr, val string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||
@@ -245,3 +248,31 @@ func TestHandleAmbientBadInput(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and
|
||||
// the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup
|
||||
// used to be stored at 18:30, and the size of the error was the deploy's offset.
|
||||
func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) {
|
||||
// No zone juggling: the assertion is that the stored wall clock is the one
|
||||
// he read, whatever zone the box is in. That is false under the old code
|
||||
// on every box except a UTC one.
|
||||
core := &ambientCore{}
|
||||
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
|
||||
Package: "com.slack",
|
||||
Title: "Standup",
|
||||
Text: "созвон завтра в 14:30",
|
||||
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||
}
|
||||
if !strings.HasSuffix(resp.Key, "_Standup") {
|
||||
t.Errorf("key = %q, want a key naming the meeting", resp.Key)
|
||||
}
|
||||
if len(core.writeLog) != 1 {
|
||||
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
|
||||
}
|
||||
if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" {
|
||||
t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{{template "shellTop" "chat"}}
|
||||
<h1>Chat</h1>
|
||||
<section class=card>
|
||||
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
||||
<div class="scroll chat-scroll" id=chatHistory>
|
||||
{{range .Messages}}
|
||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
||||
{{else}}
|
||||
<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||
<div>start a conversation</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<form method=post action=/api/chat class=chat-form>
|
||||
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
||||
<button class=btn>send</button>
|
||||
</form>
|
||||
</section>
|
||||
<script>
|
||||
var ch = document.getElementById('chatHistory');
|
||||
if(ch) ch.scrollTop = ch.scrollHeight;
|
||||
</script>
|
||||
{{template "shellBottom"}}
|
||||
+199
-265
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
@@ -75,6 +76,23 @@ var morningHTML string
|
||||
//go:embed events.html
|
||||
var eventsHTML string
|
||||
|
||||
//go:embed tools.html
|
||||
var toolsHTML string
|
||||
|
||||
//go:embed routines.html
|
||||
var routinesHTML string
|
||||
|
||||
//go:embed chat.html
|
||||
var chatPageHTML string
|
||||
|
||||
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
|
||||
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
|
||||
// with the sidebar assembled by a strings.Builder, which is the one piece of
|
||||
// markup that was still concatenated in Go.
|
||||
//
|
||||
//go:embed shell.html
|
||||
var shellHTML string
|
||||
|
||||
// ── Ethos Workstation Shell ──
|
||||
//
|
||||
// Two template pieces that wrap every page:
|
||||
@@ -135,74 +153,40 @@ var sidebarSections = []struct {
|
||||
},
|
||||
}
|
||||
|
||||
func sidebarActive(url, key string, activeKey string) string {
|
||||
if key == activeKey {
|
||||
return `class="active"`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sidebarHTML renders the sidebar navigation given the active page key.
|
||||
func sidebarHTML(active string) template.HTML {
|
||||
var b strings.Builder
|
||||
for _, sec := range sidebarSections {
|
||||
b.WriteString(`<div class=sidebar-section>`)
|
||||
b.WriteString(`<div class=sidebar-label>`)
|
||||
b.WriteString(sec.Label)
|
||||
b.WriteString(`</div>`)
|
||||
for _, p := range sec.Pages {
|
||||
cls := ""
|
||||
if p.Key == active {
|
||||
cls = ` class="active"`
|
||||
}
|
||||
b.WriteString(`<a href="`)
|
||||
b.WriteString(p.URL)
|
||||
b.WriteString(`"`)
|
||||
b.WriteString(cls)
|
||||
b.WriteString(`><span class=icon>`)
|
||||
b.WriteString(pageIcon(p.Key))
|
||||
b.WriteString(`</span><span>`)
|
||||
b.WriteString(p.Label)
|
||||
b.WriteString(`</span></a>`)
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
|
||||
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
|
||||
// sidebar template wraps it in the <use> reference.
|
||||
func pageIcon(key string) string {
|
||||
switch key {
|
||||
case "dash":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
return "i-grid"
|
||||
case "history":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
|
||||
return "i-clock"
|
||||
case "trace":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||
return "i-wave"
|
||||
case "notifications":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
||||
return "i-bell"
|
||||
case "tasks":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
return "i-grid"
|
||||
case "reminders":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||
return "i-calendar"
|
||||
case "routines":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
||||
return "i-repeat"
|
||||
case "morning":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||
return "i-calendar"
|
||||
case "chat":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
||||
return "i-message"
|
||||
case "voice":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
|
||||
return "i-mic"
|
||||
case "ecosystem":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
return "i-grid"
|
||||
case "tools":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
||||
return "i-settings"
|
||||
case "models":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||
return "i-wave"
|
||||
case "passkey":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
||||
return "i-lock"
|
||||
default:
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
|
||||
return "i-search"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,63 +226,12 @@ func pageTitle(key string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// shellTopHTML opens the shell and renders the top bar + sidebar.
|
||||
// Usage: {{template "shellTop" "<page-key>"}}
|
||||
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name=theme-color content="#14110D">
|
||||
<link rel=manifest href=/manifest.json>
|
||||
<title>maven · {{pageTitle .}}</title>
|
||||
<link rel=stylesheet href=/ui.css>
|
||||
<div class=shell data-app=maven>
|
||||
<header class=topbar>
|
||||
<div class=breadcrumbs>
|
||||
<span class=current>{{pageTitle .}}</span>
|
||||
</div>
|
||||
<div class=topbar-actions>
|
||||
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
|
||||
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
|
||||
Search
|
||||
<span class=kbd-hint>Ctrl+/</span>
|
||||
</div>
|
||||
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
|
||||
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||
</button>
|
||||
<span class=conn-status>
|
||||
<span class="dot online" id=connDot></span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class=shell-body>
|
||||
<aside class=sidebar>
|
||||
{{sidebarHTML .}}
|
||||
</aside>
|
||||
<main class=content>
|
||||
{{end}}`
|
||||
|
||||
// shellBottomHTML closes the content area, inspector, and shell.
|
||||
// Usage: {{template "shellBottom"}}
|
||||
const shellBottomHTML = `{{define "shellBottom"}}
|
||||
</main>
|
||||
<aside class=inspector id=inspector>
|
||||
<div class=inspector-inner>
|
||||
<div class=inspector-header>
|
||||
<span id=inspectorTitle>Details</span>
|
||||
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">×</button>
|
||||
</div>
|
||||
<div class=inspector-body id=inspectorBody></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<script src=/mavweb.js></script>
|
||||
{{end}}`
|
||||
|
||||
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
||||
func shellFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"pageTitle": pageTitle,
|
||||
"sidebarHTML": sidebarHTML,
|
||||
"pageTitle": pageTitle,
|
||||
"pageIcon": pageIcon,
|
||||
"sidebarSections": func() any { return sidebarSections },
|
||||
"ago": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
@@ -313,21 +246,21 @@ func shellFuncs() template.FuncMap {
|
||||
// a small fetch loop refreshes the tables in place. html/template escapes the
|
||||
// user text in facts/nudges. Read-only: browses the append-only store via
|
||||
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
||||
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
|
||||
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellHTML + dashHTML))
|
||||
|
||||
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
||||
// human surface is here (they ship no web UI of their own).
|
||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML))
|
||||
|
||||
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
|
||||
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML))
|
||||
|
||||
// morningTmpl — read-only view of today's checklist state per configured
|
||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||
// checklist state changes on the scale of minutes, not seconds.
|
||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML))
|
||||
|
||||
func noCache(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -746,108 +679,26 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap {
|
||||
m := shellFuncs()
|
||||
m["join"] = strings.Join
|
||||
m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }
|
||||
m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) }
|
||||
return m
|
||||
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
|
||||
}()).Parse(shellHTML + toolsHTML))
|
||||
|
||||
const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
<h1>Tools</h1>
|
||||
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||
<td><form method=post action=/tools>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
<input type=hidden name=action value=enable>
|
||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
||||
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
||||
<button class=btn>enable</button></form>
|
||||
<form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=action value=dismiss>
|
||||
<button class="btn btn-muted">dismiss</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
|
||||
<div>no proposed tools</div>
|
||||
<div class=hint>maven will propose tools here when she needs help running an action</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
||||
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
||||
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
||||
<td><form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
<input type=hidden name=action value=disable>
|
||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no tools enabled</div>
|
||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
||||
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
||||
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
||||
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
||||
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no MCP servers configured</div>
|
||||
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
{{template "shellBottom"}}`
|
||||
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML))
|
||||
|
||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
||||
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML))
|
||||
|
||||
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML))
|
||||
|
||||
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML))
|
||||
|
||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML))
|
||||
|
||||
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML))
|
||||
|
||||
// routinesTmpl — the proposed-routine review surface. One row per thing maven
|
||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
||||
const routinesHTML = `{{template "shellTop" "routines"}}
|
||||
<h1>Routines</h1>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
|
||||
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
|
||||
<td><form method=post action=/routines class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=accept>
|
||||
<button class=btn>accept</button></form></td>
|
||||
<td><form method=post action=/routines class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=dismiss>
|
||||
<button class="btn btn-muted">dismiss</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
||||
<div>no proposed routines</div>
|
||||
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
{{template "shellBottom"}}`
|
||||
|
||||
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
|
||||
|
||||
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
|
||||
|
||||
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
|
||||
|
||||
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML))
|
||||
|
||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
||||
|
||||
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
||||
|
||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML))
|
||||
|
||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||
m := shellFuncs()
|
||||
@@ -859,7 +710,7 @@ var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMa
|
||||
}
|
||||
m["join"] = strings.Join
|
||||
return m
|
||||
}()).Parse(shellTopHTML + traceHTML + shellBottomHTML))
|
||||
}()).Parse(shellHTML + traceHTML))
|
||||
|
||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
@@ -946,6 +797,51 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
||||
return out
|
||||
}
|
||||
|
||||
// reminderRow is one line on /reminders, with the payload unwrapped and both
|
||||
// timestamps already in his clock.
|
||||
//
|
||||
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
|
||||
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
|
||||
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
|
||||
// shape he never chose, and a time on a page he reads is the time on his wall.
|
||||
type reminderRow struct {
|
||||
Created string
|
||||
Fires string
|
||||
Status string
|
||||
Text string
|
||||
}
|
||||
|
||||
// reminderText unwraps the {"text":...} payload the router writes.
|
||||
//
|
||||
// A copy of store.ReminderText rather than a call to it, because mavweb is one
|
||||
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
|
||||
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
|
||||
// whoever renders it. Payload that is not that shape is shown as he said it.
|
||||
func reminderText(payload string) string {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
||||
if t, ok := m["text"]; ok {
|
||||
if s, isStr := t.(string); isStr && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(payload)
|
||||
}
|
||||
|
||||
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||
out := make([]reminderRow, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
out = append(out, reminderRow{
|
||||
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
|
||||
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
|
||||
Status: r.Status,
|
||||
Text: reminderText(r.Payload),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
||||
@@ -959,7 +855,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
|
||||
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
|
||||
log.Printf("reminders template: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1172,9 +1068,10 @@ func fmtTaskDate(t *time.Time) string {
|
||||
return t.Local().Format("02 Jan")
|
||||
}
|
||||
|
||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
||||
// how long ago she noticed it.
|
||||
type routineRow struct {
|
||||
// routineView is one line on the page: what maven noticed, in her words, and
|
||||
// how long ago she noticed it. A view model, not a database row — the template
|
||||
// never formats an interval or a timestamp itself.
|
||||
type routineView struct {
|
||||
ID int64
|
||||
Phrase string
|
||||
Noticed string
|
||||
@@ -1197,34 +1094,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)
|
||||
@@ -1236,23 +1152,23 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := routinesTmpl.Execute(w, struct {
|
||||
Msg string
|
||||
Proposed []routineRow
|
||||
}{msg, routineRows(proposed)}); err != nil {
|
||||
Proposed []routineView
|
||||
}{msg, toRoutineViews(proposed)}); err != nil {
|
||||
log.Printf("routines render: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// routineRows turns the wire rows into display rows. The phrase comes from
|
||||
// toRoutineViews turns the wire rows into view models. The phrase comes from
|
||||
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
||||
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
||||
out := make([]routineRow, 0, len(rs))
|
||||
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
||||
out := make([]routineView, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
||||
noticed := "just now"
|
||||
if r.CreatedTs > 0 {
|
||||
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
||||
}
|
||||
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
||||
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1261,6 +1177,45 @@ func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
||||
// 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 {
|
||||
@@ -1506,12 +1461,16 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
||||
servers = nil
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
|
||||
// flat list stops answering "what can she do to the house" somewhere
|
||||
// around fifteen rows, and that is the question this page exists for.
|
||||
if err := toolsTmpl.Execute(w, struct {
|
||||
Msg string
|
||||
Proposed []ipc.Tool
|
||||
Enabled []ipc.Tool
|
||||
Groups []tool.CapabilityGroup
|
||||
MCP []ipc.MCPServerStatus
|
||||
}{msg, proposed, enabled, servers}); err != nil {
|
||||
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil {
|
||||
log.Printf("tools render: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1649,32 +1608,7 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
|
||||
|
||||
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
||||
// and the handler redirects back to /chat with the response.
|
||||
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellTopHTML + chatPageHTML + shellBottomHTML))
|
||||
|
||||
const chatPageHTML = `{{template "shellTop" "chat"}}
|
||||
<h1>Chat</h1>
|
||||
<section class=card>
|
||||
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
||||
<div class="scroll chat-scroll" id=chatHistory>
|
||||
{{range .Messages}}
|
||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
||||
{{else}}
|
||||
<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||
<div>start a conversation</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<form method=post action=/api/chat class=chat-form>
|
||||
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
||||
<button class=btn>send</button>
|
||||
</form>
|
||||
</section>
|
||||
<script>
|
||||
var ch = document.getElementById('chatHistory');
|
||||
if(ch) ch.scrollTop = ch.scrollHeight;
|
||||
</script>
|
||||
{{template "shellBottom"}}`
|
||||
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML))
|
||||
|
||||
// handleChatPage renders the chat conversation page.
|
||||
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
|
||||
@@ -31,7 +31,7 @@ type modelController interface {
|
||||
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
||||
}
|
||||
|
||||
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
|
||||
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML))
|
||||
|
||||
const modelsHTML = `{{template "shellTop" "models"}}
|
||||
<h1>Resident model</h1>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
{{if .Reminders}}<div class=scroll><table>
|
||||
<tr><th>created</th><th>fires</th><th>status</th><th>what</th></tr>
|
||||
{{range .Reminders}}<tr>
|
||||
<td class=hint>{{.CreatedTs.Format "02 Jan 15:04"}}</td>
|
||||
<td>{{.FireTs.Format "02 Jan 15:04"}}</td>
|
||||
<td class=hint>{{.Created}}</td>
|
||||
<td>{{.Fires}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td class=text-max>{{.Payload}}</td>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The page showed the storage envelope and the UTC instant (Vikunja #469).
|
||||
func TestReminderRowsUnwrapAndLocalise(t *testing.T) {
|
||||
fire := time.Date(2026, 8, 4, 18, 30, 0, 0, time.UTC)
|
||||
rows := reminderRows([]ipc.Reminder{{
|
||||
CreatedTs: fire.Add(-time.Hour),
|
||||
FireTs: fire,
|
||||
Status: "pending",
|
||||
Payload: `{"text":"выпить таблетки"}`,
|
||||
}})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Text != "выпить таблетки" {
|
||||
t.Errorf("Text = %q, want the words without the envelope", rows[0].Text)
|
||||
}
|
||||
if want := fire.Local().Format("02 Jan 15:04"); rows[0].Fires != want {
|
||||
t.Errorf("Fires = %q, want %q", rows[0].Fires, want)
|
||||
}
|
||||
if strings.Contains(rows[0].Text, "{") {
|
||||
t.Errorf("Text still carries JSON: %q", rows[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// A payload that is not the envelope is his own words, so it is shown as it is.
|
||||
func TestReminderTextKeepsPlainPayload(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{`{"text":"позвонить маме"}`, "позвонить маме"},
|
||||
{" полить цветы ", "полить цветы"},
|
||||
{`{"body":"nope"}`, `{"body":"nope"}`},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := reminderText(tc.in); got != tc.want {
|
||||
t.Errorf("reminderText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{template "shellTop" "routines"}}
|
||||
<h1>Routines</h1>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
|
||||
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
|
||||
<td><form method=post action=/routines class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=accept>
|
||||
<button class=btn>accept</button></form></td>
|
||||
<td><form method=post action=/routines class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=dismiss>
|
||||
<button class="btn btn-muted">dismiss</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
||||
<div>no proposed routines</div>
|
||||
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
{{template "shellBottom"}}
|
||||
@@ -0,0 +1,49 @@
|
||||
{{define "shellTop"}}<!doctype html><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name=theme-color content="#14110D">
|
||||
<link rel=manifest href=/manifest.json>
|
||||
<title>maven · {{pageTitle .}}</title>
|
||||
<link rel=stylesheet href=/ui.css>
|
||||
<div class=shell data-app=maven>
|
||||
<header class=topbar>
|
||||
<div class=breadcrumbs>
|
||||
<span class=current>{{pageTitle .}}</span>
|
||||
</div>
|
||||
<div class=topbar-actions>
|
||||
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
|
||||
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
|
||||
Search
|
||||
<span class=kbd-hint>Ctrl+/</span>
|
||||
</div>
|
||||
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
|
||||
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||
</button>
|
||||
<span class=conn-status>
|
||||
<span class="dot online" id=connDot></span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class=shell-body>
|
||||
<aside class=sidebar>
|
||||
{{template "sidebar" .}}
|
||||
</aside>
|
||||
<main class=content>
|
||||
{{end}}{{/* sidebar — the section list, with the active page's link marked. The dot
|
||||
argument is the page key the page passed to shellTop. */}}{{define "sidebar"}}{{$active := .}}{{range sidebarSections}}<div class=sidebar-section><div class=sidebar-label>{{.Label}}</div>
|
||||
{{- range .Pages}}<a href="{{.URL}}"{{if eq .Key $active}} class="active"{{end}}><span class=icon><svg class=icon width="14" height="14"><use href="/ethos-icons.svg#{{pageIcon .Key}}"/></svg></span><span>{{.Label}}</span></a>
|
||||
{{- end}}</div>
|
||||
{{end}}{{end}}{{define "shellBottom"}}
|
||||
</main>
|
||||
<aside class=inspector id=inspector>
|
||||
<div class=inspector-inner>
|
||||
<div class=inspector-header>
|
||||
<span id=inspectorTitle>Details</span>
|
||||
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">×</button>
|
||||
</div>
|
||||
<div class=inspector-body id=inspectorBody></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<script src=/mavweb.js></script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,60 @@
|
||||
{{template "shellTop" "tools"}}
|
||||
<h1>Tools</h1>
|
||||
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||
<td><form method=post action=/tools>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
<input type=hidden name=action value=enable>
|
||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
||||
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
||||
<button class=btn>enable</button></form>
|
||||
<form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=action value=dismiss>
|
||||
<button class="btn btn-muted">dismiss</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
|
||||
<div>no proposed tools</div>
|
||||
<div class=hint>maven will propose tools here when she needs help running an action</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
||||
{{if .Enabled}}<p class=hint>grouped by capability domain. The dotted id is <code>scope.domain.action</code> — the same shape Hexis speaks — and it is derived from the row, so it always describes what the command actually does.</p>
|
||||
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
|
||||
<div class=scroll><table><tr><th>capability</th><th>name</th><th>command</th><th>risk</th><th></th></tr>
|
||||
{{range .Tools}}<tr><td><code>{{capability .}}</code></td><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
|
||||
<td>{{$r := risk .}}{{if eq $r "irreversible"}}<span class=red>irreversible</span>{{else if eq $r "destructive"}}<span class=red>destructive</span>{{else}}<span class=badge>safe</span>{{end}}</td>
|
||||
<td><form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
<input type=hidden name=action value=disable>
|
||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no tools enabled</div>
|
||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
||||
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
||||
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
||||
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
||||
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no MCP servers configured</div>
|
||||
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
{{template "shellBottom"}}
|
||||
+5
-5
@@ -8,12 +8,12 @@
|
||||
"//disabled_rules": [
|
||||
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
||||
"water, meal, break, service_down, netdata_critical.",
|
||||
"service_down is off because it cannot say WHICH service — mavpoll folds the",
|
||||
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
|
||||
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
|
||||
"Turn it back on once Vikunja #444 lands a fact per monitor."
|
||||
"service_down is back on: mavpoll now writes one fact per kuma monitor",
|
||||
"(service_down:<name>), so the nudge names the service and pausing a monitor",
|
||||
"in kuma silences that monitor. It is also edge-triggered, so a service that",
|
||||
"stays down is one nudge, not one every fifteen minutes."
|
||||
],
|
||||
"disabled_rules": ["service_down"],
|
||||
"disabled_rules": [],
|
||||
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Maven": "Мэйвен",
|
||||
"Nexus": "Нексус",
|
||||
"Praxis": "Праксис",
|
||||
"Hexis": "Хексис",
|
||||
"Vikunja": "Викунья",
|
||||
"SearXNG": "сёрчиксэнджи",
|
||||
"Kiwix": "Кивикс",
|
||||
"Gitea": "Гитея",
|
||||
"Home Assistant": "Хоум Ассистент",
|
||||
"Docker": "Докер",
|
||||
"Telegram": "Телеграм",
|
||||
"ntfy": "энтифай",
|
||||
"homesrv": "хоумсёрв",
|
||||
"workpc": "воркписи",
|
||||
"whisper": "виспер",
|
||||
"piper": "пайпер",
|
||||
"llama-server": "лама сервер",
|
||||
"Qwen": "Квен",
|
||||
"CalDAV": "калдав",
|
||||
"IMAP": "аймап",
|
||||
"API": "эй-пи-ай",
|
||||
"CPU": "си-пи-ю",
|
||||
"GPU": "джи-пи-ю",
|
||||
"RAM": "оперативная память",
|
||||
"SSD": "эс-эс-ди",
|
||||
"uptime": "аптайм",
|
||||
"backup": "бэкап",
|
||||
"deploy": "деплой"
|
||||
}
|
||||
@@ -223,6 +223,41 @@ Not alternatives — layers:
|
||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||
|
||||
#### "второй" points at the list she just read
|
||||
|
||||
Landed 2026-08-04 (Vikunja #448). The dialogue session carried the intent, the
|
||||
slots and the history, and not the list. She recited five tasks, he said
|
||||
"второй", and the word had nothing to point at.
|
||||
|
||||
`Session.Candidates` holds what she just offered, bound at the moment she speaks
|
||||
it and in the order she speaks it (`tasks.Spoken`). Binding afterwards would
|
||||
resolve the word against a fresh query, and the list changes between two turns.
|
||||
`cmd/mavend/ordinal.go` reads the position before routing and dispatches on the
|
||||
candidate's kind.
|
||||
|
||||
An ordinal with no verb is read back, not acted on — "второй" names a task, it
|
||||
does not say what to do with it. With a verb ("первую сделал", "последнюю
|
||||
убери") the task moves and the list is spent, because a second ordinal against a
|
||||
list that no longer holds closes the wrong work. A position she never read is
|
||||
answered with how many she did read, not routed as a fresh sentence.
|
||||
|
||||
#### Saying she got it wrong is a feature
|
||||
|
||||
Landed 2026-08-04 (Vikunja #455). `Router.CorrectMisroute` could always append a
|
||||
corrected utterance as a new classifier example, and until now nothing in the
|
||||
daemon called it, so the mechanism existed and the behaviour did not.
|
||||
|
||||
`cmd/mavend/repair.go` reaches it. He says she got it wrong and names what it
|
||||
should have been — "нет, это заметка", "это не напоминание, а факт" — and three
|
||||
things happen in one turn: the classifier learns the utterance under the named
|
||||
intent, the request is redone under it, and she says the correction landed. The
|
||||
utterance he is correcting TO is the one with no "не" in front of it.
|
||||
|
||||
Read before routing, next to the confirm and clarify turns, because a correction
|
||||
routed as a fresh utterance files the correction itself. One turn is correctable
|
||||
once, inside five minutes, and only turns she acted on — a clarify asked instead
|
||||
of acting, so there is nothing yet to be wrong about.
|
||||
|
||||
#### A restart expires a parked question
|
||||
|
||||
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||
@@ -345,6 +380,23 @@ Three rules fall out, and they are the part that was missing:
|
||||
policy does not recognise gets the confirm turn. A domain argues its way down
|
||||
to running freely; it never has to argue its way up to being gated.
|
||||
|
||||
#### Capability ids
|
||||
|
||||
A row is also read as a dotted capability id, `scope.domain.action` — the same
|
||||
shape Hexis has always spoken, which made the local surface the odd one out
|
||||
(Vikunja #452). `homelab.docker.restart`, `house.lock.unlock`,
|
||||
`mcp_vikunja.vikunja.delete_task`.
|
||||
|
||||
Derived, not stored, for the reason the tier is: a derivation is one place to
|
||||
argue with. The name is still the primary key and nothing about lookup or
|
||||
execution changed — this is a way to READ the allowlist, not a second one.
|
||||
`/tools` groups the enabled rows by `scope.domain` and prints the id and the
|
||||
tier beside each, because a flat list stops answering "what can she do to the
|
||||
house" somewhere around fifteen rows.
|
||||
|
||||
`MatchCapability` widens one way: `house` and `house.lock` both cover
|
||||
`house.lock.unlock`, and nothing lets a narrower id claim a wider pattern.
|
||||
|
||||
The irreversible tier is refused rather than asked about, because a confirm
|
||||
turn would be theatre: everything that proposed the act — an STT guess, a
|
||||
router guess, a fuzzy allowlist match — is a guess, and a spoken "да" checks
|
||||
@@ -423,6 +475,29 @@ lives in `source`; rules trust provenance.
|
||||
`source=poll:healthcheck`. A compromised poller must not be able to forge a
|
||||
trigger.
|
||||
|
||||
#### A fact per monitor, not an aggregate
|
||||
|
||||
mavpoll writes one fact per kuma monitor, keyed `service_down:<monitor name>`.
|
||||
It used to fold the whole gauge into a single boolean, and the nudge could then
|
||||
only say that something on homesrv was down. That is not something he can act
|
||||
on, so the rule shipped disabled.
|
||||
|
||||
Three things follow from the split:
|
||||
|
||||
- The key set is no longer known at wiring time. A rule declares
|
||||
`WantPrefixes` and the gatherer resolves the family per tick, which is the
|
||||
only prefix read in the loop.
|
||||
- Pausing a monitor in kuma silences that monitor. Under the aggregate it
|
||||
silenced nothing, because some other monitor kept the boolean at "down".
|
||||
- A monitor deleted in kuma would keep its last fact reading "down" forever, so
|
||||
mavpoll marks a vanished monitor "unknown". No rule fires on "unknown".
|
||||
|
||||
The rule is also edge-triggered: it fires on a transition it has not already
|
||||
nudged about (`State.NudgedSince`). A polled fact is written only when the
|
||||
value changes, but the predicate reads the current value, so without the edge
|
||||
check a service that stays down qualifies on every tick and cooldown is the
|
||||
only brake.
|
||||
|
||||
### Presence — concrete scoring
|
||||
|
||||
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Ecosystem reach, measured — 2026-08-04
|
||||
|
||||
Vikunja #405. Measured at `86dcd99` on the 30-case held-out fixture
|
||||
`internal/router/eval/ru_ecosystem_v1.json`, scored by `make eval-reach`.
|
||||
|
||||
**Praxis reach is zero. Not low — zero, on all twelve cases, under both embedders.**
|
||||
|
||||
## What was measured
|
||||
|
||||
Reach is where an utterance *arrives*, not what it achieves. The derivation is in
|
||||
`internal/router/eval/reach.go` and mirrors `actionAct` in `cmd/mavend/actions_act.go`:
|
||||
|
||||
- **Praxis** needs `IntentAct` and a fn slot whose value is one of the aliases in
|
||||
`praxisCapabilities`.
|
||||
- **Hexis** needs `IntentAct` and non-empty `Slots.Text`. It also fires from
|
||||
`hexisBeforeClarify`, so a clarified act with text reaches Hexis before the clarify
|
||||
question is ever asked.
|
||||
- Everything else stays inside Maven.
|
||||
|
||||
The fixture holds 10 Hexis cases, 12 Praxis cases and 8 negatives. The negatives carry
|
||||
the expensive direction: an utterance that reaches a mutating path it had no business
|
||||
reaching is worse than one that never arrives, because he never gets asked about it.
|
||||
|
||||
## The numbers
|
||||
|
||||
| Path | Reached the right place | Missed | Overreach | Wrong service | p50 |
|
||||
|---|---|---|---|---|---|
|
||||
| classifier + hash embedder | 7/30 (23.3%) | 22 | 1 | 0 | 14.8µs |
|
||||
| classifier + e5-small (deployed) | 16/30 (53.3%) | 11 | 1 | 2 | 23.8ms |
|
||||
|
||||
Split by target, on the deployed embedder:
|
||||
|
||||
| Want | Passed |
|
||||
|---|---|
|
||||
| hexis | 9/10 |
|
||||
| none | 7/8 |
|
||||
| **praxis** | **0/12** |
|
||||
|
||||
## The finding
|
||||
|
||||
Hexis reach is fine. Nine of ten act-shaped home and homelab utterances land on the
|
||||
entity resolver, which is what the gate was built to do: it only needs the intent and
|
||||
some text, and both the act grammar and the embedder produce those.
|
||||
|
||||
Praxis reach is structurally impossible from free Russian, and the fixture makes that
|
||||
visible for the first time. `handlePraxisAct` dispatches on exact equality between
|
||||
`Slots.Fn` and a capability alias. The fn slot is filled by `DefaultActMatcher`, whose
|
||||
allowlist is the deployment's enabled tool names — `перезапусти`, `выключи`, and so on.
|
||||
No Praxis alias is in that list, so no utterance can ever put one in the slot. The
|
||||
Russian aliases in `praxisCapabilities` (`готово`, `принято`, `игнорировать`) read as if
|
||||
they match speech and they do not: they are compared against a fn slot, never against
|
||||
the utterance.
|
||||
|
||||
That means the whole lifecycle half of the Praxis contract — acknowledge, resolve,
|
||||
ignore, pin — has no voice path at all. Attention and changes have none either.
|
||||
|
||||
Three of the twelve got as far as the wrong place, which is the same defect seen from the
|
||||
other side: `"готово, закрывай"` and `"как дела у праксиса"` route to act with text, so
|
||||
they fall past the Praxis check into the Hexis one and go to entity resolution instead.
|
||||
|
||||
## The live services
|
||||
|
||||
All three are up and answer. With `no_proxy` set for the loopback (the host's `http_proxy`
|
||||
answers 503 for 127.0.0.1, which is the same trap `llmrouter_test.go` documents):
|
||||
|
||||
```
|
||||
127.0.0.1:8989/health -> {"status":"ok"} praxis
|
||||
127.0.0.1:9740/health -> {"status":"ok"} nexus
|
||||
127.0.0.1:9741/health -> {"status":"ok"} hexis
|
||||
127.0.0.1:8989/api/v1/tools/attention?limit=3 -> []
|
||||
127.0.0.1:8989/api/v1/tools/changes?limit=3 -> []
|
||||
```
|
||||
|
||||
So the boundary is not the problem, and an end-to-end run today would add nothing: both
|
||||
Praxis feeds are empty, so even a perfect reach score would produce "ничего не требует
|
||||
внимания". The gap is entirely on Maven's side of the wire.
|
||||
|
||||
## Not measured
|
||||
|
||||
**The resident model.** The LLM router is the deployed default, and these numbers are the
|
||||
classifier only. `mavend` spawns its llama-server on a container-local port
|
||||
(127.0.0.1:40063 inside `maven-mavend-1`), unreachable from the host, and the workstation
|
||||
at 192.168.1.105:8080 was down. The classifier is the failure floor and it is what always
|
||||
answers, so the floor is worth knowing on its own — but the LLM router could fill the fn
|
||||
slot with a literal `list_attention`, since the grammar lets it emit any string. Whether
|
||||
it does is the open question, and the fixture is ready for it.
|
||||
|
||||
## What this argues for
|
||||
|
||||
Not a new intent. The seven are frozen by prompt parity with the training workspace.
|
||||
|
||||
The cheap fix is stage 0: a grammar per Praxis capability that sets `Slots.Fn` to the
|
||||
canonical arm name, the same trick `AgendaQueryGrammars` used to take agenda questions off
|
||||
the model. It costs one regex per capability on every turn and it is deterministic, which
|
||||
for a lifecycle verb is the right trade — "отметь это как сделанное" should never be a
|
||||
similarity guess.
|
||||
|
||||
The second fix is smaller and separate: `entity_attention` aliases to grammar names only,
|
||||
so scoped attention ("что там с нексусом") needs a grammar before it can be reached at
|
||||
all.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Note recall after the e5-small swap — 04-08-2026
|
||||
|
||||
Closes Vikunja #371, which asked for the swap and for this re-measurement. The embedder is no
|
||||
longer paraphrase-multilingual-MiniLM-L12-v2. It is **multilingual-e5-small**, quantized, with the
|
||||
`query:` / `passage:` prefixes it was trained with (`internal/router/onnxembedder.go`,
|
||||
`EmbedQuery` / `EmbedPassage`). `deploy/mavend.json` loads
|
||||
`models/embedder/multilingual-e5-small/model_quantized.onnx`, which is the same file `make
|
||||
download-embedder` fetches and the same file this run measured.
|
||||
|
||||
- Fixture + scorer: `internal/memory/recalleval/` — 32 cases now, not 30
|
||||
- Reproduce: `make eval-recall`
|
||||
- Commit: `b6abb19`
|
||||
- Gate as deployed: `query_min_score` 0.55, `query_min_margin` 0.008
|
||||
|
||||
The fixture grew since 31-07, so the case counts are not comparable row for row. The percentages
|
||||
are.
|
||||
|
||||
## Results
|
||||
|
||||
| | 31-07 MiniLM (onnx) | 04-08 e5-small (onnx) |
|
||||
|---|---|---|
|
||||
| **recall@1** | 60.0% (15/25) | **70.4% (19/27)** |
|
||||
| recall@3 | 80.0% (20/25) | **85.2% (23/27)** |
|
||||
| **answered after the gate** | 48.0% (12/25) | **63.0% (17/27)** |
|
||||
| **false recall** | 1/5 (20%) | **0/5** |
|
||||
| wrong note on top / tie on top | 10 / 0 | 8 / 0 |
|
||||
| ranked first, then silenced by the gate | 3 | 2 |
|
||||
| `hard` cases passed | 2/11 | 5/12 |
|
||||
| RU / EN passed | 13/24 / 3/6 | 18/26 / 4/6 |
|
||||
| latency p50 / p95 / max | 59ms / 148ms / 194ms | **23ms / 41ms / 62ms** |
|
||||
|
||||
The hash ratchet CI runs is unchanged in kind and still answers nothing after the gate: recall@1
|
||||
37.0%, recall@3 74.1%, 0/27 answered, 0/5 false. It is lexical and exists so CI has a deterministic
|
||||
floor. Never compare a hash number to an ONNX one.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. The swap paid on every axis at once, including latency
|
||||
|
||||
Ten points of recall@1, fifteen points of *answered*, the one false recall gone, and it is 2.5×
|
||||
faster because the quantized e5-small is 118MB against the 470MB fp32 file the old config loaded.
|
||||
Finding 3 of the 31-07 eval predicted the recall half and said nothing about speed; the speed came
|
||||
from fixing the second half of that finding, which was that the deployed path loaded a different
|
||||
file than the download target.
|
||||
|
||||
The concrete case that eval named is fixed. "из-за чего кончилось место" no longer returns the
|
||||
guitar-chords filler note. It now returns a homelab note, `n2` at 0.884, and the wanted note is
|
||||
still not in the top 3 — so the query moved from absurd to merely wrong. That is the shape of what
|
||||
is left.
|
||||
|
||||
### 2. The score distributions still overlap. The margin is what separates them
|
||||
|
||||
This is the part of #371's premise that did not come true. Right-note-first top-1 scores run
|
||||
0.791 / 0.857 / 0.890 (min / median / max). Must-stay-silent top-1 scores run 0.795 / 0.815 /
|
||||
0.835. The silent cases sit *inside* the answering range, so no value of `query_min_score` keeps
|
||||
every real recall and rejects every false one — the same verdict as 31-07, at a higher and tighter
|
||||
band of scores.
|
||||
|
||||
What separates them is the second-place gap. Margin top1-top2 for a right first hit: median 0.024.
|
||||
For a must-be-silent case: median 0.002, max 0.019. A false recall is a note that beats its
|
||||
neighbours by nothing, because nothing in the store is about the question. The sweep:
|
||||
|
||||
| margin | answered | false recall |
|
||||
|---|---|---|
|
||||
| 0.000 | 18/27 (67%) | 3/5 |
|
||||
| 0.005 | 17/27 (63%) | 1/5 |
|
||||
| **0.008 (deployed)** | **17/27 (63%)** | **0/5** |
|
||||
| 0.010 | 15/27 (56%) | 0/5 |
|
||||
| 0.015 | 12/27 (44%) | 0/5 |
|
||||
|
||||
0.008 is the knee: it is the smallest margin that silences all five, and the next step up costs two
|
||||
real answers for nothing. The score gate contributes almost nothing on its own — every value from
|
||||
0.00 to 0.70 answers the same 18 and admits the same 3 — so `query_min_score` is now close to inert
|
||||
and the margin is the live dial. Leave both where they are; #412 is where a further sweep belongs.
|
||||
|
||||
### 3. What is left is a retrieval problem, not a gate problem
|
||||
|
||||
Eight cases put the wrong note on top, and the failures cluster: `hard` 5/12, `preference` 5/9,
|
||||
`homelab` 8/13. Four of the eight have the right note in the top 3, so a reranker would collect
|
||||
them; the other four do not, so nothing downstream can. Two more rank first and are silenced by the
|
||||
margin — `en-hard-024` at 0.826 with margin 0.023, and `ru-home-026` at 0.846 with margin 0.001,
|
||||
which is a genuine near-tie against a second note that is also plausible.
|
||||
|
||||
Preference queries are the weakest class in a way that is not about the model. "когда запускать
|
||||
резервное копирование" and "как мне присылать оповещения" both return a fact, not the note that
|
||||
states the preference. Facts and notes are searched in one pass since #373, so a confidently-scored
|
||||
fact wins a question that a note answers better. That is a ranking policy question and it belongs
|
||||
in its own task, not in a threshold.
|
||||
@@ -0,0 +1,62 @@
|
||||
# How reactiveHandler is wired
|
||||
|
||||
*Last verified: 2026-08-04 @ b6abb19. Living doc: correct it in place, do not append.*
|
||||
|
||||
The decision Vikunja #433 asked for, and the rule that follows from it.
|
||||
|
||||
## The decision
|
||||
|
||||
**Group the fields into cohesive wiring structs. No container, no generator, no
|
||||
framework.** `reactiveHandler` (`cmd/mavend/voice.go`) stays the one type the voice
|
||||
server talks to. What changes is that a capability arrives as one named group, not as
|
||||
four more loose fields on a struct that already had thirty.
|
||||
|
||||
The pattern was already in the file before this was written down: `searchWiring`,
|
||||
`kiwixWiring`, `homeWiring`, `netWiring` and `ecosystemWiring` are all this shape, each
|
||||
`nil` when the capability is off. #433 makes it the rule rather than a habit, and adds
|
||||
the case the habit had missed — a group that is not a capability toggle.
|
||||
|
||||
## The worked example
|
||||
|
||||
`recallWiring` (`cmd/mavend/recall.go`) holds the five things the recall path needs:
|
||||
the embedder, the vector store, the personal boundary, and the score and margin that
|
||||
gate an answer. They used to sit in three separate places on the handler with the two
|
||||
gate numbers a hundred lines away from the store they gate.
|
||||
|
||||
Its zero value means "no recall", which is why it is a value and not a pointer. The
|
||||
`*Wiring` types that model an optional capability stay pointers, because `nil` is how
|
||||
"not configured" is spelled and a zero-valued search client would be a client pointed at
|
||||
nothing.
|
||||
|
||||
## Why not the alternatives
|
||||
|
||||
**Narrow consumer-side interfaces at each handler** is the more idiomatic Go answer and
|
||||
it is not rejected, only deferred. It is the right move at the point a handler is pulled
|
||||
into its own package, because that is when the import direction starts to matter. Doing
|
||||
it first would mean writing an interface per handler against a struct nobody can pass
|
||||
anywhere, which is churn bought against a package split that has not happened.
|
||||
|
||||
**A container or a wire-style generator** is rejected outright. This is one binary with
|
||||
one composition root (`wireVoice` in `cmd/mavend/voicewire.go`). Generated wiring would
|
||||
add a build step and a layer of indirection to solve a problem that is currently one
|
||||
composite literal long, and it would make the "is this capability configured" question
|
||||
harder to answer by reading, which is the question this file is mostly about.
|
||||
|
||||
## What this unlocks
|
||||
|
||||
The reason `cmd/mavend/` cannot split into `mavend/actions/` today is that every action
|
||||
handler is a method on a struct with thirty unexported fields: moving handlers to a
|
||||
subdirectory means exporting all of them or inventing an interface to pass through. That
|
||||
was the answer given on the tick.go and voice.go splits, and it is still true. Grouping
|
||||
is the step that makes it false later — a handler that takes `recallWiring` and nothing
|
||||
else can move without the other twenty-five fields following it.
|
||||
|
||||
## The rule
|
||||
|
||||
**A wiring change does not ride a feature PR.** The voice.go and tick.go splits were
|
||||
safe to merge because the moved code diffed identical, line for line. A regrouping that
|
||||
touches the confirm gate or the act allowlist is its own change, reviewed on its own, or
|
||||
it is not reviewable at all.
|
||||
|
||||
New capability, new group. A capability that adds four fields to `reactiveHandler`
|
||||
instead of one struct is the thing this decision exists to stop.
|
||||
@@ -90,6 +90,13 @@ export LD_LIBRARY_PATH="$ROOT/deps/piper"
|
||||
|
||||
Without `-piper` it runs as a stub.
|
||||
|
||||
`-lexicon deploy/tts-lexicon.json` adds the pronunciation dictionary: a flat
|
||||
JSON object of name to Russian spelling, applied to the text just before piper
|
||||
reads it. It is how `Vikunja` is said as a word rather than spelled out, and how
|
||||
`SearXNG` and `homesrv` are said at all. Off unless the flag is set; a path that
|
||||
is set and unreadable stops mavttsd rather than letting it say names wrong in
|
||||
silence. Adding a name needs a restart of mavttsd and nothing else.
|
||||
|
||||
## mavweb — PWA voice bridge (WebSocket ↔ TCP)
|
||||
|
||||
No CGo, no deps; builds with stock Go.
|
||||
|
||||
@@ -103,9 +103,14 @@ query path.
|
||||
```
|
||||
|
||||
`Recognizes()` requires both `enabled` and a `model_path`, so a half-filled block reads as off
|
||||
rather than as a capability that fails every turn. With `enabled` and no model the daemon still
|
||||
attaches the three methods — profiles can be created, listed and deleted — and logs that
|
||||
recognition is blocked.
|
||||
rather than as a capability that fails every turn.
|
||||
|
||||
That gate was written, documented, and then never called. It is called now, and the behaviour it
|
||||
describes changed with it. `enabled` with no `model_path` used to attach all three methods and log
|
||||
that enrolment was on. Today `newSpeakerWiring` returns nil, so the methods are absent, and the log
|
||||
says why: there is nothing to embed with, so enrol, list and forget would all be no-ops. That is
|
||||
the one config shape where the operator most needs to be told otherwise, and it was the shape that
|
||||
lied.
|
||||
|
||||
## Still open
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Plan: The work board surface
|
||||
|
||||
**The decision Vikunja #431 asked for. Written 04-08-2026.**
|
||||
|
||||
**Verdict: build it, in a smaller shape than the task imagined.** The board is worth
|
||||
moving out of the file. The intake form belongs on the `/tasks` page, not on the voice
|
||||
path. The argument is not built, now or later.
|
||||
|
||||
## Why it is worth building
|
||||
|
||||
The reason is the one the task gives and it holds: company rules forbid pointing Claude
|
||||
at work repos, and Maven is the one assistant on the box that work material may reach.
|
||||
No telemetry, no cloud model, no third-party account. That is not a preference here, it
|
||||
is the whole permission.
|
||||
|
||||
The build is also small, because most of it landed already:
|
||||
|
||||
| Piece | Where | State |
|
||||
|---|---|---|
|
||||
| task rows, dedupe, status lifecycle | `internal/store/migrations.go:149` and migration #15 | done |
|
||||
| capture from speech, urgency stripped | `router.ParseTaskCapture`, `router.TaskCaptureGrammar` | done |
|
||||
| recite the list on request | `router.IsTaskListQuery` | done |
|
||||
| a page to read and change the board | `/tasks` in `cmd/mavweb` | done |
|
||||
| counting a shape without judging it | `internal/memory/behavior.go` | done, as precedent |
|
||||
| a proposal he reads when he chooses | `/routines`, the proposed-routine queue | done, as precedent |
|
||||
|
||||
`tasks` already carries `status` (candidate, open, done, dropped), `due_ts`, `weight`,
|
||||
`source`, `evidence`, `ext_id` and `resolved_by`. Three things are missing. It has no
|
||||
definition of done and no blocked-on. There is no way to edit a task after capture:
|
||||
`SetTaskStatus` moves the status and nothing writes text, date or weight again. And
|
||||
there is no grouping he controls, because order is computed by `tasks.Rank` alone.
|
||||
|
||||
## Where the form lives, and why not voice
|
||||
|
||||
The task asks the form to refuse a capture with no definition of done. That refusal
|
||||
cannot live on the voice path, for two reasons.
|
||||
|
||||
**The parked-state mechanism is binary.** `resolveConfirm` in `cmd/mavend/confirm.go`
|
||||
answers yes or no against a slot with a 90-second life. Filling four fields over four
|
||||
turns is slot filling, which is a different mechanism and a new one. Nothing in the
|
||||
daemon does it today.
|
||||
|
||||
**The definition of done is the worst possible field to dictate.** It is the one string
|
||||
that has to be exact, because its whole purpose is to be unarguable later. Whisper
|
||||
transcribing a sentence of Russian work vocabulary is where exactness goes to die, and
|
||||
the capture path already had to strip a question mark that whisper invented.
|
||||
|
||||
So: voice captures a line and recites the list. The page is where a line becomes an
|
||||
item with a definition of done, a blocked-on and a date. A captured line lands as
|
||||
`candidate` and stays there until it is filled in, which is what `candidate` was for.
|
||||
|
||||
The refusal the task wants survives, moved: the page will not promote a candidate to
|
||||
`open` without a definition of done, the same way `ParseTaskCapture` will not file a
|
||||
marker with nothing after it. And the field must close on either outcome, so "it already
|
||||
works" counts as complete. A definition of done that only one result satisfies is a wish.
|
||||
|
||||
## Does the stage-0 trick stretch
|
||||
|
||||
The task asks this before any shape is committed to. It was checked. The answer is
|
||||
partly.
|
||||
|
||||
`TaskCaptureGrammar` matches every utterance and lets `ParseTaskCapture` decide inside
|
||||
`Build`, keeping the intent at `note` and leaving the frozen seven-intent contract alone.
|
||||
That trick stretches to **recite** and to **status change**: both are a marker plus a
|
||||
referent, both are a lookup, and a status change is a small closed verb set over a list
|
||||
he can see. It does not stretch to **intake**, because intake is not one utterance, and
|
||||
it does not need to, because intake moved to the page.
|
||||
|
||||
One cost to name. Each such grammar matches everything and runs its parser on every
|
||||
turn, ahead of the resident model. Two more of them is fine. A dozen would make stage 0
|
||||
a second router with no evaluation behind it, and at that point the frozen enum is the
|
||||
smaller problem.
|
||||
|
||||
## What is not built: the argument
|
||||
|
||||
Not now and not later behind a flag. The task is right about why, and
|
||||
`internal/memory/behavior.go` already argued it for habits: a 1.7B asked whether evidence
|
||||
proves anything will agree fluently and launder a guess into a decision. A wrong claim
|
||||
about his work, stated confidently, is the most expensive kind of wrong Maven can be.
|
||||
|
||||
The line is the same line behaviour memory drew. She may **count**:
|
||||
|
||||
- no state change in eleven days
|
||||
- blocked on a person, with no date
|
||||
- four of nine waiting on two people
|
||||
|
||||
Those are queries over rows. She may not assess whether a build proves anything, whether
|
||||
a blocker is real, or whether a task should be dropped.
|
||||
|
||||
## Persona
|
||||
|
||||
A progress tracker is a nag by default, and "not a nag" is hard. The line is already
|
||||
drawn twice in the codebase and it is drawn the same way here:
|
||||
|
||||
- A date he set becomes a reminder. He set it, so it is not her raising it.
|
||||
- A stall becomes a proposal he reads when he chooses, on a page, like `/routines`.
|
||||
- Ask what is on the board and she recites. She never opens with it.
|
||||
|
||||
The day plan is the place to watch. `tickLoop.dayPlan` reads calendar events, pending
|
||||
reminders and checklist facts, and it does not read tasks. Adding the board to the
|
||||
morning nudge is exactly the move that turns this into a nag, so the board goes on the
|
||||
page and into the answer when asked, and not into the unprompted morning message.
|
||||
|
||||
## The build, as tasks
|
||||
|
||||
1. Two columns on `tasks`: definition of done, and blocked-on. Blocked-on resolves
|
||||
through Nexus like any other person reference, because identity lives in Nexus.
|
||||
2. An edit path. Today a task is write-once except for its status, so the form has
|
||||
nothing to save into.
|
||||
3. `/tasks` grows the form: promote candidate to open only with a definition of done,
|
||||
set a date, set blocked-on. A date set here writes a reminder.
|
||||
4. A status-change grammar at stage 0, following `TaskCaptureGrammar`.
|
||||
5. Counted stall shapes on `/tasks`, phrased as counts. No assessment.
|
||||
|
||||
Note for whoever picks up 3: `/tasks` accepts its POST without the step-up gate, while
|
||||
`/routines` and `/tools` require a passkey. That was deliberate for capture. Adding an
|
||||
edit path is the moment to re-argue it, not to inherit it silently.
|
||||
|
||||
Each is separable and each is worth stopping after.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Plan: What the ambient calendar path should be
|
||||
|
||||
**The decision Vikunja #432 asked for. Written 04-08-2026.**
|
||||
|
||||
**Verdict: keep the endpoint, change the contract.** The relay app sends structured
|
||||
fields, not a notification blob. The free-text parser stays as the degraded path, because
|
||||
there is a real case where the phone cannot produce structure. Delete the endpoint only
|
||||
if the answer to the one open question below is no.
|
||||
|
||||
## First, the task's premise is out of date
|
||||
|
||||
#432 states as confirmed that every ambient event lands on the day the notification was
|
||||
posted, because there is no date parsing at all. That was true when the task was filed
|
||||
and it is not true now.
|
||||
|
||||
`4e4c917` added `dayWords` and `dayOffset` (`internal/calendar/ambient.go:53`), so
|
||||
"завтра в 15:00" now dates to tomorrow. The same commit added `ambientPastGrace`, which
|
||||
refuses an event landing more than two hours before the notification, on the reasoning
|
||||
that the day was inferred and a stale inference is wrong rather than late. `45a5e37`
|
||||
(#482, this week) fixed a second dating bug the task did not know about: the wall clock
|
||||
was resolved against the notification's own zone, so every ambient meeting on a non-UTC
|
||||
box landed off by the deploy's UTC offset.
|
||||
|
||||
What is still missing is an explicit date. "5 августа в 15:00" and "12.08 15:00" carry no
|
||||
day word, so they date to today and the past-grace check drops them. That is a smaller
|
||||
defect than the one filed, and it fails safe rather than storing a wrong meeting.
|
||||
|
||||
The task's third question also has an answer, and the answer is yes. `internal/morning/plan.go:174`
|
||||
prefixes an uncertain item with "похоже, " and `cmd/mavend/actions_query.go:334` carries
|
||||
`Confidence < 1.0` into the calendar recital. Both readers hedge.
|
||||
|
||||
## The open question, and it is the owner's
|
||||
|
||||
**Can the phone read Android's calendar provider, or only the notification text?**
|
||||
|
||||
Everything follows from this and nothing in this repo can answer it.
|
||||
|
||||
A `NotificationListenerService` sees a title and a body. It cannot know a meeting's real
|
||||
start, end or organiser, because those are not in the notification. So if the relay is
|
||||
limited to the notification stream, free-text parsing on this side is not a choice, it is
|
||||
the only thing available, and #432's suggestion that the phone send structured JSON
|
||||
cannot be honoured.
|
||||
|
||||
If the app may instead read `CalendarContract`, it has the actual event rows, and the
|
||||
whole parser stops being necessary. That is the better shape by a wide margin: a real
|
||||
start and end, a real title, an explicit date, no clock-reading heuristic and no
|
||||
past-grace guard, because nothing is inferred.
|
||||
|
||||
Reading the phone's calendar provider does not break the constraint the design was built
|
||||
around. The refusal in `internal/calendar/ambient.go:10` is about holding a work
|
||||
credential **on the homelab**, which is what ties the box's blast radius to the employer.
|
||||
The phone already holds that session. Nothing new lands on homesrv either way.
|
||||
|
||||
**Assumption, and it needs his answer:** a managed work profile may block a third-party
|
||||
app from reading work calendar rows. If it does, the notification stream is all there is.
|
||||
|
||||
## The decision
|
||||
|
||||
**Keep the endpoint.** Deleting it costs the parser, the tests and the wg-facing token,
|
||||
and buys nothing while the question above is open. It is off unless `-ambient-token` is
|
||||
set, so an unbuilt relay carries no surface today.
|
||||
|
||||
**Make structured the primary shape.** `/api/ambient` should accept an event with an
|
||||
explicit start, end and title, and store it without parsing anything. Confidence stays
|
||||
below 1.0 and the source stays `ambient:notif`, because the provenance claim is unchanged:
|
||||
this is the phone telling Maven what it sees, not Maven reading a calendar.
|
||||
|
||||
**Keep the free-text shape as the degraded path.** It is what a notification-only relay
|
||||
can send, and it is already written and tested.
|
||||
|
||||
**Delete it instead if** the relay is not going to be built. That is the one answer that
|
||||
closes this without code, and it is his to give.
|
||||
|
||||
## What not to do
|
||||
|
||||
Do not add date parsing to the free-text path yet. That is the patch #432 explicitly
|
||||
refuses to accept as closure, and it is the wrong order: if the relay can send a date, no
|
||||
date parser is needed, and if it cannot, the parser is guessing at a date from text that
|
||||
was never meant to carry one.
|
||||
|
||||
## Follow-on, unrelated to the decision
|
||||
|
||||
`cmd/mavweb/ambient.go:33` records a known gap worth keeping visible: an ambient meeting
|
||||
writes `calendar_event_*` and never `calendar_busy`, so it is good enough to recite and
|
||||
not good enough to suppress a nudge. That is backwards. Suppressing a nudge is the
|
||||
lower-risk use of a low-confidence signal, and reciting one is the higher-risk use. It
|
||||
needs an expiry on the busy level, so it is its own task either way.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Plan: Where mavwaked and mavenclient run
|
||||
|
||||
**The decision Vikunja #463 asked for. Written 04-08-2026.**
|
||||
|
||||
**Verdict: not in compose on homesrv. They run on a client machine in the room he is in.**
|
||||
The transport for that already exists and nothing needs building to allow it. What needs
|
||||
building is a way to check the wake path at all, which is a separate task.
|
||||
|
||||
## The state that prompted this
|
||||
|
||||
`docker-compose.yml` runs mavend, mavsttd, mavttsd, mavweb and mavpoll. `mavwaked` and
|
||||
`mavenclient` appear in no compose file and run as no host process. Both build under
|
||||
`make build`. So the wake word and the voice-activity gate are untested by construction:
|
||||
QA session 1 step 2 covers push-to-talk from `/dash` only, and #287 (voice session
|
||||
quality) can never be more than half-answered while this holds.
|
||||
|
||||
## The reason is not hardware
|
||||
|
||||
homesrv has a microphone. It is a Lenovo IdeaPad 5 Pro, and `/proc/asound/cards` lists
|
||||
the ACP digital mic array with capture devices at `/dev/snd/pcmC1D0c` and
|
||||
`/dev/snd/pcmC2D0c`. Adding `/dev/snd` to compose and joining the `audio` group would
|
||||
work.
|
||||
|
||||
It would also be pointless. A wake-word daemon is worth having in the room he is standing
|
||||
in. homesrv is a server, so its microphone hears the room the server is in, which is not
|
||||
where anybody talks to Maven. Wiring audio into a container to listen to an empty room is
|
||||
work spent on a capability nobody can use.
|
||||
|
||||
## The reason it belongs off-box
|
||||
|
||||
`mavenclient` is a client by name and by design: microphone, then STT, then core, then
|
||||
TTS. It is the one binary in the tree meant to run somewhere else. `mavwaked` is the gate
|
||||
in front of it, so it goes wherever the microphone goes.
|
||||
|
||||
Maven already has components that are not containers on homesrv. `mavupdate` is a
|
||||
host-side tool. The resident model, STT and TTS prefer the workstation and fall back to
|
||||
homesrv (`docs/offload.md`). Off-box is a shape this system already has.
|
||||
|
||||
**And the wire already supports it.** `ipc.Dial` takes a netaddr seam address:
|
||||
a bare path is the unix socket, and `tcp://host:port?token=...` reaches a core on another
|
||||
host, with the token checked in `internal/netaddr` before `internal/ipc` sees the
|
||||
connection. So a client machine reaching mavend over wg or the LAN needs no new protocol
|
||||
work. It needs mavend to listen on TCP, which `deploy/mavend.json` does not currently ask
|
||||
for.
|
||||
|
||||
## What this means for the QA plan
|
||||
|
||||
QA session 1 step 2 should say what it actually covers, which is push-to-talk through
|
||||
`/dash`. It should not read as though it covers the voice loop. The wake path is checked
|
||||
on the client machine or it is not checked, and today there is no client machine.
|
||||
|
||||
That is the honest state, and it is worse than the task suggests: this is not a
|
||||
configuration gap that a compose entry closes. Until a machine with a microphone runs
|
||||
`mavwaked` and `mavenclient` against a TCP-listening mavend, `internal/wake` and
|
||||
`cmd/mavwaked`'s VAD are covered by their unit tests and by nothing else.
|
||||
|
||||
## What was wrong in CLAUDE.md
|
||||
|
||||
The daemon table lists all nine binaries with no column for where they run, which is how
|
||||
this went unnoticed for as long as it did. It now says which two are not on the box.
|
||||
+200
-22
@@ -1,17 +1,21 @@
|
||||
# QA plan: checking Maven properly
|
||||
|
||||
*Last verified: 2026-08-02 @ 20aa2d5. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-04 @ 8d816f4. Living doc: correct it in place, do not append.*
|
||||
|
||||
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
||||
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
|
||||
Reconciled 2026-08-04 against the board, after the review stack merged.
|
||||
|
||||
42 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
|
||||
build work. Most sat unverifiable while Maven was down for 11 days. That
|
||||
blocker is gone.
|
||||
The board holds 95 open tasks and 35 of them are `QA:` tasks. The ratio moved
|
||||
because the build backlog grew, not because verification shrank. QA is
|
||||
verification work, not build work, and most of it sat unverifiable while Maven
|
||||
was down for 11 days. That blocker is gone.
|
||||
|
||||
The plan as written on 2026-08-01 named 40 task numbers. Ten open `QA:` tasks were
|
||||
missing and two of the named ones had closed. Every open task now appears below,
|
||||
the eight non-QA ones in the last two sections.
|
||||
Every open `QA:` task appears below. Distrust the count in this header first. It
|
||||
is right on the day it is written and wrong a week later.
|
||||
|
||||
Fourteen ids this plan used to name closed on 2026-08-04 and are gone from it. If
|
||||
you cannot find one, check whether it closed before assuming the plan dropped it.
|
||||
|
||||
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
|
||||
downstream assumes the voice loop works, and nobody has confirmed that since
|
||||
@@ -84,8 +88,71 @@ session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
|
||||
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
|
||||
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
|
||||
|
||||
Steps 1 and 3-6 were run on 02-08-2026 and pass. Steps 2 and 7-9 still need a
|
||||
person at the box, because they need a microphone or a nudge to arrive.
|
||||
Steps 1 and 3-6 were run on 02-08-2026 and pass.
|
||||
|
||||
**Step 2 no longer needs a person, and step 9 has a number now** (04-08-2026).
|
||||
`POST /api/ptt` takes raw PCM16 16kHz mono and answers with audio plus an
|
||||
`X-Reply-Text` header, so the committed STT fixtures stand in for a microphone:
|
||||
|
||||
```sh
|
||||
tail -c +45 cmd/mavsttd/testdata/ru_query.wav > /tmp/q.pcm
|
||||
curl -s --noproxy '*' -D /tmp/h -o /tmp/reply.pcm -X POST \
|
||||
http://127.0.0.1:9201/api/ptt --data-binary @/tmp/q.pcm \
|
||||
-H 'Content-Type: application/octet-stream' -m 180
|
||||
```
|
||||
|
||||
That covers audio in → STT → router → phrasing → TTS audio out. It leaves only
|
||||
browser microphone capture needing a person, and the wake path needing a machine.
|
||||
Do not post `en_act.wav` without deciding first: it is a mutating act.
|
||||
|
||||
**Steps 7 and 8 still cannot run, but 15 is no longer the reason** (04-08-2026).
|
||||
The desk presence poster is installed on workpc. It is a `maven-desk` systemd
|
||||
user timer on a 60s cadence, gated by hypridle at 120s idle. `desk_active` facts
|
||||
now arrive, and the first landed at 18:43.
|
||||
|
||||
What blocks the two steps now is that no rule wants to fire. `/trace` shows all
|
||||
five at `predicate`, none inert:
|
||||
|
||||
| rule | sev | why it is false |
|
||||
|---|---|---|
|
||||
| water | 1 | needs ≥3h since the last `water` fact; step 2's `ru_fact` wrote one |
|
||||
| meal | 1 | needs ≥6h since a `meal` fact; none exists |
|
||||
| break | 2 | needs both `desk_active` and a `break` fact; `break` has never been written |
|
||||
| service_down | 4 | no kuma monitor is down |
|
||||
| netdata_critical | 3 | nothing critical |
|
||||
|
||||
So the honest way to run step 8 is to wait three hours after the last `water`
|
||||
fact, or to write one antedated. Do not read the water rule's silence as a defect.
|
||||
|
||||
**The sev4 telegram reach works** (04-08-2026). Resuming a paused kuma monitor
|
||||
for paperless, which is genuinely down, put a real `service_down` through the
|
||||
whole path with presence away:
|
||||
|
||||
```
|
||||
23:03 voicesink: no live voice session for service_down, falling through to away channels
|
||||
/notifications: 19:03 | service_down | telegram | pending | Сервис перестал отвечать.
|
||||
04.08 23:03 | nudge | service_down | telegram | sent | 23:03
|
||||
```
|
||||
|
||||
`ChannelsFor(Sev4, Away)` returned telegram, the send succeeded, and the row
|
||||
holds at `pending` because sev4 repeats until acked. The 15:51 row shows the
|
||||
same rule reaching `acted` earlier, so the ack path works too.
|
||||
|
||||
The body was `Сервис перестал отвечать.`, which names no service. That is a bug
|
||||
and it is deterministic, filed as **534**. `nudgeValues` fills `{service}` from
|
||||
`State.Fact("service_down")`, an exact key mavpoll stopped writing when
|
||||
per-monitor facts landed. Nine of the ten templates carry `{service}`, so all
|
||||
nine are rejected as unfillable. The one nameless variant is left as the only
|
||||
usable one, every time. The stub and LLM phrasers both call `loop.DownServices`
|
||||
and get it right. The template path is the one that runs.
|
||||
|
||||
**Presence itself has a real defect, filed as 532.** `SavePresenceState` has no
|
||||
caller outside tests, so the singleton row is never written. The gate is fine,
|
||||
because it reads the bucket `GatherState` computes in memory each tick. Two
|
||||
things follow. Hysteresis is dead, because `lastBucket` is always cold-start `Away`
|
||||
and the 0.30-0.55 hold band never applies. And every presence readout lies:
|
||||
`/dash` shows `away — score 0.00 (never)` with fresh `desk_active` facts arriving
|
||||
every 60s. Do not trust that number while checking anything else here.
|
||||
|
||||
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
|
||||
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
|
||||
@@ -105,9 +172,36 @@ turns look misaligned when they are not.
|
||||
**Passes** (02-08-2026, five turns): `я рада`, `поняла`, `помогла`,
|
||||
`проверила`, `записала`, `грустна`, `ты` throughout, no pet names.
|
||||
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
|
||||
back. This is the only check that covers mic to STT to core to TTS to
|
||||
speaker as one path. It is also the path the eleven-day outage most likely
|
||||
broke.
|
||||
back. This covers browser mic to STT to core to TTS as one path. It does
|
||||
**not** cover the wake word or the voice-activity gate, and no step here
|
||||
does — see below.
|
||||
**Passes below the browser** (04-08-2026, three fixtures through `/api/ptt`):
|
||||
HTTP 200, `audio/l16;rate=16000;channels=1`, and real speech back. `ru_query`
|
||||
answered `на 04.08.2026 ничего нет.` in 3.82s of audio at RMS 3865, `ru_fact`
|
||||
answered `отметила: water = выпил`, `ru_reminder` answered `хорошо, напомню.`
|
||||
at `intent=reminder`.
|
||||
**Passes in the browser too** (04-08-2026), and it needed no person. Headless
|
||||
Chrome takes a fake microphone, so the whole browser half runs unattended:
|
||||
|
||||
```sh
|
||||
chrome --headless=new --remote-debugging-port=9333 --remote-allow-origins='*' \
|
||||
--use-fake-device-for-media-stream --use-fake-ui-for-media-stream \
|
||||
--use-file-for-fake-audio-capture=cmd/mavsttd/testdata/ru_query.wav%noloop
|
||||
```
|
||||
|
||||
Then drive it over the debug protocol: click `#btn`, wait, click again, read
|
||||
`#status` and `#log`. That covers `getUserMedia`, `MediaRecorder`, the webm
|
||||
decode and the hand-written resample to 16k Int16. It logged
|
||||
`sending 188160 bytes`, which is 5.88s at 16k mono, and got the reply back.
|
||||
|
||||
**The button is on `/`, not `/dash`.** `handleVoice` serves it at the root
|
||||
(`main.go:332`). `/dash` is the presence and fact dashboard and carries no
|
||||
`#btn`. This step said `/dash` until 04-08-2026.
|
||||
|
||||
One defect fell out, filed as **533**. The reply logged as
|
||||
`на+04.08.2026+ничего+нет.` The header is escaped with `url.QueryEscape`,
|
||||
which writes a space as `+`, then decoded with `decodeURIComponent`, which
|
||||
leaves `+` alone. Transcript only, the audio is fine.
|
||||
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
|
||||
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
|
||||
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
||||
@@ -128,12 +222,42 @@ turns look misaligned when they are not.
|
||||
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
|
||||
and stitch unrelated topics. Asked whether he should move flats, she opened
|
||||
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
|
||||
**The slowness now has a cause and a number** (04-08-2026). A spoken turn
|
||||
takes 32 to 34 seconds. One phrasing call is 30.0s of that. STT is 1.0s
|
||||
and routing is under 10ms. Both interactive calls decoded exactly 512 tokens,
|
||||
which is the phrasing cap. Both were truncated, to produce a reply of
|
||||
under 25 characters.
|
||||
The cause is `responseGrammar`, not the model. Its last rule is
|
||||
`ws ::= [ \t\n]*`, and `*` is unbounded, so the model emits `{` and then
|
||||
satisfies `ws` with whitespace until `max_tokens` stops it. Reproduced on a
|
||||
second server: at `repeat_penalty` 1.0 it runs to 512 and returns
|
||||
`finish_reason=length`, at 1.3 it stops at 24. Bounding the rule to
|
||||
`[ \t\n]{0,4}` gives a clean stop at 33 tokens three times out of three with
|
||||
no penalty at all.
|
||||
Only some callers are exposed. `internal/llm.Req` sends `repeat_penalty` and
|
||||
the replier sets it to 1.3, so that path is protected by accident. `chatReq`
|
||||
in the phraser sends no penalty, so `PhraseChat`, `PhraseQuery`,
|
||||
`PhraseNudge` and `PhraseReminder` all run at the default 1.0. Filed as
|
||||
**531**.
|
||||
Two guesses were wrong on the way and are recorded so nobody repeats them.
|
||||
It is not reasoning tokens: the probe returned `reasoning_content` of length
|
||||
0, and the grammar constrains output from the first token. It is not the
|
||||
`--cache-ram 512` limit either: that is MiB of prompt cache and the 512 that
|
||||
was hit is a token count.
|
||||
The wandering is a second thing and stays on 287.
|
||||
|
||||
**The wake path cannot be checked as deployed.** `mavwaked` and `mavenclient`
|
||||
appear in no compose file and run as no host process. Step 2 covers only
|
||||
push-to-talk, from `/dash` through mavsttd and mavttsd. Wake word and VAD
|
||||
are untested by construction. Decide whether they belong in compose or on a
|
||||
client machine, and say which in the deploy docs. Tracked as **463**.
|
||||
**The wake path cannot be checked here, and that is now the decision rather
|
||||
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
|
||||
no host process. They are not going to. They belong on a client machine in the
|
||||
room he is standing in, because homesrv's microphone is real and in the wrong
|
||||
room — **463**, written up in `docs/plans/17-where-the-voice-loop-runs.md`.
|
||||
|
||||
So the wake word and the VAD gate are covered by their unit tests and by
|
||||
nothing else, and no session at this box changes that. Checking them needs a
|
||||
machine with a microphone running both binaries against a TCP-listening mavend.
|
||||
`ipc.Dial` already speaks `tcp://host:port?token=...`, so the work is a machine
|
||||
and a config line, not protocol work. Until then, **287** can only be
|
||||
half-answered, and step 2 above is push-to-talk, not the voice loop.
|
||||
|
||||
**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
|
||||
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
|
||||
@@ -245,10 +369,12 @@ check that the failure floor catches a mid-session model death.
|
||||
|
||||
These need real use rather than a command, grouped by what one sitting covers.
|
||||
|
||||
**Morning and delivery** (**280**, **281**, **128**, **282**, **283**, **285**):
|
||||
**Morning and delivery** (**280**, **281**, **128**, **283**, **285**):
|
||||
open `/morning`, walk the seven required behaviours, then check the four
|
||||
interruption outcomes and the digest gap. **282** needs the `desk_active` script
|
||||
enabled on the desk PC first, which is **15** and needs you at that machine.
|
||||
interruption outcomes and the digest gap. The presence half of this sitting
|
||||
cannot run. `desk-active.sh` is on workpc, but no systemd user unit enables it,
|
||||
so no `desk_active` fact has ever been written. That is **15** and needs you at
|
||||
that machine.
|
||||
**283** is the event intake envelope every reach shares, so a delivery check
|
||||
exercises it whether you name it or not. **285** is not verification: the bridge
|
||||
framework works and the remaining ask is more adapters. Decide which reach comes
|
||||
@@ -374,6 +500,36 @@ With Praxis stopped the card reads `praxis — unreachable` while Nexus and Hexi
|
||||
keep rendering. On `docker start` the card returns to `nothing needs attention.`
|
||||
with no mavend restart. Independent degradation and recovery both hold.
|
||||
|
||||
**Workstation offload** (**492**): never run, and added to this plan on
|
||||
2026-08-04. It covers **485**, which shipped in PR #97. Three states, one rule:
|
||||
silent when the workstation would only do the job better, named when the resident
|
||||
model cannot do the job at all.
|
||||
|
||||
1. **Card free.** mavgpud 200, llama-server holding gemma-4-12b. A routing turn
|
||||
and a phrased reply both complete through the workstation. Confirm that from
|
||||
the mavgpud request log, not from the answer sounding good. Nothing in the
|
||||
answer says where it was phrased.
|
||||
2. **Card held.** Start a training run so mavgpud yields and answers 503. The same
|
||||
turns complete on Qwen3-1.7B with no mention of the fallback. Then kill the
|
||||
card mid-utterance, with a request in flight. That is the case no unit test
|
||||
reaches and the one most likely to hang.
|
||||
3. **Machine asleep.** Suspend workpc. It must be indistinguishable from held.
|
||||
Bring it back and confirm the prober re-admits it inside one 15s interval, with
|
||||
no mavend restart.
|
||||
|
||||
Two things are likely wrong. A remote that accepts the connection and then never
|
||||
answers is worse than a 503. `timeout` is 90s, so measure what a turn waits. And
|
||||
two models mean two prompt renderings: `check_prompt_parity.py` guards Go against
|
||||
the relabelling prompt, not gemma against Qwen, so confirm `{"response","mood"}`
|
||||
parses from both.
|
||||
|
||||
**workpc is running training as of 2026-08-04**, so the held state is available
|
||||
today and the free state is not. Run step 2 first, out of order.
|
||||
|
||||
Write down one number at the end. Read the mavgpud journal and record the
|
||||
fraction of a working week the card is free. That is what **488** left open, and
|
||||
it decides whether the offload is worth carrying.
|
||||
|
||||
**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
|
||||
neither lever can be pulled on this box. See **477**.
|
||||
|
||||
@@ -471,6 +627,25 @@ at any address (**478**). `allow_private` does work, measured both ways.
|
||||
`CaptureStart`. There is no `cmd/mavheard`, no mavweb route, and `mavenclient`
|
||||
never calls it (**480**). Two of its QA steps are also stale.
|
||||
|
||||
**Four stale QA steps were rewritten on 04-08-2026** under **480**, against the
|
||||
code rather than against what the plans said. All four failed the same way: the
|
||||
daemon was right and the step described an older daemon.
|
||||
|
||||
| Step | Said | Says now |
|
||||
|---|---|---|
|
||||
| 253/3 | boots with the methods unknown | refuses to boot, `config.go:1651` |
|
||||
| 253/10 | no `:transcript` note by default | true only with a summary present |
|
||||
| 255/5 | `speaker: enrolment on, recognition BLOCKED` | that line is gone, the capability stays off |
|
||||
| 252/3 | `vision: stored image <id-prefix>` | `vision: stored <id>`, `vision.go:199` |
|
||||
|
||||
Two of them are worth reading past the correction. 253/10 was false in exactly
|
||||
the scenario 253/16 creates, because `writeNotes` saves the transcript whenever
|
||||
the summary is empty so a dead llama-server does not lose the meeting. And 255/5
|
||||
changed because `Recognizes()` was written as the gate, documented as one, and
|
||||
never called — calling it turned `enabled` with no model from a half-working
|
||||
capability into a refusal. Enrolling into a store nothing can match against is
|
||||
not a working half.
|
||||
|
||||
**257, netscan.** Steps 2, 3 and 9 pass at unit level. Step 1 fails. Steps 4 to 8
|
||||
need the block enabled. Step 10 is Bluetooth and stays skipped.
|
||||
|
||||
@@ -523,7 +698,7 @@ Not QA. These are blocked on a decision or a credential only you have.
|
||||
| # | what |
|
||||
|---|---|
|
||||
| 16 | Create the Kuma API key. `-kuma-key uk5_mavpoll-key` in `docker-compose.yml` is still the placeholder. |
|
||||
| 15 | Deploy `desk_active` on the desk PC. Blocks **282**. |
|
||||
| 15 | Enable the `desk_active` units on workpc. The script is there; the timer is `not-found`, so the strongest presence signal writes nothing. Blocks the presence half of session 3. |
|
||||
| 122 | Finish the CPT run for Qwen3-1.7B. The persona fix depends on it. |
|
||||
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
|
||||
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
|
||||
@@ -557,8 +732,11 @@ where they land, so the board stops reading as 50 things Maven owes.
|
||||
31ms. The router buys about 4 points of accuracy for four orders of magnitude
|
||||
of latency. Whether that still earns its place is now an open question.
|
||||
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
|
||||
5. Session 3, split whichever way suits you. All five sittings ran on
|
||||
5. Session 3, split whichever way suits you. Five of its six sittings ran on
|
||||
02-08-2026. Read the per-sitting notes before repeating any of them.
|
||||
6. The workstation offload sitting (**492**), which has never run. It is last
|
||||
because it is newest, not because it matters least. It is the one sitting whose
|
||||
subject changes state on its own.
|
||||
|
||||
The next thing to fix is not in this plan. Four defects say the same sentence:
|
||||
a capability is built and no utterance reaches it. **466** (a clarify is global),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maven — Re-architecture (Qwen3 resident model, revised 2026-07-18)
|
||||
|
||||
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-04 @ b6abb19. Living doc: correct it in place, do not append.*
|
||||
|
||||
> Supersedes the classifier-first routing model. Agreed in a design session
|
||||
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
|
||||
@@ -40,8 +40,10 @@ utterance
|
||||
are deferred until the main feature set is complete.
|
||||
- **Embedder demoted from router to tool** — it now backs `memory.search`
|
||||
(RAG) and gives the router a cheap "similar past notes/intents" hint. The
|
||||
router no longer depends on it clearing a threshold. Upgrade MiniLM → bge-m3
|
||||
for better RU retrieval later (model swap, not architecture).
|
||||
router no longer depends on it clearing a threshold. The MiniLM upgrade is
|
||||
done: it is multilingual-e5-small, asymmetric, with the `query:`/`passage:`
|
||||
prefixes (Vikunja #371, `docs/evals/2026-08-04-recall-e5-small.md`). A
|
||||
further swap is a model swap, not architecture (Vikunja #412).
|
||||
|
||||
### Router output
|
||||
- Constrained structured JSON action `{tool, args, escalate}` — NOT free-form
|
||||
|
||||
@@ -10,12 +10,15 @@ require (
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require github.com/kami/hexis v0.0.0
|
||||
require (
|
||||
github.com/aaaton/golem/v4 v4.0.2
|
||||
github.com/aaaton/golem/v4/dicts/ru v0.0.0-20250408131944-3488790fc110
|
||||
github.com/kami/hexis v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kami/praxis v0.0.0
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -25,8 +28,4 @@ require (
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
|
||||
|
||||
replace github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus
|
||||
|
||||
replace github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
github.com/aaaton/golem/v4 v4.0.0/go.mod h1:OfK/S5v9Exsx1yO21WorREuIVV+Y5K2hygP0A9oJCCI=
|
||||
github.com/aaaton/golem/v4 v4.0.2 h1:m4FvpSL8Zcv7XjmrKiBP7dp5FzhPCji9FcQRcH6T23k=
|
||||
github.com/aaaton/golem/v4 v4.0.2/go.mod h1:OfK/S5v9Exsx1yO21WorREuIVV+Y5K2hygP0A9oJCCI=
|
||||
github.com/aaaton/golem/v4/dicts/ru v0.0.0-20250408131944-3488790fc110 h1:aRLhKltXUyZg/7DoZE5PcNhETfTjMBXBEzD/JVRVVN4=
|
||||
github.com/aaaton/golem/v4/dicts/ru v0.0.0-20250408131944-3488790fc110/go.mod h1:n14MqOgbLBidXRIvLw9H3/vFyE4+PcjVxYOu05f55R4=
|
||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
)
|
||||
|
||||
// Ambient events — the work calendar read (Vikunja #126).
|
||||
@@ -48,18 +50,6 @@ type Notification struct {
|
||||
// reposting a notification for a meeting already under way.
|
||||
const ambientPastGrace = 2 * time.Hour
|
||||
|
||||
// dayWords maps the words that move a notification off Posted's day. Only
|
||||
// explicit ones: an offset is a claim about which day, and guessing which day
|
||||
// is exactly the guess this parse refuses to make.
|
||||
var dayWords = map[string]int{
|
||||
"завтра": 1,
|
||||
"tomorrow": 1,
|
||||
"сегодня": 0,
|
||||
"today": 0,
|
||||
"tonight": 0,
|
||||
"послезавтра": 2,
|
||||
}
|
||||
|
||||
// EventFromNotification turns a notification into the event it describes, or
|
||||
// reports false when it does not clearly describe one.
|
||||
//
|
||||
@@ -68,7 +58,35 @@ var dayWords = map[string]int{
|
||||
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
||||
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
||||
// time gets DefaultReminderDuration.
|
||||
//
|
||||
// The clock reading is read in the daemon's zone (Vikunja #482). Posted is an
|
||||
// instant and carries an offset; "созвон в 14:30" is a wall clock and carries
|
||||
// none, so the zone has to come from somewhere else. A relay that posts
|
||||
// "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30
|
||||
// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a
|
||||
// UTC box. The owner's phone and the box share a zone, so the box's zone is the
|
||||
// honest reading of a bare wall clock.
|
||||
//
|
||||
// See EventFromNotificationIn, which takes the zone explicitly.
|
||||
func EventFromNotification(n Notification) (Event, bool) {
|
||||
return EventFromNotificationIn(n, time.Local)
|
||||
}
|
||||
|
||||
// EventFromNotificationIn is EventFromNotification against an explicit zone.
|
||||
//
|
||||
// "созвон в 14:30" carries no zone, and nobody writing a phone notification
|
||||
// means 14:30Z. The wall clock used to be resolved against Posted's own zone,
|
||||
// and a phone posts an RFC 3339 instant ending in Z, so every ambient meeting
|
||||
// on this box landed four hours late. The size of the error is the deploy's UTC
|
||||
// offset, which is why it is invisible on a UTC box and wrong everywhere else.
|
||||
//
|
||||
// Posted stays an instant in its own zone: it says when the phone showed the
|
||||
// notification, and the past-grace check compares instants. Only the day and
|
||||
// the wall clock are read in loc.
|
||||
func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
if n.Posted.IsZero() {
|
||||
return Event{}, false
|
||||
}
|
||||
@@ -82,8 +100,9 @@ func EventFromNotification(n Notification) (Event, bool) {
|
||||
return Event{}, false
|
||||
}
|
||||
|
||||
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
|
||||
loc := n.Posted.Location()
|
||||
// The day is Posted's LOCAL day: a notification posted at 23:30Z saying
|
||||
// "завтра в 09:00" is already tomorrow where he is standing.
|
||||
y, m, d := n.Posted.In(loc).AddDate(0, 0, dayOffset(line)).Date()
|
||||
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
|
||||
// Too far in the past to be the meeting this notification is about. The day
|
||||
// was inferred, so the honest reading is that the inference was wrong.
|
||||
@@ -104,11 +123,16 @@ func EventFromNotification(n Notification) (Event, bool) {
|
||||
}
|
||||
|
||||
// dayOffset reports how many days off Posted's day the notification puts the
|
||||
// event. Words are matched whole, so "послезавтра" is not read as "завтра".
|
||||
// event. Only explicit words move it: an offset is a claim about which day, and
|
||||
// guessing which day is exactly the guess this parse refuses to make.
|
||||
//
|
||||
// The words are a closed class and live complete in internal/lexicon (Vikunja
|
||||
// #525), which is how "позавчера" resolves here now — it never did while the map
|
||||
// was inline. Matched whole, so "послезавтра" is not read as "завтра".
|
||||
func dayOffset(line string) int {
|
||||
for _, f := range strings.Fields(strings.ToLower(line)) {
|
||||
f = strings.Trim(f, ".,;:!?—–-()\"'«»")
|
||||
if off, ok := dayWords[f]; ok {
|
||||
if off, ok := lexicon.DayOffset(f); ok {
|
||||
return off
|
||||
}
|
||||
}
|
||||
@@ -121,7 +145,7 @@ func dayOffset(line string) int {
|
||||
func stripDayWords(s string) string {
|
||||
out := make([]string, 0, 8)
|
||||
for _, f := range strings.Fields(s) {
|
||||
if _, ok := dayWords[strings.Trim(strings.ToLower(f), ".,;:!?—–-()\"'«»")]; ok {
|
||||
if _, ok := lexicon.DayOffset(strings.Trim(f, ".,;:!?—–-()\"'«»")); ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A bare clock reading in a notification is read in the daemon's zone, so every
|
||||
// test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is
|
||||
// the offset the 18:30 bug was measured at, so a regression shows up as four
|
||||
// hours rather than as nothing at all on a UTC runner.
|
||||
func TestMain(m *testing.M) {
|
||||
time.Local = time.FixedZone("+04", 4*3600)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestEventFromNotification(t *testing.T) {
|
||||
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
|
||||
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -74,12 +84,12 @@ func TestEventFromNotification(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
ev, ok := EventFromNotificationIn(Notification{
|
||||
Package: "com.google.android.gm",
|
||||
Title: tt.title,
|
||||
Text: tt.text,
|
||||
Posted: posted,
|
||||
})
|
||||
}, posted.Location())
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||
}
|
||||
@@ -98,8 +108,8 @@ func TestEventFromNotification(t *testing.T) {
|
||||
if !ev.End.After(ev.Start) {
|
||||
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
||||
}
|
||||
// The event lands on the day the phone showed it, in the phone's
|
||||
// location — not shifted into UTC.
|
||||
// The event lands on the day the phone showed it, in the zone it
|
||||
// was resolved against — never shifted into UTC.
|
||||
if ev.Start.Location() != posted.Location() {
|
||||
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||
}
|
||||
@@ -115,8 +125,7 @@ func TestEventFromNotification(t *testing.T) {
|
||||
// the meeting twelve hours in the past and filed it under today in FactKey. A
|
||||
// wrong meeting stored is worse than nothing stored.
|
||||
func TestEventFromNotificationDayWords(t *testing.T) {
|
||||
loc := time.FixedZone("+04", 4*3600)
|
||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
|
||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -162,10 +171,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
ev, ok := EventFromNotificationIn(Notification{
|
||||
Package: "com.google.android.calendar",
|
||||
Title: tt.title, Text: tt.text, Posted: tt.posted,
|
||||
})
|
||||
}, tt.posted.Location())
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||
}
|
||||
@@ -189,10 +198,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
||||
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
|
||||
// that into the key.
|
||||
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
ev, ok := EventFromNotificationIn(Notification{
|
||||
Title: "Завтра Планёрка 09:00",
|
||||
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
|
||||
})
|
||||
}, time.UTC)
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
}
|
||||
@@ -201,6 +210,31 @@ func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Vikunja #482. A relay that posts its instant as UTC used to hand the wall
|
||||
// clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z
|
||||
// and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset,
|
||||
// and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z.
|
||||
func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
Package: "com.slack",
|
||||
Title: "Standup",
|
||||
Text: "созвон в 14:30",
|
||||
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
}
|
||||
if got := ev.Start.Format("15:04"); got != "14:30" {
|
||||
t.Errorf("start = %s, want 14:30 local", got)
|
||||
}
|
||||
if ev.Start.Location() != time.Local {
|
||||
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
|
||||
}
|
||||
if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want {
|
||||
t.Errorf("fact key = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||
t.Error("a notification with no posted_at has no date to sit on")
|
||||
@@ -209,10 +243,10 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||
|
||||
// An ambient event must never be indistinguishable from a calendar read.
|
||||
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
ev, ok := EventFromNotificationIn(Notification{
|
||||
Title: "Планёрка 10:00-10:30",
|
||||
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||
})
|
||||
}, time.UTC)
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
}
|
||||
@@ -237,3 +271,52 @@ func TestStripClock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The defect this file's zone handling exists for: a phone posts an RFC 3339
|
||||
// instant ending in Z, and "созвон в 14:30" used to be resolved against that
|
||||
// Z, so on a UTC+4 box the meeting was stored at 18:30. A wall clock in a
|
||||
// notification is local by construction.
|
||||
func TestNotificationClockIsLocalNotUTC(t *testing.T) {
|
||||
samara := time.FixedZone("+04", 4*3600)
|
||||
n := Notification{
|
||||
Package: "com.slack",
|
||||
Title: "Standup",
|
||||
Text: "созвон в 14:30",
|
||||
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
|
||||
}
|
||||
ev, ok := EventFromNotificationIn(n, samara)
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
}
|
||||
if got := ev.Start.Format("15:04"); got != "14:30" {
|
||||
t.Errorf("start = %s local, want 14:30", got)
|
||||
}
|
||||
if got := ev.Start.UTC().Format("15:04"); got != "10:30" {
|
||||
t.Errorf("start = %sZ, want 10:30Z (14:30 at UTC+4)", got)
|
||||
}
|
||||
if ev.Start.Location() != samara {
|
||||
t.Errorf("location = %v, want %v", ev.Start.Location(), samara)
|
||||
}
|
||||
// The stored key is the local day, so it files under the day he lived.
|
||||
if want, got := "calendar_event_20260802_Standup", FactKeyIn(ev, samara); got != want {
|
||||
t.Errorf("FactKey = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A notification posted late in the UTC evening is already the next day where
|
||||
// he is standing. The day must come from the local clock, not from Posted's.
|
||||
func TestNotificationDayIsTheLocalDay(t *testing.T) {
|
||||
samara := time.FixedZone("+04", 4*3600)
|
||||
n := Notification{
|
||||
Title: "Планёрка 09:00",
|
||||
Text: "завтра",
|
||||
Posted: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), // 03-08 01:00 local
|
||||
}
|
||||
ev, ok := EventFromNotificationIn(n, samara)
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
}
|
||||
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 4 {
|
||||
t.Errorf("date = %d-%02d-%02d, want 2026-08-04 (tomorrow, locally)", y, m, d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +149,11 @@ type Config struct {
|
||||
//
|
||||
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
||||
// this only subtracts. It exists because a rule can be right in principle
|
||||
// and useless in practice — kuma's service_down cannot name the service it
|
||||
// is nudging about (Vikunja #444), so being told "a service on homesrv is
|
||||
// down" every fifteen minutes is noise with no action attached. Turning it
|
||||
// off beats learning to ignore her.
|
||||
// and useless in practice. service_down was the case that forced it: it
|
||||
// could not name the service it was nudging about, so being told "a service
|
||||
// on homesrv is down" every fifteen minutes was noise with no action
|
||||
// attached. That is fixed — one fact per kuma monitor — and the rule ships
|
||||
// enabled again. The escape hatch stays.
|
||||
//
|
||||
// A disabled rule is never gathered for, never evaluated, and never
|
||||
// delivered on any channel. Unknown names are ignored, so removing a rule
|
||||
|
||||
@@ -42,12 +42,27 @@ type Turn struct {
|
||||
Text string // raw utterance
|
||||
}
|
||||
|
||||
// Candidate — one item she just read out loud, kept so his next words can
|
||||
// pick it ("второй", "первую сделал"). Vikunja #448.
|
||||
//
|
||||
// Bound at the moment she speaks the list, not resolved afterwards: the list
|
||||
// can change between two turns, and "второй" means the second thing she said,
|
||||
// not the second row of a fresh query.
|
||||
type Candidate struct {
|
||||
Kind string // what it is, e.g. "task" — the resolver dispatches on this
|
||||
Ref int64 // the row it points at
|
||||
Label string // what she called it, so she can repeat it back
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Intent Intent
|
||||
Slots Slots
|
||||
Timestamp time.Time
|
||||
TTL time.Duration
|
||||
History []Turn // most recent turns, newest last; used for anaphora + cross-intent
|
||||
// Candidates — the list she just offered, in the order she said it. Empty
|
||||
// on every turn that offered no choice, which is most of them.
|
||||
Candidates []Candidate
|
||||
}
|
||||
|
||||
func (s *Session) IsExpired(now time.Time) bool {
|
||||
@@ -143,6 +158,26 @@ func (s *SessionStore) Put(id string, sess *Session) {
|
||||
s.save(id, sess)
|
||||
}
|
||||
|
||||
// SetCandidates attaches a just-spoken list to the live session.
|
||||
//
|
||||
// In place rather than through Put, because the turn was already remembered by
|
||||
// the time the answer was built: replacing the session here would drop the
|
||||
// slots the next follow-up inherits. No session, no candidates — a choice with
|
||||
// no turn behind it has nothing to be a choice about.
|
||||
func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.sessions[id]
|
||||
if ok && !sess.IsExpired(now) {
|
||||
sess.Candidates = cands
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
s.save(id, sess)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(id string) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, id)
|
||||
|
||||
+42
-129
@@ -184,6 +184,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).
|
||||
//
|
||||
@@ -678,135 +720,6 @@ type acceptProposedRoutineReq struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
||||
// for modules that live in-process for now (router, delivery) and by tests,
|
||||
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
||||
// - the client proxy (client.go) — same interface, over the wire.
|
||||
//
|
||||
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
||||
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
||||
// That swappability is the seam the auth layer will insert into without
|
||||
// touching the module code.
|
||||
type CoreAPI interface {
|
||||
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
||||
LatestFact(ctx context.Context, key string) (Fact, error)
|
||||
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
||||
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
||||
Presence(ctx context.Context) (Presence, error)
|
||||
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
||||
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||
// every status (Vikunja #390).
|
||||
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||
|
||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||
// RecentNotesFromSource — the newest n notes whose source starts with
|
||||
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
||||
// from recall, so this is the only way to reach them, and it keeps the feed
|
||||
// answer from being crowded out of a fixed window by his own notes.
|
||||
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
||||
|
||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||
// destructive and flips status to 'enabled'. DisableTool reverts an
|
||||
// enabled tool back to proposed (it stays in the store, won't run).
|
||||
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
||||
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
||||
// LookupTool/ListTools read them.
|
||||
// scope defaults to "homelab" when empty.
|
||||
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
||||
DisableTool(ctx context.Context, name string) error
|
||||
DeleteTool(ctx context.Context, name string) error
|
||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||
RevertFact(ctx context.Context, key string) (int64, error)
|
||||
|
||||
// ListProposedRoutines returns proposed routines, newest first.
|
||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||
// intake seam for the voice path, the web form and the future email
|
||||
// extractor. Idempotent per live normalised text; the response says
|
||||
// whether a row was actually created.
|
||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||
// "live" is candidate + open (outstanding work).
|
||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
// 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
|
||||
|
||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
// persisted — it's a daemon-level cache).
|
||||
TickTrace(ctx context.Context) (TickTrace, 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. The store adapter returns an error
|
||||
// (morning routines are daemon-config, not persisted) — same shape as
|
||||
// TickTrace.
|
||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||
|
||||
// MCPServers reports the configured MCP servers and their health
|
||||
// (Vikunja #251). Read-only introspection for /tools — there is no
|
||||
// "call this tool" method on purpose: an MCP tool runs through the same
|
||||
// allowlist, confirm turn and act path as any other tool, and a second
|
||||
// mutation path would be a second thing to get wrong. Empty when the
|
||||
// mcp config block is absent, which is the default.
|
||||
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
||||
|
||||
// DayPlan returns today's ordered plan — calendar events, pending
|
||||
// reminders and any morning checklist still outstanding (see
|
||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||
// The store adapter returns an error (the plan needs the daemon's routine
|
||||
// config) — same shape as TickTrace and MorningStatus.
|
||||
DayPlan(ctx context.Context) (DayPlan, error)
|
||||
|
||||
// Chat routes a text utterance through the reactive handler's core path
|
||||
// (router → dialogue → action → replier) and returns the reply text.
|
||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||
//
|
||||
// conversation names the thread. A parked clarifying question is held per
|
||||
// conversation, so an unanswered question on one reach cannot eat the next
|
||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||
// tap and is still one conversation of its own, separate from the mic.
|
||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||
|
||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||
// it came from: a relayed notification, a mail candidate, a feed item, a
|
||||
// changed page, a spend, a presence probe.
|
||||
//
|
||||
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
||||
// the store adapter returns an error, because the journal is a bounded
|
||||
// in-memory ring and not a table. Its contents are a window over intake,
|
||||
// never the durable record — that is still the fact, note or task the
|
||||
// intake path wrote.
|
||||
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
||||
}
|
||||
|
||||
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
|
||||
// event.Event field for field; the ipc package does not import internal/event
|
||||
// so the wire shape stays independent of the in-process type.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CoreAPI and the eight domain interfaces it composes (Vikunja #408).
|
||||
//
|
||||
// It used to be one flat block of forty methods, and the cost of that shape
|
||||
// was paid three times over: every method added an arm to the dispatcher, a
|
||||
// stub to the daemon's locked API, and a stub to every test double. Two of
|
||||
// those three are already gone — the dispatcher is a table (methodTable in
|
||||
// server.go), and UnimplementedCoreAPI took the padding out of the doubles and
|
||||
// out of lockedAPI, which no longer exists.
|
||||
//
|
||||
// What was left is the interface itself, and this file is that half. CoreAPI
|
||||
// is unchanged as a type: the same forty methods, in the same order, so the
|
||||
// wire contract, the client proxy and the store adapter are all untouched.
|
||||
// What it gains is a named seam per domain, so a caller that only reads facts
|
||||
// can say FactAPI and a reader can see which cluster a method belongs to
|
||||
// without counting lines.
|
||||
//
|
||||
// Add a method to the domain it belongs to, not to CoreAPI.
|
||||
|
||||
// FactAPI — the fact store: write, read the current value, read history, and
|
||||
// undo. Presence sits here because it is a hysteresis view over presence
|
||||
// facts, not a store of its own.
|
||||
type FactAPI interface {
|
||||
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
||||
LatestFact(ctx context.Context, key string) (Fact, error)
|
||||
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
||||
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
||||
Presence(ctx context.Context) (Presence, error)
|
||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||
RevertFact(ctx context.Context, key string) (int64, error)
|
||||
}
|
||||
|
||||
// ReminderAPI — scheduled sends the owner asked for.
|
||||
type ReminderAPI interface {
|
||||
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
||||
}
|
||||
|
||||
// NudgeAPI — proactive sends Maven proposed, their outcomes, and the outbox
|
||||
// they went out through.
|
||||
type NudgeAPI interface {
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
||||
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||
// every status (Vikunja #390).
|
||||
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||
}
|
||||
|
||||
// NoteAPI — free text he captured, plus the embedded recall over it.
|
||||
type NoteAPI interface {
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||
// RecentNotesFromSource — the newest n notes whose source starts with
|
||||
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
||||
// from recall, so this is the only way to reach them, and it keeps the feed
|
||||
// answer from being crowded out of a fixed window by his own notes.
|
||||
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
||||
}
|
||||
|
||||
// ToolAPI — the capability allowlist and its read side.
|
||||
//
|
||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||
// destructive and flips status to 'enabled'. DisableTool reverts an
|
||||
// enabled tool back to proposed (it stays in the store, won't run).
|
||||
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
||||
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
||||
// LookupTool/ListTools read them.
|
||||
// scope defaults to "homelab" when empty.
|
||||
type ToolAPI interface {
|
||||
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
||||
DisableTool(ctx context.Context, name string) error
|
||||
DeleteTool(ctx context.Context, name string) error
|
||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||
|
||||
// MCPServers reports the configured MCP servers and their health
|
||||
// (Vikunja #251). Read-only introspection for /tools — there is no
|
||||
// "call this tool" method on purpose: an MCP tool runs through the same
|
||||
// allowlist, confirm turn and act path as any other tool, and a second
|
||||
// mutation path would be a second thing to get wrong. Empty when the
|
||||
// mcp config block is absent, which is the default.
|
||||
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
||||
}
|
||||
|
||||
// RoutineAPI — the shapes of his day: routines Maven noticed and proposed, the
|
||||
// morning checklist, and today's plan.
|
||||
//
|
||||
// MorningStatus and DayPlan are daemon-computed rather than stored, so the
|
||||
// store adapter returns an error for both — the same shape as TickTrace.
|
||||
type RoutineAPI interface {
|
||||
// ListProposedRoutines returns proposed routines, newest first.
|
||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
||||
// 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.
|
||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||
|
||||
// DayPlan returns today's ordered plan — calendar events, pending
|
||||
// reminders and any morning checklist still outstanding (see
|
||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||
DayPlan(ctx context.Context) (DayPlan, error)
|
||||
}
|
||||
|
||||
// TaskAPI — outstanding work, whatever surface it arrived from.
|
||||
type TaskAPI interface {
|
||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||
// intake seam for the voice path, the web form and the future email
|
||||
// extractor. Idempotent per live normalised text; the response says
|
||||
// whether a row was actually created.
|
||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||
// "live" is candidate + open (outstanding work).
|
||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
// 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
|
||||
}
|
||||
|
||||
// SystemAPI — what the daemon knows about itself, plus the one method that
|
||||
// runs a whole turn.
|
||||
type SystemAPI interface {
|
||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
// persisted — it's a daemon-level cache).
|
||||
TickTrace(ctx context.Context) (TickTrace, error)
|
||||
|
||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||
|
||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||
// it came from: a relayed notification, a mail candidate, a feed item, a
|
||||
// changed page, a spend, a presence probe.
|
||||
//
|
||||
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
||||
// the store adapter returns an error, because the journal is a bounded
|
||||
// in-memory ring and not a table. Its contents are a window over intake,
|
||||
// never the durable record — that is still the fact, note or task the
|
||||
// intake path wrote.
|
||||
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
||||
|
||||
// Chat routes a text utterance through the reactive handler's core path
|
||||
// (router → dialogue → action → replier) and returns the reply text.
|
||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||
//
|
||||
// conversation names the thread. A parked clarifying question is held per
|
||||
// conversation, so an unanswered question on one reach cannot eat the next
|
||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||
// tap and is still one conversation of its own, separate from the mic.
|
||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||
}
|
||||
|
||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||
// - the in-process store adapter (storeapi.go) — used by the daemon
|
||||
// for modules that live in-process for now (router, delivery) and by tests,
|
||||
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
||||
// - the client proxy (client.go) — same interface, over the wire.
|
||||
//
|
||||
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
||||
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
||||
// That swappability is the seam the auth layer inserts into without touching
|
||||
// the module code.
|
||||
type CoreAPI interface {
|
||||
FactAPI
|
||||
ReminderAPI
|
||||
NudgeAPI
|
||||
NoteAPI
|
||||
ToolAPI
|
||||
RoutineAPI
|
||||
TaskAPI
|
||||
SystemAPI
|
||||
}
|
||||
@@ -36,6 +36,13 @@ const maxFrame = 4 << 20
|
||||
// (we read the length but not the body), so the caller must close it.
|
||||
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
||||
|
||||
// The framing is hand-rolled and it stays that way (Vikunja #410): ninety
|
||||
// lines, readable on the wire with socat, and every standard replacement
|
||||
// brings schema machinery this boundary does not want. The condition is that
|
||||
// it is defended by tests rather than inherited untested — truncated header,
|
||||
// truncated body, partial reads, frame boundaries and a non-JSON body all
|
||||
// live in frame_test.go.
|
||||
//
|
||||
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
|
||||
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
||||
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The framing is hand-rolled, and it is the protocol all nine daemons depend
|
||||
// on, so a bug in it is a bug everywhere (Vikunja #410). The review asked
|
||||
// whether to replace it with something standard. It stays: length-prefixed
|
||||
// JSON over a unix socket is ninety lines, it is readable with socat, and the
|
||||
// alternatives (net/rpc, gRPC, a codec library) all buy schema machinery this
|
||||
// boundary does not want. What was wrong was inheriting it untested. These
|
||||
// are the paths a real socket produces that the round-trip test never does.
|
||||
|
||||
// A short header is not EOF. EOF means the peer closed cleanly between
|
||||
// frames, which the server treats as a normal disconnect; a header that stops
|
||||
// halfway is a truncated frame and must be reported as an error, or a peer
|
||||
// that dies mid-write looks like one that hung up politely.
|
||||
func TestReadFrameTruncatedHeader(t *testing.T) {
|
||||
var v any
|
||||
err := readFrame(bytes.NewReader([]byte{0, 0, 4}), &v)
|
||||
if err == nil {
|
||||
t.Fatal("a three-byte header must fail")
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
t.Fatalf("err = %v, want a truncation error, not EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A header promising more body than follows. Same reasoning: the frame never
|
||||
// arrived, so it must not decode into a zero value the caller then trusts.
|
||||
func TestReadFrameTruncatedBody(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], 32)
|
||||
buf.Write(hdr[:])
|
||||
buf.WriteString(`{"m":"pi`)
|
||||
|
||||
var got map[string]string
|
||||
if err := readFrame(&buf, &got); err == nil {
|
||||
t.Fatal("a body shorter than its prefix must fail")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("decoded %v from a truncated frame", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing at all is EOF, and only this is.
|
||||
func TestReadFrameEmptyIsEOF(t *testing.T) {
|
||||
var v any
|
||||
if err := readFrame(bytes.NewReader(nil), &v); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
// byteAtATime returns one byte per Read, which is what a socket is allowed to
|
||||
// do and what a bytes.Reader never does. readFrame uses io.ReadFull for both
|
||||
// the header and the body; this is the test that would fail if either turned
|
||||
// into a bare Read.
|
||||
type byteAtATime struct {
|
||||
b []byte
|
||||
i int
|
||||
}
|
||||
|
||||
func (r *byteAtATime) Read(p []byte) (int, error) {
|
||||
if r.i >= len(r.b) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
p[0] = r.b[r.i]
|
||||
r.i++
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func TestReadFrameReassemblesPartialReads(t *testing.T) {
|
||||
type payload struct {
|
||||
Msg string `json:"m"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
want := payload{Msg: "привет", N: 7}
|
||||
var buf bytes.Buffer
|
||||
if err := writeFrame(&buf, want); err != nil {
|
||||
t.Fatalf("writeFrame: %v", err)
|
||||
}
|
||||
|
||||
var got payload
|
||||
if err := readFrame(&byteAtATime{b: buf.Bytes()}, &got); err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Two frames written back to back must come back as two frames. A reader that
|
||||
// consumed more than one frame's body would desynchronize the connection, and
|
||||
// the symptom would be a reply attributed to the wrong request.
|
||||
func TestReadFrameStopsAtTheFrameBoundary(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
for _, m := range []string{"first", "second"} {
|
||||
if err := writeFrame(&buf, map[string]string{"m": m}); err != nil {
|
||||
t.Fatalf("writeFrame: %v", err)
|
||||
}
|
||||
}
|
||||
r := &byteAtATime{b: buf.Bytes()}
|
||||
for _, want := range []string{"first", "second"} {
|
||||
var got map[string]string
|
||||
if err := readFrame(r, &got); err != nil {
|
||||
t.Fatalf("readFrame(%s): %v", want, err)
|
||||
}
|
||||
if got["m"] != want {
|
||||
t.Fatalf("got %q, want %q", got["m"], want)
|
||||
}
|
||||
}
|
||||
var extra map[string]string
|
||||
if err := readFrame(r, &extra); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("after two frames: err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A body that is not JSON is an error, not a zero value. The peer is either
|
||||
// broken or not speaking this protocol; either way the caller must not read
|
||||
// on as though it decoded.
|
||||
func TestReadFrameRejectsNonJSONBody(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
var hdr [4]byte
|
||||
body := []byte("not json at all")
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||
buf.Write(hdr[:])
|
||||
buf.Write(body)
|
||||
|
||||
var got map[string]string
|
||||
if err := readFrame(&buf, &got); err == nil {
|
||||
t.Fatal("a non-JSON body must fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// mapErr turns a store sentinel into its wire twin so a module can errors.Is
|
||||
// without importing internal/store. The design is right and the failure mode is
|
||||
// quiet: add a sentinel to store, forget the switch, and the client gets an
|
||||
// untyped error that no caller can branch on. These two tests are the alarm.
|
||||
|
||||
// mapErrPairs — every store sentinel that has a wire twin, and the twin.
|
||||
var mapErrPairs = []struct {
|
||||
name string // the store identifier, for the coverage test below
|
||||
from error
|
||||
wants error
|
||||
}{
|
||||
{"ErrNoFact", store.ErrNoFact, ErrNoFact},
|
||||
{"ErrConfidence", store.ErrConfidence, ErrConfidence},
|
||||
{"ErrVoidsMissing", store.ErrVoidsMissing, ErrVoidsMissing},
|
||||
{"ErrNudgeNotFound", store.ErrNudgeNotFound, ErrNudgeNotFound},
|
||||
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
|
||||
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||
}
|
||||
|
||||
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
||||
// each with the reason it stays store-side. A new sentinel is in neither list
|
||||
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
|
||||
// module can branch on an error is a decision, not a default.
|
||||
var unmappedStoreErrors = map[string]string{
|
||||
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
|
||||
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
|
||||
|
||||
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
|
||||
|
||||
"ErrProposedRoutineNotFound": "no module branches on a routine id that vanished; accept and dismiss are owner clicks",
|
||||
"ErrProposedRoutineExists": "the propose path already reports 'nothing new' through its bool return",
|
||||
"ErrRoutineStatus": "an unknown status is a caller bug, not a state a module recovers from",
|
||||
|
||||
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
|
||||
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
|
||||
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
|
||||
|
||||
// The shopping and packing lists follow the task verdicts exactly, and for
|
||||
// the same reasons. No module branches on any of the three today.
|
||||
"ErrListItemNotFound": "the surface re-lists rather than branch on an item id that vanished",
|
||||
"ErrListItemEmpty": "input validation — an empty item is refused before it reaches the store",
|
||||
"ErrListItemStatus": "an unknown status is a caller bug, not a state a module recovers from",
|
||||
}
|
||||
|
||||
// The mapping itself, through a wrap, because every real caller wraps.
|
||||
func TestMapErrMapsEveryPair(t *testing.T) {
|
||||
for _, p := range mapErrPairs {
|
||||
got := mapErr(fmt.Errorf("storeapi: %w", p.from))
|
||||
if got != p.wants {
|
||||
t.Errorf("mapErr(store.%s) = %v, want %v", p.name, got, p.wants)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anything mapErr does not recognise passes through untouched. A module that
|
||||
// cannot branch on an error must still see the original text.
|
||||
func TestMapErrPassesUnknownThrough(t *testing.T) {
|
||||
if mapErr(nil) != nil {
|
||||
t.Error("mapErr(nil) must stay nil")
|
||||
}
|
||||
own := fmt.Errorf("socket closed")
|
||||
if got := mapErr(own); got != own {
|
||||
t.Errorf("mapErr(%v) = %v, want the same error back", own, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The parity half: every exported sentinel in internal/store is either mapped
|
||||
// or listed with a reason. Read off the source, so a sentinel added in a file
|
||||
// this package never touches still trips it.
|
||||
func TestMapErrCoversEveryStoreSentinel(t *testing.T) {
|
||||
mapped := map[string]bool{}
|
||||
for _, p := range mapErrPairs {
|
||||
mapped[p.name] = true
|
||||
}
|
||||
|
||||
for _, name := range storeSentinelNames(t) {
|
||||
if mapped[name] || unmappedStoreErrors[name] != "" {
|
||||
continue
|
||||
}
|
||||
t.Errorf("store.%s is a new sentinel with no verdict: add it to mapErr and mapErrPairs, "+
|
||||
"or to unmappedStoreErrors with the reason a module cannot branch on it", name)
|
||||
}
|
||||
}
|
||||
|
||||
// storeSentinelNames reads internal/store for exported package-level error
|
||||
// values: `var ErrX = errors.New(...)`, inside a block or on its own.
|
||||
func storeSentinelNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, "../store", func(fi fs.FileInfo) bool {
|
||||
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse internal/store: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, pkg := range pkgs {
|
||||
for _, f := range pkg.Files {
|
||||
for _, d := range f.Decls {
|
||||
gd, ok := d.(*ast.GenDecl)
|
||||
if !ok || gd.Tok != token.VAR {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gd.Specs {
|
||||
vs, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, n := range vs.Names {
|
||||
if !strings.HasPrefix(n.Name, "Err") || !n.IsExported() {
|
||||
continue
|
||||
}
|
||||
if i < len(vs.Values) && isErrorsNew(vs.Values[i]) {
|
||||
out = append(out, n.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) < len(mapErrPairs) {
|
||||
t.Fatalf("found %d sentinels in internal/store, fewer than the %d already mapped — the scan is broken, not the store", len(out), len(mapErrPairs))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isErrorsNew(e ast.Expr) bool {
|
||||
call, ok := e.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "New" {
|
||||
return false
|
||||
}
|
||||
id, ok := sel.X.(*ast.Ident)
|
||||
return ok && id.Name == "errors"
|
||||
}
|
||||
+3
-442
@@ -2,9 +2,7 @@ package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -13,449 +11,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/netaddr"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
|
||||
// these inside the core process; the socket Server calls it through the
|
||||
// CoreAPI interface, so over-the-wire and in-process callers behave
|
||||
// identically. The translation here is the only place store sentinels cross
|
||||
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
|
||||
// view of errors stable regardless of transport.
|
||||
type storeAPI struct {
|
||||
s *store.Store
|
||||
}
|
||||
|
||||
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
|
||||
// unlocked handle held ONLY in core's address space; this adapter never
|
||||
// returns it to a caller — core mediates.
|
||||
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
|
||||
|
||||
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
||||
var voids sql.NullInt64
|
||||
if req.VoidsID != nil {
|
||||
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
|
||||
}
|
||||
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
|
||||
f, err := a.s.LatestFact(ctx, key)
|
||||
if err != nil {
|
||||
return Fact{}, mapErr(err)
|
||||
}
|
||||
return toFact(f), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
||||
f, err := a.s.LatestFactBySource(ctx, key, source)
|
||||
if err != nil {
|
||||
return Fact{}, mapErr(err)
|
||||
}
|
||||
return toFact(f), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
d, err := a.s.Since(ctx, key, now)
|
||||
return d, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
|
||||
b, score, upd, err := a.s.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
|
||||
}
|
||||
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return mapErr(a.s.MarkReminder(ctx, id, status))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
rs, err := a.s.ListReminders(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Reminder, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = toReminder(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
|
||||
return mapErr(a.s.RescheduleReminder(ctx, id, now))
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
out, err := a.s.RecentOutcomes(ctx, rule, n)
|
||||
return out, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
fs, err := a.s.RecentFacts(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
||||
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
fs, err := a.s.CalendarEvents(ctx, from, to)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
||||
trs, err := a.s.RecentEcosystemTraces(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]EcosystemTrace, len(trs))
|
||||
for i, tr := range trs {
|
||||
out[i] = EcosystemTrace{
|
||||
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
|
||||
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
|
||||
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
ns, err := a.s.RecentNudges(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Nudge, len(ns))
|
||||
for i, ng := range ns {
|
||||
out[i] = toNudge(ng)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]DeliveryAttempt, len(as))
|
||||
for i, at := range as {
|
||||
out[i] = DeliveryAttempt{
|
||||
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
||||
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
||||
}
|
||||
if at.HasComplete {
|
||||
t := at.Completed
|
||||
out[i].Completed = &t
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||
ns, err := a.s.QueryNotes(ctx, embedding, k)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, n := range ns {
|
||||
out[i] = toNote(n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, note := range ns {
|
||||
out[i] = toNote(note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotes(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, note := range ns {
|
||||
out[i] = toNote(note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
|
||||
return ok, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
|
||||
return mapErr(a.s.DisableTool(ctx, name))
|
||||
}
|
||||
|
||||
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||
t, err := a.s.LookupTool(ctx, name)
|
||||
if err != nil {
|
||||
return Tool{}, mapErr(err)
|
||||
}
|
||||
return toTool(t), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
|
||||
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) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, errors.New("store: tick trace 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")
|
||||
}
|
||||
|
||||
// RecentEvents — same shape as TickTrace: the intake journal is a bounded ring
|
||||
// in the daemon's memory, not a table, so a bare store cannot serve it.
|
||||
func (a *storeAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
|
||||
return nil, errors.New("store: intake events not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
||||
return nil, nil // no manager behind a bare store: nothing configured
|
||||
}
|
||||
|
||||
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||
ts, err := a.s.ListTools(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Tool, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = toTool(t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return mapErr(a.s.DeleteTool(ctx, name))
|
||||
}
|
||||
|
||||
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
res, err := a.s.CaptureTask(ctx, store.Task{
|
||||
CreatedTs: req.Ts,
|
||||
Text: req.Text,
|
||||
Source: req.Source,
|
||||
Evidence: req.Evidence,
|
||||
ExternalID: req.ExternalID,
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
Weight: req.Weight,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptureTaskResp{}, mapErr(err)
|
||||
}
|
||||
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
ts, err := a.s.ListTasks(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Task, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = Task{
|
||||
ID: t.ID,
|
||||
CreatedTs: t.CreatedTs,
|
||||
Text: t.Text,
|
||||
Source: t.Source,
|
||||
Evidence: t.Evidence,
|
||||
ExternalID: t.ExternalID,
|
||||
Status: t.Status,
|
||||
Due: t.Due,
|
||||
Weight: t.Weight,
|
||||
Resolved: t.ResolvedTs,
|
||||
ResolvedBy: t.ResolvedBy,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]ProposedRoutine, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = ProposedRoutine{
|
||||
ID: r.ID,
|
||||
Action: r.Action,
|
||||
Object: r.Object,
|
||||
IntervalDays: r.IntervalDays,
|
||||
Status: r.Status,
|
||||
CreatedTs: r.CreatedTs.UnixMilli(),
|
||||
}
|
||||
if r.ReminderID != nil {
|
||||
out[i].ReminderID = r.ReminderID
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
||||
}
|
||||
|
||||
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
|
||||
}
|
||||
|
||||
func toTool(t store.Tool) Tool {
|
||||
return Tool{
|
||||
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
||||
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
|
||||
}
|
||||
}
|
||||
|
||||
func toReminder(r store.Reminder) Reminder {
|
||||
return Reminder{
|
||||
ID: r.ID,
|
||||
CreatedTs: r.CreatedTs,
|
||||
FireTs: r.FireTs,
|
||||
NextFireTs: r.NextFireTs,
|
||||
Payload: r.Payload,
|
||||
Status: r.Status,
|
||||
Cron: r.Cron,
|
||||
}
|
||||
}
|
||||
|
||||
func toNote(n store.Note) Note {
|
||||
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
|
||||
}
|
||||
|
||||
func toNudge(n store.Nudge) Nudge {
|
||||
out := Nudge{
|
||||
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
|
||||
Message: n.Message, Outcome: n.Outcome,
|
||||
}
|
||||
if n.OutcomeTs.Valid {
|
||||
v := n.OutcomeTs.Int64
|
||||
out.OutcomeTs = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toFact(f store.Fact) Fact {
|
||||
out := Fact{
|
||||
ID: f.ID,
|
||||
Ts: f.Ts,
|
||||
Kind: string(f.Kind),
|
||||
Key: f.Key,
|
||||
Value: f.Value,
|
||||
Source: f.Source,
|
||||
Confidence: f.Confidence,
|
||||
}
|
||||
if f.VoidsID.Valid {
|
||||
v := f.VoidsID.Int64
|
||||
out.VoidsID = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
|
||||
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
|
||||
// keeping internal text off the wire except to the daemon log).
|
||||
func mapErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNoFact):
|
||||
return ErrNoFact
|
||||
case errors.Is(err, store.ErrConfidence):
|
||||
return ErrConfidence
|
||||
case errors.Is(err, store.ErrVoidsMissing):
|
||||
return ErrVoidsMissing
|
||||
case errors.Is(err, store.ErrNudgeNotFound):
|
||||
return ErrNudgeNotFound
|
||||
case errors.Is(err, store.ErrNudgeOutcome):
|
||||
return ErrNudgeOutcome
|
||||
case errors.Is(err, store.ErrReminderNotFound):
|
||||
return ErrReminderNotFound
|
||||
case errors.Is(err, store.ErrReminderState):
|
||||
return ErrReminderState
|
||||
case errors.Is(err, store.ErrToolNotFound):
|
||||
return ErrToolNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Server — the core side of the boundary. Listens on a unix domain socket,
|
||||
// accepts module connections, frames requests to a CoreAPI and responses back.
|
||||
// One Server per daemon process; concurrent connections are handled in their
|
||||
@@ -966,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 {
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
// ipc/storeapi.go — the sqlite-backed CoreAPI.
|
||||
//
|
||||
// Split out of server.go, move-only (Vikunja #423). server.go was two
|
||||
// unrelated things: this adapter, and the dispatcher that calls it over the
|
||||
// socket. Nothing here knows there is a wire.
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
|
||||
// these inside the core process; the socket Server calls it through the
|
||||
// CoreAPI interface, so over-the-wire and in-process callers behave
|
||||
// identically. The translation here is the only place store sentinels cross
|
||||
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
|
||||
// view of errors stable regardless of transport.
|
||||
type storeAPI struct {
|
||||
s *store.Store
|
||||
}
|
||||
|
||||
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
|
||||
// unlocked handle held ONLY in core's address space; this adapter never
|
||||
// returns it to a caller — core mediates.
|
||||
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
|
||||
|
||||
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
||||
var voids sql.NullInt64
|
||||
if req.VoidsID != nil {
|
||||
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
|
||||
}
|
||||
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
|
||||
f, err := a.s.LatestFact(ctx, key)
|
||||
if err != nil {
|
||||
return Fact{}, mapErr(err)
|
||||
}
|
||||
return toFact(f), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
||||
f, err := a.s.LatestFactBySource(ctx, key, source)
|
||||
if err != nil {
|
||||
return Fact{}, mapErr(err)
|
||||
}
|
||||
return toFact(f), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
d, err := a.s.Since(ctx, key, now)
|
||||
return d, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
|
||||
b, score, upd, err := a.s.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
|
||||
}
|
||||
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return mapErr(a.s.MarkReminder(ctx, id, status))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
rs, err := a.s.ListReminders(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Reminder, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = toReminder(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
|
||||
return mapErr(a.s.RescheduleReminder(ctx, id, now))
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
out, err := a.s.RecentOutcomes(ctx, rule, n)
|
||||
return out, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
fs, err := a.s.RecentFacts(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
||||
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
fs, err := a.s.CalendarEvents(ctx, from, to)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
||||
trs, err := a.s.RecentEcosystemTraces(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]EcosystemTrace, len(trs))
|
||||
for i, tr := range trs {
|
||||
out[i] = EcosystemTrace{
|
||||
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
|
||||
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
|
||||
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
ns, err := a.s.RecentNudges(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Nudge, len(ns))
|
||||
for i, ng := range ns {
|
||||
out[i] = toNudge(ng)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]DeliveryAttempt, len(as))
|
||||
for i, at := range as {
|
||||
out[i] = DeliveryAttempt{
|
||||
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
||||
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
||||
}
|
||||
if at.HasComplete {
|
||||
t := at.Completed
|
||||
out[i].Completed = &t
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||
ns, err := a.s.QueryNotes(ctx, embedding, k)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, n := range ns {
|
||||
out[i] = toNote(n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, note := range ns {
|
||||
out[i] = toNote(note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotes(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, note := range ns {
|
||||
out[i] = toNote(note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
|
||||
return ok, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
|
||||
return mapErr(a.s.DisableTool(ctx, name))
|
||||
}
|
||||
|
||||
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||
t, err := a.s.LookupTool(ctx, name)
|
||||
if err != nil {
|
||||
return Tool{}, mapErr(err)
|
||||
}
|
||||
return toTool(t), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
|
||||
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) 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")
|
||||
}
|
||||
|
||||
// RecentEvents — same shape as TickTrace: the intake journal is a bounded ring
|
||||
// in the daemon's memory, not a table, so a bare store cannot serve it.
|
||||
func (a *storeAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
|
||||
return nil, errors.New("store: intake events not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
||||
return nil, nil // no manager behind a bare store: nothing configured
|
||||
}
|
||||
|
||||
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||
ts, err := a.s.ListTools(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Tool, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = toTool(t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return mapErr(a.s.DeleteTool(ctx, name))
|
||||
}
|
||||
|
||||
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
res, err := a.s.CaptureTask(ctx, store.Task{
|
||||
CreatedTs: req.Ts,
|
||||
Text: req.Text,
|
||||
Source: req.Source,
|
||||
Evidence: req.Evidence,
|
||||
ExternalID: req.ExternalID,
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
Weight: req.Weight,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptureTaskResp{}, mapErr(err)
|
||||
}
|
||||
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
ts, err := a.s.ListTasks(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Task, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = Task{
|
||||
ID: t.ID,
|
||||
CreatedTs: t.CreatedTs,
|
||||
Text: t.Text,
|
||||
Source: t.Source,
|
||||
Evidence: t.Evidence,
|
||||
ExternalID: t.ExternalID,
|
||||
Status: t.Status,
|
||||
Due: t.Due,
|
||||
Weight: t.Weight,
|
||||
Resolved: t.ResolvedTs,
|
||||
ResolvedBy: t.ResolvedBy,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]ProposedRoutine, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = ProposedRoutine{
|
||||
ID: r.ID,
|
||||
Action: r.Action,
|
||||
Object: r.Object,
|
||||
IntervalDays: r.IntervalDays,
|
||||
Status: string(r.Status),
|
||||
CreatedTs: r.CreatedTs.UnixMilli(),
|
||||
}
|
||||
if r.ReminderID != nil {
|
||||
out[i].ReminderID = r.ReminderID
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
||||
}
|
||||
|
||||
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
|
||||
}
|
||||
|
||||
func toTool(t store.Tool) Tool {
|
||||
return Tool{
|
||||
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
||||
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
|
||||
}
|
||||
}
|
||||
|
||||
func toReminder(r store.Reminder) Reminder {
|
||||
return Reminder{
|
||||
ID: r.ID,
|
||||
CreatedTs: r.CreatedTs,
|
||||
FireTs: r.FireTs,
|
||||
NextFireTs: r.NextFireTs,
|
||||
Payload: r.Payload,
|
||||
Status: r.Status,
|
||||
Cron: r.Cron,
|
||||
}
|
||||
}
|
||||
|
||||
func toNote(n store.Note) Note {
|
||||
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
|
||||
}
|
||||
|
||||
func toNudge(n store.Nudge) Nudge {
|
||||
out := Nudge{
|
||||
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
|
||||
Message: n.Message, Outcome: n.Outcome,
|
||||
}
|
||||
if n.OutcomeTs.Valid {
|
||||
v := n.OutcomeTs.Int64
|
||||
out.OutcomeTs = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toFact(f store.Fact) Fact {
|
||||
out := Fact{
|
||||
ID: f.ID,
|
||||
Ts: f.Ts,
|
||||
Kind: string(f.Kind),
|
||||
Key: f.Key,
|
||||
Value: f.Value,
|
||||
Source: f.Source,
|
||||
Confidence: f.Confidence,
|
||||
}
|
||||
if f.VoidsID.Valid {
|
||||
v := f.VoidsID.Int64
|
||||
out.VoidsID = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
|
||||
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
|
||||
// keeping internal text off the wire except to the daemon log).
|
||||
func mapErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNoFact):
|
||||
return ErrNoFact
|
||||
case errors.Is(err, store.ErrConfidence):
|
||||
return ErrConfidence
|
||||
case errors.Is(err, store.ErrVoidsMissing):
|
||||
return ErrVoidsMissing
|
||||
case errors.Is(err, store.ErrNudgeNotFound):
|
||||
return ErrNudgeNotFound
|
||||
case errors.Is(err, store.ErrNudgeOutcome):
|
||||
return ErrNudgeOutcome
|
||||
case errors.Is(err, store.ErrReminderNotFound):
|
||||
return ErrReminderNotFound
|
||||
case errors.Is(err, store.ErrReminderState):
|
||||
return ErrReminderState
|
||||
case errors.Is(err, store.ErrToolNotFound):
|
||||
return ErrToolNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -105,6 +105,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -67,6 +67,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
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package lexicon holds the Russian word sets that can be finished.
|
||||
//
|
||||
// A closed class has a fixed number of members: the language has as many
|
||||
// interrogative pronouns as it has, and no utterance will ever contain a
|
||||
// thirteenth month. Those sets belong in a data file, complete, and that is what
|
||||
// this package is (Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%").
|
||||
//
|
||||
// It is the first of three mechanisms that replaced hand-written Russian
|
||||
// patterns, and the only one that answers with certainty. The other two are the
|
||||
// embedder, for recognising an open set of phrasings, and a morphological
|
||||
// dictionary, for questions about grammar. A list that can never be finished is
|
||||
// a guess dressed as a rule and does not go here.
|
||||
//
|
||||
// What this package does NOT do is match. It hands out sets and lookups; the
|
||||
// caller decides what to do with a hit, because "this token is an interrogative"
|
||||
// and "this utterance is a question" are different claims.
|
||||
package lexicon
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
//go:embed lexicon_ru_v1.json
|
||||
var files embed.FS
|
||||
|
||||
// ruFile is the versioned file this package reads. A new version is a new file,
|
||||
// not an edit to this one, so a caller pinned to v1 keeps the words it was
|
||||
// measured against.
|
||||
const ruFile = "lexicon_ru_v1.json"
|
||||
|
||||
type lexiconFile struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Sets map[string]lexiSet `json:"sets"`
|
||||
}
|
||||
|
||||
type lexiSet struct {
|
||||
Note string `json:"note"`
|
||||
Words []string `json:"words"`
|
||||
Values map[string]int `json:"values"`
|
||||
}
|
||||
|
||||
// ru is parsed once at init. A malformed embedded file is a build-time mistake
|
||||
// that survived to runtime, and there is no sane degraded behaviour for "the
|
||||
// months are missing", so it panics rather than answering with an empty set.
|
||||
var ru = mustLoad()
|
||||
|
||||
func mustLoad() lexiconFile {
|
||||
data, err := files.ReadFile(ruFile)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("lexicon: read %s: %v", ruFile, err))
|
||||
}
|
||||
var f lexiconFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
|
||||
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs",
|
||||
} {
|
||||
s, ok := f.Sets[name]
|
||||
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
||||
panic(fmt.Sprintf("lexicon: %s has no set %q", ruFile, name))
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// words returns a copy of a word set, so a caller cannot edit the lexicon by
|
||||
// holding onto what it was given.
|
||||
func words(set string) []string {
|
||||
src := ru.Sets[set].Words
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
return out
|
||||
}
|
||||
|
||||
// Interrogatives returns the question words, Russian and English.
|
||||
func Interrogatives() []string { return words("interrogatives") }
|
||||
|
||||
// CaptureVerbs returns the imperatives that mean "record this".
|
||||
func CaptureVerbs() []string { return words("capture_verbs") }
|
||||
|
||||
// NarrativeRequests returns the imperatives that mean "tell me about".
|
||||
func NarrativeRequests() []string { return words("narrative_requests") }
|
||||
|
||||
// RepairMarkers lists the ways he says the previous turn was routed wrong. See
|
||||
// the set's own note for why this one is a list and not a seed set.
|
||||
func RepairMarkers() []string { return words("repair_markers") }
|
||||
|
||||
// FirstPerson lists every form of the first-person pronoun. Callers use it to
|
||||
// decide that a sentence is about him: internal/router/complaint.go keeps a
|
||||
// complaint out of the fact store unless one of these appears, because losing a
|
||||
// fact he meant to store is the worse mistake.
|
||||
func FirstPerson() []string { return words("first_person") }
|
||||
|
||||
// NotPlaceAfterV returns the words that follow "в" without naming a place.
|
||||
func NotPlaceAfterV() []string { return words("not_place_after_v") }
|
||||
|
||||
// PartsOfDay returns the one-word names for a time of day: "вечером", "утром".
|
||||
// They say which part of a day and never which day, so a caller that needs the
|
||||
// day wants DayOffsetWords instead.
|
||||
func PartsOfDay() []string { return words("parts_of_day") }
|
||||
|
||||
// ReminderVerbs returns the imperatives that open a reminder.
|
||||
func ReminderVerbs() []string { return words("reminder_verbs") }
|
||||
|
||||
// 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.
|
||||
func Cardinal(word string) (int, bool) {
|
||||
n, ok := ru.Sets["cardinals"].Values[norm(word)]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// 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
|
||||
// return is the only way to tell a hit from a miss. Callers that used to switch
|
||||
// on strings.Contains had to order "послезавтра" before "завтра" by hand,
|
||||
// because one contains the other; a lookup has no such trap.
|
||||
func DayOffset(word string) (int, bool) {
|
||||
n, ok := ru.Sets["day_offsets"].Values[norm(word)]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// DayOffsetWords lists the relative day words themselves, sorted so the order is
|
||||
// stable across builds — a caller that folds them into a regexp alternation would
|
||||
// otherwise produce a different pattern every run. Map iteration order is why
|
||||
// this sorts rather than the caller.
|
||||
func DayOffsetWords() []string {
|
||||
vals := ru.Sets["day_offsets"].Values
|
||||
out := make([]string, 0, len(vals))
|
||||
for w := range vals {
|
||||
out = append(out, w)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DayOffsetIn finds a relative day word anywhere in a phrase and reports its
|
||||
// offset. Where two words appear, the one that moves furthest from today wins in
|
||||
// absolute terms: "не сегодня, а послезавтра" is about the day after tomorrow,
|
||||
// and the longest-match rule that picking the first word would need is exactly
|
||||
// what the old Contains switch got wrong.
|
||||
func DayOffsetIn(text string) (int, bool) {
|
||||
lower := norm(text)
|
||||
best, found := 0, false
|
||||
for word, n := range ru.Sets["day_offsets"].Values {
|
||||
if !containsWord(lower, word) {
|
||||
continue
|
||||
}
|
||||
if !found || abs(n) > abs(best) {
|
||||
best, found = n, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// Weekday returns the Russian name of a weekday index, Sunday first, matching
|
||||
// Go's time.Weekday. An index off the end returns "".
|
||||
func Weekday(i int) string { return at("weekdays", i) }
|
||||
|
||||
// MonthGenitive returns the month name a date takes — "10 июля", not "июль".
|
||||
// The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call.
|
||||
func MonthGenitive(m int) string { return at("months_genitive", m) }
|
||||
|
||||
// HourSpoken returns an hour spelled out for the voice. 0 to 23.
|
||||
func HourSpoken(h int) string { return at("hours_spoken", h) }
|
||||
|
||||
func at(set string, i int) string {
|
||||
w := ru.Sets[set].Words
|
||||
if i < 0 || i >= len(w) {
|
||||
return ""
|
||||
}
|
||||
return w[i]
|
||||
}
|
||||
|
||||
func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 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.
|
||||
func containsWord(haystack, needle string) bool {
|
||||
if needle == "" {
|
||||
return false
|
||||
}
|
||||
from := 0
|
||||
for {
|
||||
i := strings.Index(haystack[from:], needle)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
i += from
|
||||
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
|
||||
return true
|
||||
}
|
||||
from = i + len(needle)
|
||||
if from >= len(haystack) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func boundaryBefore(s string, i int) bool {
|
||||
if i == 0 {
|
||||
return true
|
||||
}
|
||||
r, _ := utf8.DecodeLastRuneInString(s[:i])
|
||||
return !wordRune(r)
|
||||
}
|
||||
|
||||
func boundaryAfter(s string, i int) bool {
|
||||
if i >= len(s) {
|
||||
return true
|
||||
}
|
||||
r, _ := utf8.DecodeRuneInString(s[i:])
|
||||
return !wordRune(r)
|
||||
}
|
||||
|
||||
func wordRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "russian closed-class lexicons v1",
|
||||
"notes": [
|
||||
"Every set here is a CLOSED CLASS: the language has a fixed number of members and the list can be finished. That is why it is a list at all. A word list that can never be finished is a guess dressed as a rule, and it belongs with the embedder, not here (Vikunja #522).",
|
||||
"Editing a word is a data change. No Go change, no rebuild of a pattern, no second copy to keep in step — month names used to live in two files and day offsets in three.",
|
||||
"Interrogatives and capture verbs carry their English members too. He speaks both languages in one sentence and the router sees one utterance.",
|
||||
"The sets are matched over tokens, never as substrings: \"что\" inside \"чтобы\" and \"как\" inside \"какао\" are not questions.",
|
||||
"Order matters in weekdays, months and hours, and nowhere else. weekdays starts at Sunday because Go's time.Weekday does. months is 1-indexed with an empty slot at 0 for the same reason. hours is indexed by the hour itself.",
|
||||
"A form missing from a closed set is a bug report, not a judgement call. Add it."
|
||||
],
|
||||
"sets": {
|
||||
"interrogatives": {
|
||||
"note": "The Russian interrogative pronouns and adverbs, declined, plus the English ones. Closed class: this is the whole list, and a question word outside it does not exist.",
|
||||
"words": [
|
||||
"что", "чего", "чему", "чем", "чём",
|
||||
"кто", "кого", "кому", "кем", "ком",
|
||||
"какой", "какая", "какое", "какие", "какого", "какому", "каким", "каких", "какими", "каком",
|
||||
"который", "которая", "которое", "которые", "которого", "котором",
|
||||
"чей", "чья", "чьё", "чьи",
|
||||
"где", "куда", "откуда", "когда", "докуда",
|
||||
"почему", "зачем", "отчего", "как", "сколько", "насколько", "каково",
|
||||
"what", "who", "whom", "whose", "why", "when", "where", "which", "how"
|
||||
]
|
||||
},
|
||||
"capture_verbs": {
|
||||
"note": "An explicit instruction to record something, in the imperative he actually speaks. Not a closed class in the grammatical sense, but a closed set of the commands Maven answers to — it is her vocabulary, and its members are decided here rather than discovered.",
|
||||
"words": [
|
||||
"запиши", "запомни", "отметь", "заметь", "добавь", "сохрани", "занеси", "внеси",
|
||||
"note", "remember", "log", "save", "add"
|
||||
]
|
||||
},
|
||||
"narrative_requests": {
|
||||
"note": "\"Tell me about X\" asks for knowledge Maven does not hold about him. It carries no question mark and no interrogative, which is how \"расскажи про битву при Ватерлоо\" reached the fact store (#470).",
|
||||
"words": [
|
||||
"расскажи", "объясни", "опиши", "перечисли", "поясни",
|
||||
"tell", "explain", "describe", "list"
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
"ноль": 0, "нуль": 0, "zero": 0,
|
||||
"один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "one": 1,
|
||||
"два": 2, "две": 2, "двух": 2, "two": 2,
|
||||
"три": 3, "трёх": 3, "трех": 3, "three": 3,
|
||||
"четыре": 4, "четырёх": 4, "четырех": 4, "four": 4,
|
||||
"пять": 5, "пяти": 5, "five": 5,
|
||||
"шесть": 6, "шести": 6, "six": 6,
|
||||
"семь": 7, "семи": 7, "seven": 7,
|
||||
"восемь": 8, "восьми": 8, "eight": 8,
|
||||
"девять": 9, "девяти": 9, "nine": 9,
|
||||
"десять": 10, "десяти": 10, "ten": 10,
|
||||
"одиннадцать": 11, "одиннадцати": 11, "eleven": 11,
|
||||
"двенадцать": 12, "двенадцати": 12, "twelve": 12,
|
||||
"тринадцать": 13, "тринадцати": 13, "thirteen": 13,
|
||||
"четырнадцать": 14, "четырнадцати": 14, "fourteen": 14,
|
||||
"пятнадцать": 15, "пятнадцати": 15, "fifteen": 15,
|
||||
"шестнадцать": 16, "шестнадцати": 16, "sixteen": 16,
|
||||
"семнадцать": 17, "семнадцати": 17, "seventeen": 17,
|
||||
"восемнадцать": 18, "восемнадцати": 18, "eighteen": 18,
|
||||
"девятнадцать": 19, "девятнадцати": 19, "nineteen": 19,
|
||||
"двадцать": 20, "двадцати": 20, "twenty": 20,
|
||||
"тридцать": 30, "тридцати": 30, "thirty": 30,
|
||||
"сорок": 40, "сорока": 40, "forty": 40,
|
||||
"пятьдесят": 50, "пятидесяти": 50, "fifty": 50,
|
||||
"шестьдесят": 60, "шестидесяти": 60, "sixty": 60,
|
||||
"семьдесят": 70, "семидесяти": 70, "seventy": 70,
|
||||
"восемьдесят": 80, "восьмидесяти": 80, "eighty": 80,
|
||||
"девяносто": 90, "девяноста": 90, "ninety": 90,
|
||||
"сто": 100, "ста": 100, "hundred": 100
|
||||
}
|
||||
},
|
||||
"day_offsets": {
|
||||
"note": "The words that name a day relative to today, and the number of days each one moves. Only explicit ones: an offset is a claim about which day, and guessing which day is the guess these callers refuse to make. Multi-word members are matched as a phrase.",
|
||||
"values": {
|
||||
"позавчера": -2,
|
||||
"вчера": -1,
|
||||
"yesterday": -1,
|
||||
"сегодня": 0,
|
||||
"today": 0,
|
||||
"tonight": 0,
|
||||
"завтра": 1,
|
||||
"tomorrow": 1,
|
||||
"послезавтра": 2,
|
||||
"day after tomorrow": 2
|
||||
}
|
||||
},
|
||||
"weekdays": {
|
||||
"note": "Nominative, starting at Sunday so the index is Go's time.Weekday.",
|
||||
"words": [
|
||||
"воскресенье", "понедельник", "вторник", "среда",
|
||||
"четверг", "пятница", "суббота"
|
||||
]
|
||||
},
|
||||
"months_genitive": {
|
||||
"note": "The form a date takes: \"10 июля\", not \"июль\". 1-indexed, so slot 0 is empty and month numbers need no arithmetic.",
|
||||
"words": [
|
||||
"", "января", "февраля", "марта", "апреля", "мая", "июня",
|
||||
"июля", "августа", "сентября", "октября", "ноября", "декабря"
|
||||
]
|
||||
},
|
||||
"hours_spoken": {
|
||||
"note": "Hours spelled out for the voice: \"3 ч\" is fine on a screen and wrong out loud. Indexed by the hour, 0 to 23.",
|
||||
"words": [
|
||||
"ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь",
|
||||
"девять", "десять", "одиннадцать", "двенадцать", "тринадцать",
|
||||
"четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать",
|
||||
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три"
|
||||
]
|
||||
},
|
||||
"repair_markers": {
|
||||
"note": "The ways he says she got it wrong. A closed set of Maven's own vocabulary, like capture_verbs: its members are decided here rather than discovered. NOT the embedder, and that is deliberate — a correction rewrites the previous turn and runs pre-route, before the turn vector exists, so a near-miss would redo a request he did not make. Phrases, matched as substrings.",
|
||||
"words": [
|
||||
"не так поняла", "неправильно поняла", "ты не поняла", "не поняла меня",
|
||||
"ты ошиблась", "это не", "а не", "не про то",
|
||||
"got it wrong", "not a ", "that was wrong"
|
||||
]
|
||||
},
|
||||
"first_person": {
|
||||
"note": "Every form of the first-person pronoun, plus the English ones. Closed class in the strictest sense: the language has these and no others. A sentence carrying one is about him, which is what makes it a fact rather than a passing complaint.",
|
||||
"words": [
|
||||
"я", "меня", "мне", "мной", "мною",
|
||||
"мы", "нас", "нам", "нами",
|
||||
"i", "me", "my", "mine", "we", "us", "our"
|
||||
]
|
||||
},
|
||||
"not_place_after_v": {
|
||||
"note": "Words that follow the preposition \"в\" without naming a place, so \"в общем\" and \"в котором часу\" are not read as a city we do not know.",
|
||||
"words": [
|
||||
"данный", "данную", "данное", "этот", "эту", "это", "том", "той",
|
||||
"котором", "которой", "какое", "какой", "который", "каком",
|
||||
"общем", "точности", "курсе", "принципе", "итоге", "целом",
|
||||
"сутках", "часах", "минутах", "секундах", "неделе", "месяце", "году",
|
||||
"начале", "конце", "середине", "течение", "течении"
|
||||
]
|
||||
},
|
||||
"parts_of_day": {
|
||||
"note": "The times of day named as one word, in the instrumental case Russian uses for when something happens. A day has as many parts as it has, so this set is finished. They are not day offsets: \"вечером\" says which part of a day, never which day (Vikunja #530).",
|
||||
"words": [
|
||||
"утром", "днём", "днем", "вечером", "ночью",
|
||||
"morning", "afternoon", "evening", "night"
|
||||
]
|
||||
},
|
||||
"reminder_verbs": {
|
||||
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530).",
|
||||
"words": [
|
||||
"напомни", "напомните", "напомнить", "напоминай",
|
||||
"remind"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package lexicon
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestClosedSetsAreComplete — the point of the package. A closed class can be
|
||||
// finished, so the test names the members that were MISSING from the inline Go
|
||||
// lists this package replaced (Vikunja #525) and every one of them has to be
|
||||
// there. Add to this list when a form turns up unhandled.
|
||||
func TestClosedSetsAreComplete(t *testing.T) {
|
||||
inter := map[string]bool{}
|
||||
for _, w := range Interrogatives() {
|
||||
inter[w] = true
|
||||
}
|
||||
// Instrumental and prepositional cases of что and кто, and the declined
|
||||
// какой. The old list had что/чего and nothing else, so "чем ты занята" and
|
||||
// "в каком часу" carried no interrogative at all.
|
||||
for _, w := range []string{"чем", "чём", "чему", "кем", "ком", "каком", "какими", "насколько"} {
|
||||
if !inter[w] {
|
||||
t.Errorf("interrogatives is missing %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
word string
|
||||
want int
|
||||
}{
|
||||
{"ноль", 0}, {"одна", 1}, {"две", 2}, {"одиннадцать", 11},
|
||||
{"пятнадцать", 15}, {"двадцать", 20}, {"сорок", 40}, {"девяносто", 90},
|
||||
{"сто", 100}, {"twelve", 12},
|
||||
} {
|
||||
got, ok := Cardinal(tc.word)
|
||||
if !ok || got != tc.want {
|
||||
t.Errorf("Cardinal(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
|
||||
}
|
||||
}
|
||||
if _, ok := Cardinal("бэкап"); ok {
|
||||
t.Error("Cardinal must not answer for a word that is not a number")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDayOffsetHasNoOrderingTrap — the defect a lookup removes. The callers this
|
||||
// replaced used strings.Contains in a switch, so "послезавтра" had to be tested
|
||||
// before "завтра" by hand or the day after tomorrow read as tomorrow.
|
||||
func TestDayOffsetHasNoOrderingTrap(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"послезавтра", 2, true},
|
||||
{"завтра", 1, true},
|
||||
{"сегодня", 0, true},
|
||||
{"вчера", -1, true},
|
||||
{"позавчера", -2, true},
|
||||
{"встреча послезавтра в 14:30", 2, true},
|
||||
{"Tomorrow at 09:00", 1, true},
|
||||
{"не сегодня, а послезавтра", 2, true},
|
||||
{"в четверг", 0, false},
|
||||
{"завтраком", 0, false},
|
||||
} {
|
||||
got, ok := DayOffsetIn(tc.text)
|
||||
if ok != tc.ok || (ok && got != tc.want) {
|
||||
t.Errorf("DayOffsetIn(%q) = %d, %v; want %d, %v", tc.text, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
// "сегодня" is offset 0, which is also the zero value, so the second return
|
||||
// is the only thing that separates a hit from a miss.
|
||||
if n, ok := DayOffset("сегодня"); n != 0 || !ok {
|
||||
t.Errorf("DayOffset(сегодня) = %d, %v; want 0, true", n, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndexedSetsLineUpWithTheirCallers — weekdays start at Sunday because Go's
|
||||
// time.Weekday does, and months are 1-indexed so a month number needs no
|
||||
// arithmetic. Off-by-one here is a wrong date spoken out loud.
|
||||
func TestIndexedSetsLineUpWithTheirCallers(t *testing.T) {
|
||||
if got := Weekday(0); got != "воскресенье" {
|
||||
t.Errorf("Weekday(0) = %q, want воскресенье", got)
|
||||
}
|
||||
if got := Weekday(1); got != "понедельник" {
|
||||
t.Errorf("Weekday(1) = %q, want понедельник", got)
|
||||
}
|
||||
if got := MonthGenitive(1); got != "января" {
|
||||
t.Errorf("MonthGenitive(1) = %q, want января", got)
|
||||
}
|
||||
if got := MonthGenitive(12); got != "декабря" {
|
||||
t.Errorf("MonthGenitive(12) = %q, want декабря", got)
|
||||
}
|
||||
if got := MonthGenitive(0); got != "" {
|
||||
t.Errorf("MonthGenitive(0) = %q, want the empty slot", got)
|
||||
}
|
||||
if got := HourSpoken(23); got != "двадцать три" {
|
||||
t.Errorf("HourSpoken(23) = %q, want двадцать три", got)
|
||||
}
|
||||
for _, i := range []int{-1, 7, 13, 24} {
|
||||
if got := Weekday(i); i >= 7 && got != "" {
|
||||
t.Errorf("Weekday(%d) = %q, want empty", i, got)
|
||||
}
|
||||
}
|
||||
if got := HourSpoken(24); got != "" {
|
||||
t.Errorf("HourSpoken(24) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallerCannotEditTheLexicon — the sets are handed out as copies. A caller
|
||||
// that sorted the slice it was given would otherwise reorder weekdays for
|
||||
// everybody.
|
||||
func TestCallerCannotEditTheLexicon(t *testing.T) {
|
||||
first := Interrogatives()
|
||||
first[0] = "мутировало"
|
||||
if again := Interrogatives(); again[0] == "мутировало" {
|
||||
t.Fatal("the lexicon handed out its own backing array")
|
||||
}
|
||||
}
|
||||
@@ -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": factAt("service_down", "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(-1*time.Minute))},
|
||||
}
|
||||
r := ServiceDownRule() // Sev4 ops
|
||||
passed, blocked, d := ExplainGate(s, r)
|
||||
@@ -259,8 +259,8 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"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)),
|
||||
},
|
||||
}
|
||||
cand, trace := ExplainTick(s, DefaultRules())
|
||||
|
||||
@@ -198,12 +198,12 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||
"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": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||
"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),
|
||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||
},
|
||||
}
|
||||
// sanity: every rule really does want to fire, so the pick is a real choice.
|
||||
|
||||
@@ -91,6 +91,20 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
return State{}, nil, err
|
||||
}
|
||||
|
||||
// prefix families — the keys a rule cannot name at wiring time (one fact
|
||||
// per kuma monitor). Loaded into the same map; State.FactsUnder reads them.
|
||||
for _, r := range g.rules {
|
||||
for _, p := range r.WantPrefixes {
|
||||
fam, err := g.store.LatestFactsByPrefix(ctx, p)
|
||||
if err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
for _, f := range fam {
|
||||
facts[f.Key] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// last nudge per rule + cooldown-until derived from the active cooldown.
|
||||
// "active" = the feedback tuner's persisted base if one exists, else the
|
||||
// rule's static Base. LatestFactBySource is the trust-by-provenance read
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The gatherer is the only impure piece, and a rule over a prefix has no keys
|
||||
// to declare at wiring time. This is the end of that path: mavpoll's per-monitor
|
||||
// facts reach the snapshot, and the rule fires on the one that is down.
|
||||
func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "loop_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
for key, val := range map[string]string{
|
||||
"service_down:db": "down",
|
||||
"service_down:web": "up",
|
||||
} {
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-time.Minute)); err != nil {
|
||||
t.Fatalf("SetValue %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
rules := []Rule{ServiceDownRule()}
|
||||
st, _, err := NewGatherer(s, rules).GatherState(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("GatherState: %v", err)
|
||||
}
|
||||
if _, ok := st.Facts["service_down:db"]; !ok {
|
||||
t.Fatalf("prefix family not gathered: %v", st.Facts)
|
||||
}
|
||||
if got := DownServices(st); len(got) != 1 || got[0] != "db" {
|
||||
t.Fatalf("DownServices = %v, want [db]", got)
|
||||
}
|
||||
if !rules[0].Predicate(st) {
|
||||
t.Fatal("the rule must fire on a gathered per-monitor fact")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user