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:
parent
60b2fcf56b
commit
f96325f804
14 changed files with 99 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue