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>
379 lines
12 KiB
Python
379 lines
12 KiB
Python
"""
|
|
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")
|