Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed491e23fc | |||
| 246db4e609 | |||
| b6abb19090 | |||
| 1f7fd476ec | |||
| 08512ad58b |
@@ -32,7 +32,11 @@ See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for t
|
||||
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
|
||||
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
|
||||
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
|
||||
stays on homesrv permanently, because it backs that floor. Read `docs/offload.md` before
|
||||
stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small,
|
||||
quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:`
|
||||
prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced
|
||||
MiniLM and bought ten points of recall@1 and 2.5× the speed; see
|
||||
`docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before
|
||||
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||
are the work.
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ deps-piper:
|
||||
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
||||
# a short question against a longer passage, which is what note recall is.
|
||||
# The quantized file is the one we download, deploy and measure — see
|
||||
# docs/evals/2026-07-31-recall.md.
|
||||
# docs/evals/2026-08-04-recall-e5-small.md for what the swap bought.
|
||||
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
||||
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
||||
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
||||
|
||||
@@ -73,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
|
||||
// rather than the value. The utterance stays alongside as provenance —
|
||||
// readable on /trace, never the answer and never embedded.
|
||||
if h.memStore != nil {
|
||||
if h.recall.memStore != nil {
|
||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||
if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil {
|
||||
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||
log.Printf("voice: embed fact for memory: %v", err)
|
||||
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||
} else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "fact",
|
||||
"text": text,
|
||||
|
||||
@@ -20,7 +20,7 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
// embed the note text with the same model the classifier uses, persist
|
||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||
// facts — no predicate reads it (spec's two-memory split).
|
||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||
vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
@@ -33,8 +33,8 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
}
|
||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||
if h.memStore != nil {
|
||||
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
if h.recall.memStore != nil {
|
||||
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "note",
|
||||
"text": dec.Utterance,
|
||||
|
||||
@@ -406,7 +406,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
// sources below both need, run once, in the position it always ran in. It
|
||||
// only claims the turn when the embedder fails.
|
||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
@@ -426,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
|
||||
// which memory gets to answer them.
|
||||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if h.memStore == nil {
|
||||
if h.recall.memStore == nil {
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
|
||||
if herr != nil {
|
||||
log.Printf("voice: memory search: %v", herr)
|
||||
return "", false
|
||||
}
|
||||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
||||
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
@@ -479,7 +479,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
for i, n := range notes {
|
||||
noteScores[i] = n.Score
|
||||
}
|
||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
||||
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||
return "", false
|
||||
}
|
||||
// Same topic veto as queryMemory above: the best note must be about what
|
||||
@@ -766,8 +766,8 @@ func isPersonalQuery(utterance string) bool {
|
||||
// computed. Same shape as the cascade: the better test leads, the offline one
|
||||
// always answers.
|
||||
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
|
||||
h.boundary.load(ctx, h.embedder)
|
||||
if personal, world, ok := h.boundary.score(t.vec); ok {
|
||||
h.recall.boundary.load(ctx, h.recall.embedder)
|
||||
if personal, world, ok := h.recall.boundary.score(t.vec); ok {
|
||||
if personal > world {
|
||||
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
|
||||
return true
|
||||
|
||||
@@ -299,7 +299,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
h, _, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.embedder = emb
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
|
||||
@@ -28,11 +28,10 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: rtr,
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
return h, api
|
||||
@@ -44,7 +43,7 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
||||
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
||||
t.Fatal("a question was stored as a fact about him")
|
||||
}
|
||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
@@ -81,7 +80,7 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
||||
// #493: what recall reads back is the fact, not the sentence he said.
|
||||
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
||||
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
@@ -117,7 +116,7 @@ func TestFactConfidence(t *testing.T) {
|
||||
|
||||
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||
t.Helper()
|
||||
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
|
||||
vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", text, err)
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHa
|
||||
}
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
embedder: nil,
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
recall: recallWiring{embedder: nil},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
||||
{"how do i boil an egg", false},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{embedder: emb}
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
ctx := context.Background()
|
||||
wrong := 0
|
||||
for _, c := range cases {
|
||||
@@ -80,7 +80,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
||||
}
|
||||
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
|
||||
got := h.isPersonalTurn(ctx, turn)
|
||||
p, w, ok := h.boundary.score(vec)
|
||||
p, w, ok := h.recall.boundary.score(vec)
|
||||
if !ok {
|
||||
t.Fatal("seeds did not load with a working embedder")
|
||||
}
|
||||
|
||||
@@ -85,15 +85,17 @@ func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reac
|
||||
|
||||
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
||||
h := &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
embedder: emb,
|
||||
api: ipc.NewStoreAPI(st),
|
||||
recall: recallWiring{
|
||||
embedder: emb,
|
||||
memStore: mem,
|
||||
minScore: 0.55,
|
||||
minMargin: 0.008,
|
||||
},
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phr,
|
||||
now: func() time.Time { return now },
|
||||
memStore: mem,
|
||||
dataStore: st,
|
||||
queryMinScore: 0.55,
|
||||
queryMinMargin: 0.008,
|
||||
weatherProvider: nil,
|
||||
}
|
||||
return h, phr
|
||||
|
||||
@@ -26,11 +26,10 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: rtr,
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
@@ -101,11 +100,10 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||
matcher := tool.NewMatcher(api)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, matcher, 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
|
||||
+35
-1
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "github.com/kami/maven/internal/memory"
|
||||
import (
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// bestRecall is the read side of the long-term memory store: the top hit when
|
||||
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
||||
@@ -23,3 +26,34 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re
|
||||
}
|
||||
return results[0], true
|
||||
}
|
||||
|
||||
// recallWiring — the recall subsystem's dependencies, held as one group on
|
||||
// reactiveHandler (Vikunja #433). It is the worked example for the wiring
|
||||
// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty
|
||||
// loose ones, so a handler names what it needs and the package can be split
|
||||
// later without exporting the whole struct.
|
||||
//
|
||||
// The zero value is usable and means "no recall": no embedder, no vector
|
||||
// store, and a gate that is never consulted because nothing is ever searched.
|
||||
type recallWiring struct {
|
||||
// embedder — reused for note write/query (same model as the classifier).
|
||||
embedder router.Embedder
|
||||
|
||||
// memStore — the vector index over notes and facts.
|
||||
memStore memory.Store
|
||||
|
||||
// boundary — the embedded seed sets behind the personal boundary
|
||||
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||
boundary personalBoundary
|
||||
|
||||
// minScore — the note-recall confidence gate. Top cosine below this ⇒
|
||||
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob,
|
||||
// not load-bearing math (same posture as the presence thresholds). Set by
|
||||
// wireVoice from VoiceConfig; default 0.55.
|
||||
minScore float64
|
||||
|
||||
// minMargin — the second half of that gate: how far the top hit must beat
|
||||
// the runner-up. 0 ⇒ margin off.
|
||||
minMargin float64
|
||||
}
|
||||
|
||||
@@ -428,20 +428,22 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||
|
||||
w.handler = &reactiveHandler{
|
||||
stt: simTranscriber{},
|
||||
tts: simSynthesizer{},
|
||||
router: rtr,
|
||||
embedder: emb,
|
||||
stt: simTranscriber{},
|
||||
tts: simSynthesizer{},
|
||||
router: rtr,
|
||||
recall: recallWiring{
|
||||
embedder: emb,
|
||||
memStore: st.VectorMemory(),
|
||||
minScore: config.DefaultQueryMinScore,
|
||||
minMargin: config.DefaultQueryMinMargin,
|
||||
},
|
||||
api: api,
|
||||
matcher: matcher,
|
||||
tools: tool.NewExecutor(api, 5*time.Second),
|
||||
phraser: phraser.NewStub(),
|
||||
replier: newLLMReplier(scripted, nil),
|
||||
now: clock.Now,
|
||||
memStore: st.VectorMemory(),
|
||||
dataStore: st,
|
||||
queryMinScore: config.DefaultQueryMinScore,
|
||||
queryMinMargin: config.DefaultQueryMinMargin,
|
||||
timeParser: router.StubDateTimeParser{},
|
||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||
|
||||
+10
-19
@@ -55,7 +55,6 @@ import (
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -72,14 +71,16 @@ import (
|
||||
// safe (the wired stt/tts/router/api all are); called from per-conn
|
||||
// goroutines on the voice.Server.
|
||||
type reactiveHandler struct {
|
||||
stt stt.Transcriber
|
||||
tts tts.Synthesizer
|
||||
router *router.Router
|
||||
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
||||
// boundary — the embedded seed sets behind the personal boundary
|
||||
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||
boundary personalBoundary
|
||||
stt stt.Transcriber
|
||||
tts tts.Synthesizer
|
||||
router *router.Router
|
||||
|
||||
// recall — the note-and-fact recall subsystem: the embedder, the vector
|
||||
// store it writes into, the personal boundary, and the two numbers that
|
||||
// gate an answer. Grouped rather than spread across the handler because a
|
||||
// handler that recalls needs all five and a handler that does not needs
|
||||
// none of them (Vikunja #433, docs/handler-wiring.md).
|
||||
recall recallWiring
|
||||
// api — the CoreAPI the handler reads and writes through. Wired with the
|
||||
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
|
||||
// upgradeAPI.
|
||||
@@ -123,18 +124,8 @@ type reactiveHandler struct {
|
||||
weatherProvider weather.Provider
|
||||
weatherLocation string // default location for weather queries
|
||||
|
||||
memStore memory.Store
|
||||
dataStore *store.Store // direct store access for event extraction + pattern detection
|
||||
|
||||
// queryMinScore — the note-recall confidence gate. Top cosine below this ⇒
|
||||
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not
|
||||
// load-bearing math (same posture as the presence thresholds). Set by
|
||||
// wireVoice from VoiceConfig; default 0.55.
|
||||
queryMinScore float64
|
||||
// queryMinMargin — the second half of that gate: how far the top hit must
|
||||
// beat the runner-up. 0 ⇒ margin off.
|
||||
queryMinMargin float64
|
||||
|
||||
// timeParser — used as a fallback for stage-0 reminder grammar matches
|
||||
// (where the extractor didn't run). Shared with the router's extractor.
|
||||
// The production dateparser will replace StubDateTimeParser here too.
|
||||
|
||||
+21
-19
@@ -262,19 +262,18 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
|
||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||
h := &reactiveHandler{
|
||||
stt: transcriber,
|
||||
tts: synthesizer,
|
||||
router: rtr,
|
||||
embedder: emb,
|
||||
api: coreAPI,
|
||||
tools: exec,
|
||||
matcher: matcher,
|
||||
replier: replier,
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
home: w.home,
|
||||
netscan: w.netscan,
|
||||
stt: transcriber,
|
||||
tts: synthesizer,
|
||||
router: rtr,
|
||||
api: coreAPI,
|
||||
tools: exec,
|
||||
matcher: matcher,
|
||||
replier: replier,
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
home: w.home,
|
||||
netscan: w.netscan,
|
||||
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||
// capability, and capabilities are off unless configured.
|
||||
crawler: onDemandCrawler(cfg),
|
||||
@@ -283,18 +282,21 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
search: wireSearch(cfg),
|
||||
// nil unless a `kiwix` block names a server. Same swap-aware client the
|
||||
// router and replier use, so the rewriter follows a model swap.
|
||||
kiwix: wireKiwix(cfg, llmClient),
|
||||
weatherProvider: weatherProvider,
|
||||
weatherLocation: weatherLocation,
|
||||
memStore: memStore,
|
||||
kiwix: wireKiwix(cfg, llmClient),
|
||||
weatherProvider: weatherProvider,
|
||||
weatherLocation: weatherLocation,
|
||||
recall: recallWiring{
|
||||
embedder: emb,
|
||||
memStore: memStore,
|
||||
minScore: cfg.Voice.QueryMinScore,
|
||||
minMargin: cfg.Voice.QueryMinMargin,
|
||||
},
|
||||
dataStore: dataStore,
|
||||
dialogueSessions: dialogueSessions,
|
||||
clarifyStore: clarifyStore,
|
||||
// 0 here (unset config) ⇒ the dialogue default.
|
||||
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||
queryMinScore: cfg.Voice.QueryMinScore,
|
||||
queryMinMargin: cfg.Voice.QueryMinMargin,
|
||||
timeParser: timeParser,
|
||||
ecosystem: eco,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-04 @ b6abb19. Living doc: correct it in place, do not append.*
|
||||
|
||||
> Supersedes the classifier-first routing model. Agreed in a design session
|
||||
> after diagnosing that homesrv deploys with a **stub phraser** (no LLM
|
||||
@@ -40,8 +40,10 @@ utterance
|
||||
are deferred until the main feature set is complete.
|
||||
- **Embedder demoted from router to tool** — it now backs `memory.search`
|
||||
(RAG) and gives the router a cheap "similar past notes/intents" hint. The
|
||||
router no longer depends on it clearing a threshold. Upgrade MiniLM → bge-m3
|
||||
for better RU retrieval later (model swap, not architecture).
|
||||
router no longer depends on it clearing a threshold. The MiniLM upgrade is
|
||||
done: it is multilingual-e5-small, asymmetric, with the `query:`/`passage:`
|
||||
prefixes (Vikunja #371, `docs/evals/2026-08-04-recall-e5-small.md`). A
|
||||
further swap is a model swap, not architecture (Vikunja #412).
|
||||
|
||||
### Router output
|
||||
- Constrained structured JSON action `{tool, args, escalate}` — NOT free-form
|
||||
|
||||
@@ -678,135 +678,6 @@ type acceptProposedRoutineReq struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
||||
// for modules that live in-process for now (router, delivery) and by tests,
|
||||
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
||||
// - the client proxy (client.go) — same interface, over the wire.
|
||||
//
|
||||
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
||||
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
||||
// That swappability is the seam the auth layer will insert into without
|
||||
// touching the module code.
|
||||
type CoreAPI interface {
|
||||
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
||||
LatestFact(ctx context.Context, key string) (Fact, error)
|
||||
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
||||
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
||||
Presence(ctx context.Context) (Presence, error)
|
||||
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
||||
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||
// every status (Vikunja #390).
|
||||
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||
|
||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||
// RecentNotesFromSource — the newest n notes whose source starts with
|
||||
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
||||
// from recall, so this is the only way to reach them, and it keeps the feed
|
||||
// answer from being crowded out of a fixed window by his own notes.
|
||||
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
||||
|
||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||
// destructive and flips status to 'enabled'. DisableTool reverts an
|
||||
// enabled tool back to proposed (it stays in the store, won't run).
|
||||
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
||||
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
||||
// LookupTool/ListTools read them.
|
||||
// scope defaults to "homelab" when empty.
|
||||
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
||||
DisableTool(ctx context.Context, name string) error
|
||||
DeleteTool(ctx context.Context, name string) error
|
||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||
RevertFact(ctx context.Context, key string) (int64, error)
|
||||
|
||||
// ListProposedRoutines returns proposed routines, newest first.
|
||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||
// intake seam for the voice path, the web form and the future email
|
||||
// extractor. Idempotent per live normalised text; the response says
|
||||
// whether a row was actually created.
|
||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||
// "live" is candidate + open (outstanding work).
|
||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||
|
||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
// persisted — it's a daemon-level cache).
|
||||
TickTrace(ctx context.Context) (TickTrace, error)
|
||||
|
||||
// MorningStatus returns each configured morning routine's current
|
||||
// checklist state (see internal/morning): active today/now, which items
|
||||
// are done, which are still missing. The store adapter returns an error
|
||||
// (morning routines are daemon-config, not persisted) — same shape as
|
||||
// TickTrace.
|
||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||
|
||||
// MCPServers reports the configured MCP servers and their health
|
||||
// (Vikunja #251). Read-only introspection for /tools — there is no
|
||||
// "call this tool" method on purpose: an MCP tool runs through the same
|
||||
// allowlist, confirm turn and act path as any other tool, and a second
|
||||
// mutation path would be a second thing to get wrong. Empty when the
|
||||
// mcp config block is absent, which is the default.
|
||||
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
||||
|
||||
// DayPlan returns today's ordered plan — calendar events, pending
|
||||
// reminders and any morning checklist still outstanding (see
|
||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||
// The store adapter returns an error (the plan needs the daemon's routine
|
||||
// config) — same shape as TickTrace and MorningStatus.
|
||||
DayPlan(ctx context.Context) (DayPlan, error)
|
||||
|
||||
// Chat routes a text utterance through the reactive handler's core path
|
||||
// (router → dialogue → action → replier) and returns the reply text.
|
||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||
//
|
||||
// conversation names the thread. A parked clarifying question is held per
|
||||
// conversation, so an unanswered question on one reach cannot eat the next
|
||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||
// tap and is still one conversation of its own, separate from the mic.
|
||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||
|
||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||
// it came from: a relayed notification, a mail candidate, a feed item, a
|
||||
// changed page, a spend, a presence probe.
|
||||
//
|
||||
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
||||
// the store adapter returns an error, because the journal is a bounded
|
||||
// in-memory ring and not a table. Its contents are a window over intake,
|
||||
// never the durable record — that is still the fact, note or task the
|
||||
// intake path wrote.
|
||||
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
||||
}
|
||||
|
||||
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
|
||||
// event.Event field for field; the ipc package does not import internal/event
|
||||
// so the wire shape stays independent of the in-process type.
|
||||
|
||||
@@ -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.
|
||||
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
||||
|
||||
// The framing is hand-rolled and it stays that way (Vikunja #410): ninety
|
||||
// lines, readable on the wire with socat, and every standard replacement
|
||||
// brings schema machinery this boundary does not want. The condition is that
|
||||
// it is defended by tests rather than inherited untested — truncated header,
|
||||
// truncated body, partial reads, frame boundaries and a non-JSON body all
|
||||
// live in frame_test.go.
|
||||
//
|
||||
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
|
||||
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
||||
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The framing is hand-rolled, and it is the protocol all nine daemons depend
|
||||
// on, so a bug in it is a bug everywhere (Vikunja #410). The review asked
|
||||
// whether to replace it with something standard. It stays: length-prefixed
|
||||
// JSON over a unix socket is ninety lines, it is readable with socat, and the
|
||||
// alternatives (net/rpc, gRPC, a codec library) all buy schema machinery this
|
||||
// boundary does not want. What was wrong was inheriting it untested. These
|
||||
// are the paths a real socket produces that the round-trip test never does.
|
||||
|
||||
// A short header is not EOF. EOF means the peer closed cleanly between
|
||||
// frames, which the server treats as a normal disconnect; a header that stops
|
||||
// halfway is a truncated frame and must be reported as an error, or a peer
|
||||
// that dies mid-write looks like one that hung up politely.
|
||||
func TestReadFrameTruncatedHeader(t *testing.T) {
|
||||
var v any
|
||||
err := readFrame(bytes.NewReader([]byte{0, 0, 4}), &v)
|
||||
if err == nil {
|
||||
t.Fatal("a three-byte header must fail")
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
t.Fatalf("err = %v, want a truncation error, not EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A header promising more body than follows. Same reasoning: the frame never
|
||||
// arrived, so it must not decode into a zero value the caller then trusts.
|
||||
func TestReadFrameTruncatedBody(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], 32)
|
||||
buf.Write(hdr[:])
|
||||
buf.WriteString(`{"m":"pi`)
|
||||
|
||||
var got map[string]string
|
||||
if err := readFrame(&buf, &got); err == nil {
|
||||
t.Fatal("a body shorter than its prefix must fail")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("decoded %v from a truncated frame", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing at all is EOF, and only this is.
|
||||
func TestReadFrameEmptyIsEOF(t *testing.T) {
|
||||
var v any
|
||||
if err := readFrame(bytes.NewReader(nil), &v); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
// byteAtATime returns one byte per Read, which is what a socket is allowed to
|
||||
// do and what a bytes.Reader never does. readFrame uses io.ReadFull for both
|
||||
// the header and the body; this is the test that would fail if either turned
|
||||
// into a bare Read.
|
||||
type byteAtATime struct {
|
||||
b []byte
|
||||
i int
|
||||
}
|
||||
|
||||
func (r *byteAtATime) Read(p []byte) (int, error) {
|
||||
if r.i >= len(r.b) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
p[0] = r.b[r.i]
|
||||
r.i++
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func TestReadFrameReassemblesPartialReads(t *testing.T) {
|
||||
type payload struct {
|
||||
Msg string `json:"m"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
want := payload{Msg: "привет", N: 7}
|
||||
var buf bytes.Buffer
|
||||
if err := writeFrame(&buf, want); err != nil {
|
||||
t.Fatalf("writeFrame: %v", err)
|
||||
}
|
||||
|
||||
var got payload
|
||||
if err := readFrame(&byteAtATime{b: buf.Bytes()}, &got); err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Two frames written back to back must come back as two frames. A reader that
|
||||
// consumed more than one frame's body would desynchronize the connection, and
|
||||
// the symptom would be a reply attributed to the wrong request.
|
||||
func TestReadFrameStopsAtTheFrameBoundary(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
for _, m := range []string{"first", "second"} {
|
||||
if err := writeFrame(&buf, map[string]string{"m": m}); err != nil {
|
||||
t.Fatalf("writeFrame: %v", err)
|
||||
}
|
||||
}
|
||||
r := &byteAtATime{b: buf.Bytes()}
|
||||
for _, want := range []string{"first", "second"} {
|
||||
var got map[string]string
|
||||
if err := readFrame(r, &got); err != nil {
|
||||
t.Fatalf("readFrame(%s): %v", want, err)
|
||||
}
|
||||
if got["m"] != want {
|
||||
t.Fatalf("got %q, want %q", got["m"], want)
|
||||
}
|
||||
}
|
||||
var extra map[string]string
|
||||
if err := readFrame(r, &extra); !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("after two frames: err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A body that is not JSON is an error, not a zero value. The peer is either
|
||||
// broken or not speaking this protocol; either way the caller must not read
|
||||
// on as though it decoded.
|
||||
func TestReadFrameRejectsNonJSONBody(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
var hdr [4]byte
|
||||
body := []byte("not json at all")
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||
buf.Write(hdr[:])
|
||||
buf.Write(body)
|
||||
|
||||
var got map[string]string
|
||||
if err := readFrame(&buf, &got); err == nil {
|
||||
t.Fatal("a non-JSON body must fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine,
|
||||
Action: r.Action,
|
||||
Object: r.Object,
|
||||
IntervalDays: r.IntervalDays,
|
||||
Status: r.Status,
|
||||
Status: string(r.Status),
|
||||
CreatedTs: r.CreatedTs.UnixMilli(),
|
||||
}
|
||||
if r.ReminderID != nil {
|
||||
|
||||
@@ -8,14 +8,37 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// The three states a proposal can be in. A proposal starts 'proposed' and
|
||||
// moves once, either way, and never moves again.
|
||||
// RoutineStatus — the state a proposal is in. A defined type, not a bare
|
||||
// string, because the legal set used to live in a comment: nothing caught a
|
||||
// typo at compile time, nothing enumerated the set for a test, and a bad value
|
||||
// surfaced as a /routines row that neither accepts nor dismisses (Vikunja #46,
|
||||
// #410).
|
||||
type RoutineStatus string
|
||||
|
||||
// The three states a proposal can be in. A proposal starts proposed and moves
|
||||
// once, either way, and never moves again.
|
||||
const (
|
||||
RoutineProposed = "proposed"
|
||||
RoutineAccepted = "accepted"
|
||||
RoutineDismissed = "dismissed"
|
||||
RoutineProposed RoutineStatus = "proposed"
|
||||
RoutineAccepted RoutineStatus = "accepted"
|
||||
RoutineDismissed RoutineStatus = "dismissed"
|
||||
)
|
||||
|
||||
// RoutineStatuses — the legal set, and the single source of truth a test can
|
||||
// range over. Adding a state means adding it here.
|
||||
var RoutineStatuses = []RoutineStatus{RoutineProposed, RoutineAccepted, RoutineDismissed}
|
||||
|
||||
// Valid reports whether s is one of RoutineStatuses.
|
||||
func (s RoutineStatus) Valid() bool {
|
||||
for _, v := range RoutineStatuses {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s RoutineStatus) String() string { return string(s) }
|
||||
|
||||
// ProposedRoutine — a detected pattern the system wants to nudge about on a
|
||||
// repeating interval. Status 'proposed' means awaiting human confirmation;
|
||||
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
|
||||
@@ -30,7 +53,7 @@ type ProposedRoutine struct {
|
||||
Action string
|
||||
Object string
|
||||
IntervalDays float64
|
||||
Status string // proposed | accepted | dismissed
|
||||
Status RoutineStatus
|
||||
CreatedTs time.Time
|
||||
ReminderID *int64
|
||||
AcceptedTs *time.Time
|
||||
@@ -40,6 +63,7 @@ type ProposedRoutine struct {
|
||||
var (
|
||||
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
|
||||
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
|
||||
ErrRoutineStatus = errors.New("store: unknown routine status")
|
||||
)
|
||||
|
||||
// CreateProposedRoutine inserts a new proposed routine. Returns
|
||||
@@ -51,6 +75,16 @@ var (
|
||||
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
||||
// a nag.
|
||||
//
|
||||
// The object stays a local string. It is not resolved against Nexus and it
|
||||
// carries no canonical entity ref (asked on the PR 4 review, decided here,
|
||||
// Vikunja #410). Nexus owns identity for things the ecosystem acts on, and
|
||||
// nothing acts on a routine object: it is the word he used, replayed back to
|
||||
// him in a nudge, and compared only against itself for the UNIQUE key. Two
|
||||
// spellings of the same watering can are two routines, and that is the right
|
||||
// answer when the point is to say the sentence he would say. Canonical refs
|
||||
// arrive here only if a routine ever drives a Hexis call, which is Vikunja
|
||||
// #272, not this.
|
||||
//
|
||||
// Vikunja #43: this is called both from the voice fact-write path (for the
|
||||
// immediate spoken confirmation) and from the digestion tick's proactive
|
||||
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
|
||||
@@ -102,8 +136,13 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
|
||||
}
|
||||
|
||||
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
||||
// An empty status returns every row.
|
||||
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
|
||||
// An empty status returns every row; an unknown one is refused rather than
|
||||
// silently answering with nothing, since a typo and a genuinely empty state
|
||||
// read the same otherwise.
|
||||
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status RoutineStatus) ([]ProposedRoutine, error) {
|
||||
if status != "" && !status.Valid() {
|
||||
return nil, fmt.Errorf("%w: %q", ErrRoutineStatus, status)
|
||||
}
|
||||
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
||||
FROM proposed_routines`
|
||||
var args []any
|
||||
|
||||
@@ -235,7 +235,7 @@ func TestListProposedRoutinesByStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
status string
|
||||
status RoutineStatus
|
||||
want int
|
||||
}{
|
||||
{RoutineProposed, 0},
|
||||
@@ -266,3 +266,59 @@ func TestLookupMissingProposedRoutine(t *testing.T) {
|
||||
t.Fatal("want nil for missing routine")
|
||||
}
|
||||
}
|
||||
|
||||
// Every legal status must survive the database. The status column is written
|
||||
// by three different UPDATE statements with the value spelled inline, so a
|
||||
// constant that drifts from its SQL is exactly the failure this catches: the
|
||||
// row would come back in a state no Go code compares equal to.
|
||||
func TestRoutineStatusRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
write := map[RoutineStatus]func(id int64) error{
|
||||
RoutineProposed: func(int64) error { return nil },
|
||||
RoutineAccepted: func(id int64) error { return s.AcceptProposedRoutine(ctx, id, now) },
|
||||
RoutineDismissed: func(id int64) error { return s.DismissProposedRoutine(ctx, id) },
|
||||
}
|
||||
|
||||
for _, want := range RoutineStatuses {
|
||||
if !want.Valid() {
|
||||
t.Fatalf("%q is in RoutineStatuses but not Valid()", want)
|
||||
}
|
||||
object := "object-" + want.String()
|
||||
id, err := s.CreateProposedRoutine(ctx, "полить", object, 3, now)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProposedRoutine(%s): %v", want, err)
|
||||
}
|
||||
if err := write[want](id); err != nil {
|
||||
t.Fatalf("move to %s: %v", want, err)
|
||||
}
|
||||
got, err := s.LookupProposedRoutine(ctx, "полить", object)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("LookupProposedRoutine(%s): %v", want, err)
|
||||
}
|
||||
if got.Status != want {
|
||||
t.Errorf("status = %q, want %q", got.Status, want)
|
||||
}
|
||||
list, err := s.ListProposedRoutinesByStatus(ctx, want)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProposedRoutinesByStatus(%s): %v", want, err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("status %s: listed %d rows, want 1", want, len(list))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A typo used to read as "nothing is in that state", which is the same answer
|
||||
// a correct query gives on an empty table.
|
||||
func TestListByStatusRefusesAnUnknownStatus(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, err := s.ListProposedRoutinesByStatus(context.Background(), "accpeted"); !errors.Is(err, ErrRoutineStatus) {
|
||||
t.Fatalf("err = %v, want ErrRoutineStatus", err)
|
||||
}
|
||||
if RoutineStatus("accpeted").Valid() {
|
||||
t.Error("a typo must not be Valid()")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user