03fa52dfd4
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
137 lines
4.5 KiB
Go
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
|
|
}
|