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:
@@ -1118,10 +1118,15 @@ class TestDecompositionPassAnthropicBatch:
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return mock_rows # SELECT candidates
|
||||
# _next_atomic_seq calls (every 3rd after first: 2, 5, 8, ...)
|
||||
if call_count[0] in (2, 5):
|
||||
# _next_atomic_seq calls: call 2 (control 1), call 6 (control 2)
|
||||
if call_count[0] in (2, 6):
|
||||
return mock_seq
|
||||
return MagicMock() # INSERT/UPDATE
|
||||
# INSERT RETURNING calls: call 3 (control 1), call 7 (control 2)
|
||||
if call_count[0] in (3, 7):
|
||||
mock_insert = MagicMock()
|
||||
mock_insert.fetchone.return_value = (f"new-uuid-{call_count[0]}",)
|
||||
return mock_insert
|
||||
return MagicMock() # parent_links INSERT / UPDATE
|
||||
mock_db.execute.side_effect = side_effect
|
||||
|
||||
batched_response = json.dumps({
|
||||
@@ -1608,12 +1613,16 @@ class TestPass0bWithEnrichment:
|
||||
mock_db = MagicMock()
|
||||
mock_seq = MagicMock()
|
||||
mock_seq.fetchone.return_value = (0,)
|
||||
mock_insert = MagicMock()
|
||||
mock_insert.fetchone.return_value = ("new-uuid-1",)
|
||||
|
||||
call_count = [0]
|
||||
def side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return mock_seq # _next_atomic_seq
|
||||
if call_count[0] == 2:
|
||||
return mock_insert # INSERT RETURNING id
|
||||
return MagicMock()
|
||||
mock_db.execute.side_effect = side_effect
|
||||
|
||||
@@ -1623,12 +1632,20 @@ class TestPass0bWithEnrichment:
|
||||
decomp._process_pass0b_control(obl, parsed, stats)
|
||||
)
|
||||
|
||||
# _write_atomic_control is call #2: db.execute(text(...), {params})
|
||||
# _write_atomic_control INSERT is call #2: db.execute(text(...), {params})
|
||||
insert_call = mock_db.execute.call_args_list[1]
|
||||
# positional args: (text_obj, params_dict)
|
||||
insert_params = insert_call[0][1]
|
||||
assert insert_params["severity"] == "medium"
|
||||
|
||||
# parent_link INSERT is call #3
|
||||
link_call = mock_db.execute.call_args_list[2]
|
||||
link_query = str(link_call[0][0])
|
||||
assert "control_parent_links" in link_query
|
||||
link_params = link_call[0][1]
|
||||
assert link_params["cu"] == "new-uuid-1"
|
||||
assert link_params["pu"] == "p-uuid"
|
||||
|
||||
def test_test_obligation_gets_testing_category(self):
|
||||
"""Test obligations should get category='testing'."""
|
||||
obl = {
|
||||
@@ -1664,12 +1681,16 @@ class TestPass0bWithEnrichment:
|
||||
mock_db = MagicMock()
|
||||
mock_seq = MagicMock()
|
||||
mock_seq.fetchone.return_value = (0,)
|
||||
mock_insert = MagicMock()
|
||||
mock_insert.fetchone.return_value = ("new-uuid-2",)
|
||||
|
||||
call_count = [0]
|
||||
def side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return mock_seq
|
||||
if call_count[0] == 2:
|
||||
return mock_insert # INSERT RETURNING id
|
||||
return MagicMock()
|
||||
mock_db.execute.side_effect = side_effect
|
||||
|
||||
@@ -1679,7 +1700,99 @@ class TestPass0bWithEnrichment:
|
||||
decomp._process_pass0b_control(obl, parsed, stats)
|
||||
)
|
||||
|
||||
# _write_atomic_control is call #2: db.execute(text(...), {params})
|
||||
# _write_atomic_control INSERT is call #2: db.execute(text(...), {params})
|
||||
insert_call = mock_db.execute.call_args_list[1]
|
||||
insert_params = insert_call[0][1]
|
||||
assert insert_params["category"] == "testing"
|
||||
|
||||
def test_parent_link_created_with_source_citation(self):
|
||||
"""_write_atomic_control inserts a row into control_parent_links
|
||||
with source_regulation and source_article parsed from parent_citation."""
|
||||
import json as _json
|
||||
obl = {
|
||||
"oc_id": "oc-link-1",
|
||||
"candidate_id": "OC-DSGVO-01",
|
||||
"parent_uuid": "p-uuid-dsgvo",
|
||||
"obligation_text": "Daten minimieren",
|
||||
"action": "minimieren",
|
||||
"object": "personenbezogene Daten",
|
||||
"is_test": False,
|
||||
"is_reporting": False,
|
||||
"parent_title": "Datenminimierung",
|
||||
"parent_category": "privacy",
|
||||
"parent_citation": _json.dumps({
|
||||
"source": "DSGVO",
|
||||
"article": "Art. 5 Abs. 1 lit. c",
|
||||
"paragraph": "",
|
||||
}),
|
||||
"parent_severity": "high",
|
||||
"parent_control_id": "PRIV-001",
|
||||
"source_ref": "DSGVO Art. 5 Abs. 1 lit. c",
|
||||
"trigger_type": "continuous",
|
||||
"is_implementation_specific": False,
|
||||
}
|
||||
parsed = {
|
||||
"title": "Personenbezogene Daten minimieren",
|
||||
"objective": "Nur erforderliche Daten erheben",
|
||||
"requirements": ["Datenminimierung"],
|
||||
"test_procedure": ["Audit"],
|
||||
"evidence": ["Protokoll"],
|
||||
"severity": "high",
|
||||
"category": "privacy",
|
||||
}
|
||||
stats = {"controls_created": 0, "candidates_processed": 0,
|
||||
"llm_failures": 0, "dedup_linked": 0, "dedup_review": 0}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_seq = MagicMock()
|
||||
mock_seq.fetchone.return_value = (0,)
|
||||
mock_insert = MagicMock()
|
||||
mock_insert.fetchone.return_value = ("new-uuid-dsgvo",)
|
||||
|
||||
call_count = [0]
|
||||
def side_effect(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return mock_seq
|
||||
if call_count[0] == 2:
|
||||
return mock_insert
|
||||
return MagicMock()
|
||||
mock_db.execute.side_effect = side_effect
|
||||
|
||||
import asyncio
|
||||
decomp = DecompositionPass(db=mock_db)
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
decomp._process_pass0b_control(obl, parsed, stats)
|
||||
)
|
||||
|
||||
# Call #3 is the parent_link INSERT
|
||||
link_call = mock_db.execute.call_args_list[2]
|
||||
link_query = str(link_call[0][0])
|
||||
assert "control_parent_links" in link_query
|
||||
link_params = link_call[0][1]
|
||||
assert link_params["cu"] == "new-uuid-dsgvo"
|
||||
assert link_params["pu"] == "p-uuid-dsgvo"
|
||||
assert link_params["sr"] == "DSGVO"
|
||||
assert link_params["sa"] == "Art. 5 Abs. 1 lit. c"
|
||||
assert link_params["oci"] == "oc-link-1"
|
||||
|
||||
def test_parse_citation_handles_formats(self):
|
||||
"""_parse_citation handles JSON string, dict, empty, and invalid."""
|
||||
import json as _json
|
||||
from compliance.services.decomposition_pass import _parse_citation
|
||||
|
||||
# JSON string
|
||||
result = _parse_citation(_json.dumps({"source": "NIS2", "article": "Art. 21"}))
|
||||
assert result["source"] == "NIS2"
|
||||
assert result["article"] == "Art. 21"
|
||||
|
||||
# Already a dict
|
||||
result = _parse_citation({"source": "DSGVO", "article": "Art. 5"})
|
||||
assert result["source"] == "DSGVO"
|
||||
|
||||
# Empty / None
|
||||
assert _parse_citation("") == {}
|
||||
assert _parse_citation(None) == {}
|
||||
|
||||
# Invalid JSON
|
||||
assert _parse_citation("not json") == {}
|
||||
|
||||
Reference in New Issue
Block a user