Files
kami 03fa52dfd4 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
2026-07-20 12:00:37 +04:00

137 lines
4.5 KiB
Go

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
}