fixed tools

This commit is contained in:
ValueOn AG 2026-04-14 16:15:32 +02:00
parent fe89952252
commit 4852059c7d
5 changed files with 188 additions and 47 deletions

View file

@ -292,7 +292,8 @@ def _extractDocuments(upstream: Dict[str, Any]) -> Dict[str, Any]:
docs = files
elif 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]:

View file

@ -11,6 +11,7 @@ RBAC: mandate-admin or compliance-viewer role required.
"""
import logging
import re
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
@ -94,6 +95,28 @@ async def getAiAuditEntryContent(
result = aiAuditLogger.getAiAuditEntryContent(entryId, mandateId)
if not result:
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
@ -147,3 +170,62 @@ async def getAuditStats(
from modules.shared.aiAuditLogger import aiAuditLogger
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__'):
response.metadata.neutralizationExcluded = _excludedDocs
self._writeAuditEntry(request, response, _wasNeutralized)
return response
async def callAiStream(self, request: AiCallRequest):
@ -233,9 +235,11 @@ class AiService:
logger.debug("callAiStream: neutralization phase done, starting main AI stream")
self.aiObjects.billingCallback = self._createBillingCallback()
_finalResponse = None
try:
async for chunk in self.aiObjects.callWithTextContextStream(request):
if not isinstance(chunk, str):
_finalResponse = chunk
if _excludedDocs:
if not hasattr(chunk, 'metadata') or chunk.metadata is None:
chunk.metadata = {}
@ -246,6 +250,8 @@ class AiService:
yield chunk
finally:
self.aiObjects.billingCallback = None
if _finalResponse:
self._writeAuditEntry(request, _finalResponse, _wasNeutralized)
async def callEmbedding(self, texts: List[str]) -> AiCallResponse:
"""Generate embeddings while respecting allowedProviders."""
@ -1092,13 +1098,54 @@ detectedIntent-Werte:
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:
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 {}
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(
userId=user.id,
mandateId=mandateId or "",
@ -1112,15 +1159,17 @@ detectedIntent-Werte:
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,
neutralizationActive=wasNeutralized,
neutralizationMappingsCount=mappingsCount,
contentInput=contentInput,
contentOutput=contentOutput,
storeFullContent=True,
success=not hasError,
errorMessage=str(getattr(response, 'errorMessage', None)) if hasError else None,
)
except Exception as e:
logger.warning(f"AI audit log failed (non-critical): {e}")
return _billingCallback
def _calculateEffectiveProviders(self) -> Optional[List[str]]:
"""
Calculate effective allowed providers: RBAC Workflow.

View file

@ -648,40 +648,31 @@ class DynamicMode(BaseMode):
methodName, actionName = compoundActionName.split('.', 1)
from modules.workflows.processing.shared.methodDiscovery import methods as _methods
if methodName in _methods:
methodInstance = _methods[methodName]['instance']
if actionName in methodInstance.actions:
action_info = methodInstance.actions[actionName]
# Use structured WorkflowActionParameter objects from new system
storedActions = _methods[methodName].get('actions', {})
if actionName in storedActions:
action_info = storedActions[actionName]
parameters_def = action_info.get('parameters', {})
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()
logger.info(f"Added documentList to parameters: {len(docList.references)} references")
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')
if docListParam and isinstance(docListParam, list):
parameters['documentList'] = docListParam
logger.info(f"Preserved documentList from selection parameters: {len(docListParam)} references")
# Use connectionReference from selection (required)
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:
connectionRef = selection['parameters'].get('connectionReference')
if connectionRef:
# Check if action actually has connectionReference parameter
methodName, actionName = compoundActionName.split('.', 1)
from modules.workflows.processing.shared.methodDiscovery import methods as _methods
if methodName in _methods:
methodInstance = _methods[methodName]['instance']
if actionName in methodInstance.actions:
action_info = methodInstance.actions[actionName]
# Use structured WorkflowActionParameter objects from new system
storedActions = _methods[methodName].get('actions', {})
if actionName in storedActions:
action_info = storedActions[actionName]
parameters_def = action_info.get('parameters', {})
if 'connectionReference' in parameters_def:
parameters['connectionReference'] = connectionRef

View file

@ -16,6 +16,24 @@ logger = logging.getLogger(__name__)
# Global methods catalog - moved from serviceCenter
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):
"""Dynamically discover all method classes and their actions in modules methods package.
@ -47,7 +65,7 @@ def discoverMethods(serviceCenter):
continue
methodInstance = item(serviceCenter)
actions = methodInstance.actions
actions = _collectActionsUnfiltered(methodInstance)
methodInfo = {
'instance': methodInstance,
@ -76,11 +94,11 @@ def getActionParameterList(methodName: str, actionName: str, methods: Dict[str,
if not methods or methodName not in methods:
return ""
methodInstance = methods[methodName]['instance']
if actionName not in methodInstance.actions:
storedActions = methods[methodName].get('actions', {})
if actionName not in storedActions:
return ""
action_info = methodInstance.actions[actionName]
action_info = storedActions[actionName]
# Use structured WorkflowActionParameter objects from new system
parameters = action_info.get('parameters', {})