gateway/modules/workflows/processing/shared/methodDiscovery.py
2025-12-17 10:45:09 +01:00

161 lines
7.7 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
# methodDiscovery.py
# Method discovery and management for workflow execution
import logging
import importlib
import pkgutil
import inspect
from typing import Any, Dict, List
from modules.workflows.methods.methodBase import MethodBase
# Set up logger
logger = logging.getLogger(__name__)
# Global methods catalog - moved from serviceCenter
methods = {}
def discoverMethods(serviceCenter):
"""Dynamically discover all method classes and their actions in modules methods package.
CRITICAL: If methods are already discovered, updates their Services reference to ensure
they use the current workflow (self.services.workflow). This prevents stale workflow IDs
from being used when a new workflow starts.
"""
try:
# Import the methods package
methodsPackage = importlib.import_module('modules.workflows.methods')
# Discover all modules and packages in the methods package
for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__):
if name.startswith('method'):
try:
if isPkg:
# Package (folder) - import __init__.py which exports the Method class
module = importlib.import_module(f'modules.workflows.methods.{name}')
else:
# Module (file) - import directly
module = importlib.import_module(f'modules.workflows.methods.{name}')
# Find all classes in the module that inherit from MethodBase
for itemName, item in inspect.getmembers(module):
if (inspect.isclass(item) and
issubclass(item, MethodBase) and
item != MethodBase):
# Check if method already exists in cache
shortName = itemName.replace('Method', '').lower()
if itemName in methods or shortName in methods:
# Method already discovered - update Services reference to use current workflow
existingMethodInfo = methods.get(itemName) or methods.get(shortName)
if existingMethodInfo and existingMethodInfo.get('instance'):
existingMethodInfo['instance'].services = serviceCenter
logger.debug(f"Updated Services reference for cached method {itemName} to use current workflow")
else:
# Method exists but instance is missing - recreate it
methodInstance = item(serviceCenter)
actions = methodInstance.actions
methodInfo = {
'instance': methodInstance,
'actions': actions,
'description': item.__doc__ or f"Method {itemName}"
}
methods[itemName] = methodInfo
methods[shortName] = methodInfo
logger.info(f"Recreated method {itemName} (short: {shortName}) with {len(actions)} actions")
else:
# Method not discovered yet - create new instance
methodInstance = item(serviceCenter)
# Use the actions property from MethodBase which handles @action decorator
actions = methodInstance.actions
# Create method info
methodInfo = {
'instance': methodInstance,
'actions': actions,
'description': item.__doc__ or f"Method {itemName}"
}
# Store the method with full class name
methods[itemName] = methodInfo
# Also store with short name for action executor access
methods[shortName] = methodInfo
logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions")
except Exception as e:
logger.error(f"Error discovering method {name}: {str(e)}")
continue
logger.info(f"Discovered/updated {len(methods)} method entries total")
except Exception as e:
logger.error(f"Error discovering methods: {str(e)}")
def getMethodsList(serviceCenter):
"""Get a list of available methods with their signatures"""
if not methods:
discoverMethods(serviceCenter)
methodsList = []
for methodName, methodInfo in methods.items():
methodDescription = methodInfo['description']
actionsList = []
for actionName, actionInfo in methodInfo['actions'].items():
actionDescription = actionInfo['description']
parameters = actionInfo['parameters']
# Build parameter signature
paramSig = []
for paramName, paramInfo in parameters.items():
paramType = paramInfo['type']
paramRequired = paramInfo['required']
paramDefault = paramInfo['default']
if paramRequired:
paramSig.append(f"{paramName}: {paramType}")
else:
defaultStr = f" = {paramDefault}" if paramDefault is not None else " = None"
paramSig.append(f"{paramName}: {paramType}{defaultStr}")
paramSignature = f"({', '.join(paramSig)})" if paramSig else "()"
actionsList.append(f"- {actionName}{paramSignature}: {actionDescription}")
actionsStr = "\n".join(actionsList)
methodsList.append(f"**{methodName}**: {methodDescription}\n{actionsStr}")
return "\n\n".join(methodsList)
def getActionParameterList(methodName: str, actionName: str, methods: Dict[str, Any]) -> str:
"""Get action parameter list from method docstring for AI parameter generation (list only)."""
try:
if not methods or methodName not in methods:
return ""
methodInstance = methods[methodName]['instance']
if actionName not in methodInstance.actions:
return ""
action_info = methodInstance.actions[actionName]
# Extract parameter descriptions from docstring
docstring = action_info.get('description', '')
paramDescriptions, paramTypes = methodInstance._extractParameterDetails(docstring)
param_list = []
for paramName, paramDesc in paramDescriptions.items():
paramType = paramTypes.get(paramName, 'Any')
if paramDesc:
param_list.append(f"- {paramName} ({paramType}): {paramDesc}")
else:
param_list.append(f"- {paramName} ({paramType})")
# Return list only, without leading headings or trailing text
return "\n".join(param_list)
except Exception as e:
logger.error(f"Error getting action parameter signature for {methodName}.{actionName}: {str(e)}")
return ""