def create_graph_maintenance_router(agent: SymfonicAgent) -> APIRouter:
"""Create a sub-router for graph maintenance endpoints."""
router = APIRouter(tags=["graph-maintenance"])
def _get_graph_store(scope: FrameworkTenantScope):
semantic = agent._orchestrator.get_layer(MemoryLayer.SEMANTIC)
if semantic is None or not hasattr(semantic, "_graph"):
raise HTTPException(404, "Semantic layer not available")
return semantic._graph # type: ignore[union-attr]
# ------------------------------------------------------------------
# POST /edges/bulk-delete
# ------------------------------------------------------------------
@router.post("/edges/bulk-delete")
async def bulk_delete_edges(
request: BulkDeleteRequest,
raw_request: Request,
scope: FrameworkTenantScope = Depends(get_tenant_scope), # noqa: B008
) -> BulkDeleteResponse:
"""Delete multiple edges by ID. Failures are logged but do not fail the request."""
try:
graph_store = _get_graph_store(scope)
memory_scope = scope.to_memory_scope()
deleted = 0
for edge_id in request.ids:
try:
await graph_store.delete_edge(memory_scope, EdgeId(edge_id))
deleted += 1
except Exception:
logger.warning("Failed to delete edge %s", edge_id)
_emit_maintenance_audit(
raw_request, scope,
action="bulk_delete",
resource_type="edge",
metadata={"requested": len(request.ids), "deleted": deleted},
)
return BulkDeleteResponse(deleted=deleted)
except HTTPException:
raise
except SecurityScopeError as exc:
raise HTTPException(403, "Access denied") from exc
except ScopeValidationError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc:
logger.exception("Bulk edge delete failed")
raise HTTPException(500, "Internal server error") from exc
# ------------------------------------------------------------------
# POST /memories/bulk-delete
# ------------------------------------------------------------------
@router.post("/memories/bulk-delete")
async def bulk_delete_nodes(
request: BulkDeleteRequest,
raw_request: Request,
scope: FrameworkTenantScope = Depends(get_tenant_scope), # noqa: B008
) -> BulkDeleteResponse:
"""Delete multiple nodes by ID. Cascade-deletes connected edges."""
try:
graph_store = _get_graph_store(scope)
memory_scope = scope.to_memory_scope()
deleted = 0
for node_id in request.ids:
try:
await graph_store.delete_node(memory_scope, NodeId(node_id))
deleted += 1
except Exception:
logger.warning("Failed to delete node %s", node_id)
_emit_maintenance_audit(
raw_request, scope,
action="bulk_delete",
resource_type="memory_node",
metadata={"requested": len(request.ids), "deleted": deleted},
)
return BulkDeleteResponse(deleted=deleted)
except HTTPException:
raise
except SecurityScopeError as exc:
raise HTTPException(403, "Access denied") from exc
except ScopeValidationError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc:
logger.exception("Bulk node delete failed")
raise HTTPException(500, "Internal server error") from exc
# ------------------------------------------------------------------
# GET /memory/audit
# ------------------------------------------------------------------
@router.get("/memory/audit")
async def audit_graph(
min_edge_weight: float = 1.5,
scope: FrameworkTenantScope = Depends(get_tenant_scope), # noqa: B008
) -> AuditResponse:
"""Analyse the graph and return orphan nodes, weak edges, and density."""
try:
graph_store = _get_graph_store(scope)
memory_scope = scope.to_memory_scope()
total_nodes, total_edges, density, orphans, weak = await _run_audit(
graph_store, memory_scope, min_edge_weight,
)
return AuditResponse(
total_nodes=total_nodes,
total_edges=total_edges,
density=density,
orphan_nodes=orphans,
weak_edges=weak,
recommendations=AuditRecommendations(
prune_orphans=len(orphans),
prune_weak_edges=len(weak),
),
)
except HTTPException:
raise
except SecurityScopeError as exc:
raise HTTPException(403, "Access denied") from exc
except ScopeValidationError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc:
logger.exception("Graph audit failed")
raise HTTPException(500, "Internal server error") from exc
# ------------------------------------------------------------------
# POST /memory/prune
# ------------------------------------------------------------------
@router.post("/memory/prune")
async def prune_graph(
request: PruneRequest,
raw_request: Request,
scope: FrameworkTenantScope = Depends(get_tenant_scope), # noqa: B008
) -> PruneResponse:
"""Prune orphan nodes and/or weak edges from the graph.
When ``dry_run`` is true, returns what *would* be deleted without
making any changes.
"""
try:
graph_store = _get_graph_store(scope)
memory_scope = scope.to_memory_scope()
_, _, _, orphans, weak = await _run_audit(
graph_store, memory_scope, request.min_edge_weight,
)
target_orphans = orphans if request.prune_orphans else []
target_weak = weak if request.prune_weak_edges else []
orphans_pruned = 0
edges_pruned = 0
if not request.dry_run:
# Delete orphan nodes (cascade-deletes their edges)
for orphan in target_orphans:
try:
await graph_store.delete_node(
memory_scope, NodeId(orphan.id),
)
orphans_pruned += 1
except Exception:
logger.warning("Failed to prune orphan node %s", orphan.id)
# Delete weak edges
for edge in target_weak:
try:
await graph_store.delete_edge(
memory_scope, EdgeId(edge.id),
)
edges_pruned += 1
except Exception:
logger.warning("Failed to prune weak edge %s", edge.id)
else:
orphans_pruned = len(target_orphans)
edges_pruned = len(target_weak)
# Audit only real (non-dry-run) prunes — dry runs are reads.
if not request.dry_run and (orphans_pruned or edges_pruned):
_emit_maintenance_audit(
raw_request, scope,
action="prune",
resource_type="graph",
metadata={
"orphans_pruned": orphans_pruned,
"edges_pruned": edges_pruned,
},
)
return PruneResponse(
dry_run=request.dry_run,
orphans_pruned=orphans_pruned,
edges_pruned=edges_pruned,
details=PruneDetails(
orphan_nodes=target_orphans,
weak_edges=target_weak,
),
)
except HTTPException:
raise
except SecurityScopeError as exc:
raise HTTPException(403, "Access denied") from exc
except ScopeValidationError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc:
logger.exception("Graph prune failed")
raise HTTPException(500, "Internal server error") from exc
return router