484 lines
No EOL
22 KiB
Python
484 lines
No EOL
22 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
from typing import Dict, List, Optional, Any
|
|
from datetime import datetime, UTC
|
|
import logging
|
|
|
|
from functools import wraps
|
|
|
|
from modules.datamodels.datamodelWorkflowActions import WorkflowActionDefinition, WorkflowActionParameter
|
|
from modules.datamodels.datamodelRbac import AccessRuleContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def action(func):
|
|
"""Decorator to mark a method as an available action
|
|
|
|
IMPORTANT: Action methods should NOT return resultLabel in their ActionResult.
|
|
The resultLabel is managed by the action handler using the action's execResultLabel
|
|
from the action plan. This ensures consistent document routing throughout the workflow.
|
|
|
|
Action methods should only return:
|
|
- success: bool
|
|
- documents: List[ActionDocument]
|
|
- error: str (if success=False)
|
|
|
|
REQUIRED: All ActionDocument instances MUST include validationMetadata for content validation
|
|
and refinement. Without validationMetadata, results cannot be approved.
|
|
|
|
Example validationMetadata structure:
|
|
validationMetadata = {
|
|
"actionType": "moduleName.actionName",
|
|
"param1": value1,
|
|
"param2": value2,
|
|
# ... other relevant parameters for validation
|
|
}
|
|
|
|
See MethodBase._createValidationMetadata() for a helper method to create standard metadata.
|
|
"""
|
|
@wraps(func)
|
|
async def wrapper(self, parameters: Dict[str, Any], *args, **kwargs):
|
|
return await func(self, parameters, *args, **kwargs)
|
|
wrapper.is_action = True
|
|
return wrapper
|
|
|
|
class MethodBase:
|
|
"""Base class for all methods
|
|
|
|
IMPORTANT: All actions that return ActionDocument instances MUST include validationMetadata.
|
|
This metadata is required for content validation and refinement. Without it, results cannot
|
|
be approved by the validation system.
|
|
|
|
Use _createValidationMetadata() helper method to create standardized metadata structures.
|
|
"""
|
|
|
|
def __init__(self, services: Any):
|
|
"""Initialize method with services object"""
|
|
self.services = services
|
|
self.name: str
|
|
self.description: str
|
|
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
|
|
|
|
# Actions MÜSSEN als Dictionary definiert sein
|
|
# Jede Method-Klasse muss _actions Dictionary in __init__ definieren
|
|
self._actions: Dict[str, WorkflowActionDefinition] = {}
|
|
|
|
# Nach Initialisierung: Actions validieren (wird überschrieben, wenn _actions gesetzt wird)
|
|
# Validierung erfolgt erst nach vollständiger Initialisierung der Subklasse
|
|
|
|
def _validateActions(self):
|
|
"""Validate that _actions dictionary is properly defined"""
|
|
if not hasattr(self, '_actions') or not isinstance(self._actions, dict):
|
|
raise ValueError(f"Method {self.name} must define _actions dictionary in __init__")
|
|
|
|
for actionName, actionDef in self._actions.items():
|
|
if not isinstance(actionDef, WorkflowActionDefinition):
|
|
raise ValueError(f"Action '{actionName}' in {self.name} must be WorkflowActionDefinition instance")
|
|
|
|
if not actionDef.actionId:
|
|
raise ValueError(f"Action '{actionName}' in {self.name} must have actionId")
|
|
|
|
if not actionDef.execute:
|
|
raise ValueError(f"Action '{actionName}' in {self.name} must have execute function")
|
|
|
|
@property
|
|
def actions(self) -> Dict[str, Dict[str, Any]]:
|
|
"""
|
|
Dynamically collect all actions from _actions dictionary.
|
|
Returns format for API/UI consumption.
|
|
|
|
REQUIREMENT: Alle Actions müssen in _actions Dictionary definiert sein.
|
|
Actions ohne _actions Definition sind nicht verfügbar.
|
|
"""
|
|
result = {}
|
|
|
|
# Actions müssen in _actions Dictionary definiert sein
|
|
if not hasattr(self, '_actions') or not self._actions:
|
|
self.logger.error(f"Method {self.name} has no _actions dictionary defined. Actions will not be available.")
|
|
return result
|
|
|
|
totalActions = len(self._actions)
|
|
deniedActions = []
|
|
|
|
for actionName, actionDef in self._actions.items():
|
|
# RBAC-Check: Prüfe ob Action für aktuellen User verfügbar ist
|
|
if not self._checkActionPermission(actionDef.actionId):
|
|
deniedActions.append(f"{actionName} ({actionDef.actionId})")
|
|
continue # Skip if user doesn't have permission
|
|
|
|
# Konvertiere WorkflowActionDefinition zu System-Format
|
|
result[actionName] = {
|
|
'description': actionDef.description,
|
|
'parameters': self._convertParametersToSystemFormat(actionDef.parameters),
|
|
'method': self._createActionWrapper(actionDef)
|
|
}
|
|
|
|
if deniedActions:
|
|
self.logger.warning(f"Method {self.name}: {len(deniedActions)}/{totalActions} actions denied by RBAC: {deniedActions[:5]}{'...' if len(deniedActions) > 5 else ''}")
|
|
if not result and totalActions > 0:
|
|
self.logger.error(f"Method {self.name}: ALL {totalActions} actions denied by RBAC! This will result in empty action list.")
|
|
|
|
return result
|
|
|
|
def _checkActionPermission(self, actionId: str) -> bool:
|
|
"""
|
|
Check if current user has permission to execute this action.
|
|
Uses RBAC RESOURCE context.
|
|
|
|
REQUIREMENT: RBAC-Service muss verfügbar sein.
|
|
"""
|
|
if not hasattr(self.services, 'rbac') or not self.services.rbac:
|
|
self.logger.error(f"RBAC service not available (services.rbac is None). Action {actionId} will be denied.")
|
|
return False
|
|
|
|
# Get current user from services.user (not from chat service)
|
|
currentUser = getattr(self.services, 'user', None)
|
|
if not currentUser:
|
|
self.logger.warning(f"No current user found (services.user is None). Action {actionId} will be denied.")
|
|
return False
|
|
|
|
# RBAC-Check: RESOURCE context, item = actionId
|
|
# mandateId/featureInstanceId from services context needed to resolve user roles
|
|
try:
|
|
mandateId = getattr(self.services, 'mandateId', None)
|
|
featureInstanceId = getattr(self.services, 'featureInstanceId', None)
|
|
permissions = self.services.rbac.getUserPermissions(
|
|
user=currentUser,
|
|
context=AccessRuleContext.RESOURCE,
|
|
item=actionId,
|
|
mandateId=str(mandateId) if mandateId else None,
|
|
featureInstanceId=str(featureInstanceId) if featureInstanceId else None
|
|
)
|
|
hasPermission = permissions.view
|
|
if not hasPermission:
|
|
# Log detailed RBAC denial info
|
|
userRoles = getattr(currentUser, 'roleLabels', []) or []
|
|
self.logger.warning(
|
|
f"RBAC denied action {actionId} for user {currentUser.id}. "
|
|
f"User roles: {userRoles}, mandateId={mandateId}, "
|
|
f"Permissions: view={permissions.view}, read={permissions.read}, "
|
|
f"create={permissions.create}, update={permissions.update}, delete={permissions.delete}. "
|
|
f"No matching RBAC rule found for context=RESOURCE, item={actionId}"
|
|
)
|
|
return hasPermission
|
|
except Exception as e:
|
|
self.logger.error(f"RBAC check failed for action {actionId}: {str(e)}. Action will be denied.")
|
|
return False
|
|
|
|
def _convertParametersToSystemFormat(self, parameters: Dict[str, WorkflowActionParameter]) -> Dict[str, Dict[str, Any]]:
|
|
"""Convert WorkflowActionParameter dict to system format for API/UI consumption"""
|
|
result = {}
|
|
for paramName, param in parameters.items():
|
|
result[paramName] = {
|
|
'type': param.type,
|
|
'required': param.required,
|
|
'description': param.description,
|
|
'default': param.default,
|
|
'frontendType': param.frontendType.value,
|
|
'frontendOptions': param.frontendOptions,
|
|
'validation': param.validation
|
|
}
|
|
return result
|
|
|
|
def _createActionWrapper(self, actionDef: WorkflowActionDefinition):
|
|
"""Create wrapper function for action execution with parameter validation"""
|
|
async def wrapper(parameters: Dict[str, Any], *args, **kwargs):
|
|
# Parameter-Validierung basierend auf WorkflowActionParameter definitions
|
|
validatedParams = self._validateParameters(parameters, actionDef.parameters)
|
|
|
|
# Execute action
|
|
return await actionDef.execute(validatedParams, *args, **kwargs)
|
|
|
|
wrapper.is_action = True
|
|
return wrapper
|
|
|
|
def _validateParameters(self, parameters: Dict[str, Any], paramDefs: Dict[str, WorkflowActionParameter]) -> Dict[str, Any]:
|
|
"""Validate parameters against definitions
|
|
|
|
IMPORTANT: System parameters (like parentOperationId, expectedDocumentFormats) are preserved
|
|
even if they're not in the parameter definitions, as they're used internally by the framework.
|
|
"""
|
|
validated = {}
|
|
|
|
# System parameters that should always be preserved, even if not in paramDefs
|
|
systemParams = ['parentOperationId', 'expectedDocumentFormats']
|
|
for sysParam in systemParams:
|
|
if sysParam in parameters:
|
|
validated[sysParam] = parameters[sysParam]
|
|
|
|
for paramName, paramDef in paramDefs.items():
|
|
value = parameters.get(paramName)
|
|
|
|
# Check required
|
|
if paramDef.required and value is None:
|
|
raise ValueError(f"Required parameter '{paramName}' is missing")
|
|
|
|
# Use default if not provided
|
|
if value is None and paramDef.default is not None:
|
|
value = paramDef.default
|
|
|
|
# Type validation
|
|
if value is not None:
|
|
value = self._validateType(value, paramDef.type)
|
|
|
|
# Custom validation rules
|
|
if paramDef.validation and value is not None:
|
|
self._applyValidationRules(value, paramDef.validation)
|
|
|
|
validated[paramName] = value
|
|
|
|
return validated
|
|
|
|
def _validateType(self, value: Any, expectedType: str) -> Any:
|
|
"""Validate and convert value to expected type"""
|
|
# Type validation logic
|
|
typeMap = {
|
|
'str': str,
|
|
'int': int,
|
|
'float': float,
|
|
'bool': bool,
|
|
'list': list,
|
|
'dict': dict,
|
|
}
|
|
|
|
# Handle List[str], List[int], etc.
|
|
if expectedType.startswith('List['):
|
|
if isinstance(value, str):
|
|
value = [v.strip() for v in value.split(',') if v.strip()] if ',' in value else [value]
|
|
elif not isinstance(value, list):
|
|
value = [value]
|
|
# Extract inner type
|
|
innerType = expectedType[5:-1].strip() # Remove "List[" and "]"
|
|
if innerType in typeMap:
|
|
return [typeMap[innerType](v) for v in value]
|
|
return value
|
|
|
|
# Handle Dict[str, Any], etc.
|
|
if expectedType.startswith('Dict['):
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"Expected dict for type '{expectedType}', got {type(value).__name__}")
|
|
return value
|
|
|
|
# Handle simple types (bool must be checked before int since bool is subclass of int)
|
|
if expectedType in typeMap:
|
|
expectedTypeClass = typeMap[expectedType]
|
|
if expectedType == 'int' and isinstance(value, bool):
|
|
raise ValueError(f"Expected int, got bool: {value}")
|
|
if expectedType == 'bool' and isinstance(value, int) and not isinstance(value, bool):
|
|
return bool(value)
|
|
if not isinstance(value, expectedTypeClass):
|
|
try:
|
|
return expectedTypeClass(value)
|
|
except (ValueError, TypeError) as e:
|
|
raise ValueError(f"Cannot convert {value} to {expectedType}: {str(e)}")
|
|
|
|
return value
|
|
|
|
def _applyValidationRules(self, value: Any, rules: Dict[str, Any]):
|
|
"""Apply custom validation rules"""
|
|
if 'min' in rules:
|
|
if isinstance(value, (int, float)) and value < rules['min']:
|
|
raise ValueError(f"Value must be >= {rules['min']}")
|
|
elif isinstance(value, str) and len(value) < rules['min']:
|
|
raise ValueError(f"String length must be >= {rules['min']}")
|
|
|
|
if 'max' in rules:
|
|
if isinstance(value, (int, float)) and value > rules['max']:
|
|
raise ValueError(f"Value must be <= {rules['max']}")
|
|
elif isinstance(value, str) and len(value) > rules['max']:
|
|
raise ValueError(f"String length must be <= {rules['max']}")
|
|
|
|
if 'pattern' in rules:
|
|
import re
|
|
if not re.match(rules['pattern'], str(value)):
|
|
raise ValueError(f"Value does not match required pattern: {rules['pattern']}")
|
|
|
|
def getActionSignature(self, actionName: str) -> str:
|
|
"""Get formatted action signature for AI prompt generation (detailed version)"""
|
|
allActions = self.actions
|
|
if actionName not in allActions:
|
|
return ""
|
|
|
|
action = allActions[actionName]
|
|
paramList = []
|
|
|
|
# Extract detailed parameter information from docstring
|
|
docstring = action.get('description', '')
|
|
paramDescriptions, paramTypes = self._extractParameterDetails(docstring)
|
|
|
|
for paramName in paramDescriptions:
|
|
paramType = paramTypes.get(paramName, 'Any')
|
|
paramDesc = paramDescriptions.get(paramName, '')
|
|
# Mark required parameters with * if possible (not available from docstring, so omit)
|
|
if paramDesc:
|
|
paramList.append(f"{paramName}:{paramType} # {paramDesc}")
|
|
else:
|
|
paramList.append(f"{paramName}:{paramType}")
|
|
|
|
signature = f"{self.name}.{actionName}"
|
|
|
|
if paramList:
|
|
signature += f"({', '.join(paramList)})"
|
|
|
|
# Add return type and main description
|
|
returnType = "ActionResult"
|
|
mainDesc = self._extractMainDescription(docstring)
|
|
|
|
if mainDesc:
|
|
signature += f" -> {returnType} # {mainDesc}"
|
|
|
|
return signature
|
|
|
|
def _extractParameterDetails(self, docstring: str):
|
|
"""Extract parameter names, types, and descriptions from docstring"""
|
|
descriptions = {}
|
|
types = {}
|
|
if not docstring:
|
|
return descriptions, types
|
|
|
|
lines = docstring.split('\n')
|
|
inParameters = False
|
|
for line in lines:
|
|
line = line.strip()
|
|
if 'Parameters:' in line:
|
|
inParameters = True
|
|
continue
|
|
elif inParameters and (line.startswith('Returns:') or line.startswith('Raises:') or line.startswith('Args:')):
|
|
break
|
|
elif inParameters and line:
|
|
# Look for parameter descriptions like "paramName (type): description"
|
|
if ':' in line and '(' in line:
|
|
parts = line.split(':', 1)
|
|
if len(parts) == 2:
|
|
paramPart = parts[0].strip()
|
|
descPart = parts[1].strip()
|
|
# Extract parameter name and type
|
|
if '(' in paramPart:
|
|
paramName = paramPart.split('(')[0].strip()
|
|
# Normalize bullet-prefixed parameter names like "- aiPrompt" or "* aiPrompt"
|
|
if paramName.startswith('-') or paramName.startswith('*'):
|
|
paramName = paramName[1:].strip()
|
|
paramType = paramPart[paramPart.find('(')+1:paramPart.find(')')].strip()
|
|
descriptions[paramName] = descPart
|
|
types[paramName] = paramType
|
|
# Also handle multi-line descriptions
|
|
elif line and not line.startswith('Each document') and not line.startswith('contains'):
|
|
if descriptions:
|
|
lastParam = list(descriptions.keys())[-1]
|
|
descriptions[lastParam] += " " + line
|
|
return descriptions, types
|
|
|
|
def _extractMainDescription(self, docstring: str) -> str:
|
|
"""Extract main description from docstring"""
|
|
if not docstring:
|
|
return ""
|
|
|
|
lines = docstring.split('\n')
|
|
mainDesc = ""
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
if line and not line.startswith('Parameters:') and not line.startswith('Returns:') and not line.startswith('Raises:'):
|
|
mainDesc = line
|
|
break
|
|
|
|
return mainDesc
|
|
|
|
def _formatType(self, type_annotation) -> str:
|
|
"""Format type annotation for display"""
|
|
if type_annotation == Any:
|
|
return "Any"
|
|
elif hasattr(type_annotation, '__name__'):
|
|
return type_annotation.__name__
|
|
elif hasattr(type_annotation, '_name'):
|
|
return type_annotation._name
|
|
else:
|
|
return str(type_annotation)
|
|
|
|
def _createValidationMetadata(self, actionName: str, **kwargs) -> Dict[str, Any]:
|
|
"""
|
|
Helper method to create standardized validationMetadata for ActionDocument instances.
|
|
|
|
This method ensures all actions include the required validationMetadata structure
|
|
for content validation and refinement. Without metadata, results cannot be approved.
|
|
|
|
Args:
|
|
actionName: Name of the action (e.g., "readEmails", "uploadDocument")
|
|
**kwargs: Additional action-specific metadata fields
|
|
|
|
Returns:
|
|
Dictionary with validationMetadata structure including:
|
|
- actionType: Full action identifier (moduleName.actionName)
|
|
- All provided kwargs as additional metadata fields
|
|
|
|
Example:
|
|
validationMetadata = self._createValidationMetadata(
|
|
"readEmails",
|
|
connectionReference=connectionReference,
|
|
folder=folder,
|
|
limit=limit,
|
|
emailCount=len(emails)
|
|
)
|
|
|
|
ActionDocument(
|
|
documentName="emails.json",
|
|
documentData=json.dumps(data),
|
|
mimeType="application/json",
|
|
validationMetadata=validationMetadata # REQUIRED
|
|
)
|
|
"""
|
|
metadata = {
|
|
"actionType": f"{self.name}.{actionName}"
|
|
}
|
|
metadata.update(kwargs)
|
|
return metadata
|
|
|
|
def _generateMeaningfulFileName(self, base_name: str, extension: str, workflow_context: Dict[str, Any] = None, action_name: str = None) -> str:
|
|
"""
|
|
Generate a meaningful file name with round/task/action information.
|
|
|
|
Format: {base_name}_alpha_r{round}t{task}a{action}.{extension}
|
|
Example: report_alpha_r1t3a4.json
|
|
|
|
Args:
|
|
base_name: Base name for the file (e.g., "report", "analysis", "summary")
|
|
extension: File extension without dot (e.g., "json", "html", "txt")
|
|
workflow_context: Dictionary with currentRound, currentTask, currentAction
|
|
action_name: Name of the action being performed (optional, for additional context)
|
|
|
|
Returns:
|
|
Formatted file name string
|
|
"""
|
|
try:
|
|
# Get workflow context from services if not provided
|
|
if workflow_context is None and hasattr(self.services, 'workflow'):
|
|
workflow_context = self.services.chat.getWorkflowContext()
|
|
|
|
# Extract round, task, action numbers
|
|
round_num = workflow_context.get('currentRound', 0) if workflow_context else 0
|
|
task_num = workflow_context.get('currentTask', 0) if workflow_context else 0
|
|
action_num = workflow_context.get('currentAction', 0) if workflow_context else 0
|
|
|
|
# Clean base name (remove special characters, spaces)
|
|
clean_base = base_name.lower().replace(' ', '_').replace('-', '_')
|
|
# Remove any non-alphanumeric characters except underscores
|
|
import re
|
|
clean_base = re.sub(r'[^a-z0-9_]', '', clean_base)
|
|
|
|
# Add action name if provided
|
|
if action_name:
|
|
clean_action = action_name.lower().replace(' ', '_').replace('-', '_')
|
|
clean_action = re.sub(r'[^a-z0-9_]', '', clean_action)
|
|
clean_base = f"{clean_base}_{clean_action}"
|
|
|
|
# Generate the meaningful file name
|
|
meaningful_name = f"{clean_base}_r{round_num}t{task_num}a{action_num}.{extension}"
|
|
|
|
self.logger.debug(f"Generated meaningful file name: {meaningful_name} (Round: {round_num}, Task: {task_num}, Action: {action_num})")
|
|
return meaningful_name
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Error generating meaningful file name, using fallback: {str(e)}")
|
|
# Fallback to timestamp-based naming
|
|
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
|
return f"{base_name}_{timestamp}.{extension}" |