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