From 1230a953bddb5358b48daaf0fa678e7d70937341 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 14 Apr 2026 11:16:19 +0200 Subject: [PATCH] compliance view --- app.py | 3 + modules/datamodels/datamodelAiAudit.py | 150 +++++++++++ modules/routes/routeAudit.py | 149 +++++++++++ .../services/serviceAgent/toolboxRegistry.py | 4 +- .../services/serviceAi/mainServiceAi.py | 85 ++++-- modules/shared/aiAuditLogger.py | 253 ++++++++++++++++++ modules/shared/auditLogger.py | 3 +- modules/system/mainSystem.py | 36 ++- 8 files changed, 640 insertions(+), 43 deletions(-) create mode 100644 modules/datamodels/datamodelAiAudit.py create mode 100644 modules/routes/routeAudit.py create mode 100644 modules/shared/aiAuditLogger.py diff --git a/app.py b/app.py index ad93990c..03d30364 100644 --- a/app.py +++ b/app.py @@ -573,6 +573,9 @@ app.include_router(voiceUserRouter) from modules.routes.routeSharepoint import router as sharepointRouter app.include_router(sharepointRouter) +from modules.routes.routeAudit import router as auditRouter +app.include_router(auditRouter) + from modules.routes.routeAdminLogs import router as adminLogsRouter app.include_router(adminLogsRouter) diff --git a/modules/datamodels/datamodelAiAudit.py b/modules/datamodels/datamodelAiAudit.py new file mode 100644 index 00000000..6f914690 --- /dev/null +++ b/modules/datamodels/datamodelAiAudit.py @@ -0,0 +1,150 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""AI Audit Log data model for Compliance & AI-Datenfluss tracking. + +Records metadata (and optionally content) of every AI provider call +for compliance, audit, and data-protection reporting. +""" + +import uuid +from typing import Optional + +from pydantic import BaseModel, Field + +from modules.shared.i18nRegistry import i18nModel +from modules.shared.timeUtils import getUtcTimestamp + + +@i18nModel("AI-Audit-Eintrag") +class AiAuditLogEntry(BaseModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + timestamp: float = Field( + default_factory=getUtcTimestamp, + description="Event timestamp (UTC epoch seconds)", + json_schema_extra={ + "label": "Zeitpunkt", + "frontend_type": "timestamp", + "frontend_readonly": True, + }, + ) + + userId: str = Field( + description="ID of the user who triggered the AI call", + json_schema_extra={"label": "Benutzer-ID"}, + ) + username: Optional[str] = Field( + default=None, + description="Username at time of call (denormalized for display)", + json_schema_extra={"label": "Benutzername"}, + ) + mandateId: str = Field( + description="Mandate context of the call", + json_schema_extra={"label": "Mandanten-ID"}, + ) + featureInstanceId: Optional[str] = Field( + default=None, + description="Feature instance context", + json_schema_extra={"label": "Feature-Instanz-ID"}, + ) + featureCode: Optional[str] = Field( + default=None, + description="Feature code (e.g. workspace, trustee)", + json_schema_extra={"label": "Feature"}, + ) + instanceLabel: Optional[str] = Field( + default=None, + description="Human-readable instance label at time of call", + json_schema_extra={"label": "Instanz"}, + ) + + aiProvider: str = Field( + description="AI provider key (e.g. azure-openai, anthropic)", + json_schema_extra={"label": "AI-Provider"}, + ) + aiModel: str = Field( + description="Model name used (e.g. gpt-4o, claude-3.5-sonnet)", + json_schema_extra={"label": "AI-Modell"}, + ) + operationType: Optional[str] = Field( + default=None, + description="Operation type (chat, embedding, image, tts, …)", + json_schema_extra={"label": "Typ"}, + ) + + tokensInput: Optional[int] = Field( + default=None, + description="Input tokens consumed", + json_schema_extra={"label": "Tokens (Input)"}, + ) + tokensOutput: Optional[int] = Field( + default=None, + description="Output tokens consumed", + json_schema_extra={"label": "Tokens (Output)"}, + ) + processingTimeMs: Optional[int] = Field( + default=None, + description="Processing time in milliseconds", + json_schema_extra={"label": "Verarbeitungszeit (ms)"}, + ) + priceCHF: Optional[float] = Field( + default=None, + description="Cost in CHF (base price, before markup)", + json_schema_extra={"label": "Kosten (CHF)"}, + ) + + neutralizationActive: bool = Field( + default=False, + description="Whether neutralization was active for this call", + json_schema_extra={"label": "Neutralisierung"}, + ) + neutralizationMappingsCount: Optional[int] = Field( + default=None, + description="Number of neutralization mappings applied", + json_schema_extra={"label": "Neutralisierungs-Mappings"}, + ) + + contentStored: bool = Field( + default=False, + description="Whether full content was persisted (mandate opt-in)", + json_schema_extra={"label": "Inhalt gespeichert"}, + ) + contentInputHash: Optional[str] = Field( + default=None, + description="SHA-256 hash of the input content", + json_schema_extra={"label": "Input-Hash", "frontend_visible": False}, + ) + contentInputPreview: Optional[str] = Field( + default=None, + description="First ~200 chars of input (always stored)", + json_schema_extra={"label": "Input-Vorschau"}, + ) + contentOutputPreview: Optional[str] = Field( + default=None, + description="First ~200 chars of output (always stored)", + json_schema_extra={"label": "Output-Vorschau"}, + ) + contentInputFull: Optional[str] = Field( + default=None, + description="Full input content (only if mandate opted in)", + json_schema_extra={"label": "Vollständiger Input", "frontend_visible": False}, + ) + contentOutputFull: Optional[str] = Field( + default=None, + description="Full output content (only if mandate opted in)", + json_schema_extra={"label": "Vollständiger Output", "frontend_visible": False}, + ) + + success: bool = Field( + default=True, + description="Whether the AI call succeeded", + json_schema_extra={"label": "Erfolgreich"}, + ) + errorMessage: Optional[str] = Field( + default=None, + description="Error message if the call failed", + json_schema_extra={"label": "Fehlermeldung"}, + ) diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py new file mode 100644 index 00000000..6120bf78 --- /dev/null +++ b/modules/routes/routeAudit.py @@ -0,0 +1,149 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Compliance & Audit API endpoints. + +Provides three views: +- AI Data-Flow Log (Tab A) +- Security/GDPR Audit Log (Tab B) +- Aggregated Audit Statistics (Tab C) + +RBAC: mandate-admin or compliance-viewer role required. +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Path, status +from starlette.requests import Request + +from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext +from modules.datamodels.datamodelUam import User +from modules.shared.i18nRegistry import apiRouteContext + +logger = logging.getLogger(__name__) +routeApiMsg = apiRouteContext("routeAudit") + +router = APIRouter(prefix="/api/audit", tags=["Audit"]) + + +def _requireAuditAccess(context: RequestContext): + """Raise 403 unless user has mandate-admin or compliance-viewer access.""" + from modules.auth.authentication import _hasSysAdminRole + if _hasSysAdminRole(str(context.user.id)): + return + + from modules.interfaces.interfaceDbApp import getInterface + appIf = getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) + mandateId = str(context.mandateId) if context.mandateId else None + if not mandateId: + raise HTTPException(status_code=403, detail=routeApiMsg("Mandanten-Kontext erforderlich")) + + from modules.datamodels.datamodelRbac import AccessRuleContext + permissions = appIf.rbac.getUserPermissions( + context.user, AccessRuleContext.UI, "ui.system.complianceAudit", + mandateId=mandateId, + ) + if not permissions or not permissions.view: + raise HTTPException(status_code=403, detail=routeApiMsg("Kein Zugriff auf Audit-Daten")) + + +# ── Tab A: AI Data-Flow Log ── + +@router.get("/ai-log") +@limiter.limit("120/minute") +async def getAiAuditLog( + request: Request, + context: RequestContext = Depends(getRequestContext), + userId: Optional[str] = Query(None), + featureInstanceId: Optional[str] = Query(None), + aiModel: Optional[str] = Query(None), + dateFrom: Optional[float] = Query(None, description="UTC epoch seconds"), + dateTo: Optional[float] = Query(None, description="UTC epoch seconds"), + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), +): + _requireAuditAccess(context) + mandateId = str(context.mandateId) if context.mandateId else "" + if not mandateId: + raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich")) + + from modules.shared.aiAuditLogger import aiAuditLogger + return aiAuditLogger.getAiAuditLogs( + mandateId, + userId=userId, + featureInstanceId=featureInstanceId, + aiModel=aiModel, + fromTimestamp=dateFrom, + toTimestamp=dateTo, + limit=limit, + offset=offset, + ) + + +@router.get("/ai-log/{entryId}/content") +@limiter.limit("60/minute") +async def getAiAuditEntryContent( + request: Request, + entryId: str = Path(...), + context: RequestContext = Depends(getRequestContext), +): + _requireAuditAccess(context) + mandateId = str(context.mandateId) if context.mandateId else "" + + from modules.shared.aiAuditLogger import aiAuditLogger + result = aiAuditLogger.getAiAuditEntryContent(entryId, mandateId) + if not result: + raise HTTPException(status_code=404, detail=routeApiMsg("Audit-Eintrag nicht gefunden")) + return result + + +# ── Tab B: Security / GDPR Audit Log ── + +@router.get("/log") +@limiter.limit("120/minute") +async def getAuditLog( + request: Request, + context: RequestContext = Depends(getRequestContext), + userId: Optional[str] = Query(None), + category: Optional[str] = Query(None), + action: Optional[str] = Query(None), + dateFrom: Optional[float] = Query(None), + dateTo: Optional[float] = Query(None), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + _requireAuditAccess(context) + mandateId = str(context.mandateId) if context.mandateId else None + + from modules.shared.auditLogger import audit_logger + records = audit_logger.getAuditLogs( + userId=userId, + mandateId=mandateId, + category=category, + action=action, + fromTimestamp=dateFrom, + toTimestamp=dateTo, + limit=limit + offset + 1, + ) + totalItems = len(records) + page = records[offset: offset + limit] + return {"items": page, "totalItems": totalItems} + + +# ── Tab C: Audit Statistics ── + +@router.get("/stats") +@limiter.limit("60/minute") +async def getAuditStats( + request: Request, + context: RequestContext = Depends(getRequestContext), + timeRange: int = Query(30, ge=1, le=365, description="Days to aggregate"), + groupBy: str = Query("model", description="Grouping: model, user, feature, day"), +): + _requireAuditAccess(context) + mandateId = str(context.mandateId) if context.mandateId else "" + if not mandateId: + raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich")) + + from modules.shared.aiAuditLogger import aiAuditLogger + return aiAuditLogger.getAiAuditStats(mandateId, timeRangeDays=timeRange, groupBy=groupBy) diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py index 7646da11..32440896 100644 --- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py @@ -148,13 +148,11 @@ def _registerDefaultToolboxes() -> None: ToolboxDefinition( id="email", label="Email", - description="Read and send emails via Outlook/Gmail", + description="Send emails or save as draft via Outlook (supports HTML body and file attachments). Use sendMail with draft=true for drafts.", requiresConnection="microsoft", isDefault=False, tools=[ "sendMail", - "outlook_readEmails", "outlook_searchEmails", - "outlook_composeAndDraftReply", "outlook_sendDraft", ], ), ToolboxDefinition( diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py index a9df1e9b..8619ead1 100644 --- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py +++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py @@ -1038,6 +1038,7 @@ detectedIntent-Werte: Returns a function that records one billing transaction per individual model call. Each transaction contains the exact provider name AND model name. + Also writes an AI audit log entry for compliance tracking. For a 200 MB document processed with N parallel AI calls (possibly different models), this creates N separate billing transactions - one per model call. @@ -1047,7 +1048,6 @@ detectedIntent-Werte: featureInstanceId = getattr(self.services, 'featureInstanceId', None) featureCode = getattr(self.services, 'featureCode', None) - # Get workflow ID if available workflowId = None workflow = getattr(self.services, 'workflow', None) if workflow and hasattr(workflow, 'id'): @@ -1056,39 +1056,68 @@ detectedIntent-Werte: billingService = getBillingService(user, mandateId, featureInstanceId, featureCode) def _billingCallback(response) -> None: - """Record billing transaction with full AI call metadata.""" - if not response or getattr(response, 'errorCount', 0) > 0: + """Record billing transaction + AI audit entry.""" + if not response: return - - basePriceCHF = getattr(response, 'priceCHF', 0.0) - if not basePriceCHF or basePriceCHF <= 0: - return - + provider = getattr(response, 'provider', None) or 'unknown' modelName = getattr(response, 'modelName', None) or 'unknown' - + basePriceCHF = getattr(response, 'priceCHF', 0.0) + hasError = getattr(response, 'errorCount', 0) > 0 + processingTime = getattr(response, 'processingTime', None) + bytesSent = getattr(response, 'bytesSent', None) + bytesReceived = getattr(response, 'bytesReceived', None) + + if not hasError and basePriceCHF and basePriceCHF > 0: + try: + billingService.recordUsage( + priceCHF=basePriceCHF, + workflowId=workflowId, + aicoreProvider=provider, + aicoreModel=modelName, + description=f"AI: {modelName}", + processingTime=processingTime, + bytesSent=bytesSent, + bytesReceived=bytesReceived, + errorCount=getattr(response, 'errorCount', None) + ) + logger.debug( + f"Billed model call: {basePriceCHF:.4f} CHF, " + f"provider={provider}, model={modelName}, mandate={mandateId}" + ) + except Exception as e: + logger.error( + f"BILLING: Failed to record transaction! " + f"Cost={basePriceCHF:.4f} CHF, user={user.id}, mandate={mandateId}, " + f"provider={provider}, model={modelName}, error={e}" + ) + try: - billingService.recordUsage( - priceCHF=basePriceCHF, - workflowId=workflowId, - aicoreProvider=provider, - aicoreModel=modelName, - description=f"AI: {modelName}", - processingTime=getattr(response, 'processingTime', None), - bytesSent=getattr(response, 'bytesSent', None), - bytesReceived=getattr(response, 'bytesReceived', None), - errorCount=getattr(response, 'errorCount', None) - ) - logger.debug( - f"Billed model call: {basePriceCHF:.4f} CHF, " - f"provider={provider}, model={modelName}, mandate={mandateId}" + from modules.shared.aiAuditLogger import aiAuditLogger + + contentOut = getattr(response, 'content', None) + metadata = getattr(response, 'metadata', None) or {} + tokensUsed = metadata.get('tokensUsed') if isinstance(metadata, dict) else None + + aiAuditLogger.logAiCall( + userId=user.id, + mandateId=mandateId or "", + aiProvider=provider, + aiModel=modelName, + username=getattr(user, 'username', None), + featureInstanceId=featureInstanceId, + featureCode=featureCode, + operationType=metadata.get('operationType') if isinstance(metadata, dict) else None, + tokensInput=tokensUsed.get('input') if isinstance(tokensUsed, dict) else None, + tokensOutput=tokensUsed.get('output') if isinstance(tokensUsed, dict) else None, + processingTimeMs=int(processingTime * 1000) if processingTime else None, + priceCHF=basePriceCHF if basePriceCHF else None, + contentOutput=str(contentOut)[:500] if contentOut else None, + success=not hasError, + errorMessage=str(getattr(response, 'errorMessage', None)) if hasError else None, ) except Exception as e: - logger.error( - f"BILLING: Failed to record transaction! " - f"Cost={basePriceCHF:.4f} CHF, user={user.id}, mandate={mandateId}, " - f"provider={provider}, model={modelName}, error={e}" - ) + logger.warning(f"AI audit log failed (non-critical): {e}") return _billingCallback diff --git a/modules/shared/aiAuditLogger.py b/modules/shared/aiAuditLogger.py new file mode 100644 index 00000000..153cd99a --- /dev/null +++ b/modules/shared/aiAuditLogger.py @@ -0,0 +1,253 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""AI Audit Logger — records every AI provider call for compliance reporting. + +Usage: + from modules.shared.aiAuditLogger import aiAuditLogger + aiAuditLogger.logAiCall(userId=..., mandateId=..., ...) +""" + +import hashlib +import logging +from collections import defaultdict +from datetime import datetime, timezone, timedelta +from typing import Any, Dict, List, Optional + +from modules.shared.timeUtils import getUtcTimestamp + +logger = logging.getLogger(__name__) + +_PREVIEW_LENGTH = 200 + + +class AiAuditLogger: + """Persists AI audit entries to the poweron_app database.""" + + def __init__(self): + self._db = None + self._initialized = False + + def _ensureInitialized(self): + if self._initialized: + return + try: + from modules.connectors.connectorDbPostgre import _get_cached_connector + from modules.shared.configuration import APP_CONFIG + from modules.datamodels.datamodelAiAudit import AiAuditLogEntry + + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) + + self._db = _get_cached_connector( + dbHost=dbHost, + dbDatabase="poweron_app", + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId="system", + ) + self._db._ensureTableExists(AiAuditLogEntry) + self._initialized = True + except Exception as e: + logger.error(f"AI audit logger init failed: {e}") + + def logAiCall( + self, + userId: str, + mandateId: str, + aiProvider: str, + aiModel: str, + *, + username: Optional[str] = None, + featureInstanceId: Optional[str] = None, + featureCode: Optional[str] = None, + instanceLabel: Optional[str] = None, + operationType: Optional[str] = None, + tokensInput: Optional[int] = None, + tokensOutput: Optional[int] = None, + processingTimeMs: Optional[int] = None, + priceCHF: Optional[float] = None, + neutralizationActive: bool = False, + neutralizationMappingsCount: Optional[int] = None, + contentInput: Optional[str] = None, + contentOutput: Optional[str] = None, + storeFullContent: bool = False, + success: bool = True, + errorMessage: Optional[str] = None, + ) -> Optional[str]: + """Write one AI audit entry. Returns the entry id or None on failure.""" + self._ensureInitialized() + if not self._db: + return None + + try: + from modules.datamodels.datamodelAiAudit import AiAuditLogEntry + + inputPreview = (contentInput or "")[:_PREVIEW_LENGTH] or None + outputPreview = (contentOutput or "")[:_PREVIEW_LENGTH] or None + inputHash = hashlib.sha256(contentInput.encode("utf-8")).hexdigest() if contentInput else None + + entry = AiAuditLogEntry( + userId=userId, + username=username, + mandateId=mandateId, + featureInstanceId=featureInstanceId or "", + featureCode=featureCode, + instanceLabel=instanceLabel, + aiProvider=aiProvider, + aiModel=aiModel, + operationType=operationType, + tokensInput=tokensInput, + tokensOutput=tokensOutput, + processingTimeMs=processingTimeMs, + priceCHF=priceCHF, + neutralizationActive=neutralizationActive, + neutralizationMappingsCount=neutralizationMappingsCount, + contentStored=storeFullContent and bool(contentInput), + contentInputHash=inputHash, + contentInputPreview=inputPreview, + contentOutputPreview=outputPreview, + contentInputFull=contentInput if storeFullContent else None, + contentOutputFull=contentOutput if storeFullContent else None, + success=success, + errorMessage=errorMessage, + ) + self._db.recordCreate(AiAuditLogEntry, entry.model_dump()) + return entry.id + except Exception as e: + logger.error(f"Failed to write AI audit entry: {e}", exc_info=True) + return None + + # ── Read helpers (used by route) ── + + def getAiAuditLogs( + self, + mandateId: str, + *, + userId: Optional[str] = None, + featureInstanceId: Optional[str] = None, + aiModel: Optional[str] = None, + fromTimestamp: Optional[float] = None, + toTimestamp: Optional[float] = None, + limit: int = 100, + offset: int = 0, + ) -> Dict[str, Any]: + """Return paginated AI audit entries for a mandate.""" + self._ensureInitialized() + if not self._db: + return {"items": [], "totalItems": 0} + + from modules.datamodels.datamodelAiAudit import AiAuditLogEntry + + recordFilter: Dict[str, Any] = {"mandateId": mandateId} + if userId: + recordFilter["userId"] = userId + if featureInstanceId: + recordFilter["featureInstanceId"] = featureInstanceId + if aiModel: + recordFilter["aiModel"] = aiModel + + allRecords = self._db.getRecordset( + AiAuditLogEntry, + recordFilter=recordFilter, + ) + + if fromTimestamp is not None: + allRecords = [r for r in allRecords if (r.get("timestamp") or 0) >= fromTimestamp] + if toTimestamp is not None: + allRecords = [r for r in allRecords if (r.get("timestamp") or 0) <= toTimestamp] + + allRecords.sort(key=lambda r: r.get("timestamp") or 0, reverse=True) + totalItems = len(allRecords) + page = allRecords[offset: offset + limit] + + for item in page: + item.pop("contentInputFull", None) + item.pop("contentOutputFull", None) + + return {"items": page, "totalItems": totalItems} + + def getAiAuditEntryContent(self, entryId: str, mandateId: str) -> Optional[Dict[str, Any]]: + """Return full content for a single entry (RBAC checked by route).""" + self._ensureInitialized() + if not self._db: + return None + + from modules.datamodels.datamodelAiAudit import AiAuditLogEntry + + records = self._db.getRecordset( + AiAuditLogEntry, recordFilter={"id": entryId, "mandateId": mandateId} + ) + if not records: + return None + rec = records[0] + return { + "id": rec.get("id"), + "contentStored": rec.get("contentStored", False), + "contentInputFull": rec.get("contentInputFull"), + "contentOutputFull": rec.get("contentOutputFull"), + "contentInputPreview": rec.get("contentInputPreview"), + "contentOutputPreview": rec.get("contentOutputPreview"), + } + + def getAiAuditStats( + self, + mandateId: str, + *, + timeRangeDays: int = 30, + groupBy: str = "model", + ) -> Dict[str, Any]: + """Aggregate statistics for Tab C.""" + self._ensureInitialized() + if not self._db: + return {} + + from modules.datamodels.datamodelAiAudit import AiAuditLogEntry + + cutoff = (datetime.now(timezone.utc) - timedelta(days=max(1, timeRangeDays))).timestamp() + + allRecords = self._db.getRecordset( + AiAuditLogEntry, recordFilter={"mandateId": mandateId} + ) + records = [r for r in allRecords if (r.get("timestamp") or 0) >= cutoff] + + callsByDay: Dict[str, int] = defaultdict(int) + callsByModel: Dict[str, int] = defaultdict(int) + callsByFeature: Dict[str, int] = defaultdict(int) + costByDay: Dict[str, float] = defaultdict(float) + callsByUser: Dict[str, int] = defaultdict(int) + neutralizationCount = 0 + totalCalls = len(records) + + for r in records: + ts = r.get("timestamp") or 0 + try: + day = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") + except (ValueError, OSError): + day = "unknown" + callsByDay[day] += 1 + callsByModel[r.get("aiModel") or "unknown"] += 1 + callsByFeature[r.get("featureCode") or "system"] += 1 + costByDay[day] += r.get("priceCHF") or 0.0 + callsByUser[r.get("username") or r.get("userId") or "unknown"] += 1 + if r.get("neutralizationActive"): + neutralizationCount += 1 + + sortedDays = sorted(callsByDay.keys()) + neutralizationPercent = round(100.0 * neutralizationCount / totalCalls, 1) if totalCalls else 0.0 + + return { + "totalCalls": totalCalls, + "timeRangeDays": timeRangeDays, + "callsPerDay": [{"date": d, "calls": callsByDay[d]} for d in sortedDays], + "costPerDay": [{"date": d, "cost": round(costByDay[d], 4)} for d in sortedDays], + "callsByModel": dict(sorted(callsByModel.items(), key=lambda x: -x[1])), + "callsByFeature": dict(sorted(callsByFeature.items(), key=lambda x: -x[1])), + "topUsers": dict(sorted(callsByUser.items(), key=lambda x: -x[1])[:10]), + "neutralizationPercent": neutralizationPercent, + } + + +aiAuditLogger = AiAuditLogger() diff --git a/modules/shared/auditLogger.py b/modules/shared/auditLogger.py index 48cd8fdb..0f9c2b39 100644 --- a/modules/shared/auditLogger.py +++ b/modules/shared/auditLogger.py @@ -352,7 +352,6 @@ class AuditLogger: records = self._db.getRecordset( AuditLogEntry, recordFilter=recordFilter if recordFilter else None, - orderBy="timestamp DESC" ) # Apply timestamp filtering in Python (PostgreSQL connector may not support $gt/$lt) @@ -367,7 +366,7 @@ class AuditLogger: filteredRecords.append(record) records = filteredRecords - # Apply limit + records.sort(key=lambda r: r.get("timestamp", 0), reverse=True) return records[:limit] except Exception as e: diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 6f9163d9..277e7e4b 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -46,23 +46,39 @@ NAVIGATION_SECTIONS = [ { "id": "home", "objectKey": "ui.system.home", - "label": t("Übersicht"), + "label": t("Start"), "icon": "FaHome", "path": "/", "order": 10, "public": True, }, - { - "id": "integrations", - "objectKey": "ui.system.integrations", - "label": t("Integrationen"), - "icon": "FaProjectDiagram", - "path": "/integrations", - "order": 15, - "public": True, - }, ], "subgroups": [ + # ── Übersichten ── + { + "id": "system-overviews", + "title": t("Übersichten"), + "order": 15, + "items": [ + { + "id": "integrations", + "objectKey": "ui.system.integrations", + "label": t("Integrationen"), + "icon": "FaProjectDiagram", + "path": "/integrations", + "order": 10, + "public": True, + }, + { + "id": "compliance-audit", + "objectKey": "ui.system.complianceAudit", + "label": t("Compliance & Audit"), + "icon": "FaShieldAlt", + "path": "/compliance-audit", + "order": 20, + }, + ], + }, # ── Basisdaten ── { "id": "system-basedata",