""" 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()