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 }