first
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user