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:
@@ -955,6 +955,12 @@ class DecompositionPass:
|
||||
logger.info("Pass 0a: %s", stats)
|
||||
return stats
|
||||
|
||||
_NORMATIVE_STRENGTH_MAP = {
|
||||
"muss": "must", "must": "must",
|
||||
"soll": "should", "should": "should",
|
||||
"kann": "may", "may": "may",
|
||||
}
|
||||
|
||||
def _process_pass0a_obligations(
|
||||
self,
|
||||
raw_obligations: list[dict],
|
||||
@@ -964,6 +970,10 @@ class DecompositionPass:
|
||||
) -> None:
|
||||
"""Validate and write obligation candidates from LLM output."""
|
||||
for idx, raw in enumerate(raw_obligations):
|
||||
raw_strength = raw.get("normative_strength", "must").lower().strip()
|
||||
normative_strength = self._NORMATIVE_STRENGTH_MAP.get(
|
||||
raw_strength, "must"
|
||||
)
|
||||
cand = ObligationCandidate(
|
||||
candidate_id=f"OC-{control_id}-{idx + 1:02d}",
|
||||
parent_control_uuid=control_uuid,
|
||||
@@ -971,7 +981,7 @@ class DecompositionPass:
|
||||
action=raw.get("action", ""),
|
||||
object_=raw.get("object", ""),
|
||||
condition=raw.get("condition"),
|
||||
normative_strength=raw.get("normative_strength", "must"),
|
||||
normative_strength=normative_strength,
|
||||
is_test_obligation=bool(raw.get("is_test_obligation", False)),
|
||||
is_reporting_obligation=bool(raw.get("is_reporting_obligation", False)),
|
||||
)
|
||||
@@ -1246,9 +1256,7 @@ class DecompositionPass:
|
||||
seq = self._next_atomic_seq(obl["parent_control_id"])
|
||||
atomic.candidate_id = f"{obl['parent_control_id']}-A{seq:02d}"
|
||||
|
||||
self._write_atomic_control(
|
||||
atomic, obl["parent_uuid"], obl["candidate_id"]
|
||||
)
|
||||
new_uuid = self._write_atomic_control(atomic, obl)
|
||||
|
||||
self.db.execute(
|
||||
text("""
|
||||
@@ -1260,7 +1268,7 @@ class DecompositionPass:
|
||||
)
|
||||
|
||||
# Index in Qdrant for future dedup checks
|
||||
if self._dedup:
|
||||
if self._dedup and new_uuid:
|
||||
pattern_id_val = None
|
||||
pid_row2 = self.db.execute(text(
|
||||
"SELECT pattern_id FROM canonical_controls WHERE id = CAST(:uid AS uuid)"
|
||||
@@ -1268,13 +1276,9 @@ class DecompositionPass:
|
||||
if pid_row2:
|
||||
pattern_id_val = pid_row2[0]
|
||||
|
||||
# Get the UUID of the newly inserted control
|
||||
new_row = self.db.execute(text(
|
||||
"SELECT id::text FROM canonical_controls WHERE control_id = :cid ORDER BY created_at DESC LIMIT 1"
|
||||
), {"cid": atomic.candidate_id}).fetchone()
|
||||
if new_row and pattern_id_val:
|
||||
if pattern_id_val:
|
||||
await self._dedup.index_control(
|
||||
control_uuid=new_row[0],
|
||||
control_uuid=new_uuid,
|
||||
control_id=atomic.candidate_id,
|
||||
title=atomic.title,
|
||||
action=obl.get("action", ""),
|
||||
@@ -1505,43 +1509,88 @@ class DecompositionPass:
|
||||
)
|
||||
|
||||
def _write_atomic_control(
|
||||
self, atomic: AtomicControlCandidate,
|
||||
parent_uuid: str, candidate_id: str,
|
||||
) -> None:
|
||||
"""Insert an atomic control into canonical_controls."""
|
||||
self.db.execute(
|
||||
self, atomic: AtomicControlCandidate, obl: dict,
|
||||
) -> Optional[str]:
|
||||
"""Insert an atomic control and create parent link.
|
||||
|
||||
Returns the UUID of the newly created control, or None on failure.
|
||||
"""
|
||||
parent_uuid = obl["parent_uuid"]
|
||||
candidate_id = obl["candidate_id"]
|
||||
|
||||
result = self.db.execute(
|
||||
text("""
|
||||
INSERT INTO canonical_controls (
|
||||
control_id, title, objective, requirements,
|
||||
test_procedure, evidence, severity, category,
|
||||
control_id, title, objective, rationale,
|
||||
scope, requirements,
|
||||
test_procedure, evidence, severity,
|
||||
open_anchors, category,
|
||||
release_state, parent_control_uuid,
|
||||
decomposition_method,
|
||||
generation_metadata
|
||||
generation_metadata,
|
||||
framework_id,
|
||||
generation_strategy, pipeline_version
|
||||
) VALUES (
|
||||
:control_id, :title, :objective,
|
||||
:requirements, :test_procedure, :evidence,
|
||||
:severity, :category, 'draft',
|
||||
:control_id, :title, :objective, :rationale,
|
||||
:scope, :requirements,
|
||||
:test_procedure, :evidence,
|
||||
:severity, :open_anchors, :category,
|
||||
'draft',
|
||||
CAST(:parent_uuid AS uuid), 'pass0b',
|
||||
:gen_meta
|
||||
:gen_meta,
|
||||
CAST(:framework_id AS uuid),
|
||||
'pass0b', 2
|
||||
)
|
||||
RETURNING id::text
|
||||
"""),
|
||||
{
|
||||
"control_id": atomic.candidate_id,
|
||||
"title": atomic.title,
|
||||
"objective": atomic.objective,
|
||||
"rationale": getattr(atomic, "rationale", None) or "Aus Obligation abgeleitet.",
|
||||
"scope": json.dumps({}),
|
||||
"requirements": json.dumps(atomic.requirements),
|
||||
"test_procedure": json.dumps(atomic.test_procedure),
|
||||
"evidence": json.dumps(atomic.evidence),
|
||||
"severity": atomic.severity,
|
||||
"open_anchors": json.dumps([]),
|
||||
"category": atomic.category,
|
||||
"parent_uuid": parent_uuid,
|
||||
"gen_meta": json.dumps({
|
||||
"decomposition_source": candidate_id,
|
||||
"decomposition_method": "pass0b",
|
||||
}),
|
||||
"framework_id": "14b1bdd2-abc7-4a43-adae-14471ee5c7cf",
|
||||
},
|
||||
)
|
||||
|
||||
row = result.fetchone()
|
||||
new_uuid = row[0] if row else None
|
||||
|
||||
# Create M:N parent link (control_parent_links)
|
||||
if new_uuid:
|
||||
citation = _parse_citation(obl.get("parent_citation", ""))
|
||||
self.db.execute(
|
||||
text("""
|
||||
INSERT INTO control_parent_links
|
||||
(control_uuid, parent_control_uuid, link_type, confidence,
|
||||
source_regulation, source_article, obligation_candidate_id)
|
||||
VALUES
|
||||
(CAST(:cu AS uuid), CAST(:pu AS uuid), 'decomposition', 1.0,
|
||||
:sr, :sa, CAST(:oci AS uuid))
|
||||
ON CONFLICT (control_uuid, parent_control_uuid) DO NOTHING
|
||||
"""),
|
||||
{
|
||||
"cu": new_uuid,
|
||||
"pu": parent_uuid,
|
||||
"sr": citation.get("source", ""),
|
||||
"sa": citation.get("article", ""),
|
||||
"oci": obl["oc_id"],
|
||||
},
|
||||
)
|
||||
|
||||
return new_uuid
|
||||
|
||||
def _next_atomic_seq(self, parent_control_id: str) -> int:
|
||||
"""Get the next sequence number for atomic controls under a parent."""
|
||||
result = self.db.execute(
|
||||
@@ -2004,6 +2053,22 @@ def _format_citation(citation) -> str:
|
||||
return str(citation)
|
||||
|
||||
|
||||
def _parse_citation(citation) -> dict:
|
||||
"""Parse source_citation JSONB into a dict with source/article/paragraph."""
|
||||
if not citation:
|
||||
return {}
|
||||
if isinstance(citation, dict):
|
||||
return citation
|
||||
if isinstance(citation, str):
|
||||
try:
|
||||
c = json.loads(citation)
|
||||
if isinstance(c, dict):
|
||||
return c
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _compute_extraction_confidence(flags: dict) -> float:
|
||||
"""Compute confidence score from quality flags."""
|
||||
score = 0.0
|
||||
|
||||
Reference in New Issue
Block a user