Files
breakpilot-core/control-pipeline/api/dependency_routes.py
Benjamin Admin 42ab5ead26 feat(pipeline): implement Control Dependency Engine (Block 9)
Core engine (dependency_engine.py):
- 5 dependency types: prerequisite, supersedes, compensating_control,
  conditional_requirement, scope_exclusion
- Generic condition evaluator (JSONB rules with AND/OR/NOT/field ops)
- Priority-based conflict resolution
- Cycle detection (DFS) + topological sort
- Full evaluation with MCP-compatible dependency_resolution trace
- 39 tests all passing (incl. GHV scenario from user requirements)

Automatic generator (dependency_generator.py):
- Ontology-based: same normalized_object + phase sequence -> prerequisite
- Pattern-based: define->implement, implement->monitor, etc.
- Domain packs: YAML rules for GDPR, AI Act, CRA, Security, Labor Contracts
- 14 tests all passing

API routes (dependency_routes.py):
- CRUD for dependencies
- POST /evaluate with dependency resolution
- POST /generate (auto-generation with dry_run)
- POST /validate (cycle detection)
- GET /graph (nodes + edges for visualization)

Prompt enhancement (decomposition_pass.py):
- Added dependency_hints + lifecycle_phase_order to Pass 0b prompt
- Stored in generation_metadata for post-processing

DB migration: control_dependencies + control_evaluation_results tables

126 tests total, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 20:28:10 +02:00

449 lines
15 KiB
Python

"""
FastAPI routes for the Control Dependency Engine.
Endpoints:
GET /v1/dependencies — List dependencies
POST /v1/dependencies — Create a dependency
DELETE /v1/dependencies/{dep_id} — Deactivate a dependency
POST /v1/dependencies/generate — Auto-generate dependencies
POST /v1/dependencies/evaluate — Evaluate controls with dependencies
GET /v1/dependencies/evaluate/{run_id} — Get evaluation results
POST /v1/dependencies/validate — Validate graph (cycle check)
GET /v1/dependencies/graph — Dependency graph for visualization
"""
import json
import logging
import uuid
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from db.session import SessionLocal
from services.dependency_engine import (
Dependency,
ControlState,
EvaluationResult,
evaluate_controls,
detect_cycles,
load_all_active_dependencies,
load_dependencies_for_controls,
store_dependency,
store_evaluation_results,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/dependencies", tags=["dependencies"])
# =============================================================================
# REQUEST / RESPONSE MODELS
# =============================================================================
class DependencyCreateRequest(BaseModel):
source_control_id: str
target_control_id: str
dependency_type: str
condition: dict = {}
effect: dict = {}
priority: int = 100
class EvaluateRequest(BaseModel):
control_ids: Optional[list] = None
company_profile: dict = {}
control_statuses: dict = {}
class GenerateRequest(BaseModel):
enable_ontology: bool = True
enable_patterns: bool = True
enable_domain_packs: bool = True
dry_run: bool = True
limit: int = 0
# =============================================================================
# LIST DEPENDENCIES
# =============================================================================
@router.get("/")
async def list_dependencies(
dependency_type: Optional[str] = Query(default=None),
source_id: Optional[str] = Query(default=None),
target_id: Optional[str] = Query(default=None),
active_only: bool = Query(default=True),
limit: int = Query(default=100, le=1000),
offset: int = Query(default=0),
):
"""List dependencies with optional filters."""
db = SessionLocal()
try:
conditions = []
params: dict = {"lim": limit, "off": offset}
if active_only:
conditions.append("is_active = TRUE")
if dependency_type:
conditions.append("dependency_type = :dtype")
params["dtype"] = dependency_type
if source_id:
conditions.append("source_control_id = CAST(:src AS uuid)")
params["src"] = source_id
if target_id:
conditions.append("target_control_id = CAST(:tgt AS uuid)")
params["tgt"] = target_id
where = "WHERE " + " AND ".join(conditions) if conditions else ""
rows = db.execute(text(f"""
SELECT d.id, d.source_control_id, d.target_control_id,
d.dependency_type, d.condition, d.effect, d.priority,
d.generation_method, d.is_active, d.created_at,
s.control_id AS source_cid, s.title AS source_title,
t.control_id AS target_cid, t.title AS target_title
FROM control_dependencies d
LEFT JOIN canonical_controls s ON s.id = d.source_control_id
LEFT JOIN canonical_controls t ON t.id = d.target_control_id
{where}
ORDER BY d.priority, d.created_at
LIMIT :lim OFFSET :off
"""), params).fetchall()
total = db.execute(text(f"""
SELECT COUNT(*) FROM control_dependencies d {where}
"""), params).scalar()
return {
"total": total,
"dependencies": [
{
"id": str(r[0]),
"source_control_id": str(r[1]),
"target_control_id": str(r[2]),
"dependency_type": r[3],
"condition": r[4],
"effect": r[5],
"priority": r[6],
"generation_method": r[7],
"is_active": r[8],
"created_at": str(r[9]) if r[9] else None,
"source_control_id_short": r[10] or "",
"source_title": r[11] or "",
"target_control_id_short": r[12] or "",
"target_title": r[13] or "",
}
for r in rows
],
}
finally:
db.close()
# =============================================================================
# CREATE DEPENDENCY
# =============================================================================
@router.post("/")
async def create_dependency(req: DependencyCreateRequest):
"""Create a manual dependency."""
valid_types = {"prerequisite", "conditional_requirement", "supersedes",
"compensating_control", "scope_exclusion"}
if req.dependency_type not in valid_types:
raise HTTPException(400, f"Invalid type. Must be one of: {valid_types}")
if req.source_control_id == req.target_control_id:
raise HTTPException(400, "Source and target must be different")
db = SessionLocal()
try:
dep = Dependency(
source_control_id=req.source_control_id,
target_control_id=req.target_control_id,
dependency_type=req.dependency_type,
condition=req.condition,
effect=req.effect,
priority=req.priority,
generation_method="manual",
)
dep_id = store_dependency(db, dep)
db.commit()
return {"id": dep_id, "status": "created"}
except Exception as e:
db.rollback()
raise HTTPException(500, str(e))
finally:
db.close()
# =============================================================================
# DELETE (DEACTIVATE) DEPENDENCY
# =============================================================================
@router.delete("/{dep_id}")
async def deactivate_dependency(dep_id: str):
"""Deactivate a dependency (soft delete)."""
db = SessionLocal()
try:
result = db.execute(
text("UPDATE control_dependencies SET is_active = FALSE, updated_at = NOW() WHERE id = CAST(:did AS uuid)"),
{"did": dep_id},
)
db.commit()
if result.rowcount == 0:
raise HTTPException(404, "Dependency not found")
return {"status": "deactivated"}
finally:
db.close()
# =============================================================================
# AUTO-GENERATE DEPENDENCIES
# =============================================================================
@router.post("/generate")
async def generate_dependencies(req: GenerateRequest):
"""Auto-generate dependencies from ontology, patterns, and domain packs."""
from services.dependency_generator import generate_all_dependencies
db = SessionLocal()
try:
query = """
SELECT id::text, control_id, title, generation_metadata
FROM canonical_controls
WHERE release_state = 'draft'
AND generation_metadata->>'decomposition_method' = 'pass0b'
"""
if req.limit > 0:
query += f" LIMIT {req.limit}"
rows = db.execute(text(query)).fetchall()
controls = []
for r in rows:
meta = r[3] if isinstance(r[3], dict) else {}
controls.append({
"id": r[0],
"control_id": r[1],
"title": r[2],
"generation_metadata": meta,
})
deps, stats = generate_all_dependencies(
controls,
enable_ontology=req.enable_ontology,
enable_patterns=req.enable_patterns,
enable_domain_packs=req.enable_domain_packs,
)
if not req.dry_run:
stored = 0
for dep in deps:
store_dependency(db, dep)
stored += 1
db.commit()
stats["stored"] = stored
return {
"dry_run": req.dry_run,
"controls_analyzed": len(controls),
"stats": stats,
"sample_dependencies": [
{
"source": d.source_control_id[:8],
"target": d.target_control_id[:8],
"type": d.dependency_type,
"method": d.generation_method,
"priority": d.priority,
}
for d in deps[:20]
],
}
finally:
db.close()
# =============================================================================
# EVALUATE CONTROLS
# =============================================================================
@router.post("/evaluate")
async def evaluate(req: EvaluateRequest):
"""Evaluate controls with dependency resolution."""
db = SessionLocal()
try:
# Load control statuses
if req.control_statuses:
control_ids = list(req.control_statuses.keys())
states = {
cid: ControlState(control_id=cid, raw_status=status)
for cid, status in req.control_statuses.items()
}
elif req.control_ids:
control_ids = req.control_ids
states = {
cid: ControlState(control_id=cid, raw_status="fail")
for cid in control_ids
}
else:
raise HTTPException(400, "Provide control_ids or control_statuses")
# Load dependencies
deps = load_dependencies_for_controls(db, control_ids)
# Evaluate
results = evaluate_controls(states, deps, req.company_profile)
# Store results
store_evaluation_results(db, results, req.company_profile)
db.commit()
# Format response
run_id = next(iter(results.values())).evaluation_run_id if results else ""
return {
"evaluation_run_id": run_id,
"total_controls": len(results),
"dependencies_evaluated": len(deps),
"results": [
{
"control_id": r.control_id,
"raw_status": r.raw_status,
"resolved_status": r.resolved_status,
"dependency_resolution": r.dependency_resolution,
"confidence": r.confidence,
}
for r in results.values()
],
"summary": {
"pass": sum(1 for r in results.values() if r.resolved_status == "pass"),
"fail": sum(1 for r in results.values() if r.resolved_status == "fail"),
"not_applicable": sum(1 for r in results.values() if r.resolved_status == "not_applicable"),
"compensated_fail": sum(1 for r in results.values() if r.resolved_status == "compensated_fail"),
"review_required": sum(1 for r in results.values() if r.resolved_status == "review_required"),
},
}
finally:
db.close()
# =============================================================================
# GET EVALUATION RESULTS
# =============================================================================
@router.get("/evaluate/{run_id}")
async def get_evaluation_results(run_id: str):
"""Get stored evaluation results for a run."""
db = SessionLocal()
try:
rows = db.execute(text("""
SELECT er.control_id::text, cc.control_id, cc.title,
er.raw_status, er.resolved_status,
er.dependency_resolution, er.confidence, er.reasoning
FROM control_evaluation_results er
JOIN canonical_controls cc ON cc.id = er.control_id
WHERE er.evaluation_run_id = CAST(:rid AS uuid)
ORDER BY er.resolved_status, cc.control_id
"""), {"rid": run_id}).fetchall()
if not rows:
raise HTTPException(404, "Evaluation run not found")
return {
"evaluation_run_id": run_id,
"total": len(rows),
"results": [
{
"control_uuid": r[0],
"control_id": r[1],
"title": r[2],
"raw_status": r[3],
"resolved_status": r[4],
"dependency_resolution": r[5],
"confidence": r[6],
"reasoning": r[7],
}
for r in rows
],
}
finally:
db.close()
# =============================================================================
# VALIDATE GRAPH (CYCLE CHECK)
# =============================================================================
@router.post("/validate")
async def validate_graph():
"""Validate the dependency graph for cycles."""
db = SessionLocal()
try:
deps = load_all_active_dependencies(db)
cycles = detect_cycles(deps)
return {
"total_dependencies": len(deps),
"cycles_found": len(cycles),
"cycles": cycles[:20],
"is_valid": len(cycles) == 0,
}
finally:
db.close()
# =============================================================================
# DEPENDENCY GRAPH (FOR VISUALIZATION)
# =============================================================================
@router.get("/graph")
async def get_graph(limit: int = Query(default=200, le=1000)):
"""Get dependency graph as nodes + edges."""
db = SessionLocal()
try:
deps = load_all_active_dependencies(db)[:limit]
node_ids = set()
for d in deps:
node_ids.add(d.source_control_id)
node_ids.add(d.target_control_id)
nodes = []
if node_ids:
id_list = list(node_ids)
rows = db.execute(text("""
SELECT id::text, control_id, title, release_state, category
FROM canonical_controls
WHERE id = ANY(CAST(:ids AS uuid[]))
"""), {"ids": id_list}).fetchall()
for r in rows:
nodes.append({
"id": r[0],
"control_id": r[1],
"title": r[2],
"release_state": r[3],
"category": r[4],
})
edges = [
{
"source": d.source_control_id,
"target": d.target_control_id,
"type": d.dependency_type,
"priority": d.priority,
"generation_method": d.generation_method,
}
for d in deps
]
return {
"nodes": nodes,
"edges": edges,
"total_nodes": len(nodes),
"total_edges": len(edges),
}
finally:
db.close()