"""Traceability: return graph edges + node summaries for the React Flow view. Endpoint returns a list of ``nodes`` (artifacts) and ``edges`` (relationships) that the frontend can feed directly into React Flow to render an interactive traceability graph. """ from __future__ import annotations from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from .deps import artifact_service, relationship_service from ...services import ArtifactService, RelationshipService router = APIRouter() class FlowNode(BaseModel): id: str type: str # artifact type: requirement, task, ... label: str project_id: Optional[str] = None status: Optional[str] = None version: int = 1 source: Optional[str] = None class FlowEdge(BaseModel): id: str source: str target: str label: str # relationship_type, e.g. "implemented_by" class TraceabilityGraph(BaseModel): nodes: list[FlowNode] edges: list[FlowEdge] mock_fields: list[str] = [] @router.get( "/graph", response_model=TraceabilityGraph, summary="Return a React-Flow-ready graph (nodes+edges) starting from a seed artifact, project, or the entire tenant.", ) async def traceability_graph( artifact_id: Optional[UUID] = Query( None, description="Seed artifact. Edges will be traversed outward (outgoing + incoming).", ), project_id: Optional[UUID] = Query( None, description="Constrain the graph to a project. When set without artifact_id, returns up to `limit` artifacts.", ), max_depth: int = Query(2, ge=1, le=5, description="BFS depth when seed artifact_id is provided."), limit: int = Query(200, ge=1, le=1000, description="Max total nodes returned."), artifacts: ArtifactService = Depends(artifact_service), relationships: RelationshipService = Depends(relationship_service), ) -> TraceabilityGraph: nodes: dict[UUID, FlowNode] = {} edges: list[FlowEdge] = [] async def _add_art(ids: list[UUID]) -> None: needed = [i for i in ids if i not in nodes and len(nodes) < limit] if not needed: return for art in await artifacts.get_many(needed): nodes[art.id] = FlowNode( id=str(art.id), type=art.type if isinstance(art.type, str) else art.type.value, label=art.title, project_id=str(art.project_id), status=art.status, version=art.version, source=art.source, ) if artifact_id is not None: # BFS around the seed frontier: set[UUID] = {artifact_id} visited: set[UUID] = set() for _ in range(max_depth): current = list(frontier - visited) if not current: break await _add_art(current) visited.update(current) next_front: set[UUID] = set() for aid in current: out = await relationships.outgoing(aid, limit=50) for rel in out: edges.append( FlowEdge( id=f"e-{rel.id}", source=str(rel.source_artifact_id), target=str(rel.target_artifact_id), label=rel.relationship_type if isinstance(rel.relationship_type, str) else rel.relationship_type.value, ) ) next_front.add(rel.target_artifact_id) inc = await relationships.incoming(aid, limit=50) for rel in inc: edges.append( FlowEdge( id=f"e-{rel.id}", source=str(rel.source_artifact_id), target=str(rel.target_artifact_id), label=rel.relationship_type if isinstance(rel.relationship_type, str) else rel.relationship_type.value, ) ) next_front.add(rel.source_artifact_id) frontier = next_front - visited elif project_id is not None: arts = await artifacts.list(project_id=project_id, limit=limit) await _add_art([a.id for a in arts]) # relationships for every artifact we loaded for a in arts: out = await relationships.outgoing(a.id, limit=50) for rel in out: edges.append( FlowEdge( id=f"e-{rel.id}", source=str(rel.source_artifact_id), target=str(rel.target_artifact_id), label=rel.relationship_type if isinstance(rel.relationship_type, str) else rel.relationship_type.value, ) ) # filter edges to those whose endpoints are both in our node set node_ids = set(nodes.keys()) edges = [e for e in edges if UUID(e.source) in node_ids and UUID(e.target) in node_ids] else: # No seed, no project — return tenant-wide recent artifacts (bounded) arts = await artifacts.list(limit=limit) await _add_art([a.id for a in arts]) for a in arts: out = await relationships.outgoing(a.id, limit=20) for rel in out: edges.append( FlowEdge( id=f"e-{rel.id}", source=str(rel.source_artifact_id), target=str(rel.target_artifact_id), label=rel.relationship_type if isinstance(rel.relationship_type, str) else rel.relationship_type.value, ) ) node_ids = set(nodes.keys()) edges = [e for e in edges if UUID(e.source) in node_ids and UUID(e.target) in node_ids] return TraceabilityGraph(nodes=list(nodes.values()), edges=edges)