gateway/modules/workflows/automation2/executors/actionNodeExecutor.py
2026-03-22 19:46:50 +01:00

599 lines
28 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, services=None) -> Optional[str]:
"""
Resolve connectionId (UserConnection.id) to connectionReference format.
connectionReference format: connection:{authority}:{externalUsername}
Falls back to interfaceDbApp.getUserConnectionById when chatService resolution fails.
"""
if not connectionId:
return None
# Already in reference format
if isinstance(connectionId, str) and connectionId.startswith("connection:"):
return connectionId
# Try chatService first
if chatService:
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}"
except Exception as e:
logger.debug("_resolveConnectionIdToReference chatService: %s", e)
# Fallback: interfaceDbApp.getUserConnectionById (automation2 may not have chat.getUserConnections)
app = getattr(services, "interfaceDbApp", None) if services else None
if app and hasattr(app, "getUserConnectionById"):
try:
conn = app.getUserConnectionById(str(connectionId))
if conn:
authority = getattr(conn, "authority", None)
if hasattr(authority, "value"):
authority = authority.value
else:
authority = str(authority) if authority else "outlook"
username = getattr(conn, "externalUsername", "") or ""
return f"connection:{authority}:{username}"
except Exception as e:
logger.debug("_resolveConnectionIdToReference getUserConnectionById: %s", 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 _extractContextFromUpstream(inp: Any) -> Optional[str]:
"""
Extract plain text context from upstream node output (e.g. AI node returning txt).
Use when _extractEmailContentFromUpstream returns None the generated document content
(email body, summary, etc.) should be passed as context to email.draftEmail.
"""
if not inp:
return None
docs = None
if isinstance(inp, dict):
docs = inp.get("documents") or inp.get("documentList")
if not docs and isinstance(inp.get("data"), dict):
docs = inp.get("data", {}).get("documents")
if not docs or not isinstance(docs, (list, tuple)):
return None
doc = docs[0] if docs else None
if not doc:
return None
raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") or doc.get("content") if isinstance(doc, dict) else None)
if not raw:
return None
if isinstance(raw, bytes):
return raw.decode("utf-8", errors="replace").strip()
s = str(raw).strip()
return s if s else None
def _gatherAttachmentDocumentsFromUpstream(
nodeId: str,
inputSources: Dict[str, Dict[int, tuple]],
nodeOutputs: Dict[str, Any],
orderedNodes: List[Dict],
visited: Optional[set] = None,
) -> List[Any]:
"""
Walk upstream from nodeId through AI nodes to collect file documents (e.g. from sharepoint.downloadFile).
Used when email.draftEmail has AI upstream attachments come from file nodes, not AI output.
"""
visited = visited or set()
if nodeId in visited:
return []
visited.add(nodeId)
docs = []
src = inputSources.get(nodeId, {}).get(0)
if not src:
return []
srcId, _ = src
srcNode = next((n for n in (orderedNodes or []) if n.get("id") == srcId), None)
srcType = (srcNode or {}).get("type", "")
out = nodeOutputs.get(srcId)
if srcType in ("sharepoint.downloadFile", "sharepoint.readFile"):
if isinstance(out, dict):
for d in out.get("documents") or out.get("documentList") or []:
if isinstance(d, dict) and (d.get("documentData") or (d.get("validationMetadata") or {}).get("fileId")):
docs.append(d)
elif hasattr(d, "documentData") or (getattr(d, "validationMetadata", None) or {}).get("fileId"):
docs.append(d.model_dump() if hasattr(d, "model_dump") else d)
elif srcType.startswith("ai."):
docs.extend(
_gatherAttachmentDocumentsFromUpstream(srcId, inputSources, nodeOutputs, orderedNodes, visited)
)
return docs
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,
services=None,
) -> 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:
ref = _resolveConnectionIdToReference(chatService, connId, services)
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, self.services)
# 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["context"] = email_content.get("body", "") or "(from connected AI node)"
# Attachments: gather from file nodes upstream of AI (e.g. downloadFile -> AI -> email)
attachment_docs = _gatherAttachmentDocumentsFromUpstream(
nodeId, inputSources, nodeOutputs, orderedNodes
)
if attachment_docs:
existing = actionParams.get("documentList") or []
# Prefer file docs from upstream; append any existing that look like binary attachments
def _is_binary_attachment(d):
if isinstance(d, dict) and d.get("documentData"):
try:
import json
json.loads(d["documentData"])
return False # JSON = email content, not attachment
except (TypeError, ValueError):
return True
return bool(isinstance(d, dict) and (d.get("validationMetadata") or {}).get("fileId"))
extra = [x for x in (existing if isinstance(existing, list) else []) if _is_binary_attachment(x)]
actionParams["documentList"] = attachment_docs + extra
if not email_content:
# AI returns plain text (e.g. email.txt): use as email body directly (no extra AI call)
ctx = _extractContextFromUpstream(inp)
if ctx:
actionParams["emailContent"] = {
"subject": actionParams.get("subject", "Draft"),
"body": ctx,
"to": actionParams.get("to"),
}
actionParams["context"] = ctx
else:
# Fallback: incoming email from upstream (if flow is email->AI->draft)
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:
doc_count = len(inp.get("documents", [])) if isinstance(inp, dict) else 0
logger.warning(
"email.draftEmail: AI upstream returned %d doc(s) but context extraction failed (no subject/body, no plain text). "
"Ensure AI node outputs document with documentData.",
doc_count,
)
actionParams["context"] = "(no context extracted from upstream check AI node output)"
elif srcType in ("sharepoint.downloadFile", "sharepoint.readFile"):
# File itself is the context: pass as attachment, use filename as minimal context (no content extraction)
if not actionParams.get("context"):
inp = nodeOutputs.get(srcId)
docs = (inp.get("documents") or inp.get("documentList", [])) if isinstance(inp, dict) else []
doc = docs[0] if docs else None
name = None
if isinstance(doc, dict):
name = doc.get("documentName") or doc.get("fileName")
elif doc and hasattr(doc, "documentName"):
name = getattr(doc, "documentName", None) or getattr(doc, "fileName", None)
ctx = name if name else "Attachment"
actionParams["context"] = ctx
actionParams["emailContent"] = {
"subject": actionParams.get("subject", "Draft"),
"body": ctx,
"to": actionParams.get("to"),
}
# documentList already merged from upstream (file as attachment)
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]
# Generic context handover: when upstream provides documents, pass first doc as content for actions that expect it
docList = actionParams.get("documentList") or resolvedParams.get("documentList")
if docList and "content" not in actionParams:
first = docList[0] if isinstance(docList, list) and docList else docList
# Actions like sharepoint.uploadFile consume content from context
actionParams["content"] = first
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