AI node had the full data.response, but markdownToDocumentJson stores paragraph text in inlineRuns while RendererMarkdown only read content.text, so body text was dropped, Markdown renderer now flattens inlineRuns into real Markdown so workflow-generated .md files include the upstream text, node specific shortcuts replaced
This commit is contained in:
parent
dac9911f8b
commit
eeb9a4a161
16 changed files with 389 additions and 199 deletions
|
|
@ -25,9 +25,11 @@ AI_NODES = [
|
||||||
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
|
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
|
||||||
"description": t("Ausgabeformat"), "default": "txt"},
|
"description": t("Ausgabeformat"), "default": "txt"},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": t("Daten aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "documentTheme", "type": "str", "required": False, "frontendType": "select",
|
{"name": "documentTheme", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]},
|
"frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]},
|
||||||
"description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"},
|
"description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"},
|
||||||
|
|
@ -53,9 +55,11 @@ AI_NODES = [
|
||||||
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
|
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
|
||||||
"description": t("Recherche-Anfrage")},
|
"description": t("Recherche-Anfrage")},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": t("Daten aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
|
|
@ -74,7 +78,8 @@ AI_NODES = [
|
||||||
"description": t("Dokumentinhalt zusammenfassen"),
|
"description": t("Dokumentinhalt zusammenfassen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "summaryLength", "type": "str", "required": False, "frontendType": "select",
|
{"name": "summaryLength", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
|
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
|
||||||
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
|
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
|
||||||
|
|
@ -94,7 +99,8 @@ AI_NODES = [
|
||||||
"description": t("Dokument in Zielsprache übersetzen"),
|
"description": t("Dokument in Zielsprache übersetzen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text",
|
{"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Zielsprache (z.B. de, en, French)")},
|
"description": t("Zielsprache (z.B. de, en, French)")},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
|
|
@ -113,7 +119,8 @@ AI_NODES = [
|
||||||
"description": t("Dokument in anderes Format konvertieren"),
|
"description": t("Dokument in anderes Format konvertieren"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "targetFormat", "type": "str", "required": True, "frontendType": "select",
|
{"name": "targetFormat", "type": "str", "required": True, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
|
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
|
||||||
"description": t("Zielformat")},
|
"description": t("Zielformat")},
|
||||||
|
|
@ -143,9 +150,11 @@ AI_NODES = [
|
||||||
"frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]},
|
"frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]},
|
||||||
"description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"},
|
"description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": t("Daten aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
|
|
@ -169,9 +178,11 @@ AI_NODES = [
|
||||||
"frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]},
|
"frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]},
|
||||||
"description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"},
|
"description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": t("Daten aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ CONTEXT_NODES = [
|
||||||
"description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"),
|
"description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
|
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
|
||||||
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
|
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "extractionOptions", "type": "object", "required": False, "frontendType": "json",
|
{"name": "extractionOptions", "type": "object", "required": False, "frontendType": "json",
|
||||||
"description": t(
|
"description": t(
|
||||||
"Extraktions-Optionen (JSON), z.B. {\"includeImages\": true, \"includeTables\": true, "
|
"Extraktions-Optionen (JSON), z.B. {\"includeImages\": true, \"includeTables\": true, "
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,13 @@ EMAIL_NODES = [
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("E-Mail-Konto")},
|
"description": t("E-Mail-Konto")},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea",
|
||||||
"description": t("Daten aus vorherigen Schritten (oder direkte Beschreibung)"), "default": ""},
|
"description": t("Daten aus vorherigen Schritten (oder direkte Beschreibung)"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
{"name": "to", "type": "str", "required": False, "frontendType": "text",
|
{"name": "to", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""},
|
"description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""},
|
||||||
{"name": "documentList", "type": "str", "required": False, "frontendType": "hidden",
|
{"name": "documentList", "type": "str", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Anhang-Dokumente (via Wire oder DataRef)"), "default": ""},
|
"description": t("Anhang-Dokumente (via Wire oder DataRef)"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{"name": "emailContent", "type": "str", "required": False, "frontendType": "hidden",
|
{"name": "emailContent", "type": "str", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Direkt vorbereiteter Inhalt {subject, body, to} (via Wire — überspringt KI)"),
|
"description": t("Direkt vorbereiteter Inhalt {subject, body, to} (via Wire — überspringt KI)"),
|
||||||
"default": ""},
|
"default": ""},
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ FILE_NODES = [
|
||||||
{"name": "title", "type": "str", "required": False, "frontendType": "text",
|
{"name": "title", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Dokumenttitel")},
|
"description": t("Dokumenttitel")},
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
"description": t("Daten aus vorherigen Schritten"), "default": "",
|
||||||
|
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,8 @@ TRUSTEE_NODES = [
|
||||||
# is List[ActionDocument] (see datamodelChat.ActionResult). The
|
# is List[ActionDocument] (see datamodelChat.ActionResult). The
|
||||||
# DataPicker uses this string to filter compatible upstream paths.
|
# DataPicker uses this string to filter compatible upstream paths.
|
||||||
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
dict(_TRUSTEE_INSTANCE_PARAM),
|
dict(_TRUSTEE_INSTANCE_PARAM),
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -95,7 +96,8 @@ TRUSTEE_NODES = [
|
||||||
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
|
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumente aus vorherigen Schritten")},
|
"description": t("Dokumente aus vorherigen Schritten"),
|
||||||
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
dict(_TRUSTEE_INSTANCE_PARAM),
|
dict(_TRUSTEE_INSTANCE_PARAM),
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -612,6 +612,21 @@ SYSTEM_VARIABLES: Dict[str, Dict[str, str]] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Graph inheritance (executeGraph materialization + ActionNodeExecutor wiring)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When a parameter declares ``graphInherit.kind == "primaryTextRef"``, executeGraph
|
||||||
|
# inserts an explicit DataRef before run (see pickNotPushMigration.materializePrimaryTextHandover).
|
||||||
|
# Schema names are catalog output port types (e.g. AiResult).
|
||||||
|
|
||||||
|
PRIMARY_TEXT_HANDOVER_REF_PATH: Dict[str, List[Any]] = {
|
||||||
|
"AiResult": ["response"],
|
||||||
|
"TextResult": ["text"],
|
||||||
|
"ConsolidateResult": ["result"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
|
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
|
||||||
"""Resolve a system variable name to its runtime value."""
|
"""Resolve a system variable name to its runtime value."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Markdown renderer for report generation.
|
||||||
|
|
||||||
from .documentRendererBaseTemplate import BaseRenderer
|
from .documentRendererBaseTemplate import BaseRenderer
|
||||||
from modules.datamodels.datamodelDocument import RenderedDocument
|
from modules.datamodels.datamodelDocument import RenderedDocument
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
class RendererMarkdown(BaseRenderer):
|
class RendererMarkdown(BaseRenderer):
|
||||||
"""Renders content to Markdown format with format-specific extraction."""
|
"""Renders content to Markdown format with format-specific extraction."""
|
||||||
|
|
@ -252,6 +252,41 @@ class RendererMarkdown(BaseRenderer):
|
||||||
self.logger.warning(f"Error rendering table: {str(e)}")
|
self.logger.warning(f"Error rendering table: {str(e)}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def _renderInlineRunsMarkdown(self, runs: Any) -> str:
|
||||||
|
"""Turn Phase-5 inlineRuns (from markdownToDocumentJson) into markdown text."""
|
||||||
|
if not runs:
|
||||||
|
return ""
|
||||||
|
if not isinstance(runs, list):
|
||||||
|
return str(runs)
|
||||||
|
parts: List[str] = []
|
||||||
|
for run in runs:
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
parts.append(str(run))
|
||||||
|
continue
|
||||||
|
run_type = run.get("type", "text")
|
||||||
|
value = str(run.get("value", ""))
|
||||||
|
if run_type == "text":
|
||||||
|
parts.append(value)
|
||||||
|
elif run_type == "bold":
|
||||||
|
parts.append(f"**{value}**")
|
||||||
|
elif run_type == "italic":
|
||||||
|
parts.append(f"*{value}*")
|
||||||
|
elif run_type == "code":
|
||||||
|
if not value:
|
||||||
|
parts.append("``")
|
||||||
|
elif "`" not in value:
|
||||||
|
parts.append(f"`{value}`")
|
||||||
|
else:
|
||||||
|
parts.append(f"``{value}``")
|
||||||
|
elif run_type == "link":
|
||||||
|
href = str(run.get("href", ""))
|
||||||
|
parts.append(f"[{value}]({href})")
|
||||||
|
elif run_type == "image":
|
||||||
|
parts.append(f"")
|
||||||
|
else:
|
||||||
|
parts.append(value)
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
def _renderJsonBulletList(self, listData: Dict[str, Any]) -> str:
|
def _renderJsonBulletList(self, listData: Dict[str, Any]) -> str:
|
||||||
"""Render a JSON bullet list to markdown."""
|
"""Render a JSON bullet list to markdown."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -268,6 +303,8 @@ class RendererMarkdown(BaseRenderer):
|
||||||
for item in items:
|
for item in items:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
markdownParts.append(f"- {item}")
|
markdownParts.append(f"- {item}")
|
||||||
|
elif isinstance(item, list):
|
||||||
|
markdownParts.append(f"- {self._renderInlineRunsMarkdown(item)}")
|
||||||
elif isinstance(item, dict) and "text" in item:
|
elif isinstance(item, dict) and "text" in item:
|
||||||
markdownParts.append(f"- {item['text']}")
|
markdownParts.append(f"- {item['text']}")
|
||||||
|
|
||||||
|
|
@ -302,14 +339,24 @@ class RendererMarkdown(BaseRenderer):
|
||||||
try:
|
try:
|
||||||
# Extract from nested content structure
|
# Extract from nested content structure
|
||||||
content = paragraphData.get("content", {})
|
content = paragraphData.get("content", {})
|
||||||
|
top = paragraphData.get("text")
|
||||||
|
if isinstance(top, str) and top.strip():
|
||||||
|
if not isinstance(content, dict) or (
|
||||||
|
not content.get("text") and not content.get("inlineRuns")
|
||||||
|
):
|
||||||
|
return top
|
||||||
|
|
||||||
if isinstance(content, dict):
|
if isinstance(content, dict):
|
||||||
|
runs = self._inlineRunsFromContent(content)
|
||||||
|
if runs:
|
||||||
|
return self._renderInlineRunsMarkdown(runs)
|
||||||
text = content.get("text", "")
|
text = content.get("text", "")
|
||||||
elif isinstance(content, str):
|
elif isinstance(content, str):
|
||||||
text = content
|
text = content
|
||||||
else:
|
else:
|
||||||
text = ""
|
text = ""
|
||||||
return text if text else ""
|
return text if text else ""
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Error rendering paragraph: {str(e)}")
|
self.logger.warning(f"Error rendering paragraph: {str(e)}")
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,10 @@ async def executeGraph(
|
||||||
)
|
)
|
||||||
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
|
||||||
discoverMethods(services)
|
discoverMethods(services)
|
||||||
from modules.workflows.automation2.pickNotPushMigration import materializeConnectionRefs
|
from modules.workflows.automation2.pickNotPushMigration import (
|
||||||
|
materializeConnectionRefs,
|
||||||
|
materializePrimaryTextHandover,
|
||||||
|
)
|
||||||
from modules.workflows.automation2.featureInstanceRefMigration import (
|
from modules.workflows.automation2.featureInstanceRefMigration import (
|
||||||
materializeFeatureInstanceRefs,
|
materializeFeatureInstanceRefs,
|
||||||
)
|
)
|
||||||
|
|
@ -372,6 +375,7 @@ async def executeGraph(
|
||||||
# subsequent connection-ref pass and validation see the canonical shape.
|
# subsequent connection-ref pass and validation see the canonical shape.
|
||||||
graph = materializeFeatureInstanceRefs(graph)
|
graph = materializeFeatureInstanceRefs(graph)
|
||||||
graph = materializeConnectionRefs(graph)
|
graph = materializeConnectionRefs(graph)
|
||||||
|
graph = materializePrimaryTextHandover(graph)
|
||||||
nodeTypeIds = _getNodeTypeIds(services)
|
nodeTypeIds = _getNodeTypeIds(services)
|
||||||
logger.debug("executeGraph nodeTypeIds (%d): %s", len(nodeTypeIds), sorted(nodeTypeIds))
|
logger.debug("executeGraph nodeTypeIds (%d): %s", len(nodeTypeIds), sorted(nodeTypeIds))
|
||||||
errors = validateGraph(graph, nodeTypeIds)
|
errors = validateGraph(graph, nodeTypeIds)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# Action node executor - maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions.
|
# Action node executor — maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions.
|
||||||
#
|
#
|
||||||
# Typed Port System: explicit DataRefs / static parameters; optional ``documentList`` from input port 0
|
# Typed port system: parameters resolve via DataRefs / static values. Declarative port inheritance
|
||||||
# when the param is empty (same idea as IOExecutor wire fill).
|
# uses ``graphInherit`` on parameter definitions in node JSON (see STATIC_NODE_TYPES): e.g.
|
||||||
# ``materializeConnectionRefs`` (see pickNotPushMigration) may still rewrite empty connectionReference at run start.
|
# ``primaryTextRef`` is materialized to explicit refs in pickNotPushMigration.materializePrimaryTextHandover;
|
||||||
|
# ``documentListWire`` is applied at runtime in this executor via graphUtils.extract_wired_document_list.
|
||||||
|
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
@ -20,8 +24,23 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import Bil
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_ascii_base64_payload(s: str) -> bool:
|
||||||
|
"""Heuristic: ActionDocument binary payloads use standard ASCII base64; markdown/text uses other chars (#, *, -, …)."""
|
||||||
|
t = "".join(s.split())
|
||||||
|
if len(t) < 8:
|
||||||
|
return False
|
||||||
|
if not t.isascii():
|
||||||
|
return False
|
||||||
|
return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0
|
||||||
|
|
||||||
|
|
||||||
def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]:
|
def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]:
|
||||||
"""Normalize documentData (bytes/str/buffer) for DB file persistence."""
|
"""Normalize documentData for DB file persistence.
|
||||||
|
|
||||||
|
ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII
|
||||||
|
base64 strings; plain markdown/text stays as Unicode. Do not UTF-8-encode a base64
|
||||||
|
literal — that persists the ASCII of the encoding (file looks like base64 gibberish).
|
||||||
|
"""
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(raw, bytes):
|
if isinstance(raw, bytes):
|
||||||
|
|
@ -33,7 +52,20 @@ def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]:
|
||||||
b = raw.tobytes()
|
b = raw.tobytes()
|
||||||
return b if len(b) > 0 else None
|
return b if len(b) > 0 else None
|
||||||
if isinstance(raw, str):
|
if isinstance(raw, str):
|
||||||
b = raw.encode("utf-8")
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
return None
|
||||||
|
if _looks_like_ascii_base64_payload(stripped):
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(stripped, validate=True)
|
||||||
|
except (TypeError, binascii.Error, ValueError):
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(stripped)
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
decoded = b""
|
||||||
|
if decoded:
|
||||||
|
return decoded
|
||||||
|
b = stripped.encode("utf-8")
|
||||||
return b if len(b) > 0 else None
|
return b if len(b) > 0 else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -239,78 +271,6 @@ def _getOutputSchemaName(nodeDef: Dict) -> str:
|
||||||
return port0.get("schema", "ActionResult")
|
return port0.get("schema", "ActionResult")
|
||||||
|
|
||||||
|
|
||||||
def _extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Build a DocumentList-shaped dict from upstream node output (matches IOExecutor wire behavior).
|
|
||||||
Handles DocumentList, human upload shapes (file / files / fileIds), FileList, loop file items.
|
|
||||||
During flow.loop body execution the loop node's output is
|
|
||||||
{items, count, currentItem, currentIndex}; wired document actions must use currentItem.
|
|
||||||
"""
|
|
||||||
if inp is None:
|
|
||||||
return None
|
|
||||||
from modules.features.graphicalEditor.portTypes import (
|
|
||||||
unwrapTransit,
|
|
||||||
_coerce_document_list_upload_fields,
|
|
||||||
_file_record_to_document,
|
|
||||||
)
|
|
||||||
|
|
||||||
data = unwrapTransit(inp)
|
|
||||||
if isinstance(data, str):
|
|
||||||
one = _file_record_to_document(data)
|
|
||||||
return {"documents": [one], "count": 1} if one else None
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
d = dict(data)
|
|
||||||
_coerce_document_list_upload_fields(d)
|
|
||||||
# Per-iteration payload from executionEngine (flow.loop → downstream in loop body)
|
|
||||||
if "currentItem" in d:
|
|
||||||
ci = d.get("currentItem")
|
|
||||||
if ci is not None:
|
|
||||||
nested = _extract_wired_document_list(ci)
|
|
||||||
if nested:
|
|
||||||
return nested
|
|
||||||
docs = d.get("documents")
|
|
||||||
if isinstance(docs, list) and len(docs) > 0:
|
|
||||||
return {"documents": docs, "count": d.get("count", len(docs))}
|
|
||||||
raw_list = d.get("documentList")
|
|
||||||
if isinstance(raw_list, list) and len(raw_list) > 0 and isinstance(raw_list[0], dict):
|
|
||||||
return {"documents": raw_list, "count": len(raw_list)}
|
|
||||||
doc_id = d.get("documentId") or d.get("id")
|
|
||||||
if doc_id and str(doc_id).strip():
|
|
||||||
one: Dict[str, Any] = {"id": str(doc_id).strip()}
|
|
||||||
fn = d.get("fileName") or d.get("name")
|
|
||||||
if fn:
|
|
||||||
one["name"] = str(fn)
|
|
||||||
mt = d.get("mimeType")
|
|
||||||
if mt:
|
|
||||||
one["mimeType"] = str(mt)
|
|
||||||
return {"documents": [one], "count": 1}
|
|
||||||
files = d.get("files")
|
|
||||||
if isinstance(files, list) and files:
|
|
||||||
collected = []
|
|
||||||
for item in files:
|
|
||||||
conv = _file_record_to_document(item) if isinstance(item, dict) else None
|
|
||||||
if conv:
|
|
||||||
collected.append(conv)
|
|
||||||
if collected:
|
|
||||||
return {"documents": collected, "count": len(collected)}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _document_list_param_is_empty(val: Any) -> bool:
|
|
||||||
if val is None or val == "":
|
|
||||||
return True
|
|
||||||
if isinstance(val, list) and len(val) == 0:
|
|
||||||
return True
|
|
||||||
if isinstance(val, dict):
|
|
||||||
if val.get("documents") or val.get("references") or val.get("items"):
|
|
||||||
return False
|
|
||||||
if val.get("documentId") or val.get("id"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class ActionNodeExecutor:
|
class ActionNodeExecutor:
|
||||||
"""Execute action nodes by mapping to method actions via ActionExecutor."""
|
"""Execute action nodes by mapping to method actions via ActionExecutor."""
|
||||||
|
|
||||||
|
|
@ -323,7 +283,11 @@ class ActionNodeExecutor:
|
||||||
context: Dict[str, Any],
|
context: Dict[str, Any],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from modules.features.graphicalEditor.nodeRegistry import getNodeTypeToMethodAction
|
from modules.features.graphicalEditor.nodeRegistry import getNodeTypeToMethodAction
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflows.automation2.graphUtils import (
|
||||||
|
document_list_param_is_empty,
|
||||||
|
extract_wired_document_list,
|
||||||
|
resolveParameterReferences,
|
||||||
|
)
|
||||||
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
||||||
|
|
||||||
nodeType = node.get("type", "")
|
nodeType = node.get("type", "")
|
||||||
|
|
@ -352,16 +316,23 @@ class ActionNodeExecutor:
|
||||||
if pName and pName not in resolvedParams and "default" in pDef:
|
if pName and pName not in resolvedParams and "default" in pDef:
|
||||||
resolvedParams[pName] = pDef["default"]
|
resolvedParams[pName] = pDef["default"]
|
||||||
|
|
||||||
_param_names = {p.get("name") for p in nodeDef.get("parameters", []) if p.get("name")}
|
for pDef in nodeDef.get("parameters") or []:
|
||||||
if "documentList" in _param_names and _document_list_param_is_empty(resolvedParams.get("documentList")):
|
gi = pDef.get("graphInherit") or {}
|
||||||
|
if gi.get("kind") != "documentListWire":
|
||||||
|
continue
|
||||||
|
pname = pDef.get("name")
|
||||||
|
if not pname or not document_list_param_is_empty(resolvedParams.get(pname)):
|
||||||
|
continue
|
||||||
|
port_ix = int(gi.get("port", 0))
|
||||||
_src_map = (context.get("inputSources") or {}).get(nodeId) or {}
|
_src_map = (context.get("inputSources") or {}).get(nodeId) or {}
|
||||||
_entry = _src_map.get(0)
|
_entry = _src_map.get(port_ix)
|
||||||
if _entry:
|
if not _entry:
|
||||||
_src_node_id, _ = _entry
|
continue
|
||||||
_upstream = (context.get("nodeOutputs") or {}).get(_src_node_id)
|
_src_node_id, _ = _entry
|
||||||
_wired = _extract_wired_document_list(_upstream)
|
_upstream = (context.get("nodeOutputs") or {}).get(_src_node_id)
|
||||||
if _wired:
|
_wired = extract_wired_document_list(_upstream)
|
||||||
resolvedParams["documentList"] = _wired
|
if _wired:
|
||||||
|
resolvedParams[pname] = _wired
|
||||||
|
|
||||||
# 3. Resolve connectionReference
|
# 3. Resolve connectionReference
|
||||||
chatService = getattr(self.services, "chat", None)
|
chatService = getattr(self.services, "chat", None)
|
||||||
|
|
@ -425,6 +396,16 @@ class ActionNodeExecutor:
|
||||||
docsList = []
|
docsList = []
|
||||||
for d in (result.documents or []):
|
for d in (result.documents or []):
|
||||||
dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d
|
dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d
|
||||||
|
if isinstance(dumped, dict):
|
||||||
|
_meta = dumped.get("validationMetadata") if isinstance(dumped.get("validationMetadata"), dict) else {}
|
||||||
|
_existing = dumped.get("fileId") or _meta.get("fileId")
|
||||||
|
# e.g. file.create already persisted inside the action — avoid a second FileItem with wrong bytes
|
||||||
|
if _existing and str(_existing).strip():
|
||||||
|
dumped["documentData"] = None
|
||||||
|
dumped.setdefault("_hasBinaryData", True)
|
||||||
|
docsList.append(dumped)
|
||||||
|
continue
|
||||||
|
|
||||||
rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None)
|
rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None)
|
||||||
rawBytes = _coerce_document_data_to_bytes(rawData)
|
rawBytes = _coerce_document_data_to_bytes(rawData)
|
||||||
if isinstance(dumped, dict) and rawBytes:
|
if isinstance(dumped, dict) and rawBytes:
|
||||||
|
|
@ -463,8 +444,12 @@ class ActionNodeExecutor:
|
||||||
dumped["_hasBinaryData"] = True
|
dumped["_hasBinaryData"] = True
|
||||||
docsList.append(dumped)
|
docsList.append(dumped)
|
||||||
|
|
||||||
# Clean DocumentList shape for document nodes (match file.create: documents + count, no AiResult fields)
|
# Clean DocumentList shape for document nodes (documents + count, no ActionResult/AiResult noise)
|
||||||
if outputSchema == "DocumentList" and nodeType in ("ai.generateDocument", "ai.convertDocument"):
|
if outputSchema == "DocumentList" and nodeType in (
|
||||||
|
"ai.generateDocument",
|
||||||
|
"ai.convertDocument",
|
||||||
|
"file.create",
|
||||||
|
):
|
||||||
if not result.success:
|
if not result.success:
|
||||||
return _normalizeError(
|
return _normalizeError(
|
||||||
RuntimeError(str(result.error or "document action failed")),
|
RuntimeError(str(result.error or "document action failed")),
|
||||||
|
|
@ -488,6 +473,13 @@ class ActionNodeExecutor:
|
||||||
extractedContext = ""
|
extractedContext = ""
|
||||||
elif raw:
|
elif raw:
|
||||||
extractedContext = str(raw).strip()
|
extractedContext = str(raw).strip()
|
||||||
|
else:
|
||||||
|
# ai.process (and similar): text handover in ActionResult.data — no persisted document row
|
||||||
|
rd = getattr(result, "data", None)
|
||||||
|
if isinstance(rd, dict):
|
||||||
|
handover = rd.get("response")
|
||||||
|
if handover is not None:
|
||||||
|
extractedContext = str(handover).strip()
|
||||||
|
|
||||||
promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip()
|
promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class IOExecutor:
|
||||||
nodeOutputs = context.get("nodeOutputs", {})
|
nodeOutputs = context.get("nodeOutputs", {})
|
||||||
params = dict(node.get("parameters") or {})
|
params = dict(node.get("parameters") or {})
|
||||||
|
|
||||||
from modules.workflows.automation2.graphUtils import resolveParameterReferences
|
from modules.workflows.automation2.graphUtils import extract_wired_document_list, resolveParameterReferences
|
||||||
resolvedParams = resolveParameterReferences(params, nodeOutputs)
|
resolvedParams = resolveParameterReferences(params, nodeOutputs)
|
||||||
logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys()))
|
logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys()))
|
||||||
|
|
||||||
|
|
@ -45,9 +45,7 @@ class IOExecutor:
|
||||||
if 0 in inputSources:
|
if 0 in inputSources:
|
||||||
srcId, _ = inputSources[0]
|
srcId, _ = inputSources[0]
|
||||||
inp = nodeOutputs.get(srcId)
|
inp = nodeOutputs.get(srcId)
|
||||||
from modules.workflows.automation2.executors.actionNodeExecutor import _extract_wired_document_list
|
wired = extract_wired_document_list(inp)
|
||||||
|
|
||||||
wired = _extract_wired_document_list(inp)
|
|
||||||
docs = (wired or {}).get("documents") if isinstance(wired, dict) else None
|
docs = (wired or {}).get("documents") if isinstance(wired, dict) else None
|
||||||
if docs:
|
if docs:
|
||||||
resolvedParams.setdefault("documentList", wired)
|
resolvedParams.setdefault("documentList", wired)
|
||||||
|
|
|
||||||
|
|
@ -7,50 +7,6 @@ from typing import Dict, List, Any, Tuple, Set, Optional
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _ai_result_text_from_documents(d: Dict[str, Any]) -> Optional[str]:
|
|
||||||
"""Extract plain-text body from AiResult-style ``documents[0].documentData``."""
|
|
||||||
docs = d.get("documents")
|
|
||||||
if not isinstance(docs, list) or not docs:
|
|
||||||
return None
|
|
||||||
d0 = docs[0]
|
|
||||||
raw: Any = None
|
|
||||||
if isinstance(d0, dict):
|
|
||||||
raw = d0.get("documentData")
|
|
||||||
elif d0 is not None:
|
|
||||||
raw = getattr(d0, "documentData", None)
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
if isinstance(raw, bytes):
|
|
||||||
try:
|
|
||||||
t = raw.decode("utf-8").strip()
|
|
||||||
return t or None
|
|
||||||
except (UnicodeDecodeError, ValueError):
|
|
||||||
return None
|
|
||||||
if isinstance(raw, str):
|
|
||||||
s = raw.strip()
|
|
||||||
return s or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _ref_coalesce_empty_ai_result_text(data: Any, path: List[Any], resolved: Any) -> Any:
|
|
||||||
"""If a ref targets AiResult text fields but resolves empty/missing, fall back to documents.
|
|
||||||
|
|
||||||
Needed when: optional ``responseData`` is absent (no synthetic ``{}``), ``response`` is
|
|
||||||
still empty but ``documents`` hold the model output, or legacy graphs bind responseData only.
|
|
||||||
"""
|
|
||||||
if resolved not in (None, ""):
|
|
||||||
return resolved
|
|
||||||
if not isinstance(data, dict) or not path:
|
|
||||||
return resolved
|
|
||||||
head = path[0]
|
|
||||||
if head not in ("response", "responseData", "context"):
|
|
||||||
return resolved
|
|
||||||
if head == "context" and len(path) != 1:
|
|
||||||
return resolved
|
|
||||||
fb = _ai_result_text_from_documents(data)
|
|
||||||
return fb if fb is not None else resolved
|
|
||||||
|
|
||||||
|
|
||||||
def parseGraph(graph: Dict[str, Any]) -> Tuple[List[Dict], List[Dict], Set[str]]:
|
def parseGraph(graph: Dict[str, Any]) -> Tuple[List[Dict], List[Dict], Set[str]]:
|
||||||
"""
|
"""
|
||||||
Parse graph into nodes, connections, and node IDs.
|
Parse graph into nodes, connections, and node IDs.
|
||||||
|
|
@ -408,7 +364,6 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
|
||||||
# Form nodes store fields under {"payload": {fieldName: …}}.
|
# Form nodes store fields under {"payload": {fieldName: …}}.
|
||||||
# DataPicker emits bare field paths like ["url"]; try under payload.
|
# DataPicker emits bare field paths like ["url"]; try under payload.
|
||||||
resolved = _get_by_path(data["payload"], plist)
|
resolved = _get_by_path(data["payload"], plist)
|
||||||
resolved = _ref_coalesce_empty_ai_result_text(data, plist, resolved)
|
|
||||||
return resolveParameterReferences(resolved, nodeOutputs)
|
return resolveParameterReferences(resolved, nodeOutputs)
|
||||||
return value
|
return value
|
||||||
if value.get("type") == "value":
|
if value.get("type") == "value":
|
||||||
|
|
@ -462,3 +417,73 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
|
||||||
return "\n\n".join(p for p in parts if p)
|
return "\n\n".join(p for p in parts if p)
|
||||||
return [resolveParameterReferences(v, nodeOutputs) for v in value]
|
return [resolveParameterReferences(v, nodeOutputs) for v in value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def document_list_param_is_empty(val: Any) -> bool:
|
||||||
|
"""True when a documentList-style parameter has not been set (wire + DataRef may fill)."""
|
||||||
|
if val is None or val == "":
|
||||||
|
return True
|
||||||
|
if isinstance(val, list) and len(val) == 0:
|
||||||
|
return True
|
||||||
|
if isinstance(val, dict):
|
||||||
|
if val.get("documents") or val.get("references") or val.get("items"):
|
||||||
|
return False
|
||||||
|
if val.get("documentId") or val.get("id"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Build a DocumentList-shaped dict from an upstream node output (port wire).
|
||||||
|
Used when a parameter declares ``graphInherit.kind == "documentListWire"``.
|
||||||
|
"""
|
||||||
|
if inp is None:
|
||||||
|
return None
|
||||||
|
from modules.features.graphicalEditor.portTypes import (
|
||||||
|
unwrapTransit,
|
||||||
|
_coerce_document_list_upload_fields,
|
||||||
|
_file_record_to_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = unwrapTransit(inp)
|
||||||
|
if isinstance(data, str):
|
||||||
|
one = _file_record_to_document(data)
|
||||||
|
return {"documents": [one], "count": 1} if one else None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
d = dict(data)
|
||||||
|
_coerce_document_list_upload_fields(d)
|
||||||
|
if "currentItem" in d:
|
||||||
|
ci = d.get("currentItem")
|
||||||
|
if ci is not None:
|
||||||
|
nested = extract_wired_document_list(ci)
|
||||||
|
if nested:
|
||||||
|
return nested
|
||||||
|
docs = d.get("documents")
|
||||||
|
if isinstance(docs, list) and len(docs) > 0:
|
||||||
|
return {"documents": docs, "count": d.get("count", len(docs))}
|
||||||
|
raw_list = d.get("documentList")
|
||||||
|
if isinstance(raw_list, list) and len(raw_list) > 0 and isinstance(raw_list[0], dict):
|
||||||
|
return {"documents": raw_list, "count": len(raw_list)}
|
||||||
|
doc_id = d.get("documentId") or d.get("id")
|
||||||
|
if doc_id and str(doc_id).strip():
|
||||||
|
one: Dict[str, Any] = {"id": str(doc_id).strip()}
|
||||||
|
fn = d.get("fileName") or d.get("name")
|
||||||
|
if fn:
|
||||||
|
one["name"] = str(fn)
|
||||||
|
mt = d.get("mimeType")
|
||||||
|
if mt:
|
||||||
|
one["mimeType"] = str(mt)
|
||||||
|
return {"documents": [one], "count": 1}
|
||||||
|
files = d.get("files")
|
||||||
|
if isinstance(files, list) and files:
|
||||||
|
collected = []
|
||||||
|
for item in files:
|
||||||
|
conv = _file_record_to_document(item) if isinstance(item, dict) else None
|
||||||
|
if conv:
|
||||||
|
collected.append(conv)
|
||||||
|
if collected:
|
||||||
|
return {"documents": collected, "count": len(collected)}
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
"""
|
"""
|
||||||
Graph helpers for Pick-not-Push: materialize connectionReference as explicit DataRefs.
|
Graph helpers for Pick-not-Push: materialize typed DataRefs before executeGraph runs.
|
||||||
|
|
||||||
Runtime: executeGraph deep-copies the version graph and applies materialize_connection_refs
|
- ``materializeConnectionRefs``: empty ``connectionReference`` from upstream connection provenance.
|
||||||
so downstream nodes resolve connection UUIDs from upstream output.connection.id.
|
- ``materializePrimaryTextHandover``: parameters whose static definition includes
|
||||||
|
``graphInherit.kind == "primaryTextRef"`` (canonical paths: ``PRIMARY_TEXT_HANDOVER_REF_PATH``).
|
||||||
|
|
||||||
|
Runtime: executeGraph deep-copies the version graph and applies these passes in order.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -12,7 +15,10 @@ import logging
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.features.graphicalEditor.portTypes import resolve_output_schema_name
|
from modules.features.graphicalEditor.portTypes import (
|
||||||
|
PRIMARY_TEXT_HANDOVER_REF_PATH,
|
||||||
|
resolve_output_schema_name,
|
||||||
|
)
|
||||||
from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources
|
from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -81,3 +87,70 @@ def materializeConnectionRefs(graph: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
logger.debug("materializeConnectionRefs: %s.connectionReference -> ref %s.connection.id", nid, src_id)
|
logger.debug("materializeConnectionRefs: %s.connectionReference -> ref %s.connection.id", nid, src_id)
|
||||||
|
|
||||||
return g
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_empty_for_primary_text_inherit(val: Any) -> bool:
|
||||||
|
return val is None or val == "" or val == []
|
||||||
|
|
||||||
|
|
||||||
|
def materializePrimaryTextHandover(graph: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
For parameters declaring ``graphInherit.kind == "primaryTextRef"`` (optional ``port``, default 0) with an
|
||||||
|
empty value, set an explicit ``DataRef`` to the canonical text field of the producer on
|
||||||
|
that port (see ``PRIMARY_TEXT_HANDOVER_REF_PATH`` keyed by upstream output schema name).
|
||||||
|
"""
|
||||||
|
g = copy.deepcopy(graph)
|
||||||
|
nodes: List[Dict[str, Any]] = g.get("nodes") or []
|
||||||
|
connections = g.get("connections") or []
|
||||||
|
if not nodes:
|
||||||
|
return g
|
||||||
|
|
||||||
|
conn_map = buildConnectionMap(connections)
|
||||||
|
node_by_id = {n["id"]: n for n in nodes if n.get("id")}
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
nid = node.get("id")
|
||||||
|
ntype = node.get("type")
|
||||||
|
if not nid or not ntype:
|
||||||
|
continue
|
||||||
|
node_def = _NODE_DEF_BY_ID.get(ntype)
|
||||||
|
if not node_def:
|
||||||
|
continue
|
||||||
|
params = node.get("parameters")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
node["parameters"] = {}
|
||||||
|
params = node["parameters"]
|
||||||
|
|
||||||
|
for pdef in node_def.get("parameters") or []:
|
||||||
|
gi = pdef.get("graphInherit")
|
||||||
|
if not isinstance(gi, dict) or gi.get("kind") != "primaryTextRef":
|
||||||
|
continue
|
||||||
|
pname = pdef.get("name")
|
||||||
|
if not pname:
|
||||||
|
continue
|
||||||
|
port_ix = int(gi.get("port", 0))
|
||||||
|
if not _slot_empty_for_primary_text_inherit(params.get(pname)):
|
||||||
|
continue
|
||||||
|
input_sources = getInputSources(nid, conn_map)
|
||||||
|
if port_ix not in input_sources:
|
||||||
|
continue
|
||||||
|
src_id, _ = input_sources[port_ix]
|
||||||
|
src_node = node_by_id.get(src_id) or {}
|
||||||
|
src_def = _NODE_DEF_BY_ID.get(src_node.get("type") or "")
|
||||||
|
if not src_def:
|
||||||
|
continue
|
||||||
|
out_port = (src_def.get("outputPorts") or {}).get(0, {}) or {}
|
||||||
|
out_schema = resolve_output_schema_name(src_node, out_port if isinstance(out_port, dict) else {})
|
||||||
|
ref_path = PRIMARY_TEXT_HANDOVER_REF_PATH.get(out_schema)
|
||||||
|
if not ref_path:
|
||||||
|
continue
|
||||||
|
params[pname] = _data_ref(src_id, list(ref_path))
|
||||||
|
logger.debug(
|
||||||
|
"materializePrimaryTextHandover: %s.%s -> ref %s path=%s",
|
||||||
|
nid,
|
||||||
|
pname,
|
||||||
|
src_id,
|
||||||
|
ref_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
return g
|
||||||
|
|
|
||||||
|
|
@ -385,34 +385,33 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
))
|
))
|
||||||
|
|
||||||
final_documents = action_documents
|
final_documents = action_documents
|
||||||
|
handover_data = None
|
||||||
else:
|
else:
|
||||||
# Text response - create document from content
|
# Text-only response: keep handover in ActionResult.data (no ActionDocument).
|
||||||
# If no extension provided, use "txt" (required for filename)
|
# Avoids automation2 persisting a synthetic file per run; use ai.generateDocument for files.
|
||||||
extension = output_extension.lstrip('.') if output_extension else "txt"
|
body = aiResponse.content
|
||||||
meaningful_name = self._generateMeaningfulFileName(
|
if body is None:
|
||||||
base_name="ai",
|
body = ""
|
||||||
extension=extension,
|
elif not isinstance(body, str):
|
||||||
action_name="result"
|
body = str(body)
|
||||||
)
|
final_documents = []
|
||||||
validationMetadata = {
|
handover_data = {
|
||||||
"actionType": "ai.process",
|
"response": body,
|
||||||
"resultType": normalized_result_type if normalized_result_type else None,
|
"resultType": normalized_result_type,
|
||||||
"outputFormat": output_format if output_format else None,
|
"outputFormat": output_format,
|
||||||
"hasDocuments": False,
|
"contentType": "text",
|
||||||
"contentType": "text"
|
|
||||||
}
|
}
|
||||||
action_document = ActionDocument(
|
md = getattr(aiResponse, "metadata", None)
|
||||||
documentName=meaningful_name,
|
if md is not None:
|
||||||
documentData=aiResponse.content,
|
extra = getattr(md, "additionalData", None)
|
||||||
mimeType=output_mime_type,
|
if isinstance(extra, dict):
|
||||||
validationMetadata=validationMetadata
|
for k, v in extra.items():
|
||||||
)
|
handover_data.setdefault(k, v)
|
||||||
final_documents = [action_document]
|
|
||||||
|
|
||||||
# Complete progress tracking
|
# Complete progress tracking
|
||||||
self.services.chat.progressLogFinish(operationId, True)
|
self.services.chat.progressLogFinish(operationId, True)
|
||||||
|
|
||||||
return ActionResult.isSuccess(documents=final_documents)
|
return ActionResult.isSuccess(documents=final_documents, data=handover_data)
|
||||||
|
|
||||||
except (SubscriptionInactiveException, BillingContextError):
|
except (SubscriptionInactiveException, BillingContextError):
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import logging
|
||||||
|
|
||||||
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
||||||
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson
|
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson
|
||||||
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
|
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
|
||||||
|
|
@ -47,7 +49,10 @@ def _persistDocumentsToUserFiles(
|
||||||
if not doc_data:
|
if not doc_data:
|
||||||
continue
|
continue
|
||||||
if isinstance(doc_data, str):
|
if isinstance(doc_data, str):
|
||||||
content = base64.b64decode(doc_data)
|
try:
|
||||||
|
content = base64.b64decode(doc_data, validate=True)
|
||||||
|
except (TypeError, ValueError, binascii.Error):
|
||||||
|
content = doc_data.encode("utf-8")
|
||||||
else:
|
else:
|
||||||
content = doc_data
|
content = doc_data
|
||||||
doc_name = (
|
doc_name = (
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ class ActionExecutor:
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
success=result.success,
|
success=result.success,
|
||||||
documents=result.documents, # Return original ActionDocument objects
|
documents=result.documents, # Return original ActionDocument objects
|
||||||
|
data=result.data,
|
||||||
resultLabel=action.execResultLabel, # Always use action's execResultLabel
|
resultLabel=action.execResultLabel, # Always use action's execResultLabel
|
||||||
error=result.error or ""
|
error=result.error or ""
|
||||||
)
|
)
|
||||||
|
|
@ -265,18 +266,21 @@ class ActionExecutor:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extractResultText(self, result: ActionResult) -> str:
|
def _extractResultText(self, result: ActionResult) -> str:
|
||||||
"""Extract result text from ActionResult documents"""
|
"""Extract result text from ActionResult documents or structured data (e.g. ai.process handover)."""
|
||||||
if not result.success or not result.documents:
|
if not result.success:
|
||||||
return ""
|
return ""
|
||||||
|
if result.documents:
|
||||||
# Extract text directly from ActionDocument objects
|
resultParts = []
|
||||||
resultParts = []
|
for doc in result.documents:
|
||||||
for doc in result.documents:
|
if hasattr(doc, "documentData") and doc.documentData:
|
||||||
if hasattr(doc, 'documentData') and doc.documentData:
|
resultParts.append(str(doc.documentData))
|
||||||
resultParts.append(str(doc.documentData))
|
return "\n\n---\n\n".join(resultParts) if resultParts else ""
|
||||||
|
data = getattr(result, "data", None)
|
||||||
# Join all document results with separators
|
if isinstance(data, dict):
|
||||||
return "\n\n---\n\n".join(resultParts) if resultParts else ""
|
handover = data.get("response")
|
||||||
|
if handover is not None:
|
||||||
|
return str(handover)
|
||||||
|
return ""
|
||||||
|
|
||||||
async def _createActionCompletionMessage(self, action: ActionItem, result: ActionResult, workflow: ChatWorkflow,
|
async def _createActionCompletionMessage(self, action: ActionItem, result: ActionResult, workflow: ChatWorkflow,
|
||||||
taskStep: TaskStep, taskIndex: int, actionIndex: int):
|
taskStep: TaskStep, taskIndex: int, actionIndex: int):
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,17 @@ class MessageCreator:
|
||||||
messageText = f"**Action {currentAction} ({action.execMethod}.{action.execAction})**\n\n"
|
messageText = f"**Action {currentAction} ({action.execMethod}.{action.execAction})**\n\n"
|
||||||
messageText += f"❌ {userFriendlyText}\n\n"
|
messageText += f"❌ {userFriendlyText}\n\n"
|
||||||
messageText += f"{errorDetails}\n\n"
|
messageText += f"{errorDetails}\n\n"
|
||||||
|
|
||||||
|
# Text handover without attachment (e.g. ai.process): show content in the message body
|
||||||
|
if (
|
||||||
|
result.success
|
||||||
|
and not createdDocuments
|
||||||
|
and getattr(result, "data", None)
|
||||||
|
and isinstance(result.data, dict)
|
||||||
|
):
|
||||||
|
handover_txt = result.data.get("response")
|
||||||
|
if handover_txt is not None and str(handover_txt).strip():
|
||||||
|
messageText += "\n\n" + str(handover_txt).strip()
|
||||||
|
|
||||||
# Build concise summary to persist for history context
|
# Build concise summary to persist for history context
|
||||||
doc_count = len(createdDocuments) if createdDocuments else 0
|
doc_count = len(createdDocuments) if createdDocuments else 0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue