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>
This commit is contained in:
@@ -3,8 +3,10 @@ from fastapi import APIRouter
|
||||
from api.control_generator_routes import router as generator_router
|
||||
from api.canonical_control_routes import router as canonical_router
|
||||
from api.document_compliance_routes import router as document_router
|
||||
from api.dependency_routes import router as dependency_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(generator_router)
|
||||
router.include_router(canonical_router)
|
||||
router.include_router(document_router)
|
||||
router.include_router(dependency_router)
|
||||
|
||||
448
control-pipeline/api/dependency_routes.py
Normal file
448
control-pipeline/api/dependency_routes.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user