feat: unify workflow context picker — contextBuilder multi-select, lift type-blocking, user-language labels, backend serialization, fix circular ref crash

This commit is contained in:
Ida 2026-05-03 15:50:11 +02:00
parent 60b2fcf56b
commit f96325f804
14 changed files with 99 additions and 66 deletions

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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."""

View file

@ -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")

View file

@ -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")

View file

@ -220,17 +220,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
@ -257,7 +252,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:

View file

@ -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:

View file

@ -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)")

View file

@ -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