gateway/modules/workflows/automation2/executors/actionNodeExecutor.py
2026-03-22 18:20:31 +01:00

457 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright (c) 2025 Patrick Motsch
# Action node executor - maps ai.*, email.*, sharepoint.* to method actions via ActionExecutor.
import logging
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]:
"""Get node definition by type id for _method, _action, _paramMap."""
from modules.features.automation2.nodeDefinitions import STATIC_NODE_TYPES
for node in STATIC_NODE_TYPES:
if node.get("id") == nodeType:
return node
return None
def _resolveConnectionIdToReference(chatService, connectionId: str) -> Optional[str]:
"""
Resolve connectionId (UserConnection.id) to connectionReference format.
connectionReference format: connection:{authority}:{externalUsername}
"""
if not connectionId or not chatService:
return None
try:
connections = chatService.getUserConnections()
for c in connections or []:
conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {})
if str(conn.get("id")) == str(connectionId):
authority = conn.get("authority")
if hasattr(authority, "value"):
authority = authority.value
username = conn.get("externalUsername", "")
return f"connection:{authority}:{username}"
return None
except Exception as e:
logger.warning(f"Could not resolve connectionId {connectionId} to reference: {e}")
return None
def _extractEmailContentFromUpstream(inp: Any) -> Optional[Dict[str, Any]]:
"""
Extract {subject, body, to} from upstream node output (e.g. AI node returning JSON).
Expects JSON like {"subject": "...", "body": "...", "to": "..."} in documentData.
"""
if not inp:
return None
import json
docs = inp.get("documents", inp.get("documentList", [])) if isinstance(inp, dict) else []
if not docs:
return None
doc = docs[0] if isinstance(docs, list) else docs
raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
if not raw:
return None
try:
data = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(data, dict) and data.get("subject") and data.get("body"):
return {
"subject": str(data.get("subject", "")),
"body": str(data.get("body", "")),
"to": data.get("to"),
}
except (json.JSONDecodeError, TypeError):
pass
return None
def _getIncomingEmailFromUpstream(
nodeId: str,
inputSources: Dict[str, Dict[int, tuple]],
nodeOutputs: Dict[str, Any],
orderedNodes: List[Dict],
) -> Optional[tuple]:
"""
Walk upstream from draftEmail to find email.checkEmail/searchEmail and return (context, documentList).
context = formatted incoming email(s) for composeAndDraftEmail.
documentList = documents from the email node for attachment/context.
"""
src = inputSources.get(nodeId, {}).get(0)
if not src:
return None
srcId, _ = src
srcNode = next((n for n in (orderedNodes or []) if n.get("id") == srcId), None)
srcType = (srcNode or {}).get("type", "")
# Direct connection to email node
if srcType in ("email.checkEmail", "email.searchEmail"):
out = nodeOutputs.get(srcId)
return _formatEmailOutputAsContext(out)
# Connected via AI node: walk one more step to email source
if srcType.startswith("ai."):
src2 = inputSources.get(srcId, {}).get(0)
if not src2:
return None
emailNodeId, _ = src2
emailNode = next((n for n in (orderedNodes or []) if n.get("id") == emailNodeId), None)
if (emailNode or {}).get("type") in ("email.checkEmail", "email.searchEmail"):
out = nodeOutputs.get(emailNodeId)
return _formatEmailOutputAsContext(out)
return None
def _formatEmailOutputAsContext(out: Any) -> Optional[tuple]:
"""Format email node output as (context, documentList, reply_to) for composeAndDraftEmail.
reply_to = sender address of first email (recipient for the reply).
"""
if not out:
return None
docs = out.get("documents", out.get("documentList", [])) if isinstance(out, dict) else []
if not docs:
return None
doc = docs[0] if isinstance(docs, list) else docs
raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
if not raw:
return None
import json
try:
data = json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(data, dict):
return None
# readEmails: data.emails.emails | searchEmails: data.searchResults.results
emails_data = data.get("emails") or {}
emails_list = emails_data.get("emails", []) if isinstance(emails_data, dict) else []
if not emails_list:
search_results = data.get("searchResults") or {}
emails_list = search_results.get("results", []) if isinstance(search_results, dict) else []
if not emails_list:
return None
reply_to = None
parts = ["Reply to the following email(s):", ""]
for i, em in enumerate(emails_list[:5]): # max 5
if not isinstance(em, dict):
continue
fr = em.get("from", em.get("sender", {}))
addr = fr.get("emailAddress", {}) if isinstance(fr, dict) else {}
from_str = addr.get("address", "") or addr.get("name", "")
if from_str and not reply_to:
reply_to = addr.get("address", "") or from_str
subj = em.get("subject", "")
body = em.get("bodyPreview", "") or (em.get("body") or {}).get("content", "") if isinstance(em.get("body"), dict) else ""
if body and len(str(body)) > 1500:
body = str(body)[:1500] + "..."
parts.append(f"From: {from_str}")
parts.append(f"Subject: {subj}")
parts.append(f"Content:\n{body}")
parts.append("")
if reply_to:
parts.insert(2, f"Recipient (reply to this address): {reply_to}")
parts.insert(3, "")
context = "\n".join(parts).strip()
return (context, docs, reply_to)
def _buildSearchQuery(
query: str = None,
fromAddress: str = None,
toAddress: str = None,
subjectContains: str = None,
bodyContains: str = None,
hasAttachment: bool = None,
filter: str = None,
) -> str:
"""
Build Microsoft Graph $search query from discrete params.
Uses KQL: from:, to:, subject:, body:, hasattachments: (supported by Graph API).
"""
if filter and str(filter).strip():
return str(filter).strip()
parts = []
if query and str(query).strip():
parts.append(str(query).strip())
if fromAddress and str(fromAddress).strip():
safe = str(fromAddress).strip().replace('"', '')
parts.append(f'from:{safe}')
if toAddress and str(toAddress).strip():
safe = str(toAddress).strip().replace('"', '')
parts.append(f'to:{safe}')
if subjectContains and str(subjectContains).strip():
safe = str(subjectContains).strip().replace('"', '')
parts.append(f'subject:{safe}')
if bodyContains and str(bodyContains).strip():
safe = str(bodyContains).strip().replace('"', '')
parts.append(f'body:{safe}')
if hasAttachment is True:
parts.append("hasattachments:true")
return " ".join(parts) if parts else "*"
def _buildEmailFilter(fromAddress: str = None, subjectContains: str = None, hasAttachment: bool = None) -> str:
"""
Build Microsoft Graph API $filter string from discrete email filter params.
Used for email.checkEmail (and trigger.newEmail).
"""
parts = []
if fromAddress and str(fromAddress).strip():
safe = str(fromAddress).strip().replace("'", "''")
parts.append(f"from/emailAddress/address eq '{safe}'")
if subjectContains and str(subjectContains).strip():
safe = str(subjectContains).strip().replace("'", "''")
parts.append(f"contains(subject,'{safe}')")
if hasAttachment is True:
parts.append("hasAttachments eq true")
return " and ".join(parts) if parts else ""
def _buildActionParams(
node: Dict[str, Any],
nodeDef: Dict[str, Any],
resolvedParams: Dict[str, Any],
chatService,
) -> Dict[str, Any]:
"""
Build params for ActionExecutor from node parameters using _paramMap.
Resolves connectionId -> connectionReference.
Handles _contextFrom for composite params (e.g. email.draftEmail subject+body -> context).
"""
params = dict(resolvedParams)
paramMap = nodeDef.get("_paramMap") or {}
contextFrom = nodeDef.get("_contextFrom") or []
# email.checkEmail: build filter from discrete params (fromAddress, subjectContains, hasAttachment)
nodeType = node.get("type", "")
if nodeType == "email.checkEmail":
built = _buildEmailFilter(
fromAddress=params.get("fromAddress"),
subjectContains=params.get("subjectContains"),
hasAttachment=params.get("hasAttachment"),
)
raw_filter = (params.get("filter") or "").strip()
params["filter"] = built if built else (raw_filter if raw_filter else None)
params.pop("fromAddress", None)
params.pop("subjectContains", None)
params.pop("hasAttachment", None)
# email.searchEmail: build query from discrete params (fromAddress, toAddress, subjectContains, bodyContains, hasAttachment)
if nodeType == "email.searchEmail":
built = _buildSearchQuery(
query=params.get("query"),
fromAddress=params.get("fromAddress"),
toAddress=params.get("toAddress"),
subjectContains=params.get("subjectContains"),
bodyContains=params.get("bodyContains"),
hasAttachment=params.get("hasAttachment"),
filter=params.get("filter"),
)
params["query"] = built
params.pop("fromAddress", None)
params.pop("toAddress", None)
params.pop("subjectContains", None)
params.pop("bodyContains", None)
params.pop("hasAttachment", None)
params.pop("filter", None)
# Resolve connectionId to connectionReference
if "connectionId" in params:
connId = params.get("connectionId")
if connId and chatService:
ref = _resolveConnectionIdToReference(chatService, connId)
if ref:
params["connectionReference"] = ref
else:
logger.warning(f"Could not resolve connectionId {connId} to connectionReference")
params.pop("connectionId", None)
# Build context from multiple params (e.g. subject + body for draft email)
if contextFrom:
parts = []
for key in contextFrom:
val = params.get(key)
if val:
if key == "subject":
parts.append(f"Subject: {val}")
elif key == "body":
parts.append(f"Body:\n{val}")
else:
parts.append(str(val))
if parts:
params["context"] = "\n\n".join(parts)
for k in contextFrom:
params.pop(k, None)
# Apply paramMap: node param name -> action param name
result = {}
mappedNodeKeys = {nodeKey for nodeKey, actionKey in paramMap.items() if actionKey and nodeKey in params}
for nodeKey, actionKey in paramMap.items():
if nodeKey in params and actionKey:
result[actionKey] = params[nodeKey]
# Pass through params not used as source for mapping
for k, v in params.items():
if k not in mappedNodeKeys and k not in result:
result[k] = v
return result
class ActionNodeExecutor:
"""Execute ai.*, email.*, sharepoint.* nodes by mapping to method actions."""
def __init__(self, services: Any):
self.services = services
async def execute(
self,
node: Dict[str, Any],
context: Dict[str, Any],
) -> Any:
from modules.features.automation2.nodeRegistry import getNodeTypeToMethodAction
from modules.workflows.automation2.graphUtils import resolveParameterReferences
from modules.workflows.processing.core.actionExecutor import ActionExecutor
nodeType = node.get("type", "")
nodeId = node.get("id", "")
logger.info("ActionNodeExecutor node %s type=%s", nodeId, nodeType)
mapping = getNodeTypeToMethodAction()
methodAction = mapping.get(nodeType)
if not methodAction:
logger.debug("ActionNodeExecutor node %s not in mapping -> None", nodeId)
return None
methodName, actionName = methodAction
logger.info("ActionNodeExecutor node %s method=%s action=%s", nodeId, methodName, actionName)
nodeDef = _getNodeDefinition(nodeType)
params = dict(node.get("parameters") or {})
resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {}))
# Merge input from connected nodes (documentList, etc.)
inputSources = context.get("inputSources", {}).get(nodeId, {})
if 0 in inputSources:
srcId, _ = inputSources[0]
inp = context.get("nodeOutputs", {}).get(srcId)
if isinstance(inp, dict):
resolvedParams.setdefault("documentList", inp.get("documents", inp.get("documentList", [])))
elif inp is not None:
resolvedParams.setdefault("input", inp)
# ai.prompt with email upstream: inject actual email content into prompt so AI has context
# (getChatDocumentsFromDocumentList fails in automation2 workflow has no messages)
if nodeType.startswith("ai."):
orderedNodes = context.get("_orderedNodes") or []
if 0 in inputSources:
srcId, _ = inputSources[0]
srcNode = next((n for n in orderedNodes if n.get("id") == srcId), None)
srcType = (srcNode or {}).get("type", "")
if srcType in ("email.checkEmail", "email.searchEmail"):
incoming = _getIncomingEmailFromUpstream(
nodeId,
context.get("inputSources", {}),
context.get("nodeOutputs", {}),
orderedNodes,
)
if incoming:
ctx, _doc_list, _reply_to = incoming
if ctx and ctx.strip():
base_prompt = (resolvedParams.get("aiPrompt") or "").strip()
resolvedParams["aiPrompt"] = (
f"Eingehende E-Mail:\n{ctx}\n\nAufgabe: {base_prompt}"
if base_prompt
else f"Eingehende E-Mail:\n{ctx}"
)
logger.debug("ai.prompt: injected email context from upstream %s", srcType)
chatService = getattr(self.services, "chat", None)
actionParams = _buildActionParams(node, nodeDef or {}, resolvedParams, chatService)
# email.checkEmail: pause and wait for new email (background poller will resume)
if nodeType == "email.checkEmail":
runId = context.get("_runId")
workflowId = context.get("workflowId")
connRef = actionParams.get("connectionReference")
if runId and workflowId and connRef:
from modules.workflows.automation2.executors import PauseForEmailWaitError
waitConfig = {
"connectionReference": connRef,
"folder": actionParams.get("folder", "Inbox"),
"limit": min(int(actionParams.get("limit") or 10), 50),
"filter": actionParams.get("filter"),
}
raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig)
# Fallback: no pause (calls readEmails directly) needs runId, workflowId, connectionReference
if not runId or not workflowId:
logger.warning(
"email.checkEmail not pausing (runId=%s workflowId=%s) run must be saved/executed as workflow",
runId,
workflowId,
)
elif not connRef:
logger.warning(
"email.checkEmail not pausing connectionReference missing (check connectionId/config)",
)
# email.draftEmail: use AI output as emailContent if available; else pass incoming email as context
if nodeType == "email.draftEmail":
inputSources = context.get("inputSources", {})
nodeOutputs = context.get("nodeOutputs", {})
orderedNodes = context.get("_orderedNodes") or []
if 0 in inputSources.get(nodeId, {}):
srcId, _ = inputSources[nodeId][0]
srcNode = next((n for n in orderedNodes if n.get("id") == srcId), None)
srcType = (srcNode or {}).get("type", "")
if srcType.startswith("ai."):
inp = nodeOutputs.get(srcId)
email_content = _extractEmailContentFromUpstream(inp)
if email_content:
actionParams["emailContent"] = email_content
actionParams.setdefault("context", "(from connected AI node)")
else:
# AI failed or wrong format: pass incoming email from upstream as context
incoming = _getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)
if incoming:
ctx, doc_list, reply_to = incoming
actionParams["context"] = ctx
if doc_list and not actionParams.get("documentList"):
actionParams["documentList"] = doc_list
if reply_to and not actionParams.get("to"):
actionParams["to"] = [reply_to]
else:
# Direct connection to email.checkEmail/searchEmail: use incoming email as context
if not actionParams.get("context"):
incoming = _getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)
if incoming:
ctx, doc_list, reply_to = incoming
actionParams["context"] = ctx
if doc_list and not actionParams.get("documentList"):
actionParams["documentList"] = doc_list
if reply_to and not actionParams.get("to"):
actionParams["to"] = [reply_to]
# sharepoint.uploadFile: content from documentList (upstream) if not in params
if nodeType == "sharepoint.uploadFile" and "content" not in actionParams:
docList = actionParams.get("documentList") or resolvedParams.get("documentList")
if docList:
actionParams["content"] = docList[0] if isinstance(docList, list) and docList else docList
executor = ActionExecutor(self.services)
logger.info("ActionNodeExecutor node %s calling executeAction(%s, %s)", nodeId, methodName, actionName)
result = await executor.executeAction(methodName, actionName, actionParams)
out = {
"success": result.success,
"error": result.error,
"documents": [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])],
"data": result.model_dump() if hasattr(result, "model_dump") else {"success": result.success, "error": result.error},
}
logger.info(
"ActionNodeExecutor node %s result: success=%s error=%s doc_count=%d",
nodeId,
result.success,
result.error,
len(out.get("documents", [])),
)
return out