Skip to content

symfonic.agent.fastapi.graph_maintenance_router

graph_maintenance_router

FastAPI router for graph maintenance endpoints.

Provides bulk delete operations, graph audit (dry-run analysis), and graph pruning. Split from graph_router.py to keep each module under ~300 lines.

create_graph_maintenance_router

create_graph_maintenance_router(
    agent: SymfonicAgent,
) -> APIRouter

Create a sub-router for graph maintenance endpoints.

Source code in src/symfonic/agent/fastapi/graph_maintenance_router.py
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