Merge pull request #120 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
f299fc4bca
13 changed files with 706 additions and 56 deletions
3
app.py
3
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)
|
||||
|
||||
|
|
|
|||
150
modules/datamodels/datamodelAiAudit.py
Normal file
150
modules/datamodels/datamodelAiAudit.py
Normal file
|
|
@ -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"},
|
||||
)
|
||||
|
|
@ -1266,11 +1266,27 @@ class AppObjects:
|
|||
return []
|
||||
|
||||
def getUserConnectionById(self, connectionId: str) -> Optional[UserConnection]:
|
||||
"""Get a single UserConnection by ID."""
|
||||
"""Get a single UserConnection by ID or by reference string (connection:authority:username)."""
|
||||
try:
|
||||
# Try direct UUID lookup first
|
||||
connections = self.db.getRecordset(
|
||||
UserConnection, recordFilter={"id": connectionId}
|
||||
)
|
||||
|
||||
# Fallback: parse "connection:authority:username" format from AI agent
|
||||
if not connections and connectionId.startswith("connection:"):
|
||||
parts = connectionId.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
authority = parts[1]
|
||||
username = parts[2]
|
||||
allConns = self.db.getRecordset(UserConnection, recordFilter={"externalUsername": username})
|
||||
for c in (allConns or []):
|
||||
a = c.get("authority", "")
|
||||
aVal = a.value if hasattr(a, "value") else str(a)
|
||||
if aVal == authority:
|
||||
connections = [c]
|
||||
break
|
||||
|
||||
if connections:
|
||||
conn_dict = connections[0]
|
||||
return UserConnection(
|
||||
|
|
|
|||
|
|
@ -530,6 +530,21 @@ class KnowledgeObjects:
|
|||
total_chunks = len(chunks)
|
||||
embedding_pct = round(100.0 * chunks_with_embedding / total_chunks, 1) if total_chunks else 0.0
|
||||
|
||||
sorted_files = sorted(
|
||||
files,
|
||||
key=lambda r: float(r.get("extractedAt") or 0),
|
||||
reverse=True,
|
||||
)
|
||||
recently_indexed = []
|
||||
for row in sorted_files[:10]:
|
||||
recently_indexed.append({
|
||||
"fileName": row.get("fileName", ""),
|
||||
"mimeType": row.get("mimeType", ""),
|
||||
"status": row.get("status", "unknown"),
|
||||
"extractedAt": row.get("extractedAt"),
|
||||
"totalSize": row.get("totalSize", 0),
|
||||
})
|
||||
|
||||
return {
|
||||
"scope": {
|
||||
"featureInstanceId": featureInstanceId,
|
||||
|
|
@ -548,6 +563,7 @@ class KnowledgeObjects:
|
|||
"documentsByMimeCategory": dict(sorted(mime_counts.items(), key=lambda x: -x[1])),
|
||||
"chunksByContentType": dict(sorted(content_type_counts.items())),
|
||||
"timelineIndexedDocuments": timeline,
|
||||
"recentlyIndexedDocuments": recently_indexed,
|
||||
"generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
|
|
|||
149
modules/routes/routeAudit.py
Normal file
149
modules/routes/routeAudit.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -135,6 +135,11 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str):
|
|||
"""Create an async handler that dispatches to the ActionExecutor."""
|
||||
async def _handler(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
|
||||
try:
|
||||
if context:
|
||||
if "featureInstanceId" not in args and context.get("featureInstanceId"):
|
||||
args["featureInstanceId"] = context["featureInstanceId"]
|
||||
if "mandateId" not in args and context.get("mandateId"):
|
||||
args["mandateId"] = context["mandateId"]
|
||||
result = await actionExecutor.executeAction(methodName, actionName, args)
|
||||
data = _formatActionResult(result)
|
||||
return ToolResult(
|
||||
|
|
|
|||
|
|
@ -44,12 +44,12 @@ def _registerConnectionTools(registry: ToolRegistry, services):
|
|||
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="No connections available.")
|
||||
lines = []
|
||||
for conn in connections:
|
||||
connId = conn.get("id", "") if isinstance(conn, dict) else getattr(conn, "id", "")
|
||||
authority = conn.get("authority", "?") if isinstance(conn, dict) else getattr(conn, "authority", "?")
|
||||
authorityVal = authority.value if hasattr(authority, "value") else str(authority)
|
||||
username = conn.get("externalUsername", "") if isinstance(conn, dict) else getattr(conn, "externalUsername", "")
|
||||
email = conn.get("externalEmail", "") if isinstance(conn, dict) else getattr(conn, "externalEmail", "")
|
||||
ref = f"connection:{authorityVal}:{username}"
|
||||
lines.append(f"- {ref} ({email})")
|
||||
lines.append(f"- connectionId: {connId} | {authorityVal} | {username} ({email})")
|
||||
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="\n".join(lines))
|
||||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e))
|
||||
|
|
@ -137,7 +137,7 @@ def _registerConnectionTools(registry: ToolRegistry, services):
|
|||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error=str(e))
|
||||
|
||||
_connToolParams = {
|
||||
"connectionId": {"type": "string", "description": "UserConnection ID"},
|
||||
"connectionId": {"type": "string", "description": "UserConnection UUID (from listConnections output, e.g. '3fa85f64-...')"},
|
||||
"service": {"type": "string", "description": "Service name (sharepoint, outlook, drive, etc.)"},
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ def _registerConnectionTools(registry: ToolRegistry, services):
|
|||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connectionId": {"type": "string", "description": "UserConnection ID"},
|
||||
"connectionId": {"type": "string", "description": "UserConnection UUID (from listConnections output)"},
|
||||
"to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"},
|
||||
"subject": {"type": "string", "description": "Email subject"},
|
||||
"body": {"type": "string", "description": "Email body — plain text or HTML markup"},
|
||||
|
|
|
|||
|
|
@ -267,6 +267,8 @@ class AgentService:
|
|||
registerCoreTools(registry, self.services)
|
||||
|
||||
try:
|
||||
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
||||
discoverMethods(self.services)
|
||||
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
||||
actionExecutor = ActionExecutor(self.services)
|
||||
adapter = ActionToolAdapter(actionExecutor)
|
||||
|
|
@ -293,10 +295,14 @@ class AgentService:
|
|||
|
||||
userConnections: List[str] = []
|
||||
try:
|
||||
connectionService = self._getService("connection")
|
||||
if connectionService and hasattr(connectionService, "getConnections"):
|
||||
connections = connectionService.getConnections() or []
|
||||
userConnections = [c.get("authority", "") for c in connections if c.get("authority")]
|
||||
chatService = self.services.chat if hasattr(self.services, "chat") else None
|
||||
if chatService and hasattr(chatService, "getUserConnections"):
|
||||
connections = chatService.getUserConnections() or []
|
||||
for c in connections:
|
||||
authority = c.get("authority", "") if isinstance(c, dict) else getattr(c, "authority", "")
|
||||
authorityVal = authority.value if hasattr(authority, "value") else str(authority)
|
||||
if authorityVal:
|
||||
userConnections.append(authorityVal)
|
||||
except Exception as e:
|
||||
logger.debug("Could not resolve user connections for toolbox activation: %s", e)
|
||||
|
||||
|
|
@ -370,13 +376,23 @@ class AgentService:
|
|||
if registry.isValidTool(toolName):
|
||||
activatedCount += 1
|
||||
continue
|
||||
try:
|
||||
from modules.serviceCenter.services.serviceAgent.coreTools import registerCoreTools
|
||||
registerCoreTools(registry, self.services)
|
||||
if registry.isValidTool(toolName):
|
||||
activatedCount += 1
|
||||
logger.info("requestToolbox: re-registered tool '%s' (core) from toolbox '%s'", toolName, toolboxId)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ActionToolAdapter
|
||||
adapter = ActionToolAdapter(self._getService("actionExecutor"))
|
||||
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
||||
adapter = ActionToolAdapter(ActionExecutor(self.services))
|
||||
adapter.registerAll(registry)
|
||||
if registry.isValidTool(toolName):
|
||||
activatedCount += 1
|
||||
logger.info("requestToolbox: re-registered tool '%s' from toolbox '%s'", toolName, toolboxId)
|
||||
logger.info("requestToolbox: re-registered tool '%s' (action) from toolbox '%s'", toolName, toolboxId)
|
||||
else:
|
||||
logger.warning("requestToolbox: tool '%s' from toolbox '%s' could not be registered", toolName, toolboxId)
|
||||
except Exception as regErr:
|
||||
|
|
|
|||
|
|
@ -148,20 +148,18 @@ def _registerDefaultToolboxes() -> None:
|
|||
ToolboxDefinition(
|
||||
id="email",
|
||||
label="Email",
|
||||
description="Read and send emails via Outlook/Gmail",
|
||||
requiresConnection="microsoft",
|
||||
description="Send emails or save as draft via Outlook (supports HTML body and file attachments). Use sendMail with draft=true for drafts.",
|
||||
requiresConnection="msft",
|
||||
isDefault=False,
|
||||
tools=[
|
||||
"sendMail",
|
||||
"outlook_readEmails", "outlook_searchEmails",
|
||||
"outlook_composeAndDraftReply", "outlook_sendDraft",
|
||||
],
|
||||
),
|
||||
ToolboxDefinition(
|
||||
id="sharepoint",
|
||||
label="SharePoint",
|
||||
description="Access SharePoint sites, lists, and files",
|
||||
requiresConnection="microsoft",
|
||||
requiresConnection="msft",
|
||||
isDefault=False,
|
||||
tools=[
|
||||
"sharepoint_findDocuments", "sharepoint_readDocuments",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
return
|
||||
|
||||
basePriceCHF = getattr(response, 'priceCHF', 0.0)
|
||||
if not basePriceCHF or basePriceCHF <= 0:
|
||||
"""Record billing transaction + AI audit entry."""
|
||||
if not response:
|
||||
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
|
||||
|
||||
|
|
|
|||
253
modules/shared/aiAuditLogger.py
Normal file
253
modules/shared/aiAuditLogger.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue