This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
@@ -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)