"""GraphRAG plugin routes — relationship CRUD and BFS graph traversal.""" from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.deps import get_current_user, require_permission from app.plugins.builtins.graph_rag.models import EntityRelationship from app.plugins.builtins.graph_rag.schemas import ( RelationshipCreate, TraverseRequest, ) from app.plugins.builtins.graph_rag.services import ( create_relationship, delete_relationship, traverse_graph, ) router = APIRouter(prefix="/api/v1/graph", tags=["graph-rag"]) @router.post("/relationships", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("graph:write"))]) async def create_relationship_route( body: RelationshipCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Create a new relationship between two entities.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) source_id = _parse_uuid(body.source_id, "source_id") target_id = _parse_uuid(body.target_id, "target_id") result = await create_relationship( db=db, tenant_id=tenant_id, source_type=body.source_type, source_id=source_id, target_type=body.target_type, target_id=target_id, relationship_type=body.relationship_type, metadata=body.metadata, owner_id=user_id, ) if "error" in result: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"detail": result["error"], "code": result.get("code", "duplicate")}, ) return result @router.get("/relationships", dependencies=[Depends(require_permission("graph:read"))]) async def list_relationships( source_type: str | None = Query(None, description="Filter by source entity type"), source_id: str | None = Query(None, description="Filter by source entity UUID"), target_type: str | None = Query(None, description="Filter by target entity type"), target_id: str | None = Query(None, description="Filter by target entity UUID"), relationship_type: str | None = Query(None, description="Filter by relationship type"), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List relationships with optional filters.""" tenant_id = uuid.UUID(current_user["tenant_id"]) base = select(EntityRelationship).where( EntityRelationship.tenant_id == tenant_id, EntityRelationship.deleted_at.is_(None), ) if source_type: base = base.where(EntityRelationship.source_type == source_type) if source_id: base = base.where(EntityRelationship.source_id == _parse_uuid(source_id, "source_id")) if target_type: base = base.where(EntityRelationship.target_type == target_type) if target_id: base = base.where(EntityRelationship.target_id == _parse_uuid(target_id, "target_id")) if relationship_type: base = base.where(EntityRelationship.relationship_type == relationship_type) # Count total count_q = select(func.count()).select_from(base.subquery()) total_result = await db.execute(count_q) total = total_result.scalar_one() # Paginated query offset = (page - 1) * page_size stmt = base.order_by(EntityRelationship.created_at.desc()).offset(offset).limit(page_size) result = await db.execute(stmt) relationships = result.scalars().all() return { "items": [ { "id": str(r.id), "source_type": r.source_type, "source_id": str(r.source_id), "target_type": r.target_type, "target_id": str(r.target_id), "relationship_type": r.relationship_type, "metadata": r.meta, "owner_id": str(r.owner_id) if r.owner_id else None, "created_at": r.created_at.isoformat() if r.created_at else None, } for r in relationships ], "total": total, } @router.post("/traverse", dependencies=[Depends(require_permission("graph:read"))]) async def traverse_graph_route( body: TraverseRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """BFS traversal of the knowledge graph from a starting node.""" tenant_id = uuid.UUID(current_user["tenant_id"]) source_id = _parse_uuid(body.source_id, "source_id") result = await traverse_graph( db=db, tenant_id=tenant_id, source_type=body.source_type, source_id=source_id, max_hops=body.max_hops, relationship_types=body.relationship_types, ) return result @router.delete("/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("graph:write"))]) async def delete_relationship_route( relationship_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Delete a relationship by ID.""" tenant_id = uuid.UUID(current_user["tenant_id"]) rid = _parse_uuid(relationship_id, "relationship_id") success = await delete_relationship(db, tenant_id, rid) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"detail": "Relationship not found", "code": "not_found"}, ) return Response(status_code=status.HTTP_204_NO_CONTENT) def _parse_uuid(val: str, field: str) -> uuid.UUID: try: return uuid.UUID(val) except (ValueError, TypeError): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": f"Invalid {field}", "code": "invalid_id"}, ) from None