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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user