feat: control_parent_links population + traceability API + frontend
- _write_atomic_control() now uses RETURNING id and inserts into
control_parent_links (M:N) with source_regulation, source_article,
and obligation_candidate_id parsed from parent's source_citation
- New _parse_citation() helper for JSONB source_citation extraction
- New GET /controls/{id}/traceability endpoint returning full chain:
parent links with obligations, child controls, source_count
- Backend: control_type filter (atomic/rich) for controls + count
- Frontend: Rechtsgrundlagen section in ControlDetail showing all
parent links per source regulation with obligation text + strength
- Frontend: Atomic/Rich filter dropdown in Control Library list
- Frontend: GenerationStrategyBadge recognizes 'pass0b' strategy
- Tests: 3 new tests for parent_link creation + citation parsing,
existing batch test mock updated for RETURNING clause
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ Endpoints:
|
||||
GET /v1/canonical/frameworks/{framework_id}/controls — Controls of a framework
|
||||
GET /v1/canonical/controls — All controls (filterable)
|
||||
GET /v1/canonical/controls/{control_id} — Single control
|
||||
GET /v1/canonical/controls/{control_id}/traceability — Traceability chain
|
||||
GET /v1/canonical/controls/{control_id}/similar — Find similar controls
|
||||
POST /v1/canonical/controls — Create a control
|
||||
PUT /v1/canonical/controls/{control_id} — Update a control
|
||||
@@ -314,6 +315,7 @@ async def list_controls(
|
||||
target_audience: Optional[str] = Query(None),
|
||||
source: Optional[str] = Query(None, description="Filter by source_citation->source"),
|
||||
search: Optional[str] = Query(None, description="Full-text search in control_id, title, objective"),
|
||||
control_type: Optional[str] = Query(None, description="Filter: atomic, rich, or all"),
|
||||
sort: Optional[str] = Query("control_id", description="Sort field: control_id, created_at, severity"),
|
||||
order: Optional[str] = Query("asc", description="Sort order: asc or desc"),
|
||||
limit: Optional[int] = Query(None, ge=1, le=5000, description="Max results"),
|
||||
@@ -351,6 +353,10 @@ async def list_controls(
|
||||
else:
|
||||
query += " AND source_citation->>'source' = :src"
|
||||
params["src"] = source
|
||||
if control_type == "atomic":
|
||||
query += " AND decomposition_method = 'pass0b'"
|
||||
elif control_type == "rich":
|
||||
query += " AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')"
|
||||
if search:
|
||||
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
|
||||
params["q"] = f"%{search}%"
|
||||
@@ -391,6 +397,7 @@ async def count_controls(
|
||||
target_audience: Optional[str] = Query(None),
|
||||
source: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
control_type: Optional[str] = Query(None),
|
||||
):
|
||||
"""Count controls matching filters (for pagination)."""
|
||||
query = "SELECT count(*) FROM canonical_controls WHERE 1=1"
|
||||
@@ -420,6 +427,10 @@ async def count_controls(
|
||||
else:
|
||||
query += " AND source_citation->>'source' = :src"
|
||||
params["src"] = source
|
||||
if control_type == "atomic":
|
||||
query += " AND decomposition_method = 'pass0b'"
|
||||
elif control_type == "rich":
|
||||
query += " AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')"
|
||||
if search:
|
||||
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
|
||||
params["q"] = f"%{search}%"
|
||||
@@ -481,6 +492,134 @@ async def get_control(control_id: str):
|
||||
return _control_row(row)
|
||||
|
||||
|
||||
@router.get("/controls/{control_id}/traceability")
|
||||
async def get_control_traceability(control_id: str):
|
||||
"""Get the full traceability chain for a control.
|
||||
|
||||
For atomic controls: shows all parent links with source regulations,
|
||||
articles, and the obligation chain.
|
||||
For rich controls: shows child atomic controls derived from them.
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
# Get control UUID
|
||||
ctrl = db.execute(
|
||||
text("""
|
||||
SELECT id, control_id, title, parent_control_uuid,
|
||||
decomposition_method, source_citation
|
||||
FROM canonical_controls WHERE control_id = :cid
|
||||
"""),
|
||||
{"cid": control_id.upper()},
|
||||
).fetchone()
|
||||
|
||||
if not ctrl:
|
||||
raise HTTPException(status_code=404, detail="Control not found")
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"control_id": ctrl.control_id,
|
||||
"title": ctrl.title,
|
||||
"is_atomic": ctrl.decomposition_method == "pass0b",
|
||||
}
|
||||
|
||||
ctrl_uuid = str(ctrl.id)
|
||||
|
||||
# Parent links (M:N) — for atomic controls
|
||||
parent_links = db.execute(
|
||||
text("""
|
||||
SELECT cpl.parent_control_uuid, cpl.link_type,
|
||||
cpl.confidence, cpl.source_regulation,
|
||||
cpl.source_article, cpl.obligation_candidate_id,
|
||||
cc.control_id AS parent_control_id,
|
||||
cc.title AS parent_title,
|
||||
cc.source_citation AS parent_citation,
|
||||
oc.obligation_text, oc.action, oc.object,
|
||||
oc.normative_strength
|
||||
FROM control_parent_links cpl
|
||||
JOIN canonical_controls cc ON cc.id = cpl.parent_control_uuid
|
||||
LEFT JOIN obligation_candidates oc ON oc.id = cpl.obligation_candidate_id
|
||||
WHERE cpl.control_uuid = CAST(:uid AS uuid)
|
||||
ORDER BY cpl.source_regulation, cpl.source_article
|
||||
"""),
|
||||
{"uid": ctrl_uuid},
|
||||
).fetchall()
|
||||
|
||||
result["parent_links"] = [
|
||||
{
|
||||
"parent_control_id": pl.parent_control_id,
|
||||
"parent_title": pl.parent_title,
|
||||
"link_type": pl.link_type,
|
||||
"confidence": float(pl.confidence) if pl.confidence else 1.0,
|
||||
"source_regulation": pl.source_regulation,
|
||||
"source_article": pl.source_article,
|
||||
"parent_citation": pl.parent_citation,
|
||||
"obligation": {
|
||||
"text": pl.obligation_text,
|
||||
"action": pl.action,
|
||||
"object": pl.object,
|
||||
"normative_strength": pl.normative_strength,
|
||||
} if pl.obligation_text else None,
|
||||
}
|
||||
for pl in parent_links
|
||||
]
|
||||
|
||||
# Also include the 1:1 parent (backwards compat) if not already in links
|
||||
if ctrl.parent_control_uuid:
|
||||
parent_uuids_in_links = {
|
||||
str(pl.parent_control_uuid) for pl in parent_links
|
||||
}
|
||||
parent_uuid_str = str(ctrl.parent_control_uuid)
|
||||
if parent_uuid_str not in parent_uuids_in_links:
|
||||
legacy = db.execute(
|
||||
text("""
|
||||
SELECT control_id, title, source_citation
|
||||
FROM canonical_controls WHERE id = CAST(:uid AS uuid)
|
||||
"""),
|
||||
{"uid": parent_uuid_str},
|
||||
).fetchone()
|
||||
if legacy:
|
||||
result["parent_links"].insert(0, {
|
||||
"parent_control_id": legacy.control_id,
|
||||
"parent_title": legacy.title,
|
||||
"link_type": "decomposition",
|
||||
"confidence": 1.0,
|
||||
"source_regulation": None,
|
||||
"source_article": None,
|
||||
"parent_citation": legacy.source_citation,
|
||||
"obligation": None,
|
||||
})
|
||||
|
||||
# Child controls — for rich controls
|
||||
children = db.execute(
|
||||
text("""
|
||||
SELECT control_id, title, category, severity,
|
||||
decomposition_method
|
||||
FROM canonical_controls
|
||||
WHERE parent_control_uuid = CAST(:uid AS uuid)
|
||||
ORDER BY control_id
|
||||
"""),
|
||||
{"uid": ctrl_uuid},
|
||||
).fetchall()
|
||||
|
||||
result["children"] = [
|
||||
{
|
||||
"control_id": ch.control_id,
|
||||
"title": ch.title,
|
||||
"category": ch.category,
|
||||
"severity": ch.severity,
|
||||
"decomposition_method": ch.decomposition_method,
|
||||
}
|
||||
for ch in children
|
||||
]
|
||||
|
||||
# Unique source regulations count
|
||||
regs = set()
|
||||
for pl in result["parent_links"]:
|
||||
if pl.get("source_regulation"):
|
||||
regs.add(pl["source_regulation"])
|
||||
result["source_count"] = len(regs)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONTROL CRUD (CREATE / UPDATE / DELETE)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user