Add entity-aware fact resolution against Nexus (Vikunja #279)
facts gain a Subject/EntityID/ResolutionState triple and an async enrichment worker that resolves free-text subjects to canonical Nexus entity_ids, mirroring Praxis's enrichment-worker pattern. Ambiguous or unreachable Nexus never guesses an entity_id — the fact stays pending or terminal-ambiguous instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
This commit is contained in:
@@ -84,6 +84,12 @@ type Config struct {
|
||||
// falls back to the rule's static Base, matching pre-autotune behavior).
|
||||
AutotuneInterval Duration `json:"autotune_interval,omitempty"`
|
||||
|
||||
// FactEnrichmentInterval — how often the fact-entity enrichment worker
|
||||
// polls for facts with resolution_state='pending' and resolves their
|
||||
// subject against Nexus. Default 30s. Only runs when Nexus is configured;
|
||||
// no-ops (harmlessly) otherwise.
|
||||
FactEnrichmentInterval Duration `json:"fact_enrichment_interval,omitempty"`
|
||||
|
||||
// Ntfy — the ntfy push sink config. nil ⇒ ntfy channel not wired.
|
||||
// sev3 (ops soft) away + sev4 (ops hard) present + reminders away all
|
||||
// route here; not wiring ntfy means those routes drop silently.
|
||||
@@ -357,6 +363,8 @@ const (
|
||||
DefaultRouterThreshold = 0.55
|
||||
DefaultQueryMinScore = 0.55
|
||||
DefaultToolTimeout = 30 * time.Second
|
||||
|
||||
DefaultFactEnrichmentInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
// Load reads the JSON config at path and applies defaults. A missing file is
|
||||
@@ -394,6 +402,9 @@ func (c *Config) applyDefaults() {
|
||||
if c.AutotuneInterval == 0 {
|
||||
c.AutotuneInterval = Duration(DefaultAutotuneInterval)
|
||||
}
|
||||
if c.FactEnrichmentInterval == 0 {
|
||||
c.FactEnrichmentInterval = Duration(DefaultFactEnrichmentInterval)
|
||||
}
|
||||
// StateDir — when set, use it as the base for both db and socket if their
|
||||
// paths are still relative (empty). If StateDir is empty, fall back to the
|
||||
// XDG-style defaults (data dir for db, runtime dir for socket).
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WriteFactAboutSubject is WriteFact plus a free-text subject the fact is
|
||||
// about (e.g. "the espresso machine", "Kate"). If subject is non-empty the
|
||||
// row starts life at ResolutionPending and the fact-enrichment worker
|
||||
// (cmd/mavend/factenrichment.go) later resolves it against Nexus into
|
||||
// EntityID. Facts with no subject are ordinary — ResolutionState stays
|
||||
// ResolutionNone and no enrichment work is queued for them.
|
||||
func (s *Store) WriteFactAboutSubject(ctx context.Context, ts time.Time, kind FactKind, key, subject, value, source string, confidence float64, voidsID sql.NullInt64) (int64, error) {
|
||||
if confidence <= 0.0 || confidence > 1.0 {
|
||||
return 0, fmt.Errorf("%w: %f", ErrConfidence, confidence)
|
||||
}
|
||||
if voidsID.Valid {
|
||||
var ok int64
|
||||
err := s.db.QueryRowContext(ctx, "SELECT 1 FROM facts WHERE id = ?", voidsID.Int64).Scan(&ok)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, fmt.Errorf("%w: id=%d", ErrVoidsMissing, voidsID.Int64)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("voids lookup: %w", err)
|
||||
}
|
||||
}
|
||||
state := ResolutionNone
|
||||
if subject != "" {
|
||||
state = ResolutionPending
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id, subject, resolution_state)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
ts.UnixMilli(), string(kind), key, value, source, confidence, voidsID, subject, string(state))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("write fact about subject: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("last insert id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PendingFactResolutions returns up to limit facts awaiting entity
|
||||
// resolution (ResolutionPending), oldest first — the enrichment worker's
|
||||
// work queue. Voided facts are included: a corrected fact's subject still
|
||||
// deserves resolution so history stays queryable by entity.
|
||||
func (s *Store) PendingFactResolutions(ctx context.Context, limit int) ([]Fact, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, kind, key, value, source, confidence, voids_id, subject, entity_id, resolution_state
|
||||
FROM facts
|
||||
WHERE resolution_state = ?
|
||||
ORDER BY id ASC
|
||||
LIMIT ?`, string(ResolutionPending), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pending fact resolutions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Fact
|
||||
for rows.Next() {
|
||||
f, err := scanFactWithEntity(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ResolveFactEntity records the outcome of resolving a pending fact's
|
||||
// subject against Nexus. entityID is ignored (left NULL) unless state is
|
||||
// ResolutionResolved — an ambiguous or not-found result must not leave a
|
||||
// stale/guessed entity_id behind.
|
||||
func (s *Store) ResolveFactEntity(ctx context.Context, factID int64, entityID string, state FactResolutionState) error {
|
||||
var idArg sql.NullString
|
||||
if state == ResolutionResolved {
|
||||
idArg = sql.NullString{String: entityID, Valid: entityID != ""}
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE facts SET entity_id = ?, resolution_state = ? WHERE id = ?`,
|
||||
idArg, string(state), factID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve fact entity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FactsByEntity returns facts resolved to the given Nexus entity_id, newest
|
||||
// first, up to limit. Only ResolutionResolved rows carry an entity_id so
|
||||
// this naturally excludes pending/ambiguous/not_found rows.
|
||||
func (s *Store) FactsByEntity(ctx context.Context, entityID string, limit int) ([]Fact, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, kind, key, value, source, confidence, voids_id, subject, entity_id, resolution_state
|
||||
FROM facts
|
||||
WHERE entity_id = ?
|
||||
ORDER BY ts DESC, id DESC
|
||||
LIMIT ?`, entityID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("facts by entity: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Fact
|
||||
for rows.Next() {
|
||||
f, err := scanFactWithEntity(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanFactWithEntity(r rowScanner) (Fact, error) {
|
||||
var f Fact
|
||||
var tsMilli int64
|
||||
var kind string
|
||||
var voids sql.NullInt64
|
||||
var state string
|
||||
if err := r.Scan(&f.ID, &tsMilli, &kind, &f.Key, &f.Value, &f.Source, &f.Confidence, &voids,
|
||||
&f.Subject, &f.EntityID, &state); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Fact{}, ErrNoFact
|
||||
}
|
||||
return Fact{}, err
|
||||
}
|
||||
f.Ts = time.UnixMilli(tsMilli).UTC()
|
||||
f.Kind = FactKind(kind)
|
||||
f.VoidsID = voids
|
||||
f.ResolutionState = FactResolutionState(state)
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWriteFactAboutSubject_NoSubjectStaysNone(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.WriteFactAboutSubject(ctx, time.Now(), KindSelf, "mood", "", `"content"`, "tap:mood", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
|
||||
pending, err := s.PendingFactResolutions(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingFactResolutions: %v", err)
|
||||
}
|
||||
for _, f := range pending {
|
||||
if f.ID == id {
|
||||
t.Fatalf("fact %d written with no subject should not be queued for resolution", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFactAboutSubject_QueuesPendingResolution(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.WriteFactAboutSubject(ctx, time.Now(), KindEnv, "likes", "the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
|
||||
pending, err := s.PendingFactResolutions(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingFactResolutions: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].ID != id {
|
||||
t.Fatalf("expected fact %d in pending queue, got %+v", id, pending)
|
||||
}
|
||||
if pending[0].Subject != "the espresso machine" {
|
||||
t.Fatalf("expected subject preserved, got %q", pending[0].Subject)
|
||||
}
|
||||
if pending[0].ResolutionState != ResolutionPending {
|
||||
t.Fatalf("expected ResolutionPending, got %q", pending[0].ResolutionState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFactEntity_ResolvedMakesItFindableByEntity(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.WriteFactAboutSubject(ctx, time.Now(), KindEnv, "likes", "the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
|
||||
if err := s.ResolveFactEntity(ctx, id, "ent_espresso", ResolutionResolved); err != nil {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
|
||||
pending, err := s.PendingFactResolutions(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingFactResolutions: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("expected resolved fact to leave the pending queue, got %+v", pending)
|
||||
}
|
||||
|
||||
facts, err := s.FactsByEntity(ctx, "ent_espresso", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FactsByEntity: %v", err)
|
||||
}
|
||||
if len(facts) != 1 || facts[0].ID != id {
|
||||
t.Fatalf("expected fact %d under ent_espresso, got %+v", id, facts)
|
||||
}
|
||||
if !facts[0].EntityID.Valid || facts[0].EntityID.String != "ent_espresso" {
|
||||
t.Fatalf("expected EntityID set, got %+v", facts[0].EntityID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFactEntity_AmbiguousDoesNotStoreAnEntityID covers the
|
||||
// ecosystem-wide invariant that ambiguity blocks mutation: an ambiguous
|
||||
// resolve must never guess an entity_id, even transiently.
|
||||
func TestResolveFactEntity_AmbiguousDoesNotStoreAnEntityID(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.WriteFactAboutSubject(ctx, time.Now(), KindEnv, "likes", "kate", `"true"`, "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
|
||||
// Simulate an ambiguous Nexus response: candidates found, but no single
|
||||
// entity_id — the enrichment worker passes "" for entityID in this case.
|
||||
if err := s.ResolveFactEntity(ctx, id, "", ResolutionAmbiguous); err != nil {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
|
||||
pending, err := s.PendingFactResolutions(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingFactResolutions: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("ambiguous fact should leave the pending queue (it's terminal), got %+v", pending)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,13 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
completed_ts INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`, // #6 — durable delivery outbox
|
||||
|
||||
`ALTER TABLE facts ADD COLUMN subject TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE facts ADD COLUMN entity_id TEXT;
|
||||
ALTER TABLE facts ADD COLUMN resolution_state TEXT NOT NULL DEFAULT 'none'
|
||||
CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found'));
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -40,8 +40,32 @@ type Fact struct {
|
||||
Source string // tap:* | infer:* | poll:* | ambient | promote | feedback
|
||||
Confidence float64
|
||||
VoidsID sql.NullInt64
|
||||
|
||||
// Subject, EntityID, ResolutionState — entity-aware memory (Vikunja #279).
|
||||
// Subject is the free-text "who/what this fact is about" supplied at write
|
||||
// time by WriteFactAboutSubject; empty means the fact isn't about a
|
||||
// resolvable entity (ResolutionState stays "none"). EntityID is the
|
||||
// canonical Nexus entity_id once the enrichment worker resolves Subject.
|
||||
Subject string
|
||||
EntityID sql.NullString
|
||||
ResolutionState FactResolutionState
|
||||
}
|
||||
|
||||
// FactResolutionState — where a fact's Subject stands in Nexus entity
|
||||
// resolution. "none" = no subject given (most facts). "pending" = subject
|
||||
// given, not yet resolved. Terminal states: "resolved", "ambiguous" (Nexus
|
||||
// returned candidates, not stored — matches the ecosystem's ambiguity-blocks
|
||||
// invariant), "not_found".
|
||||
type FactResolutionState string
|
||||
|
||||
const (
|
||||
ResolutionNone FactResolutionState = "none"
|
||||
ResolutionPending FactResolutionState = "pending"
|
||||
ResolutionResolved FactResolutionState = "resolved"
|
||||
ResolutionAmbiguous FactResolutionState = "ambiguous"
|
||||
ResolutionNotFound FactResolutionState = "not_found"
|
||||
)
|
||||
|
||||
// Bucket — presence hysteresis state.
|
||||
type Bucket string
|
||||
|
||||
|
||||
Reference in New Issue
Block a user