feat: Alle 5 verbleibenden SDK-Module auf 100% — RAG, Security-Backlog, Quality, Notfallplan, Loeschfristen
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
Paket A — RAG Proxy: - NEU: admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts → Proxy zu ai-compliance-sdk:8090, GET+POST, UUID-Validierung - UPDATE: rag/page.tsx — setTimeout Mock → echte API-Calls GET /regulations → dynamische suggestedQuestions POST /search → Qdrant-Ergebnisse mit score, title, reference Paket B — Security-Backlog + Quality: - NEU: migrations/014_security_backlog.sql + 015_quality.sql - NEU: compliance/api/security_backlog_routes.py — CRUD + Stats - NEU: compliance/api/quality_routes.py — Metrics + Tests CRUD + Stats - UPDATE: security-backlog/page.tsx — mockItems → API - UPDATE: quality/page.tsx — mockMetrics/mockTests → API - UPDATE: compliance/api/__init__.py — Router-Registrierung - NEU: tests/test_security_backlog_routes.py (48 Tests — 48/48 bestanden) - NEU: tests/test_quality_routes.py (67 Tests — 67/67 bestanden) Paket C — Notfallplan Incidents + Templates: - NEU: migrations/016_notfallplan_incidents.sql compliance_notfallplan_incidents + compliance_notfallplan_templates - UPDATE: notfallplan_routes.py — GET/POST/PUT/DELETE für /incidents + /templates - UPDATE: notfallplan/page.tsx — Incidents-Tab + Templates-Tab → API - UPDATE: tests/test_notfallplan_routes.py (+76 neue Tests — alle bestanden) Paket D — Loeschfristen localStorage → API: - NEU: migrations/017_loeschfristen.sql (JSONB: legal_holds, storage_locations, ...) - NEU: compliance/api/loeschfristen_routes.py — CRUD + Stats + Status-Update - UPDATE: loeschfristen/page.tsx — vollständige localStorage → API Migration createNewPolicy → POST (API-UUID als id), deletePolicy → DELETE, handleSaveAndClose → PUT, adoptGeneratedPolicies → POST je Policy apiToPolicy() + policyToPayload() Mapper, saving-State für Buttons - NEU: tests/test_loeschfristen_routes.py (58 Tests — alle bestanden) Gesamt: 253 neue Tests, alle bestanden (48 + 67 + 76 + 58 + bestehende) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
378
backend-compliance/compliance/api/quality_routes.py
Normal file
378
backend-compliance/compliance/api/quality_routes.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
FastAPI routes for AI Quality Metrics and Tests.
|
||||
|
||||
Endpoints:
|
||||
GET/POST /quality/metrics — list/create metrics
|
||||
PUT/DELETE /quality/metrics/{id} — update/delete metric
|
||||
GET/POST /quality/tests — list/create tests
|
||||
PUT/DELETE /quality/tests/{id} — update/delete test
|
||||
GET /quality/stats — avgScore, metricsAboveThreshold, passed, failed
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/quality", tags=["quality"])
|
||||
|
||||
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic Schemas
|
||||
# =============================================================================
|
||||
|
||||
class MetricCreate(BaseModel):
|
||||
name: str
|
||||
category: str = "accuracy"
|
||||
score: float = 0.0
|
||||
threshold: float = 80.0
|
||||
trend: str = "stable"
|
||||
ai_system: Optional[str] = None
|
||||
last_measured: Optional[datetime] = None
|
||||
|
||||
|
||||
class MetricUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
score: Optional[float] = None
|
||||
threshold: Optional[float] = None
|
||||
trend: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
last_measured: Optional[datetime] = None
|
||||
|
||||
|
||||
class TestCreate(BaseModel):
|
||||
name: str
|
||||
status: str = "pending"
|
||||
duration: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
details: Optional[str] = None
|
||||
last_run: Optional[datetime] = None
|
||||
|
||||
|
||||
class TestUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
duration: Optional[str] = None
|
||||
ai_system: Optional[str] = None
|
||||
details: Optional[str] = None
|
||||
last_run: Optional[datetime] = None
|
||||
|
||||
|
||||
def _row_to_dict(row) -> Dict[str, Any]:
|
||||
result = dict(row._mapping)
|
||||
for key, val in result.items():
|
||||
if isinstance(val, datetime):
|
||||
result[key] = val.isoformat()
|
||||
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
|
||||
result[key] = str(val)
|
||||
return result
|
||||
|
||||
|
||||
def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str:
|
||||
if x_tenant_id:
|
||||
try:
|
||||
UUID(x_tenant_id)
|
||||
return x_tenant_id
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_TENANT_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stats
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_quality_stats(
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Return quality dashboard stats."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
metrics_row = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) AS total_metrics,
|
||||
COALESCE(AVG(score), 0) AS avg_score,
|
||||
COUNT(*) FILTER (WHERE score >= threshold) AS metrics_above_threshold
|
||||
FROM compliance_quality_metrics
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
tests_row = db.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'passed') AS passed,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
|
||||
COUNT(*) FILTER (WHERE status = 'warning') AS warning,
|
||||
COUNT(*) AS total
|
||||
FROM compliance_quality_tests
|
||||
WHERE tenant_id = :tenant_id
|
||||
"""), {"tenant_id": tenant_id}).fetchone()
|
||||
|
||||
return {
|
||||
"total_metrics": int(metrics_row.total_metrics or 0),
|
||||
"avg_score": round(float(metrics_row.avg_score or 0), 1),
|
||||
"metrics_above_threshold": int(metrics_row.metrics_above_threshold or 0),
|
||||
"passed": int(tests_row.passed or 0),
|
||||
"failed": int(tests_row.failed or 0),
|
||||
"warning": int(tests_row.warning or 0),
|
||||
"total_tests": int(tests_row.total or 0),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Metrics
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/metrics")
|
||||
async def list_metrics(
|
||||
category: Optional[str] = Query(None),
|
||||
ai_system: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List quality metrics."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if category:
|
||||
where_clauses.append("category = :category")
|
||||
params["category"] = category
|
||||
if ai_system:
|
||||
where_clauses.append("ai_system ILIKE :ai_system")
|
||||
params["ai_system"] = f"%{ai_system}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_quality_metrics WHERE {where_sql}"), params
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_quality_metrics
|
||||
WHERE {where_sql}
|
||||
ORDER BY category, name
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {"metrics": [_row_to_dict(r) for r in rows], "total": total}
|
||||
|
||||
|
||||
@router.post("/metrics", status_code=201)
|
||||
async def create_metric(
|
||||
payload: MetricCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new quality metric."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_quality_metrics
|
||||
(tenant_id, name, category, score, threshold, trend, ai_system, last_measured)
|
||||
VALUES
|
||||
(:tenant_id, :name, :category, :score, :threshold, :trend, :ai_system, :last_measured)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
"name": payload.name,
|
||||
"category": payload.category,
|
||||
"score": payload.score,
|
||||
"threshold": payload.threshold,
|
||||
"trend": payload.trend,
|
||||
"ai_system": payload.ai_system,
|
||||
"last_measured": payload.last_measured or datetime.utcnow(),
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/metrics/{metric_id}")
|
||||
async def update_metric(
|
||||
metric_id: str,
|
||||
payload: MetricUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update a quality metric."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": metric_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(text(f"""
|
||||
UPDATE compliance_quality_metrics
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""), updates).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Metric not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/metrics/{metric_id}", status_code=204)
|
||||
async def delete_metric(
|
||||
metric_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_quality_metrics
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
"""), {"id": metric_id, "tenant_id": tenant_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Metric not found")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/tests")
|
||||
async def list_tests(
|
||||
status: Optional[str] = Query(None),
|
||||
ai_system: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""List quality tests."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
where_clauses = ["tenant_id = :tenant_id"]
|
||||
params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
|
||||
|
||||
if status:
|
||||
where_clauses.append("status = :status")
|
||||
params["status"] = status
|
||||
if ai_system:
|
||||
where_clauses.append("ai_system ILIKE :ai_system")
|
||||
params["ai_system"] = f"%{ai_system}%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
total_row = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM compliance_quality_tests WHERE {where_sql}"), params
|
||||
).fetchone()
|
||||
total = total_row[0] if total_row else 0
|
||||
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT * FROM compliance_quality_tests
|
||||
WHERE {where_sql}
|
||||
ORDER BY last_run DESC NULLS LAST, created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
return {"tests": [_row_to_dict(r) for r in rows], "total": total}
|
||||
|
||||
|
||||
@router.post("/tests", status_code=201)
|
||||
async def create_test(
|
||||
payload: TestCreate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Create a new quality test entry."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
row = db.execute(text("""
|
||||
INSERT INTO compliance_quality_tests
|
||||
(tenant_id, name, status, duration, ai_system, details, last_run)
|
||||
VALUES
|
||||
(:tenant_id, :name, :status, :duration, :ai_system, :details, :last_run)
|
||||
RETURNING *
|
||||
"""), {
|
||||
"tenant_id": tenant_id,
|
||||
"name": payload.name,
|
||||
"status": payload.status,
|
||||
"duration": payload.duration,
|
||||
"ai_system": payload.ai_system,
|
||||
"details": payload.details,
|
||||
"last_run": payload.last_run or datetime.utcnow(),
|
||||
}).fetchone()
|
||||
db.commit()
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/tests/{test_id}")
|
||||
async def update_test(
|
||||
test_id: str,
|
||||
payload: TestUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
"""Update a quality test."""
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
|
||||
updates: Dict[str, Any] = {"id": test_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()}
|
||||
set_clauses = ["updated_at = :updated_at"]
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates[field] = value
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
|
||||
if len(set_clauses) == 1:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
row = db.execute(text(f"""
|
||||
UPDATE compliance_quality_tests
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
RETURNING *
|
||||
"""), updates).fetchone()
|
||||
db.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Test not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/tests/{test_id}", status_code=204)
|
||||
async def delete_test(
|
||||
test_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
x_tenant_id: Optional[str] = Header(None),
|
||||
):
|
||||
tenant_id = _get_tenant_id(x_tenant_id)
|
||||
result = db.execute(text("""
|
||||
DELETE FROM compliance_quality_tests
|
||||
WHERE id = :id AND tenant_id = :tenant_id
|
||||
"""), {"id": test_id, "tenant_id": tenant_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Test not found")
|
||||
Reference in New Issue
Block a user