Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed491e23fc | |||
| 246db4e609 | |||
| b6abb19090 | |||
| 1f7fd476ec | |||
| 08512ad58b | |||
| ff71d981ef | |||
| 4d83f8c785 | |||
| a439117995 | |||
| 2689715c2d | |||
| 05f47aef4b | |||
| 45a5e37963 | |||
| 6d8a95095a | |||
| 7e21cd06b3 | |||
| 09c648b934 | |||
| 4f516657da |
@@ -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
|
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
|
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
|
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
|
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||||
are the work.
|
are the work.
|
||||||
|
|
||||||
@@ -229,8 +233,11 @@ world questions, so she needs to read external sources. What replaces it:
|
|||||||
|
|
||||||
## Web UI conventions
|
## Web UI conventions
|
||||||
|
|
||||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
|
||||||
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
|
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
|
||||||
|
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
|
||||||
|
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
|
||||||
|
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
|
||||||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||||||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ deps-piper:
|
|||||||
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
||||||
# a short question against a longer passage, which is what note recall is.
|
# a short question against a longer passage, which is what note recall is.
|
||||||
# The quantized file is the one we download, deploy and measure — see
|
# The quantized file is the one we download, deploy and measure — see
|
||||||
# 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_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
||||||
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
||||||
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
||||||
|
|||||||
+2
-3
@@ -16,7 +16,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -36,10 +35,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
|
|||||||
}
|
}
|
||||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
||||||
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||||
return phraser.Ack(phraser.FailAck, nil), true
|
return "не получилось отметить.", true
|
||||||
}
|
}
|
||||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||||
return phraser.Ack(phraser.AckNudge, nil), true
|
return "отлично, отметила.", true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,14 +58,10 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
|
|||||||
// Conversational: build history from dialogue session (prior user turns)
|
// Conversational: build history from dialogue session (prior user turns)
|
||||||
// and let the LLM respond from general knowledge + context.
|
// and let the LLM respond from general knowledge + context.
|
||||||
history := h.chatHistory()
|
history := h.chatHistory()
|
||||||
// The phraser hands back its own fallback text alongside the error, so the
|
|
||||||
// turn survives a dead server and the failure still reaches the log.
|
|
||||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: chat: %v", err)
|
log.Printf("voice: chat: %v", err)
|
||||||
}
|
return "поговорили."
|
||||||
if reply == "" {
|
|
||||||
return phraser.ChatFallback()
|
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/mcp"
|
"github.com/kami/maven/internal/mcp"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
)
|
)
|
||||||
@@ -51,37 +50,31 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
|||||||
// destructive: park it and ask. The next utterance answers.
|
// destructive: park it and ask. The next utterance answers.
|
||||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||||
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||||
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
|
||||||
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
|
||||||
// help: everything that proposed this act — the STT, the router,
|
|
||||||
// the fuzzy allowlist match — is a guess, and a spoken "да" checks
|
|
||||||
// none of it. She names the gap instead.
|
|
||||||
return phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
|
||||||
case errors.Is(err, tool.ErrNotEnabled):
|
case errors.Is(err, tool.ErrNotEnabled):
|
||||||
return h.proposeGap(ctx, dec)
|
return h.proposeGap(ctx, dec)
|
||||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
||||||
// The row is enabled and the backend is gone. Drafting a proposal
|
// The row is enabled and the backend is gone. Drafting a proposal
|
||||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||||
// question.
|
// question.
|
||||||
return phraser.A(phraser.ActServerDown, nil)
|
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
||||||
case errors.Is(err, mcp.ErrToolGone):
|
case errors.Is(err, mcp.ErrToolGone):
|
||||||
return phraser.A(phraser.ActWithdrawn, nil)
|
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
||||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||||
// An MCP tool that wants named arguments a spoken verb cannot
|
// An MCP tool that wants named arguments a spoken verb cannot
|
||||||
// supply. Guessing them would be a wrong act, so she says so
|
// supply. Guessing them would be a wrong act, so she says so
|
||||||
// instead — the tool is still runnable from the authed surface,
|
// instead — the tool is still runnable from the authed surface,
|
||||||
// where a human types them.
|
// where a human types them.
|
||||||
return phraser.A(phraser.ActNeedsArgs, nil)
|
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||||
}
|
}
|
||||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||||
if out != "" {
|
if out != "" {
|
||||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
return "не получилось выполнить команду: " + firstLine(out)
|
||||||
}
|
}
|
||||||
return phraser.A(phraser.ActFail, nil)
|
return "не получилось выполнить команду."
|
||||||
}
|
}
|
||||||
if out != "" {
|
if out != "" {
|
||||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
return "готово: " + firstLine(out)
|
||||||
}
|
}
|
||||||
return phraser.A(phraser.ActDone, nil)
|
return "готово."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/router"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The act path speaks each tier (Vikunja #449): a safe row runs, a destructive
|
|
||||||
// one costs a confirm turn, an irreversible one is refused with the reason.
|
|
||||||
func TestActPathSpeaksTheTiers(t *testing.T) {
|
|
||||||
h, st, _ := newClarifyHandler(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
now := h.now()
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
cmd []string
|
|
||||||
destructive bool
|
|
||||||
}{
|
|
||||||
{"status", []string{"true"}, false},
|
|
||||||
{"restart", []string{"true"}, true},
|
|
||||||
{"wipe", []string{"rm", "-rf"}, true},
|
|
||||||
} {
|
|
||||||
if _, err := st.ProposeTool(ctx, tc.name, "test", "homelab", now); err != nil {
|
|
||||||
t.Fatalf("propose %s: %v", tc.name, err)
|
|
||||||
}
|
|
||||||
if err := st.EnableTool(ctx, tc.name, tc.cmd, tc.destructive, "homelab", now); err != nil {
|
|
||||||
t.Fatalf("enable %s: %v", tc.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
act := func(fn string) string {
|
|
||||||
return h.actionAct(ctx, router.Decision{
|
|
||||||
Intent: router.IntentAct,
|
|
||||||
Utterance: fn,
|
|
||||||
Slots: router.Slots{Fn: fn, HasFn: true},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if reply := act("status"); !strings.HasPrefix(reply, "готово") {
|
|
||||||
t.Errorf("safe act replied %q; want it to have run", reply)
|
|
||||||
}
|
|
||||||
// PR 112's review cut «скажи «да» или «нет».» — he knows how to answer a
|
|
||||||
// yes/no question — so the confirm turn is recognised by the question.
|
|
||||||
if reply := act("restart"); !strings.Contains(reply, "да или нет") {
|
|
||||||
t.Errorf("destructive act replied %q; want a confirm turn", reply)
|
|
||||||
}
|
|
||||||
// Clear the confirm the destructive act parked, so what is pending after
|
|
||||||
// the irreversible one is only what the irreversible one parked.
|
|
||||||
h.mu.Lock()
|
|
||||||
h.pending = nil
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
reply := act("wipe")
|
|
||||||
if strings.Contains(reply, "да или нет") {
|
|
||||||
t.Fatalf("irreversible act asked for a confirm: %q", reply)
|
|
||||||
}
|
|
||||||
if !strings.Contains(reply, "не вернуть") {
|
|
||||||
t.Errorf("irreversible act replied %q; want it to name the reason", reply)
|
|
||||||
}
|
|
||||||
// Nothing was parked, so a later "да" cannot pick it up.
|
|
||||||
h.mu.Lock()
|
|
||||||
pending := h.pending
|
|
||||||
h.mu.Unlock()
|
|
||||||
if pending != nil {
|
|
||||||
t.Errorf("an irreversible act parked %+v", pending)
|
|
||||||
}
|
|
||||||
// And it is still an enabled row — refusing to run it from voice is not
|
|
||||||
// the same as taking it off the allowlist.
|
|
||||||
if got, err := st.LookupTool(ctx, "wipe"); err != nil || got.Status != "enabled" {
|
|
||||||
t.Errorf("wipe is %+v, %v; want it still enabled", got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -15,7 +14,7 @@ import (
|
|||||||
// it for recall, and let pattern detection propose a routine.
|
// it for recall, and let pattern detection propose a routine.
|
||||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||||
if !dec.Slots.HasKey {
|
if !dec.Slots.HasKey {
|
||||||
return phraser.Ack(phraser.FailFactUnparsed, nil)
|
return "не разобрала, что записать — попробуй иначе."
|
||||||
}
|
}
|
||||||
// A question is never a fact about him (#470). "какая последняя версия
|
// A question is never a fact about him (#470). "какая последняя версия
|
||||||
// языка Go?" used to land here, and the value stored was whatever the
|
// языка Go?" used to land here, and the value stored was whatever the
|
||||||
@@ -63,7 +62,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
factID, err := h.api.WriteFact(ctx, req)
|
factID, err := h.api.WriteFact(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: write fact: %v", err)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return phraser.Ack(phraser.FailFact, nil)
|
return "не получилось сохранить факт."
|
||||||
}
|
}
|
||||||
// Index the fact in long-term memory (best-effort, must not fail the fact
|
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||||
// write). Facts aren't in the notes table, so this is the only recall path
|
// write). Facts aren't in the notes table, so this is the only recall path
|
||||||
@@ -74,11 +73,11 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
// hears; storing the utterance meant recall answered with his own sentence
|
// hears; storing the utterance meant recall answered with his own sentence
|
||||||
// rather than the value. The utterance stays alongside as provenance —
|
// rather than the value. The utterance stays alongside as provenance —
|
||||||
// readable on /trace, never the answer and never embedded.
|
// 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)
|
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)
|
log.Printf("voice: embed fact for memory: %v", err)
|
||||||
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
} else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "fact",
|
"type": "fact",
|
||||||
"text": text,
|
"text": text,
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/router"
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Standing lists on the voice path (Vikunja #453).
|
|
||||||
//
|
|
||||||
// Three halves, mirroring what task capture already does: an add that runs at
|
|
||||||
// the top of actionNote, a read-back query source, and a crossing-off that runs
|
|
||||||
// on the same note path because "всё купил" is note-shaped.
|
|
||||||
//
|
|
||||||
// These read h.dataStore rather than the CoreAPI. A list is local to the core
|
|
||||||
// and nothing outside it writes one: the web UI has no list page, no reach
|
|
||||||
// files groceries, and the digestion worker does not read the table. When
|
|
||||||
// something outside mavend needs to add to a list, the ipc seam is what it
|
|
||||||
// grows through — the intake rules that CaptureTaskReq documents are about
|
|
||||||
// shared intake, and there is none here yet.
|
|
||||||
//
|
|
||||||
// Nothing here speaks unprompted. A list is answered when asked about.
|
|
||||||
|
|
||||||
// captureListFromNote claims the turn when the utterance adds to, clears, or
|
|
||||||
// crosses one item off a list. ("", false) hands the turn back to the note path.
|
|
||||||
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
|
||||||
if h.dataStore == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
// Clearing is read before removing on purpose: "всё купил" and "купил
|
|
||||||
// молоко" start with the same word, and only the second one names an item.
|
|
||||||
if list, ok := router.ParseListClear(dec.Utterance); ok {
|
|
||||||
n, err := h.dataStore.ClearList(ctx, list, h.now())
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("voice: clear list: %v", err)
|
|
||||||
return "не получилось обновить список.", true
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return "в списке и так ничего не было.", true
|
|
||||||
}
|
|
||||||
return "вычеркнула всё, список пустой.", true
|
|
||||||
}
|
|
||||||
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
|
|
||||||
if reply, ok := h.removeListItem(ctx, cap); ok {
|
|
||||||
return reply, true
|
|
||||||
}
|
|
||||||
// Nothing on the list by that name. "купил новый ноутбук" is a note and
|
|
||||||
// must stay one, so the turn goes back rather than claiming a removal
|
|
||||||
// that removed nothing.
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
cap, ok := router.ParseListCapture(dec.Utterance)
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
|
|
||||||
List: cap.List,
|
|
||||||
Item: cap.Item,
|
|
||||||
Source: "tap:voice",
|
|
||||||
CreatedTs: h.now(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("voice: add list item: %v", err)
|
|
||||||
return "не получилось добавить в список.", true
|
|
||||||
}
|
|
||||||
if !res.Created {
|
|
||||||
return cap.Item + " уже в списке.", true
|
|
||||||
}
|
|
||||||
return "добавила в список: " + cap.Item + ".", true
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeListItem crosses one named item off. It reports false when the list
|
|
||||||
// holds nothing by that name, which is what keeps the marker words from
|
|
||||||
// swallowing ordinary notes.
|
|
||||||
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
|
|
||||||
items, err := h.dataStore.ListItems(ctx, cap.List, "")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("voice: list items: %v", err)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
want := store.NormalizeTaskText(cap.Item)
|
|
||||||
for _, li := range items {
|
|
||||||
if store.NormalizeTaskText(li.Item) != want {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
|
|
||||||
log.Printf("voice: cross off list item: %v", err)
|
|
||||||
return "не получилось обновить список.", true
|
|
||||||
}
|
|
||||||
return "вычеркнула: " + li.Item + ".", true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// queryList — "что в списке покупок?", "что мне купить?".
|
|
||||||
//
|
|
||||||
// A query source, so it sits in querySources and either claims the turn or
|
|
||||||
// passes it on. It is before the recall sources for the reason every specific
|
|
||||||
// source is: the notes pass would otherwise answer a list question with
|
|
||||||
// whatever note is nearest.
|
|
||||||
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
|
||||||
list, ok := router.ParseListQuery(t.dec.Utterance)
|
|
||||||
if !ok || h.dataStore == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
items, err := h.dataStore.ListItems(ctx, list, "")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("voice: list items: %v", err)
|
|
||||||
return "не получилось посмотреть список.", true
|
|
||||||
}
|
|
||||||
return formatListRU(list, items), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatListRU reads a list aloud. One sentence, comma-separated, because a
|
|
||||||
// shopping list is heard in a shop and a numbered recital is unusable there.
|
|
||||||
func formatListRU(list string, items []store.ListItem) string {
|
|
||||||
name := "списке " + listGenitive(list)
|
|
||||||
if len(items) == 0 {
|
|
||||||
return "в " + name + " пусто."
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(items))
|
|
||||||
for _, li := range items {
|
|
||||||
names = append(names, li.Item)
|
|
||||||
}
|
|
||||||
return "в " + name + ": " + strings.Join(names, ", ") + "."
|
|
||||||
}
|
|
||||||
|
|
||||||
// listGenitive puts a list tag into the case "список <…>" needs. Russian
|
|
||||||
// declines the noun and she must not say "в списке покупки".
|
|
||||||
func listGenitive(list string) string {
|
|
||||||
switch list {
|
|
||||||
case "покупки":
|
|
||||||
return "покупок"
|
|
||||||
case "аптека":
|
|
||||||
return "аптеки"
|
|
||||||
case "хозяйство":
|
|
||||||
return "хозяйства"
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/router"
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
|
||||||
|
|
||||||
func listHandler(t *testing.T) *reactiveHandler {
|
|
||||||
t.Helper()
|
|
||||||
return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
|
|
||||||
}
|
|
||||||
|
|
||||||
func askList(t *testing.T, h *reactiveHandler, utterance string) (string, bool) {
|
|
||||||
t.Helper()
|
|
||||||
return h.captureListFromNote(context.Background(), router.Decision{
|
|
||||||
Intent: router.IntentNote, Utterance: utterance,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListCaptureAddsAndReadsBack(t *testing.T) {
|
|
||||||
h := listHandler(t)
|
|
||||||
for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} {
|
|
||||||
if reply, ok := askList(t, h, u); !ok {
|
|
||||||
t.Fatalf("%q was not claimed (reply %q)", u, reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if reply, ok := askList(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") {
|
|
||||||
t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok)
|
|
||||||
}
|
|
||||||
answer, ok := h.queryList(context.Background(), &queryTurn{
|
|
||||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"},
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("the list question was not claimed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") {
|
|
||||||
t.Errorf("answer %q; want both items", answer)
|
|
||||||
}
|
|
||||||
if strings.Contains(answer, "списке покупки") {
|
|
||||||
t.Errorf("answer %q declines the list name wrong", answer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An utterance with no list marker is a note and must stay one, whichever half
|
|
||||||
// of the parser it brushes against.
|
|
||||||
func TestListCapturePassesOrdinaryNotes(t *testing.T) {
|
|
||||||
h := listHandler(t)
|
|
||||||
for _, u := range []string{
|
|
||||||
"молоко закончилось",
|
|
||||||
"надо бы съездить в магазин",
|
|
||||||
"купил новый ноутбук",
|
|
||||||
"добавь в список покупок",
|
|
||||||
} {
|
|
||||||
if reply, ok := askList(t, h, u); ok {
|
|
||||||
t.Errorf("%q was claimed as a list turn: %q", u, reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListCrossOffOneItemAndThenAll(t *testing.T) {
|
|
||||||
h := listHandler(t)
|
|
||||||
for _, u := range []string{
|
|
||||||
"добавь в список покупок молоко",
|
|
||||||
"добавь в список покупок хлеб",
|
|
||||||
"добавь в список аптеки бинт",
|
|
||||||
} {
|
|
||||||
if _, ok := askList(t, h, u); !ok {
|
|
||||||
t.Fatalf("%q was not claimed", u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reply, ok := askList(t, h, "вычеркни молоко")
|
|
||||||
if !ok || !strings.Contains(reply, "молоко") {
|
|
||||||
t.Fatalf("cross off replied %q, %v", reply, ok)
|
|
||||||
}
|
|
||||||
open, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
if len(open) != 1 || open[0].Item != "хлеб" {
|
|
||||||
t.Fatalf("open list %+v; want only хлеб", open)
|
|
||||||
}
|
|
||||||
if reply, ok := askList(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") {
|
|
||||||
t.Errorf("clear replied %q, %v", reply, ok)
|
|
||||||
}
|
|
||||||
open, err = h.dataStore.ListItems(context.Background(), "покупки", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
if len(open) != 0 {
|
|
||||||
t.Errorf("%d items still open after всё купил", len(open))
|
|
||||||
}
|
|
||||||
// The other list is untouched, and it is read back on its own.
|
|
||||||
answer, ok := h.queryList(context.Background(), &queryTurn{
|
|
||||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"},
|
|
||||||
})
|
|
||||||
if !ok || !strings.Contains(answer, "бинт") {
|
|
||||||
t.Errorf("аптека answer %q, %v; want бинт", answer, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryListSaysWhenItIsEmpty(t *testing.T) {
|
|
||||||
h := listHandler(t)
|
|
||||||
answer, ok := h.queryList(context.Background(), &queryTurn{
|
|
||||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"},
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("the list question was not claimed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(answer, "пусто") {
|
|
||||||
t.Errorf("empty answer %q; want it to say so", answer)
|
|
||||||
}
|
|
||||||
if _, ok := h.queryList(context.Background(), &queryTurn{
|
|
||||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
|
||||||
}); ok {
|
|
||||||
t.Error("the list source claimed a task question")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 0 answers a list turn without the model: the grammars route it, and the
|
|
||||||
// action handlers re-parse what the grammar matched.
|
|
||||||
func TestListGrammarsRouteWithoutTheModel(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
utterance string
|
|
||||||
want router.Intent
|
|
||||||
}{
|
|
||||||
{"добавь в список покупок молоко", router.IntentNote},
|
|
||||||
{"что в списке покупок?", router.IntentQuery},
|
|
||||||
{"всё купил", router.IntentNote},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
var got router.Intent
|
|
||||||
claimed := false
|
|
||||||
for _, g := range router.ListGrammars() {
|
|
||||||
m := g.Pattern.FindStringSubmatch(c.utterance)
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if dec, ok := g.Build(m); ok {
|
|
||||||
got, claimed = dec.Intent, true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !claimed {
|
|
||||||
t.Errorf("no list grammar claimed %q", c.utterance)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if got != c.want {
|
|
||||||
t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, g := range router.ListGrammars() {
|
|
||||||
m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра")
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := g.Build(m); ok {
|
|
||||||
t.Errorf("grammar %s claimed a reminder", g.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListStoreSourceIsVoice(t *testing.T) {
|
|
||||||
h := listHandler(t)
|
|
||||||
if _, ok := askList(t, h, "добавь в список покупок молоко"); !ok {
|
|
||||||
t.Fatal("not claimed")
|
|
||||||
}
|
|
||||||
items, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
if len(items) != 1 || items[0].Source != "tap:voice" {
|
|
||||||
t.Errorf("stored %+v; want one row from tap:voice", items)
|
|
||||||
}
|
|
||||||
if items[0].Status != store.ListItemOpen {
|
|
||||||
t.Errorf("status %q; want open", items[0].Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,30 +17,24 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
|||||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
// A standing list is neither work nor recall (Vikunja #453). Checked here
|
|
||||||
// for the same reason and at the same cost: before the embedding is paid
|
|
||||||
// for, and it passes the turn straight back when no marker matches.
|
|
||||||
if reply, ok := h.captureListFromNote(ctx, dec); ok {
|
|
||||||
return reply
|
|
||||||
}
|
|
||||||
// embed the note text with the same model the classifier uses, persist
|
// embed the note text with the same model the classifier uses, persist
|
||||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||||
// facts — no predicate reads it (spec's two-memory split).
|
// facts — no predicate reads it (spec's two-memory split).
|
||||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: embed note: %v", err)
|
log.Printf("voice: embed note: %v", err)
|
||||||
return phraser.Ack(phraser.FailNote, nil)
|
return "не получилось сохранить заметку."
|
||||||
}
|
}
|
||||||
noteTs := h.now()
|
noteTs := h.now()
|
||||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: write note: %v", err)
|
log.Printf("voice: write note: %v", err)
|
||||||
return phraser.Ack(phraser.FailNote, nil)
|
return "не получилось сохранить заметку."
|
||||||
}
|
}
|
||||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||||
if h.memStore != nil {
|
if h.recall.memStore != nil {
|
||||||
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"text": dec.Utterance,
|
"text": dec.Utterance,
|
||||||
|
|||||||
+46
-64
@@ -85,11 +85,6 @@ var querySources = []querySource{
|
|||||||
// the money facts the poller wrote, and the notes pass would otherwise
|
// the money facts the poller wrote, and the notes pass would otherwise
|
||||||
// answer it from whatever he once said about spending. Its matcher needs a
|
// answer it from whatever he once said about spending. Its matcher needs a
|
||||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||||
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
|
||||||
// about the shopping list, and the recall pass would otherwise answer it
|
|
||||||
// from an old note about the shop. Its matcher needs an explicit list
|
|
||||||
// marker, so "надо бы съездить в магазин" is untouched.
|
|
||||||
{name: "list", answer: (*reactiveHandler).queryList},
|
|
||||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||||
// Before the recall sources and before general knowledge: "что нового?" is
|
// Before the recall sources and before general knowledge: "что нового?" is
|
||||||
// a question about the feeds she reads, and general knowledge would answer
|
// a question about the feeds she reads, and general knowledge would answer
|
||||||
@@ -152,9 +147,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
|||||||
// The previous question cannot be re-asked for another day. Saying so
|
// The previous question cannot be re-asked for another day. Saying so
|
||||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||||
// truth is that she never looked.
|
// truth is that she never looked.
|
||||||
return phraser.Q(phraser.QueryOtherDay, nil)
|
return "про другой день так не отвечу — спроси целиком."
|
||||||
}
|
}
|
||||||
return phraser.Q(phraser.QueryUnknown, nil)
|
return "не знаю."
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||||
@@ -172,11 +167,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
|||||||
if dec.Slots.HasTime {
|
if dec.Slots.HasTime {
|
||||||
// The query asks about timing — the fact's own timestamp is the
|
// The query asks about timing — the fact's own timestamp is the
|
||||||
// answer it's looking for. Format as a natural reply.
|
// answer it's looking for. Format as a natural reply.
|
||||||
return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
||||||
}
|
}
|
||||||
// General fact reference: describe what we know.
|
// General fact reference: describe what we know.
|
||||||
if dec.Utterance == "" {
|
if dec.Utterance == "" {
|
||||||
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
|
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
||||||
}
|
}
|
||||||
// The utterance still carries the question; fall through to normal RAG
|
// The utterance still carries the question; fall through to normal RAG
|
||||||
// with the resolved key in context.
|
// with the resolved key in context.
|
||||||
@@ -202,7 +197,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
|||||||
plan, err := h.api.DayPlan(ctx)
|
plan, err := h.api.DayPlan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: day plan: %v", err)
|
log.Printf("voice: day plan: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailPlan, nil), true
|
return "не получилось собрать план.", true
|
||||||
}
|
}
|
||||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||||
return plan.Spoken, true
|
return plan.Spoken, true
|
||||||
@@ -247,7 +242,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
|||||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: habits: recent facts: %v", err)
|
log.Printf("voice: habits: recent facts: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailNotes, nil), true
|
return "не получилось посмотреть записи.", true
|
||||||
}
|
}
|
||||||
obs := make([]memory.Observation, 0, len(facts))
|
obs := make([]memory.Observation, 0, len(facts))
|
||||||
for _, f := range facts {
|
for _, f := range facts {
|
||||||
@@ -286,7 +281,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||||
// letting general knowledge answer "что нового?" would be an invented
|
// letting general knowledge answer "что нового?" would be an invented
|
||||||
// news bulletin.
|
// news bulletin.
|
||||||
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
return "я пока не читаю ленты — они не настроены.", true
|
||||||
}
|
}
|
||||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||||
// used to push the newest headline out of the window, and she answered "в
|
// used to push the newest headline out of the window, and she answered "в
|
||||||
@@ -294,7 +289,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: feeds: recent notes: %v", err)
|
log.Printf("voice: feeds: recent notes: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
return "не получилось посмотреть ленты.", true
|
||||||
}
|
}
|
||||||
var picked []string
|
var picked []string
|
||||||
for _, n := range notes {
|
for _, n := range notes {
|
||||||
@@ -311,11 +306,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
}
|
}
|
||||||
if len(picked) == 0 {
|
if len(picked) == 0 {
|
||||||
if q.Category != "" {
|
if q.Category != "" {
|
||||||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
return "по этой теме в лентах пока ничего.", true
|
||||||
}
|
}
|
||||||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
return "в лентах пока ничего нового.", true
|
||||||
}
|
}
|
||||||
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
|
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||||
@@ -329,7 +324,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: calendar events: %v", err)
|
log.Printf("voice: calendar events: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailCalendar, nil), true
|
return "не получилось проверить календарь.", true
|
||||||
}
|
}
|
||||||
// Provenance travels with each event. A work meeting relayed off a phone
|
// Provenance travels with each event. A work meeting relayed off a phone
|
||||||
// notification (source ambient:notif, #126) is stored below full confidence
|
// notification (source ambient:notif, #126) is stored below full confidence
|
||||||
@@ -372,12 +367,9 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.netscan == nil {
|
if h.netscan == nil {
|
||||||
// The recogniser already matched, so this is a question about HIS LAN
|
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||||
// and there is no scanner to answer it. Falling through sent it to the
|
// swallow "сколько устройств в сети?" before recall has looked.
|
||||||
// search leg, which answered with a paragraph about routers in general
|
return "", false
|
||||||
// and put his network question on an upstream engine (Vikunja #479).
|
|
||||||
// A missing capability names itself.
|
|
||||||
return phraser.Q(phraser.QueryNetOff, nil), true
|
|
||||||
}
|
}
|
||||||
return h.netscan.scanSummary(ctx)
|
return h.netscan.scanSummary(ctx)
|
||||||
}
|
}
|
||||||
@@ -390,13 +382,13 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
|||||||
if loc == "" {
|
if loc == "" {
|
||||||
// He named no city and voice.weather.default_location is unset. Saying
|
// He named no city and voice.weather.default_location is unset. Saying
|
||||||
// so is the only honest answer; picking a city would be inventing one.
|
// so is the only honest answer; picking a city would be inventing one.
|
||||||
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||||
}
|
}
|
||||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||||
if errors.Is(err, weather.ErrNotConfigured) {
|
if errors.Is(err, weather.ErrNotConfigured) {
|
||||||
return phraser.Q(phraser.QueryWeatherOff, nil), true
|
return "погода не настроена.", true
|
||||||
}
|
}
|
||||||
if errors.Is(err, weather.ErrLocationUnknown) {
|
if errors.Is(err, weather.ErrLocationUnknown) {
|
||||||
// He named a place and the geocoder does not have it. Saying so beats
|
// He named a place and the geocoder does not have it. Saying so beats
|
||||||
@@ -405,24 +397,19 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
|||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: weather: %v", err)
|
log.Printf("voice: weather: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailWeather, nil), true
|
return "не получилось узнать погоду.", true
|
||||||
}
|
}
|
||||||
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
||||||
"location": w.Location,
|
|
||||||
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
|
||||||
"word": phraser.Degrees(w.Temperature),
|
|
||||||
"condition": w.Condition,
|
|
||||||
}), true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||||
// sources below both need, run once, in the position it always ran in. It
|
// sources below both need, run once, in the position it always ran in. It
|
||||||
// only claims the turn when the embedder fails.
|
// only claims the turn when the embedder fails.
|
||||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: embed query: %v", err)
|
log.Printf("voice: embed query: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
return "не получилось найти ответ.", true
|
||||||
}
|
}
|
||||||
t.vec = vec
|
t.vec = vec
|
||||||
return "", false
|
return "", false
|
||||||
@@ -439,15 +426,15 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
|||||||
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
||||||
// which memory gets to answer them.
|
// which memory gets to answer them.
|
||||||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if h.memStore == nil {
|
if h.recall.memStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
log.Printf("voice: memory search: %v", herr)
|
log.Printf("voice: memory search: %v", herr)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -463,13 +450,7 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
|||||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||||
// stored.
|
// stored.
|
||||||
if hit.Meta["type"] == "note" {
|
if hit.Meta["type"] == "note" {
|
||||||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
|
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
||||||
switch {
|
|
||||||
case perr != nil:
|
|
||||||
// Reading the note back verbatim beats the phraser's own fallback,
|
|
||||||
// which only wraps the same text in "вот что я нашла:".
|
|
||||||
log.Printf("voice: recall phrase: %v", perr)
|
|
||||||
case reply != "":
|
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,14 +472,14 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: query notes: %v", err)
|
log.Printf("voice: query notes: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
return "не получилось найти ответ.", true
|
||||||
}
|
}
|
||||||
t.notes = notes
|
t.notes = notes
|
||||||
noteScores := make([]float64, len(notes))
|
noteScores := make([]float64, len(notes))
|
||||||
for i, n := range notes {
|
for i, n := range notes {
|
||||||
noteScores[i] = n.Score
|
noteScores[i] = n.Score
|
||||||
}
|
}
|
||||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
// Same topic veto as queryMemory above: the best note must be about what
|
// Same topic veto as queryMemory above: the best note must be about what
|
||||||
@@ -516,7 +497,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
log.Printf("voice: phrase query: %v", err)
|
log.Printf("voice: phrase query: %v", err)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
reply = "вот что я нашла: " + texts[0]
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -538,24 +519,25 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.crawler == nil {
|
if h.crawler == nil {
|
||||||
// He named a URL, so the question is about that page and nothing else
|
// Fall through. Reading pages is off unless configured, and on a daemon
|
||||||
// can answer it. The older comment here argued for falling through and
|
// where it was never turned on the older behaviour is right: the model
|
||||||
// letting the model answer as if the URL had not been said; that is a
|
// answers the question as if the URL had not been said. Announcing a
|
||||||
// guess dressed as an answer (Vikunja #479).
|
// configuration status is for a capability that exists and failed, not
|
||||||
return phraser.Q(phraser.QueryPageOff, nil), true
|
// for one he never asked for.
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
page, err := h.crawler.Page(ctxFetch, link)
|
page, err := h.crawler.Page(ctxFetch, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, crawl.ErrRobots) {
|
if errors.Is(err, crawl.ErrRobots) {
|
||||||
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||||
}
|
}
|
||||||
log.Printf("voice: web: %v", err)
|
log.Printf("voice: web: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailPage, nil), true
|
return "не получилось прочитать страницу.", true
|
||||||
}
|
}
|
||||||
if page.Text == "" {
|
if page.Text == "" {
|
||||||
return phraser.Q(phraser.QueryPageEmpty, nil), true
|
return "страница открылась, но читать там нечего.", true
|
||||||
}
|
}
|
||||||
// The page is handed to the phraser the same way a note is: as context for
|
// The page is handed to the phraser the same way a note is: as context for
|
||||||
// the question he actually asked. She answers the question, she does not
|
// the question he actually asked. She answers the question, she does not
|
||||||
@@ -565,7 +547,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser (or it failed): read back the top of the page rather than
|
// No phraser (or it failed): read back the top of the page rather than
|
||||||
// pretend the fetch did not happen.
|
// pretend the fetch did not happen.
|
||||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -631,7 +613,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser, or it failed. Read back the best evidence rather than
|
// No phraser, or it failed. Read back the best evidence rather than
|
||||||
// pretend the search did not happen.
|
// pretend the search did not happen.
|
||||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -712,7 +694,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||||
// the search did not happen.
|
// the search did not happen.
|
||||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -742,7 +724,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
||||||
return phraser.Q(phraser.QueryPersonalNone, nil), true
|
return "не знаю — не нашла у тебя такой записи.", true
|
||||||
}
|
}
|
||||||
|
|
||||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||||
@@ -784,8 +766,8 @@ func isPersonalQuery(utterance string) bool {
|
|||||||
// computed. Same shape as the cascade: the better test leads, the offline one
|
// computed. Same shape as the cascade: the better test leads, the offline one
|
||||||
// always answers.
|
// always answers.
|
||||||
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
|
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
|
||||||
h.boundary.load(ctx, h.embedder)
|
h.recall.boundary.load(ctx, h.recall.embedder)
|
||||||
if personal, world, ok := h.boundary.score(t.vec); ok {
|
if personal, world, ok := h.recall.boundary.score(t.vec); ok {
|
||||||
if personal > world {
|
if personal > world {
|
||||||
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
|
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
|
||||||
return true
|
return true
|
||||||
@@ -810,15 +792,15 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
|
|||||||
if h.phraser == nil {
|
if h.phraser == nil {
|
||||||
// No model of any size. That is not the workstation being asleep, so it
|
// No model of any size. That is not the workstation being asleep, so it
|
||||||
// is not that gap: it is simply not knowing.
|
// is not that gap: it is simply not knowing.
|
||||||
return phraser.Q(phraser.QueryUnknown, nil), true
|
return "не знаю.", true
|
||||||
}
|
}
|
||||||
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||||||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||||
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
||||||
return worldGap(), true
|
return worldGap, true
|
||||||
}
|
}
|
||||||
if err != nil || reply == "" {
|
if err != nil || reply == "" {
|
||||||
return phraser.Q(phraser.QueryUnknown, nil), true
|
return "не знаю.", true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,13 +21,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !dec.Slots.HasTime {
|
if !dec.Slots.HasTime {
|
||||||
return phraser.Ack(phraser.FailReminderTime, nil)
|
return "не получилось разобрать время напоминания."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||||
log.Printf("voice: create reminder: %v", err)
|
log.Printf("voice: create reminder: %v", err)
|
||||||
return phraser.Ack(phraser.FailReminder, nil)
|
return "не получилось поставить напоминание."
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/tasks"
|
"github.com/kami/maven/internal/tasks"
|
||||||
@@ -41,18 +40,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: capture task: %v", err)
|
log.Printf("voice: capture task: %v", err)
|
||||||
return phraser.Ack(phraser.FailTask, nil), true
|
return "не получилось записать задачу.", true
|
||||||
}
|
}
|
||||||
if resp.Promoted {
|
if resp.Promoted {
|
||||||
// It was a candidate Maven derived from something she read, and he has
|
// It was a candidate Maven derived from something she read, and he has
|
||||||
// now said it himself. Saying "уже в списке" here would be answering a
|
// now said it himself. Saying "уже в списке" here would be answering a
|
||||||
// confirmation with a shrug.
|
// confirmation with a shrug.
|
||||||
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
return "поняла, беру в работу: " + cap.Text, true
|
||||||
}
|
}
|
||||||
if !resp.Created {
|
if !resp.Created {
|
||||||
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
|
return "это уже в списке.", true
|
||||||
}
|
}
|
||||||
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
|
return "записала: " + cap.Text, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||||
}
|
}
|
||||||
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
if !strings.Contains(reply, "не получилось") {
|
||||||
t.Errorf("reply = %q, want an honest failure", reply)
|
t.Errorf("reply = %q, want an honest failure", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +137,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
|||||||
if !(openIdx < candIdx) {
|
if !(openIdx < candIdx) {
|
||||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не подтверждал") {
|
if !strings.Contains(reply, "не подтвердил") {
|
||||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
|||||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h.embedder = emb
|
h.recall.embedder = emb
|
||||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||||
|
|
||||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
|||||||
+11
-16
@@ -125,17 +125,13 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A daemon where page reading was never turned on names the gap. He asked
|
// A daemon where page reading was never turned on — the default — answers the
|
||||||
// about one page, nothing else on the box can read it, and the old behaviour
|
// question the way it did before the capability existed. Claiming the turn to
|
||||||
// here was to answer as though the URL had not been said (Vikunja #479).
|
// report a configuration status is for something that exists and failed.
|
||||||
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
|
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
||||||
h := buildWebHandler(nil)
|
h := buildWebHandler(nil)
|
||||||
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
||||||
if !ok {
|
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
||||||
t.Fatal("an unconfigured crawler let the page question fall through")
|
|
||||||
}
|
|
||||||
if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
|
|
||||||
t.Errorf("got %q, want the gap named", reply)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,22 +156,21 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the web source did not claim a question with a URL")
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
}
|
}
|
||||||
if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
|
if !strings.Contains(reply, "не получилось") {
|
||||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// robots.txt is honoured on the answer path too, and she says the page is
|
// robots.txt is honoured on the answer path too, and she says so instead of
|
||||||
// closed instead of reporting a generic failure.
|
// reporting a generic failure.
|
||||||
func TestQueryWebObeysRobots(t *testing.T) {
|
func TestQueryWebObeysRobots(t *testing.T) {
|
||||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the web source did not claim a question with a URL")
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
}
|
}
|
||||||
// She names the cause without reading a filename out loud.
|
if !strings.Contains(reply, "robots.txt") {
|
||||||
if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") {
|
t.Errorf("reply = %q, want the robots answer", reply)
|
||||||
t.Errorf("reply = %q, want the closed-page answer with no filename", reply)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/calendar"
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -148,8 +147,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||||
}
|
}
|
||||||
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
if reply != "не получилось собрать план." {
|
||||||
t.Errorf("reply = %q, want the honest failure", reply)
|
t.Errorf("reply = %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-142
@@ -9,25 +9,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/tool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The three services, spelled the way she says them out loud. A service that is
|
|
||||||
// down or refusing has to be named: they degrade independently, so "не
|
|
||||||
// отвечает" on its own tells him nothing he can act on, and each call site
|
|
||||||
// already knows which one it was talking to — it records the same name in the
|
|
||||||
// trace (Vikunja #521).
|
|
||||||
const (
|
|
||||||
serviceNexus = "Nexus"
|
|
||||||
serviceHexis = "Hexis"
|
|
||||||
)
|
|
||||||
|
|
||||||
// serviceVars — the one-key map the eco_down and eco_denied lines take.
|
|
||||||
func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
|
|
||||||
|
|
||||||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||||||
// rather than a map[string]func because each arm carries its own state: the
|
// rather than a map[string]func because each arm carries its own state: the
|
||||||
// verb aliases it answers to, the trace name it records, and its own reply
|
// verb aliases it answers to, the trace name it records, and its own reply
|
||||||
@@ -159,10 +144,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
log.Printf("ecosystem: praxis attention: %v", err)
|
log.Printf("ecosystem: praxis attention: %v", err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||||
started, traceErrorFields(err))
|
started, traceErrorFields(err))
|
||||||
return phraser.A(phraser.AttentionFail, nil)
|
return "не могу сейчас узнать, что требует внимания."
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return phraser.A(phraser.AttentionNone, nil)
|
return "ничего не требует внимания."
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||||
var parts []string
|
var parts []string
|
||||||
@@ -172,11 +157,6 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
importance, _ := item["importance"].(float64)
|
importance, _ := item["importance"].(float64)
|
||||||
rule, _ := item["rule"].(string)
|
rule, _ := item["rule"].(string)
|
||||||
s := title
|
s := title
|
||||||
if s == "" {
|
|
||||||
// An item Praxis returned without a title is not an item she can
|
|
||||||
// read out. Counting it would put an empty slot in the list.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if importance > 0 {
|
if importance > 0 {
|
||||||
s += fmt.Sprintf(" (важность %d", int(importance))
|
s += fmt.Sprintf(" (важность %d", int(importance))
|
||||||
if rule != "" {
|
if rule != "" {
|
||||||
@@ -195,13 +175,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
return "требует внимания: " + strings.Join(parts, "; ")
|
||||||
// Praxis returned items and not one of them could be said. "ничего не
|
|
||||||
// требует внимания" is the honest answer; the list line would render as
|
|
||||||
// its own label and a colon (Vikunja #521).
|
|
||||||
return phraser.A(phraser.AttentionNone, nil)
|
|
||||||
}
|
|
||||||
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// listChangesCapability reads the recent-changes feed.
|
// listChangesCapability reads the recent-changes feed.
|
||||||
@@ -218,29 +192,19 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
|||||||
log.Printf("ecosystem: praxis changes: %v", err)
|
log.Printf("ecosystem: praxis changes: %v", err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||||
started, traceErrorFields(err))
|
started, traceErrorFields(err))
|
||||||
return phraser.A(phraser.ChangesFail, nil)
|
return "не могу сейчас узнать об изменениях."
|
||||||
}
|
}
|
||||||
if len(changes) == 0 {
|
if len(changes) == 0 {
|
||||||
return phraser.A(phraser.ChangesNone, nil)
|
return "нет изменений."
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, c := range changes {
|
for _, c := range changes {
|
||||||
title, _ := c["title"].(string)
|
title, _ := c["title"].(string)
|
||||||
if title == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
typ, _ := c["change_type"].(string)
|
typ, _ := c["change_type"].(string)
|
||||||
if typ == "" {
|
|
||||||
parts = append(parts, title)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
return "изменения: " + strings.Join(parts, "; ")
|
||||||
return phraser.A(phraser.ChangesNone, nil)
|
|
||||||
}
|
|
||||||
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||||
@@ -266,12 +230,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
subject = dec.Slots.Text
|
subject = dec.Slots.Text
|
||||||
}
|
}
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
return phraser.A(phraser.EcoAboutWhat, nil)
|
return "про что именно спросить?"
|
||||||
}
|
}
|
||||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||||
// than quietly answering about something else.
|
// than quietly answering about something else.
|
||||||
return phraser.A(phraser.EcoNoNexus, nil)
|
return "не могу связать это с сущностью — Nexus не настроен."
|
||||||
}
|
}
|
||||||
|
|
||||||
started := h.now()
|
started := h.now()
|
||||||
@@ -284,15 +248,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
return "экосистема отклоняет доступ, проверь токен."
|
||||||
}
|
}
|
||||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
return "экосистема недоступна, попробуй ещё раз."
|
||||||
}
|
}
|
||||||
if len(ambiguous) > 0 {
|
if len(ambiguous) > 0 {
|
||||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
return phraser.A(phraser.EcoUnknownEntity, nil)
|
return "не знаю такой сущности."
|
||||||
}
|
}
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = subject
|
displayName = subject
|
||||||
@@ -304,7 +268,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||||
}
|
}
|
||||||
items, scoped := scopedToEntity(items, entityID)
|
items, scoped := scopedToEntity(items, entityID)
|
||||||
if !scoped {
|
if !scoped {
|
||||||
@@ -315,7 +279,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
||||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||||
"entity_id": entityID, "count": len(items),
|
"entity_id": entityID, "count": len(items),
|
||||||
@@ -339,9 +303,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
parts = append(parts, known)
|
parts = append(parts, known)
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
return "по «" + displayName + "» ничего нет."
|
||||||
}
|
}
|
||||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||||
@@ -406,7 +370,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
|
|||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
|
out := "я помню: " + strings.Join(parts, ", ")
|
||||||
if more {
|
if more {
|
||||||
out += ", и это не всё"
|
out += ", и это не всё"
|
||||||
}
|
}
|
||||||
@@ -533,64 +497,6 @@ func traceErrorFields(err error) map[string]any {
|
|||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// entityResolution — what asking Nexus about a turn's candidate names came to.
|
|
||||||
// One shape rather than five return values, because the caller needs the
|
|
||||||
// reference that answered as well as the answer: it goes in the trace.
|
|
||||||
type entityResolution struct {
|
|
||||||
subject string // the reference Nexus answered about
|
|
||||||
entityID string // set when exactly one name resolved
|
|
||||||
displayName string // that entity's name as Nexus spells it
|
|
||||||
ambiguous []string // candidate display names to ask between
|
|
||||||
err error // a dependency failure, not a miss
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveEntityCandidates asks Nexus about each name the turn offered and
|
|
||||||
// reports what it knows, stopping early where the answer is already decided.
|
|
||||||
//
|
|
||||||
// The rules, in the order they apply:
|
|
||||||
//
|
|
||||||
// - A dependency failure ends it. Nexus being down is not "no such entity",
|
|
||||||
// and asking about the next name would report the outage as a miss.
|
|
||||||
// - Nexus calling one name ambiguous ends it. It has the candidates and it is
|
|
||||||
// telling us to ask.
|
|
||||||
// - Two names resolving to different entities is a clarify too, this time ours:
|
|
||||||
// "перезапусти nginx на muzick-indexer" names both a service and its host,
|
|
||||||
// and picking either would be inventing an intent he did not state.
|
|
||||||
// - Nothing resolving returns the first name as the subject, so the trace says
|
|
||||||
// what was actually looked for.
|
|
||||||
func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []string) entityResolution {
|
|
||||||
var out entityResolution
|
|
||||||
for _, ref := range refs {
|
|
||||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, ref, nil)
|
|
||||||
if err != nil {
|
|
||||||
return entityResolution{subject: ref, err: err}
|
|
||||||
}
|
|
||||||
if len(ambiguous) > 0 {
|
|
||||||
return entityResolution{subject: ref, ambiguous: ambiguous}
|
|
||||||
}
|
|
||||||
if entityID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if out.entityID == "" {
|
|
||||||
out = entityResolution{subject: ref, entityID: entityID, displayName: displayName}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if entityID == out.entityID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Both are real and they are not the same thing. Hand back the names
|
|
||||||
// Nexus spells, not the words he happened to say.
|
|
||||||
return entityResolution{
|
|
||||||
subject: out.subject,
|
|
||||||
ambiguous: []string{out.displayName, displayName},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if out.entityID == "" && len(refs) > 0 {
|
|
||||||
out.subject = refs[0]
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleHexisAct — resolves entity references through Nexus and executes
|
// handleHexisAct — resolves entity references through Nexus and executes
|
||||||
// matching capabilities through Hexis. Returns a reply string when handled,
|
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||||
// or "" to fall through to the system command executor.
|
// or "" to fall through to the system command executor.
|
||||||
@@ -608,27 +514,27 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
|
|
||||||
// Resolve the utterance text as an entity reference through Nexus. An
|
// Resolve the utterance text as an entity reference through Nexus. An
|
||||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||||
// The names come from entityReferences, not straight from the Text slot: the
|
// The name comes from entityReferenceText, not straight from the Text slot:
|
||||||
// model transliterates Latin names as it routes (Vikunja #476, #524).
|
// the model transliterates Latin names as it routes (Vikunja #476).
|
||||||
|
subject := entityReferenceText(dec)
|
||||||
started := h.now()
|
started := h.now()
|
||||||
res := h.resolveEntityCandidates(ctx, entityReferences(dec))
|
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||||||
subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
return "экосистема отклоняет доступ, проверь токен."
|
||||||
}
|
}
|
||||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||||
// and report degradation rather than silently falling through to the
|
// and report degradation rather than silently falling through to the
|
||||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||||
// independently, never a silent all-clear).
|
// independently, never a silent all-clear).
|
||||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
return "экосистема недоступна, попробуй ещё раз."
|
||||||
}
|
}
|
||||||
if len(ambiguous) > 0 {
|
if len(ambiguous) > 0 {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||||
map[string]any{"candidates": len(ambiguous)})
|
map[string]any{"candidates": len(ambiguous)})
|
||||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||||
@@ -647,9 +553,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
|
return "экосистема отклоняет доступ, проверь токен."
|
||||||
}
|
}
|
||||||
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
|
return "экосистема недоступна, попробуй ещё раз."
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||||
@@ -692,30 +598,13 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
names = append(names, m.Name)
|
names = append(names, m.Name)
|
||||||
}
|
}
|
||||||
return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
|
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
||||||
}
|
}
|
||||||
matched := matches[0]
|
matched := matches[0]
|
||||||
|
|
||||||
// Read-only capabilities run immediately; mutating ones are parked for an
|
// Read-only capabilities run immediately; mutating ones are parked for an
|
||||||
// explicit spoken confirm bound to this capability + target.
|
// explicit spoken confirm bound to this capability + target.
|
||||||
// The tier decides, and Hexis owns the tier (Vikunja #523). read_only alone
|
if !matched.ReadOnly {
|
||||||
// used to decide it here, which flattened three answers into two: a
|
|
||||||
// capability that wipes the thing it names got the same single spoken "да"
|
|
||||||
// as one that restarts a service, and requires_confirmation — which the
|
|
||||||
// Hexis contract calls server-derived and not settable by a caller — was
|
|
||||||
// read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk.
|
|
||||||
tier := tool.RiskOfCapability(matched.Risk, matched.ReadOnly, matched.RequiresConfirmation)
|
|
||||||
policy := tool.PolicyFor(tier)
|
|
||||||
if !policy.VoiceMayRun {
|
|
||||||
// Irreversible. A confirm turn would not help, for the same reason it
|
|
||||||
// does not help a local row: the STT heard it, the model routed it and
|
|
||||||
// a substring matched the capability, and a spoken "да" checks none of
|
|
||||||
// those. She names the gap and he runs it himself.
|
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", traceRefused, started,
|
|
||||||
map[string]any{"entity_id": entityID, "capability": matched.Name, "risk": string(tier)})
|
|
||||||
return phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
|
||||||
}
|
|
||||||
if policy.Confirm {
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.pendingHexis = &pendingHexisExec{
|
h.pendingHexis = &pendingHexisExec{
|
||||||
capabilityID: matched.ID,
|
capabilityID: matched.ID,
|
||||||
@@ -727,7 +616,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||||
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName})
|
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||||
@@ -747,7 +636,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
mergeFields(traceErrorFields(err), map[string]any{
|
mergeFields(traceErrorFields(err), map[string]any{
|
||||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||||
}))
|
}))
|
||||||
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
return "не получилось выполнить команду для " + displayName + "."
|
||||||
}
|
}
|
||||||
// One record per hop: the second write this used to make said the same
|
// One record per hop: the second write this used to make said the same
|
||||||
// thing under a different key, in a different shape.
|
// thing under a different key, in a different shape.
|
||||||
@@ -755,7 +644,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
"entity_id": entityID, "entity_name": displayName,
|
"entity_id": entityID, "entity_name": displayName,
|
||||||
"capability": capName, "causation_id": causationID,
|
"capability": capName, "causation_id": causationID,
|
||||||
})
|
})
|
||||||
return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
|
return "команда выполнена для " + displayName + "."
|
||||||
}
|
}
|
||||||
|
|
||||||
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
|
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
|||||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
||||||
// store is the one thing the Praxis path could inherit it through.
|
// store is the one thing the Praxis path could inherit it through.
|
||||||
nexus.SetFault(503)
|
nexus.SetFault(503)
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||||
@@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
|||||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
||||||
}
|
}
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||||
}
|
}
|
||||||
if actRan(reply) {
|
if strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
|||||||
|
|
||||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if reply == "" || actRan(reply) {
|
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
|||||||
nexus := newFakeNexus(t, body)
|
nexus := newFakeNexus(t, body)
|
||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if reply == "" || actRan(reply) {
|
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
|||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if actRan(reply) {
|
if strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
exec := tracesFor(t, h, "hexis", "execute")
|
exec := tracesFor(t, h, "hexis", "execute")
|
||||||
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
if len(traces(t, h)) == 0 {
|
if len(traces(t, h)) == 0 {
|
||||||
@@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Errorf("%s: total outage must not answer with silence", name)
|
t.Errorf("%s: total outage must not answer with silence", name)
|
||||||
}
|
}
|
||||||
if actRan(reply) {
|
if strings.Contains(reply, "выполнена") {
|
||||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
|||||||
|
|
||||||
nexus.SetFault(503)
|
nexus.SetFault(503)
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if actRan(reply) {
|
if strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
nexus.SetFault(0)
|
nexus.SetFault(0)
|
||||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if !actRan(reply) {
|
if !strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
|||||||
|
|
||||||
// The follow-up "да" turn executes exactly the parked capability.
|
// The follow-up "да" turn executes exactly the parked capability.
|
||||||
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
||||||
if !handled || !actRan(confirmReply) {
|
if !handled || !strings.Contains(confirmReply, "выполнена") {
|
||||||
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
||||||
}
|
}
|
||||||
if !*executed {
|
if !*executed {
|
||||||
@@ -126,7 +125,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
|||||||
if h.pendingHexis != nil {
|
if h.pendingHexis != nil {
|
||||||
t.Fatal("read-only cap should not park a confirmation")
|
t.Fatal("read-only cap should not park a confirmation")
|
||||||
}
|
}
|
||||||
if !actRan(reply) {
|
if !strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("unexpected reply %q", reply)
|
t.Fatalf("unexpected reply %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +186,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
||||||
}
|
}
|
||||||
if actRan(reply) {
|
if strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +219,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
||||||
}
|
}
|
||||||
if actRan(reply) {
|
if strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,73 +241,3 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
|||||||
t.Fatal("not_found resolution must never execute a hexis capability")
|
t.Fatal("not_found resolution must never execute a hexis capability")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// actRan — the reply is the line she says when a capability ran against an
|
|
||||||
// entity. The tests used to look for the substring "выполнена", which was a
|
|
||||||
// literal out of the act file: the review reworded that line to "готово: {name}"
|
|
||||||
// and seventeen assertions went with it (Vikunja #521).
|
|
||||||
func actRan(reply string) bool {
|
|
||||||
return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
// muzickIndexer — the display name every ecosystem fixture resolves to.
|
|
||||||
const muzickIndexer = "Muzick indexer"
|
|
||||||
|
|
||||||
// read_only used to be the whole decision on this path, which meant a
|
|
||||||
// capability that destroys what it names got the same single spoken "да" as one
|
|
||||||
// that restarts a service. Hexis declares the tier and the voice path is not an
|
|
||||||
// authorised surface for the top one (Vikunja #523).
|
|
||||||
func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
|
||||||
caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]`
|
|
||||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
|
||||||
|
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
|
||||||
if *executed {
|
|
||||||
t.Fatal("an irreversible capability ran from the voice path")
|
|
||||||
}
|
|
||||||
if h.pendingHexis != nil {
|
|
||||||
t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority")
|
|
||||||
}
|
|
||||||
if !strings.Contains(reply, "не вернуть") {
|
|
||||||
t.Errorf("reply = %q; want it to name why she will not run it", reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The other half: Hexis calling a capability safe is enough to run it, even
|
|
||||||
// though read_only is the field that used to decide. Nothing here re-derives.
|
|
||||||
func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
|
||||||
caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]`
|
|
||||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
|
||||||
|
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
|
||||||
if !*executed {
|
|
||||||
t.Fatal("a capability Hexis calls safe should run")
|
|
||||||
}
|
|
||||||
if !actRan(reply) {
|
|
||||||
t.Fatalf("unexpected reply %q", reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A mutating capability with no declared tier keeps the confirm turn it has
|
|
||||||
// always had, so the split does not quietly loosen an existing box.
|
|
||||||
func TestHexisUndeclaredTierStillConfirms(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
|
||||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
|
||||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
|
||||||
|
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
|
||||||
if *executed {
|
|
||||||
t.Fatal("a mutating capability ran without a confirm")
|
|
||||||
}
|
|
||||||
if h.pendingHexis == nil {
|
|
||||||
t.Fatal("a mutating capability did not park a confirm")
|
|
||||||
}
|
|
||||||
if !strings.Contains(reply, "да или нет") {
|
|
||||||
t.Errorf("reply = %q; want the confirm question", reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-43
@@ -13,11 +13,6 @@ import (
|
|||||||
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
|
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
|
||||||
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
|
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
|
||||||
|
|
||||||
// maxEntityReferences caps how many names one utterance may send to Nexus. The
|
|
||||||
// cap is not about correctness, it is about one turn not fanning out into a
|
|
||||||
// dozen HTTP calls when the utterance is a paragraph of English.
|
|
||||||
const maxEntityReferences = 4
|
|
||||||
|
|
||||||
// hasLatin reports whether s carries a Latin letter.
|
// hasLatin reports whether s carries a Latin letter.
|
||||||
func hasLatin(s string) bool {
|
func hasLatin(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -28,54 +23,37 @@ func hasLatin(s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// entityReferences returns the names Nexus is asked to resolve, in the order
|
// entityReferenceText is the name Nexus is asked to resolve.
|
||||||
// they were said.
|
|
||||||
//
|
//
|
||||||
// Normally there is one, and it is the router's Text slot — the verb phrase the
|
// Normally that is the router's Text slot, which is the verb phrase the model
|
||||||
// model wrote. But the resident model rewrites a Russian utterance as it routes,
|
// wrote. But the resident model rewrites a Russian utterance as it routes, and
|
||||||
// and on the way it transliterates: "перезапусти muzick indexer" came back as
|
// on the way it transliterates: "перезапусти muzick indexer" came back as
|
||||||
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
|
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
|
||||||
// service nobody has ever named, so the act cannot resolve its target even with
|
// service nobody has ever named, so the act cannot resolve its target even
|
||||||
// every gate open.
|
// with every gate open.
|
||||||
//
|
//
|
||||||
// The recovery is deliberately narrow. Only when the utterance holds a Latin run
|
// The recovery is deliberately narrow. Only when the utterance holds a Latin
|
||||||
// and the model's Text holds none has a name certainly been rewritten. Anything
|
// run and the model's Text holds none has a name certainly been rewritten —
|
||||||
// else keeps the Text slot, so an English utterance and a Russian entity name
|
// then the longest Latin run in his own words is the reference. Anything else
|
||||||
// are both untouched. Un-transliterating the Cyrillic back is not attempted: the
|
// keeps the Text slot, so an English utterance and a Russian entity name are
|
||||||
|
// both untouched. Un-transliterating the Cyrillic back is not attempted: the
|
||||||
// surface form he said is right there, and guessing at a reverse mapping would
|
// surface form he said is right there, and guessing at a reverse mapping would
|
||||||
// invent a second name to be wrong about.
|
// invent a second name to be wrong about.
|
||||||
//
|
func entityReferenceText(dec router.Decision) string {
|
||||||
// What this does NOT do is pick. It used to return the longest run, and length
|
|
||||||
// is a guess: "перезапусти nginx на muzick-indexer" has two names in it and the
|
|
||||||
// longer one is not reliably the target. Nexus owns which names it knows
|
|
||||||
// (docs/ecosystem.md — ambiguous resolution asks the owner, it does not pick),
|
|
||||||
// so every run goes over and Nexus answers. Two runs that both resolve are a
|
|
||||||
// clarify, not a coin toss.
|
|
||||||
func entityReferences(dec router.Decision) []string {
|
|
||||||
text := dec.Slots.Text
|
text := dec.Slots.Text
|
||||||
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
||||||
return []string{text}
|
return text
|
||||||
}
|
}
|
||||||
var refs []string
|
longest := ""
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
||||||
m = strings.TrimSpace(m)
|
if len(m) > len(longest) {
|
||||||
// A single stray letter is not a name.
|
longest = m
|
||||||
if len(m) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(m)
|
|
||||||
if seen[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = true
|
|
||||||
refs = append(refs, m)
|
|
||||||
if len(refs) == maxEntityReferences {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(refs) == 0 {
|
longest = strings.TrimSpace(longest)
|
||||||
return []string{text}
|
// A single stray letter is not a name.
|
||||||
|
if len(longest) < 2 {
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
return refs
|
return longest
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-101
@@ -2,76 +2,61 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEntityReferences pins when his own words win over the model's, and that
|
// TestEntityReferenceText pins when his own words win over the model's.
|
||||||
// every name he said goes over rather than one of them being picked.
|
func TestEntityReferenceText(t *testing.T) {
|
||||||
func TestEntityReferences(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
utterance string
|
utterance string
|
||||||
text string
|
text string
|
||||||
want []string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "the model transliterated the name",
|
name: "the model transliterated the name",
|
||||||
utterance: "перезапусти muzick indexer",
|
utterance: "перезапусти muzick indexer",
|
||||||
text: "перезагрузить музик индексер",
|
text: "перезагрузить музик индексер",
|
||||||
want: []string{"muzick indexer"},
|
want: "muzick indexer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "it kept the name, so nothing to repair",
|
name: "it kept the name, so nothing to repair",
|
||||||
utterance: "перезапусти muzick indexer",
|
utterance: "перезапусти muzick indexer",
|
||||||
text: "перезагрузить muzick indexer",
|
text: "перезагрузить muzick indexer",
|
||||||
want: []string{"перезагрузить muzick indexer"},
|
want: "перезагрузить muzick indexer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "an all-Russian entity name is not a rewrite",
|
name: "an all-Russian entity name is not a rewrite",
|
||||||
utterance: "перезапусти домашний сервер",
|
utterance: "перезапусти домашний сервер",
|
||||||
text: "перезагрузить домашний сервер",
|
text: "перезагрузить домашний сервер",
|
||||||
want: []string{"перезагрузить домашний сервер"},
|
want: "перезагрузить домашний сервер",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "an English turn never enters the recovery",
|
name: "an English turn never enters the recovery",
|
||||||
utterance: "restart muzick indexer",
|
utterance: "restart muzick indexer",
|
||||||
text: "restart muzick indexer",
|
text: "restart muzick indexer",
|
||||||
want: []string{"restart muzick indexer"},
|
want: "restart muzick indexer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "both names go over, in the order he said them",
|
name: "the longest Latin run is the name",
|
||||||
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
||||||
text: "перезагрузить нгинкс",
|
text: "перезагрузить нгинкс",
|
||||||
want: []string{"nginx", "muzick-indexer"},
|
want: "muzick-indexer",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "one stray letter is not a name",
|
name: "one stray letter is not a name",
|
||||||
utterance: "перезапусти сервер a",
|
utterance: "перезапусти сервер a",
|
||||||
text: "перезагрузить сервер",
|
text: "перезагрузить сервер",
|
||||||
want: []string{"перезагрузить сервер"},
|
want: "перезагрузить сервер",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "the same name twice is one question",
|
|
||||||
utterance: "перезапусти nginx, ну правда, nginx",
|
|
||||||
text: "перезагрузить нгинкс",
|
|
||||||
want: []string{"nginx"},
|
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
||||||
got := entityReferences(dec)
|
if got := entityReferenceText(dec); got != tc.want {
|
||||||
if len(got) != len(tc.want) {
|
t.Fatalf("entityReferenceText = %q, want %q", got, tc.want)
|
||||||
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if got[i] != tc.want[i] {
|
|
||||||
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -146,77 +131,3 @@ func TestClarifyStillAsksWithoutHexis(t *testing.T) {
|
|||||||
t.Fatal("she must still ask what to do")
|
t.Fatal("she must still ask what to do")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nexusInOrder serves one resolve answer per call, in order, so a test can say
|
|
||||||
// what Nexus knows about the first name and what it knows about the second. The
|
|
||||||
// last body repeats once the list runs out.
|
|
||||||
func nexusInOrder(t *testing.T, bodies ...string) *fakeServer {
|
|
||||||
t.Helper()
|
|
||||||
var mu sync.Mutex
|
|
||||||
n := 0
|
|
||||||
return newFakeServer(t, map[string]http.HandlerFunc{
|
|
||||||
"POST /api/v1/resolve": func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
mu.Lock()
|
|
||||||
body := bodies[min(n, len(bodies)-1)]
|
|
||||||
n++
|
|
||||||
mu.Unlock()
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(body))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTwoResolvedNamesAsk — «перезапусти nginx на muzick-indexer» names a
|
|
||||||
// service and the host it runs on. Both are real, and which one he meant is not
|
|
||||||
// in the utterance, so she asks. Picking one by length was the old behaviour and
|
|
||||||
// length is not evidence (Vikunja #524).
|
|
||||||
func TestTwoResolvedNamesAsk(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
nexus := nexusInOrder(t,
|
|
||||||
fixtureNexusResolved("ent_nginx", "nginx", "service"),
|
|
||||||
fixtureNexusResolved("ent_host", "Muzick indexer", "device"),
|
|
||||||
)
|
|
||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
|
||||||
|
|
||||||
dec := router.Decision{
|
|
||||||
Utterance: "перезапусти nginx на muzick-indexer",
|
|
||||||
Intent: router.IntentAct,
|
|
||||||
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
|
||||||
}
|
|
||||||
reply := h.handleHexisAct(ctx, dec)
|
|
||||||
if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") {
|
|
||||||
t.Fatalf("reply = %q, want both names she found", reply)
|
|
||||||
}
|
|
||||||
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
|
||||||
t.Fatal("she must not execute against a target she is still asking about")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTheNameNexusKnowsWins — the other half. Two names go over and only one is
|
|
||||||
// an entity, so there is nothing to ask about and the act runs.
|
|
||||||
func TestTheNameNexusKnowsWins(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
nexus := nexusInOrder(t,
|
|
||||||
fixtureNexusNotFound(),
|
|
||||||
fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"),
|
|
||||||
)
|
|
||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
|
||||||
|
|
||||||
dec := router.Decision{
|
|
||||||
Utterance: "перезапусти nginx на muzick-indexer",
|
|
||||||
Intent: router.IntentAct,
|
|
||||||
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
|
||||||
}
|
|
||||||
reply := h.handleHexisAct(ctx, dec)
|
|
||||||
if reply == "" {
|
|
||||||
t.Fatal("the resolvable name must carry the act")
|
|
||||||
}
|
|
||||||
if len(nexus.Requests()) != 2 {
|
|
||||||
t.Fatalf("nexus asked %d times, want both names", len(nexus.Requests()))
|
|
||||||
}
|
|
||||||
if hexis.Count("POST", "/api/v1/execute") == 0 {
|
|
||||||
t.Fatal("hexis was never asked to run it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,11 +28,10 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
|||||||
|
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
|
|||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
return h, api
|
return h, api
|
||||||
@@ -44,7 +43,7 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
|||||||
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
||||||
t.Fatal("a question was stored as a fact about him")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("memory search: %v", err)
|
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.
|
// #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
|
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
||||||
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("memory search: %v", err)
|
t.Fatalf("memory search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,7 +116,7 @@ func TestFactConfidence(t *testing.T) {
|
|||||||
|
|
||||||
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
|
vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("embed %q: %v", text, err)
|
t.Fatalf("embed %q: %v", text, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &reactiveHandler{
|
return &reactiveHandler{
|
||||||
api: ipc.NewStoreAPI(st),
|
api: ipc.NewStoreAPI(st),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
phraser: phraser.NewStub(),
|
phraser: phraser.NewStub(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
feedsOn: feedsOn,
|
feedsOn: feedsOn,
|
||||||
embedder: nil,
|
recall: recallWiring{embedder: nil},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
|||||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||||
}
|
}
|
||||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||||
if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
|
if !strings.Contains(reply, "ничего") {
|
||||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,22 +87,16 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
|||||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||||
// answered by the model inventing a bulletin.
|
// answered by the model inventing a bulletin.
|
||||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||||
// Against the entries, not against a substring: both of these have several
|
|
||||||
// wordings, so "ничего нового" passed only on the turns the picker happened
|
|
||||||
// to choose the first one.
|
|
||||||
off := buildFeedHandler(t, false)
|
off := buildFeedHandler(t, false)
|
||||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||||
if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
if !ok || !strings.Contains(reply, "не настроены") {
|
||||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||||
}
|
}
|
||||||
on := buildFeedHandler(t, true)
|
on := buildFeedHandler(t, true)
|
||||||
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
||||||
if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
|
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||||
}
|
}
|
||||||
if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
|
||||||
t.Fatalf("an empty feed answered as an unconfigured one: %q", reply)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||||
|
|||||||
+20
-5
@@ -11,7 +11,6 @@ import (
|
|||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/netscan"
|
"github.com/kami/maven/internal/netscan"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||||
@@ -107,7 +106,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
|||||||
res, err := w.scan(ctx)
|
res, err := w.scan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("netscan: scan: %v", err)
|
log.Printf("netscan: scan: %v", err)
|
||||||
return phraser.Q(phraser.QueryFailNetscan, nil), true
|
return "не получилось просканировать сеть.", true
|
||||||
}
|
}
|
||||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||||
// устройств" after stopping two thirds of the way through the range is a
|
// устройств" after stopping two thirds of the way through the range is a
|
||||||
@@ -117,9 +116,9 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
|||||||
tail = ", но успела посмотреть не всю сеть"
|
tail = ", но успела посмотреть не всю сеть"
|
||||||
}
|
}
|
||||||
if len(res.Hosts) == 0 {
|
if len(res.Hosts) == 0 {
|
||||||
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
return "в сети никого не нашла" + tail + ".", true
|
||||||
}
|
}
|
||||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||||
if shape := scanShape(res.Hosts); shape != "" {
|
if shape := scanShape(res.Hosts); shape != "" {
|
||||||
out += ", " + shape
|
out += ", " + shape
|
||||||
}
|
}
|
||||||
@@ -180,7 +179,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
|||||||
if w.api == nil {
|
if w.api == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||||
if res.Truncated {
|
if res.Truncated {
|
||||||
head += " (не вся сеть)"
|
head += " (не вся сеть)"
|
||||||
}
|
}
|
||||||
@@ -211,6 +210,22 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||||
|
// 5+ устройств, and the teens are all the last form.
|
||||||
|
func hostWord(n int) string {
|
||||||
|
if n%100 >= 11 && n%100 <= 14 {
|
||||||
|
return "устройств"
|
||||||
|
}
|
||||||
|
switch n % 10 {
|
||||||
|
case 1:
|
||||||
|
return "устройство"
|
||||||
|
case 2, 3, 4:
|
||||||
|
return "устройства"
|
||||||
|
default:
|
||||||
|
return "устройств"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||||
|
|||||||
+12
-21
@@ -10,8 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||||
@@ -76,6 +74,18 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||||
|
for n, want := range map[int]string{
|
||||||
|
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||||
|
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||||
|
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||||
|
} {
|
||||||
|
if got := hostWord(n); got != want {
|
||||||
|
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsNetworkQuery(t *testing.T) {
|
func TestIsNetworkQuery(t *testing.T) {
|
||||||
yes := []string{
|
yes := []string{
|
||||||
"какие устройства в сети?",
|
"какие устройства в сети?",
|
||||||
@@ -159,22 +169,3 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
|||||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An unconfigured scanner names the gap instead of declining the turn.
|
|
||||||
//
|
|
||||||
// Falling through sent "какие устройства в сети?" to the search leg, which
|
|
||||||
// answered with a paragraph about routers in general — and put a question about
|
|
||||||
// his own LAN on an upstream engine, which the personal boundary exists to
|
|
||||||
// prevent (Vikunja #479).
|
|
||||||
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
|
|
||||||
h := &reactiveHandler{}
|
|
||||||
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
|
|
||||||
dec: router.Decision{Utterance: "какие устройства в сети?"},
|
|
||||||
})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("an unconfigured scanner let the question fall through to search")
|
|
||||||
}
|
|
||||||
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
|
|
||||||
t.Errorf("got %q, want the gap named", reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/phraser/eval"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The persona checks, run before she speaks (Vikunja #399).
|
|
||||||
//
|
|
||||||
// RunChecks and RunTalkChecks only ever ran from the eval package, so
|
|
||||||
// everything the fixtures measured was offline knowledge: we could say "about
|
|
||||||
// one reply in three is broken" and still ship every one of them. This runs the
|
|
||||||
// cheap half of that on the live path, and replaces a failing message with the
|
|
||||||
// deterministic floor.
|
|
||||||
//
|
|
||||||
// Which checks: the unambiguous string tests only — feminine self-reference,
|
|
||||||
// how she addresses him, and a leaked-reasoning test. Not length, which is
|
|
||||||
// path-specific, and not ontopic, which compares against fragments the fixture
|
|
||||||
// supplies and runtime does not have. Not hisgender either — see guardSpoken.
|
|
||||||
//
|
|
||||||
// No retry. A retry doubles the latency on the exact turn that is already going
|
|
||||||
// badly, and on the nudge path the moment has passed.
|
|
||||||
//
|
|
||||||
// The known cost, written down because it is real: a wrongly flagged good reply
|
|
||||||
// is replaced by a flatter stub one. That is the right trade — a stub sentence
|
|
||||||
// is dull, a leaked reasoning trace is broken — but it means these checks can
|
|
||||||
// no longer be tuned for sensitivity alone.
|
|
||||||
|
|
||||||
// checkLeak — the name reported when the model's scaffolding reaches the text.
|
|
||||||
const checkLeak = "leak"
|
|
||||||
|
|
||||||
// leakPatterns — reasoning and protocol that belongs to the model, not to him.
|
|
||||||
// The resident model is a Thinking variant, so an unclosed reasoning block is
|
|
||||||
// the failure mode, not a hypothetical (Vikunja #398).
|
|
||||||
var leakPatterns = []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`(?i)<\s*/?\s*think`),
|
|
||||||
regexp.MustCompile(`(?i)thinking\s*(process|:)`),
|
|
||||||
regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
|
|
||||||
// Raw contract JSON: the parser already unwraps a good one, so a body that
|
|
||||||
// still carries the keys is one it could not read.
|
|
||||||
regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
|
|
||||||
// The persona block quoted back at him.
|
|
||||||
regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkPersonaLeak reports whether the model's own scaffolding is in the text.
|
|
||||||
func checkPersonaLeak(body string) (string, bool) {
|
|
||||||
for _, re := range leakPatterns {
|
|
||||||
if m := re.FindString(body); m != "" {
|
|
||||||
return "leaked " + strings.TrimSpace(m), false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
|
|
||||||
// personaRejects counts what the guard caught, by check name, so the real
|
|
||||||
// production rate is knowable rather than inferred from the fixture.
|
|
||||||
var personaRejects = struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
by map[string]int
|
|
||||||
}{by: map[string]int{}}
|
|
||||||
|
|
||||||
func personaRejectCounts() map[string]int {
|
|
||||||
personaRejects.mu.Lock()
|
|
||||||
defer personaRejects.mu.Unlock()
|
|
||||||
out := make(map[string]int, len(personaRejects.by))
|
|
||||||
for k, v := range personaRejects.by {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// guardSpoken checks a phrased message. It returns the failed check and false
|
|
||||||
// when the message must not be said; path names the caller, for the log.
|
|
||||||
//
|
|
||||||
// An empty message passes: the caller already treats that as a failure and
|
|
||||||
// falls back on its own, and reporting it as a persona breach would put a
|
|
||||||
// misleading line in the count.
|
|
||||||
func guardSpoken(path, body string) (string, bool) {
|
|
||||||
if strings.TrimSpace(body) == "" {
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
if detail, ok := checkPersonaLeak(body); !ok {
|
|
||||||
return rejectSpoken(path, checkLeak, detail, body), false
|
|
||||||
}
|
|
||||||
// Feminine and address only. HisGender is not run here: it reads a
|
|
||||||
// sentence-initial feminine verb with no pronoun — "записала, что ты выпил
|
|
||||||
// воды" — as a woman being addressed, when it is her own correct
|
|
||||||
// self-reference. Offline that is a point of score; on this path it would
|
|
||||||
// replace a good reply with a stub one on every fact she confirms.
|
|
||||||
for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
|
|
||||||
if !r.Pass {
|
|
||||||
return rejectSpoken(path, r.Name, r.Detail, body), false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
|
|
||||||
// rejectSpoken logs what she nearly said and counts it. The whole text, not a
|
|
||||||
// prefix: the point of the log line is that the failure can be read back later
|
|
||||||
// and argued with.
|
|
||||||
func rejectSpoken(path, check, detail, body string) string {
|
|
||||||
personaRejects.mu.Lock()
|
|
||||||
personaRejects.by[check]++
|
|
||||||
personaRejects.mu.Unlock()
|
|
||||||
log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
|
|
||||||
return check
|
|
||||||
}
|
|
||||||
|
|
||||||
// guardNudge checks a phrased nudge and falls back to the deterministic floor
|
|
||||||
// when it fails. The nudge path, unlike the reply path, cannot ask again: the
|
|
||||||
// tick has already decided she speaks, so the choice is the floor's wording or
|
|
||||||
// a broken sentence.
|
|
||||||
func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
|
|
||||||
if _, ok := guardSpoken("nudge", pn.Body); ok {
|
|
||||||
return pn
|
|
||||||
}
|
|
||||||
stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
|
|
||||||
if err != nil {
|
|
||||||
// The Stub is templates over the candidate and does not fail. If it
|
|
||||||
// somehow does, the model's text is still what the rule decided to
|
|
||||||
// say, and saying nothing is the worse outcome.
|
|
||||||
return pn
|
|
||||||
}
|
|
||||||
return stub
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGuardPassesWhatSheShouldSay(t *testing.T) {
|
|
||||||
good := []string{
|
|
||||||
"записала: купить хлеб.",
|
|
||||||
"поняла, напомню в 11:00.",
|
|
||||||
"ты не пил воду с утра.",
|
|
||||||
"я рада, что получилось.",
|
|
||||||
"",
|
|
||||||
}
|
|
||||||
for _, body := range good {
|
|
||||||
if check, ok := guardSpoken("test", body); !ok {
|
|
||||||
t.Errorf("guardSpoken(%q) rejected on %s", body, check)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGuardStopsWhatSheShouldNot(t *testing.T) {
|
|
||||||
bad := []struct {
|
|
||||||
body string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"<think>он просил воду</think> попей воды.", checkLeak},
|
|
||||||
{"Thinking Process: он давно не пил.", checkLeak},
|
|
||||||
{`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
|
|
||||||
{"я напомнил тебе про воду.", "feminine"},
|
|
||||||
{"вы давно не пили воду.", "address"},
|
|
||||||
}
|
|
||||||
for _, c := range bad {
|
|
||||||
check, ok := guardSpoken("test", c.body)
|
|
||||||
if ok {
|
|
||||||
t.Errorf("guardSpoken(%q) let it through", c.body)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if check != c.want {
|
|
||||||
t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGuardCountsWhatItCaught(t *testing.T) {
|
|
||||||
before := personaRejectCounts()[checkLeak]
|
|
||||||
if _, ok := guardSpoken("test", "<think>…"); ok {
|
|
||||||
t.Fatal("a leaked reasoning block was let through")
|
|
||||||
}
|
|
||||||
if after := personaRejectCounts()[checkLeak]; after != before+1 {
|
|
||||||
t.Errorf("leak count %d; want %d", after, before+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
|
|
||||||
// deterministic wording, not dropped and not retried.
|
|
||||||
func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
|
|
||||||
cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
|
|
||||||
bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
|
|
||||||
got := guardNudge(bad, cand)
|
|
||||||
if got.Body == bad.Body {
|
|
||||||
t.Fatal("the broken nudge was delivered unchanged")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(got.Body) == "" {
|
|
||||||
t.Fatal("the nudge was dropped rather than re-worded")
|
|
||||||
}
|
|
||||||
good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
|
|
||||||
if guardNudge(good, cand).Body != good.Body {
|
|
||||||
t.Error("a good nudge was replaced")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -70,7 +70,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
|||||||
{"how do i boil an egg", false},
|
{"how do i boil an egg", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &reactiveHandler{embedder: emb}
|
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
wrong := 0
|
wrong := 0
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
@@ -80,7 +80,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
|
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
|
||||||
got := h.isPersonalTurn(ctx, turn)
|
got := h.isPersonalTurn(ctx, turn)
|
||||||
p, w, ok := h.boundary.score(vec)
|
p, w, ok := h.recall.boundary.score(vec)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("seeds did not load with a working embedder")
|
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()}
|
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: ipc.NewStoreAPI(st),
|
api: ipc.NewStoreAPI(st),
|
||||||
embedder: emb,
|
recall: recallWiring{
|
||||||
|
embedder: emb,
|
||||||
|
memStore: mem,
|
||||||
|
minScore: 0.55,
|
||||||
|
minMargin: 0.008,
|
||||||
|
},
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
phraser: phr,
|
phraser: phr,
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: mem,
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
queryMinScore: 0.55,
|
|
||||||
queryMinMargin: 0.008,
|
|
||||||
weatherProvider: nil,
|
weatherProvider: nil,
|
||||||
}
|
}
|
||||||
return h, phr
|
return h, phr
|
||||||
@@ -121,8 +123,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
|||||||
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
||||||
})
|
})
|
||||||
reply := askQuery(t, h, q)
|
reply := askQuery(t, h, q)
|
||||||
if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
|
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
|
||||||
t.Errorf("reply %q, want the note read back", reply)
|
t.Errorf("reply %q, want %q", reply, want)
|
||||||
}
|
}
|
||||||
// One text, the winning memory's — the answer came from the memory
|
// One text, the winning memory's — the answer came from the memory
|
||||||
// pass, not from handing the phraser every note in the table.
|
// pass, not from handing the phraser every note in the table.
|
||||||
@@ -151,7 +153,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
|||||||
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
||||||
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
||||||
})
|
})
|
||||||
if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) {
|
if reply := askQuery(t, h, q); reply != "не знаю." {
|
||||||
t.Errorf("reply %q, want silence", reply)
|
t.Errorf("reply %q, want silence", reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||||
@@ -33,10 +32,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
val := "false"
|
val := "false"
|
||||||
reply := phraser.Ack(phraser.AckQuietOff, nil)
|
reply := "тихий режим выключен."
|
||||||
if on {
|
if on {
|
||||||
val = "true"
|
val = "true"
|
||||||
reply = phraser.Ack(phraser.AckQuietOn, nil)
|
reply = "тихий режим включён. буду реже напоминать."
|
||||||
}
|
}
|
||||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
Ts: h.now(),
|
Ts: h.now(),
|
||||||
@@ -47,7 +46,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
|||||||
Confidence: 1.0,
|
Confidence: 1.0,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: write quiet_hours: %v", err)
|
log.Printf("voice: write quiet_hours: %v", err)
|
||||||
return phraser.Ack(phraser.FailQuiet, nil), true
|
return "не получилось переключить тихий режим.", true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
|
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,11 +100,10 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
|||||||
matcher := tool.NewMatcher(api)
|
matcher := tool.NewMatcher(api)
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: buildRouter(emb, matcher, 0.55, nil),
|
router: buildRouter(emb, matcher, 0.55, nil),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-1
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/kami/maven/internal/memory"
|
import (
|
||||||
|
"github.com/kami/maven/internal/memory"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
// bestRecall is the read side of the long-term memory store: the top hit when
|
// bestRecall is the read side of the long-term memory store: the top hit when
|
||||||
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
||||||
@@ -23,3 +26,34 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re
|
|||||||
}
|
}
|
||||||
return results[0], true
|
return results[0], true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recallWiring — the recall subsystem's dependencies, held as one group on
|
||||||
|
// reactiveHandler (Vikunja #433). It is the worked example for the wiring
|
||||||
|
// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty
|
||||||
|
// loose ones, so a handler names what it needs and the package can be split
|
||||||
|
// later without exporting the whole struct.
|
||||||
|
//
|
||||||
|
// The zero value is usable and means "no recall": no embedder, no vector
|
||||||
|
// store, and a gate that is never consulted because nothing is ever searched.
|
||||||
|
type recallWiring struct {
|
||||||
|
// embedder — reused for note write/query (same model as the classifier).
|
||||||
|
embedder router.Embedder
|
||||||
|
|
||||||
|
// memStore — the vector index over notes and facts.
|
||||||
|
memStore memory.Store
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,10 +30,5 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
|||||||
if err != nil || out == "" {
|
if err != nil || out == "" {
|
||||||
return r.stub.Reply(d)
|
return r.stub.Reply(d)
|
||||||
}
|
}
|
||||||
// The persona checks, on the live path (personaguard.go). A reply that
|
|
||||||
// leaks reasoning or calls him "вы" is worse than a flat one.
|
|
||||||
if _, ok := guardSpoken("reply", out); !ok {
|
|
||||||
return r.stub.Reply(d)
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/llm"
|
"github.com/kami/maven/internal/llm"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
@@ -30,12 +29,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
|||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
|
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||||
@@ -43,16 +42,6 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
|||||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertAck — the stub picks between variants now, so two calls to it are not
|
|
||||||
// expected to match. What must hold is that the reply is a line that entry can
|
|
||||||
// produce, which is the same claim without pinning one wording.
|
|
||||||
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
|
||||||
t.Helper()
|
|
||||||
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
|
|
||||||
t.Errorf("on %s: got %q, want a %q line", what, got, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
||||||
|
|||||||
+20
-12
@@ -4,10 +4,6 @@
|
|||||||
// plural agreement, clock/date rendering, and the "do I actually know this
|
// plural agreement, clock/date rendering, and the "do I actually know this
|
||||||
// place/day" guards that pick an honest reply over a confidently wrong one.
|
// place/day" guards that pick an honest reply over a confidently wrong one.
|
||||||
// Extend this file rather than voice.go for anything in that shape.
|
// Extend this file rather than voice.go for anything in that shape.
|
||||||
//
|
|
||||||
// Count agreement is not here. It is say.CountWord, because there were four
|
|
||||||
// copies of the same three-way rule and two of the sites that needed it were
|
|
||||||
// spelling one form out (Vikunja #521).
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,8 +11,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var ruWeekdays = []string{
|
var ruWeekdays = []string{
|
||||||
@@ -100,11 +94,11 @@ func mentionsUnknownDay(u string) bool {
|
|||||||
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
||||||
func ruClock(t time.Time) string {
|
func ruClock(t time.Time) string {
|
||||||
h, m := t.Hour(), t.Minute()
|
h, m := t.Hour(), t.Minute()
|
||||||
hourWord := say.CountWord(h, "час", "часа", "часов")
|
hourWord := ruPlural(h, "час", "часа", "часов")
|
||||||
if m == 0 {
|
if m == 0 {
|
||||||
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(m, "минута", "минуты", "минут"))
|
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
||||||
@@ -124,6 +118,22 @@ func dayPrefix(now, day time.Time) string {
|
|||||||
return "это"
|
return "это"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ruPlural(n int, one, two, many string) string {
|
||||||
|
n = n % 100
|
||||||
|
if n > 10 && n < 20 {
|
||||||
|
return many
|
||||||
|
}
|
||||||
|
n = n % 10
|
||||||
|
switch n {
|
||||||
|
case 1:
|
||||||
|
return one
|
||||||
|
case 2, 3, 4:
|
||||||
|
return two
|
||||||
|
default:
|
||||||
|
return many
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
||||||
// rather than the current clock — guards replySystem from replying "сейчас
|
// rather than the current clock — guards replySystem from replying "сейчас
|
||||||
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
||||||
@@ -159,13 +169,11 @@ func formatTime(t time.Time) string {
|
|||||||
case diff < 10*time.Minute:
|
case diff < 10*time.Minute:
|
||||||
return "несколько минут назад"
|
return "несколько минут назад"
|
||||||
case diff < 60*time.Minute:
|
case diff < 60*time.Minute:
|
||||||
n := int(diff.Minutes())
|
return fmt.Sprintf("%d минут назад", int(diff.Minutes()))
|
||||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут"))
|
|
||||||
case diff < 2*time.Hour:
|
case diff < 2*time.Hour:
|
||||||
return "час назад"
|
return "час назад"
|
||||||
case diff < 24*time.Hour:
|
case diff < 24*time.Hour:
|
||||||
n := int(diff.Hours())
|
return fmt.Sprintf("%d часа назад", int(diff.Hours()))
|
||||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
|
||||||
default:
|
default:
|
||||||
return t.Format("2 января 15:04")
|
return t.Format("2 января 15:04")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,20 +428,22 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
|||||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||||
|
|
||||||
w.handler = &reactiveHandler{
|
w.handler = &reactiveHandler{
|
||||||
stt: simTranscriber{},
|
stt: simTranscriber{},
|
||||||
tts: simSynthesizer{},
|
tts: simSynthesizer{},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
recall: recallWiring{
|
||||||
|
embedder: emb,
|
||||||
|
memStore: st.VectorMemory(),
|
||||||
|
minScore: config.DefaultQueryMinScore,
|
||||||
|
minMargin: config.DefaultQueryMinMargin,
|
||||||
|
},
|
||||||
api: api,
|
api: api,
|
||||||
matcher: matcher,
|
matcher: matcher,
|
||||||
tools: tool.NewExecutor(api, 5*time.Second),
|
tools: tool.NewExecutor(api, 5*time.Second),
|
||||||
phraser: phraser.NewStub(),
|
phraser: phraser.NewStub(),
|
||||||
replier: newLLMReplier(scripted, nil),
|
replier: newLLMReplier(scripted, nil),
|
||||||
now: clock.Now,
|
now: clock.Now,
|
||||||
memStore: st.VectorMemory(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
queryMinScore: config.DefaultQueryMinScore,
|
|
||||||
queryMinMargin: config.DefaultQueryMinMargin,
|
|
||||||
timeParser: router.StubDateTimeParser{},
|
timeParser: router.StubDateTimeParser{},
|
||||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||||
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||||
|
|||||||
+5
-10
@@ -4,12 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/smarthome"
|
"github.com/kami/maven/internal/smarthome"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -142,10 +140,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
ents, err := w.client.States(ctx)
|
ents, err := w.client.States(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("smarthome: summary: %v", err)
|
log.Printf("smarthome: summary: %v", err)
|
||||||
return phraser.A(phraser.HomeUnreachable, nil), true
|
return "не смогла достучаться до дома.", true
|
||||||
}
|
}
|
||||||
if len(ents) == 0 {
|
if len(ents) == 0 {
|
||||||
return phraser.A(phraser.HomeEmpty, nil), true
|
return "дом ничего не отдаёт.", true
|
||||||
}
|
}
|
||||||
var on []string
|
var on []string
|
||||||
var sensors []string
|
var sensors []string
|
||||||
@@ -179,7 +177,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
}
|
}
|
||||||
// Silent truncation on a status read is the same failure as the cap
|
// Silent truncation on a status read is the same failure as the cap
|
||||||
// one layer up: she has to say the list is not the whole list.
|
// one layer up: she has to say the list is not the whole list.
|
||||||
line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")})
|
line := "включено: " + strings.Join(shown, ", ")
|
||||||
if rest > 0 {
|
if rest > 0 {
|
||||||
line += fmt.Sprintf(" и ещё %d", rest)
|
line += fmt.Sprintf(" и ещё %d", rest)
|
||||||
}
|
}
|
||||||
@@ -187,10 +185,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
case dark > 0 && len(sensors) == 0:
|
case dark > 0 && len(sensors) == 0:
|
||||||
// Nothing is on and everything she can see is unreachable. "всё
|
// Nothing is on and everything she can see is unreachable. "всё
|
||||||
// выключено" would be a claim about the house she cannot make.
|
// выключено" would be a claim about the house she cannot make.
|
||||||
return phraser.A(phraser.HomeDark, map[string]string{
|
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
||||||
"count": strconv.Itoa(dark),
|
|
||||||
"word": phraser.Devices(dark),
|
|
||||||
}), true
|
|
||||||
default:
|
default:
|
||||||
parts = append(parts, "всё выключено")
|
parts = append(parts, "всё выключено")
|
||||||
}
|
}
|
||||||
@@ -198,7 +193,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
parts = append(parts, strings.Join(sensors, ", "))
|
parts = append(parts, strings.Join(sensors, ", "))
|
||||||
}
|
}
|
||||||
if dark > 0 {
|
if dark > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark)))
|
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
|
||||||
}
|
}
|
||||||
return strings.Join(parts, "; ") + ".", true
|
return strings.Join(parts, "; ") + ".", true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,10 +51,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
|
|||||||
}
|
}
|
||||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
||||||
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||||
return phraser.Ack(phraser.FailSnooze, nil), true
|
return "не получилось отложить.", true
|
||||||
}
|
}
|
||||||
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||||
return phraser.Ack(phraser.AckSnooze, nil), true
|
return "хорошо, вернусь к этому позже.", true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
||||||
|
|||||||
@@ -10,25 +10,19 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/calendar"
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
"github.com/kami/maven/internal/ipc"
|
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
"github.com/kami/maven/internal/pattern"
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,9 +171,6 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
t.queueNudge(ctx, cand, state, now)
|
t.queueNudge(ctx, cand, state, now)
|
||||||
} else {
|
} else {
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||||
if err == nil {
|
|
||||||
pn = guardNudge(pn, *cand)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -319,617 +310,6 @@ func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
|
|||||||
return pn.Body, pn.Summary
|
return pn.Body, pn.Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldQueue — true when digest is enabled and the candidate's severity is
|
|
||||||
// at or below the configured ceiling.
|
|
||||||
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
|
|
||||||
return t.digestCfg != nil && t.digestCfg.Enabled &&
|
|
||||||
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
|
|
||||||
}
|
|
||||||
|
|
||||||
// queueNudge — phrases the candidate and appends it to the digest queue.
|
|
||||||
// Deduplicates by rule name: if the same rule is already queued, this is a
|
|
||||||
// no-op (the first fire within the window is the one that counts).
|
|
||||||
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
|
|
||||||
for _, q := range t.digestQ {
|
|
||||||
if q.Rule == cand.Rule.Name {
|
|
||||||
return // already queued
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.digestQ = append(t.digestQ, QueuedNudge{
|
|
||||||
Rule: cand.Rule.Name,
|
|
||||||
Severity: int(cand.Severity),
|
|
||||||
Body: pn.Body,
|
|
||||||
Key: cand.Rule.Name,
|
|
||||||
QueuedAt: now,
|
|
||||||
})
|
|
||||||
t.cachePhrase(pn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeFlush — flushes the digest queue if the window has elapsed since the
|
|
||||||
// first item or the queue reached MaxItems.
|
|
||||||
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
first := t.digestQ[0]
|
|
||||||
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
|
|
||||||
len(t.digestQ) >= t.digestCfg.MaxItems {
|
|
||||||
t.flushDigest(ctx, now, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flushDigest — concatenates queued nudge bodies into a single digest
|
|
||||||
// notification and dispatches it. Clears the queue after a successful send.
|
|
||||||
// The digest uses the max severity among queued items for routing.
|
|
||||||
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
if len(t.digestQ) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
maxSev := 0
|
|
||||||
for i, q := range t.digestQ {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(" · ")
|
|
||||||
}
|
|
||||||
b.WriteString(q.Body)
|
|
||||||
if q.Severity > maxSev {
|
|
||||||
maxSev = q.Severity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body := b.String()
|
|
||||||
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
|
|
||||||
|
|
||||||
cand := loop.Candidate{
|
|
||||||
Rule: loop.Rule{
|
|
||||||
Name: "digest",
|
|
||||||
Severity: loop.Severity(maxSev),
|
|
||||||
},
|
|
||||||
Severity: loop.Severity(maxSev),
|
|
||||||
State: state,
|
|
||||||
}
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: cand,
|
|
||||||
Body: body,
|
|
||||||
Summary: summary,
|
|
||||||
}
|
|
||||||
t.cachePhrase(pn)
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
// keep the queue — the next tick's maybeFlush re-attempts.
|
|
||||||
log.Printf("tick: dispatch digest: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.digestQ = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectPatterns runs the pattern detector proactively over every
|
|
||||||
// action+object pair that has ever produced an event, independent of
|
|
||||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
|
||||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
|
||||||
// own schedule reading accumulated history, not only as a side effect of a
|
|
||||||
// live voice turn.
|
|
||||||
//
|
|
||||||
// Idempotence and noise are handled by the store, not here — this function
|
|
||||||
// is safe to call every tick:
|
|
||||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
|
||||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
|
||||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
|
||||||
// already has a row — in ANY status — produces no second row and no log
|
|
||||||
// spam beyond the one line at genuine creation.
|
|
||||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
|
||||||
// status in place; the row is never deleted. So the same Lookup check
|
|
||||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
|
||||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
|
||||||
// calling the same shared path the voice route already used.
|
|
||||||
//
|
|
||||||
// By default this only creates a row for the /routines page to show: it does
|
|
||||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
|
||||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
|
||||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
|
||||||
// for the restraints that apply even then. A proposal only starts producing
|
|
||||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
|
||||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: distinct event pairs: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
announced := false
|
|
||||||
for _, p := range pairs {
|
|
||||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == nil {
|
|
||||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
|
||||||
}
|
|
||||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
|
||||||
// One announcement per tick at most, whatever the scan turned up. The
|
|
||||||
// rest are on /routines; they are not lost, they are just not shouted.
|
|
||||||
// Nor are they queued: the row now exists, so no later tick re-detects
|
|
||||||
// them and they are never announced. See announceProposal.
|
|
||||||
if announced {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
announced = t.announceProposal(ctx, r, now, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// announceProposal offers a freshly inferred routine through the ordinary
|
|
||||||
// care-delivery path, if announcing is switched on at all. Returns true when
|
|
||||||
// something was actually sent.
|
|
||||||
//
|
|
||||||
// Everything here is restraint. The feature is off unless configured; when on
|
|
||||||
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
|
||||||
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
|
||||||
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
|
||||||
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
|
||||||
// real send, but the proposal row already exists, so the next tick will not
|
|
||||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
|
||||||
// he reads it on /routines instead, which is the whole point of the page.
|
|
||||||
//
|
|
||||||
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
|
||||||
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
|
||||||
// that first proposes it. Combined with one announcement per tick, the first
|
|
||||||
// tick over a populated history announces one pattern and permanently silences
|
|
||||||
// every other pattern found in the same pass. That is the intent, not an
|
|
||||||
// oversight — an inferred routine is not worth a second attempt at his
|
|
||||||
// attention, and /routines lists all of them. So the cooldown does not drain a
|
|
||||||
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
|
||||||
// later ticks. If it should ever become "one per day until each is mentioned",
|
|
||||||
// that needs a queue rather than this counter.
|
|
||||||
//
|
|
||||||
// Cooldown gets its default here as well as in applyDefaults. That is
|
|
||||||
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
|
||||||
// and an unspaced announcer is not what those tests mean to exercise.
|
|
||||||
//
|
|
||||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
|
||||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
|
||||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
|
||||||
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
|
||||||
if !t.proposalCfg.AnnounceProposals() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
|
||||||
if cooldown <= 0 {
|
|
||||||
cooldown = config.DefaultProposalCooldown
|
|
||||||
}
|
|
||||||
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
|
||||||
if !loop.Gate(state, rule) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
body := pattern.PhraseRoutine(r)
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
|
||||||
Body: body,
|
|
||||||
Summary: body,
|
|
||||||
}
|
|
||||||
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(sent) == 0 {
|
|
||||||
return false // routing dropped it — /routines still has it.
|
|
||||||
}
|
|
||||||
t.lastProposalAt = now
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
|
||||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
|
||||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
|
||||||
// digest entry that outlives one full day is describing a day that's already
|
|
||||||
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
|
||||||
// news. Bounding at one day also means a digest can never silently span a
|
|
||||||
// weekend of quiet hours into an unbounded backlog.
|
|
||||||
const digestExpiry = 24 * time.Hour
|
|
||||||
|
|
||||||
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
|
||||||
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
|
||||||
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
|
||||||
// Anything beyond the cap is still marked drained (it did get its moment;
|
|
||||||
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
|
||||||
// count instead of being spoken in full.
|
|
||||||
const maxDigestSpokenItems = 3
|
|
||||||
|
|
||||||
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
|
||||||
// gate blocked for a genuine restraint reason and durably records the
|
|
||||||
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
|
||||||
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
|
||||||
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
|
||||||
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
|
||||||
// makes repeat calls here harmless, but skipping the phrase call entirely
|
|
||||||
// when a pending entry already exists avoids the LLM round-trip too).
|
|
||||||
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
|
||||||
if trace == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, tr := range trace.RuleTraces {
|
|
||||||
if !tr.PredicateResult || tr.GateResult {
|
|
||||||
continue // didn't want to fire, or wasn't suppressed
|
|
||||||
}
|
|
||||||
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
|
||||||
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
expires := now.Add(digestExpiry)
|
|
||||||
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
|
||||||
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
|
||||||
} else if deduped {
|
|
||||||
// same suppressed nudge already pending — nothing new to say.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
|
||||||
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
|
||||||
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
|
||||||
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: expire stale digest entries: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
|
||||||
// suppression reasons have actually cleared — quiet hours over, back from
|
|
||||||
// away, out of the meeting. Draining while still suppressed would just be a
|
|
||||||
// second way to nag through quiet hours; the bundle waits for the same "is
|
|
||||||
// it allowed right now" condition a live nudge already waits for.
|
|
||||||
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
|
||||||
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries, err := t.store.PendingDigestEntries(ctx, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: pending digest entries: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
spoken := entries
|
|
||||||
extra := 0
|
|
||||||
if len(spoken) > maxDigestSpokenItems {
|
|
||||||
spoken = entries[:maxDigestSpokenItems]
|
|
||||||
extra = len(entries) - maxDigestSpokenItems
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
maxSev := 0
|
|
||||||
for i, e := range spoken {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(" · ")
|
|
||||||
}
|
|
||||||
b.WriteString(e.Body)
|
|
||||||
if e.Severity > maxSev {
|
|
||||||
maxSev = e.Severity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if extra > 0 {
|
|
||||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
|
||||||
}
|
|
||||||
body := b.String()
|
|
||||||
// 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
|
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||||
// rule:
|
// rule:
|
||||||
@@ -1002,101 +382,3 @@ func (t *tickLoop) trace() *loop.TickTrace {
|
|||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
return t.lastTrace
|
return t.lastTrace
|
||||||
}
|
}
|
||||||
|
|
||||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
|
||||||
// daemon's in-memory tick trace cache.
|
|
||||||
type daemonAPI struct {
|
|
||||||
ipc.CoreAPI
|
|
||||||
getTrace func() *loop.TickTrace
|
|
||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
|
||||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
|
||||||
chatFn func(ctx context.Context, 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,111 @@
|
|||||||
|
// mavend/tick_api.go — the daemonAPI read surface over the tick loop.
|
||||||
|
//
|
||||||
|
// Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon
|
||||||
|
// for, and the loop-to-ipc conversions those answers need.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||||
|
// daemon's in-memory tick trace cache.
|
||||||
|
type daemonAPI struct {
|
||||||
|
ipc.CoreAPI
|
||||||
|
getTrace func() *loop.TickTrace
|
||||||
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
|
chatFn func(ctx context.Context, conversation, text string) string
|
||||||
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||||
|
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||||
|
// look the same to a reader on purpose, because neither is a fault and the
|
||||||
|
// page renders both as an empty table.
|
||||||
|
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||||
|
if d.getEvents == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getEvents(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
|
if d.chatFn == nil {
|
||||||
|
return "", errors.New("mavend: chat not available")
|
||||||
|
}
|
||||||
|
return d.chatFn(ctx, conversation, text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||||
|
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||||
|
// default state and the web surface renders it as such.
|
||||||
|
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||||
|
if d.getMCPServers == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getMCPServers(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||||
|
trace := d.getTrace()
|
||||||
|
if trace == nil {
|
||||||
|
return ipc.TickTrace{}, nil
|
||||||
|
}
|
||||||
|
return toIPCTickTrace(*trace), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||||
|
if d.getMorningStatus == nil {
|
||||||
|
return nil, errors.New("mavend: morning status not available")
|
||||||
|
}
|
||||||
|
return d.getMorningStatus(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||||
|
if d.getDayPlan == nil {
|
||||||
|
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||||
|
}
|
||||||
|
return d.getDayPlan(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||||
|
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||||
|
for i, r := range t.RuleTraces {
|
||||||
|
rules[i] = toIPCRuleTrace(r)
|
||||||
|
}
|
||||||
|
return ipc.TickTrace{
|
||||||
|
Now: t.Now,
|
||||||
|
Winner: t.Winner,
|
||||||
|
Rules: rules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
|
||||||
|
return ipc.RuleTrace{
|
||||||
|
RuleName: r.RuleName,
|
||||||
|
Severity: int(r.Severity),
|
||||||
|
PredicateResult: r.PredicateResult,
|
||||||
|
GateResult: r.GateResult,
|
||||||
|
GateBlockedBy: r.GateBlockedBy,
|
||||||
|
GateDetail: toIPCGateDetail(r.GateDetail),
|
||||||
|
WasSelected: r.WasSelected,
|
||||||
|
LostTo: r.LostTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
|
||||||
|
return ipc.GateDetail{
|
||||||
|
SnoozeUntil: d.SnoozeUntil,
|
||||||
|
CooldownUntil: d.CooldownUntil,
|
||||||
|
QuietHours: d.QuietHours,
|
||||||
|
CalendarBusy: d.CalendarBusy,
|
||||||
|
Presence: d.Presence,
|
||||||
|
InertKeysMissing: d.InertKeysMissing,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// 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/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()
|
||||||
|
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
|
||||||
|
|
||||||
|
cand := loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||||
|
Severity: loop.Severity(maxSev),
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
||||||
|
t.cachePhrase(pn)
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch digest bundle: %v", err)
|
||||||
|
return // leave entries pending; retried next tick
|
||||||
|
}
|
||||||
|
ids := make([]int64, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
ids[i] = e.ID
|
||||||
|
}
|
||||||
|
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
||||||
|
log.Printf("tick: drain digest entries: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// mavend/tick_morning.go — the morning checklist and the day plan.
|
||||||
|
//
|
||||||
|
// Split out of tick.go, move-only (Vikunja #422). One window per configured
|
||||||
|
// routine, nudging once at the end for what is still open, plus the read
|
||||||
|
// surfaces /morning renders.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fireMorningRoutines checks each configured checklist against today's facts
|
||||||
|
// and dispatches a nag listing exactly what's still missing, at most once per
|
||||||
|
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
||||||
|
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
||||||
|
// concern — pulling it into the shared gather path would leak that mapping
|
||||||
|
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
||||||
|
// operator text (item labels joined), not LLM-phrased, same rationale as
|
||||||
|
// cron routines: deterministic, can't hallucinate a checklist item.
|
||||||
|
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
if len(t.morningRoutines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
|
||||||
|
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||||
|
body := morningNudgeBody(cand)
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||||
|
Severity: loop.Severity(cand.Routine.Severity),
|
||||||
|
State: state,
|
||||||
|
},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// morningNudgeBody words the one message a routine gets per day. Required
|
||||||
|
// items are what she says was not done; optional ones follow, worded as
|
||||||
|
// something he could still do rather than something he owes (Vikunja #473).
|
||||||
|
// Operator text, not phrased by the model, for the same reason it always was:
|
||||||
|
// a checklist item must not be invented.
|
||||||
|
func morningNudgeBody(cand morning.Candidate) string {
|
||||||
|
labels := func(items []morning.Item) string {
|
||||||
|
out := make([]string, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
out[i] = it.Label
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
|
||||||
|
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
|
||||||
|
body += fmt.Sprintf(". если будет время — %s", labels(opt))
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
||||||
|
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||||
|
// decision) and morningStatus (read-only query) so the two paths can never
|
||||||
|
// disagree about what evidence exists.
|
||||||
|
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
||||||
|
keys := make(map[string]struct{})
|
||||||
|
for _, r := range t.morningRoutines {
|
||||||
|
for _, it := range r.Items {
|
||||||
|
keys[it.FactKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
facts := make(map[string]store.Fact, len(keys))
|
||||||
|
for k := range keys {
|
||||||
|
f, err := t.store.LatestFact(ctx, k)
|
||||||
|
if err == nil {
|
||||||
|
facts[k] = f
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != store.ErrNoFact {
|
||||||
|
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return facts
|
||||||
|
}
|
||||||
|
|
||||||
|
// morningStatus is the read-only "what's missing" query the web UI (and
|
||||||
|
// eventually a voice query) calls. Pure recompute over the current facts —
|
||||||
|
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
||||||
|
// "state right now," not "should we nag."
|
||||||
|
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
||||||
|
if len(t.morningRoutines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
||||||
|
for _, r := range t.morningRoutines {
|
||||||
|
st := morning.Evaluate(r, facts, now)
|
||||||
|
done := make(map[string]bool, len(st.Completed))
|
||||||
|
for _, it := range st.Completed {
|
||||||
|
done[it.Key] = true
|
||||||
|
}
|
||||||
|
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
||||||
|
for i, it := range r.Items {
|
||||||
|
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
||||||
|
}
|
||||||
|
out = append(out, ipc.MorningRoutineStatus{
|
||||||
|
Name: r.Name,
|
||||||
|
Active: st.Active,
|
||||||
|
WindowStart: r.WindowStart,
|
||||||
|
WindowEnd: r.WindowEnd,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||||
|
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||||
|
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||||
|
//
|
||||||
|
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||||
|
// unprompted delivery in maven stays with the morning nudge and the
|
||||||
|
// dispatcher's policy.
|
||||||
|
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
var events []morning.PlanEntry
|
||||||
|
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: calendar events: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range facts {
|
||||||
|
events = append(events, morning.PlanEntry{
|
||||||
|
At: f.Ts,
|
||||||
|
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||||
|
// fact value carries would say it twice.
|
||||||
|
Text: calendar.FactSummary(f.Value),
|
||||||
|
Kind: morning.PlanEvent,
|
||||||
|
// Provenance below a calendar read (an ambient relay, #126) is
|
||||||
|
// hedged rather than recited as fact.
|
||||||
|
Uncertain: f.Confidence < 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminders []morning.PlanEntry
|
||||||
|
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range rems {
|
||||||
|
if r.Status != store.ReminderPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fire := r.NextFireTs
|
||||||
|
if fire.IsZero() {
|
||||||
|
fire = r.FireTs
|
||||||
|
}
|
||||||
|
reminders = append(reminders, morning.PlanEntry{
|
||||||
|
At: fire,
|
||||||
|
Text: r.Text(),
|
||||||
|
Kind: morning.PlanReminder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var checklistFacts map[string]store.Fact
|
||||||
|
if len(t.morningRoutines) > 0 {
|
||||||
|
checklistFacts = t.gatherMorningFacts(ctx)
|
||||||
|
}
|
||||||
|
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||||
|
|
||||||
|
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||||
|
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||||
|
for i, it := range plan.Items {
|
||||||
|
out.Items[i] = ipc.DayPlanItem{
|
||||||
|
At: it.At,
|
||||||
|
Text: it.Text,
|
||||||
|
Kind: string(it.Kind),
|
||||||
|
Uncertain: it.Uncertain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// mavend/tick_routines.go — pattern detection and the routines that fire.
|
||||||
|
//
|
||||||
|
// Split out of tick.go, move-only (Vikunja #422). Configured routines, the
|
||||||
|
// routines he accepted on /routines, and the tick-side detector that proposes
|
||||||
|
// new ones.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
|
"github.com/kami/maven/internal/routine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectPatterns runs the pattern detector proactively over every
|
||||||
|
// action+object pair that has ever produced an event, independent of
|
||||||
|
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||||
|
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||||
|
// own schedule reading accumulated history, not only as a side effect of a
|
||||||
|
// live voice turn.
|
||||||
|
//
|
||||||
|
// Idempotence and noise are handled by the store, not here — this function
|
||||||
|
// is safe to call every tick:
|
||||||
|
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||||
|
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||||
|
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||||
|
// already has a row — in ANY status — produces no second row and no log
|
||||||
|
// spam beyond the one line at genuine creation.
|
||||||
|
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||||
|
// status in place; the row is never deleted. So the same Lookup check
|
||||||
|
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||||
|
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||||
|
// calling the same shared path the voice route already used.
|
||||||
|
//
|
||||||
|
// By default this only creates a row for the /routines page to show: it does
|
||||||
|
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||||
|
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||||
|
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||||
|
// for the restraints that apply even then. A proposal only starts producing
|
||||||
|
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||||
|
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: distinct event pairs: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announced := false
|
||||||
|
for _, p := range pairs {
|
||||||
|
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||||
|
}
|
||||||
|
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||||
|
// One announcement per tick at most, whatever the scan turned up. The
|
||||||
|
// rest are on /routines; they are not lost, they are just not shouted.
|
||||||
|
// Nor are they queued: the row now exists, so no later tick re-detects
|
||||||
|
// them and they are never announced. See announceProposal.
|
||||||
|
if announced {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
announced = t.announceProposal(ctx, r, now, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceProposal offers a freshly inferred routine through the ordinary
|
||||||
|
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||||
|
// something was actually sent.
|
||||||
|
//
|
||||||
|
// Everything here is restraint. The feature is off unless configured; when on
|
||||||
|
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||||
|
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||||
|
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||||
|
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||||
|
// real send, but the proposal row already exists, so the next tick will not
|
||||||
|
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||||
|
// he reads it on /routines instead, which is the whole point of the page.
|
||||||
|
//
|
||||||
|
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
||||||
|
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
||||||
|
// that first proposes it. Combined with one announcement per tick, the first
|
||||||
|
// tick over a populated history announces one pattern and permanently silences
|
||||||
|
// every other pattern found in the same pass. That is the intent, not an
|
||||||
|
// oversight — an inferred routine is not worth a second attempt at his
|
||||||
|
// attention, and /routines lists all of them. So the cooldown does not drain a
|
||||||
|
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
||||||
|
// later ticks. If it should ever become "one per day until each is mentioned",
|
||||||
|
// that needs a queue rather than this counter.
|
||||||
|
//
|
||||||
|
// Cooldown gets its default here as well as in applyDefaults. That is
|
||||||
|
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
||||||
|
// and an unspaced announcer is not what those tests mean to exercise.
|
||||||
|
//
|
||||||
|
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||||
|
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||||
|
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||||
|
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||||
|
if !t.proposalCfg.AnnounceProposals() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||||
|
if cooldown <= 0 {
|
||||||
|
cooldown = config.DefaultProposalCooldown
|
||||||
|
}
|
||||||
|
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||||
|
if !loop.Gate(state, rule) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body := pattern.PhraseRoutine(r)
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(sent) == 0 {
|
||||||
|
return false // routing dropped it — /routines still has it.
|
||||||
|
}
|
||||||
|
t.lastProposalAt = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||||
|
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||||
|
// in config.Load, so this is a pure field copy.
|
||||||
|
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
||||||
|
if len(rc) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]routine.Routine, len(rc))
|
||||||
|
for i, r := range rc {
|
||||||
|
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
||||||
|
// last fire. Each is delivered as a nudge through the normal routing table
|
||||||
|
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
||||||
|
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
||||||
|
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
||||||
|
// already advanced the last-fire time so a transient failure drops that fire
|
||||||
|
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
||||||
|
// no repeat-til-ack).
|
||||||
|
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
||||||
|
Severity: loop.Severity(r.Severity),
|
||||||
|
State: state,
|
||||||
|
},
|
||||||
|
Body: r.Body,
|
||||||
|
Summary: r.Body,
|
||||||
|
}
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireAcceptedRoutines nudges about the routines the user accepted, once per
|
||||||
|
// interval (Vikunja #366). Accepting used to create a single reminder, so a
|
||||||
|
// non-weekly routine fired once and went quiet forever; the schedule lives in
|
||||||
|
// the proposed_routines row now and the loop re-reads it every tick.
|
||||||
|
//
|
||||||
|
// A routine is a care-class nudge and goes through the restraint gate like any
|
||||||
|
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
|
||||||
|
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
|
||||||
|
// goes out on the next tick that the gate allows — one nudge, held, not dropped
|
||||||
|
// and not repeated.
|
||||||
|
//
|
||||||
|
// The body is literal text built from the detected action and object, not
|
||||||
|
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
|
||||||
|
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
rows, err := t.store.ListAcceptedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: list accepted routines: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted := make([]routine.Accepted, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.AcceptedTs == nil {
|
||||||
|
continue // accepted before the schedule column existed — no clock to start from.
|
||||||
|
}
|
||||||
|
accepted = append(accepted, routine.Accepted{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Action + " " + r.Object,
|
||||||
|
IntervalDays: r.IntervalDays,
|
||||||
|
Accepted: *r.AcceptedTs,
|
||||||
|
LastFired: r.LastFiredTs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range routine.DueAccepted(accepted, now) {
|
||||||
|
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
|
||||||
|
if !loop.Gate(state, rule) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := "пора: " + a.Name
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(sent) == 0 {
|
||||||
|
continue // routing dropped it — leave it due.
|
||||||
|
}
|
||||||
|
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
|
||||||
|
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -584,7 +584,7 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
markPresent(t, st, ctx, now)
|
markPresent(t, st, ctx, now)
|
||||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
|
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
|
||||||
t.Fatalf("seed service_down: %v", err)
|
t.Fatalf("seed service_down: %v", err)
|
||||||
}
|
}
|
||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
|
|||||||
+10
-19
@@ -55,7 +55,6 @@ import (
|
|||||||
"github.com/kami/maven/internal/crawl"
|
"github.com/kami/maven/internal/crawl"
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -72,14 +71,16 @@ import (
|
|||||||
// safe (the wired stt/tts/router/api all are); called from per-conn
|
// safe (the wired stt/tts/router/api all are); called from per-conn
|
||||||
// goroutines on the voice.Server.
|
// goroutines on the voice.Server.
|
||||||
type reactiveHandler struct {
|
type reactiveHandler struct {
|
||||||
stt stt.Transcriber
|
stt stt.Transcriber
|
||||||
tts tts.Synthesizer
|
tts tts.Synthesizer
|
||||||
router *router.Router
|
router *router.Router
|
||||||
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
|
||||||
// boundary — the embedded seed sets behind the personal boundary
|
// recall — the note-and-fact recall subsystem: the embedder, the vector
|
||||||
// (personalboundary.go). Zero value is usable and loads on first query;
|
// store it writes into, the personal boundary, and the two numbers that
|
||||||
// with no embedder it never loads and the boundary uses personalMarkers.
|
// gate an answer. Grouped rather than spread across the handler because a
|
||||||
boundary personalBoundary
|
// handler that recalls needs all five and a handler that does not needs
|
||||||
|
// none of them (Vikunja #433, docs/handler-wiring.md).
|
||||||
|
recall recallWiring
|
||||||
// api — the CoreAPI the handler reads and writes through. Wired with the
|
// api — the CoreAPI the handler reads and writes through. Wired with the
|
||||||
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
|
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
|
||||||
// upgradeAPI.
|
// upgradeAPI.
|
||||||
@@ -123,18 +124,8 @@ type reactiveHandler struct {
|
|||||||
weatherProvider weather.Provider
|
weatherProvider weather.Provider
|
||||||
weatherLocation string // default location for weather queries
|
weatherLocation string // default location for weather queries
|
||||||
|
|
||||||
memStore memory.Store
|
|
||||||
dataStore *store.Store // direct store access for event extraction + pattern detection
|
dataStore *store.Store // direct store access for event extraction + pattern detection
|
||||||
|
|
||||||
// queryMinScore — the note-recall confidence gate. Top cosine below this ⇒
|
|
||||||
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not
|
|
||||||
// load-bearing math (same posture as the presence thresholds). Set by
|
|
||||||
// wireVoice from VoiceConfig; default 0.55.
|
|
||||||
queryMinScore float64
|
|
||||||
// queryMinMargin — the second half of that gate: how far the top hit must
|
|
||||||
// beat the runner-up. 0 ⇒ margin off.
|
|
||||||
queryMinMargin float64
|
|
||||||
|
|
||||||
// timeParser — used as a fallback for stage-0 reminder grammar matches
|
// timeParser — used as a fallback for stage-0 reminder grammar matches
|
||||||
// (where the extractor didn't run). Shared with the router's extractor.
|
// (where the extractor didn't run). Shared with the router's extractor.
|
||||||
// The production dateparser will replace StubDateTimeParser here too.
|
// The production dateparser will replace StubDateTimeParser here too.
|
||||||
|
|||||||
+21
-23
@@ -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) -----
|
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
stt: transcriber,
|
stt: transcriber,
|
||||||
tts: synthesizer,
|
tts: synthesizer,
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
api: coreAPI,
|
||||||
api: coreAPI,
|
tools: exec,
|
||||||
tools: exec,
|
matcher: matcher,
|
||||||
matcher: matcher,
|
replier: replier,
|
||||||
replier: replier,
|
phraser: phr,
|
||||||
phraser: phr,
|
now: time.Now,
|
||||||
now: time.Now,
|
feedsOn: cfg.Feeds != nil,
|
||||||
feedsOn: cfg.Feeds != nil,
|
home: w.home,
|
||||||
home: w.home,
|
netscan: w.netscan,
|
||||||
netscan: w.netscan,
|
|
||||||
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||||
// capability, and capabilities are off unless configured.
|
// capability, and capabilities are off unless configured.
|
||||||
crawler: onDemandCrawler(cfg),
|
crawler: onDemandCrawler(cfg),
|
||||||
@@ -283,18 +282,21 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
search: wireSearch(cfg),
|
search: wireSearch(cfg),
|
||||||
// nil unless a `kiwix` block names a server. Same swap-aware client the
|
// nil unless a `kiwix` block names a server. Same swap-aware client the
|
||||||
// router and replier use, so the rewriter follows a model swap.
|
// router and replier use, so the rewriter follows a model swap.
|
||||||
kiwix: wireKiwix(cfg, llmClient),
|
kiwix: wireKiwix(cfg, llmClient),
|
||||||
weatherProvider: weatherProvider,
|
weatherProvider: weatherProvider,
|
||||||
weatherLocation: weatherLocation,
|
weatherLocation: weatherLocation,
|
||||||
memStore: memStore,
|
recall: recallWiring{
|
||||||
|
embedder: emb,
|
||||||
|
memStore: memStore,
|
||||||
|
minScore: cfg.Voice.QueryMinScore,
|
||||||
|
minMargin: cfg.Voice.QueryMinMargin,
|
||||||
|
},
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
dialogueSessions: dialogueSessions,
|
dialogueSessions: dialogueSessions,
|
||||||
clarifyStore: clarifyStore,
|
clarifyStore: clarifyStore,
|
||||||
// 0 here (unset config) ⇒ the dialogue default.
|
// 0 here (unset config) ⇒ the dialogue default.
|
||||||
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
||||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||||
queryMinScore: cfg.Voice.QueryMinScore,
|
|
||||||
queryMinMargin: cfg.Voice.QueryMinMargin,
|
|
||||||
timeParser: timeParser,
|
timeParser: timeParser,
|
||||||
ecosystem: eco,
|
ecosystem: eco,
|
||||||
}
|
}
|
||||||
@@ -382,10 +384,6 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||||
// is an agenda question and must not.
|
// is an agenda question and must not.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
|
|
||||||
// question first and a narrative request second (Vikunja #498).
|
|
||||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
|
||||||
grammars = append(grammars, router.ListGrammars()...)
|
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||||
// explicit capture marker beats the model, which called it an act and
|
// explicit capture marker beats the model, which called it an act and
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ type worldPhraser interface {
|
|||||||
// question about his meeting came back as a swimming competition in Nottingham.
|
// question about his meeting came back as a swimming competition in Nottingham.
|
||||||
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
|
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
|
||||||
// being down.
|
// being down.
|
||||||
//
|
const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||||
// The wording lives in fallbacks_ru_v1.json and is fixed there, not picked from
|
|
||||||
// variants: this sentence names one specific gap and must not drift into a
|
|
||||||
// general "I don't know".
|
|
||||||
func worldGap() string { return phraser.WorldGap() }
|
|
||||||
|
|
||||||
// phraseWorld asks the world model, or reports the gap.
|
// phraseWorld asks the world model, or reports the gap.
|
||||||
//
|
//
|
||||||
@@ -58,11 +54,7 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri
|
|||||||
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
||||||
return ""
|
return ""
|
||||||
case err != nil:
|
case err != nil:
|
||||||
// The resident phraser answers this call with its fallback text and the
|
|
||||||
// error together. Drop the text: these callers hold the passage itself
|
|
||||||
// and read it back better than "вот что я нашла: <passage>" does.
|
|
||||||
log.Printf("voice: %s: phrase: %v", name, err)
|
log.Printf("voice: %s: phrase: %v", name, err)
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestQueryGeneralNamesTheGap(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("queryGeneral passed on the last source in the chain")
|
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||||
}
|
}
|
||||||
if reply != worldGap() {
|
if reply != worldGap {
|
||||||
t.Fatalf("reply = %q, want the named gap", reply)
|
t.Fatalf("reply = %q, want the named gap", reply)
|
||||||
}
|
}
|
||||||
if g.worldCalls != 1 {
|
if g.worldCalls != 1 {
|
||||||
@@ -51,7 +51,7 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("queryGeneral passed on the last source in the chain")
|
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||||
}
|
}
|
||||||
if !phraser.IsUnknownFallback(reply) {
|
if reply != "не знаю." {
|
||||||
t.Fatalf("reply = %q, want the Stub's answer", reply)
|
t.Fatalf("reply = %q, want the Stub's answer", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,12 +61,12 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
|||||||
// English in it.
|
// English in it.
|
||||||
func TestWorldGapIsInPersona(t *testing.T) {
|
func TestWorldGapIsInPersona(t *testing.T) {
|
||||||
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
|
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
|
||||||
if strings.Contains(worldGap(), bad) {
|
if strings.Contains(worldGap, bad) {
|
||||||
t.Errorf("the gap phrase contains %q: %s", bad, worldGap())
|
t.Errorf("the gap phrase contains %q: %s", bad, worldGap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.ContainsAny(worldGap(), "abcdefghijklmnopqrstuvwxyz") {
|
if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") {
|
||||||
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap())
|
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
-21
@@ -140,6 +140,10 @@ type poller struct {
|
|||||||
wgIface string
|
wgIface string
|
||||||
wgCmd string
|
wgCmd string
|
||||||
|
|
||||||
|
// kumaSeen — monitor name → state as of the last poll, so a monitor that
|
||||||
|
// disappears from the gauge can be marked unknown instead of staying down.
|
||||||
|
kumaSeen map[string]string
|
||||||
|
|
||||||
// zen is nil unless a token file was configured — money tracking is a
|
// zen is nil unless a token file was configured — money tracking is a
|
||||||
// capability, off by default like weather and telegram.
|
// capability, off by default like weather and telegram.
|
||||||
zen *zenmoney.Client
|
zen *zenmoney.Client
|
||||||
@@ -333,50 +337,96 @@ func maxSeverity(a netdataAlarms) string {
|
|||||||
return sev
|
return sev
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
|
// ---- kuma: monitor_status gauge → one fact per monitor ---------------------
|
||||||
|
|
||||||
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
||||||
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
|
// V is 1=up 0=down 2=pending 3=maintenance. We write one fact per monitor,
|
||||||
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
|
// keyed `service_down:<monitor name>`, because the nudge has to say WHICH
|
||||||
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
|
// service is down. The aggregate this used to write could not, which is why
|
||||||
// only needs "something is down".
|
// the rule shipped disabled.
|
||||||
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
var (
|
||||||
|
kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
||||||
|
kumaName = regexp.MustCompile(`monitor_name="([^"]*)"`)
|
||||||
|
)
|
||||||
|
|
||||||
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
||||||
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
down, seen := kumaAnyDown(body)
|
states := kumaMonitors(body)
|
||||||
if !seen {
|
if len(states) == 0 {
|
||||||
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
||||||
}
|
}
|
||||||
val := "up"
|
var firstErr error
|
||||||
if down {
|
for name, val := range states {
|
||||||
val = "down"
|
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, val, now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err // one bad monitor must not blind the rest
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
|
// A monitor deleted in kuma stops appearing in the gauge, and its last fact
|
||||||
|
// would otherwise read "down" forever. Mark it unknown, which no rule fires
|
||||||
|
// on. The seen-set is in memory, so a restart forgets it — harmless, since
|
||||||
|
// the next poll that still lacks the monitor says nothing new either.
|
||||||
|
for name := range p.kumaSeen {
|
||||||
|
if _, still := states[name]; !still {
|
||||||
|
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, "unknown", now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.kumaSeen = states
|
||||||
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
|
// kumaSource — the provenance the loop rule requires. Written here, checked in
|
||||||
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
|
// loop.ServiceDownRule; a poller under any other source cannot fire it.
|
||||||
// lines matched at all (wrong endpoint or auth rejected before the body).
|
const kumaSource = "poll:uptimekuma"
|
||||||
func kumaAnyDown(body []byte) (down, seen bool) {
|
|
||||||
|
// kumaFactKey — the fact key for one monitor. The suffix is the name he hears,
|
||||||
|
// so it stays as kuma spells it rather than being slugged into something else.
|
||||||
|
func kumaFactKey(name string) string { return "service_down:" + name }
|
||||||
|
|
||||||
|
// kumaMonitors parses kuma's Prometheus text into monitor name → state
|
||||||
|
// ("up"/"down"/"pending"/"maintenance"). An empty map means no monitor_status
|
||||||
|
// line matched at all (wrong endpoint, or auth rejected before the body).
|
||||||
|
// A line with no monitor_name label is skipped: a fact nobody can name is
|
||||||
|
// exactly the thing this replaced.
|
||||||
|
func kumaMonitors(body []byte) map[string]string {
|
||||||
|
out := make(map[string]string)
|
||||||
for _, line := range strings.Split(string(body), "\n") {
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||||
if m == nil {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen = true
|
nm := kumaName.FindStringSubmatch(m[1])
|
||||||
|
if nm == nil || strings.TrimSpace(nm[1]) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
v, err := strconv.ParseFloat(m[2], 64)
|
v, err := strconv.ParseFloat(m[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if v == 0 {
|
out[strings.TrimSpace(nm[1])] = kumaState(v)
|
||||||
down = true
|
}
|
||||||
}
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// kumaState — the gauge's four values. pending and maintenance are not "down":
|
||||||
|
// a monitor paused in kuma should silence that monitor, not page him.
|
||||||
|
func kumaState(v float64) string {
|
||||||
|
switch v {
|
||||||
|
case 0:
|
||||||
|
return "down"
|
||||||
|
case 1:
|
||||||
|
return "up"
|
||||||
|
case 2:
|
||||||
|
return "pending"
|
||||||
|
case 3:
|
||||||
|
return "maintenance"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
}
|
}
|
||||||
return down, seen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ---------------------------------------------------------------
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|||||||
+36
-12
@@ -35,22 +35,46 @@ func TestMaxSeverity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKumaAnyDown(t *testing.T) {
|
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
body string
|
name string
|
||||||
down, seen bool
|
body string
|
||||||
|
want map[string]string
|
||||||
}{
|
}{
|
||||||
{"", false, false},
|
{"empty body", "", map[string]string{}},
|
||||||
{`monitor_status{monitor_name="web"} 1`, false, true},
|
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
|
||||||
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
|
{
|
||||||
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
|
"one up one down",
|
||||||
{`# HELP monitor_status ...`, false, false},
|
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
|
||||||
|
map[string]string{"web": "up", "db": "down"},
|
||||||
|
},
|
||||||
|
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
|
||||||
|
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
|
||||||
|
{
|
||||||
|
"other labels do not hide the name",
|
||||||
|
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
|
||||||
|
map[string]string{"ci": "down"},
|
||||||
|
},
|
||||||
|
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
down, seen := kumaAnyDown([]byte(c.body))
|
t.Run(c.name, func(t *testing.T) {
|
||||||
if down != c.down || seen != c.seen {
|
got := kumaMonitors([]byte(c.body))
|
||||||
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
|
if len(got) != len(c.want) {
|
||||||
}
|
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
for k, v := range c.want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKumaFactKeyCarriesTheName(t *testing.T) {
|
||||||
|
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
|
||||||
|
t.Errorf("kumaFactKey = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,10 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleAmbientAuth(t *testing.T) {
|
func TestHandleAmbientAuth(t *testing.T) {
|
||||||
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
// "завтра" so the meeting is in the future in every zone: the clock in the
|
||||||
|
// text is a local wall clock and posted_at is a Z instant, so a bare "10:00"
|
||||||
|
// is already stale on a box east of UTC and stores nothing.
|
||||||
|
body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||||
|
|
||||||
newReq := func(hdr, val string) *http.Request {
|
newReq := func(hdr, val string) *http.Request {
|
||||||
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||||
@@ -245,3 +248,31 @@ func TestHandleAmbientBadInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and
|
||||||
|
// the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup
|
||||||
|
// used to be stored at 18:30, and the size of the error was the deploy's offset.
|
||||||
|
func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) {
|
||||||
|
// No zone juggling: the assertion is that the stored wall clock is the one
|
||||||
|
// he read, whatever zone the box is in. That is false under the old code
|
||||||
|
// on every box except a UTC one.
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
|
||||||
|
Package: "com.slack",
|
||||||
|
Title: "Standup",
|
||||||
|
Text: "созвон завтра в 14:30",
|
||||||
|
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(resp.Key, "_Standup") {
|
||||||
|
t.Errorf("key = %q, want a key naming the meeting", resp.Key)
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
|
||||||
|
}
|
||||||
|
if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" {
|
||||||
|
t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{template "shellTop" "chat"}}
|
||||||
|
<h1>Chat</h1>
|
||||||
|
<section class=card>
|
||||||
|
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
||||||
|
<div class="scroll chat-scroll" id=chatHistory>
|
||||||
|
{{range .Messages}}
|
||||||
|
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
||||||
|
{{else}}
|
||||||
|
<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||||
|
<div>start a conversation</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<form method=post action=/api/chat class=chat-form>
|
||||||
|
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
||||||
|
<button class=btn>send</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
var ch = document.getElementById('chatHistory');
|
||||||
|
if(ch) ch.scrollTop = ch.scrollHeight;
|
||||||
|
</script>
|
||||||
|
{{template "shellBottom"}}
|
||||||
+67
-243
@@ -75,6 +75,23 @@ var morningHTML string
|
|||||||
//go:embed events.html
|
//go:embed events.html
|
||||||
var eventsHTML string
|
var eventsHTML string
|
||||||
|
|
||||||
|
//go:embed tools.html
|
||||||
|
var toolsHTML string
|
||||||
|
|
||||||
|
//go:embed routines.html
|
||||||
|
var routinesHTML string
|
||||||
|
|
||||||
|
//go:embed chat.html
|
||||||
|
var chatPageHTML string
|
||||||
|
|
||||||
|
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
|
||||||
|
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
|
||||||
|
// with the sidebar assembled by a strings.Builder, which is the one piece of
|
||||||
|
// markup that was still concatenated in Go.
|
||||||
|
//
|
||||||
|
//go:embed shell.html
|
||||||
|
var shellHTML string
|
||||||
|
|
||||||
// ── Ethos Workstation Shell ──
|
// ── Ethos Workstation Shell ──
|
||||||
//
|
//
|
||||||
// Two template pieces that wrap every page:
|
// Two template pieces that wrap every page:
|
||||||
@@ -135,74 +152,40 @@ var sidebarSections = []struct {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func sidebarActive(url, key string, activeKey string) string {
|
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
|
||||||
if key == activeKey {
|
// sidebar template wraps it in the <use> reference.
|
||||||
return `class="active"`
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// sidebarHTML renders the sidebar navigation given the active page key.
|
|
||||||
func sidebarHTML(active string) template.HTML {
|
|
||||||
var b strings.Builder
|
|
||||||
for _, sec := range sidebarSections {
|
|
||||||
b.WriteString(`<div class=sidebar-section>`)
|
|
||||||
b.WriteString(`<div class=sidebar-label>`)
|
|
||||||
b.WriteString(sec.Label)
|
|
||||||
b.WriteString(`</div>`)
|
|
||||||
for _, p := range sec.Pages {
|
|
||||||
cls := ""
|
|
||||||
if p.Key == active {
|
|
||||||
cls = ` class="active"`
|
|
||||||
}
|
|
||||||
b.WriteString(`<a href="`)
|
|
||||||
b.WriteString(p.URL)
|
|
||||||
b.WriteString(`"`)
|
|
||||||
b.WriteString(cls)
|
|
||||||
b.WriteString(`><span class=icon>`)
|
|
||||||
b.WriteString(pageIcon(p.Key))
|
|
||||||
b.WriteString(`</span><span>`)
|
|
||||||
b.WriteString(p.Label)
|
|
||||||
b.WriteString(`</span></a>`)
|
|
||||||
}
|
|
||||||
b.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
return template.HTML(b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
|
|
||||||
func pageIcon(key string) string {
|
func pageIcon(key string) string {
|
||||||
switch key {
|
switch key {
|
||||||
case "dash":
|
case "dash":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
return "i-grid"
|
||||||
case "history":
|
case "history":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
|
return "i-clock"
|
||||||
case "trace":
|
case "trace":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
return "i-wave"
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
return "i-bell"
|
||||||
case "tasks":
|
case "tasks":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
return "i-grid"
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
return "i-calendar"
|
||||||
case "routines":
|
case "routines":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
return "i-repeat"
|
||||||
case "morning":
|
case "morning":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
return "i-calendar"
|
||||||
case "chat":
|
case "chat":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
return "i-message"
|
||||||
case "voice":
|
case "voice":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
|
return "i-mic"
|
||||||
case "ecosystem":
|
case "ecosystem":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
return "i-grid"
|
||||||
case "tools":
|
case "tools":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
return "i-settings"
|
||||||
case "models":
|
case "models":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
return "i-wave"
|
||||||
case "passkey":
|
case "passkey":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
return "i-lock"
|
||||||
default:
|
default:
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
|
return "i-search"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,63 +225,12 @@ func pageTitle(key string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shellTopHTML opens the shell and renders the top bar + sidebar.
|
|
||||||
// Usage: {{template "shellTop" "<page-key>"}}
|
|
||||||
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
|
|
||||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
||||||
<meta name=theme-color content="#14110D">
|
|
||||||
<link rel=manifest href=/manifest.json>
|
|
||||||
<title>maven · {{pageTitle .}}</title>
|
|
||||||
<link rel=stylesheet href=/ui.css>
|
|
||||||
<div class=shell data-app=maven>
|
|
||||||
<header class=topbar>
|
|
||||||
<div class=breadcrumbs>
|
|
||||||
<span class=current>{{pageTitle .}}</span>
|
|
||||||
</div>
|
|
||||||
<div class=topbar-actions>
|
|
||||||
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
|
|
||||||
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
|
|
||||||
Search
|
|
||||||
<span class=kbd-hint>Ctrl+/</span>
|
|
||||||
</div>
|
|
||||||
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
|
|
||||||
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
|
|
||||||
</button>
|
|
||||||
<span class=conn-status>
|
|
||||||
<span class="dot online" id=connDot></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class=shell-body>
|
|
||||||
<aside class=sidebar>
|
|
||||||
{{sidebarHTML .}}
|
|
||||||
</aside>
|
|
||||||
<main class=content>
|
|
||||||
{{end}}`
|
|
||||||
|
|
||||||
// shellBottomHTML closes the content area, inspector, and shell.
|
|
||||||
// Usage: {{template "shellBottom"}}
|
|
||||||
const shellBottomHTML = `{{define "shellBottom"}}
|
|
||||||
</main>
|
|
||||||
<aside class=inspector id=inspector>
|
|
||||||
<div class=inspector-inner>
|
|
||||||
<div class=inspector-header>
|
|
||||||
<span id=inspectorTitle>Details</span>
|
|
||||||
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">×</button>
|
|
||||||
</div>
|
|
||||||
<div class=inspector-body id=inspectorBody></div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src=/mavweb.js></script>
|
|
||||||
{{end}}`
|
|
||||||
|
|
||||||
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
||||||
func shellFuncs() template.FuncMap {
|
func shellFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"pageTitle": pageTitle,
|
"pageTitle": pageTitle,
|
||||||
"sidebarHTML": sidebarHTML,
|
"pageIcon": pageIcon,
|
||||||
|
"sidebarSections": func() any { return sidebarSections },
|
||||||
"ago": func(t time.Time) string {
|
"ago": func(t time.Time) string {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return "never"
|
return "never"
|
||||||
@@ -313,21 +245,21 @@ func shellFuncs() template.FuncMap {
|
|||||||
// a small fetch loop refreshes the tables in place. html/template escapes the
|
// a small fetch loop refreshes the tables in place. html/template escapes the
|
||||||
// user text in facts/nudges. Read-only: browses the append-only store via
|
// user text in facts/nudges. Read-only: browses the append-only store via
|
||||||
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
||||||
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
|
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellHTML + dashHTML))
|
||||||
|
|
||||||
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
||||||
// human surface is here (they ship no web UI of their own).
|
// human surface is here (they ship no web UI of their own).
|
||||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML))
|
||||||
|
|
||||||
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||||
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||||
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
|
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML))
|
||||||
|
|
||||||
// morningTmpl — read-only view of today's checklist state per configured
|
// morningTmpl — read-only view of today's checklist state per configured
|
||||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||||
// checklist state changes on the scale of minutes, not seconds.
|
// checklist state changes on the scale of minutes, not seconds.
|
||||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML))
|
||||||
|
|
||||||
func noCache(h http.Handler) http.Handler {
|
func noCache(h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -747,107 +679,23 @@ var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMa
|
|||||||
m := shellFuncs()
|
m := shellFuncs()
|
||||||
m["join"] = strings.Join
|
m["join"] = strings.Join
|
||||||
return m
|
return m
|
||||||
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
|
}()).Parse(shellHTML + toolsHTML))
|
||||||
|
|
||||||
const toolsHTML = `{{template "shellTop" "tools"}}
|
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML))
|
||||||
<h1>Tools</h1>
|
|
||||||
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
|
|
||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
|
||||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
|
||||||
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
|
||||||
{{range .Proposed}}<tr>
|
|
||||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
|
||||||
<td><form method=post action=/tools>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
|
||||||
<input type=hidden name=action value=enable>
|
|
||||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
|
||||||
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
|
||||||
<button class=btn>enable</button></form>
|
|
||||||
<form method=post action=/tools class=inline-form>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=action value=dismiss>
|
|
||||||
<button class="btn btn-muted">dismiss</button></form></td>
|
|
||||||
</tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
|
|
||||||
<div>no proposed tools</div>
|
|
||||||
<div class=hint>maven will propose tools here when she needs help running an action</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
|
||||||
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
|
||||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
|
||||||
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
|
||||||
<td><form method=post action=/tools class=inline-form>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
|
||||||
<input type=hidden name=action value=disable>
|
|
||||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
|
||||||
<div>no tools enabled</div>
|
|
||||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
|
||||||
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
|
||||||
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
|
||||||
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
|
||||||
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
|
||||||
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
|
||||||
<div>no MCP servers configured</div>
|
|
||||||
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
{{template "shellBottom"}}`
|
|
||||||
|
|
||||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML))
|
||||||
|
|
||||||
|
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML))
|
||||||
|
|
||||||
|
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML))
|
||||||
|
|
||||||
|
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML))
|
||||||
|
|
||||||
|
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML))
|
||||||
|
|
||||||
|
// routinesTmpl — the proposed-routine review surface. One row per thing maven
|
||||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
// noticed, in her words, with at most two actions: accept or dismiss.
|
||||||
const routinesHTML = `{{template "shellTop" "routines"}}
|
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML))
|
||||||
<h1>Routines</h1>
|
|
||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
|
|
||||||
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
|
|
||||||
{{range .Proposed}}<tr>
|
|
||||||
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
|
|
||||||
<td><form method=post action=/routines class=inline-form>
|
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
|
||||||
<input type=hidden name=action value=accept>
|
|
||||||
<button class=btn>accept</button></form></td>
|
|
||||||
<td><form method=post action=/routines class=inline-form>
|
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
|
||||||
<input type=hidden name=action value=dismiss>
|
|
||||||
<button class="btn btn-muted">dismiss</button></form></td>
|
|
||||||
</tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
|
||||||
<div>no proposed routines</div>
|
|
||||||
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
{{template "shellBottom"}}`
|
|
||||||
|
|
||||||
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
|
||||||
|
|
||||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||||
m := shellFuncs()
|
m := shellFuncs()
|
||||||
@@ -859,7 +707,7 @@ var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMa
|
|||||||
}
|
}
|
||||||
m["join"] = strings.Join
|
m["join"] = strings.Join
|
||||||
return m
|
return m
|
||||||
}()).Parse(shellTopHTML + traceHTML + shellBottomHTML))
|
}()).Parse(shellHTML + traceHTML))
|
||||||
|
|
||||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
@@ -1172,9 +1020,10 @@ func fmtTaskDate(t *time.Time) string {
|
|||||||
return t.Local().Format("02 Jan")
|
return t.Local().Format("02 Jan")
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
// routineView is one line on the page: what maven noticed, in her words, and
|
||||||
// how long ago she noticed it.
|
// how long ago she noticed it. A view model, not a database row — the template
|
||||||
type routineRow struct {
|
// never formats an interval or a timestamp itself.
|
||||||
|
type routineView struct {
|
||||||
ID int64
|
ID int64
|
||||||
Phrase string
|
Phrase string
|
||||||
Noticed string
|
Noticed string
|
||||||
@@ -1236,23 +1085,23 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := routinesTmpl.Execute(w, struct {
|
if err := routinesTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []routineRow
|
Proposed []routineView
|
||||||
}{msg, routineRows(proposed)}); err != nil {
|
}{msg, toRoutineViews(proposed)}); err != nil {
|
||||||
log.Printf("routines render: %v", err)
|
log.Printf("routines render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineRows turns the wire rows into display rows. The phrase comes from
|
// toRoutineViews turns the wire rows into view models. The phrase comes from
|
||||||
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
||||||
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
||||||
out := make([]routineRow, 0, len(rs))
|
out := make([]routineView, 0, len(rs))
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
||||||
noticed := "just now"
|
noticed := "just now"
|
||||||
if r.CreatedTs > 0 {
|
if r.CreatedTs > 0 {
|
||||||
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
||||||
}
|
}
|
||||||
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1649,32 +1498,7 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
|
|||||||
|
|
||||||
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
||||||
// and the handler redirects back to /chat with the response.
|
// and the handler redirects back to /chat with the response.
|
||||||
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellTopHTML + chatPageHTML + shellBottomHTML))
|
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML))
|
||||||
|
|
||||||
const chatPageHTML = `{{template "shellTop" "chat"}}
|
|
||||||
<h1>Chat</h1>
|
|
||||||
<section class=card>
|
|
||||||
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
|
||||||
<div class="scroll chat-scroll" id=chatHistory>
|
|
||||||
{{range .Messages}}
|
|
||||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
|
||||||
{{else}}
|
|
||||||
<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
|
||||||
<div>start a conversation</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<form method=post action=/api/chat class=chat-form>
|
|
||||||
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
|
||||||
<button class=btn>send</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
<script>
|
|
||||||
var ch = document.getElementById('chatHistory');
|
|
||||||
if(ch) ch.scrollTop = ch.scrollHeight;
|
|
||||||
</script>
|
|
||||||
{{template "shellBottom"}}`
|
|
||||||
|
|
||||||
// handleChatPage renders the chat conversation page.
|
// handleChatPage renders the chat conversation page.
|
||||||
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type modelController interface {
|
|||||||
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
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"}}
|
const modelsHTML = `{{template "shellTop" "models"}}
|
||||||
<h1>Resident model</h1>
|
<h1>Resident model</h1>
|
||||||
|
|||||||
@@ -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,58 @@
|
|||||||
|
{{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"}}
|
||||||
+5
-5
@@ -8,12 +8,12 @@
|
|||||||
"//disabled_rules": [
|
"//disabled_rules": [
|
||||||
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
||||||
"water, meal, break, service_down, netdata_critical.",
|
"water, meal, break, service_down, netdata_critical.",
|
||||||
"service_down is off because it cannot say WHICH service — mavpoll folds the",
|
"service_down is back on: mavpoll now writes one fact per kuma monitor",
|
||||||
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
|
"(service_down:<name>), so the nudge names the service and pausing a monitor",
|
||||||
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
|
"in kuma silences that monitor. It is also edge-triggered, so a service that",
|
||||||
"Turn it back on once Vikunja #444 lands a fact per monitor."
|
"stays down is one nudge, not one every fifteen minutes."
|
||||||
],
|
],
|
||||||
"disabled_rules": ["service_down"],
|
"disabled_rules": [],
|
||||||
|
|
||||||
"phraser": {
|
"phraser": {
|
||||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||||
|
|||||||
+23
-59
@@ -317,40 +317,6 @@ don't improvise.** Destructive ones still gate behind confirm.
|
|||||||
Misroute correction is append-only and grows the router's examples with use —
|
Misroute correction is append-only and grows the router's examples with use —
|
||||||
same shape as `nudges.outcome` tuning cooldowns, no retrain.
|
same shape as `nudges.outcome` tuning cooldowns, no retrain.
|
||||||
|
|
||||||
#### Risk tiers, not one boolean
|
|
||||||
|
|
||||||
`Destructive` on a tool row is one bit set by whoever ticked the checkbox on
|
|
||||||
`/tools`. It is a mechanism, and it never said which acts are destructive,
|
|
||||||
whether a confirmed act stays confirmed, or what a new tool domain inherits.
|
|
||||||
`internal/tool/risk.go` is the policy (Vikunja #449). The tier is DERIVED from
|
|
||||||
the row, not stored, so it can be argued with in one place instead of being
|
|
||||||
whatever the last person to enable the tool believed.
|
|
||||||
|
|
||||||
| Tier | What it is | What it costs |
|
|
||||||
|---|---|---|
|
|
||||||
| `safe` | a read, or a change he can undo by saying the opposite | runs on first hearing |
|
|
||||||
| `destructive` | it changes something real and undoing it takes work | one confirm turn, every time |
|
|
||||||
| `irreversible` | the thing does not come back: a wipe, a format, a delete with no bin | voice may not authorise it at all |
|
|
||||||
|
|
||||||
Three rules fall out, and they are the part that was missing:
|
|
||||||
|
|
||||||
- **Which acts are destructive is not only the checkbox.** A house row always
|
|
||||||
is, because there is no read-only way to turn the heating off. A row whose
|
|
||||||
argv names one of the irreversible verbs always is, whatever the row says.
|
|
||||||
- **A confirmed act never stays confirmed.** At any tier. A confirmation binds
|
|
||||||
one capability, one target and one argument list, and it dies with the parked
|
|
||||||
turn (90s). "The same act again" is a new act. A sticky confirm is a standing
|
|
||||||
grant and nothing on the voice path may hold one.
|
|
||||||
- **A new domain inherits `destructive`, not `safe`.** A dispatch shape the
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
none of it. She names the gap and he runs it himself. The row stays enabled;
|
|
||||||
refusing to run it from voice is not the same as taking it off the allowlist.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Voice pipeline (STT / TTS)
|
## Voice pipeline (STT / TTS)
|
||||||
@@ -423,6 +389,29 @@ lives in `source`; rules trust provenance.
|
|||||||
`source=poll:healthcheck`. A compromised poller must not be able to forge a
|
`source=poll:healthcheck`. A compromised poller must not be able to forge a
|
||||||
trigger.
|
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
|
### Presence — concrete scoring
|
||||||
|
|
||||||
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
|
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
|
||||||
@@ -688,31 +677,6 @@ add a new principle; it applied the existing one at smaller and smaller scope.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A list is the fourth shape
|
|
||||||
|
|
||||||
Facts, notes and tasks were the three append-only shapes. `list_items` is the
|
|
||||||
fourth (Vikunja #453): an item, a status, and a list tag.
|
|
||||||
|
|
||||||
It is not a task. Milk is not work, nothing prioritises it, and the ranker must
|
|
||||||
not start counting groceries as outstanding errands. It is not a fact either,
|
|
||||||
because it claims nothing about the world. What it is, is a set that grows and
|
|
||||||
shrinks.
|
|
||||||
|
|
||||||
The property that makes the separate table worth it: no predicate reads a list.
|
|
||||||
Nothing ranks it, nothing nudges about it, the digestion worker ignores it. So
|
|
||||||
two people adding to the same list at once cost nothing — there is no order to
|
|
||||||
disagree about and no lifecycle past crossed-off.
|
|
||||||
|
|
||||||
The unique index is the tasks one, per list, and live rows only. Saying "молоко"
|
|
||||||
twice before the shop is one line; saying it again next week, after the last one
|
|
||||||
was crossed off, is a new line.
|
|
||||||
|
|
||||||
Spoken, it is four turns: add, read back, cross one item off, cross the lot off.
|
|
||||||
All four are matched deterministically in `internal/router/list.go` and all four
|
|
||||||
run at stage 0, because an add and a read-back are cheap and should not depend on
|
|
||||||
the resident model having a good turn. Crossing one item off claims the turn only
|
|
||||||
when the list holds that item, which is what keeps "купил новый ноутбук" a note.
|
|
||||||
|
|
||||||
## Calendar
|
## Calendar
|
||||||
|
|
||||||
Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is
|
Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Maven — Re-architecture (Qwen3 resident model, revised 2026-07-18)
|
# 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
|
> Supersedes the classifier-first routing model. Agreed in a design session
|
||||||
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
|
> 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.
|
are deferred until the main feature set is complete.
|
||||||
- **Embedder demoted from router to tool** — it now backs `memory.search`
|
- **Embedder demoted from router to tool** — it now backs `memory.search`
|
||||||
(RAG) and gives the router a cheap "similar past notes/intents" hint. The
|
(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
|
router no longer depends on it clearing a threshold. The MiniLM upgrade is
|
||||||
for better RU retrieval later (model swap, not architecture).
|
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
|
### Router output
|
||||||
- Constrained structured JSON action `{tool, args, escalate}` — NOT free-form
|
- Constrained structured JSON action `{tool, args, escalate}` — NOT free-form
|
||||||
|
|||||||
@@ -68,7 +68,28 @@ var dayWords = map[string]int{
|
|||||||
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
||||||
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
||||||
// time gets DefaultReminderDuration.
|
// time gets DefaultReminderDuration.
|
||||||
|
//
|
||||||
|
// The clock reading is a wall clock in the daemon's local zone. See
|
||||||
|
// EventFromNotificationIn.
|
||||||
func EventFromNotification(n Notification) (Event, bool) {
|
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() {
|
if n.Posted.IsZero() {
|
||||||
return Event{}, false
|
return Event{}, false
|
||||||
}
|
}
|
||||||
@@ -82,8 +103,9 @@ func EventFromNotification(n Notification) (Event, bool) {
|
|||||||
return Event{}, false
|
return Event{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
|
// The day is Posted's LOCAL day: a notification posted at 23:30Z saying
|
||||||
loc := n.Posted.Location()
|
// "завтра в 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)
|
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
|
// 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.
|
// was inferred, so the honest reading is that the inference was wrong.
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ func TestEventFromNotification(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ev, ok := EventFromNotification(Notification{
|
ev, ok := EventFromNotificationIn(Notification{
|
||||||
Package: "com.google.android.gm",
|
Package: "com.google.android.gm",
|
||||||
Title: tt.title,
|
Title: tt.title,
|
||||||
Text: tt.text,
|
Text: tt.text,
|
||||||
Posted: posted,
|
Posted: posted,
|
||||||
})
|
}, posted.Location())
|
||||||
if ok != tt.wantOK {
|
if ok != tt.wantOK {
|
||||||
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,8 @@ func TestEventFromNotification(t *testing.T) {
|
|||||||
if !ev.End.After(ev.Start) {
|
if !ev.End.After(ev.Start) {
|
||||||
t.Errorf("end %v must be after start %v", ev.End, 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
|
// The event lands on the day the phone showed it, in the zone it
|
||||||
// location — not shifted into UTC.
|
// was resolved against — never shifted into UTC.
|
||||||
if ev.Start.Location() != posted.Location() {
|
if ev.Start.Location() != posted.Location() {
|
||||||
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||||
}
|
}
|
||||||
@@ -162,10 +162,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ev, ok := EventFromNotification(Notification{
|
ev, ok := EventFromNotificationIn(Notification{
|
||||||
Package: "com.google.android.calendar",
|
Package: "com.google.android.calendar",
|
||||||
Title: tt.title, Text: tt.text, Posted: tt.posted,
|
Title: tt.title, Text: tt.text, Posted: tt.posted,
|
||||||
})
|
}, tt.posted.Location())
|
||||||
if ok != tt.wantOK {
|
if ok != tt.wantOK {
|
||||||
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||||
}
|
}
|
||||||
@@ -189,10 +189,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
|||||||
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
|
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
|
||||||
// that into the key.
|
// that into the key.
|
||||||
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||||
ev, ok := EventFromNotification(Notification{
|
ev, ok := EventFromNotificationIn(Notification{
|
||||||
Title: "Завтра Планёрка 09:00",
|
Title: "Завтра Планёрка 09:00",
|
||||||
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
|
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
|
||||||
})
|
}, time.UTC)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected an event")
|
t.Fatal("expected an event")
|
||||||
}
|
}
|
||||||
@@ -209,10 +209,10 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
|||||||
|
|
||||||
// An ambient event must never be indistinguishable from a calendar read.
|
// An ambient event must never be indistinguishable from a calendar read.
|
||||||
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||||
ev, ok := EventFromNotification(Notification{
|
ev, ok := EventFromNotificationIn(Notification{
|
||||||
Title: "Планёрка 10:00-10:30",
|
Title: "Планёрка 10:00-10:30",
|
||||||
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||||
})
|
}, time.UTC)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected an event")
|
t.Fatal("expected an event")
|
||||||
}
|
}
|
||||||
@@ -237,3 +237,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:
|
// 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
|
// 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
|
// and useless in practice. service_down was the case that forced it: it
|
||||||
// is nudging about (Vikunja #444), so being told "a service on homesrv is
|
// could not name the service it was nudging about, so being told "a service
|
||||||
// down" every fifteen minutes is noise with no action attached. Turning it
|
// on homesrv is down" every fifteen minutes was noise with no action
|
||||||
// off beats learning to ignore her.
|
// 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
|
// A disabled rule is never gathered for, never evaluated, and never
|
||||||
// delivered on any channel. Unknown names are ignored, so removing a rule
|
// delivered on any channel. Unknown names are ignored, so removing a rule
|
||||||
|
|||||||
@@ -678,135 +678,6 @@ type acceptProposedRoutineReq struct {
|
|||||||
ID int64 `json:"id"`
|
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
|
// 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
|
// event.Event field for field; the ipc package does not import internal/event
|
||||||
// so the wire shape stays independent of the in-process type.
|
// so the wire shape stays independent of the in-process type.
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// 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.
|
// (we read the length but not the body), so the caller must close it.
|
||||||
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
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
|
// 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
|
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
||||||
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
// 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,148 @@
|
|||||||
|
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 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"
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@ package ipc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -13,449 +11,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/netaddr"
|
"github.com/kami/maven/internal/netaddr"
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
"golang.org/x/sys/unix"
|
"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,
|
// Server — the core side of the boundary. Listens on a unix domain socket,
|
||||||
// accepts module connections, frames requests to a CoreAPI and responses back.
|
// accepts module connections, frames requests to a CoreAPI and responses back.
|
||||||
// One Server per daemon process; concurrent connections are handled in their
|
// One Server per daemon process; concurrent connections are handled in their
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ func TestExplainGate_PresenceAway(t *testing.T) {
|
|||||||
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
||||||
now := refTime()
|
now := refTime()
|
||||||
s := State{Now: now, Presence: store.Away,
|
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
|
r := ServiceDownRule() // Sev4 ops
|
||||||
passed, blocked, d := ExplainGate(s, r)
|
passed, blocked, d := ExplainGate(s, r)
|
||||||
@@ -259,8 +259,8 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cand, trace := ExplainTick(s, DefaultRules())
|
cand, trace := ExplainTick(s, DefaultRules())
|
||||||
|
|||||||
@@ -198,12 +198,12 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, 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.
|
// 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
|
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.
|
// last nudge per rule + cooldown-until derived from the active cooldown.
|
||||||
// "active" = the feedback tuner's persisted base if one exists, else the
|
// "active" = the feedback tuner's persisted base if one exists, else the
|
||||||
// rule's static Base. LatestFactBySource is the trust-by-provenance read
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
|
|||||||
Presence: store.Away,
|
Presence: store.Away,
|
||||||
QuietHours: true,
|
QuietHours: true,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := Tick(s, DefaultRules())
|
got := Tick(s, DefaultRules())
|
||||||
@@ -99,7 +99,7 @@ func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"service_down": factAt("service_down", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if got := Tick(s, DefaultRules()); got != nil {
|
if got := Tick(s, DefaultRules()); got != nil {
|
||||||
@@ -115,8 +115,8 @@ func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := Tick(s, DefaultRules())
|
got := Tick(s, DefaultRules())
|
||||||
|
|||||||
+55
-13
@@ -28,6 +28,13 @@ type Rule struct {
|
|||||||
// that check itself, leave this empty. Otherwise set to the key(s) the rule
|
// that check itself, leave this empty. Otherwise set to the key(s) the rule
|
||||||
// needs and the gate will skip the rule when any are missing.
|
// needs and the gate will skip the rule when any are missing.
|
||||||
InertWhenNoData []string
|
InertWhenNoData []string
|
||||||
|
|
||||||
|
// WantPrefixes — key prefixes whose whole family the gatherer must load.
|
||||||
|
// InertWhenNoData names keys that exist at wiring time; a rule over a key
|
||||||
|
// set that is only known at read time (one fact per kuma monitor) declares
|
||||||
|
// the prefix here instead. Prefixes never make a rule inert: an empty
|
||||||
|
// family is the predicate's own "no data" case.
|
||||||
|
WantPrefixes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
||||||
@@ -98,23 +105,58 @@ func BreakRule() Rule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceDownRule — sev4 ops hard: the `service_down` aggregate fact reads
|
// ServiceDownPrefix — mavpoll writes one fact per kuma monitor under this
|
||||||
// "down". Source must be poll:uptimekuma — kuma is the source of truth for
|
// prefix, `service_down:<monitor name>`. The suffix is the name he hears.
|
||||||
// service up/down (mavpoll writes this key). The predicate is provenance-scoped:
|
const ServiceDownPrefix = "service_down:"
|
||||||
// a compromised poller writing under a different source can't forge the trigger.
|
|
||||||
|
// ServiceDownSource — kuma is the source of truth for service up/down. The
|
||||||
|
// rule is provenance-scoped: a poller writing under a different source cannot
|
||||||
|
// forge the trigger.
|
||||||
|
const ServiceDownSource = "poll:uptimekuma"
|
||||||
|
|
||||||
|
// DownServices — the monitors currently reading "down", by name, in key order.
|
||||||
|
//
|
||||||
|
// Pure, and the rule and the phraser both call it, so the message can never
|
||||||
|
// name a service the predicate did not fire on.
|
||||||
|
func DownServices(s State) []string {
|
||||||
|
var out []string
|
||||||
|
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
||||||
|
if f.Source == ServiceDownSource && f.Value == `"down"` {
|
||||||
|
out = append(out, strings.TrimPrefix(f.Key, ServiceDownPrefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceDownRule — sev4 ops hard: at least one kuma monitor reads "down".
|
||||||
|
//
|
||||||
|
// It used to read one aggregate `service_down` fact, which is why it was
|
||||||
|
// disabled in deploy: the nudge could say that something on homesrv was down
|
||||||
|
// but never which thing. Per-monitor facts fix that, and pausing a monitor in
|
||||||
|
// kuma now silences that monitor rather than nothing.
|
||||||
|
//
|
||||||
|
// Edge-triggered — see State.NudgedSince. Without it a service that stays down
|
||||||
|
// for a day qualifies on every tick and cooldown alone is the only brake.
|
||||||
func ServiceDownRule() Rule {
|
func ServiceDownRule() Rule {
|
||||||
return Rule{
|
return Rule{
|
||||||
Name: "service_down",
|
Name: "service_down",
|
||||||
Severity: Sev4,
|
Severity: Sev4,
|
||||||
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
||||||
InertWhenNoData: []string{"service_down"},
|
WantPrefixes: []string{ServiceDownPrefix},
|
||||||
Predicate: func(s State) bool {
|
Predicate: func(s State) bool {
|
||||||
f, ok := s.Fact("service_down")
|
var newest time.Time
|
||||||
if !ok || f.Ts.IsZero() {
|
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
||||||
return false
|
if f.Source != ServiceDownSource || f.Value != `"down"` {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Ts.After(newest) {
|
||||||
|
newest = f.Ts
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// value is json `"down"`; trivial check keyed off source provenance.
|
if newest.IsZero() {
|
||||||
return f.Source == "poll:uptimekuma" && f.Value == `"down"`
|
return false // nothing down, or no data at all → shut up
|
||||||
|
}
|
||||||
|
return !s.NudgedSince("service_down", newest)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-15
@@ -193,13 +193,13 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "service_down fires on a kuma down fact",
|
name: "service_down fires on a kuma down fact",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down quiet when kuma says up",
|
name: "service_down quiet when kuma says up",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,38 +211,38 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "service_down quiet on a zero-timestamp fact",
|
name: "service_down quiet on a zero-timestamp fact",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
|
facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
// forgery attempts — right value, wrong writer.
|
// forgery attempts — right value, wrong writer.
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from the netdata poller",
|
name: "service_down refuses a forgery from the netdata poller",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from ambient audio",
|
name: "service_down refuses a forgery from ambient audio",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from the user's own voice",
|
name: "service_down refuses a forgery from the user's own voice",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a source that only looks like kuma",
|
name: "service_down refuses a source that only looks like kuma",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses an unquoted down value",
|
name: "service_down refuses an unquoted down value",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -312,9 +312,11 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
// a second no-data backstop, so a rule that forgets it loses the safety net
|
// a second no-data backstop, so a rule that forgets it loses the safety net
|
||||||
// even if its predicate happens to check.
|
// even if its predicate happens to check.
|
||||||
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
||||||
|
// A rule over a key set that only exists at read time declares a prefix
|
||||||
|
// instead — the gatherer still needs to be told what to load.
|
||||||
for _, r := range DefaultRules() {
|
for _, r := range DefaultRules() {
|
||||||
if len(r.InertWhenNoData) == 0 {
|
if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 {
|
||||||
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
|
t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,11 +379,11 @@ func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
|||||||
// hidden state, no clock reads.
|
// hidden state, no clock reads.
|
||||||
func TestPredicatesArePure(t *testing.T) {
|
func TestPredicatesArePure(t *testing.T) {
|
||||||
s := stateWith(map[string]store.Fact{
|
s := stateWith(map[string]store.Fact{
|
||||||
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
||||||
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||||
})
|
})
|
||||||
for _, r := range DefaultRules() {
|
for _, r := range DefaultRules() {
|
||||||
first := r.Predicate(s)
|
first := r.Predicate(s)
|
||||||
@@ -434,3 +436,66 @@ func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
|
|||||||
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------- per-monitor service_down -----------------------
|
||||||
|
|
||||||
|
// The rule must name what fired on it, and the phraser reads the same helper,
|
||||||
|
// so a service that is up can never be spoken as down.
|
||||||
|
func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
|
||||||
|
s := State{
|
||||||
|
Now: refTime(),
|
||||||
|
Facts: map[string]store.Fact{
|
||||||
|
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute),
|
||||||
|
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute),
|
||||||
|
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute),
|
||||||
|
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute),
|
||||||
|
"service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute),
|
||||||
|
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := DownServices(s)
|
||||||
|
want := []string{"db", "vault"} // key order, so speech is stable
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("DownServices = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("DownServices = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A monitor paused in kuma must silence that monitor. Before per-monitor facts
|
||||||
|
// the aggregate stayed "down" and pausing achieved nothing.
|
||||||
|
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
|
||||||
|
base := map[string]store.Fact{
|
||||||
|
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute),
|
||||||
|
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute),
|
||||||
|
}
|
||||||
|
if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
||||||
|
t.Fatal("web is still down, the rule must fire")
|
||||||
|
}
|
||||||
|
delete(base, "service_down:web")
|
||||||
|
if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
||||||
|
t.Fatal("only a paused monitor is left, the rule must be quiet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge-triggered: he is told once per transition. A service that stays down
|
||||||
|
// for a day used to qualify on every tick, with cooldown as the only brake.
|
||||||
|
func TestServiceDownFiresOncePerTransition(t *testing.T) {
|
||||||
|
down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour)
|
||||||
|
s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}}
|
||||||
|
if !ServiceDownRule().Predicate(s) {
|
||||||
|
t.Fatal("first sight of the transition must fire")
|
||||||
|
}
|
||||||
|
s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}}
|
||||||
|
if ServiceDownRule().Predicate(s) {
|
||||||
|
t.Fatal("already told about this transition, must be quiet")
|
||||||
|
}
|
||||||
|
// A second service goes down after that nudge — a new edge, so it fires.
|
||||||
|
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute)
|
||||||
|
if !ServiceDownRule().Predicate(s) {
|
||||||
|
t.Fatal("a later transition must fire again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
package loop
|
package loop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -95,6 +97,32 @@ func (s State) Fact(key string) (store.Fact, bool) {
|
|||||||
return f, true
|
return f, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FactsUnder returns every gathered fact whose key starts with prefix, ordered
|
||||||
|
// by key so a caller that names them speaks them in a stable order. Facts with
|
||||||
|
// a zero Ts are skipped, the same "no data" rule Fact applies.
|
||||||
|
func (s State) FactsUnder(prefix string) []store.Fact {
|
||||||
|
var out []store.Fact
|
||||||
|
for k, f := range s.Facts {
|
||||||
|
if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NudgedSince reports whether rule already sent a nudge at or after ts.
|
||||||
|
//
|
||||||
|
// It is what makes a rule edge-triggered. A polled fact is written only when
|
||||||
|
// the value changes, so its Ts is the moment the service went down — but the
|
||||||
|
// predicate reads the current value, so a service that stays down keeps
|
||||||
|
// qualifying forever and cooldown alone only slows the repetition. Asking
|
||||||
|
// whether he was already told about THIS transition stops it.
|
||||||
|
func (s State) NudgedSince(rule string, ts time.Time) bool {
|
||||||
|
n, ok := s.LastNudge[rule]
|
||||||
|
return ok && !n.Ts.Before(ts)
|
||||||
|
}
|
||||||
|
|
||||||
// Since returns the duration since the latest fact for key, or (0,false).
|
// Since returns the duration since the latest fact for key, or (0,false).
|
||||||
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
||||||
func (s State) Since(key string) (time.Duration, bool) {
|
func (s State) Since(key string) (time.Duration, bool) {
|
||||||
|
|||||||
+34
-30
@@ -3,11 +3,8 @@ package memory
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
||||||
@@ -340,14 +337,13 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
|||||||
day := weekdayRU[int(wd)%7]
|
day := weekdayRU[int(wd)%7]
|
||||||
acts := p.Weekly[wd]
|
acts := p.Weekly[wd]
|
||||||
if len(acts) > 0 {
|
if len(acts) > 0 {
|
||||||
return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
|
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||||
}
|
}
|
||||||
if len(p.Everyday) > 0 {
|
if len(p.Everyday) > 0 {
|
||||||
return say.S(say.HabitWeekdaySame, map[string]string{
|
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||||
"day": day, "items": joinActivities(p.Everyday),
|
day, joinActivities(p.Everyday))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
|
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
|
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
|
||||||
@@ -359,17 +355,19 @@ func (p Profile) FormatWeekendRU() string {
|
|||||||
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
|
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
|
||||||
switch {
|
switch {
|
||||||
case len(sat) > 0 && len(sun) > 0:
|
case len(sat) > 0 && len(sun) > 0:
|
||||||
return say.S(say.HabitWeekendBoth, map[string]string{
|
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
|
||||||
"items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
|
joinActivities(sat), joinActivities(sun))
|
||||||
})
|
|
||||||
case len(sat) > 0:
|
case len(sat) > 0:
|
||||||
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
|
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
|
||||||
|
joinActivities(sat))
|
||||||
case len(sun) > 0:
|
case len(sun) > 0:
|
||||||
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
|
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
|
||||||
|
joinActivities(sun))
|
||||||
case len(p.Everyday) > 0:
|
case len(p.Everyday) > 0:
|
||||||
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
|
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||||
|
joinActivities(p.Everyday))
|
||||||
}
|
}
|
||||||
return say.S(say.HabitWeekendNone, nil)
|
return "по выходным я пока не вижу у тебя ничего постоянного."
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatOverallRU reads back the habits that hold across the whole week, and
|
// FormatOverallRU reads back the habits that hold across the whole week, and
|
||||||
@@ -380,23 +378,33 @@ func (p Profile) FormatWeekendRU() string {
|
|||||||
// a year of them, and only one of those is worth believing.
|
// a year of them, and only one of those is worth believing.
|
||||||
func (p Profile) FormatOverallRU() string {
|
func (p Profile) FormatOverallRU() string {
|
||||||
if len(p.All) == 0 {
|
if len(p.All) == 0 {
|
||||||
return say.S(say.HabitOverallNone, nil)
|
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||||
}
|
}
|
||||||
return say.S(say.HabitOverall, map[string]string{
|
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU())
|
||||||
"items": joinActivities(p.All), "span": p.spanRU(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
|
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
|
||||||
// is too short to name in days.
|
// is too short to name in days.
|
||||||
func (p Profile) spanRU() string {
|
func (p Profile) spanRU() string {
|
||||||
if p.Since.IsZero() || !p.Until.After(p.Since) {
|
if p.Since.IsZero() || !p.Until.After(p.Since) {
|
||||||
return say.S(say.HabitSpanToday, nil)
|
return "по записям за сегодня"
|
||||||
}
|
}
|
||||||
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
||||||
return say.S(say.HabitSpanDays, map[string]string{
|
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
|
||||||
"n": strconv.Itoa(days), "word": say.Days(days),
|
}
|
||||||
})
|
|
||||||
|
// pluralDaysRU — the Russian count form of "день" for n.
|
||||||
|
func pluralDaysRU(n int) string {
|
||||||
|
switch {
|
||||||
|
case n%100 >= 11 && n%100 <= 14:
|
||||||
|
return "дней"
|
||||||
|
case n%10 == 1:
|
||||||
|
return "день"
|
||||||
|
case n%10 >= 2 && n%10 <= 4:
|
||||||
|
return "дня"
|
||||||
|
default:
|
||||||
|
return "дней"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
||||||
@@ -415,18 +423,14 @@ func joinActivities(acts []Activity) string {
|
|||||||
// come from the model, so an unglossed one is as likely to be
|
// come from the model, so an unglossed one is as likely to be
|
||||||
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
|
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
|
||||||
// not a sentence.
|
// not a sentence.
|
||||||
gloss = say.S(say.HabitUnglossed, map[string]string{
|
gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
|
||||||
"key": strings.ReplaceAll(a.Key, "_", " "),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if !a.HasTypical {
|
if !a.HasTypical {
|
||||||
parts[i] = gloss
|
parts[i] = gloss
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts[i] = say.S(say.HabitAt, map[string]string{
|
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||||
"gloss": gloss,
|
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||||
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if len(parts) == 1 {
|
if len(parts) == 1 {
|
||||||
return parts[0]
|
return parts[0]
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// habitHistory — n weeks of the same weekday, at the given local time.
|
// habitHistory — n weeks of the same weekday, at the given local time.
|
||||||
@@ -72,7 +70,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
|
|||||||
if len(p.All) != 0 || len(p.Weekly) != 0 {
|
if len(p.All) != 0 || len(p.Weekly) != 0 {
|
||||||
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
|
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
|
||||||
}
|
}
|
||||||
if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
|
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
|
||||||
t.Errorf("empty profile reads %q", got)
|
t.Errorf("empty profile reads %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +124,7 @@ func TestProfileFormatRUPersona(t *testing.T) {
|
|||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q\nwant %q", got, want)
|
t.Errorf("got %q\nwant %q", got, want)
|
||||||
}
|
}
|
||||||
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") {
|
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
|
||||||
t.Errorf("an unknown weekday reads %q", empty)
|
t.Errorf("an unknown weekday reads %q", empty)
|
||||||
}
|
}
|
||||||
// Persona: she addresses him informally, never in the masculine about
|
// Persona: she addresses him informally, never in the masculine about
|
||||||
@@ -205,9 +203,7 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
|
|||||||
// A day with nothing of its own says so rather than reciting water as if
|
// A day with nothing of its own says so rather than reciting water as if
|
||||||
// Wednesday were the reason for it.
|
// Wednesday were the reason for it.
|
||||||
wed := p.FormatWeekdayRU(time.Wednesday)
|
wed := p.FormatWeekdayRU(time.Wednesday)
|
||||||
if !say.IsS(say.HabitWeekdaySame, map[string]string{
|
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
|
||||||
"day": "средам", "items": "пьёшь воду около 13:30",
|
|
||||||
}, wed) {
|
|
||||||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,19 +313,16 @@ func TestOverallNamesThePeriod(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The no-data weekday answer is about his week, not about her. "у меня пока нет
|
// The no-data weekday answer is about him, not about her. "у меня пока нет
|
||||||
// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
|
// ничего постоянного" answers a question nobody asked.
|
||||||
// because what she is reporting is the state of her records — and it names the
|
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
|
||||||
// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя"
|
|
||||||
// came out as filler, the day carries it).
|
|
||||||
func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) {
|
|
||||||
p := BuildProfile(nil, behaviorNow())
|
p := BuildProfile(nil, behaviorNow())
|
||||||
got := p.FormatWeekdayRU(time.Wednesday)
|
got := p.FormatWeekdayRU(time.Wednesday)
|
||||||
if strings.Contains(got, "у меня") {
|
if strings.Contains(got, "у меня") {
|
||||||
t.Errorf("got %q", got)
|
t.Errorf("got %q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "средам") {
|
if !strings.Contains(got, "у тебя") {
|
||||||
t.Errorf("got %q, want the day he asked about", got)
|
t.Errorf("got %q, want an answer about him", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +340,17 @@ func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPluralDaysRU(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
n int
|
||||||
|
want string
|
||||||
|
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
|
||||||
|
if got := pluralDaysRU(c.n); got != c.want {
|
||||||
|
t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// "по выходным" is a question about two days, answered as two days.
|
// "по выходным" is a question about two days, answered as two days.
|
||||||
func TestFormatWeekendRU(t *testing.T) {
|
func TestFormatWeekendRU(t *testing.T) {
|
||||||
now := behaviorNow()
|
now := behaviorNow()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,20 +164,17 @@ func (p Plan) FormatRU() string {
|
|||||||
// it is over, and saying it was empty is a false statement about a day
|
// it is over, and saying it was empty is a false statement about a day
|
||||||
// he just lived.
|
// he just lived.
|
||||||
if p.Rest {
|
if p.Rest {
|
||||||
return say.S(say.PlanRestEmpty, nil)
|
return "на сегодня больше ничего не запланировано."
|
||||||
}
|
}
|
||||||
return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")})
|
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
|
||||||
}
|
}
|
||||||
parts := make([]string, len(p.Items))
|
parts := make([]string, len(p.Items))
|
||||||
for i, it := range p.Items {
|
for i, it := range p.Items {
|
||||||
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
||||||
if it.Uncertain {
|
if it.Uncertain {
|
||||||
line = say.S(say.PlanUncertain, map[string]string{"line": line})
|
line = "похоже, " + line
|
||||||
}
|
}
|
||||||
parts[i] = line
|
parts[i] = line
|
||||||
}
|
}
|
||||||
return say.S(say.PlanDay, map[string]string{
|
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
|
||||||
"date": p.Date.Format("02.01.2006"),
|
|
||||||
"items": strings.Join(parts, "; "),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -138,7 +137,7 @@ func TestPlanFormatRU(t *testing.T) {
|
|||||||
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||||
"10:30 — утро — осталось: витамины; " +
|
"10:30 — утро — осталось: витамины; " +
|
||||||
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
||||||
"18:30 — позвонить маме"
|
"18:30 — позвонить маме."
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q\nwant %q", got, want)
|
t.Errorf("got %q\nwant %q", got, want)
|
||||||
}
|
}
|
||||||
@@ -216,26 +215,3 @@ func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) {
|
|||||||
t.Fatalf("got %+v", p.Items)
|
t.Fatalf("got %+v", p.Items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// plan_uncertain nests one rendered line inside another sentence: «похоже, » in
|
|
||||||
// front of what this loop already built. That reads as one sentence only while
|
|
||||||
// what arrives starts lowercase, and it does here because every line starts with
|
|
||||||
// the clock time. A capital after the hedge would be «похоже, Планёрка».
|
|
||||||
func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) {
|
|
||||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
|
||||||
p := Plan{Date: now, Items: []PlanEntry{
|
|
||||||
{At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true},
|
|
||||||
}}
|
|
||||||
got := p.FormatRU()
|
|
||||||
const hedge = "похоже, "
|
|
||||||
i := strings.Index(got, hedge)
|
|
||||||
if i < 0 {
|
|
||||||
t.Fatalf("%q does not hedge an uncertain item", got)
|
|
||||||
}
|
|
||||||
for _, r := range got[i+len(hedge):] {
|
|
||||||
if unicode.IsUpper(r) {
|
|
||||||
t.Fatalf("the hedge runs into a capital: %q", got)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"name": "russian capture acknowledgements v1",
|
|
||||||
"notes": [
|
|
||||||
"What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.",
|
|
||||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
|
||||||
"He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
|
|
||||||
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
|
|
||||||
"An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
|
|
||||||
],
|
|
||||||
"entries": {
|
|
||||||
"ack_fact": {
|
|
||||||
"variants": ["записала факт.", "записала.", "запомнила."]
|
|
||||||
},
|
|
||||||
"ack_fact_key": {
|
|
||||||
"variants": ["отметила: {key}", "записала: {key}", "запомнила: {key}"]
|
|
||||||
},
|
|
||||||
"ack_fact_kv": {
|
|
||||||
"variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
|
|
||||||
},
|
|
||||||
"ack_note": {
|
|
||||||
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
|
|
||||||
},
|
|
||||||
"ack_reminder": {
|
|
||||||
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
|
|
||||||
},
|
|
||||||
"ack_act": {
|
|
||||||
"variants": ["ок, записала действие: {fn}", "приняла действие: {fn}"]
|
|
||||||
},
|
|
||||||
"ack_task": {
|
|
||||||
"variants": ["записала: {text}", "добавила в задачи: {text}", "внесла в список: {text}"]
|
|
||||||
},
|
|
||||||
"ack_task_urgent": {
|
|
||||||
"variants": ["поняла, беру в работу: {text}", "поняла, это срочно: {text}"]
|
|
||||||
},
|
|
||||||
"ack_task_duplicate": {
|
|
||||||
"variants": ["это уже в списке.", "такое уже есть в задачах."]
|
|
||||||
},
|
|
||||||
"ack_nudge": {
|
|
||||||
"variants": ["отлично, отметила.", "отметила.", "хорошо, отметила."]
|
|
||||||
},
|
|
||||||
"ack_snooze": {
|
|
||||||
"variants": ["хорошо, вернусь к этому позже.", "ладно, напомню попозже.", "хорошо, отложила."]
|
|
||||||
},
|
|
||||||
"ack_generic": {
|
|
||||||
"variants": ["приняла.", "поняла."]
|
|
||||||
},
|
|
||||||
"quiet_on": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["тихий режим включён. буду реже напоминать."]
|
|
||||||
},
|
|
||||||
"quiet_off": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["тихий режим выключен."]
|
|
||||||
},
|
|
||||||
"fail_fact": {
|
|
||||||
"variants": ["не получилось сохранить факт.", "факт не сохранился."]
|
|
||||||
},
|
|
||||||
"fail_note": {
|
|
||||||
"variants": ["не получилось сохранить заметку.", "заметка не сохранилась."]
|
|
||||||
},
|
|
||||||
"fail_reminder": {
|
|
||||||
"variants": ["не получилось поставить напоминание.", "напоминание не поставилось."]
|
|
||||||
},
|
|
||||||
"fail_reminder_time": {
|
|
||||||
"variants": ["не получилось разобрать время напоминания.", "не поняла, на когда напомнить."]
|
|
||||||
},
|
|
||||||
"fail_task": {
|
|
||||||
"variants": ["не получилось записать задачу.", "задача не записалась."]
|
|
||||||
},
|
|
||||||
"fail_ack": {
|
|
||||||
"variants": ["не получилось отметить.", "не смогла отметить."]
|
|
||||||
},
|
|
||||||
"fail_snooze": {
|
|
||||||
"variants": ["не получилось отложить.", "не смогла отложить."]
|
|
||||||
},
|
|
||||||
"fail_quiet": {
|
|
||||||
"variants": ["не получилось переключить тихий режим.", "тихий режим не переключился."]
|
|
||||||
},
|
|
||||||
"fail_fact_unparsed": {
|
|
||||||
"variants": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
package phraser
|
|
||||||
|
|
||||||
// The capture acknowledgements — what she says after storing something he said,
|
|
||||||
// and what she says when storing it failed.
|
|
||||||
//
|
|
||||||
// They were string literals in eight files under cmd/mavend plus the stub
|
|
||||||
// replier in internal/voice. He hears them many times a day, which is exactly
|
|
||||||
// why they need variants and exactly why rewording one must not be a rebuild.
|
|
||||||
// Same shape as fallbacks_ru_v1.json, on the shared deck (deck.go).
|
|
||||||
//
|
|
||||||
// His data stays Go-side. The file holds "отметила: {key} = {value}"; the key
|
|
||||||
// and the value are interpolated here, so nothing he said lives in the copy.
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed ack_ru_v1.json
|
|
||||||
var ackJSON []byte
|
|
||||||
|
|
||||||
// AckSchemaVersion — this family's own version. A file that changes on a
|
|
||||||
// different day than the fallbacks cannot share their number (Vikunja #397).
|
|
||||||
const AckSchemaVersion = 1
|
|
||||||
|
|
||||||
// The entry keys. Namespaced by family, because the floor map behind a nil deck
|
|
||||||
// is process-wide.
|
|
||||||
const (
|
|
||||||
AckFact = "ack_fact"
|
|
||||||
AckFactKey = "ack_fact_key"
|
|
||||||
AckFactValue = "ack_fact_kv"
|
|
||||||
AckNote = "ack_note"
|
|
||||||
AckReminder = "ack_reminder"
|
|
||||||
AckAct = "ack_act"
|
|
||||||
AckTask = "ack_task"
|
|
||||||
AckTaskUrgent = "ack_task_urgent"
|
|
||||||
AckTaskDuplicate = "ack_task_duplicate"
|
|
||||||
AckNudge = "ack_nudge"
|
|
||||||
AckSnooze = "ack_snooze"
|
|
||||||
AckGeneric = "ack_generic"
|
|
||||||
AckQuietOn = "quiet_on"
|
|
||||||
AckQuietOff = "quiet_off"
|
|
||||||
|
|
||||||
FailFact = "fail_fact"
|
|
||||||
FailFactUnparsed = "fail_fact_unparsed"
|
|
||||||
FailNote = "fail_note"
|
|
||||||
FailReminder = "fail_reminder"
|
|
||||||
FailReminderTime = "fail_reminder_time"
|
|
||||||
FailTask = "fail_task"
|
|
||||||
FailAck = "fail_ack"
|
|
||||||
FailSnooze = "fail_snooze"
|
|
||||||
FailQuiet = "fail_quiet"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ackKeys — every key the code requires the file to define.
|
|
||||||
var ackKeys = []string{
|
|
||||||
AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
|
|
||||||
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
|
|
||||||
AckQuietOn, AckQuietOff,
|
|
||||||
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
|
|
||||||
FailTask, FailAck, FailSnooze, FailQuiet,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ackFloor — the literal each key falls back to when the file is unusable.
|
|
||||||
// These are the exact strings that lived in Go before this file existed.
|
|
||||||
var ackFloor = map[string]string{
|
|
||||||
AckFact: "записала факт.",
|
|
||||||
AckFactKey: "отметила: {key}",
|
|
||||||
AckFactValue: "отметила: {key} = {value}",
|
|
||||||
AckNote: "сохранила заметку.",
|
|
||||||
AckReminder: "напомню.",
|
|
||||||
AckAct: "ок, записала действие: {fn}",
|
|
||||||
AckTask: "записала: {text}",
|
|
||||||
AckTaskUrgent: "поняла, беру в работу: {text}",
|
|
||||||
AckTaskDuplicate: "это уже в списке.",
|
|
||||||
AckNudge: "отлично, отметила.",
|
|
||||||
AckSnooze: "хорошо, вернусь к этому позже.",
|
|
||||||
AckGeneric: "приняла.",
|
|
||||||
AckQuietOn: "тихий режим включён. буду реже напоминать.",
|
|
||||||
AckQuietOff: "тихий режим выключен.",
|
|
||||||
|
|
||||||
FailFact: "не получилось сохранить факт.",
|
|
||||||
FailFactUnparsed: "не разобрала, что записать — попробуй иначе.",
|
|
||||||
FailNote: "не получилось сохранить заметку.",
|
|
||||||
FailReminder: "не получилось поставить напоминание.",
|
|
||||||
FailReminderTime: "не получилось разобрать время напоминания.",
|
|
||||||
FailTask: "не получилось записать задачу.",
|
|
||||||
FailAck: "не получилось отметить.",
|
|
||||||
FailSnooze: "не получилось отложить.",
|
|
||||||
FailQuiet: "не получилось переключить тихий режим.",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
|
|
||||||
type Acks struct{ d *say.Deck }
|
|
||||||
|
|
||||||
// LoadAcks reads the embedded file. Pass a source to make the picking
|
|
||||||
// reproducible in tests; nil seeds from the clock.
|
|
||||||
func LoadAcks(src rand.Source) (*Acks, error) {
|
|
||||||
d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// The three entries that exist to read his own words back. A variant
|
|
||||||
// without the placeholder would confirm the capture and drop what was
|
|
||||||
// captured, which reads as a successful save of nothing.
|
|
||||||
for _, req := range []struct{ key, ph string }{
|
|
||||||
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
|
|
||||||
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
|
|
||||||
} {
|
|
||||||
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &Acks{d: d}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deck reads through a nil *Acks, which is the unloadable-file case.
|
|
||||||
func (a *Acks) deck() *say.Deck {
|
|
||||||
if a == nil {
|
|
||||||
return say.FloorDeck(ackFloor)
|
|
||||||
}
|
|
||||||
return a.d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Say returns one line for key, with his data filled into the frame. Pass nil
|
|
||||||
// when the entry takes none.
|
|
||||||
func (a *Acks) Say(key string, vars map[string]string) string {
|
|
||||||
return a.deck().Text(key, vars)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variants returns every line the file can produce, for the persona scorer.
|
|
||||||
func (a *Acks) Variants() []string { return a.deck().Variants() }
|
|
||||||
|
|
||||||
var (
|
|
||||||
ackOnce sync.Once
|
|
||||||
acks *Acks
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultAcks returns the shared instance, loading it on first use. A broken
|
|
||||||
// file logs once and leaves a nil *Acks, which still answers from ackFloor.
|
|
||||||
func DefaultAcks() *Acks {
|
|
||||||
ackOnce.Do(func() {
|
|
||||||
a, err := LoadAcks(nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("phraser: acknowledgements unavailable, using the built-in lines: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
acks = a
|
|
||||||
})
|
|
||||||
return acks
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ack — one acknowledgement line, the way every caller says it.
|
|
||||||
func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
|
|
||||||
|
|
||||||
// IsAck reports whether text is a line key could have produced. For the daemon
|
|
||||||
// tests, which can no longer compare against one literal.
|
|
||||||
func IsAck(key string, vars map[string]string, text string) bool {
|
|
||||||
return DefaultAcks().deck().Matches(key, vars, text)
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package phraser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func loadTestActs(t *testing.T) *Acts {
|
|
||||||
t.Helper()
|
|
||||||
a, err := LoadActs(rand.NewSource(1))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadActs: %v", err)
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// She talks about a lamp or a server, never about a row in a schema. «сущность»
|
|
||||||
// and «экосистема» are the same defect as saying a capability id out loud.
|
|
||||||
func TestNoActLineSaysASchemaWord(t *testing.T) {
|
|
||||||
a := loadTestActs(t)
|
|
||||||
for _, v := range a.Variants() {
|
|
||||||
for _, word := range []string{"сущност", "экосистем"} {
|
|
||||||
if strings.Contains(v, word) {
|
|
||||||
t.Errorf("variant %q says %q out loud", v, word)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject
|
|
||||||
// is not an answer he can act on.
|
|
||||||
func TestAServiceFailureNamesTheService(t *testing.T) {
|
|
||||||
a := loadTestActs(t)
|
|
||||||
for _, key := range []string{EcoDown, EcoDenied} {
|
|
||||||
got := a.Say(key, map[string]string{"name": "Praxis"})
|
|
||||||
if !strings.HasPrefix(got, "Praxis ") {
|
|
||||||
t.Errorf("%s = %q, want it to name the service", key, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A confirmation prompt for a destructive act is the worst place for an unfilled
|
|
||||||
// placeholder, so the two names it interpolates are distinct keys and both are
|
|
||||||
// declared.
|
|
||||||
func TestConfirmEntityFillsBothNames(t *testing.T) {
|
|
||||||
a := loadTestActs(t)
|
|
||||||
got := a.Say(ActConfirmEntity, map[string]string{
|
|
||||||
"name": "restart", "name_entity": "Muzick indexer",
|
|
||||||
})
|
|
||||||
if strings.ContainsAny(got, "{}") {
|
|
||||||
t.Fatalf("act_confirm_entity = %q, want no placeholder left", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") {
|
|
||||||
t.Fatalf("act_confirm_entity = %q, want both names", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// home_dark counts unreachable devices, and Russian inflects the noun after the
|
|
||||||
// number: the count goes in {count} and the noun comes from the helper.
|
|
||||||
func TestHomeDarkCountsWithTheHelper(t *testing.T) {
|
|
||||||
a := loadTestActs(t)
|
|
||||||
for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} {
|
|
||||||
got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)})
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("home_dark for %d = %q, want %q in it", n, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Four truths, four entries: a failure must not be able to report itself as a
|
|
||||||
// success, and an empty result must not read as a failure.
|
|
||||||
func TestActOutcomesStayDistinct(t *testing.T) {
|
|
||||||
a := loadTestActs(t)
|
|
||||||
seen := map[string]string{}
|
|
||||||
for _, key := range actKeys {
|
|
||||||
for _, v := range a.d.VariantsOf(key) {
|
|
||||||
if prev, dup := seen[v]; dup {
|
|
||||||
t.Errorf("%s and %s both say %q", prev, key, v)
|
|
||||||
}
|
|
||||||
seen[v] = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
package phraser
|
|
||||||
|
|
||||||
// The act and smart-home replies — what she says when a capability ran, refused,
|
|
||||||
// or could not be reached.
|
|
||||||
//
|
|
||||||
// Fourth family on the shared deck (deck.go). They were literals in
|
|
||||||
// ecosystem_acts.go, actions_act.go and smarthome.go, where a reworded line was
|
|
||||||
// a rebuild of the daemon that executes his house.
|
|
||||||
//
|
|
||||||
// The four outcomes stay four entries. Reporting a refusal with the wording of
|
|
||||||
// a success is the one failure mode this family can have, and a shared variant
|
|
||||||
// set is how it would happen.
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed acts_ru_v1.json
|
|
||||||
var actJSON []byte
|
|
||||||
|
|
||||||
// ActSchemaVersion — this family's own version.
|
|
||||||
const ActSchemaVersion = 1
|
|
||||||
|
|
||||||
// The entry keys.
|
|
||||||
const (
|
|
||||||
ActDone = "act_done"
|
|
||||||
ActDoneOut = "act_done_out"
|
|
||||||
ActDoneEntity = "act_done_entity"
|
|
||||||
ActConfirm = "act_confirm"
|
|
||||||
ActConfirmEntity = "act_confirm_entity"
|
|
||||||
ActWhich = "act_which"
|
|
||||||
ActFail = "act_fail"
|
|
||||||
ActFailOut = "act_fail_out"
|
|
||||||
ActFailEntity = "act_fail_entity"
|
|
||||||
ActServerDown = "act_server_down"
|
|
||||||
ActWithdrawn = "act_withdrawn"
|
|
||||||
ActNeedsArgs = "act_needs_args"
|
|
||||||
// ActNeedsAuthedSurface — the irreversible tier, local row or Hexis
|
|
||||||
// capability alike. Not a failure and not a refusal to help: a spoken "да"
|
|
||||||
// is the only authority the voice path can offer, and this is the one act
|
|
||||||
// it is not enough for (Vikunja #449, #523).
|
|
||||||
ActNeedsAuthedSurface = "act_needs_authed_surface"
|
|
||||||
|
|
||||||
EcoDenied = "eco_denied"
|
|
||||||
EcoDown = "eco_down"
|
|
||||||
EcoAmbiguous = "eco_ambiguous"
|
|
||||||
EcoUnknownEntity = "eco_unknown_entity"
|
|
||||||
EcoNoNexus = "eco_no_nexus"
|
|
||||||
EcoAboutWhat = "eco_about_what"
|
|
||||||
EcoRecall = "eco_recall"
|
|
||||||
|
|
||||||
AttentionNone = "attention_none"
|
|
||||||
AttentionList = "attention_list"
|
|
||||||
AttentionFail = "attention_fail"
|
|
||||||
AttentionNoneEntity = "attention_none_entity"
|
|
||||||
AttentionListEntity = "attention_list_entity"
|
|
||||||
AttentionFailEntity = "attention_fail_entity"
|
|
||||||
ChangesNone = "changes_none"
|
|
||||||
ChangesList = "changes_list"
|
|
||||||
ChangesFail = "changes_fail"
|
|
||||||
HomeUnreachable = "home_unreachable"
|
|
||||||
HomeEmpty = "home_empty"
|
|
||||||
HomeOn = "home_on"
|
|
||||||
HomeDark = "home_dark"
|
|
||||||
)
|
|
||||||
|
|
||||||
var actKeys = []string{
|
|
||||||
ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
|
|
||||||
ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
|
|
||||||
ActNeedsAuthedSurface,
|
|
||||||
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
|
|
||||||
AttentionNone, AttentionList, AttentionFail,
|
|
||||||
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
|
|
||||||
ChangesNone, ChangesList, ChangesFail,
|
|
||||||
HomeUnreachable, HomeEmpty, HomeOn, HomeDark,
|
|
||||||
}
|
|
||||||
|
|
||||||
// actFloor — the literal each key falls back to when the file is unusable. It
|
|
||||||
// started as the exact strings that lived in Go before this file existed and now
|
|
||||||
// tracks the file's first variant instead, because a floor that keeps the
|
|
||||||
// wording review threw out would say it back on the one turn nobody is watching.
|
|
||||||
var actFloor = map[string]string{
|
|
||||||
ActDone: "готово.",
|
|
||||||
ActDoneOut: "готово: {out}",
|
|
||||||
ActDoneEntity: "готово: {name}.",
|
|
||||||
ActConfirm: "выполнить «{name}»? да или нет.",
|
|
||||||
ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.",
|
|
||||||
ActWhich: "какую команду для {name}: {items}?",
|
|
||||||
ActFail: "не получилось выполнить команду.",
|
|
||||||
ActFailOut: "не получилось выполнить команду: {out}",
|
|
||||||
ActFailEntity: "не получилось выполнить команду для {name}.",
|
|
||||||
ActServerDown: "инструмент есть, но сервер не подключён.",
|
|
||||||
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
|
|
||||||
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
|
|
||||||
ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.",
|
|
||||||
|
|
||||||
EcoDenied: "{name} отклоняет доступ, проверь токен.",
|
|
||||||
EcoDown: "{name} не отвечает, попробуй ещё раз.",
|
|
||||||
EcoAmbiguous: "что именно: {items}?",
|
|
||||||
EcoUnknownEntity: "не знаю, что это.",
|
|
||||||
EcoNoNexus: "не с чем связать — Nexus не настроен.",
|
|
||||||
EcoAboutWhat: "про что именно?",
|
|
||||||
EcoRecall: "я помню: {items}",
|
|
||||||
|
|
||||||
AttentionNone: "ничего не требует внимания.",
|
|
||||||
AttentionList: "требует внимания: {items}",
|
|
||||||
AttentionFail: "не могу сейчас узнать, что требует внимания.",
|
|
||||||
AttentionNoneEntity: "по «{name}» ничего нет.",
|
|
||||||
AttentionListEntity: "по «{name}»: {items}",
|
|
||||||
AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».",
|
|
||||||
ChangesNone: "изменений нет.",
|
|
||||||
ChangesList: "изменения: {items}",
|
|
||||||
ChangesFail: "не могу сейчас узнать об изменениях.",
|
|
||||||
HomeUnreachable: "дом не отвечает.",
|
|
||||||
HomeEmpty: "дом ничего не отдаёт.",
|
|
||||||
HomeOn: "включено: {items}",
|
|
||||||
HomeDark: "не отвечают: {count} {word}.",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acts picks a hand-written Russian act reply. Safe for concurrent use.
|
|
||||||
type Acts struct{ d *say.Deck }
|
|
||||||
|
|
||||||
// LoadActs reads the embedded file. Pass a source to make the picking
|
|
||||||
// reproducible in tests; nil seeds from the clock.
|
|
||||||
func LoadActs(src rand.Source) (*Acts, error) {
|
|
||||||
d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// The entries that name what ran or what he has to choose between. A
|
|
||||||
// variant that dropped the name would confirm an act without saying which.
|
|
||||||
for _, req := range []struct{ key, ph string }{
|
|
||||||
{ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"},
|
|
||||||
{ActFailEntity, "{name}"}, {ActConfirm, "{name}"},
|
|
||||||
{ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_entity}"},
|
|
||||||
{ActWhich, "{name}"}, {ActWhich, "{items}"},
|
|
||||||
{EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"},
|
|
||||||
{AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"},
|
|
||||||
{EcoDenied, "{name}"}, {EcoDown, "{name}"},
|
|
||||||
{HomeDark, "{count}"}, {HomeDark, "{word}"},
|
|
||||||
{AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"},
|
|
||||||
{AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"},
|
|
||||||
} {
|
|
||||||
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &Acts{d: d}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deck reads through a nil *Acts, which is the unloadable-file case.
|
|
||||||
func (a *Acts) deck() *say.Deck {
|
|
||||||
if a == nil {
|
|
||||||
return say.FloorDeck(actFloor)
|
|
||||||
}
|
|
||||||
return a.d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Say returns one line for key, with the names filled into the frame.
|
|
||||||
func (a *Acts) Say(key string, vars map[string]string) string {
|
|
||||||
return a.deck().Text(key, vars)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variants returns every line the file can produce, for the persona scorer.
|
|
||||||
func (a *Acts) Variants() []string { return a.deck().Variants() }
|
|
||||||
|
|
||||||
var (
|
|
||||||
actOnce sync.Once
|
|
||||||
actsDeck *Acts
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultActs returns the shared instance, loading it on first use. A broken
|
|
||||||
// file logs once and leaves a nil *Acts, which still answers from actFloor.
|
|
||||||
func DefaultActs() *Acts {
|
|
||||||
actOnce.Do(func() {
|
|
||||||
a, err := LoadActs(nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("phraser: act replies unavailable, using the built-in lines: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
actsDeck = a
|
|
||||||
})
|
|
||||||
return actsDeck
|
|
||||||
}
|
|
||||||
|
|
||||||
// A — one act reply, the way every caller says it.
|
|
||||||
func A(key string, vars map[string]string) string { return DefaultActs().Say(key, vars) }
|
|
||||||
|
|
||||||
// IsA reports whether text is a line key could have produced, for the tests.
|
|
||||||
func IsA(key string, vars map[string]string, text string) bool {
|
|
||||||
return DefaultActs().deck().Matches(key, vars, text)
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"name": "russian act and smart-home replies v1",
|
|
||||||
"notes": [
|
|
||||||
"What she says when a capability ran, refused, or could not be reached. Edit the wording here, no Go changes needed.",
|
|
||||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
|
||||||
"\"it ran\", \"it was refused\", \"a service is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.",
|
|
||||||
"She says what he would say. No schema words out loud: not «сущность», not «экосистема», not a capability id, not a config key. She is talking about a lamp or a server.",
|
|
||||||
"A service that is down or refusing is named. \"не отвечает\" with no subject tells him nothing he can act on, and Nexus, Praxis and Hexis fail independently.",
|
|
||||||
"Placeholders: {name} an entity or capability the caller resolved, {name_entity} the entity an act runs against when {name} is already the capability, {out} the command's own output, {items} a joined list, {count} a number, {word} the counted noun in the form {count} needs. Entity names and capability ids are interpolated Go-side.",
|
|
||||||
"A count never carries a hardcoded noun. Russian inflects it — 1 устройство, 2 устройства, 5 устройств — so the number goes in {count} and the noun comes from the Go helper through {word}.",
|
|
||||||
"An entry that only exists to read a list back must never be reached with an empty list. The caller routes an empty list to the matching _none entry, because a single-variant placeholder-only line has no shorter wording to fall back to.",
|
|
||||||
"fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test."
|
|
||||||
],
|
|
||||||
"entries": {
|
|
||||||
"act_done": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["готово."]
|
|
||||||
},
|
|
||||||
"act_done_out": {
|
|
||||||
"variants": ["готово: {out}", "сделала: {out}"]
|
|
||||||
},
|
|
||||||
"act_done_entity": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["готово: {name}."]
|
|
||||||
},
|
|
||||||
"act_confirm": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["выполнить «{name}»? да или нет."]
|
|
||||||
},
|
|
||||||
"act_confirm_entity": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["выполнить «{name}» для {name_entity}? да или нет."]
|
|
||||||
},
|
|
||||||
"act_which": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["какую команду для {name}: {items}?"]
|
|
||||||
},
|
|
||||||
"act_fail": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["не получилось выполнить команду."]
|
|
||||||
},
|
|
||||||
"act_fail_out": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["не получилось выполнить команду: {out}"]
|
|
||||||
},
|
|
||||||
"act_fail_entity": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["не получилось выполнить команду для {name}."]
|
|
||||||
},
|
|
||||||
"act_server_down": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["инструмент есть, но сервер не подключён."]
|
|
||||||
},
|
|
||||||
"act_withdrawn": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
|
|
||||||
},
|
|
||||||
"act_needs_args": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
|
||||||
},
|
|
||||||
"act_needs_authed_surface": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."]
|
|
||||||
},
|
|
||||||
"eco_denied": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["{name} отклоняет доступ, проверь токен."]
|
|
||||||
},
|
|
||||||
"eco_down": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["{name} не отвечает, попробуй ещё раз."]
|
|
||||||
},
|
|
||||||
"eco_ambiguous": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["что именно: {items}?"]
|
|
||||||
},
|
|
||||||
"eco_unknown_entity": {
|
|
||||||
"variants": ["не знаю, что это.", "такого у меня нет."]
|
|
||||||
},
|
|
||||||
"eco_no_nexus": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["не с чем связать — Nexus не настроен."]
|
|
||||||
},
|
|
||||||
"eco_about_what": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["про что именно?"]
|
|
||||||
},
|
|
||||||
"eco_recall": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["я помню: {items}"]
|
|
||||||
},
|
|
||||||
"attention_none": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["ничего не требует внимания."]
|
|
||||||
},
|
|
||||||
"attention_list": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["требует внимания: {items}"]
|
|
||||||
},
|
|
||||||
"attention_fail": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["не могу сейчас узнать, что требует внимания."]
|
|
||||||
},
|
|
||||||
"attention_none_entity": {
|
|
||||||
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
|
|
||||||
},
|
|
||||||
"attention_list_entity": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["по «{name}»: {items}"]
|
|
||||||
},
|
|
||||||
"attention_fail_entity": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
|
|
||||||
},
|
|
||||||
"changes_none": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["изменений нет."]
|
|
||||||
},
|
|
||||||
"changes_list": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["изменения: {items}"]
|
|
||||||
},
|
|
||||||
"changes_fail": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["не могу сейчас узнать об изменениях."]
|
|
||||||
},
|
|
||||||
"home_unreachable": {
|
|
||||||
"variants": ["дом не отвечает.", "не достучалась до дома."]
|
|
||||||
},
|
|
||||||
"home_empty": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["дом ничего не отдаёт."]
|
|
||||||
},
|
|
||||||
"home_on": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["включено: {items}"]
|
|
||||||
},
|
|
||||||
"home_dark": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": ["не отвечают: {count} {word}."]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -67,19 +67,6 @@ func RunChecks(c Case, body, mood string) []Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feminine, HisGender and Address expose three checks one at a time, so the
|
|
||||||
// daemon can run them on a phrased message before he hears it (Vikunja #399).
|
|
||||||
// Only these three: they are unambiguous string tests with nothing to compare
|
|
||||||
// against, while length is path-specific and ontopic needs the fixture's
|
|
||||||
// expected fragments, which do not exist at runtime.
|
|
||||||
func Feminine(body string) Result { return checkFeminine(body) }
|
|
||||||
|
|
||||||
// HisGender — see checkHisGender.
|
|
||||||
func HisGender(body string) Result { return checkHisGender(body) }
|
|
||||||
|
|
||||||
// Address — see checkAddress.
|
|
||||||
func Address(body string) Result { return checkAddress(body) }
|
|
||||||
|
|
||||||
func checkMood(mood string) Result {
|
func checkMood(mood string) Result {
|
||||||
if Moods[mood] {
|
if Moods[mood] {
|
||||||
return Result{CheckMood, true, ""}
|
return Result{CheckMood, true, ""}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package eval
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestFallbackPersona scores every line in every hand-written family on the
|
|
||||||
// persona checks the nudges already pass. These lines are heard out loud and
|
|
||||||
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
|
|
||||||
// would otherwise reach him with nothing in between.
|
|
||||||
//
|
|
||||||
// Only the persona checks run. Mood and topic belong to a nudge, and these are
|
|
||||||
// not nudges.
|
|
||||||
func TestFallbackPersona(t *testing.T) {
|
|
||||||
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadFallbacks: %v", err)
|
|
||||||
}
|
|
||||||
// No CheckHisGender. It reads a feminine verb near a second-person pronoun
|
|
||||||
// as addressing him as a woman, which is right for a nudge and wrong here:
|
|
||||||
// "не знаю — не нашла у тебя такой записи" is her own verb in her own
|
|
||||||
// sentence. CheckFeminine still holds her side of the rule.
|
|
||||||
persona := map[string]bool{
|
|
||||||
CheckLang: true, CheckFeminine: true,
|
|
||||||
CheckAddress: true, CheckCringe: true, CheckLength: true,
|
|
||||||
}
|
|
||||||
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadAcks: %v", err)
|
|
||||||
}
|
|
||||||
qry, err := phraser.LoadQueries(rand.NewSource(20260804))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadQueries: %v", err)
|
|
||||||
}
|
|
||||||
variants := append(fb.Variants(), ack.Variants()...)
|
|
||||||
variants = append(variants, qry.Variants()...)
|
|
||||||
act, err := phraser.LoadActs(rand.NewSource(20260804))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadActs: %v", err)
|
|
||||||
}
|
|
||||||
variants = append(variants, act.Variants()...)
|
|
||||||
sum, err := say.LoadSummaries(rand.NewSource(20260804))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadSummaries: %v", err)
|
|
||||||
}
|
|
||||||
variants = append(variants, sum.Variants()...)
|
|
||||||
if len(variants) == 0 {
|
|
||||||
t.Fatal("no variants — the file loaded empty")
|
|
||||||
}
|
|
||||||
for _, v := range variants {
|
|
||||||
// The placeholders stand for his own words and carry no persona.
|
|
||||||
body := v
|
|
||||||
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
|
|
||||||
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
|
|
||||||
"{entity}", "{count}", "{word}",
|
|
||||||
"{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} {
|
|
||||||
body = strings.ReplaceAll(body, ph, "вода")
|
|
||||||
}
|
|
||||||
for _, r := range RunChecks(Case{}, body, "neutral") {
|
|
||||||
if persona[r.Name] && !r.Pass {
|
|
||||||
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -78,12 +78,6 @@ type TalkCase struct {
|
|||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TalkSchemaVersion — the version this loader understands. Separate from the
|
|
||||||
// nudge fixture's SchemaVersion: the two fixtures have different shapes and
|
|
||||||
// change on different days, and one shared constant would force a bump on the
|
|
||||||
// fixture that did not move.
|
|
||||||
const TalkSchemaVersion = 1
|
|
||||||
|
|
||||||
// TalkFixture — the versioned envelope, same gating as Fixture.
|
// TalkFixture — the versioned envelope, same gating as Fixture.
|
||||||
type TalkFixture struct {
|
type TalkFixture struct {
|
||||||
SchemaVersion int `json:"schema_version"`
|
SchemaVersion int `json:"schema_version"`
|
||||||
@@ -98,8 +92,8 @@ func LoadTalk() (TalkFixture, error) {
|
|||||||
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
||||||
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
||||||
}
|
}
|
||||||
if f.SchemaVersion != TalkSchemaVersion {
|
if f.SchemaVersion != SchemaVersion {
|
||||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
|
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
||||||
}
|
}
|
||||||
if len(f.Cases) == 0 {
|
if len(f.Cases) == 0 {
|
||||||
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
||||||
|
|||||||
@@ -142,13 +142,19 @@ func TestLLMTalkBaseline(t *testing.T) {
|
|||||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||||
defer p.Close()
|
defer p.Close()
|
||||||
|
|
||||||
// The model id names the run in the report. Since Vikunja #397 every path
|
// Unreachable server is fatal here, not a logged warning, and that differs
|
||||||
// returns its errors, so a server that dies mid-run shows up in the Errors
|
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead
|
||||||
// column instead of scoring as bad phrasing — the before-and-after probe that
|
// server there shows up honestly in the Errors column. PhraseChat and
|
||||||
// used to stand in for that is gone.
|
// PhraseQuery do NOT: they swallow every failure and return a canned string
|
||||||
|
// ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
|
||||||
|
// a dead server produces a full report with 0 errors and a terrible score —
|
||||||
|
// a number that looks like bad phrasing and is really no phrasing at all.
|
||||||
|
// Refusing to score without a confirmed model is the only guard available
|
||||||
|
// until the phraser reports its failures (Vikunja #397).
|
||||||
model, err := llm.ModelID(ctx, base)
|
model, err := llm.ModelID(ctx, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("no model at %s: %v", base, err)
|
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
|
||||||
|
"and would report a plausible-looking result off a dead server", base, err)
|
||||||
}
|
}
|
||||||
t.Logf("scoring model %s at %s", model, base)
|
t.Logf("scoring model %s at %s", model, base)
|
||||||
|
|
||||||
@@ -163,11 +169,10 @@ func TestLLMTalkBaseline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
||||||
|
|
||||||
// A run where nothing was phrased is not a low score, it is no measurement.
|
// And again afterwards: the run takes minutes, and a server that died or got
|
||||||
if rep.Errors == rep.Total {
|
// OOM-killed halfway through would leave the first cases scored and the rest
|
||||||
t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
|
// silently canned. Checking only at the start would not catch that.
|
||||||
}
|
if _, err := llm.ModelID(ctx, base); err != nil {
|
||||||
if rep.Errors > 0 {
|
t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err)
|
||||||
t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package phraser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// isFallback — the text she says is picked from that entry's variants, so a test
|
|
||||||
// pins the entry rather than the wording. Pinning one line would make editing
|
|
||||||
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
|
|
||||||
func isFallback(t *testing.T, key, sources, got string) bool {
|
|
||||||
t.Helper()
|
|
||||||
return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
|
|
||||||
// PhraseQuery keep the turn alive with canned text — and every one of those
|
|
||||||
// lines is also a legitimate reply, so the text alone cannot say which happened.
|
|
||||||
// The error is the only signal, and before Vikunja #397 it was dropped: the talk
|
|
||||||
// scorer reported a full run with zero errors off a server that answered nothing.
|
|
||||||
func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
p := NewLLMPhraserAt(srv.URL, Config{})
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
call func() (string, error)
|
|
||||||
key string
|
|
||||||
sources string
|
|
||||||
}{
|
|
||||||
{"chat", func() (string, error) {
|
|
||||||
return p.PhraseChat(context.Background(), "как дела", nil)
|
|
||||||
}, fbChat, ""},
|
|
||||||
{"knowledge", func() (string, error) {
|
|
||||||
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
|
||||||
}, fbQueryUnknown, ""},
|
|
||||||
{"evidence", func() (string, error) {
|
|
||||||
return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
|
|
||||||
}, fbQuerySources, "два литра"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
got, err := c.call()
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing")
|
|
||||||
}
|
|
||||||
if !isFallback(t, c.key, c.sources, got) {
|
|
||||||
t.Errorf("fallback text = %q, want a %q variant — the daemon still has to say something", got, c.key)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An empty answer is a failure too: the server is up and produced no tokens,
|
|
||||||
// which is not an answer and must not score as one.
|
|
||||||
func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
p := NewLLMPhraserAt(srv.URL, Config{})
|
|
||||||
|
|
||||||
got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("an empty response scored as an answer")
|
|
||||||
}
|
|
||||||
if !isFallback(t, fbQueryUnknown, "", got) {
|
|
||||||
t.Errorf("fallback text = %q, want a %q variant", got, fbQueryUnknown)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "empty") {
|
|
||||||
t.Errorf("error = %v; want it to name the empty response", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package phraser
|
|
||||||
|
|
||||||
// The phrasing fallbacks — what she says when the model gave her nothing usable.
|
|
||||||
//
|
|
||||||
// They were four string literals spread across phraser.go, llmphraser.go and
|
|
||||||
// cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so
|
|
||||||
// rewording one was a Go edit, a rebuild and a redeploy for what is product copy.
|
|
||||||
//
|
|
||||||
// The floor under the floor is deliberate. These strings exist because something
|
|
||||||
// already failed, so a broken template file must not be able to take the last
|
|
||||||
// words she has: every accessor falls back to the literal it replaced.
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/say"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed fallbacks_ru_v1.json
|
|
||||||
var fallbackJSON []byte
|
|
||||||
|
|
||||||
// FallbackSchemaVersion — the version this code understands. Its own constant,
|
|
||||||
// not shared with the nudge templates or the eval fixtures: two files that change
|
|
||||||
// on different days cannot be versioned by one number (Vikunja #397).
|
|
||||||
const FallbackSchemaVersion = 1
|
|
||||||
|
|
||||||
// The entry keys. Every one of them is read by a method below, so a typo in the
|
|
||||||
// file is caught at load rather than at the moment she needs the words.
|
|
||||||
const (
|
|
||||||
fbChat = "chat"
|
|
||||||
fbQueryUnknown = "query_unknown"
|
|
||||||
fbQuerySources = "query_sources"
|
|
||||||
fbWorldGap = "world_gap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fbKeys — every key the code requires the file to define.
|
|
||||||
var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
|
|
||||||
|
|
||||||
// hardFloor — the literal each key falls back to when the file is unusable.
|
|
||||||
// These are the exact strings that lived in Go before this file existed.
|
|
||||||
var hardFloor = map[string]string{
|
|
||||||
fbChat: "даже не знаю, что сказать.",
|
|
||||||
fbQueryUnknown: "не знаю.",
|
|
||||||
fbQuerySources: "вот что я нашла: {sources}",
|
|
||||||
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
|
|
||||||
type Fallbacks struct{ d *say.Deck }
|
|
||||||
|
|
||||||
// LoadFallbacks reads the embedded file. Pass a source to make the picking
|
|
||||||
// reproducible in tests; nil seeds from the clock.
|
|
||||||
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
|
|
||||||
d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// query_sources is the one entry whose whole job is to read something back.
|
|
||||||
if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &Fallbacks{d: d}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
|
|
||||||
func (f *Fallbacks) deck() *say.Deck {
|
|
||||||
if f == nil {
|
|
||||||
return say.FloorDeck(hardFloor)
|
|
||||||
}
|
|
||||||
return f.d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat — nothing usable came back on the chat path.
|
|
||||||
func (f *Fallbacks) Chat() string { return f.deck().Text(fbChat, nil) }
|
|
||||||
|
|
||||||
// Unknown — a question she cannot answer and will not guess at.
|
|
||||||
func (f *Fallbacks) Unknown() string { return f.deck().Text(fbQueryUnknown, nil) }
|
|
||||||
|
|
||||||
// FromSources — read back what she was handed, because phrasing it failed.
|
|
||||||
func (f *Fallbacks) FromSources(sources string) string {
|
|
||||||
return f.deck().Text(fbQuerySources, map[string]string{"sources": sources})
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorldGap — the world model is the one configured to answer and it is not
|
|
||||||
// answering. Fixed wording: it names a specific gap, and a variant set here
|
|
||||||
// would let "the big model is asleep" drift into "I don't know".
|
|
||||||
func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) }
|
|
||||||
|
|
||||||
// Variants returns every line the file can produce, for the persona scorer.
|
|
||||||
func (f *Fallbacks) Variants() []string { return f.deck().Variants() }
|
|
||||||
|
|
||||||
// The process-wide instance. Package-level because these lines are needed on
|
|
||||||
// paths that have no phraser to hand — cmd/mavend names the world gap without
|
|
||||||
// one — and because a template file that is embedded and validated at load has
|
|
||||||
// nothing per-instance to configure.
|
|
||||||
var (
|
|
||||||
fallbackOnce sync.Once
|
|
||||||
fallbacks *Fallbacks
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultFallbacks returns the shared instance, loading it on first use. A
|
|
||||||
// broken file logs once and leaves a nil *Fallbacks, which still answers from
|
|
||||||
// hardFloor — a daemon must not fail to boot over its own copy deck.
|
|
||||||
func DefaultFallbacks() *Fallbacks {
|
|
||||||
fallbackOnce.Do(func() {
|
|
||||||
fb, err := LoadFallbacks(nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fallbacks = fb
|
|
||||||
})
|
|
||||||
return fallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChatFallback — what she says when the chat path produced nothing.
|
|
||||||
func ChatFallback() string { return DefaultFallbacks().Chat() }
|
|
||||||
|
|
||||||
// UnknownFallback — what she says when she has no answer and will not invent one.
|
|
||||||
func UnknownFallback() string { return DefaultFallbacks().Unknown() }
|
|
||||||
|
|
||||||
// SourcesFallback — read the sources back rather than ship a broken fragment.
|
|
||||||
func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) }
|
|
||||||
|
|
||||||
// WorldGap — what he hears when the world model is configured and unreachable.
|
|
||||||
func WorldGap() string { return DefaultFallbacks().WorldGap() }
|
|
||||||
|
|
||||||
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
|
|
||||||
// The daemon tests read it to tell an answer from a shrug.
|
|
||||||
func IsUnknownFallback(text string) bool {
|
|
||||||
return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSourcesFallback reports whether text is sources read back verbatim.
|
|
||||||
func IsSourcesFallback(text, sources string) bool {
|
|
||||||
return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"name": "russian phrasing fallbacks v1",
|
|
||||||
"notes": [
|
|
||||||
"What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.",
|
|
||||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.",
|
|
||||||
"These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.",
|
|
||||||
"Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.",
|
|
||||||
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns."
|
|
||||||
],
|
|
||||||
"entries": {
|
|
||||||
"chat": {
|
|
||||||
"variants": [
|
|
||||||
"даже не знаю, что сказать.",
|
|
||||||
"не могу найти слов.",
|
|
||||||
"мысль ускользнула, повтори?",
|
|
||||||
"у меня сейчас пусто в голове."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"query_unknown": {
|
|
||||||
"variants": [
|
|
||||||
"не знаю.",
|
|
||||||
"не знаю, честно.",
|
|
||||||
"тут я пас.",
|
|
||||||
"не скажу, не знаю."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"query_sources": {
|
|
||||||
"variants": [
|
|
||||||
"вот что я нашла: {sources}",
|
|
||||||
"нашла вот это: {sources}",
|
|
||||||
"есть только это: {sources}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"world_gap": {
|
|
||||||
"fixed": true,
|
|
||||||
"variants": [
|
|
||||||
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user