Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).
## Phase 0 — Architecture guardrails
Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:
1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
that would exceed the 500-line hard cap. Auto-loads in every Claude
session in this repo.
2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
enforces the LOC cap locally, freezes migrations/ without
[migration-approved], and protects guardrail files without
[guardrail-change].
3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
packages (compliance/{services,repositories,domain,schemas}), and
tsc --noEmit for admin-compliance + developer-portal.
Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.
scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.
## Deprecation sweep
47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.
DeprecationWarning count dropped from 158 to 35.
## Phase 1 Step 1 — Contract test harness
tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.
## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)
compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):
regulation_models.py (134) — Regulation, Requirement
control_models.py (279) — Control, Mapping, Evidence, Risk
ai_system_models.py (141) — AISystem, AuditExport
service_module_models.py (176) — ServiceModule, ModuleRegulation, ModuleRisk
audit_session_models.py (177) — AuditSession, AuditSignOff
isms_governance_models.py (323) — ISMSScope, Context, Policy, Objective, SoA
isms_audit_models.py (468) — Finding, CAPA, MgmtReview, InternalAudit,
AuditTrail, Readiness
models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.
All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.
## Phase 1 Step 3 — infrastructure only
backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.
PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.
## Verification
backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
PYTHONPATH=. pytest compliance/tests/ tests/contracts/
-> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
378 lines
13 KiB
Python
378 lines
13 KiB
Python
"""
|
|
FastAPI routes for AI Act Compliance — AI System CRUD.
|
|
|
|
Endpoints:
|
|
- /ai/systems: List/Create AI systems
|
|
- /ai/systems/{id}: Get/Update/Delete AI system
|
|
- /ai/systems/{id}/assess: Run AI Act risk assessment
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
|
|
from ..db.models import AISystemDB, AIClassificationEnum, AISystemStatusEnum
|
|
from .schemas import (
|
|
AISystemCreate, AISystemUpdate, AISystemResponse, AISystemListResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["compliance-ai"])
|
|
|
|
|
|
# ============================================================================
|
|
# AI System CRUD Endpoints (AI Act Compliance)
|
|
# ============================================================================
|
|
|
|
@router.get("/ai/systems", response_model=AISystemListResponse)
|
|
async def list_ai_systems(
|
|
classification: Optional[str] = Query(None, description="Filter by classification"),
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|
sector: Optional[str] = Query(None, description="Filter by sector"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List all registered AI systems."""
|
|
query = db.query(AISystemDB)
|
|
|
|
if classification:
|
|
try:
|
|
cls_enum = AIClassificationEnum(classification)
|
|
query = query.filter(AISystemDB.classification == cls_enum)
|
|
except ValueError:
|
|
pass
|
|
|
|
if status:
|
|
try:
|
|
status_enum = AISystemStatusEnum(status)
|
|
query = query.filter(AISystemDB.status == status_enum)
|
|
except ValueError:
|
|
pass
|
|
|
|
if sector:
|
|
query = query.filter(AISystemDB.sector.ilike(f"%{sector}%"))
|
|
|
|
systems = query.order_by(AISystemDB.created_at.desc()).all()
|
|
|
|
results = [
|
|
AISystemResponse(
|
|
id=s.id,
|
|
name=s.name,
|
|
description=s.description,
|
|
purpose=s.purpose,
|
|
sector=s.sector,
|
|
classification=s.classification.value if s.classification else "unclassified",
|
|
status=s.status.value if s.status else "draft",
|
|
obligations=s.obligations or [],
|
|
assessment_date=s.assessment_date,
|
|
assessment_result=s.assessment_result,
|
|
risk_factors=s.risk_factors,
|
|
recommendations=s.recommendations,
|
|
created_at=s.created_at,
|
|
updated_at=s.updated_at,
|
|
)
|
|
for s in systems
|
|
]
|
|
|
|
return AISystemListResponse(systems=results, total=len(results))
|
|
|
|
|
|
@router.post("/ai/systems", response_model=AISystemResponse)
|
|
async def create_ai_system(
|
|
data: AISystemCreate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Register a new AI system."""
|
|
import uuid as _uuid
|
|
|
|
try:
|
|
cls_enum = AIClassificationEnum(data.classification) if data.classification else AIClassificationEnum.UNCLASSIFIED
|
|
except ValueError:
|
|
cls_enum = AIClassificationEnum.UNCLASSIFIED
|
|
|
|
try:
|
|
status_enum = AISystemStatusEnum(data.status) if data.status else AISystemStatusEnum.DRAFT
|
|
except ValueError:
|
|
status_enum = AISystemStatusEnum.DRAFT
|
|
|
|
system = AISystemDB(
|
|
id=str(_uuid.uuid4()),
|
|
name=data.name,
|
|
description=data.description,
|
|
purpose=data.purpose,
|
|
sector=data.sector,
|
|
classification=cls_enum,
|
|
status=status_enum,
|
|
obligations=data.obligations or [],
|
|
)
|
|
db.add(system)
|
|
db.commit()
|
|
db.refresh(system)
|
|
|
|
return AISystemResponse(
|
|
id=system.id,
|
|
name=system.name,
|
|
description=system.description,
|
|
purpose=system.purpose,
|
|
sector=system.sector,
|
|
classification=system.classification.value if system.classification else "unclassified",
|
|
status=system.status.value if system.status else "draft",
|
|
obligations=system.obligations or [],
|
|
assessment_date=system.assessment_date,
|
|
assessment_result=system.assessment_result,
|
|
risk_factors=system.risk_factors,
|
|
recommendations=system.recommendations,
|
|
created_at=system.created_at,
|
|
updated_at=system.updated_at,
|
|
)
|
|
|
|
|
|
@router.get("/ai/systems/{system_id}", response_model=AISystemResponse)
|
|
async def get_ai_system(system_id: str, db: Session = Depends(get_db)):
|
|
"""Get a specific AI system by ID."""
|
|
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
|
if not system:
|
|
raise HTTPException(status_code=404, detail=f"AI System {system_id} not found")
|
|
|
|
return AISystemResponse(
|
|
id=system.id,
|
|
name=system.name,
|
|
description=system.description,
|
|
purpose=system.purpose,
|
|
sector=system.sector,
|
|
classification=system.classification.value if system.classification else "unclassified",
|
|
status=system.status.value if system.status else "draft",
|
|
obligations=system.obligations or [],
|
|
assessment_date=system.assessment_date,
|
|
assessment_result=system.assessment_result,
|
|
risk_factors=system.risk_factors,
|
|
recommendations=system.recommendations,
|
|
created_at=system.created_at,
|
|
updated_at=system.updated_at,
|
|
)
|
|
|
|
|
|
@router.put("/ai/systems/{system_id}", response_model=AISystemResponse)
|
|
async def update_ai_system(
|
|
system_id: str,
|
|
data: AISystemUpdate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update an AI system."""
|
|
from datetime import datetime
|
|
|
|
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
|
if not system:
|
|
raise HTTPException(status_code=404, detail=f"AI System {system_id} not found")
|
|
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
|
|
if "classification" in update_data:
|
|
try:
|
|
update_data["classification"] = AIClassificationEnum(update_data["classification"])
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid classification: {update_data['classification']}")
|
|
|
|
if "status" in update_data:
|
|
try:
|
|
update_data["status"] = AISystemStatusEnum(update_data["status"])
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid status: {update_data['status']}")
|
|
|
|
for key, value in update_data.items():
|
|
if hasattr(system, key):
|
|
setattr(system, key, value)
|
|
|
|
system.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
db.refresh(system)
|
|
|
|
return AISystemResponse(
|
|
id=system.id,
|
|
name=system.name,
|
|
description=system.description,
|
|
purpose=system.purpose,
|
|
sector=system.sector,
|
|
classification=system.classification.value if system.classification else "unclassified",
|
|
status=system.status.value if system.status else "draft",
|
|
obligations=system.obligations or [],
|
|
assessment_date=system.assessment_date,
|
|
assessment_result=system.assessment_result,
|
|
risk_factors=system.risk_factors,
|
|
recommendations=system.recommendations,
|
|
created_at=system.created_at,
|
|
updated_at=system.updated_at,
|
|
)
|
|
|
|
|
|
@router.delete("/ai/systems/{system_id}")
|
|
async def delete_ai_system(system_id: str, db: Session = Depends(get_db)):
|
|
"""Delete an AI system."""
|
|
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
|
if not system:
|
|
raise HTTPException(status_code=404, detail=f"AI System {system_id} not found")
|
|
|
|
db.delete(system)
|
|
db.commit()
|
|
return {"success": True, "message": "AI System deleted"}
|
|
|
|
|
|
@router.post("/ai/systems/{system_id}/assess", response_model=AISystemResponse)
|
|
async def assess_ai_system(
|
|
system_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Run AI Act risk assessment for an AI system."""
|
|
from datetime import datetime
|
|
|
|
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
|
if not system:
|
|
raise HTTPException(status_code=404, detail=f"AI System {system_id} not found")
|
|
|
|
# Try AI-based assessment
|
|
assessment_result = None
|
|
try:
|
|
from ..services.ai_compliance_assistant import get_ai_assistant
|
|
assistant = get_ai_assistant()
|
|
result = await assistant.assess_module_risk(
|
|
module_name=system.name,
|
|
service_type="ai_system",
|
|
description=system.description or "",
|
|
processes_pii=True,
|
|
ai_components=True,
|
|
criticality="high",
|
|
data_categories=[],
|
|
regulations=[{"code": "AI-ACT", "relevance": "high"}],
|
|
)
|
|
assessment_result = {
|
|
"overall_risk": result.overall_risk,
|
|
"risk_factors": result.risk_factors,
|
|
"recommendations": result.recommendations,
|
|
"compliance_gaps": result.compliance_gaps,
|
|
"confidence_score": result.confidence_score,
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"AI assessment failed for {system_id}, using rule-based: {e}")
|
|
# Rule-based fallback
|
|
assessment_result = _rule_based_assessment(system)
|
|
|
|
# Update system with assessment results
|
|
classification = _derive_classification(assessment_result)
|
|
try:
|
|
system.classification = AIClassificationEnum(classification)
|
|
except ValueError:
|
|
system.classification = AIClassificationEnum.UNCLASSIFIED
|
|
|
|
system.assessment_date = datetime.now(timezone.utc)
|
|
system.assessment_result = assessment_result
|
|
system.obligations = _derive_obligations(classification)
|
|
system.risk_factors = assessment_result.get("risk_factors", [])
|
|
system.recommendations = assessment_result.get("recommendations", [])
|
|
system.status = AISystemStatusEnum.CLASSIFIED
|
|
|
|
db.commit()
|
|
db.refresh(system)
|
|
|
|
return AISystemResponse(
|
|
id=system.id,
|
|
name=system.name,
|
|
description=system.description,
|
|
purpose=system.purpose,
|
|
sector=system.sector,
|
|
classification=system.classification.value if system.classification else "unclassified",
|
|
status=system.status.value if system.status else "draft",
|
|
obligations=system.obligations or [],
|
|
assessment_date=system.assessment_date,
|
|
assessment_result=system.assessment_result,
|
|
risk_factors=system.risk_factors,
|
|
recommendations=system.recommendations,
|
|
created_at=system.created_at,
|
|
updated_at=system.updated_at,
|
|
)
|
|
|
|
|
|
def _rule_based_assessment(system: AISystemDB) -> dict:
|
|
"""Simple rule-based AI Act classification when AI service is unavailable."""
|
|
desc = (system.description or "").lower() + " " + (system.purpose or "").lower()
|
|
sector = (system.sector or "").lower()
|
|
|
|
risk_factors = []
|
|
risk_score = 0
|
|
|
|
# Check for prohibited use cases
|
|
prohibited_keywords = ["social scoring", "biometric surveillance", "emotion recognition", "subliminal manipulation"]
|
|
for kw in prohibited_keywords:
|
|
if kw in desc:
|
|
risk_factors.append({"factor": f"Prohibited use case: {kw}", "severity": "critical", "likelihood": "high"})
|
|
risk_score += 10
|
|
|
|
# Check for high-risk indicators
|
|
high_risk_keywords = ["education", "employment", "credit scoring", "law enforcement", "migration", "critical infrastructure", "medical", "bildung", "gesundheit"]
|
|
for kw in high_risk_keywords:
|
|
if kw in desc or kw in sector:
|
|
risk_factors.append({"factor": f"High-risk sector: {kw}", "severity": "high", "likelihood": "medium"})
|
|
risk_score += 5
|
|
|
|
# Check for limited-risk indicators
|
|
limited_keywords = ["chatbot", "deepfake", "emotion", "biometric"]
|
|
for kw in limited_keywords:
|
|
if kw in desc:
|
|
risk_factors.append({"factor": f"Transparency requirement: {kw}", "severity": "medium", "likelihood": "high"})
|
|
risk_score += 3
|
|
|
|
return {
|
|
"overall_risk": "critical" if risk_score >= 10 else "high" if risk_score >= 5 else "medium" if risk_score >= 3 else "low",
|
|
"risk_factors": risk_factors,
|
|
"recommendations": [
|
|
"Dokumentation des AI-Systems vervollstaendigen",
|
|
"Risikomanagement-Framework implementieren",
|
|
"Transparenzpflichten pruefen",
|
|
],
|
|
"compliance_gaps": [],
|
|
"confidence_score": 0.6,
|
|
"risk_score": risk_score,
|
|
}
|
|
|
|
|
|
def _derive_classification(assessment: dict) -> str:
|
|
"""Derive AI Act classification from assessment result."""
|
|
risk = assessment.get("overall_risk", "medium")
|
|
score = assessment.get("risk_score", 0)
|
|
|
|
if score >= 10:
|
|
return "prohibited"
|
|
elif risk in ("critical", "high") or score >= 5:
|
|
return "high-risk"
|
|
elif risk == "medium" or score >= 3:
|
|
return "limited-risk"
|
|
else:
|
|
return "minimal-risk"
|
|
|
|
|
|
def _derive_obligations(classification: str) -> list:
|
|
"""Derive AI Act obligations based on classification."""
|
|
obligations_map = {
|
|
"prohibited": ["Einsatz verboten (Art. 5 AI Act)"],
|
|
"high-risk": [
|
|
"Risikomanagementsystem (Art. 9)",
|
|
"Daten-Governance (Art. 10)",
|
|
"Technische Dokumentation (Art. 11)",
|
|
"Aufzeichnungspflicht (Art. 12)",
|
|
"Transparenz (Art. 13)",
|
|
"Menschliche Aufsicht (Art. 14)",
|
|
"Genauigkeit & Robustheit (Art. 15)",
|
|
"Konformitaetsbewertung (Art. 43)",
|
|
],
|
|
"limited-risk": [
|
|
"Transparenzpflicht (Art. 52)",
|
|
"Kennzeichnung als KI-System",
|
|
],
|
|
"minimal-risk": [
|
|
"Freiwillige Verhaltenskodizes (Art. 69)",
|
|
],
|
|
}
|
|
return obligations_map.get(classification, [])
|