feat(kg): add tsvector FTS, entity deduplication, and embedding fixes

- Replace ILIKE search with PostgreSQL tsvector/GIN full-text search
  for KG entities (migration 000031)
- Add entity deduplication system with dual-threshold strategy:
  auto-merge at 0.98+ similarity with Jaro-Winkler name match,
  flag 0.90-0.98 as candidates for manual review
- Add ScanDuplicates for bulk on-demand duplicate detection
- Add MergeEntities with advisory lock and tenant-scoped relation
  re-pointing (delete-then-update to avoid ON CONFLICT on UPDATE)
- Wire dedup inline after KG extraction pipeline
- Fix BackfillKGEmbeddings: was failing silently due to
  context.Background() missing tenant_id; now runs cross-tenant
- Fix BackfillKGEmbeddings: break on error → continue with failed
  ID tracking and max consecutive error cap
- Add EmbedEntity helper; UpsertEntity now generates embeddings
  in background goroutine
- Add HTTP endpoints: POST /kg/dedup/scan, GET /kg/dedup,
  POST /kg/merge, POST /kg/dedup/dismiss
- Add web UI: Dedup dialog with Scan All button, side-by-side
  entity comparison, merge/dismiss actions
- Add Jaro-Winkler similarity algorithm with 34 unit tests
- Update IngestExtraction to return upserted entity IDs
- Bump RequiredSchemaVersion to 31
This commit is contained in:
viettranx committed 2026-03-29 12:15:21 +07:00
1 parent 97f4942943
commit 2e869f4ece
22 files changed
+1693 -57

No files matched your search

+4
View File
@@ -42,6 +42,10 @@ func (h *KnowledgeGraphHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/agents/{agentID}/kg/extract", h.auth(h.handleExtract))
mux.HandleFunc("GET /v1/agents/{agentID}/kg/stats", h.auth(h.handleStats))
mux.HandleFunc("GET /v1/agents/{agentID}/kg/graph", h.auth(h.handleGraph))
mux.HandleFunc("POST /v1/agents/{agentID}/kg/dedup/scan", h.auth(h.handleScanDuplicates))
mux.HandleFunc("GET /v1/agents/{agentID}/kg/dedup", h.auth(h.handleListDedupCandidates))
mux.HandleFunc("POST /v1/agents/{agentID}/kg/merge", h.auth(h.handleMergeEntities))
mux.HandleFunc("POST /v1/agents/{agentID}/kg/dedup/dismiss", h.auth(h.handleDismissCandidate))
}
func (h *KnowledgeGraphHandler) auth(next http.HandlerFunc) http.HandlerFunc {
+111 -3
View File
@@ -202,15 +202,24 @@ func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Req
result.Relations[i].UserID = body.UserID
}
if err := h.store.IngestExtraction(r.Context(), agentID, body.UserID, result.Entities, result.Relations); err != nil {
entityIDs, err := h.store.IngestExtraction(r.Context(), agentID, body.UserID, result.Entities, result.Relations)
if err != nil {
slog.Warn("kg.ingest_extraction failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Run inline dedup on newly upserted entities (best-effort)
var dedupMerged, dedupFlagged int
if len(entityIDs) > 0 {
dedupMerged, dedupFlagged, _ = h.store.DedupAfterExtraction(r.Context(), agentID, body.UserID, entityIDs)
}
writeJSON(w, http.StatusOK, map[string]any{
"entities": len(result.Entities),
"relations": len(result.Relations),
"entities": len(result.Entities),
"relations": len(result.Relations),
"dedup_merged": dedupMerged,
"dedup_flagged": dedupFlagged,
})
}
@@ -227,6 +236,105 @@ func (h *KnowledgeGraphHandler) handleStats(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, stats)
}
func (h *KnowledgeGraphHandler) handleScanDuplicates(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
var body struct {
UserID string `json:"user_id"`
Threshold float64 `json:"threshold"`
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.Threshold <= 0 {
body.Threshold = 0.90
}
if body.Limit <= 0 {
body.Limit = 100
}
found, err := h.store.ScanDuplicates(r.Context(), agentID, body.UserID, body.Threshold, body.Limit)
if err != nil {
slog.Warn("kg.scan_duplicates failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"candidates_found": found})
}
func (h *KnowledgeGraphHandler) handleListDedupCandidates(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("agentID")
userID := r.URL.Query().Get("user_id")
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
candidates, err := h.store.ListDedupCandidates(r.Context(), agentID, userID, limit)
if err != nil {
slog.Warn("kg.list_dedup_candidates failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if candidates == nil {
candidates = []store.DedupCandidate{}
}
writeJSON(w, http.StatusOK, candidates)
}
func (h *KnowledgeGraphHandler) handleMergeEntities(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
var body struct {
UserID string `json:"user_id"`
TargetID string `json:"target_id"`
SourceID string `json:"source_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.TargetID == "" || body.SourceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target_id and source_id are required"})
return
}
if err := h.store.MergeEntities(r.Context(), agentID, body.UserID, body.TargetID, body.SourceID); err != nil {
slog.Warn("kg.merge_entities failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "merged"})
}
func (h *KnowledgeGraphHandler) handleDismissCandidate(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
agentID := r.PathValue("agentID")
var body struct {
CandidateID string `json:"candidate_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if body.CandidateID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "candidate_id is required"})
return
}
if err := h.store.DismissCandidate(r.Context(), agentID, body.CandidateID); err != nil {
slog.Warn("kg.dismiss_candidate failed", "error", err)
writeJSON(w, http.StatusNotFound, map[string]string{"error": "candidate not found or already resolved"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"})
}
// handleGraph returns all entities + relations for graph visualization.
func (h *KnowledgeGraphHandler) handleGraph(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("agentID")