From 9876187721bb1164e33e2d165a0c7fba7ccfc57e Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 20 Jul 2026 12:13:30 +0400 Subject: [PATCH] Wire voice-tapped facts into entity resolution; fix phraser model config WriteFactReq gains an optional Subject field (empty = old behavior, no CoreAPI signature change) and the IntentFact handler now passes the fact's key as its resolution subject, so voice-tapped facts flow into the Vikunja #279 enrichment queue automatically. Also: deploy/mavend.json's phraser was pointed at a 4B model with n_gpu_layers=99, which OOM'd under memory pressure and left a zombie llama-server child. Swapped to the 2B Qwen model matching the intended resident-model size, keeping GPU offload. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA --- cmd/mavend/fact_subject_test.go | 55 +++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 7 +++++ deploy/mavend.json | 2 +- internal/ipc/api.go | 7 +++++ internal/ipc/server.go | 2 +- 5 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 cmd/mavend/fact_subject_test.go diff --git a/cmd/mavend/fact_subject_test.go b/cmd/mavend/fact_subject_test.go new file mode 100644 index 0000000..e386b9f --- /dev/null +++ b/cmd/mavend/fact_subject_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/tool" + "github.com/kami/maven/internal/voice" +) + +// TestApplyAction_FactCapture_QueuesEntityResolution covers the wiring from +// Vikunja #279: a voice-tapped fact's key doubles as its entity-resolution +// subject, so writing a fact through applyAction (not directly through the +// store) must leave it in the fact-enrichment pending queue. +func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) + api := ipc.NewStoreAPI(st) + now := time.Now() + + emb := router.NewHashEmbedder(1024) + matcher := tool.NewMatcher(api) + rtr := buildRouter(emb, matcher, 0.55, nil) + + h := &reactiveHandler{ + api: api, + embedder: emb, + router: rtr, + replier: voice.NewStubReplier(), + now: func() time.Time { return now }, + memStore: memory.NewInMemoryStore(), + dataStore: st, + } + + dec := router.Decision{ + Intent: router.IntentFact, + Slots: router.Slots{Key: "the espresso machine", HasKey: true, Value: `"needs descaling"`}, + } + h.applyAction(ctx, dec) + + pending, err := st.PendingFactResolutions(ctx, 10) + if err != nil { + t.Fatalf("PendingFactResolutions: %v", err) + } + if len(pending) != 1 { + t.Fatalf("expected 1 fact queued for entity resolution, got %d: %+v", len(pending), pending) + } + if pending[0].Subject != "the espresso machine" { + t.Fatalf("expected subject to carry the fact key, got %q", pending[0].Subject) + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 1847b30..9bbd226 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -550,6 +550,13 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) Value: dec.Slots.Value, Source: "tap:voice", Confidence: 1.0, + // Subject: the key doubles as the entity-resolution candidate — + // a voice-tapped fact's key is usually the thing/person it's + // about ("espresso_machine", "kate"), so queueing it for Nexus + // resolution costs one async lookup and is a no-op (not_found) + // for the abstract self-state keys (mood, water) that aren't + // entities at all. + Subject: dec.Slots.Key, } factID, err := h.api.WriteFact(ctx, req) if err != nil { diff --git a/deploy/mavend.json b/deploy/mavend.json index c5d711a..7c604a0 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -6,7 +6,7 @@ "state_dir": "/var/lib/maven", "phraser": { - "model_path": "/opt/maven/models/llm/nemotron3-nano/NVIDIA-Nemotron-3-Nano-4B-UD-Q4_K_XL.gguf", + "model_path": "/opt/maven/models/llm/qwen3.5/Qwen3.5-2B-UD-Q4_K_XL.gguf", "bin_path": "llama-server", "n_gpu_layers": 99, "n_ctx": 2048, diff --git a/internal/ipc/api.go b/internal/ipc/api.go index dc266f8..32b6ac6 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -84,6 +84,13 @@ type WriteFactReq struct { Source string `json:"source"` Confidence float64 `json:"confidence"` VoidsID *int64 `json:"voids_id,omitempty"` + + // Subject — free-text "who/what this fact is about" (e.g. "the espresso + // machine", "Kate"). Empty (the default, so old callers are unaffected) + // means the fact isn't about a resolvable entity. When set, the fact + // enrichment worker (cmd/mavend/factenrichment.go) later resolves it + // against Nexus into an entity_id — see Vikunja #279. + Subject string `json:"subject,omitempty"` } // idReq — methods keyed by a single id. diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 6eb8625..f1f2d84 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -36,7 +36,7 @@ func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, erro if req.VoidsID != nil { voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true} } - id, err := a.s.WriteFact(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Value, req.Source, req.Confidence, voids) + 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) }