From c140bd14d45d89665b4d9412dee8e7d747cbc9ad Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 30 Apr 2026 23:54:45 +0200
Subject: [PATCH] fixed nodes handovers
---
modules/datamodels/datamodelDocref.py | 10 +-
.../graphicalEditor/nodeDefinitions/ai.py | 21 ++--
modules/features/trustee/mainTrustee.py | 4 +-
modules/interfaces/interfaceBootstrap.py | 95 ++++++++++++++++++
modules/routes/routeAutomationWorkspace.py | 97 +++++++++++++++----
.../services/serviceChat/mainServiceChat.py | 6 --
.../methods/methodAi/actions/process.py | 66 ++++++++++++-
.../workflows/methods/methodAi/methodAi.py | 17 ++++
8 files changed, 276 insertions(+), 40 deletions(-)
diff --git a/modules/datamodels/datamodelDocref.py b/modules/datamodels/datamodelDocref.py
index 27ba5e2b..e20fb072 100644
--- a/modules/datamodels/datamodelDocref.py
+++ b/modules/datamodels/datamodelDocref.py
@@ -110,11 +110,13 @@ class DocumentReferenceList(BaseModel):
# docItem:documentId
references.append(DocumentItemReference(documentId=parts[0]))
- # Unknown format - skip or log warning
else:
- # Try to parse as simple string (backward compatibility)
- # Assume it's a label if it doesn't match known patterns
- if refStr:
+ if not refStr:
+ continue
+ import re
+ if re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', refStr, re.I):
+ references.append(DocumentItemReference(documentId=refStr))
+ else:
references.append(DocumentListReference(label=refStr))
return cls(references=references)
diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py
index 0336e382..65e97654 100644
--- a/modules/features/graphicalEditor/nodeDefinitions/ai.py
+++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py
@@ -24,8 +24,13 @@ AI_NODES = [
{"name": "resultType", "type": "string", "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": "string", "required": False, "frontendType": "hidden",
- "description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
+ {"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef",
+ "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
+ {"name": "context", "type": "string", "required": False, "frontendType": "dataRef",
+ "description": t("Kontextdaten fuer den Prompt (Upstream-Output binden)"), "default": ""},
+ {"name": "documentTheme", "type": "string", "required": False, "frontendType": "select",
+ "frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]},
+ "description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"},
{"name": "simpleMode", "type": "boolean", "required": False, "frontendType": "checkbox",
"description": t("Einfacher Modus"), "default": True},
] + _AI_COMMON_PARAMS,
@@ -62,8 +67,8 @@ AI_NODES = [
"label": t("Dokument zusammenfassen"),
"description": t("Dokumentinhalt zusammenfassen"),
"parameters": [
- {"name": "documentList", "type": "string", "required": True, "frontendType": "hidden",
- "description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
+ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
+ "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
{"name": "summaryLength", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
@@ -82,8 +87,8 @@ AI_NODES = [
"label": t("Dokument übersetzen"),
"description": t("Dokument in Zielsprache übersetzen"),
"parameters": [
- {"name": "documentList", "type": "string", "required": True, "frontendType": "hidden",
- "description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
+ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
+ "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
{"name": "targetLanguage", "type": "string", "required": True, "frontendType": "text",
"description": t("Zielsprache (z.B. de, en, French)")},
] + _AI_COMMON_PARAMS,
@@ -101,8 +106,8 @@ AI_NODES = [
"label": t("Dokument konvertieren"),
"description": t("Dokument in anderes Format konvertieren"),
"parameters": [
- {"name": "documentList", "type": "string", "required": True, "frontendType": "hidden",
- "description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
+ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
+ "description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
{"name": "targetFormat", "type": "string", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
"description": t("Zielformat")},
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index d8f7a804..b8ab853d 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -383,7 +383,7 @@ def _buildAnalysisWorkflowGraph(prompt: str) -> Dict[str, Any]:
"parameters": {
"aiPrompt": prompt + _FINANCE_STYLE_HINT,
"context": {"type": "ref", "nodeId": "refresh", "path": ["data", "accountingData"]},
- "requireNeutralization": True,
+ "requireNeutralization": False,
"simpleMode": False,
}, "position": {"x": 500, "y": 0}},
],
@@ -478,7 +478,7 @@ TEMPLATE_WORKFLOWS = [
),
"resultType": "xlsx",
"documentTheme": "finance",
- "requireNeutralization": True,
+ "requireNeutralization": False,
"documentList": {"type": "ref", "nodeId": "trigger", "path": ["payload", "documentList"]},
"context": {"type": "ref", "nodeId": "refresh", "path": ["data", "accountingData"]},
"simpleMode": False,
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 4bcd0e97..b7a56a02 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -115,6 +115,10 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Bootstrap system workflow templates for graphical editor
_bootstrapSystemTemplates(db)
+ # Sync feature template workflows (update graph of existing instance workflows
+ # whose templateSourceId matches a current code-defined template)
+ _syncFeatureTemplateWorkflows()
+
# Ensure billing settings and accounts exist for all mandates
_bootstrapBilling()
@@ -190,6 +194,97 @@ def _bootstrapSystemTemplates(db: DatabaseConnector) -> None:
logger.warning(f"System workflow template bootstrap failed: {e}")
+def _syncFeatureTemplateWorkflows() -> None:
+ """Sync existing instance-scoped workflows with current code-defined templates.
+
+ For each feature that exposes getTemplateWorkflows(), find all AutoWorkflow
+ rows whose templateSourceId matches a template ID and update their graph
+ if the code-defined version has changed. Preserves instance-specific
+ fields (label, tags, targetFeatureInstanceId, invocations, active).
+ Idempotent, runs on every boot.
+ """
+ import json
+
+ try:
+ from modules.system.registry import loadFeatureMainModules
+ from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
+ from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
+
+ mainModules = loadFeatureMainModules()
+
+ templatesBySourceId: dict = {}
+ for featureCode, mod in mainModules.items():
+ getTemplateWorkflows = getattr(mod, "getTemplateWorkflows", None)
+ if not getTemplateWorkflows:
+ continue
+ try:
+ templates = getTemplateWorkflows() or []
+ except Exception:
+ continue
+ for tpl in templates:
+ tplId = tpl.get("id")
+ if tplId:
+ templatesBySourceId[tplId] = tpl
+
+ if not templatesBySourceId:
+ logger.info("_syncFeatureTemplateWorkflows: no templates found, skipping")
+ return
+ logger.info(f"_syncFeatureTemplateWorkflows: found {len(templatesBySourceId)} template(s): {list(templatesBySourceId.keys())}")
+
+ greenfieldDb = DatabaseConnector(
+ dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
+ dbDatabase=graphicalEditorDatabase,
+ dbUser=APP_CONFIG.get("DB_USER"),
+ dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
+ )
+
+ updated = 0
+ for sourceId, tpl in templatesBySourceId.items():
+ instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
+ "templateSourceId": sourceId,
+ "isTemplate": False,
+ })
+ if not instances:
+ continue
+
+ canonicalGraph = tpl.get("graph", {})
+
+ for inst in instances:
+ instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
+ targetInstanceId = (
+ inst.get("targetFeatureInstanceId") if isinstance(inst, dict)
+ else getattr(inst, "targetFeatureInstanceId", None)
+ ) or ""
+
+ graphJson = json.dumps(canonicalGraph)
+ graphJson = graphJson.replace("{{featureInstanceId}}", targetInstanceId)
+ newGraph = json.loads(graphJson)
+
+ existingGraph = inst.get("graph") if isinstance(inst, dict) else getattr(inst, "graph", None)
+ if isinstance(existingGraph, str):
+ try:
+ existingGraph = json.loads(existingGraph)
+ except Exception:
+ existingGraph = None
+
+ if existingGraph == newGraph:
+ logger.debug(f"_syncFeatureTemplateWorkflows: graph unchanged for workflow {instId} (template={sourceId})")
+ continue
+ logger.debug(f"_syncFeatureTemplateWorkflows: graph DIFFERS for workflow {instId} (template={sourceId}), updating")
+
+ greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph})
+ updated += 1
+ logger.info(f"_syncFeatureTemplateWorkflows: updated graph for workflow {instId} (template={sourceId})")
+
+ if updated:
+ logger.info(f"_syncFeatureTemplateWorkflows: synced {updated} workflow(s) with current templates")
+ else:
+ logger.info("_syncFeatureTemplateWorkflows: all instance graphs already match current templates")
+ greenfieldDb.close()
+ except Exception as e:
+ logger.warning(f"Feature template workflow sync failed: {e}")
+
+
def _buildSystemTemplates():
"""Build the graph definitions for platform system templates."""
return [
diff --git a/modules/routes/routeAutomationWorkspace.py b/modules/routes/routeAutomationWorkspace.py
index 6efbdeb6..b742d7ea 100644
--- a/modules/routes/routeAutomationWorkspace.py
+++ b/modules/routes/routeAutomationWorkspace.py
@@ -58,6 +58,36 @@ def _getUserAccessibleInstanceIds(userId: str) -> list[str]:
]
+_FILE_REF_KEYS = ("fileId", "documentId", "fileIds", "documents")
+
+
+def _extractFileIdsFromValue(value, accumulator: set[str]) -> None:
+ """Recursively scan a value (dict/list/str) for file id references."""
+ if isinstance(value, dict):
+ for key, sub in value.items():
+ if key in _FILE_REF_KEYS:
+ _collectFileIdsFromRef(sub, accumulator)
+ else:
+ _extractFileIdsFromValue(sub, accumulator)
+ elif isinstance(value, list):
+ for item in value:
+ _extractFileIdsFromValue(item, accumulator)
+
+
+def _collectFileIdsFromRef(val, accumulator: set[str]) -> None:
+ """Add file ids from a value located under a known file-reference key."""
+ if isinstance(val, str) and val:
+ accumulator.add(val)
+ elif isinstance(val, list):
+ for v in val:
+ if isinstance(v, str) and v:
+ accumulator.add(v)
+ elif isinstance(v, dict) and v.get("id"):
+ accumulator.add(v["id"])
+ elif isinstance(val, dict) and val.get("id"):
+ accumulator.add(val["id"])
+
+
@router.get("")
@limiter.limit("60/minute")
def listWorkspaceRuns(
@@ -198,40 +228,68 @@ def getWorkspaceRunDetail(
steps = [dict(s) for s in stepRecords]
steps.sort(key=lambda s: s.get("startedAt") or 0)
- fileItems: list = []
+ allFileIds: set[str] = set()
+ perStepFileIds: list[tuple[set[str], set[str]]] = []
+ for step in steps:
+ inputIds: set[str] = set()
+ outputIds: set[str] = set()
+ _extractFileIdsFromValue(step.get("inputSnapshot") or {}, inputIds)
+ _extractFileIdsFromValue(step.get("output") or {}, outputIds)
+ perStepFileIds.append((inputIds, outputIds))
+ allFileIds.update(inputIds)
+ allFileIds.update(outputIds)
+
+ nodeOutputs = run.get("nodeOutputs") or {}
+ runLevelIds: set[str] = set()
+ _extractFileIdsFromValue(nodeOutputs, runLevelIds)
+ allFileIds.update(runLevelIds)
+
+ fileMetaById: dict[str, dict] = {}
try:
from modules.datamodels.datamodelFiles import FileItem
from modules.interfaces.interfaceDbManagement import ComponentObjects
mgmtDb = ComponentObjects().db
if mgmtDb._ensureTableExists(FileItem):
- nodeOutputs = run.get("nodeOutputs") or {}
- fileIds: set[str] = set()
- for nodeId, output in nodeOutputs.items():
- if not isinstance(output, dict):
- continue
- for key in ("fileId", "documentId", "fileIds", "documents"):
- val = output.get(key)
- if isinstance(val, str) and val:
- fileIds.add(val)
- elif isinstance(val, list):
- for v in val:
- if isinstance(v, str) and v:
- fileIds.add(v)
- elif isinstance(v, dict) and v.get("id"):
- fileIds.add(v["id"])
- for fid in fileIds:
+ for fid in allFileIds:
try:
rec = mgmtDb.getRecord(FileItem, fid)
if rec:
- fileItems.append(dict(rec))
+ recDict = dict(rec)
+ fileMetaById[fid] = {
+ "id": fid,
+ "fileName": recDict.get("fileName") or recDict.get("name"),
+ }
except Exception:
pass
except Exception as e:
logger.warning("getWorkspaceRunDetail: file lookup failed: %s", e)
+ def _resolveFileList(ids: set[str]) -> list[dict]:
+ return [fileMetaById[fid] for fid in ids if fid in fileMetaById]
+
+ assignedFileIds: set[str] = set()
+ for step, (inputIds, outputIds) in zip(steps, perStepFileIds):
+ step["inputFiles"] = _resolveFileList(inputIds)
+ step["outputFiles"] = _resolveFileList(outputIds)
+ assignedFileIds.update(inputIds)
+ assignedFileIds.update(outputIds)
+
+ unassignedFiles = _resolveFileList(allFileIds - assignedFileIds)
+ allFiles = _resolveFileList(allFileIds)
+
run["workflowLabel"] = run.get("label") or workflow.get("label") or wfId
run["targetFeatureInstanceId"] = tid
+ targetInstanceLabel = None
+ if tid:
+ try:
+ from modules.routes.routeHelpers import resolveInstanceLabels
+ labelMap = resolveInstanceLabels([tid])
+ targetInstanceLabel = labelMap.get(tid)
+ except Exception:
+ pass
+ run["targetInstanceLabel"] = targetInstanceLabel
+
return {
"run": run,
"workflow": {
@@ -242,5 +300,6 @@ def getWorkspaceRunDetail(
"tags": workflow.get("tags", []),
} if workflow else None,
"steps": steps,
- "files": fileItems,
+ "files": allFiles,
+ "unassignedFiles": unassignedFiles,
}
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index 0630c83b..077596b8 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -199,13 +199,8 @@ class ChatService:
label = parts[1]
messageFound = None
for message in workflow.messages:
- # Validate message belongs to this workflow
msgWorkflowId = getattr(message, 'workflowId', None)
if not msgWorkflowId or msgWorkflowId != workflowId:
- if msgWorkflowId:
- logger.warning(f"Message {message.id} has workflowId {msgWorkflowId} but belongs to workflow {workflowId}. Skipping.")
- else:
- logger.warning(f"Message {message.id} has no workflowId. Skipping.")
continue
msgLabel = getattr(message, 'documentsLabel', None)
@@ -213,7 +208,6 @@ class ChatService:
messageFound = message
break
- # If found, add documents
if messageFound and messageFound.documents:
allDocuments.extend(messageFound.documents)
else:
diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py
index d82ac4f7..50500929 100644
--- a/modules/workflows/methods/methodAi/actions/process.py
+++ b/modules/workflows/methods/methodAi/actions/process.py
@@ -73,6 +73,47 @@ def _action_docs_to_content_parts(services, docs: List[Any]) -> List[ContentPart
logger.info(f"ai.process: Extracted {len(ec.parts)} parts from {name} (no persistence)")
return all_parts
+def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPart]:
+ """Fetch files by ID from the file store and extract content.
+ Used for automation2 workflows where documents are file-store references,
+ not chat message attachments."""
+ from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
+
+ mgmt = getattr(services, 'interfaceDbComponent', None)
+ extraction = getattr(services, 'extraction', None)
+ if not mgmt or not extraction:
+ logger.warning("_resolve_file_refs_to_content_parts: missing interfaceDbComponent or extraction service")
+ return []
+
+ allParts: List[ContentPart] = []
+ opts = ExtractionOptions(prompt="", mergeStrategy=MergeStrategy())
+ for ref in fileIdRefs:
+ fileId = ref.documentId
+ fileMeta = mgmt.getFile(fileId)
+ if not fileMeta:
+ logger.warning(f"_resolve_file_refs_to_content_parts: file {fileId} not found")
+ continue
+ fileData = mgmt.getFileData(fileId)
+ if not fileData:
+ logger.warning(f"_resolve_file_refs_to_content_parts: no data for file {fileId}")
+ continue
+ fileName = getattr(fileMeta, 'fileName', fileId)
+ mimeType = getattr(fileMeta, 'mimeType', 'application/octet-stream')
+ ec = extraction.extractContentFromBytes(
+ documentBytes=fileData,
+ fileName=fileName,
+ mimeType=mimeType,
+ documentId=fileId,
+ options=opts,
+ )
+ for p in ec.parts:
+ if p.data or getattr(p, "typeGroup", "") == "image":
+ p.metadata.setdefault("originalFileName", fileName)
+ allParts.append(p)
+ logger.info(f"_resolve_file_refs_to_content_parts: extracted {len(ec.parts)} parts from {fileName}")
+ return allParts
+
+
async def process(self, parameters: Dict[str, Any]) -> ActionResult:
operationId = None
try:
@@ -129,6 +170,17 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
f"ai.process: Coerced documentList ({type(documentListParam).__name__}) "
f"to DocumentReferenceList with {len(documentList.references)} references"
)
+
+ # Resolve DocumentItemReferences (file-ID refs from automation2) directly
+ # from the file store. These cannot be resolved via chat messages.
+ from modules.datamodels.datamodelDocref import DocumentItemReference
+ fileIdRefs = [r for r in documentList.references if isinstance(r, DocumentItemReference)]
+ if fileIdRefs:
+ extractedParts = _resolve_file_refs_to_content_parts(self.services, fileIdRefs)
+ if extractedParts:
+ inline_content_parts = (inline_content_parts or []) + extractedParts
+ remaining = [r for r in documentList.references if not isinstance(r, DocumentItemReference)]
+ documentList = DocumentReferenceList(references=remaining)
# Optional: if omitted, formats determined from prompt. Default "txt" is validation fallback only.
resultType = parameters.get("resultType")
@@ -157,7 +209,19 @@ 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
+
# Phase 7.3: Pass documentList and/or contentParts to AI service
contentParts: Optional[List[ContentPart]] = inline_content_parts
if "contentParts" in parameters and not inline_content_parts:
diff --git a/modules/workflows/methods/methodAi/methodAi.py b/modules/workflows/methods/methodAi/methodAi.py
index 5265f5c9..ecd60b12 100644
--- a/modules/workflows/methods/methodAi/methodAi.py
+++ b/modules/workflows/methods/methodAi/methodAi.py
@@ -56,6 +56,23 @@ class MethodAi(MethodBase):
required=False,
description="Document reference(s) in any format to use as input/context"
),
+ "context": WorkflowActionParameter(
+ name="context",
+ type="str",
+ frontendType=FrontendType.TEXTAREA,
+ required=False,
+ default="",
+ description="Additional context data (string or upstream-bound dict/list, e.g. accounting data) appended to the prompt. Non-string values are JSON-serialized."
+ ),
+ "documentTheme": WorkflowActionParameter(
+ name="documentTheme",
+ type="str",
+ frontendType=FrontendType.SELECT,
+ frontendOptions=["general", "finance", "legal", "technical", "hr"],
+ required=False,
+ default="general",
+ description="Style hint for the document renderer (e.g. finance, legal). Used by the AI agent to choose colors and layout."
+ ),
"resultType": WorkflowActionParameter(
name="resultType",
type="str",