Files
2026-07-10 01:54:25 +07:00

540 lines
17 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const (
kgDedupAutoThreshold = 0.98
kgDedupFlagThreshold = 0.90
kgDedupNameThreshold = 0.85
kgDedupEntityCap = 200
)
// IngestExtraction upserts entities and relations from an LLM extraction in a single transaction.
// Returns DB UUIDs of all upserted entities for downstream dedup processing.
func (s *SQLiteKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, userID string, entities []store.Entity, relations []store.Relation) ([]string, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback() //nolint:errcheck
now := time.Now().UTC().Format(time.RFC3339Nano)
tid := tenantIDForInsert(ctx).String()
// Upsert entities; build external_id → DB ID lookup for resolving relation endpoints.
extIDtoDBID := make(map[string]string, len(entities))
for i := range entities {
e := &entities[i]
e.AgentID = agentID
e.UserID = userID
props, _ := json.Marshal(e.Properties)
if props == nil {
props = []byte("{}")
}
newID := uuid.Must(uuid.NewV7()).String()
var actualID string
if err := tx.QueryRowContext(ctx, `
INSERT INTO kg_entities
(id, agent_id, user_id, external_id, name, entity_type, description,
properties, source_id, confidence, tenant_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(agent_id, user_id, external_id) DO UPDATE SET
name = excluded.name,
entity_type = excluded.entity_type,
description = excluded.description,
properties = excluded.properties,
source_id = excluded.source_id,
confidence = excluded.confidence,
tenant_id = excluded.tenant_id,
updated_at = excluded.updated_at
RETURNING id`,
newID, agentID, userID, e.ExternalID, e.Name, e.EntityType,
e.Description, string(props), e.SourceID, e.Confidence, tid, now, now,
).Scan(&actualID); err != nil {
return nil, fmt.Errorf("ingest entity %q: %w", e.ExternalID, err)
}
extIDtoDBID[e.ExternalID] = actualID
e.ID = actualID
}
for i := range relations {
r := &relations[i]
srcID, ok1 := extIDtoDBID[r.SourceEntityID]
tgtID, ok2 := extIDtoDBID[r.TargetEntityID]
if !ok1 || !ok2 {
continue // skip relations referencing unknown entities
}
r.AgentID = agentID
r.UserID = userID
origSrc, origTgt := r.SourceEntityID, r.TargetEntityID
r.SourceEntityID = srcID
r.TargetEntityID = tgtID
if err := upsertRelationTx(ctx, tx, agentID, userID, r, tid, now); err != nil {
return nil, fmt.Errorf("ingest relation %s->%s: %w", origSrc, origTgt, err)
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
entityIDs := make([]string, 0, len(extIDtoDBID))
for _, id := range extIDtoDBID {
entityIDs = append(entityIDs, id)
}
return entityIDs, nil
}
// PruneByConfidence deletes entities with confidence below minConfidence.
func (s *SQLiteKnowledgeGraphStore) PruneByConfidence(ctx context.Context, agentID, userID string, minConfidence float64) (int, error) {
userClause, userArgs := kgUserClauseFor(ctx, userID)
sc, scArgs, _ := scopeClause(ctx)
args := append([]any{agentID, minConfidence}, userArgs...)
args = append(args, scArgs...)
res, err := s.db.ExecContext(ctx,
`DELETE FROM kg_entities WHERE agent_id = ? AND confidence < ?`+userClause+sc,
args...,
)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// DedupAfterExtraction checks newly upserted entities for duplicates using
// Go-side Jaro-Winkler similarity. Auto-merges at >0.98 + name match,
// flags >0.90 as candidates. Caps existing entity pool at kgDedupEntityCap.
func (s *SQLiteKnowledgeGraphStore) DedupAfterExtraction(ctx context.Context, agentID, userID string, newEntityIDs []string) (int, int, error) {
if len(newEntityIDs) == 0 {
return 0, 0, nil
}
newEntities, err := s.fetchEntitiesByIDs(ctx, agentID, newEntityIDs)
if err != nil {
return 0, 0, fmt.Errorf("dedup fetch new: %w", err)
}
userClause, userArgs := kgUserClauseFor(ctx, userID)
sc, scArgs, _ := scopeClause(ctx)
poolArgs := append([]any{agentID}, userArgs...)
poolArgs = append(poolArgs, scArgs...)
poolArgs = append(poolArgs, kgDedupEntityCap+1)
poolRows, err := s.db.QueryContext(ctx,
`SELECT id, agent_id, user_id, external_id, name, entity_type, description,
properties, source_id, confidence, created_at, updated_at
FROM kg_entities
WHERE agent_id = ? AND valid_until IS NULL`+userClause+sc+`
ORDER BY updated_at DESC LIMIT ?`,
poolArgs...,
)
if err != nil {
return 0, 0, fmt.Errorf("dedup fetch pool: %w", err)
}
existing, scanErr := scanEntityRows(poolRows)
poolRows.Close()
if scanErr != nil {
return 0, 0, scanErr
}
if len(existing) >= kgDedupEntityCap {
slog.Warn("kg.dedup: entity count exceeds comparison cap", "count", len(existing), "cap", kgDedupEntityCap)
existing = existing[:kgDedupEntityCap]
}
newIDSet := make(map[string]bool, len(newEntityIDs))
for _, id := range newEntityIDs {
newIDSet[id] = true
}
var merged, flagged int
for i := range newEntities {
newE := &newEntities[i]
newText := newE.Name + " " + newE.Description
for j := range existing {
existE := &existing[j]
if existE.ID == newE.ID {
continue
}
if newIDSet[existE.ID] {
continue // both new — avoid double-processing
}
existText := existE.Name + " " + existE.Description
sim := kg.JaroWinkler(newText, existText)
if sim >= kgDedupAutoThreshold {
nameSim := kg.JaroWinkler(newE.Name, existE.Name)
if nameSim >= kgDedupNameThreshold {
targetID, sourceID := newE.ID, existE.ID
if existE.Confidence > newE.Confidence {
targetID, sourceID = existE.ID, newE.ID
}
if mergeErr := s.MergeEntities(ctx, agentID, userID, targetID, sourceID); mergeErr != nil {
slog.Warn("kg.dedup: auto-merge failed", "target", targetID, "source", sourceID, "error", mergeErr)
continue
}
merged++
break
}
} else if sim >= kgDedupFlagThreshold {
inserted, flagErr := s.insertDedupCandidate(ctx, agentID, userID, newE.ID, existE.ID, sim)
if flagErr != nil {
slog.Warn("kg.dedup: flag candidate failed", "error", flagErr)
} else if inserted {
flagged++
}
}
}
}
return merged, flagged, nil
}
// ScanDuplicates performs a bulk scan of all entities using Go-side pairwise
// Jaro-Winkler. 2-pass: load distinct entity_types, then compare within each type.
// Caps per-type pool at kgDedupEntityCap. Returns newly inserted candidates.
func (s *SQLiteKnowledgeGraphStore) ScanDuplicates(ctx context.Context, agentID, userID string, threshold float64, limit int) (int, error) {
if threshold <= 0 {
threshold = kgDedupFlagThreshold
}
if limit <= 0 {
limit = 100
}
userClause, userArgs := kgUserClauseFor(ctx, userID)
sc, scArgs, _ := scopeClause(ctx)
typeArgs := append([]any{agentID}, userArgs...)
typeArgs = append(typeArgs, scArgs...)
typeRows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT entity_type FROM kg_entities
WHERE agent_id = ? AND valid_until IS NULL`+userClause+sc,
typeArgs...,
)
if err != nil {
return 0, fmt.Errorf("kg.scan_duplicates: type query: %w", err)
}
var entityTypes []string
for typeRows.Next() {
var t string
if typeRows.Scan(&t) == nil {
entityTypes = append(entityTypes, t)
}
}
typeRows.Close()
found := 0
for _, entityType := range entityTypes {
typeEntityArgs := append([]any{agentID, entityType}, userArgs...)
typeEntityArgs = append(typeEntityArgs, scArgs...)
typeEntityArgs = append(typeEntityArgs, kgDedupEntityCap+1)
eRows, err := s.db.QueryContext(ctx,
`SELECT id, agent_id, user_id, external_id, name, entity_type, description,
properties, source_id, confidence, created_at, updated_at
FROM kg_entities
WHERE agent_id = ? AND entity_type = ? AND valid_until IS NULL`+userClause+sc+`
ORDER BY updated_at DESC LIMIT ?`,
typeEntityArgs...,
)
if err != nil {
slog.Warn("kg.scan_duplicates: entity load failed", "type", entityType, "error", err)
continue
}
pool, scanErr := scanEntityRows(eRows)
eRows.Close()
if scanErr != nil {
continue
}
if len(pool) >= kgDedupEntityCap {
slog.Warn("kg.scan_duplicates: entity count exceeds comparison cap", "type", entityType, "count", len(pool), "cap", kgDedupEntityCap)
pool = pool[:kgDedupEntityCap]
}
for i := 0; i < len(pool); i++ {
for j := i + 1; j < len(pool); j++ {
if found >= limit {
return found, nil
}
a, b := &pool[i], &pool[j]
sim := kg.JaroWinkler(a.Name+" "+a.Description, b.Name+" "+b.Description)
if sim >= threshold {
inserted, insertErr := s.insertDedupCandidate(ctx, agentID, userID, a.ID, b.ID, sim)
if insertErr != nil {
slog.Warn("kg.scan_duplicates: insert candidate failed", "error", insertErr)
continue
}
if inserted {
found++
}
}
}
}
}
return found, nil
}
// ListDedupCandidates returns pending dedup candidates joined with entity details.
func (s *SQLiteKnowledgeGraphStore) ListDedupCandidates(ctx context.Context, agentID, userID string, limit int) ([]store.DedupCandidate, error) {
if limit <= 0 {
limit = 50
}
userClause, userArgs := kgUserClauseFor(ctx, userID)
sc, scArgs, _ := scopeClause(ctx)
where := "c.agent_id = ? AND c.status = 'pending'"
args := []any{agentID}
if userClause != "" {
where += strings.ReplaceAll(userClause, " user_id", " c.user_id")
args = append(args, userArgs...)
}
if sc != "" {
where += strings.ReplaceAll(sc, " tenant_id", " c.tenant_id")
args = append(args, scArgs...)
}
args = append(args, limit)
q := `
SELECT c.id, c.similarity, c.status, c.created_at,
a.id, a.agent_id, a.user_id, a.external_id, a.name, a.entity_type,
a.description, a.properties, a.source_id, a.confidence, a.created_at, a.updated_at,
b.id, b.agent_id, b.user_id, b.external_id, b.name, b.entity_type,
b.description, b.properties, b.source_id, b.confidence, b.created_at, b.updated_at
FROM kg_dedup_candidates c
JOIN kg_entities a ON c.entity_a_id = a.id
JOIN kg_entities b ON c.entity_b_id = b.id
WHERE ` + where + `
ORDER BY c.similarity DESC, c.created_at DESC LIMIT ?`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.DedupCandidate
for rows.Next() {
var dc store.DedupCandidate
var cCreatedAt, aCreatedAt, aUpdatedAt, bCreatedAt, bUpdatedAt any
var aProps, bProps []byte
if err := rows.Scan(
&dc.ID, &dc.Similarity, &dc.Status, &cCreatedAt,
&dc.EntityA.ID, &dc.EntityA.AgentID, &dc.EntityA.UserID, &dc.EntityA.ExternalID,
&dc.EntityA.Name, &dc.EntityA.EntityType, &dc.EntityA.Description,
&aProps, &dc.EntityA.SourceID, &dc.EntityA.Confidence, &aCreatedAt, &aUpdatedAt,
&dc.EntityB.ID, &dc.EntityB.AgentID, &dc.EntityB.UserID, &dc.EntityB.ExternalID,
&dc.EntityB.Name, &dc.EntityB.EntityType, &dc.EntityB.Description,
&bProps, &dc.EntityB.SourceID, &dc.EntityB.Confidence, &bCreatedAt, &bUpdatedAt,
); err != nil {
return nil, err
}
dc.CreatedAt = scanUnixTimestamp(cCreatedAt)
dc.EntityA.CreatedAt = scanUnixTimestamp(aCreatedAt)
dc.EntityA.UpdatedAt = scanUnixTimestamp(aUpdatedAt)
dc.EntityB.CreatedAt = scanUnixTimestamp(bCreatedAt)
dc.EntityB.UpdatedAt = scanUnixTimestamp(bUpdatedAt)
if p, _ := scanJSONStringMap(aProps); p != nil {
dc.EntityA.Properties = p
}
if p, _ := scanJSONStringMap(bProps); p != nil {
dc.EntityB.Properties = p
}
results = append(results, dc)
}
return results, rows.Err()
}
// MergeEntities merges sourceID into targetID: re-points relations, deletes source.
// Batches relation re-pointing in chunks of 200 IDs.
func (s *SQLiteKnowledgeGraphStore) MergeEntities(ctx context.Context, agentID, userID, targetID, sourceID string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
shared := store.IsSharedKG(ctx) || userID == ""
for _, eid := range []string{targetID, sourceID} {
var exists bool
var q string
var args []any
if shared {
q = `SELECT EXISTS(SELECT 1 FROM kg_entities WHERE id = ? AND agent_id = ?)`
args = []any{eid, agentID}
} else {
q = `SELECT EXISTS(SELECT 1 FROM kg_entities WHERE id = ? AND agent_id = ? AND user_id = ?)`
args = []any{eid, agentID, userID}
}
if err := tx.QueryRowContext(ctx, q, args...).Scan(&exists); err != nil {
return fmt.Errorf("kg.merge: entity check: %w", err)
}
if !exists {
return fmt.Errorf("kg.merge: entity %s not found or access denied", eid)
}
}
for _, cols := range [][2]string{
{"source_entity_id", "target_entity_id"},
{"target_entity_id", "source_entity_id"},
} {
col, otherCol := cols[0], cols[1]
// Delete would-be-duplicate relations after re-point
delQ := fmt.Sprintf(`
DELETE FROM kg_relations
WHERE %s = ? AND agent_id = ?
AND EXISTS (
SELECT 1 FROM kg_relations r2
WHERE r2.%s = ?
AND r2.agent_id = kg_relations.agent_id
AND r2.user_id = kg_relations.user_id
AND r2.relation_type = kg_relations.relation_type
AND r2.%s = kg_relations.%s
)`, col, col, otherCol, otherCol)
if _, err := tx.ExecContext(ctx, delQ, sourceID, agentID, targetID); err != nil {
return fmt.Errorf("kg.merge: dedup relations %s: %w", col, err)
}
// Collect relation IDs to re-point, then batch-update in 200-ID chunks
relIDRows, err := tx.QueryContext(ctx,
fmt.Sprintf(`SELECT id FROM kg_relations WHERE %s = ? AND agent_id = ?`, col),
sourceID, agentID,
)
if err != nil {
return fmt.Errorf("kg.merge: fetch relation IDs: %w", err)
}
var relIDs []string
for relIDRows.Next() {
var rid string
if relIDRows.Scan(&rid) == nil {
relIDs = append(relIDs, rid)
}
}
relIDRows.Close()
for i := 0; i < len(relIDs); i += 200 {
end := i + 200
if end > len(relIDs) {
end = len(relIDs)
}
chunk := relIDs[i:end]
ph := strings.Repeat("?,", len(chunk))
ph = ph[:len(ph)-1]
updArgs := []any{targetID}
for _, rid := range chunk {
updArgs = append(updArgs, rid)
}
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`UPDATE kg_relations SET %s = ? WHERE id IN (%s)`, col, ph),
updArgs...,
); err != nil {
return fmt.Errorf("kg.merge: re-point %s chunk: %w", col, err)
}
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM kg_entities WHERE id = ?`, sourceID); err != nil {
return fmt.Errorf("kg.merge: delete source: %w", err)
}
// Mark any dedup candidates referencing source as merged
if _, err := tx.ExecContext(ctx, `
UPDATE kg_dedup_candidates SET status = 'merged'
WHERE (entity_a_id = ? OR entity_b_id = ?) AND status = 'pending'`,
sourceID, sourceID,
); err != nil {
slog.Warn("kg.merge: update candidates failed", "error", err)
}
return tx.Commit()
}
// DismissCandidate marks a dedup candidate as dismissed.
// Scoped by agent_id to prevent cross-agent dismissal.
func (s *SQLiteKnowledgeGraphStore) DismissCandidate(ctx context.Context, agentID, candidateID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE kg_dedup_candidates SET status = 'dismissed'
WHERE id = ? AND agent_id = ? AND status = 'pending'`,
candidateID, agentID,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// insertDedupCandidate inserts a dedup candidate pair (smaller ID first for dedup consistency).
func (s *SQLiteKnowledgeGraphStore) insertDedupCandidate(ctx context.Context, agentID, userID, entityAID, entityBID string, similarity float64) (bool, error) {
if entityAID > entityBID {
entityAID, entityBID = entityBID, entityAID
}
id := uuid.Must(uuid.NewV7()).String()
tid := tenantIDForInsert(ctx).String()
now := time.Now().UTC().Format(time.RFC3339Nano)
res, err := s.db.ExecContext(ctx, `
INSERT INTO kg_dedup_candidates
(id, tenant_id, agent_id, user_id, entity_a_id, entity_b_id, similarity, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entity_a_id, entity_b_id) DO NOTHING`,
id, tid, agentID, userID, entityAID, entityBID, similarity, now,
)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}
// fetchEntitiesByIDs loads a set of entities by their DB IDs within an agent scope.
func (s *SQLiteKnowledgeGraphStore) fetchEntitiesByIDs(ctx context.Context, agentID string, ids []string) ([]store.Entity, error) {
if len(ids) == 0 {
return nil, nil
}
ph := strings.Repeat("?,", len(ids))
ph = ph[:len(ph)-1]
args := []any{agentID}
for _, id := range ids {
args = append(args, id)
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, agent_id, user_id, external_id, name, entity_type, description,
properties, source_id, confidence, created_at, updated_at
FROM kg_entities WHERE agent_id = ? AND id IN (`+ph+`)`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntityRows(rows)
}