mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 00:15:05 +00:00
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:
1 parent
97f4942943
commit
2e869f4ece
22 files changed
+1693
-57
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user