Skip to content

Commit fcfb458

Browse files
committed
feat: entity identity synthesis — first-class Entity nodes with semantic memory
- Add Entity nodes to FalkorDB with identity, aliases, and REFERENCED_IN edges - New identity consolidation task: dedup entities + synthesize 2-5 sentence identities via LLM - Identity-aware recall: inject entity identities into recall responses - Entity API: GET /entities, GET /entity/<slug>, GET /entities/merge-candidates, POST /entity/<slug>/merge - Migration script: scripts/migrate_entity_nodes.py (idempotent, --dry-run support) - Configurable: IDENTITY_SYNTHESIS_MODEL, CONSOLIDATION_IDENTITY_INTERVAL_SECONDS - Batch queries throughout (UNWIND edges, single-query recall injection) - Conditional synthesis: skip unchanged entities to save LLM calls - 18 tests covering migration, dedup, merge, synthesis, and recall
1 parent 605633e commit fcfb458

14 files changed

Lines changed: 1698 additions & 2 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ node_modules/
4141
/benchmarks/snapshots/
4242
/benchmarks/results/
4343
benchmarks/baselines/locomo_baseline.json
44+
data/

app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def _parse_viewer_allowed_origins() -> Any:
136136
CONSOLIDATION_DELETE_THRESHOLD,
137137
CONSOLIDATION_FORGET_INTERVAL_SECONDS,
138138
CONSOLIDATION_GRACE_PERIOD_DAYS,
139+
CONSOLIDATION_IDENTITY_INTERVAL_SECONDS,
139140
CONSOLIDATION_HISTORY_LIMIT,
140141
CONSOLIDATION_IMPORTANCE_FLOOR_FACTOR,
141142
CONSOLIDATION_IMPORTANCE_PROTECTION_THRESHOLD,
@@ -358,6 +359,7 @@ def require_api_token() -> None:
358359
creative_interval_seconds=CONSOLIDATION_CREATIVE_INTERVAL_SECONDS,
359360
cluster_interval_seconds=CONSOLIDATION_CLUSTER_INTERVAL_SECONDS,
360361
forget_interval_seconds=CONSOLIDATION_FORGET_INTERVAL_SECONDS,
362+
identity_interval_seconds=CONSOLIDATION_IDENTITY_INTERVAL_SECONDS,
361363
delete_threshold=CONSOLIDATION_DELETE_THRESHOLD,
362364
archive_threshold=CONSOLIDATION_ARCHIVE_THRESHOLD,
363365
grace_period_days=CONSOLIDATION_GRACE_PERIOD_DAYS,

automem/api/entity.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Entity API endpoints for AutoMem.
2+
3+
Provides CRUD and query operations for first-class Entity nodes.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import json
9+
import logging
10+
from typing import Any, Callable, Dict, List, Optional
11+
12+
from flask import Blueprint, abort, jsonify, request
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
def _parse_aliases(raw: Any) -> List[str]:
18+
"""Safely parse aliases from a graph result (may be list or JSON string)."""
19+
if not raw:
20+
return []
21+
if isinstance(raw, list):
22+
return raw
23+
if isinstance(raw, str):
24+
try:
25+
parsed = json.loads(raw)
26+
return parsed if isinstance(parsed, list) else []
27+
except Exception:
28+
return []
29+
return []
30+
31+
32+
def _serialize_entity_row(row: list) -> Dict[str, Any]:
33+
"""Convert a result row into a serialisable entity dict.
34+
35+
Expected column order (12 columns):
36+
0: e.id, 1: e.slug, 2: e.category, 3: e.name, 4: e.aliases,
37+
5: e.identity, 6: e.identity_version, 7: e.identity_updated_at,
38+
8: e.identity_source_count, 9: ref_count,
39+
10: e.created_at, 11: e.last_referenced_at
40+
"""
41+
return {
42+
"id": row[0],
43+
"slug": row[1],
44+
"category": row[2],
45+
"name": row[3],
46+
"aliases": _parse_aliases(row[4]),
47+
"identity": row[5],
48+
"identity_version": int(row[6] or 0),
49+
"identity_updated_at": row[7],
50+
"identity_source_count": int(row[8] or 0),
51+
"reference_count": int(row[9] or 0),
52+
"created_at": row[10] if len(row) > 10 else None,
53+
"last_referenced_at": row[11] if len(row) > 11 else None,
54+
}
55+
56+
57+
def create_entity_blueprint(
58+
get_memory_graph: Callable[[], Any],
59+
logger: Any,
60+
) -> Blueprint:
61+
"""Create the entity API blueprint."""
62+
63+
bp = Blueprint("entity", __name__)
64+
65+
@bp.route("/entities", methods=["GET"])
66+
def list_entities() -> Any:
67+
graph = get_memory_graph()
68+
if graph is None:
69+
abort(503, description="FalkorDB is unavailable")
70+
71+
category = request.args.get("category")
72+
limit = min(int(request.args.get("limit", 100)), 500)
73+
74+
if category:
75+
result = graph.query(
76+
"""
77+
MATCH (e:Entity)
78+
WHERE e.merged_into IS NULL AND e.category = $category
79+
OPTIONAL MATCH (e)-[ref:REFERENCED_IN]->()
80+
WITH e, count(ref) as ref_count
81+
RETURN e.id, e.slug, e.category, e.name, e.aliases,
82+
e.identity, e.identity_version, e.identity_updated_at,
83+
e.identity_source_count, ref_count,
84+
e.created_at, e.last_referenced_at
85+
ORDER BY ref_count DESC
86+
LIMIT $limit
87+
""",
88+
{"category": category, "limit": limit},
89+
)
90+
else:
91+
result = graph.query(
92+
"""
93+
MATCH (e:Entity)
94+
WHERE e.merged_into IS NULL
95+
OPTIONAL MATCH (e)-[ref:REFERENCED_IN]->()
96+
WITH e, count(ref) as ref_count
97+
RETURN e.id, e.slug, e.category, e.name, e.aliases,
98+
e.identity, e.identity_version, e.identity_updated_at,
99+
e.identity_source_count, ref_count,
100+
e.created_at, e.last_referenced_at
101+
ORDER BY ref_count DESC
102+
LIMIT $limit
103+
""",
104+
{"limit": limit},
105+
)
106+
107+
entities = []
108+
for row in getattr(result, "result_set", []) or []:
109+
entities.append(_serialize_entity_row(row))
110+
111+
return jsonify({"status": "success", "entities": entities, "count": len(entities)})
112+
113+
@bp.route("/entity/<slug>", methods=["GET"])
114+
def get_entity(slug: str) -> Any:
115+
graph = get_memory_graph()
116+
if graph is None:
117+
abort(503, description="FalkorDB is unavailable")
118+
119+
# Try direct slug match first, then alias lookup
120+
result = graph.query(
121+
"""
122+
MATCH (e:Entity)
123+
WHERE e.merged_into IS NULL AND (e.slug = $slug OR $slug IN e.aliases)
124+
OPTIONAL MATCH (e)-[ref:REFERENCED_IN]->()
125+
WITH e, count(ref) as ref_count
126+
RETURN e.id, e.slug, e.category, e.name, e.aliases,
127+
e.identity, e.identity_version, e.identity_updated_at,
128+
e.identity_source_count, ref_count,
129+
e.created_at, e.last_referenced_at
130+
LIMIT 1
131+
""",
132+
{"slug": slug},
133+
)
134+
135+
rows = getattr(result, "result_set", []) or []
136+
if not rows:
137+
abort(404, description=f"Entity '{slug}' not found")
138+
139+
entity = _serialize_entity_row(rows[0])
140+
return jsonify({"status": "success", "entity": entity})
141+
142+
@bp.route("/entities/merge-candidates", methods=["GET"])
143+
def merge_candidates() -> Any:
144+
graph = get_memory_graph()
145+
if graph is None:
146+
abort(503, description="FalkorDB is unavailable")
147+
148+
try:
149+
from automem.consolidation.entity_dedup import find_merge_candidates
150+
151+
auto_merge, review = find_merge_candidates(graph)
152+
auto_merge_ids = {(c.entity_a_id, c.entity_b_id) for c in auto_merge}
153+
candidates = []
154+
for c in auto_merge + review:
155+
candidates.append({
156+
"entity_a": c.entity_a_id,
157+
"entity_b": c.entity_b_id,
158+
"canonical": c.canonical_id,
159+
"alias": c.alias_id,
160+
"confidence": c.confidence,
161+
"reason": c.reason,
162+
"auto_merge": (c.entity_a_id, c.entity_b_id) in auto_merge_ids,
163+
})
164+
return jsonify({"status": "success", "candidates": candidates, "count": len(candidates)})
165+
except Exception as exc:
166+
logger.exception("Failed to find merge candidates")
167+
return jsonify({"error": "Failed to find merge candidates", "details": str(exc)}), 500
168+
169+
@bp.route("/entity/<slug>/merge", methods=["POST"])
170+
def merge_entity(slug: str) -> Any:
171+
graph = get_memory_graph()
172+
if graph is None:
173+
abort(503, description="FalkorDB is unavailable")
174+
175+
data = request.get_json(silent=True) or {}
176+
target_slug = data.get("merge_into")
177+
if not target_slug:
178+
abort(400, description="'merge_into' slug is required")
179+
180+
# Resolve both entities
181+
alias_result = graph.query(
182+
"MATCH (e:Entity) WHERE e.slug = $slug AND e.merged_into IS NULL RETURN e.id LIMIT 1",
183+
{"slug": slug},
184+
)
185+
canonical_result = graph.query(
186+
"MATCH (e:Entity) WHERE e.slug = $slug AND e.merged_into IS NULL RETURN e.id LIMIT 1",
187+
{"slug": target_slug},
188+
)
189+
190+
alias_rows = getattr(alias_result, "result_set", []) or []
191+
canonical_rows = getattr(canonical_result, "result_set", []) or []
192+
193+
if not alias_rows:
194+
abort(404, description=f"Entity '{slug}' not found")
195+
if not canonical_rows:
196+
abort(404, description=f"Target entity '{target_slug}' not found")
197+
198+
alias_id = alias_rows[0][0]
199+
canonical_id = canonical_rows[0][0]
200+
201+
try:
202+
from automem.consolidation.entity_dedup import merge_entities
203+
204+
merge_result = merge_entities(graph, canonical_id, alias_id)
205+
return jsonify({
206+
"status": "success",
207+
"merge": {
208+
"canonical": merge_result.canonical_id,
209+
"alias": merge_result.alias_id,
210+
"alias_slug": merge_result.alias_slug,
211+
"edges_moved": merge_result.edges_moved,
212+
},
213+
})
214+
except Exception as exc:
215+
logger.exception("Merge failed")
216+
return jsonify({"error": "Merge failed", "details": str(exc)}), 500
217+
218+
return bp

automem/api/recall.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,6 +1507,59 @@ def _run_single_query(
15071507
result["jit_enriched"] = True
15081508
jit_enriched_count += 1
15091509

1510+
# Entity identity injection: look up Entity nodes for entities in query/results
1511+
entity_identities: List[Dict[str, Any]] = []
1512+
if query_text and graph is not None:
1513+
try:
1514+
query_entities = _extract_query_entities(query_text)
1515+
result_entity_slugs: Set[str] = set()
1516+
for res in results[:10]:
1517+
mem = res.get("memory") or res
1518+
for tag in mem.get("tags") or []:
1519+
if isinstance(tag, str) and tag.startswith("entity:"):
1520+
parts = tag.split(":")
1521+
if len(parts) >= 3:
1522+
result_entity_slugs.add(parts[2])
1523+
all_entity_slugs: Set[str] = set()
1524+
for ent_name in query_entities:
1525+
all_entity_slugs.add(ent_name.lower().replace(" ", "-"))
1526+
all_entity_slugs.update(result_entity_slugs)
1527+
1528+
slug_list = list(all_entity_slugs)[:10]
1529+
try:
1530+
ent_result = graph.query(
1531+
"""
1532+
MATCH (e:Entity)
1533+
WHERE e.merged_into IS NULL
1534+
AND e.identity IS NOT NULL
1535+
AND (e.slug IN $slugs OR any(a IN e.aliases WHERE a IN $slugs))
1536+
RETURN e.id, e.slug, e.category, e.name, e.aliases,
1537+
e.identity, e.identity_source_count,
1538+
e.identity_updated_at
1539+
""",
1540+
{"slugs": slug_list},
1541+
)
1542+
for row in getattr(ent_result, "result_set", []) or []:
1543+
aliases = row[4] or []
1544+
if isinstance(aliases, str):
1545+
try:
1546+
aliases = json.loads(aliases)
1547+
except Exception:
1548+
aliases = []
1549+
entity_identities.append({
1550+
"id": row[0],
1551+
"name": row[3],
1552+
"category": row[2],
1553+
"aliases": aliases,
1554+
"identity": row[5],
1555+
"identity_source_count": int(row[6] or 0),
1556+
"identity_updated_at": row[7],
1557+
})
1558+
except Exception:
1559+
logger.debug("Entity identity batch lookup failed for slugs %s", slug_list)
1560+
except Exception:
1561+
logger.debug("Entity identity injection failed")
1562+
15101563
response = {
15111564
"status": "success",
15121565
"query": query_text,
@@ -1556,6 +1609,8 @@ def _run_single_query(
15561609
"filtered_count": pre_filter_count - len(results),
15571610
}
15581611
response["query_time_ms"] = round((time.perf_counter() - query_start) * 1000, 2)
1612+
if entity_identities:
1613+
response["entities"] = entity_identities
15591614
if any_context_profile:
15601615
response["context_priority"] = {
15611616
"language": any_context_profile.get("language"),

automem/api/runtime_bootstrap.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from automem.api.admin import create_admin_blueprint_full
66
from automem.api.consolidation import create_consolidation_blueprint_full
77
from automem.api.enrichment import create_enrichment_blueprint
8+
from automem.api.entity import create_entity_blueprint
89
from automem.api.graph import create_graph_blueprint
910
from automem.api.health import create_health_blueprint
1011
from automem.api.memory import create_memory_blueprint_full
@@ -172,6 +173,11 @@ def register_blueprints(
172173
require_api_token=require_api_token_fn,
173174
)
174175

176+
entity_bp = create_entity_blueprint(
177+
get_memory_graph_fn,
178+
logger,
179+
)
180+
175181
app.register_blueprint(health_bp)
176182
app.register_blueprint(enrichment_bp)
177183
app.register_blueprint(memory_bp)
@@ -180,6 +186,7 @@ def register_blueprints(
180186
app.register_blueprint(consolidation_bp)
181187
app.register_blueprint(graph_bp)
182188
app.register_blueprint(stream_bp)
189+
app.register_blueprint(entity_bp)
183190

184191
if is_viewer_enabled():
185192
viewer_bp = create_viewer_blueprint()

automem/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,17 @@
6868
CONSOLIDATION_CONTROL_LABEL = "ConsolidationControl"
6969
CONSOLIDATION_RUN_LABEL = "ConsolidationRun"
7070
CONSOLIDATION_CONTROL_NODE_ID = os.getenv("CONSOLIDATION_CONTROL_NODE_ID", "global")
71+
CONSOLIDATION_IDENTITY_INTERVAL_SECONDS = int(
72+
os.getenv("CONSOLIDATION_IDENTITY_INTERVAL_SECONDS", str(604800)) # 7 days
73+
)
74+
IDENTITY_SYNTHESIS_MODEL = os.getenv("IDENTITY_SYNTHESIS_MODEL", "gpt-5.4")
75+
7176
CONSOLIDATION_TASK_FIELDS = {
7277
"decay": "decay_last_run",
7378
"creative": "creative_last_run",
7479
"cluster": "cluster_last_run",
7580
"forget": "forget_last_run",
81+
"identity": "identity_last_run",
7682
"full": "full_last_run",
7783
}
7884

0 commit comments

Comments
 (0)