Merge pull request #122 from valueonag/feat/demo-system-readieness

Feat/demo system readieness
This commit is contained in:
Patrick Motsch 2026-04-14 16:25:10 +02:00 committed by GitHub
commit 2d9bead706
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 208 additions and 48 deletions

View file

@ -292,7 +292,8 @@ def _extractDocuments(upstream: Dict[str, Any]) -> Dict[str, Any]:
docs = files docs = files
elif fileIds: elif fileIds:
docs = [{"validationMetadata": {"fileId": fid}} for fid in fileIds] docs = [{"validationMetadata": {"fileId": fid}} for fid in fileIds]
return {"documents": docs if isinstance(docs, list) else [docs]} if docs else {} normalized = docs if isinstance(docs, list) else [docs]
return {"documents": normalized, "documentList": normalized} if docs else {}
def _extractText(upstream: Dict[str, Any]) -> Dict[str, Any]: def _extractText(upstream: Dict[str, Any]) -> Dict[str, Any]:

View file

@ -11,6 +11,7 @@ RBAC: mandate-admin or compliance-viewer role required.
""" """
import logging import logging
import re
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
@ -94,6 +95,28 @@ async def getAiAuditEntryContent(
result = aiAuditLogger.getAiAuditEntryContent(entryId, mandateId) result = aiAuditLogger.getAiAuditEntryContent(entryId, mandateId)
if not result: if not result:
raise HTTPException(status_code=404, detail=routeApiMsg("Audit-Eintrag nicht gefunden")) raise HTTPException(status_code=404, detail=routeApiMsg("Audit-Eintrag nicht gefunden"))
_phRx = re.compile(r"\[([a-z]+)\.([a-f0-9-]{36})\]")
combinedText = (result.get("contentInputFull") or "") + (result.get("contentOutputFull") or "")
phIds = set(m.group(2) for m in _phRx.finditer(combinedText))
mappings = []
if phIds:
try:
from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutIf
neutIf = getNeutIf(context.user, mandateId=mandateId, featureInstanceId=None)
for phId in phIds:
attr = neutIf.getAttributeById(phId)
if attr:
mappings.append({
"id": attr.get("id", ""),
"originalText": attr.get("originalText", ""),
"patternType": attr.get("patternType", ""),
})
except Exception as mapErr:
logger.warning(f"Could not resolve neutralization mappings for audit entry {entryId}: {mapErr}")
result["neutralizationMappings"] = mappings
return result return result
@ -147,3 +170,62 @@ async def getAuditStats(
from modules.shared.aiAuditLogger import aiAuditLogger from modules.shared.aiAuditLogger import aiAuditLogger
return aiAuditLogger.getAiAuditStats(mandateId, timeRangeDays=timeRange, groupBy=groupBy) return aiAuditLogger.getAiAuditStats(mandateId, timeRangeDays=timeRange, groupBy=groupBy)
# ── Tab D: Neutralization Mappings ──
@router.get("/neutralization-mappings")
@limiter.limit("120/minute")
async def getNeutralizationMappings(
request: Request,
context: RequestContext = Depends(getRequestContext),
limit: int = Query(200, ge=1, le=2000),
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"))
try:
from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutIf
neutIf = getNeutIf(context.user, mandateId=mandateId, featureInstanceId=None)
attrs = neutIf.getNeutralizationAttributes()
items = [a.model_dump() if hasattr(a, "model_dump") else dict(a) for a in attrs]
for item in items:
pType = item.get("patternType", "")
uid = item.get("id", "")
item["placeholder"] = f"[{pType}.{uid}]" if pType and uid else uid
items.sort(key=lambda r: (r.get("patternType", ""), r.get("originalText", "")))
totalItems = len(items)
page = items[offset: offset + limit]
return {"items": page, "totalItems": totalItems}
except Exception as e:
logger.error(f"Failed to load neutralization mappings: {e}")
raise HTTPException(status_code=500, detail=routeApiMsg("Fehler beim Laden der Neutralisierungs-Zuordnungen"))
@router.delete("/neutralization-mappings/{mappingId}")
@limiter.limit("60/minute")
async def deleteNeutralizationMapping(
request: Request,
mappingId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
_requireAuditAccess(context)
mandateId = str(context.mandateId) if context.mandateId else ""
if not mandateId:
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
try:
from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutIf
neutIf = getNeutIf(context.user, mandateId=mandateId, featureInstanceId=None)
deleted = neutIf.deleteAttributeById(mappingId)
if not deleted:
raise HTTPException(status_code=404, detail=routeApiMsg("Zuordnung nicht gefunden"))
return {"success": True, "id": mappingId}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete neutralization mapping {mappingId}: {e}")
raise HTTPException(status_code=500, detail=routeApiMsg("Fehler beim Löschen der Zuordnung"))

View file

@ -207,6 +207,8 @@ class AiService:
elif hasattr(response.metadata, '__dict__'): elif hasattr(response.metadata, '__dict__'):
response.metadata.neutralizationExcluded = _excludedDocs response.metadata.neutralizationExcluded = _excludedDocs
self._writeAuditEntry(request, response, _wasNeutralized)
return response return response
async def callAiStream(self, request: AiCallRequest): async def callAiStream(self, request: AiCallRequest):
@ -233,9 +235,11 @@ class AiService:
logger.debug("callAiStream: neutralization phase done, starting main AI stream") logger.debug("callAiStream: neutralization phase done, starting main AI stream")
self.aiObjects.billingCallback = self._createBillingCallback() self.aiObjects.billingCallback = self._createBillingCallback()
_finalResponse = None
try: try:
async for chunk in self.aiObjects.callWithTextContextStream(request): async for chunk in self.aiObjects.callWithTextContextStream(request):
if not isinstance(chunk, str): if not isinstance(chunk, str):
_finalResponse = chunk
if _excludedDocs: if _excludedDocs:
if not hasattr(chunk, 'metadata') or chunk.metadata is None: if not hasattr(chunk, 'metadata') or chunk.metadata is None:
chunk.metadata = {} chunk.metadata = {}
@ -246,6 +250,8 @@ class AiService:
yield chunk yield chunk
finally: finally:
self.aiObjects.billingCallback = None self.aiObjects.billingCallback = None
if _finalResponse:
self._writeAuditEntry(request, _finalResponse, _wasNeutralized)
async def callEmbedding(self, texts: List[str]) -> AiCallResponse: async def callEmbedding(self, texts: List[str]) -> AiCallResponse:
"""Generate embeddings while respecting allowedProviders.""" """Generate embeddings while respecting allowedProviders."""
@ -1092,13 +1098,54 @@ detectedIntent-Werte:
f"provider={provider}, model={modelName}, error={e}" f"provider={provider}, model={modelName}, error={e}"
) )
return _billingCallback
def _writeAuditEntry(self, request, response, wasNeutralized: bool = False):
"""Write a rich AI audit entry with input, output, and neutralization metadata."""
try: try:
from modules.shared.aiAuditLogger import aiAuditLogger from modules.shared.aiAuditLogger import aiAuditLogger
contentOut = getattr(response, 'content', None) user = self.services.user
mandateId = self.services.mandateId
featureInstanceId = getattr(self.services, 'featureInstanceId', None)
featureCode = getattr(self.services, 'featureCode', None)
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)
metadata = getattr(response, 'metadata', None) or {} metadata = getattr(response, 'metadata', None) or {}
tokensUsed = metadata.get('tokensUsed') if isinstance(metadata, dict) else None tokensUsed = metadata.get('tokensUsed') if isinstance(metadata, dict) else None
inputParts = []
if request.prompt:
inputParts.append(f"[Prompt] {request.prompt}")
if request.context:
inputParts.append(f"[Context] {request.context}")
if request.messages and isinstance(request.messages, list):
for msg in request.messages:
role = msg.get("role", "?") if isinstance(msg, dict) else "?"
content = msg.get("content", "") if isinstance(msg, dict) else ""
if isinstance(content, str) and content:
inputParts.append(f"[{role}] {content}")
elif isinstance(content, list):
textParts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
if textParts:
inputParts.append(f"[{role}] {' '.join(textParts)}")
contentInput = "\n---\n".join(inputParts) if inputParts else None
contentOut = getattr(response, 'content', None)
contentOutput = str(contentOut) if contentOut else None
neutralSvc = self._get_service("neutralization") if wasNeutralized else None
mappingsCount = None
if neutralSvc and hasattr(neutralSvc, 'getActiveMappingsCount'):
try:
mappingsCount = neutralSvc.getActiveMappingsCount()
except Exception:
pass
aiAuditLogger.logAiCall( aiAuditLogger.logAiCall(
userId=user.id, userId=user.id,
mandateId=mandateId or "", mandateId=mandateId or "",
@ -1112,15 +1159,17 @@ detectedIntent-Werte:
tokensOutput=tokensUsed.get('output') if isinstance(tokensUsed, dict) else None, tokensOutput=tokensUsed.get('output') if isinstance(tokensUsed, dict) else None,
processingTimeMs=int(processingTime * 1000) if processingTime else None, processingTimeMs=int(processingTime * 1000) if processingTime else None,
priceCHF=basePriceCHF if basePriceCHF else None, priceCHF=basePriceCHF if basePriceCHF else None,
contentOutput=str(contentOut)[:500] if contentOut else None, neutralizationActive=wasNeutralized,
neutralizationMappingsCount=mappingsCount,
contentInput=contentInput,
contentOutput=contentOutput,
storeFullContent=True,
success=not hasError, success=not hasError,
errorMessage=str(getattr(response, 'errorMessage', None)) if hasError else None, errorMessage=str(getattr(response, 'errorMessage', None)) if hasError else None,
) )
except Exception as e: except Exception as e:
logger.warning(f"AI audit log failed (non-critical): {e}") logger.warning(f"AI audit log failed (non-critical): {e}")
return _billingCallback
def _calculateEffectiveProviders(self) -> Optional[List[str]]: def _calculateEffectiveProviders(self) -> Optional[List[str]]:
""" """
Calculate effective allowed providers: RBAC Workflow. Calculate effective allowed providers: RBAC Workflow.

View file

@ -158,19 +158,23 @@ def _applyEmailSearchQuery(params: Dict) -> None:
def _wireHandover(nodeDef: Dict, inputSources: Dict, nodeOutputs: Dict, params: Dict) -> None: def _wireHandover(nodeDef: Dict, inputSources: Dict, nodeOutputs: Dict, params: Dict) -> None:
"""Apply wire-handover: extract fields from upstream using INPUT_EXTRACTORS.""" """Apply wire-handover: extract fields from upstream using INPUT_EXTRACTORS."""
if 0 not in inputSources: if 0 not in inputSources:
logger.debug("_wireHandover: no port 0 in inputSources=%s", inputSources)
return return
srcId, _ = inputSources[0] srcId, _ = inputSources[0]
upstream = nodeOutputs.get(srcId) upstream = nodeOutputs.get(srcId)
if not upstream or not isinstance(upstream, dict): if not upstream or not isinstance(upstream, dict):
logger.debug("_wireHandover: upstream for %s is missing or not dict: %s", srcId, type(upstream))
return return
data = _unwrapTransit(upstream) data = _unwrapTransit(upstream)
if not isinstance(data, dict): if not isinstance(data, dict):
logger.debug("_wireHandover: unwrapped data is not dict: %s", type(data))
return return
inputPorts = nodeDef.get("inputPorts", {}) inputPorts = nodeDef.get("inputPorts", {})
port0 = inputPorts.get(0, {}) port0 = inputPorts.get(0, {})
accepts = port0.get("accepts", []) accepts = port0.get("accepts", [])
logger.debug("_wireHandover: srcId=%s accepts=%s upstream_keys=%s params_keys_before=%s", srcId, accepts, list(data.keys()), list(params.keys()))
for schemaName in accepts: for schemaName in accepts:
if schemaName == "Transit": if schemaName == "Transit":
@ -178,9 +182,15 @@ def _wireHandover(nodeDef: Dict, inputSources: Dict, nodeOutputs: Dict, params:
extractor = INPUT_EXTRACTORS.get(schemaName) extractor = INPUT_EXTRACTORS.get(schemaName)
if extractor: if extractor:
extracted = extractor(data) extracted = extractor(data)
logger.debug("_wireHandover: extractor %s returned keys=%s", schemaName, list(extracted.keys()) if extracted else None)
if extracted: if extracted:
for k, v in extracted.items(): for k, v in extracted.items():
params.setdefault(k, v) existing = params.get(k)
if not existing:
params[k] = v
logger.debug("_wireHandover: set %s (was empty/missing) type=%s len=%s", k, type(v).__name__, len(v) if isinstance(v, (list, str, dict)) else "n/a")
else:
logger.debug("_wireHandover: skip %s (already has value, type=%s)", k, type(existing).__name__)
return return
@ -222,11 +232,14 @@ class ActionNodeExecutor:
# 1. Resolve parameters (DataRef, SystemVar, Static) # 1. Resolve parameters (DataRef, SystemVar, Static)
params = dict(node.get("parameters") or {}) params = dict(node.get("parameters") or {})
logger.debug("ActionNodeExecutor node %s raw params keys=%s", nodeId, list(params.keys()))
resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {})) resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {}))
logger.debug("ActionNodeExecutor node %s resolved params keys=%s documentList_present=%s documentList_type=%s", nodeId, list(resolvedParams.keys()), "documentList" in resolvedParams, type(resolvedParams.get("documentList")).__name__)
# 2. Wire-handover via extractors (fills missing params from upstream) # 2. Wire-handover via extractors (fills missing params from upstream)
inputSources = context.get("inputSources", {}).get(nodeId, {}) inputSources = context.get("inputSources", {}).get(nodeId, {})
_wireHandover(nodeDef, inputSources, context.get("nodeOutputs", {}), resolvedParams) _wireHandover(nodeDef, inputSources, context.get("nodeOutputs", {}), resolvedParams)
logger.debug("ActionNodeExecutor node %s after wireHandover: params keys=%s documentList_present=%s documentList_type=%s", nodeId, list(resolvedParams.keys()), "documentList" in resolvedParams, type(resolvedParams.get("documentList")).__name__)
# 3. Apply defaults from parameter definitions # 3. Apply defaults from parameter definitions
for pDef in nodeDef.get("parameters", []): for pDef in nodeDef.get("parameters", []):

View file

@ -213,9 +213,11 @@ def _resolveDocumentList(documentListParam, services) -> List[tuple]:
if isinstance(documentListParam, list) and documentListParam: if isinstance(documentListParam, list) and documentListParam:
first = documentListParam[0] first = documentListParam[0]
logger.debug("_resolveDocumentList: %d items, first type=%s, first keys=%s", len(documentListParam), type(first).__name__, list(first.keys()) if isinstance(first, dict) else "n/a")
if isinstance(first, dict) and ("documentData" in first or "documentName" in first): if isinstance(first, dict) and ("documentData" in first or "documentName" in first):
for doc in documentListParam: for doc in documentListParam:
rawData = doc.get("documentData") rawData = doc.get("documentData")
logger.debug("_resolveDocumentList: doc keys=%s documentData type=%s documentData truthy=%s", list(doc.keys()), type(rawData).__name__, bool(rawData))
if not rawData: if not rawData:
continue continue
try: try:
@ -228,6 +230,8 @@ def _resolveDocumentList(documentListParam, services) -> List[tuple]:
results.append((data, fileId, fileName, mimeType)) results.append((data, fileId, fileName, mimeType))
if results: if results:
return results return results
else:
logger.debug("_resolveDocumentList: first item has no documentData/documentName key, falling through to chat fallback")
chatService = getattr(services, "chat", None) chatService = getattr(services, "chat", None)
if not chatService: if not chatService:
@ -268,7 +272,9 @@ async def processDocuments(self, parameters: Dict[str, Any]) -> ActionResult:
return ActionResult.isFailure(error="featureInstanceId is required") return ActionResult.isFailure(error="featureInstanceId is required")
try: try:
logger.debug("processDocuments: documentListParam type=%s len=%s", type(documentListParam).__name__, len(documentListParam) if isinstance(documentListParam, (list, str)) else "n/a")
extractionDocs = _resolveDocumentList(documentListParam, self.services) extractionDocs = _resolveDocumentList(documentListParam, self.services)
logger.debug("processDocuments: extractionDocs count=%d", len(extractionDocs))
if not extractionDocs: if not extractionDocs:
return ActionResult.isFailure(error="No documents found for documentList") return ActionResult.isFailure(error="No documents found for documentList")

View file

@ -648,40 +648,31 @@ class DynamicMode(BaseMode):
methodName, actionName = compoundActionName.split('.', 1) methodName, actionName = compoundActionName.split('.', 1)
from modules.workflows.processing.shared.methodDiscovery import methods as _methods from modules.workflows.processing.shared.methodDiscovery import methods as _methods
if methodName in _methods: if methodName in _methods:
methodInstance = _methods[methodName]['instance'] storedActions = _methods[methodName].get('actions', {})
if actionName in methodInstance.actions: if actionName in storedActions:
action_info = methodInstance.actions[actionName] action_info = storedActions[actionName]
# Use structured WorkflowActionParameter objects from new system
parameters_def = action_info.get('parameters', {}) parameters_def = action_info.get('parameters', {})
if 'documentList' in parameters_def: if 'documentList' in parameters_def:
# Convert DocumentReferenceList to string list for database serialization
# Action methods will convert it back to DocumentReferenceList when needed
parameters['documentList'] = docList.to_string_list() parameters['documentList'] = docList.to_string_list()
logger.info(f"Added documentList to parameters: {len(docList.references)} references") logger.info(f"Added documentList to parameters: {len(docList.references)} references")
elif 'documentList' not in parameters and isinstance(selection, dict) and 'parameters' in selection: elif 'documentList' not in parameters and isinstance(selection, dict) and 'parameters' in selection:
# Fallback: if documentList is already in selection['parameters'] as a list, preserve it
# This handles guided actions where documentList is already in the right format
docListParam = selection['parameters'].get('documentList') docListParam = selection['parameters'].get('documentList')
if docListParam and isinstance(docListParam, list): if docListParam and isinstance(docListParam, list):
parameters['documentList'] = docListParam parameters['documentList'] = docListParam
logger.info(f"Preserved documentList from selection parameters: {len(docListParam)} references") logger.info(f"Preserved documentList from selection parameters: {len(docListParam)} references")
# Use connectionReference from selection (required)
connectionRef = selection.get('connectionReference') connectionRef = selection.get('connectionReference')
# If not found at top level, check in selection['parameters'] (guided action case)
if not connectionRef and isinstance(selection, dict) and 'parameters' in selection: if not connectionRef and isinstance(selection, dict) and 'parameters' in selection:
connectionRef = selection['parameters'].get('connectionReference') connectionRef = selection['parameters'].get('connectionReference')
if connectionRef: if connectionRef:
# Check if action actually has connectionReference parameter
methodName, actionName = compoundActionName.split('.', 1) methodName, actionName = compoundActionName.split('.', 1)
from modules.workflows.processing.shared.methodDiscovery import methods as _methods from modules.workflows.processing.shared.methodDiscovery import methods as _methods
if methodName in _methods: if methodName in _methods:
methodInstance = _methods[methodName]['instance'] storedActions = _methods[methodName].get('actions', {})
if actionName in methodInstance.actions: if actionName in storedActions:
action_info = methodInstance.actions[actionName] action_info = storedActions[actionName]
# Use structured WorkflowActionParameter objects from new system
parameters_def = action_info.get('parameters', {}) parameters_def = action_info.get('parameters', {})
if 'connectionReference' in parameters_def: if 'connectionReference' in parameters_def:
parameters['connectionReference'] = connectionRef parameters['connectionReference'] = connectionRef

View file

@ -16,6 +16,24 @@ logger = logging.getLogger(__name__)
# Global methods catalog - moved from serviceCenter # Global methods catalog - moved from serviceCenter
methods = {} methods = {}
def _collectActionsUnfiltered(methodInstance) -> Dict[str, Dict[str, Any]]:
"""Collect actions from a method instance without RBAC filtering.
During discovery, services.rbac is not yet available (no user context).
RBAC is enforced at execution time by the ActionExecutor instead.
"""
result = {}
if not hasattr(methodInstance, '_actions') or not methodInstance._actions:
return result
for actionName, actionDef in methodInstance._actions.items():
result[actionName] = {
'description': actionDef.description,
'parameters': methodInstance._convertParametersToSystemFormat(actionDef.parameters),
'method': methodInstance._createActionWrapper(actionDef)
}
return result
def discoverMethods(serviceCenter): def discoverMethods(serviceCenter):
"""Dynamically discover all method classes and their actions in modules methods package. """Dynamically discover all method classes and their actions in modules methods package.
@ -47,7 +65,7 @@ def discoverMethods(serviceCenter):
continue continue
methodInstance = item(serviceCenter) methodInstance = item(serviceCenter)
actions = methodInstance.actions actions = _collectActionsUnfiltered(methodInstance)
methodInfo = { methodInfo = {
'instance': methodInstance, 'instance': methodInstance,
@ -76,11 +94,11 @@ def getActionParameterList(methodName: str, actionName: str, methods: Dict[str,
if not methods or methodName not in methods: if not methods or methodName not in methods:
return "" return ""
methodInstance = methods[methodName]['instance'] storedActions = methods[methodName].get('actions', {})
if actionName not in methodInstance.actions: if actionName not in storedActions:
return "" return ""
action_info = methodInstance.actions[actionName] action_info = storedActions[actionName]
# Use structured WorkflowActionParameter objects from new system # Use structured WorkflowActionParameter objects from new system
parameters = action_info.get('parameters', {}) parameters = action_info.get('parameters', {})