diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py index 3b665981..b0291600 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py @@ -12,17 +12,30 @@ import uuid from typing import Dict, Any, List, Optional -def _make_json_serializable(obj: Any) -> Any: +_INTERNAL_SKIP_KEYS = frozenset({"_context", "_orderedNodes"}) + + +def _make_json_serializable(obj: Any, _depth: int = 0) -> Any: """ Recursively convert bytes to base64 strings so structures can be JSON-serialized for storage in JSONB columns. + + Internal runtime keys (_context, _orderedNodes) are skipped — they hold live + Python objects (including back-references to nodeOutputs) and must never be + stored. A depth guard prevents runaway recursion on unexpected circular refs. """ + if _depth > 50: + return None if isinstance(obj, bytes): return base64.b64encode(obj).decode("ascii") if isinstance(obj, dict): - return {k: _make_json_serializable(v) for k, v in obj.items()} + return { + k: _make_json_serializable(v, _depth + 1) + for k, v in obj.items() + if k not in _INTERNAL_SKIP_KEYS + } if isinstance(obj, list): - return [_make_json_serializable(v) for v in obj] + return [_make_json_serializable(v, _depth + 1) for v in obj] return obj from modules.datamodels.datamodelUam import User diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py index 857b1516..c575f39c 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py @@ -24,10 +24,10 @@ AI_NODES = [ {"name": "resultType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]}, "description": t("Ausgabeformat"), "default": "txt"}, - {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", - "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Kontextdaten fuer den Prompt (Upstream-Output binden)"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", + "description": t("Dokumente aus vorherigen Schritten"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, {"name": "documentTheme", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]}, "description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"}, @@ -37,7 +37,7 @@ AI_NODES = [ "inputs": 1, "outputs": 1, "inputPorts": {0: {"accepts": [ - "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult", + "FormPayload", "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult", ]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True}, @@ -52,14 +52,14 @@ AI_NODES = [ "parameters": [ {"name": "prompt", "type": "str", "required": True, "frontendType": "textarea", "description": t("Recherche-Anfrage")}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, - {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", - "description": t("Optionale Dokumentenliste aus Upstream-Node (Text wird dem Prompt hinzugefügt)"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", + "description": t("Dokumente aus vorherigen Schritten"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -72,7 +72,7 @@ AI_NODES = [ "description": t("Dokumentinhalt zusammenfassen"), "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", - "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, + "description": t("Dokumente aus vorherigen Schritten")}, {"name": "summaryLength", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["brief", "medium", "detailed"]}, "description": t("Kurz, mittel oder ausführlich"), "default": "medium"}, @@ -92,7 +92,7 @@ AI_NODES = [ "description": t("Dokument in Zielsprache übersetzen"), "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", - "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, + "description": t("Dokumente aus vorherigen Schritten")}, {"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text", "description": t("Zielsprache (z.B. de, en, French)")}, ] + _AI_COMMON_PARAMS, @@ -111,7 +111,7 @@ AI_NODES = [ "description": t("Dokument in anderes Format konvertieren"), "parameters": [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", - "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""}, + "description": t("Dokumente aus vorherigen Schritten")}, {"name": "targetFormat", "type": "str", "required": True, "frontendType": "select", "frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]}, "description": t("Zielformat")}, @@ -132,14 +132,14 @@ AI_NODES = [ "parameters": [ {"name": "prompt", "type": "str", "required": True, "frontendType": "textarea", "description": t("Generierungs-Prompt")}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, - {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", - "description": t("Optionale Dokumentenliste als Vorlage/Referenz"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", + "description": t("Dokumente aus vorherigen Schritten"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "DocumentList"}}, "meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True}, "_method": "ai", @@ -156,14 +156,14 @@ AI_NODES = [ {"name": "resultType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]}, "description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Optionaler Kontext aus Upstream-Node (wird dem Prompt vorangestellt)"), "default": ""}, - {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef", - "description": t("Optionale Dokumentenliste als Referenz"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, + {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden", + "description": t("Dokumente aus vorherigen Schritten"), "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, - "inputPorts": {0: {"accepts": ["Transit", "AiResult", "DocumentList", "ActionResult"]}}, + "inputPorts": {0: {"accepts": ["FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult"]}}, "outputPorts": {0: {"schema": "AiResult"}}, "meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True}, "_method": "ai", diff --git a/modules/features/graphicalEditor/nodeDefinitions/email.py b/modules/features/graphicalEditor/nodeDefinitions/email.py index 698efa94..8f316605 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/email.py +++ b/modules/features/graphicalEditor/nodeDefinitions/email.py @@ -62,8 +62,8 @@ EMAIL_NODES = [ {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "msft"}, "description": t("E-Mail-Konto")}, - {"name": "context", "type": "str", "required": False, "frontendType": "templateTextarea", - "description": t("Kontext / Brief-Beschreibung für die KI-Komposition"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea", + "description": t("Daten aus vorherigen Schritten (oder direkte Beschreibung)"), "default": ""}, {"name": "to", "type": "str", "required": False, "frontendType": "text", "description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""}, {"name": "documentList", "type": "str", "required": False, "frontendType": "hidden", diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index 9fbd261d..9795903d 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -23,8 +23,8 @@ FILE_NODES = [ {"name": "language", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["de", "en", "fr"]}, "description": t("Sprache"), "default": "de"}, - {"name": "context", "type": "str", "required": False, "frontendType": "dataRef", - "description": t("Inhalt aus Upstream-Node (binden via DataRef oder Wire)"), "default": ""}, + {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", + "description": t("Daten aus vorherigen Schritten"), "default": ""}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/features/graphicalEditor/nodeDefinitions/trustee.py index 7392b4d2..3adc9d3f 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/trustee.py +++ b/modules/features/graphicalEditor/nodeDefinitions/trustee.py @@ -77,7 +77,7 @@ TRUSTEE_NODES = [ # is List[ActionDocument] (see datamodelChat.ActionResult). The # DataPicker uses this string to filter compatible upstream paths. {"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef", - "description": t("Dokumentenliste — gebunden via DataRef.")}, + "description": t("Dokumente aus vorherigen Schritten")}, dict(_TRUSTEE_INSTANCE_PARAM), ], "inputs": 1, @@ -95,7 +95,7 @@ TRUSTEE_NODES = [ "description": t("Trustee-Positionen in Buchhaltungssystem übertragen."), "parameters": [ {"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef", - "description": t("Verarbeitete Dokumentenliste — gebunden via DataRef.")}, + "description": t("Dokumente aus vorherigen Schritten")}, dict(_TRUSTEE_INSTANCE_PARAM), ], "inputs": 1, diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index 246d4791..56de0b26 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -34,6 +34,8 @@ class PortField(BaseModel): # FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible # producers by sub-type. Type must be "str" when discriminator is True. discriminator: bool = False + # Surfaces this field at the top of the DataPicker list as the most common pick. + recommended: bool = False class PortSchema(BaseModel): @@ -153,7 +155,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = { ]), "DocumentList": PortSchema(name="DocumentList", fields=[ PortField(name="documents", type="List[Document]", - description="Dokumentenliste"), + description="Dokumente aus vorherigen Schritten", recommended=True), PortField(name="connection", type="ConnectionRef", required=False, description="Verbindung, mit der die Liste erzeugt wurde"), PortField(name="source", type="SharePointFolderRef", required=False, diff --git a/modules/workflows/automation2/graphUtils.py b/modules/workflows/automation2/graphUtils.py index 1d2aeb13..72b6b9f4 100644 --- a/modules/workflows/automation2/graphUtils.py +++ b/modules/workflows/automation2/graphUtils.py @@ -398,5 +398,11 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any: return str(data) if data is not None else m.group(0) return re.sub(r"\{\{\s*([^}]+)\s*\}\}", repl, value) if isinstance(value, list): + # contextBuilder: list where every item is a `{"type":"ref",...}` envelope. + # Resolve each ref and join the serialised parts into a single prompt string. + if value and all(isinstance(v, dict) and v.get("type") == "ref" for v in value): + from modules.workflows.methods.methodAi._common import serialize_context + parts = [serialize_context(resolveParameterReferences(v, nodeOutputs)) for v in value] + return "\n\n".join(p for p in parts if p) return [resolveParameterReferences(v, nodeOutputs) for v in value] return value diff --git a/modules/workflows/methods/methodAi/_common.py b/modules/workflows/methods/methodAi/_common.py index 9e77d431..d913f9e4 100644 --- a/modules/workflows/methods/methodAi/_common.py +++ b/modules/workflows/methods/methodAi/_common.py @@ -3,6 +3,27 @@ """Shared helpers for AI workflow actions.""" +import json +from typing import Any + + +def serialize_context(val: Any) -> str: + """Convert any context value to a readable string for use in AI prompts. + + - None / empty string → "" + - str → as-is + - dict / list → pretty-printed JSON + - anything else → str() + """ + if val is None or val == "" or val == []: + return "" + if isinstance(val, str): + return val.strip() + try: + return json.dumps(val, ensure_ascii=False, indent=2) + except Exception: + return str(val) + def applyCommonAiParams(parameters: dict, request) -> None: """Apply common AI parameters (requireNeutralization, allowedModels) from node to request.""" diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py index 24237289..ee375d89 100644 --- a/modules/workflows/methods/methodAi/actions/generateCode.py +++ b/modules/workflows/methods/methodAi/actions/generateCode.py @@ -14,12 +14,10 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil logger = logging.getLogger(__name__) async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult: - base_prompt = parameters.get("prompt") or "" - context_val = parameters.get("context") - if context_val and isinstance(context_val, str) and context_val.strip(): - prompt = f"Kontext:\n{context_val.strip()}\n\n{base_prompt.strip()}" - else: - prompt = base_prompt + from modules.workflows.methods.methodAi._common import serialize_context + base_prompt = (parameters.get("prompt") or "").strip() + context_val = serialize_context(parameters.get("context")) + prompt = f"Kontext:\n{context_val}\n\n{base_prompt}" if context_val else base_prompt if not prompt.strip(): return ActionResult.isFailure(error="prompt is required") diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 1d4a4b66..a8fcaf0f 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -14,12 +14,10 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil logger = logging.getLogger(__name__) async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: - base_prompt = parameters.get("prompt") or "" - context_val = parameters.get("context") - if context_val and isinstance(context_val, str) and context_val.strip(): - prompt = f"Kontext:\n{context_val.strip()}\n\n{base_prompt.strip()}" - else: - prompt = base_prompt + from modules.workflows.methods.methodAi._common import serialize_context + base_prompt = (parameters.get("prompt") or "").strip() + context_val = serialize_context(parameters.get("context")) + prompt = f"Kontext:\n{context_val}\n\n{base_prompt}" if context_val else base_prompt if not prompt.strip(): return ActionResult.isFailure(error="prompt is required") diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 50500929..24364452 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -210,17 +210,12 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult: mimeMap = {"txt": "text/plain", "json": "application/json", "html": "text/html", "md": "text/markdown", "csv": "text/csv", "xml": "application/xml"} output_mime_type = mimeMap.get(normalized_result_type, "text/plain") if normalized_result_type else "text/plain" - # Normalize context: workflow refs may resolve to dict/list instead of str - paramContext = parameters.get("context") - if paramContext is not None and not isinstance(paramContext, str): - try: - paramContext = json.dumps(paramContext, ensure_ascii=False, default=str) - parameters["context"] = paramContext - logger.info(f"ai.process: Serialized non-string context ({type(parameters.get('context')).__name__}) to JSON ({len(paramContext)} chars)") - except Exception as e: - logger.warning(f"ai.process: Failed to serialize context: {e}") - paramContext = str(paramContext) - parameters["context"] = paramContext + # Normalize context: serialize any non-string value (dict/list/int/…) to text + from modules.workflows.methods.methodAi._common import serialize_context + paramContext = serialize_context(parameters.get("context")) + parameters["context"] = paramContext + if paramContext: + logger.info(f"ai.process: context serialized ({len(paramContext)} chars)") # Phase 7.3: Pass documentList and/or contentParts to AI service contentParts: Optional[List[ContentPart]] = inline_content_parts @@ -247,7 +242,7 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult: self.services.chat.progressLogUpdate(operationId, 0.6, "Calling AI (simple mode)") context_parts = [] - paramContext = parameters.get("context") + paramContext = parameters.get("context") # already serialized above if paramContext and isinstance(paramContext, str) and paramContext.strip(): context_parts.append(paramContext.strip()) if documentList and len(documentList.references) > 0: diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py index 2cf388b9..e32f8e65 100644 --- a/modules/workflows/methods/methodAi/actions/webResearch.py +++ b/modules/workflows/methods/methodAi/actions/webResearch.py @@ -15,15 +15,15 @@ logger = logging.getLogger(__name__) def _build_research_prompt(parameters: Dict[str, Any]) -> str: """Assemble the final research prompt from prompt + optional context/documentList.""" + from modules.workflows.methods.methodAi._common import serialize_context base_prompt = (parameters.get("prompt") or "").strip() - context_val = parameters.get("context") + context_val = serialize_context(parameters.get("context")) doc_list = parameters.get("documentList") parts: list[str] = [] - # Prepend context string if provided - if context_val and isinstance(context_val, str) and context_val.strip(): - parts.append(f"Kontext:\n{context_val.strip()}") + if context_val: + parts.append(f"Kontext:\n{context_val}") # Extract text from documentList items if provided if doc_list: diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index 5feb6287..96804b10 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -74,10 +74,9 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult: Create a file from context (text/markdown from upstream AI node). Uses GenerationService.renderReport to produce docx, pdf, txt, md, html, xlsx, etc. """ - context = parameters.get("context", "") or parameters.get("text", "") or "" - if not isinstance(context, str): - context = str(context) if context else "" - context = context.strip() + from modules.workflows.methods.methodAi._common import serialize_context + raw_context = parameters.get("context", "") or parameters.get("text", "") or "" + context = serialize_context(raw_context) if not context: return ActionResult.isFailure(error="context is required (connect an AI node or provide text)") diff --git a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py index 43c4dc41..a191e84b 100644 --- a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py +++ b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py @@ -14,7 +14,8 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A try: connectionReference = parameters.get("connectionReference") to = parameters.get("to") or [] # Optional for drafts - can save draft without recipients - context = parameters.get("context") + from modules.workflows.methods.methodAi._common import serialize_context + context = serialize_context(parameters.get("context")) or None documentList = parameters.get("documentList") or [] replySourceDocuments = parameters.get("replySourceDocuments") or [] # Original email(s) for reply attachment # ``attachments`` (added in 2026-04 for the PWG pilot) is a list of