This commit is contained in:
ValueOn AG 2026-05-21 07:18:34 +02:00
commit 5277ced44c
88 changed files with 12232 additions and 1405 deletions

View file

@ -1,13 +1,38 @@
name: Deploy Gateway
name: Deploy Plattform-Core
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Tests auf Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
set -e
cd /srv/gateway/current
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/plattform-core.git
git fetch origin main
git reset --hard origin/main
test -f env-gateway-prod-forgejo.env
cp env-gateway-prod-forgejo.env .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir
python -m pytest tests/ --ignore=tests/demo
"
deploy:
runs-on: ubuntu-latest
needs: test
steps:
- name: Deploy to Infomaniak VM
env:
@ -19,12 +44,15 @@ jobs:
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
cd /srv/gateway/current &&
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/gateway.git &&
git pull &&
cp env-gateway-prod-forgejo.env .env &&
rm -f env-*.env &&
source .venv/bin/activate &&
pip install -r requirements.txt --no-cache-dir &&
set -e
cd /srv/gateway/current
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/plattform-core.git
git fetch origin main
git reset --hard origin/main
test -f env-gateway-prod-forgejo.env
cp env-gateway-prod-forgejo.env .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir
sudo systemctl restart gateway
"
"

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Patrick Motsch
"""Load CONFIG_KEY from Azure App Service for CI pytest (Kudu API + publish profile)."""
from __future__ import annotations
import base64
import json
import os
import sys
import urllib.request
import xml.etree.ElementTree as ET
def main() -> None:
profile_xml = os.environ.get("AZURE_PUBLISH_PROFILE")
setting_name = os.environ.get("SETTING_NAME", "CONFIG_KEY")
if not profile_xml:
print("::error::AZURE_PUBLISH_PROFILE is not set", file=sys.stderr)
sys.exit(1)
root = ET.fromstring(profile_xml)
pub = None
for element in root.findall(".//publishProfile"):
url = (element.get("publishUrl") or "").lower()
if "scm" in url:
pub = element
break
if pub is None:
pub = root.find(".//publishProfile")
if pub is None:
print("::error::No publishProfile in publish profile XML", file=sys.stderr)
sys.exit(1)
host = (pub.get("publishUrl") or "").split(":")[0]
user = pub.get("userName")
pwd = pub.get("userPWD")
if not (host and user and pwd):
print("::error::Could not parse SCM credentials from publish profile", file=sys.stderr)
sys.exit(1)
api = f"https://{host}/api/settings"
req = urllib.request.Request(api)
cred = base64.b64encode(f"{user}:{pwd}".encode()).decode()
req.add_header("Authorization", f"Basic {cred}")
try:
with urllib.request.urlopen(req, timeout=60) as resp:
settings = json.load(resp)
except Exception as exc:
print(f"::error::Kudu settings request failed: {exc}", file=sys.stderr)
sys.exit(1)
if not isinstance(settings, dict) or setting_name not in settings:
preview = sorted(settings.keys())[:25] if isinstance(settings, dict) else []
print(
f"::error::{setting_name} not in Azure App Service application settings "
f"(sample keys: {preview})",
file=sys.stderr,
)
sys.exit(1)
value = settings[setting_name]
if not value or not str(value).strip():
print(f"::error::{setting_name} is empty in Azure App Service", file=sys.stderr)
sys.exit(1)
github_env = os.environ.get("GITHUB_ENV")
if github_env:
with open(github_env, "a", encoding="utf-8") as handle:
handle.write(f"{setting_name}<<EOF\n{value}\nEOF\n")
print(f"Loaded {setting_name} from Azure App Service ({len(value)} characters)")
if __name__ == "__main__":
main()

View file

@ -48,8 +48,51 @@ env:
REGION: europe-west6 # Zurich region
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Determine environment
id: env
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
ENV_TYPE="${{ github.event.inputs.environment }}"
elif [ "${{ github.ref }}" == "refs/heads/int" ]; then
ENV_TYPE="int"
else
ENV_TYPE="prod"
fi
echo "env_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Set environment file
run: |
ENV_FILE="${{ steps.env.outputs.env_file }}"
test -f "$ENV_FILE"
cp "$ENV_FILE" .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.lock ]; then
pip install -r requirements.lock --no-cache-dir
else
pip install -r requirements.txt --no-cache-dir
fi
- name: Run tests
run: python -m pytest tests/ --ignore=tests/demo
deploy:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
id-token: write # Required for Workload Identity Federation

View file

@ -16,8 +16,43 @@ concurrency:
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
environment: Production
steps:
- uses: actions/checkout@v5
- name: Set up Python version
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Set environment file
run: |
test -f env-gateway-int.env
cp env-gateway-int.env .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.lock ]; then
pip install -r requirements.lock --no-cache-dir
else
pip install -r requirements.txt --no-cache-dir
fi
- name: Load CONFIG_KEY from Azure App Service
env:
AZURE_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_INT }}
run: python .github/scripts/load_config_key_from_azure.py
- name: Run tests
run: python -m pytest tests/ --ignore=tests/demo
build:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read #This is required for actions/checkout
@ -43,8 +78,6 @@ jobs:
pip install -r requirements.txt --no-cache-dir
fi
# Optional: Add step to run tests here (PyTest, Django test suites, etc.)
- name: Zip artifact for deployment
run: zip release.zip ./* -r

View file

@ -16,8 +16,43 @@ concurrency:
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
environment: Production
steps:
- uses: actions/checkout@v5
- name: Set up Python version
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Set environment file
run: |
test -f env-gateway-prod.env
cp env-gateway-prod.env .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.lock ]; then
pip install -r requirements.lock --no-cache-dir
else
pip install -r requirements.txt --no-cache-dir
fi
- name: Load CONFIG_KEY from Azure App Service
env:
AZURE_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_PROD }}
run: python .github/scripts/load_config_key_from_azure.py
- name: Run tests
run: python -m pytest tests/ --ignore=tests/demo
build:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read #This is required for actions/checkout
@ -43,8 +78,6 @@ jobs:
pip install -r requirements.txt --no-cache-dir
fi
# Optional: Add step to run tests here (PyTest, Django test suites, etc.)
- name: Zip artifact for deployment
run: zip release.zip ./* -r

View file

@ -38,6 +38,25 @@ jobs:
- name: Generate requirements.lock
run: pip-compile requirements.txt -o requirements.lock
- name: Set environment file
run: |
if [ "${{ github.ref }}" == "refs/heads/int" ]; then
ENV_FILE="env-gateway-int.env"
else
ENV_FILE="env-gateway-prod.env"
fi
test -f "$ENV_FILE"
cp "$ENV_FILE" .env
- name: Install dependencies from generated lock
run: pip install -r requirements.lock --no-cache-dir
- name: Run tests
run: python -m pytest tests/ --ignore=tests/demo
- name: Clean up .env before commit
run: rm -f .env
- name: Commit and push requirements.lock
run: |
git config user.name "github-actions[bot]"

View file

@ -38,7 +38,6 @@
"title": "Pro Scan-Dokument",
"parameters": {
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
"level": "auto",
"concurrency": 1
}
},

View file

@ -0,0 +1,612 @@
# Copyright (c) 2025 Patrick Motsch
"""Backend-driven condition operator catalog and value-kind resolution for flow.ifElse."""
from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
from modules.shared.i18nRegistry import resolveText, t
logger = logging.getLogger(__name__)
VALUE_KINDS = (
"string",
"number",
"boolean",
"datetime",
"array",
"object",
"file",
"context",
"unknown",
)
CONTENT_TYPE_OPTIONS = ("text", "image", "table", "code", "media")
OUTPUT_MODE_OPTIONS = ("blob", "lines", "pages", "chunks", "structured")
LANGUAGE_OPTIONS = ("de", "en", "fr", "it")
MIME_EXAMPLE_OPTIONS = (
"application/pdf",
"image/png",
"image/jpeg",
"text/plain",
"text/csv",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
_NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES}
def _op(
op_id: str,
label_key: str,
*,
needs_value: bool = True,
value_input: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
out: Dict[str, Any] = {"id": op_id, "labelKey": label_key, "needsValue": needs_value}
if value_input is not None:
out["valueInput"] = value_input
return out
def _build_catalog() -> Dict[str, List[Dict[str, Any]]]:
text_in = {"kind": "text"}
num_in = {"kind": "number"}
date_in = {"kind": "date"}
regex_in = {"kind": "regex"}
select = lambda opts, kind: {"kind": kind, "options": list(opts)}
return {
"string": [
_op("eq", "condition.op.eq", value_input=text_in),
_op("neq", "condition.op.neq", value_input=text_in),
_op("contains", "condition.op.contains", value_input=text_in),
_op("not_contains", "condition.op.not_contains", value_input=text_in),
_op("starts_with", "condition.op.starts_with", value_input=text_in),
_op("ends_with", "condition.op.ends_with", value_input=text_in),
_op("regex", "condition.op.regex", value_input=regex_in),
_op("empty", "condition.op.empty", needs_value=False),
_op("not_empty", "condition.op.not_empty", needs_value=False),
],
"number": [
_op("eq", "condition.op.eq", value_input=num_in),
_op("neq", "condition.op.neq", value_input=num_in),
_op("lt", "condition.op.lt", value_input=num_in),
_op("lte", "condition.op.lte", value_input=num_in),
_op("gt", "condition.op.gt", value_input=num_in),
_op("gte", "condition.op.gte", value_input=num_in),
_op("empty", "condition.op.empty", needs_value=False),
_op("not_empty", "condition.op.not_empty", needs_value=False),
],
"boolean": [
_op("is_true", "condition.op.is_true", needs_value=False),
_op("is_false", "condition.op.is_false", needs_value=False),
],
"datetime": [
_op("eq", "condition.op.eq", value_input=date_in),
_op("neq", "condition.op.neq", value_input=date_in),
_op("before", "condition.op.before", value_input=date_in),
_op("after", "condition.op.after", value_input=date_in),
_op("empty", "condition.op.empty", needs_value=False),
_op("not_empty", "condition.op.not_empty", needs_value=False),
],
"array": [
_op("contains", "condition.op.contains", value_input=text_in),
_op("not_contains", "condition.op.not_contains", value_input=text_in),
_op("empty", "condition.op.empty", needs_value=False),
_op("not_empty", "condition.op.not_empty", needs_value=False),
_op("length_eq", "condition.op.length_eq", value_input=num_in),
_op("length_gt", "condition.op.length_gt", value_input=num_in),
_op("length_lt", "condition.op.length_lt", value_input=num_in),
],
"object": [
_op("empty", "condition.op.empty", needs_value=False),
_op("not_empty", "condition.op.not_empty", needs_value=False),
],
"file": [
_op("exists", "condition.op.exists", needs_value=False),
_op("not_exists", "condition.op.not_exists", needs_value=False),
_op("mime_is", "condition.op.mime_is", value_input=select(MIME_EXAMPLE_OPTIONS, "mime")),
_op("mime_contains", "condition.op.mime_contains", value_input=text_in),
_op("empty", "condition.op.empty", needs_value=False),
_op("not_empty", "condition.op.not_empty", needs_value=False),
],
"context": [
_op(
"contains_content",
"condition.op.contains_content",
value_input=select(CONTENT_TYPE_OPTIONS, "contentType"),
),
_op("language_is", "condition.op.language_is", value_input=select(LANGUAGE_OPTIONS, "language")),
_op(
"output_mode_is",
"condition.op.output_mode_is",
value_input=select(OUTPUT_MODE_OPTIONS, "outputMode"),
),
_op("file_count_eq", "condition.op.file_count_eq", value_input=num_in),
_op("file_count_gt", "condition.op.file_count_gt", value_input=num_in),
_op("file_count_lt", "condition.op.file_count_lt", value_input=num_in),
_op("slot_count_eq", "condition.op.slot_count_eq", value_input=num_in),
_op("slot_count_gt", "condition.op.slot_count_gt", value_input=num_in),
_op("slot_count_lt", "condition.op.slot_count_lt", value_input=num_in),
_op("regex_on_text", "condition.op.regex_on_text", value_input=regex_in),
_op("empty", "condition.op.empty", needs_value=False),
_op("not_empty", "condition.op.not_empty", needs_value=False),
],
"unknown": [
_op("eq", "condition.op.eq", value_input=text_in),
_op("empty", "condition.op.empty", needs_value=False),
_op("not_empty", "condition.op.not_empty", needs_value=False),
],
}
CONDITION_OPERATOR_CATALOG: Dict[str, List[Dict[str, Any]]] = _build_catalog()
_LABEL_KEYS = {
"condition.op.eq": t("ist gleich"),
"condition.op.neq": t("ist ungleich"),
"condition.op.contains": t("enthält"),
"condition.op.not_contains": t("enthält nicht"),
"condition.op.starts_with": t("beginnt mit"),
"condition.op.ends_with": t("endet mit"),
"condition.op.regex": t("Regex-Match"),
"condition.op.empty": t("ist leer"),
"condition.op.not_empty": t("ist nicht leer"),
"condition.op.lt": t("kleiner als"),
"condition.op.lte": t(""),
"condition.op.gt": t("größer als"),
"condition.op.gte": t(""),
"condition.op.is_true": t("ist wahr"),
"condition.op.is_false": t("ist falsch"),
"condition.op.before": t("vor"),
"condition.op.after": t("nach"),
"condition.op.exists": t("vorhanden"),
"condition.op.not_exists": t("nicht vorhanden"),
"condition.op.mime_is": t("MIME-Typ ist"),
"condition.op.mime_contains": t("MIME-Typ enthält"),
"condition.op.contains_content": t("enthält Inhaltstyp"),
"condition.op.language_is": t("Sprache ist"),
"condition.op.output_mode_is": t("Ausgabemodus ist"),
"condition.op.file_count_eq": t("Dateianzahl gleich"),
"condition.op.file_count_gt": t("Dateianzahl größer als"),
"condition.op.file_count_lt": t("Dateianzahl kleiner als"),
"condition.op.slot_count_eq": t("Slot-Anzahl gleich"),
"condition.op.slot_count_gt": t("Slot-Anzahl größer als"),
"condition.op.slot_count_lt": t("Slot-Anzahl kleiner als"),
"condition.op.regex_on_text": t("Regex auf extrahiertem Text"),
"condition.op.length_eq": t("Länge gleich"),
"condition.op.length_gt": t("Länge größer als"),
"condition.op.length_lt": t("Länge kleiner als"),
}
def localize_operator_catalog(lang: str = "de") -> Dict[str, List[Dict[str, Any]]]:
"""Serialize catalog with resolved labels for API consumers."""
out: Dict[str, List[Dict[str, Any]]] = {}
for kind, ops in CONDITION_OPERATOR_CATALOG.items():
loc_ops: List[Dict[str, Any]] = []
for op in ops:
entry = dict(op)
label_key = op.get("labelKey", "")
label_src = _LABEL_KEYS.get(str(label_key), label_key)
entry["label"] = resolveText(label_src, lang)
loc_ops.append(entry)
out[kind] = loc_ops
return out
def catalog_type_to_value_kind(catalog_type: str) -> str:
"""Map port-catalog / dataPickOptions type strings to condition valueKind."""
ct = (catalog_type or "").strip()
if not ct or ct == "Any":
return "unknown"
low = ct.lower()
if low in ("str", "string", "email", "url"):
return "string"
if low in ("int", "float", "number"):
return "number"
if low == "bool":
return "boolean"
if low in ("date", "datetime", "timestamp"):
return "datetime"
if low.startswith("list[") or low == "list":
return "array"
if low.startswith("dict") or low == "dict":
return "object"
if low in ("file", "actiondocument", "fileref"):
return "file"
return "unknown"
def _paths_equal(a: List[Any], b: List[Any]) -> bool:
if len(a) != len(b):
return False
return all(str(x) == str(y) for x, y in zip(a, b))
def _is_context_producer(node_type: str) -> bool:
return node_type in ("context.extractContent", "context.mergeContext", "context.setContext")
def _path_suggests_context(path: List[Any], producer_type: str) -> bool:
if not path:
return _is_context_producer(producer_type)
last = str(path[-1])
if last in ("data", "files", "merged", "presentation"):
return True
if "files" in [str(p) for p in path]:
return True
if _is_context_producer(producer_type) and path[0] in ("data", "response", "merged"):
return True
return False
def _path_suggests_file(path: List[Any], producer_type: str) -> bool:
path_str = [str(p) for p in path]
if producer_type == "input.upload":
return True
if "file" in path_str or "documents" in path_str or "mimeType" in path_str or "fileName" in path_str:
return True
if producer_type.startswith("sharepoint.") and "file" in path_str:
return True
return False
def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upstream: bool = False) -> str:
"""Resolve condition valueKind for a DataRef against the workflow graph."""
if not isinstance(ref, dict):
return "unknown"
producer_id = ref.get("nodeId")
path = ref.get("path") or []
if not isinstance(path, list):
path = []
if not producer_id:
return "unknown"
nodes = graph.get("nodes") or []
node_by_id = {n.get("id"): n for n in nodes if n.get("id")}
producer = node_by_id.get(producer_id) or {}
producer_type = str(producer.get("type") or "")
if _path_suggests_context(path, producer_type):
return "context"
if _path_suggests_file(path, producer_type):
tail = str(path[-1]) if path else ""
if tail in ("mimeType", "fileName"):
return "string"
return "file"
if not _skip_upstream:
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths
target_id = graph.get("targetNodeId") or producer_id
matched_type: Optional[str] = None
for entry in compute_upstream_paths(graph, target_id):
if entry.get("producerNodeId") != producer_id:
continue
entry_path = entry.get("path") or []
if _paths_equal(list(entry_path), list(path)):
matched_type = str(entry.get("type") or "Any")
break
if matched_type is None and path:
parent_path = list(path[:-1])
for entry in compute_upstream_paths(graph, target_id):
if entry.get("producerNodeId") != producer_id:
continue
if _paths_equal(list(entry.get("path") or []), parent_path):
matched_type = str(entry.get("type") or "Any")
break
if matched_type:
vk = catalog_type_to_value_kind(matched_type)
if vk != "unknown":
return vk
if producer_type in ("trigger.form", "input.form") and path and str(path[0]) == "payload":
return "string"
return "unknown"
def resolve_condition_meta(
graph: Dict[str, Any],
ref: Dict[str, Any],
*,
lang: str = "de",
) -> Dict[str, Any]:
"""Return valueKind and localized operators for a DataRef."""
value_kind = resolve_value_kind(graph, ref)
catalog = localize_operator_catalog(lang)
operators = catalog.get(value_kind) or catalog.get("unknown", [])
return {"valueKind": value_kind, "operators": operators}
def _is_empty_value(val: Any) -> bool:
if val is None:
return True
if val == "":
return True
if isinstance(val, (list, dict, tuple)) and len(val) == 0:
return True
return False
def _parse_datetime(val: Any) -> Optional[datetime]:
if val is None:
return None
if hasattr(val, "timestamp"):
return val # type: ignore[return-value]
s = str(val).strip()
if not s:
return None
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except ValueError:
return None
def _compare_dates(left: Any, right: Any, op) -> bool:
try:
a, b = _parse_datetime(left), _parse_datetime(right)
if a is None or b is None:
return False
return op(a, b)
except Exception as e:
logger.warning("_compare_dates failed: left=%s right=%s: %s", left, right, e)
return False
def _file_exists(val: Any) -> bool:
if val is None:
return False
if isinstance(val, dict):
return bool(val.get("url") or val.get("name") or val.get("fileId"))
if isinstance(val, str):
return len(val.strip()) > 0
return bool(val)
def _extract_mime(val: Any) -> str:
if isinstance(val, dict):
return str(val.get("mimeType") or val.get("contentType") or "")
return ""
def _presentation_envelopes_from_value(val: Any) -> List[Dict[str, Any]]:
try:
from modules.workflows.methods.methodContext.actions.extractContent import (
normalize_presentation_envelopes,
)
return normalize_presentation_envelopes(val)
except Exception as e:
logger.debug("_presentation_envelopes_from_value: %s", e)
return []
def _joined_text_from_context(val: Any) -> str:
try:
from modules.workflows.methods.methodContext.actions.extractContent import (
joined_text_from_extract_node_data,
)
return joined_text_from_extract_node_data(val) or ""
except Exception:
return ""
def _iter_presentation_parts(envelope: Dict[str, Any]) -> List[Dict[str, Any]]:
parts: List[Dict[str, Any]] = []
files = envelope.get("files") or {}
if not isinstance(files, dict):
return parts
for bucket in files.values():
if not isinstance(bucket, dict):
continue
data = bucket.get("data")
mode = str(bucket.get("outputMode") or "").strip().lower()
if mode == "blob" and isinstance(data, str):
from modules.workflows.methods.methodContext.actions.extractContent import parse_blob_data_segments
parts.extend(parse_blob_data_segments(data))
continue
if isinstance(data, list):
for slot in data:
if isinstance(slot, dict):
parts.append(slot)
elif isinstance(data, dict):
parts.append(data)
return parts
def _context_has_content_type(val: Any, content_type: str) -> bool:
target = (content_type or "").strip().lower()
if not target:
return False
for env in _presentation_envelopes_from_value(val):
for part in _iter_presentation_parts(env):
tg = (part.get("typeGroup") or part.get("contentType") or "").strip().lower()
if target == "media":
if tg in ("image", "media", "video", "audio"):
return True
elif tg == target:
return True
return False
def _guess_language_code(text: str) -> str:
sample = (text or "").strip()[:2000]
if not sample:
return ""
de_hits = len(re.findall(r"\b(der|die|das|und|ist|nicht|mit)\b", sample, re.I))
en_hits = len(re.findall(r"\b(the|and|is|not|with|for)\b", sample, re.I))
fr_hits = len(re.findall(r"\b(le|la|les|et|est|pas|avec)\b", sample, re.I))
it_hits = len(re.findall(r"\b(il|la|lo|gli|e|non|con)\b", sample, re.I))
scores = {"de": de_hits, "en": en_hits, "fr": fr_hits, "it": it_hits}
best = max(scores, key=scores.get)
return best if scores[best] > 0 else ""
def _context_language(val: Any) -> str:
if isinstance(val, dict):
meta = val.get("_meta")
if isinstance(meta, dict):
lang = meta.get("language") or meta.get("detectedLanguage")
if lang:
return str(lang).strip().lower()[:2]
text = _joined_text_from_context(val)
return _guess_language_code(text)
def _context_output_mode(val: Any) -> str:
for env in _presentation_envelopes_from_value(val):
om = env.get("outputMode")
if om:
return str(om)
files = env.get("files") or {}
if isinstance(files, dict):
for bucket in files.values():
if isinstance(bucket, dict) and bucket.get("outputMode"):
return str(bucket.get("outputMode"))
if isinstance(val, dict) and val.get("outputMode"):
return str(val.get("outputMode"))
return ""
def _context_file_count(val: Any) -> int:
for env in _presentation_envelopes_from_value(val):
fo = env.get("fileOrder")
if isinstance(fo, list):
return len(fo)
return 0
def _context_slot_count(val: Any) -> int:
total = 0
for env in _presentation_envelopes_from_value(val):
files = env.get("files") or {}
if not isinstance(files, dict):
continue
for bucket in files.values():
if not isinstance(bucket, dict):
continue
data = bucket.get("data")
if isinstance(data, list):
total += len(data)
elif data is not None:
total += 1
return total
def apply_condition_operator(left: Any, operator: str, right: Any, value_kind: Optional[str] = None) -> bool:
"""Evaluate a single condition operator against a resolved left-hand value."""
op = (operator or "eq").strip()
vk = (value_kind or "unknown").strip()
if op == "eq":
if vk == "datetime":
return _compare_dates(left, right, lambda a, b: a == b)
return left == right
if op == "neq":
if vk == "datetime":
return _compare_dates(left, right, lambda a, b: a != b)
return left != right
if op in ("lt", "lte", "gt", "gte"):
try:
l = float(left) if left is not None else 0
r = float(right) if right is not None else 0
if op == "lt":
return l < r
if op == "lte":
return l <= r
if op == "gt":
return l > r
return l >= r
except (TypeError, ValueError):
return False
if op == "contains":
if isinstance(left, (list, tuple, set)):
return right in left or any(str(right) == str(x) for x in left)
return right is not None and str(right) in str(left or "")
if op == "not_contains":
if isinstance(left, (list, tuple, set)):
return right not in left and not any(str(right) == str(x) for x in left)
return right is None or str(right) not in str(left or "")
if op == "starts_with":
return right is not None and str(left or "").startswith(str(right))
if op == "ends_with":
return right is not None and str(left or "").endswith(str(right))
if op == "regex":
try:
return bool(re.search(str(right or ""), str(left or "")))
except re.error as e:
logger.warning("regex operator failed: %s", e)
return False
if op == "empty":
return _is_empty_value(left)
if op == "not_empty":
return not _is_empty_value(left)
if op == "is_true":
return bool(left)
if op == "is_false":
return not bool(left)
if op == "before":
return _compare_dates(left, right, lambda a, b: a < b)
if op == "after":
return _compare_dates(left, right, lambda a, b: a > b)
if op == "exists":
return _file_exists(left)
if op == "not_exists":
return not _file_exists(left)
if op == "mime_is":
return _extract_mime(left).lower() == str(right or "").lower()
if op == "mime_contains":
return str(right or "").lower() in _extract_mime(left).lower()
if op in ("length_eq", "length_gt", "length_lt"):
try:
length = len(left) if left is not None else 0
r = int(float(right))
if op == "length_eq":
return length == r
if op == "length_gt":
return length > r
return length < r
except (TypeError, ValueError):
return False
if op == "contains_content":
return _context_has_content_type(left, str(right or ""))
if op == "language_is":
return _context_language(left) == str(right or "").strip().lower()[:2]
if op == "output_mode_is":
return _context_output_mode(left) == str(right or "")
if op == "file_count_eq":
return _context_file_count(left) == int(float(right))
if op == "file_count_gt":
return _context_file_count(left) > int(float(right))
if op == "file_count_lt":
return _context_file_count(left) < int(float(right))
if op == "slot_count_eq":
return _context_slot_count(left) == int(float(right))
if op == "slot_count_gt":
return _context_slot_count(left) > int(float(right))
if op == "slot_count_lt":
return _context_slot_count(left) < int(float(right))
if op == "regex_on_text":
try:
text = _joined_text_from_context(left)
return bool(re.search(str(right or ""), text))
except re.error as e:
logger.warning("regex_on_text failed: %s", e)
return False
return False

View file

@ -83,7 +83,60 @@ def normalize_invocations_list(items: Optional[List[Any]]) -> List[Dict[str, Any
return out
# Schedule / cron: wire an external job runner (APScheduler, Celery, system cron) to call
_NODE_TYPE_TO_KIND = {
"trigger.manual": "manual",
"trigger.form": "form",
"trigger.schedule": "schedule",
}
def invocations_synced_with_graph(
graph: Optional[Dict[str, Any]],
stored_invocations: Optional[List[Any]],
) -> List[Dict[str, Any]]:
"""Derive primary invocation (index 0) from the first start node in ``graph``.
If the graph has no start node, only non-primary stored invocations are kept
(no injected default). Document order in ``nodes`` defines which start wins.
"""
from modules.workflows.automation2.graphUtils import getTriggerNodes
g = graph if isinstance(graph, dict) else {}
nodes = g.get("nodes") or []
stored = list(stored_invocations or [])
rest: List[Dict[str, Any]] = []
for raw in stored[1:]:
if isinstance(raw, dict):
rest.append(normalize_invocation_entry(raw))
triggers = getTriggerNodes(nodes)
if not triggers:
return rest
node = triggers[0]
nt = str(node.get("type", "")).strip()
kind = _NODE_TYPE_TO_KIND.get(nt, "manual")
nid = node.get("id")
if not nid:
nid = str(uuid.uuid4())
raw_title = node.get("title") or node.get("label") or "Start"
old_primary = stored[0] if stored and isinstance(stored[0], dict) else {}
config: Dict[str, Any] = {}
if isinstance(old_primary.get("config"), dict) and old_primary.get("kind") == kind:
config = dict(old_primary["config"])
desc = old_primary.get("description") if isinstance(old_primary.get("description"), dict) else {}
primary_raw: Dict[str, Any] = {
"id": str(nid),
"kind": kind,
"enabled": True,
"title": raw_title,
"description": desc,
"config": config,
}
primary = normalize_invocation_entry(primary_raw)
return [primary] + rest
# POST .../execute with entryPointId set to a schedule entry — no separate in-process scheduler here yet.

View file

@ -49,7 +49,7 @@ from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
AutoRun as Automation2WorkflowRun,
AutoTask as Automation2HumanTask,
)
from modules.features.graphicalEditor.entryPoints import normalize_invocations_list
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import registerDatabase
@ -109,7 +109,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
if r.get("active") is False:
continue
wf = dict(r)
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations"))
invocations = wf.get("invocations") or []
primary = invocations[0] if invocations else {}
if not isinstance(primary, dict):
@ -204,7 +204,7 @@ class GraphicalEditorObjects:
)
rows = [dict(r) for r in records] if records else []
for wf in rows:
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations"))
return rows
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
@ -221,7 +221,7 @@ class GraphicalEditorObjects:
if not records:
return None
wf = dict(records[0])
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations"))
return wf
def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]:
@ -234,10 +234,10 @@ class GraphicalEditorObjects:
data["targetFeatureInstanceId"] = self.featureInstanceId
if "active" not in data or data.get("active") is None:
data["active"] = True
data["invocations"] = normalize_invocations_list(data.get("invocations"))
data["invocations"] = invocations_synced_with_graph(data.get("graph") or {}, data.get("invocations"))
created = self.db.recordCreate(Automation2Workflow, data)
out = dict(created)
out["invocations"] = normalize_invocations_list(out.get("invocations"))
out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations"))
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)
@ -252,11 +252,15 @@ class GraphicalEditorObjects:
return None
data.pop("mandateId", None)
data.pop("featureInstanceId", None)
if "invocations" in data:
data["invocations"] = normalize_invocations_list(data.get("invocations"))
if "graph" in data or "invocations" in data:
g = data["graph"] if "graph" in data else existing.get("graph")
if not isinstance(g, dict):
g = {}
inv = data["invocations"] if "invocations" in data else existing.get("invocations")
data["invocations"] = invocations_synced_with_graph(g, inv)
updated = self.db.recordModify(Automation2Workflow, workflowId, data)
out = dict(updated)
out["invocations"] = normalize_invocations_list(out.get("invocations"))
out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations"))
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED)

View file

@ -32,11 +32,6 @@ UI_OBJECTS = [
"label": t("Editor", context="UI"),
"meta": {"area": "editor"}
},
{
"objectKey": "ui.feature.graphicalEditor.workflows",
"label": t("Workflows", context="UI"),
"meta": {"area": "workflows"}
},
{
"objectKey": "ui.feature.graphicalEditor.templates",
"label": t("Vorlagen", context="UI"),

View file

@ -3,6 +3,131 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
CONTEXT_BUILDER_PARAM_DESCRIPTION,
)
from modules.features.graphicalEditor.nodeDefinitions.flow import (
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
)
# Shared authoritative DataPicker paths (same handover idea as ``context.extractContent`` outputPorts).
ACTION_RESULT_DATA_PICK_OPTIONS = [
{
"path": ["documents", 0, "documentData"],
"pickerLabel": t("Gesamter Inhalt"),
"detail": t(
"Strukturiertes Handover als JSON inklusive aller Textteile "
"und Verweisen auf ausgelagerte Bilder."
),
"recommended": True,
"type": "Any",
},
{
"path": ["response"],
"pickerLabel": t("Nur Text"),
"detail": t("Verketteter Klartext aus allen erkannten Textteilen."),
"recommended": True,
"type": "str",
},
{
"path": ["imageDocumentsOnly"],
"pickerLabel": t("Nur Bilder"),
"detail": t("Nur die extrahierten Bilddokumente als Liste, ohne JSON-Handover."),
"recommended": False,
"type": "List[ActionDocument]",
},
{
"path": ["documents"],
"pickerLabel": t("Alle Dateitypen"),
"detail": t("Alle Ausgabedokumente nacheinander: JSON-Handover und Bilder."),
"recommended": False,
"type": "List[ActionDocument]",
},
]
AI_RESULT_DATA_PICK_OPTIONS = [
*CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
{
"path": ["documents", 0, "documentData"],
"pickerLabel": t("Gesamter Inhalt"),
"detail": t(
"Hauptausgabedatei oder strukturierter Inhalt von ``documents[0]`` "
"(z. B. erzeugtes Dokument, JSON-Handover)."
),
"recommended": False,
"type": "Any",
},
{
"path": ["response"],
"pickerLabel": t("Nur Text"),
"detail": t("Modell-Antwort als reiner Fließtext (ohne eingebettete Bildbytes)."),
"recommended": False,
"type": "str",
},
{
"path": ["imageDocumentsOnly"],
"pickerLabel": t("Nur Bilder"),
"detail": t("Nur Bild-Dokumente aus ``documents`` (ohne erstes Nicht-Bild-Artefakt, falls gesetzt)."),
"recommended": False,
"type": "List[ActionDocument]",
},
{
"path": ["documents"],
"pickerLabel": t("Alle Ausgabedateien"),
"detail": t("Alle Dokumente der KI-Antwort: erzeugte Dateien, Bilder, Anhänge."),
"recommended": False,
"type": "List[Document]",
},
]
DOCUMENT_LIST_DATA_PICK_OPTIONS = [
{
"path": ["documents"],
"pickerLabel": t("Alle Dokumente"),
"detail": t("Die vollständige Dokumentenliste."),
"recommended": True,
"type": "List[Document]",
},
{
"path": ["documents", 0],
"pickerLabel": t("Erstes Dokument"),
"detail": t("Metadaten und Pfade des ersten Listeneintrags."),
"recommended": False,
"type": "Document",
},
{
"path": ["count"],
"pickerLabel": t("Anzahl"),
"detail": t("Anzahl der Dokumente."),
"recommended": False,
"type": "int",
},
]
CONSOLIDATE_RESULT_DATA_PICK_OPTIONS = [
{
"path": ["result"],
"pickerLabel": t("Konsolidiertes Ergebnis"),
"detail": t("Text oder Struktur nach Konsolidierung."),
"recommended": True,
"type": "Any",
},
{
"path": ["mode"],
"pickerLabel": t("Modus"),
"detail": t("Verwendeter Konsolidierungsmodus."),
"recommended": False,
"type": "str",
},
{
"path": ["count"],
"pickerLabel": t("Anzahl"),
"detail": t("Anzahl zusammengeführter Elemente."),
"recommended": False,
"type": "int",
},
]
_AI_COMMON_PARAMS = [
{"name": "requireNeutralization", "type": "bool", "required": False,
"frontendType": "checkbox", "default": False,
@ -25,12 +150,11 @@ AI_NODES = [
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
"description": t("Ausgabeformat"), "default": "txt"},
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "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",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
{"name": "documentTheme", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]},
"description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"},
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
{"name": "simpleMode", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Einfacher Modus"), "default": True},
] + _AI_COMMON_PARAMS,
@ -39,7 +163,8 @@ AI_NODES = [
"inputPorts": {0: {"accepts": [
"FormPayload", "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult",
]}},
"outputPorts": {0: {"schema": "AiResult"}},
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
"paramMappers": ["aiPromptLegacyAlias"],
"meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
"_action": "process",
@ -53,16 +178,18 @@ AI_NODES = [
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
"description": t("Recherche-Anfrage")},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
{"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,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": [
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
]}},
"outputPorts": {0: {"schema": "AiResult"}},
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
"_action": "webResearch",
@ -74,15 +201,22 @@ AI_NODES = [
"description": t("Dokumentinhalt zusammenfassen"),
"parameters": [
{"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": "resultType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
"description": t("Ausgabeformat"), "default": "txt"},
{"name": "summaryLength", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
"description": t("Zielordner in Meine Dateien"),
"default": ""},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
"_action": "summarizeDocument",
@ -94,14 +228,21 @@ AI_NODES = [
"description": t("Dokument in Zielsprache übersetzen"),
"parameters": [
{"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": "resultType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
"description": t("Ausgabeformat"), "default": "txt"},
{"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text",
"description": t("Zielsprache (z.B. de, en, French)")},
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
"description": t("Zielordner in Meine Dateien"),
"default": ""},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-translate", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
"_action": "translateDocument",
@ -113,15 +254,19 @@ AI_NODES = [
"description": t("Dokument in anderes Format konvertieren"),
"parameters": [
{"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",
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
"description": t("Zielformat")},
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
"description": t("Zielordner in Meine Dateien"),
"default": ""},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
"_action": "convertDocument",
@ -142,17 +287,22 @@ AI_NODES = [
{"name": "documentType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]},
"description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"},
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
"description": t("Zielordner in Meine Dateien"),
"default": ""},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
{"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,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": [
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
"_action": "generateDocument",
@ -168,17 +318,22 @@ AI_NODES = [
{"name": "resultType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]},
"description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"},
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
"description": t("Zielordner in Meine Dateien"),
"default": ""},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
{"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,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": [
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
]}},
"outputPorts": {0: {"schema": "AiResult"}},
"outputPorts": {0: {"schema": "AiResult", "dataPickOptions": AI_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
"_action": "generateCode",
@ -198,7 +353,7 @@ AI_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["AggregateResult", "Transit"]}},
"outputPorts": {0: {"schema": "ConsolidateResult"}},
"outputPorts": {0: {"schema": "ConsolidateResult", "dataPickOptions": CONSOLIDATE_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-table-merge-cells", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
"_action": "consolidate",

View file

@ -4,6 +4,63 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
TASK_LIST_DATA_PICK_OPTIONS = [
{
"path": ["tasks"],
"pickerLabel": t("Alle Aufgaben"),
"detail": t("Vollständige Aufgabenliste."),
"recommended": True,
"type": "List[TaskItem]",
},
{
"path": ["tasks", 0],
"pickerLabel": t("Erste Aufgabe"),
"detail": t("Erstes Listenelement."),
"recommended": False,
"type": "TaskItem",
},
{
"path": ["count"],
"pickerLabel": t("Anzahl"),
"detail": t("Anzahl der Aufgaben."),
"recommended": False,
"type": "int",
},
{
"path": ["listId"],
"pickerLabel": t("Listen-ID"),
"detail": t("ClickUp-Listen-Kontext, falls gesetzt."),
"recommended": False,
"type": "str",
},
]
TASK_RESULT_DATA_PICK_OPTIONS = [
{
"path": ["success"],
"pickerLabel": t("Erfolg"),
"detail": t("Ob der API-Aufruf erfolgreich war."),
"recommended": True,
"type": "bool",
},
{
"path": ["taskId"],
"pickerLabel": t("Aufgaben-ID"),
"detail": t("ID der betroffenen Aufgabe."),
"recommended": True,
"type": "str",
},
{
"path": ["task"],
"pickerLabel": t("Aufgabendaten"),
"detail": t("Vollständiges Task-Objekt (Dict)."),
"recommended": True,
"type": "Dict",
},
]
CLICKUP_NODES = [
{
"id": "clickup.searchTasks",
@ -33,7 +90,7 @@ CLICKUP_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TaskList"}},
"outputPorts": {0: {"schema": "TaskList", "dataPickOptions": TASK_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-magnify", "color": "#7B68EE", "usesAi": False},
"_method": "clickup",
"_action": "searchTasks",
@ -58,7 +115,7 @@ CLICKUP_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TaskList"}},
"outputPorts": {0: {"schema": "TaskList", "dataPickOptions": TASK_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE", "usesAi": False},
"_method": "clickup",
"_action": "listTasks",
@ -80,7 +137,7 @@ CLICKUP_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TaskResult"}},
"outputPorts": {0: {"schema": "TaskResult", "dataPickOptions": TASK_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE", "usesAi": False},
"_method": "clickup",
"_action": "getTask",
@ -124,7 +181,7 @@ CLICKUP_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TaskResult"}},
"outputPorts": {0: {"schema": "TaskResult", "dataPickOptions": TASK_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE", "usesAi": False},
"_method": "clickup",
"_action": "createTask",
@ -148,7 +205,8 @@ CLICKUP_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["TaskResult", "Transit"]}},
"outputPorts": {0: {"schema": "TaskResult"}},
"outputPorts": {0: {"schema": "TaskResult", "dataPickOptions": TASK_RESULT_DATA_PICK_OPTIONS}},
"paramMappers": ["clickupTaskUpdateMerge"],
"meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE", "usesAi": False},
"_method": "clickup",
"_action": "updateTask",
@ -174,7 +232,7 @@ CLICKUP_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-attachment", "color": "#7B68EE", "usesAi": False},
"_method": "clickup",
"_action": "uploadAttachment",

View file

@ -1,29 +1,447 @@
# Copyright (c) 2025 Patrick Motsch
# Context node definitions — structural extraction without AI.
# Context node definitions — structural extraction without AI plus
# generic key/value, merge, filter and transform helpers.
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.flow import (
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
)
_CONTEXT_INPUT_SCHEMAS = [
"Transit",
"ActionResult",
"AiResult",
"MergeResult",
"FormPayload",
"DocumentList",
"EmailList",
"TaskList",
"FileList",
"LoopItem",
"UdmDocument",
]
CONTEXT_NODES = [
{
"id": "context.extractContent",
"category": "context",
"label": t("Inhalt extrahieren"),
"description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"),
"description": t(
"Extrahiert Inhalt ohne KI. ``data`` ist die gewählte **Presentation** (`fileOrder`, `files` je "
"Quelldatei, kanonisches `data` pro Bucket) plus ``_meta`` (Quellnamen, Operation, Persist). "
"``response`` für diesen Knoten bleibt leer — kein zusätzlicher Fließtext. "
"``imageDocumentsOnly`` enthält Bilder über persistierte Artefakte."
),
"injectRunContext": True,
"parameters": [
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
{"name": "extractionOptions", "type": "object", "required": False, "frontendType": "json",
"description": t(
"Extraktions-Optionen (JSON), z.B. {\"includeImages\": true, \"includeTables\": true, "
"\"outputDetail\": \"full\"}"),
"default": {}},
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": "",
"graphInherit": {"port": 0, "kind": "documentListWire"}},
{
"name": "contentFilter",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "all", "label": t("Alles (Text, Tabellen, Bilder)")},
{"value": "textOnly", "label": t("Nur Text und Tabellen")},
{"value": "imagesOnly", "label": t("Nur Bilder")},
{"value": "noImages", "label": t("Alles ausser Bilder")},
]
},
"default": "all",
"description": t(
"Welche extrahierten Parts weiterverwendet werden. "
"all = alle Typgruppen inkl. Bilder; "
"textOnly = ausschliesslich Text-, Tabellen- und Struktur-Parts; "
"imagesOnly = ausschliesslich Bild-Parts; "
"noImages = alle Parts ausser Bildern (weiter als textOnly: "
"auch kuenftige Nicht-Bild-Typen bleiben erhalten)."
),
},
{
"name": "outputMode",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "blob", "label": t("Ausgabe: ein Textblock (blob)")},
{"value": "lines", "label": t("Ausgabe: Zeilen / Segmente")},
{"value": "pages", "label": t("Ausgabe: nach Seite (z. B. PDF)")},
{"value": "chunks", "label": t("Ausgabe: Chunks (fixe Groesse)")},
{"value": "structured", "label": t("Ausgabe: Parts als Liste")},
]
},
"default": "lines",
"description": t(
"Wie das Ergebnis unter ``files`` strukturiert wird (``outputMode``: blob, lines, …)."
),
},
{
"name": "splitBy",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "newline", "label": t("Trennen: Zeilenumbruch")},
{"value": "paragraph", "label": t("Trennen: Absatz (Leerzeilen)")},
{"value": "sentence", "label": t("Trennen: Saetze (heuristisch)")},
]
},
"default": "newline",
"description": t(
"Gueltig fuer ``outputMode`` lines und chunks: welches Trennzeichen der "
"zusammenhaengende Klartext zuerst erhaelt."
),
},
{
"name": "chunkSizeUnit",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"dependsOn": "outputMode",
"showWhen": ["chunks"],
"options": [
{"value": "tokens", "label": t("Chunk-Groesse: Tokens (approx. ~4 Zeichen)")},
{"value": "characters", "label": t("Chunk-Groesse: Zeichen")},
{"value": "words", "label": t("Chunk-Groesse: Woerter")},
]
},
"default": "tokens",
"description": t("Einheit fuer ``chunkSize`` / ``chunkOverlap`` wenn outputMode chunks."),
},
{
"name": "chunkSize",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"dependsOn": "outputMode",
"showWhen": ["chunks"],
"options": [
{"value": "256", "label": "256"},
{"value": "500", "label": "500"},
{"value": "1000", "label": "1000"},
{"value": "2000", "label": "2000"},
{"value": "4000", "label": "4000"},
]
},
"default": "500",
"description": t("Zielgroesse pro Chunk (siehe chunkSizeUnit). Nur bei outputMode chunks."),
},
{
"name": "chunkOverlap",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"dependsOn": "outputMode",
"showWhen": ["chunks"],
"options": [
{"value": "0", "label": "0"},
{"value": "25", "label": "25"},
{"value": "50", "label": "50"},
{"value": "100", "label": "100"},
{"value": "200", "label": "200"},
]
},
"default": "0",
"description": t("Ueberlappung zwischen aufeinanderfolgenden Chunks (gleiche Einheit wie chunkSize)."),
},
{
"name": "filterEmptyLines",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "true", "label": t("Ja")},
{"value": "false", "label": t("Nein")},
]
},
"default": "true",
"description": t("Leere bzw. nur-Whitespace-Segmente bei lines/chunks entfernen."),
},
{
"name": "trimWhitespace",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "true", "label": t("Ja")},
{"value": "false", "label": t("Nein")},
]
},
"default": "true",
"description": t("Fuehrende und nachfolgende Leerzeichen pro Segment trimmen."),
},
{
"name": "includeLineNumbers",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "true", "label": t("Ja")},
{"value": "false", "label": t("Nein")},
]
},
"default": "false",
"description": t("Bei lines: jedem Eintrag eine Zeilennummer (1-based) zuweisen."),
},
{
"name": "includeMetadata",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "true", "label": t("Ja")},
{"value": "false", "label": t("Nein")},
]
},
"default": "false",
"description": t("Dateiname und einfache Offsets bei lines/chunks/pages an Eintraege haengen."),
},
{
"name": "csvHeaderRow",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "true", "label": t("Ja")},
{"value": "false", "label": t("Nein")},
]
},
"default": "true",
"description": t(
"Bei CSV-Dateien: erste Zeile als Spaltenkoepfe interpretieren "
"und ``csvRows`` als Liste von Objekten in ``presentation`` schreiben."
),
},
{
"name": "pdfExtractMode",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "text", "label": t("PDF/Parts: Text & Tabellen (keine Bild-Parts)")},
{"value": "tables", "label": t("PDF/Parts: nur Tabellen-Parts")},
{"value": "images", "label": t("PDF/Parts: nur Bild-Parts")},
{"value": "all", "label": t("PDF/Parts: alle Typgruppen")},
]
},
"default": "all",
"description": t(
"Filtert fuer die Presentation-Schicht nach typeGroup/MIME "
"(gilt fuer alle Dokumenttypen analog, nicht nur PDF). "
"Passt zum Inhaltsfilter „Alles“; „Text & Tabellen“ blendet Bild-Parts in der Presentation aus."
),
},
{
"name": "markdownPreserveFormatting",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {
"options": [
{"value": "true", "label": t("Markdown beibehalten")},
{"value": "false", "label": t("zu vereinfachtem Klartext reduzieren")},
]
},
"default": "false",
"description": t(
"Bei text/markdown-Parts: leichte Entfernung von Markup-Zeichen wenn false."
),
},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "UdmDocument"}},
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
"outputPorts": {
0: {
"schema": "ActionResult",
# Override the schema-level primaryTextRef path: ``response`` is intentionally
# empty for this node; downstream nodes with ``primaryTextRef`` should resolve to
# the full presentation object under ``data``.
"primaryTextRefPath": ["data"],
# Authoritative DataPicker paths (same idea as ``parameters`` for configuration).
# Frontend uses only this list — no schema expansion merge for this port.
"dataPickOptions": [
{
"path": ["data"],
"pickerLabel": t("Vollständiges data-Objekt"),
"detail": t(
"Presentation-Envelope (``schemaVersion``, ``kind``, ``fileOrder``, ``files``) "
"plus ``_meta`` (``operationRef``, ``sourceFileNames``, Persist)."
),
"recommended": True,
"type": "Any",
},
{
"path": ["data", "files"],
"pickerLabel": t("Alle Dateibuckets"),
"detail": t("Map Dateischlüssel → Bucket (Zeilenliste, Blob, CSV-Tabelle bei structured, …)."),
"recommended": False,
"type": "Any",
},
{
"path": ["imageDocumentsOnly"],
"pickerLabel": t("Nur Bilder"),
"detail": t(
"Nur die Bilder aus der Extraktion (persistierte Artefakte bzw. inline), "
"als Liste fuer nachgelagerte Schritte."
),
"recommended": False,
"type": "List[ActionDocument]",
},
{
"path": ["data", "_meta"],
"pickerLabel": t("Metadaten (_meta)"),
"detail": t(
"``operationRef``, ``sourceFileNames``, Presentation-Parameter, Liste persistierter Bilder."
),
"recommended": False,
"type": "Any",
},
],
}
},
"meta": {"icon": "mdi-file-tree-outline", "color": "#00897B", "usesAi": False},
"_method": "context",
"_action": "extractContent",
# Executor behaviour flags — drives actionNodeExecutor without hardcoded type checks.
"skipUnifiedPresentation": True,
"clearResponse": True,
"imageDocumentsFromExtractData": True,
"popDocumentsFromOutput": True,
},
{
"id": "context.mergeContext",
"category": "context",
"label": t("Kontext zusammenführen"),
"description": t(
"Führt eine Liste von Ergebnissen zu einem einzigen Kontext zusammen. "
"Ausgabe ``data``: versionierter Umschlag (``schemaVersion``, ``kind``), Felder wie "
"``merged`` / ``first`` / ``response`` sowie ``_meta``. "
"Wähle als Datenquelle die Option Alle Schleifen-Ergebnisse einer Schleife, "
"um alle Iterationsergebnisse in einem Datensatz zu vereinen."
),
"parameters": [
{
"name": "dataSource",
"type": "Any",
"required": True,
"frontendType": "dataRef",
"description": t(
"Datenquelle: Liste von Einträgen zum Zusammenführen "
"(z. B. Schleife → Alle Schleifen-Ergebnisse)"
),
},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": _CONTEXT_INPUT_SCHEMAS}},
"outputPorts": {
0: {"schema": "ActionResult", "dataPickOptions": CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS}
},
"injectUpstreamPayload": True,
# Same contract as transformContext: picker paths like ``merged`` / ``first`` must match
# ``nodeOutputs`` (see actionNodeExecutor ``surfaceDataAsTopLevel``); merge payloads live in ``data``.
"surfaceDataAsTopLevel": True,
"meta": {"icon": "mdi-call-merge", "color": "#7B1FA2", "usesAi": False},
"_method": "context",
"_action": "mergeContext",
# Image documents live on ``data.merged.imageDocumentsOnly`` (accumulated across
# iterations) rather than the top-level ``documents`` list which is always empty.
"imageDocumentsFromMerged": True,
},
{
"id": "context.transformContext",
"category": "context",
"label": t("Kontext transformieren"),
"description": t(
"Verändert die Struktur des eingehenden Datenstroms. "
"Ausgabe ``data``: versionierter Umschlag (``schemaVersion``, ``kind``: transform), "
"konfigurierte Ausgabe-Felder und ``_meta``. "
"Operationen pro Mapping: 'rename' (Key umbenennen), 'cast' (Typ konvertieren), "
"'nest' (mehrere Felder unter neuem Objekt zusammenfassen), "
"'flatten' (verschachteltes Objekt auf oberste Ebene heben), "
"'compute' (neues Feld aus Template-/{{...}}-Ausdruck berechnen). "
"Jedes Mapping definiert: 'sourceField' (Eingangspfad / Ausdruck), "
"'outputField' (Ausgabe-Key), 'operation' und 'type' (Zieltyp). "
"Das Ergebnis ist ein neues Objekt — der ursprüngliche Datenstrom "
"wird nicht automatisch weitergegeben (ausser 'passthroughUnmapped: true')."
),
"parameters": [
{
"name": "mappings",
"type": "list",
"required": True,
"frontendType": "mappingTable",
"default": [],
"description": t(
"Liste von Mapping-Einträgen. Jeder Eintrag: "
"sourceField (DataRef-Pfad oder Ausdruck), "
"outputField (Ziel-Key im Output), "
"operation (rename | cast | nest | flatten | compute), "
"type (str | int | bool | float | object | list — für cast), "
"expression (für compute: Template oder Ausdruck, z.B. '{{firstName}} {{lastName}}')."
),
},
{
"name": "passthroughUnmapped",
"type": "bool",
"required": False,
"frontendType": "checkbox",
"default": False,
"description": t(
"Alle nicht gemappten Felder des Eingangs zusätzlich in den Output übernehmen."
),
},
{
"name": "flattenDepth",
"type": "int",
"required": False,
"frontendType": "number",
"default": 1,
"description": t("Tiefe für flatten-Operation (1 = eine Ebene, -1 = vollständig)"),
},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": _CONTEXT_INPUT_SCHEMAS}},
"outputPorts": {
0: {
"schema": {
"kind": "fromGraph",
"parameter": "mappings",
"nameField": "outputField",
"schemaName": "Transform_dynamic",
},
"dynamic": True,
"deriveFrom": "mappings",
"deriveNameField": "outputField",
"dataPickOptions": CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
# ActionResult is the correct normalization schema — NOT FormPayload.
# The output is a versionned ActionResult envelope built by contextEnvelope.
"fromGraphResultSchema": "ActionResult",
}
},
"injectUpstreamPayload": True,
"surfaceDataAsTopLevel": True,
"meta": {"icon": "mdi-swap-horizontal", "color": "#EF6C00", "usesAi": False},
"_method": "context",
"_action": "transformContext",
},
]

View file

@ -0,0 +1,22 @@
# Copyright (c) 2025 Patrick Motsch
# Shared parameter copy for ``contextBuilder`` fields (upstream data pick).
from modules.shared.i18nRegistry import t
CONTEXT_BUILDER_PARAM_DESCRIPTION = t(
"Inhalt aus vorherigen Schritten wählen (DataRef / Daten-Picker): z. B. „response“ für Klartext, "
"Handover-Pfade für strukturiertes JSON oder Medienlisten. "
"Die Auflösung erfolgt vollständig serverseitig (`resolveParameterReferences`). "
"Formular-Schritte speichern Antworten unter „payload“ — fehlt ein gewählter Pfad am Root, "
"wird derselbe Pfad automatisch unter „payload“ nachgeschlagen (Kompatibilität mit älteren "
"und neuen Picker-Pfaden). "
"In Freitext-/Template-Feldern werden weiterhin Platzhalter `{{KnotenId.feld.b.z.}}` ersetzt "
"(gleiche Semantik inkl. optionalem Nachschlagen unter „payload“)."
)
# Kurzreferenz für Node-Beschreibungen (optional einbinden): dieselbe Auflösungslogik
# wie bei DataRefs — kein separates Variablen-Subsystem.
REF_AND_TEMPLATE_COMPATIBILITY_SUMMARY = t(
"Verweise: typisierte DataRefs im Parameter; Zeichenketten-Templates mit {{…}}; "
"Formular-Felder unter output.payload."
)

View file

@ -3,6 +3,25 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS
AGGREGATE_RESULT_DATA_PICK_OPTIONS = [
{
"path": ["items"],
"pickerLabel": t("Gesammelte Elemente"),
"detail": t("Alle aus der Schleife gesammelten Werte."),
"recommended": True,
"type": "List[Any]",
},
{
"path": ["count"],
"pickerLabel": t("Anzahl"),
"detail": t("Anzahl gesammelter Elemente."),
"recommended": False,
"type": "int",
},
]
DATA_NODES = [
{
"id": "data.aggregate",
@ -17,7 +36,7 @@ DATA_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit", "AiResult", "LoopItem"]}},
"outputPorts": {0: {"schema": "AggregateResult"}},
"outputPorts": {0: {"schema": "AggregateResult", "dataPickOptions": AGGREGATE_RESULT_DATA_PICK_OPTIONS}},
"executor": "data",
"meta": {"icon": "mdi-playlist-plus", "color": "#607D8B", "usesAi": False},
},
@ -55,7 +74,7 @@ DATA_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["AggregateResult", "Transit"]}},
"outputPorts": {0: {"schema": "ConsolidateResult"}},
"outputPorts": {0: {"schema": "ConsolidateResult", "dataPickOptions": CONSOLIDATE_RESULT_DATA_PICK_OPTIONS}},
"executor": "data",
"meta": {"icon": "mdi-table-merge-cells", "color": "#607D8B", "usesAi": False},
},

View file

@ -3,6 +3,35 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
CONTEXT_BUILDER_PARAM_DESCRIPTION,
)
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
EMAIL_LIST_DATA_PICK_OPTIONS = [
{
"path": ["emails"],
"pickerLabel": t("Alle E-Mails"),
"detail": t("Die vollständige E-Mail-Liste des Schritts."),
"recommended": True,
"type": "List[EmailItem]",
},
{
"path": ["emails", 0],
"pickerLabel": t("Erste E-Mail"),
"detail": t("Das erste Element der Liste."),
"recommended": False,
"type": "EmailItem",
},
{
"path": ["count"],
"pickerLabel": t("Anzahl"),
"detail": t("Anzahl gefundener E-Mails."),
"recommended": False,
"type": "int",
},
]
EMAIL_NODES = [
{
"id": "email.checkEmail",
@ -23,7 +52,8 @@ EMAIL_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "EmailList"}},
"outputPorts": {0: {"schema": "EmailList", "dataPickOptions": EMAIL_LIST_DATA_PICK_OPTIONS}},
"paramMappers": ["emailCheckFilter"],
"meta": {"icon": "mdi-email-check", "color": "#1976D2", "usesAi": False},
"_method": "outlook",
"_action": "readEmails",
@ -47,7 +77,8 @@ EMAIL_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "EmailList"}},
"outputPorts": {0: {"schema": "EmailList", "dataPickOptions": EMAIL_LIST_DATA_PICK_OPTIONS}},
"paramMappers": ["emailSearchQuery"],
"meta": {"icon": "mdi-email-search", "color": "#1976D2", "usesAi": False},
"_method": "outlook",
"_action": "searchEmails",
@ -63,11 +94,13 @@ EMAIL_NODES = [
"frontendOptions": {"authority": "msft"},
"description": t("E-Mail-Konto")},
{"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea",
"description": t("Daten aus vorherigen Schritten (oder direkte Beschreibung)"), "default": ""},
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
{"name": "to", "type": "str", "required": False, "frontendType": "text",
"description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""},
{"name": "documentList", "type": "str", "required": False, "frontendType": "hidden",
"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",
"description": t("Direkt vorbereiteter Inhalt {subject, body, to} (via Wire — überspringt KI)"),
"default": ""},
@ -78,7 +111,8 @@ EMAIL_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["EmailDraft", "AiResult", "Transit", "ConsolidateResult", "DocumentList"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"paramMappers": ["emailDraftContextFromSubjectBody"],
"meta": {"icon": "mdi-email-edit", "color": "#1976D2", "usesAi": False},
"_method": "outlook",
"_action": "composeAndDraftEmailWithContext",

View file

@ -3,27 +3,41 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
CONTEXT_BUILDER_PARAM_DESCRIPTION,
)
from modules.features.graphicalEditor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS
FILE_NODES = [
{
"id": "file.create",
"category": "file",
"label": t("Datei erstellen"),
"description": t("Erstellt eine Datei aus Kontext (Text/Markdown von KI)."),
"description": t(
"Erstellt eine Datei aus der Presentation von „Inhalt extrahieren“ "
"(``data`` oder Schleifen-``bodyResults``). Ausgabe über den Generation-Service."
),
"parameters": [
{"name": "outputFormat", "type": "str", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
"description": t("Ausgabeformat"), "default": "docx"},
{"name": "title", "type": "str", "required": False, "frontendType": "text",
"description": t("Dokumenttitel")},
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
"description": t("Zielordner in Meine Dateien"),
"default": ""},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
"graphInherit": {"port": 0, "kind": "recommendedDataPickRef"}},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload", "LoopItem", "ActionResult"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
"_method": "file",
"_action": "create",
# Emit a debug log tracing how the ``context`` parameter was resolved.
"logContextResolution": True,
},
]

View file

@ -3,6 +3,35 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS
BOOL_RESULT_DATA_PICK_OPTIONS = [
{
"path": ["result"],
"pickerLabel": t("Ergebnis"),
"detail": t("Boolesches Ergebnis (z. B. Genehmigung ja/nein)."),
"recommended": True,
"type": "bool",
},
{
"path": ["reason"],
"pickerLabel": t("Begründung"),
"detail": t("Optionale textuelle Begründung."),
"recommended": False,
"type": "str",
},
]
TEXT_RESULT_DATA_PICK_OPTIONS = [
{
"path": ["text"],
"pickerLabel": t("Text"),
"detail": t("Vom Benutzer eingegebener oder gewählter Text."),
"recommended": True,
"type": "str",
},
]
# Canonical form field types — single source of truth.
# portType maps to the PORT_TYPE_CATALOG primitive used by DataPicker / validateGraph.
FORM_FIELD_TYPES = [
@ -55,7 +84,7 @@ INPUT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "BoolResult"}},
"outputPorts": {0: {"schema": "BoolResult", "dataPickOptions": BOOL_RESULT_DATA_PICK_OPTIONS}},
"executor": "input",
"meta": {"icon": "mdi-check-decagram", "color": "#4CAF50", "usesAi": False},
},
@ -78,7 +107,7 @@ INPUT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
"executor": "input",
"meta": {"icon": "mdi-upload", "color": "#2196F3", "usesAi": False},
},
@ -96,7 +125,7 @@ INPUT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TextResult"}},
"outputPorts": {0: {"schema": "TextResult", "dataPickOptions": TEXT_RESULT_DATA_PICK_OPTIONS}},
"executor": "input",
"meta": {"icon": "mdi-comment-text", "color": "#FF9800", "usesAi": False},
},
@ -115,7 +144,7 @@ INPUT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "BoolResult"}},
"outputPorts": {0: {"schema": "BoolResult", "dataPickOptions": BOOL_RESULT_DATA_PICK_OPTIONS}},
"executor": "input",
"meta": {"icon": "mdi-magnify-scan", "color": "#673AB7", "usesAi": False},
},
@ -133,7 +162,7 @@ INPUT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "TextResult"}},
"outputPorts": {0: {"schema": "TextResult", "dataPickOptions": TEXT_RESULT_DATA_PICK_OPTIONS}},
"executor": "input",
"meta": {"icon": "mdi-format-list-checks", "color": "#009688", "usesAi": False},
},
@ -153,7 +182,7 @@ INPUT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "BoolResult"}},
"outputPorts": {0: {"schema": "BoolResult", "dataPickOptions": BOOL_RESULT_DATA_PICK_OPTIONS}},
"executor": "input",
"meta": {"icon": "mdi-checkbox-marked-circle", "color": "#8BC34A", "usesAi": False},
},

View file

@ -4,6 +4,8 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
# - type FeatureInstanceRef[redmine] is filtered by the DataPicker.
# - frontendType "featureInstance" is rendered by FeatureInstancePicker which
@ -31,7 +33,7 @@ REDMINE_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-ticket-outline", "color": "#4A6FA5", "usesAi": False},
"_method": "redmine",
"_action": "readTicket",
@ -59,7 +61,7 @@ REDMINE_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-format-list-bulleted", "color": "#4A6FA5", "usesAi": False},
"_method": "redmine",
"_action": "listTickets",
@ -91,7 +93,7 @@ REDMINE_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-ticket-plus-outline", "color": "#4A6FA5", "usesAi": False},
"_method": "redmine",
"_action": "createTicket",
@ -127,7 +129,7 @@ REDMINE_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-ticket-confirmation-outline", "color": "#4A6FA5", "usesAi": False},
"_method": "redmine",
"_action": "updateTicket",
@ -151,7 +153,7 @@ REDMINE_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-chart-bar", "color": "#4A6FA5", "usesAi": False},
"_method": "redmine",
"_action": "getStats",
@ -169,7 +171,7 @@ REDMINE_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-database-sync", "color": "#4A6FA5", "usesAi": False},
"_method": "redmine",
"_action": "runSync",

View file

@ -3,6 +3,35 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.ai import (
ACTION_RESULT_DATA_PICK_OPTIONS,
DOCUMENT_LIST_DATA_PICK_OPTIONS,
)
FILE_LIST_DATA_PICK_OPTIONS = [
{
"path": ["files"],
"pickerLabel": t("Alle Dateien"),
"detail": t("Die vollständige Dateiliste."),
"recommended": True,
"type": "List[FileItem]",
},
{
"path": ["files", 0],
"pickerLabel": t("Erste Datei"),
"detail": t("Das erste Listenelement."),
"recommended": False,
"type": "FileItem",
},
{
"path": ["count"],
"pickerLabel": t("Anzahl"),
"detail": t("Anzahl der Dateien."),
"recommended": False,
"type": "int",
},
]
SHAREPOINT_NODES = [
{
"id": "sharepoint.findFile",
@ -23,7 +52,7 @@ SHAREPOINT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "FileList"}},
"outputPorts": {0: {"schema": "FileList", "dataPickOptions": FILE_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-search", "color": "#0078D4", "usesAi": False},
"_method": "sharepoint",
"_action": "findDocumentPath",
@ -44,7 +73,7 @@ SHAREPOINT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["FileList", "Transit", "LoopItem"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-document", "color": "#0078D4", "usesAi": False},
"_method": "sharepoint",
"_action": "readDocuments",
@ -67,7 +96,7 @@ SHAREPOINT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-upload", "color": "#0078D4", "usesAi": False},
"_method": "sharepoint",
"_action": "uploadFile",
@ -88,7 +117,7 @@ SHAREPOINT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "FileList"}},
"outputPorts": {0: {"schema": "FileList", "dataPickOptions": FILE_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-folder-open", "color": "#0078D4", "usesAi": False},
"_method": "sharepoint",
"_action": "listDocuments",
@ -109,7 +138,7 @@ SHAREPOINT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["FileList", "Transit", "LoopItem"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"outputPorts": {0: {"schema": "DocumentList", "dataPickOptions": DOCUMENT_LIST_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-download", "color": "#0078D4", "usesAi": False},
"_method": "sharepoint",
"_action": "downloadFileByPath",
@ -133,7 +162,7 @@ SHAREPOINT_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-content-copy", "color": "#0078D4", "usesAi": False},
"_method": "sharepoint",
"_action": "copyFile",

View file

@ -1,27 +1,29 @@
# Copyright (c) 2025 Patrick Motsch
# Canvas start nodes — variant reflects workflow configuration (gear in editor).
# Start nodes (palette category ``start``); kinds align with workflow entry points / run envelope.
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
TRIGGER_NODES = [
{
"id": "trigger.manual",
"category": "trigger",
"category": "start",
"label": t("Start"),
"description": t("Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …)."),
"description": t("Manuell Trigger. Workflow startet nur, wenn auf Start-Button geklickt wird."),
"parameters": [],
"inputs": 0,
"outputs": 1,
"inputPorts": {},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"executor": "trigger",
"meta": {"icon": "mdi-play", "color": "#4CAF50", "usesAi": False},
},
{
"id": "trigger.form",
"category": "trigger",
"category": "start",
"label": t("Start (Formular)"),
"description": t("Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node."),
"description": t("Formular Trigger. Workflow startet nur, wenn das Formular ausgefüllt und abgeschickt wird."),
"parameters": [
{
"name": "formFields",
@ -40,9 +42,9 @@ TRIGGER_NODES = [
},
{
"id": "trigger.schedule",
"category": "trigger",
"category": "start",
"label": t("Start (Zeitplan)"),
"description": t("Cron-Ausdruck für geplante Läufe."),
"description": t("Workflow startet nach dem ausgewählten Zeitplan."),
"parameters": [
{
"name": "cron",
@ -51,11 +53,18 @@ TRIGGER_NODES = [
"frontendType": "cron",
"description": t("Cron-Ausdruck"),
},
{
"name": "schedule",
"type": "json",
"required": False,
"frontendType": "hidden",
"description": t("Zeitplan (intern, für Editor-Roundtrip)"),
},
],
"inputs": 0,
"outputs": 1,
"inputPorts": {},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"executor": "trigger",
"meta": {"icon": "mdi-clock", "color": "#2196F3", "usesAi": False},
},

View file

@ -3,6 +3,8 @@
from modules.shared.i18nRegistry import t
from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS
# Typed FeatureInstance binding (replaces legacy `string, hidden`).
# - type uses the discriminator notation `FeatureInstanceRef[<code>]` so the
# DataPicker / RequiredAttributePicker can filter compatible upstream paths.
@ -35,7 +37,7 @@ TRUSTEE_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-database-refresh", "color": "#4CAF50", "usesAi": False},
"_method": "trustee",
"_action": "refreshAccountingData",
@ -62,7 +64,7 @@ TRUSTEE_NODES = [
# Runtime returns ActionResult.isSuccess(documents=[...]) — see
# actions/extractFromFiles.py. Declaring DocumentList here was adapter
# drift and broke the DataPicker for downstream nodes.
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50", "usesAi": True},
"_method": "trustee",
"_action": "extractFromFiles",
@ -77,13 +79,14 @@ TRUSTEE_NODES = [
# is List[ActionDocument] (see datamodelChat.ActionResult). The
# DataPicker uses this string to filter compatible upstream paths.
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
"description": t("Dokumente aus vorherigen Schritten")},
"description": t("Dokumente aus vorherigen Schritten"),
"graphInherit": {"port": 0, "kind": "documentListWire"}},
dict(_TRUSTEE_INSTANCE_PARAM),
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["ActionResult", "DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-file-document-check", "color": "#4CAF50", "usesAi": False},
"_method": "trustee",
"_action": "processDocuments",
@ -95,13 +98,14 @@ TRUSTEE_NODES = [
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
"parameters": [
{"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),
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["ActionResult", "DocumentList", "Transit"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-calculator", "color": "#4CAF50", "usesAi": False},
"_method": "trustee",
"_action": "syncToAccounting",
@ -138,7 +142,7 @@ TRUSTEE_NODES = [
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit", "AiResult", "ConsolidateResult", "UdmDocument"]}},
"outputPorts": {0: {"schema": "ActionResult"}},
"outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}},
"meta": {"icon": "mdi-database-search", "color": "#4CAF50", "usesAi": False},
"_method": "trustee",
"_action": "queryData",

View file

@ -1,13 +1,14 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Node Type Registry for graphicalEditor - static node definitions (ai, email, sharepoint, trigger, flow, data, input).
Node Type Registry for graphicalEditor - static node definitions (start, input, flow, data, ai, email, ).
Nodes are defined first; IO/method actions are used at execution time.
"""
import logging
from typing import Dict, List, Any, Optional
from modules.features.graphicalEditor.conditionOperators import localize_operator_catalog
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
from modules.features.graphicalEditor.nodeAdapter import bindsActionFromLegacy
@ -82,6 +83,34 @@ def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
pc["description"] = resolveText(pd, lang)
params.append(pc)
out["parameters"] = params
out_ports: Dict[Any, Dict[str, Any]] = {}
for idx, po in (node.get("outputPorts") or {}).items():
if not isinstance(po, dict):
continue
port_copy = dict(po)
opts = port_copy.get("dataPickOptions")
if isinstance(opts, list):
loc_opts: List[Dict[str, Any]] = []
for o in opts:
if not isinstance(o, dict):
continue
oc = dict(o)
pl = oc.get("pickerLabel")
if pl is not None:
oc["pickerLabel"] = resolveText(pl, lang)
dt = oc.get("detail")
if dt is not None:
oc["detail"] = resolveText(dt, lang)
loc_opts.append(oc)
port_copy["dataPickOptions"] = loc_opts
out_ports[idx] = port_copy
if isinstance(node.get("outputPorts"), dict):
out["outputPorts"] = out_ports
# Legacy node-level key no longer used — do not expose.
out.pop("outputPickHints", None)
return out
@ -95,7 +124,7 @@ def getNodeTypesForApi(
nodes = getNodeTypes(services, language)
localized = [_localizeNode(n, language) for n in nodes]
categories = [
{"id": "trigger", "label": "Trigger"},
{"id": "start", "label": "Start"},
{"id": "input", "label": "Eingabe/Mensch"},
{"id": "flow", "label": "Ablauf"},
{"id": "data", "label": "Daten"},
@ -112,13 +141,14 @@ def getNodeTypesForApi(
for name, schema in PORT_TYPE_CATALOG.items():
catalogSerialized[name] = {
"name": schema.name,
"fields": [f.model_dump() for f in schema.fields],
"fields": [f.model_dump(by_alias=True, exclude_none=True) for f in schema.fields],
}
return {
"nodeTypes": localized,
"categories": categories,
"portTypeCatalog": catalogSerialized,
"conditionOperatorCatalog": localize_operator_catalog(language),
"systemVariables": SYSTEM_VARIABLES,
"formFieldTypes": FORM_FIELD_TYPES,
}

View file

@ -13,9 +13,9 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from modules.shared.i18nRegistry import resolveText
from modules.shared.i18nRegistry import resolveText, t
logger = logging.getLogger(__name__)
@ -25,6 +25,8 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
class PortField(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, …
description: str = ""
@ -36,11 +38,19 @@ class PortField(BaseModel):
discriminator: bool = False
# Surfaces this field at the top of the DataPicker list as the most common pick.
recommended: bool = False
# Human DataPicker title (camelCase JSON for frontend). Omit for technical paths-only.
picker_label: Optional[str] = Field(default=None, serialization_alias="pickerLabel")
# For List[T] fields: segment between parent and inner field (iteration / one list item).
picker_item_label: Optional[str] = Field(default=None, serialization_alias="pickerItemLabel")
class PortSchema(BaseModel):
name: str # e.g. "EmailDraft", "AiResult", "Transit"
fields: List[PortField]
# Declarative flag for the engine: when True, the executor attaches
# connection provenance ({id, authority, label}) onto the output. Replaces
# hard-coded schema lists in actionNodeExecutor._attachConnectionProvenance.
carriesConnectionProvenance: bool = False
class InputPortDef(BaseModel):
@ -153,7 +163,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="text", type="str", required=False, description="Textinhalt"),
PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"),
]),
"DocumentList": PortSchema(name="DocumentList", fields=[
"DocumentList": PortSchema(name="DocumentList", carriesConnectionProvenance=True, fields=[
PortField(name="documents", type="List[Document]",
description="Dokumente aus vorherigen Schritten", recommended=True),
PortField(name="connection", type="ConnectionRef", required=False,
@ -163,7 +173,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="count", type="int", required=False,
description="Anzahl Dokumente"),
]),
"FileList": PortSchema(name="FileList", fields=[
"FileList": PortSchema(name="FileList", carriesConnectionProvenance=True, fields=[
PortField(name="files", type="List[FileItem]",
description="Dateiliste"),
PortField(name="connection", type="ConnectionRef", required=False,
@ -173,7 +183,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="count", type="int", required=False,
description="Anzahl Dateien"),
]),
"EmailDraft": PortSchema(name="EmailDraft", fields=[
"EmailDraft": PortSchema(name="EmailDraft", carriesConnectionProvenance=True, fields=[
PortField(name="subject", type="str",
description="Betreff"),
PortField(name="body", type="str",
@ -187,7 +197,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="connection", type="ConnectionRef", required=False,
description="Outlook-/Graph-Verbindung"),
]),
"EmailList": PortSchema(name="EmailList", fields=[
"EmailList": PortSchema(name="EmailList", carriesConnectionProvenance=True, fields=[
PortField(name="emails", type="List[EmailItem]",
description="E-Mails"),
PortField(name="connection", type="ConnectionRef", required=False,
@ -195,7 +205,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="count", type="int", required=False,
description="Anzahl"),
]),
"TaskList": PortSchema(name="TaskList", fields=[
"TaskList": PortSchema(name="TaskList", carriesConnectionProvenance=True, fields=[
PortField(name="tasks", type="List[TaskItem]",
description="Aufgaben"),
PortField(name="connection", type="ConnectionRef", required=False,
@ -219,15 +229,39 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
]),
"AiResult": PortSchema(name="AiResult", fields=[
PortField(name="prompt", type="str",
description="Prompt"),
description="Prompt",
picker_label=t("Eingabe (Prompt des Schritts)"),
),
PortField(name="response", type="str",
description="Antworttext", recommended=True),
description=(
"Antworttext (Modell-Fließtext o. ä.; Bilder liegen in documents, nicht hier)."
),
recommended=True,
picker_label=t("Ausgabetext (Modell)"),
),
PortField(name="responseData", type="Dict", required=False,
description="Strukturierte Antwort (nur bei JSON-Ausgabe)"),
description="Strukturierte Antwort (nur bei JSON-Ausgabe)",
picker_label=t("Strukturierte Antwortdaten")),
PortField(name="context", type="str",
description="Kontext"),
description="Kontext",
picker_label=t("Eingabe-Kontext")),
PortField(name="documents", type="List[Document]",
description="Dokumente"),
description=(
"Erzeugte oder mitgegebene Dateien (z. B. Bilder); documentData = Nutzlast pro Eintrag."
),
picker_label=t("Alle Ausgabe-Dateien (Liste)"),
picker_item_label=t("je Datei"),
),
PortField(name="data", type="Dict", required=False,
description=(
"Internes Payload-Objekt (entspricht ``ActionResult.data``-Semantik). "
"Wird vom Executor gesetzt und enthält denselben Inhalt wie ``response`` "
"in strukturierter Form; primär für nachgelagerte Kontext-Nodes."
),
picker_label=t("Technische Detaildaten (data)")),
PortField(name="imageDocumentsOnly", type="List[Document]", required=False,
description="Nur Bild-bezogene Einträge aus documents.",
picker_label=t("Nur Bilder (Liste)")),
]),
"BoolResult": PortSchema(name="BoolResult", fields=[
PortField(name="result", type="bool",
@ -237,7 +271,8 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
]),
"TextResult": PortSchema(name="TextResult", fields=[
PortField(name="text", type="str",
description="Text"),
description="Text",
picker_label=t("Text (Schrittausgabe)")),
]),
"LoopItem": PortSchema(name="LoopItem", fields=[
PortField(name="currentItem", type="Any",
@ -263,13 +298,32 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="merged", type="Dict",
description="Zusammengeführte Daten"),
]),
"ContextBranch": PortSchema(name="ContextBranch", fields=[
PortField(name="items", type="List[Any]",
description="Schleifen-fertige Elemente aus dem (gefilterten) Kontext",
recommended=True,
picker_label=t("Gefilterte Elemente")),
PortField(name="data", type="Dict", required=False,
description="Gefilterter Presentation-Umschlag oder Eingabe-Spiegel",
picker_label=t("Kontext (data)")),
PortField(name="filterApplied", type="bool", required=False,
description="True wenn ein Kontext-Inhaltsfilter angewendet wurde"),
PortField(name="contentType", type="str", required=False,
description="Angewendeter Inhaltstyp-Filter (z. B. image)"),
PortField(name="match", type="int", required=False,
description="Aktiver Ausgangs-Index (Fall oder Sonst)"),
]),
"ActionDocument": PortSchema(name="ActionDocument", fields=[
PortField(name="documentName", type="str",
description="Dokumentname"),
description="Dokumentname",
picker_label=t("Dateiname")),
PortField(name="documentData", type="Any",
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)"),
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)",
picker_label=t("Dateiinhalt (JSON, Text oder Bild)"),
recommended=True),
PortField(name="mimeType", type="str",
description="MIME-Typ"),
description="MIME-Typ",
picker_label=t("Dateityp (MIME)")),
PortField(name="fileId", type="str", required=False,
description="Persistierte FileItem.id (vom Engine ergänzt)"),
PortField(name="fileName", type="str", required=False,
@ -285,12 +339,62 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
# Without it in the catalog the DataPicker cannot offer downstream
# bindings like `processDocuments → documents → *` for syncToAccounting.
PortField(name="documents", type="List[ActionDocument]", required=False,
description="Erzeugte Dokumente (immer befüllt für Trustee/AI/Email/...)"),
description=(
"Dokumentliste für Actions mit echten Artefakt-Dokumenten. "
"Beim Knoten „Inhalt extrahieren“ fehlt dieses Feld in der Knotenausgabe."
),
picker_label=t("Alle Ausgabe-Dokumente"),
picker_item_label=t("je Dokument"),
),
PortField(name="data", type="Dict", required=False,
description="Ergebnisdaten"),
description=(
"Strukturierter Inhalt. Bei **context.extractContent**: **Presentation**-Root "
"(`schemaVersion`, `kind`, `fileOrder`, `files`) plus **`_meta`** — ohne "
"zusätzliches `response`/`contentExtracted`-Duplikat."
),
picker_label=t("Technische Detaildaten (data)")),
# Mirror AiResult primary text fields so DataPicker / primaryTextRef behave the same
PortField(name="prompt", type="str", required=False,
description="Optional: auslösender Prompt / Schrittname",
picker_label=t("Auslöser / Prompt (falls vorhanden)")),
PortField(name="response", type="str", required=False,
description=(
"Fließtext wo die Action einen liefert. Bei **„Inhalt extrahieren“** absichtlich leer — "
"Inhalt liegt in ``data``.``files``."
),
recommended=True,
picker_label=t("Nur Fließtext (gesamt)")),
PortField(name="context", type="str", required=False,
description="Optional: Eingabe-Kontext",
picker_label=t("Mitgegebener Kontext")),
PortField(name="imageDocumentsOnly", type="List[ActionDocument]", required=False,
description=(
"Nur Bild-bezogene Einträge. Bei „Inhalt extrahieren“: synthetische "
"Einträge mit ``fileId`` aus persistierten Extrakt-Bildern (kein separates JSON-Dokument)."
),
picker_label=t("Nur Bilder (Liste)")),
PortField(name="responseData", type="Dict", required=False,
description="Optional: strukturierte Zusatzdaten",
picker_label=t("Strukturierte Zusatzdaten")),
PortField(name="presentation", type="Dict", required=False,
description=(
"Selten: Top-Level-Spiegel von Präsentationsdaten andere Actions. "
"Bei „Inhalt extrahieren“ liegt alles direkt unter ``data`` (kein zusätzlicher Spiegel)."
),
picker_label=t("Presentation (Top-Level-Spiegel)")),
PortField(name="presentationSummary", type="Dict", required=False,
description=(
"Kompakte Metadaten zu ``presentation`` (Debugging / traces)."
),
picker_label=t("Presentation-Zusammenfassung")),
PortField(name="presentationConfig", type="Dict", required=False,
description=(
"Optional: Debugging-Konfiguration; bei Extract liegt die Primärquelle in ``validationMetadata`` des JSON-Dokuments."
),
picker_label=t("Presentation-Konfiguration")),
]),
"Transit": PortSchema(name="Transit", fields=[]),
"UdmDocument": PortSchema(name="UdmDocument", fields=[
"UdmDocument": PortSchema(name="UdmDocument", carriesConnectionProvenance=True, fields=[
PortField(name="id", type="str", description="Dokument-ID"),
PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"),
PortField(name="sourcePath", type="str", description="Quellpfad"),
@ -402,6 +506,14 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
"RedmineRelationList": PortSchema(name="RedmineRelationList", fields=[
PortField(name="relations", type="List[Any]", description="Relationen"),
PortField(name="count", type="int", required=False, description="Anzahl in dieser Seite"),
PortField(name="totalMatched", type="int", required=False,
description="Gesamtanzahl nach Filter"),
PortField(name="offset", type="int", required=False, description="Pagination-Offset"),
PortField(name="hasMore", type="bool", required=False, description="Weitere Seiten verfügbar"),
]),
"RedmineStats": PortSchema(name="RedmineStats", fields=[
PortField(name="kpis", type="Dict[str,Any]",
description="Key Performance Indicators"),
@ -612,6 +724,24 @@ 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).
# ``recommendedDataPickRef`` uses upstream ``outputPorts.dataPickOptions`` where ``recommended: true``
# (see pickNotPushMigration.materializeRecommendedDataPickRef).
# Schema names are catalog output port types (e.g. AiResult).
PRIMARY_TEXT_HANDOVER_REF_PATH: Dict[str, List[Any]] = {
"AiResult": ["response"],
"ActionResult": ["response"],
"TextResult": ["text"],
"ConsolidateResult": ["result"],
}
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
"""Resolve a system variable name to its runtime value."""
from datetime import datetime, timezone
@ -809,8 +939,22 @@ def _resolveTransitChain(
# Schema derivation for dynamic outputs
# ---------------------------------------------------------------------------
def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Optional[PortSchema]:
"""Derive output schema from a field-builder JSON list (``fields``, ``formFields``, …)."""
def deriveFormPayloadSchemaFromParam(
node: Dict[str, Any],
param_key: str,
name_field: str = "name",
type_field: str = "type",
label_field: str = "label",
schema_name: str = "FormPayload_dynamic",
) -> Optional[PortSchema]:
"""Derive an output schema from a graph-defined parameter.
Supports three parameter shapes:
- List[Dict] with ``name_field`` (e.g. ``fields[].name``, ``entries[].key``,
``mappings[].outputField``).
- Group-fields: ``type == "group"`` recursed via ``fields``.
- List[str]: each string is taken as a leaf path key (used for ``filterContext.keys``).
"""
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
_FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES}
@ -833,21 +977,35 @@ def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Op
))
for f in fields_param:
if not isinstance(f, dict) or not f.get("name"):
if isinstance(f, str):
if f.strip():
_append_field(f.strip(), "str", None, False)
continue
fname = str(f["name"])
if str(f.get("type", "")).lower() == "group" and isinstance(f.get("fields"), list):
if not isinstance(f, dict):
continue
fname_raw = f.get(name_field)
if not fname_raw and name_field == "contextKey":
fname_raw = f.get("key")
if not fname_raw:
continue
fname = str(fname_raw)
if str(f.get(type_field, "")).lower() == "group" and isinstance(f.get("fields"), list):
for sub in f["fields"]:
if isinstance(sub, dict) and sub.get("name"):
if isinstance(sub, dict) and sub.get(name_field):
_append_field(
f"{fname}.{sub['name']}",
sub.get("type", "str"),
sub.get("label"),
f"{fname}.{sub[name_field]}",
sub.get(type_field, "str"),
sub.get(label_field),
bool(sub.get("required", False)),
)
continue
_append_field(fname, f.get("type", "str"), f.get("label"), bool(f.get("required", False)))
return PortSchema(name="FormPayload_dynamic", fields=portFields) if portFields else None
_append_field(
fname,
f.get(type_field, "str"),
f.get(label_field),
bool(f.get("required", False)),
)
return PortSchema(name=schema_name, fields=portFields) if portFields else None
def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
@ -872,9 +1030,20 @@ def parse_graph_defined_output_schema(
schema_spec = output_port.get("schema")
if isinstance(schema_spec, dict) and schema_spec.get("kind") == "fromGraph":
param_key = str(schema_spec.get("parameter") or "fields")
return deriveFormPayloadSchemaFromParam(node, param_key)
name_field = str(schema_spec.get("nameField") or "name")
type_field = str(schema_spec.get("typeField") or "type")
label_field = str(schema_spec.get("labelField") or "label")
schema_name = str(schema_spec.get("schemaName") or "FormPayload_dynamic")
return deriveFormPayloadSchemaFromParam(
node, param_key,
name_field=name_field, type_field=type_field,
label_field=label_field, schema_name=schema_name,
)
if output_port.get("dynamic") and output_port.get("deriveFrom"):
return deriveFormPayloadSchemaFromParam(node, str(output_port.get("deriveFrom")))
name_field = str(output_port.get("deriveNameField") or "name")
return deriveFormPayloadSchemaFromParam(
node, str(output_port.get("deriveFrom")), name_field=name_field,
)
if isinstance(schema_spec, str) and schema_spec:
return PORT_TYPE_CATALOG.get(schema_spec)
return None

View file

@ -26,7 +26,8 @@ from modules.workflows.automation2.runEnvelope import (
normalize_run_envelope,
)
from modules.features.graphicalEditor.entryPoints import find_invocation
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths
from modules.features.graphicalEditor.conditionOperators import resolve_condition_meta
from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths, compute_graph_data_sources
from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")
@ -192,6 +193,56 @@ def post_upstream_paths(
return {"paths": paths}
@router.post("/{instanceId}/condition-meta")
@limiter.limit("120/minute")
def post_condition_meta(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
body: Dict[str, Any] = Body(...),
language: str = Query("de", description="Localization (en, de, fr)"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Return valueKind and operators for a DataRef (backend-driven If/Else UI)."""
_validateInstanceAccess(instanceId, context)
graph = body.get("graph")
ref = body.get("ref")
node_id = body.get("nodeId")
if not isinstance(graph, dict) or not isinstance(ref, dict):
raise HTTPException(status_code=400, detail=routeApiMsg("graph and ref are required"))
graph_payload = dict(graph)
if node_id:
graph_payload["targetNodeId"] = str(node_id)
return resolve_condition_meta(graph_payload, ref, lang=language)
@router.post("/{instanceId}/graph-data-sources")
@limiter.limit("120/minute")
def post_graph_data_sources(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
body: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Scope-aware data sources for the DataPicker.
Takes ``{ nodeId, graph: { nodes, connections } }`` and returns::
{
"availableSourceIds": [...], # ancestors minus loop-body nodes on Done branch
"portIndexOverrides": {nodeId: n}, # use outputPorts[n] instead of 0
"loopBodyContextIds": [...], # loops whose body the node is in
}
All loop scope logic lives here so the frontend has zero topology knowledge.
"""
_validateInstanceAccess(instanceId, context)
graph = body.get("graph")
node_id = body.get("nodeId")
if not isinstance(graph, dict) or not node_id:
raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required"))
return compute_graph_data_sources(graph, str(node_id))
@router.get("/{instanceId}/upstream-paths/{node_id}")
@limiter.limit("60/minute")
def get_upstream_paths_saved(
@ -1724,6 +1775,51 @@ async def complete_task(
)
@router.post("/{instanceId}/tasks/{taskId}/cancel")
@limiter.limit("30/minute")
def cancel_pending_task_stop_run(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
taskId: str = Path(..., description="Human task ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Cancel a pending human task and stop the workflow run behind it."""
mandateId = _validateInstanceAccess(instanceId, context)
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
task = iface.getTask(taskId)
if not task:
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
wf_ids = {w.get("id") for w in iface.getWorkflows() if w.get("id")}
if task.get("workflowId") not in wf_ids:
raise HTTPException(status_code=404, detail=routeApiMsg("Task not found"))
if task.get("status") != "pending":
raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed"))
run_id = task.get("runId")
from modules.workflows.automation2.executionEngine import requestRunStop
if run_id:
requestRunStop(run_id)
db_run = iface.getRun(run_id)
if db_run:
current = db_run.get("status") or ""
if current not in ("completed", "failed", "cancelled"):
iface.updateRun(run_id, status="cancelled")
pending = iface.getTasks(runId=run_id, status="pending")
for t in pending:
tid = t.get("id")
if tid:
iface.updateTask(tid, status="cancelled")
else:
iface.updateTask(taskId, status="cancelled")
return {"success": True, "runId": run_id, "taskId": taskId}
# -------------------------------------------------------------------------
# Monitoring / Metrics
# -------------------------------------------------------------------------

View file

@ -0,0 +1,308 @@
# Copyright (c) 2025 Patrick Motsch
"""Build flow.switch branch payloads: filtered context + loop-ready items."""
from __future__ import annotations
import copy
import re
from typing import Any, Dict, List, Optional
from modules.features.graphicalEditor.portTypes import unwrapTransit
_CONTEXT_FILTER_OPERATORS = frozenset({"contains_content"})
_BLOB_IMAGE_CHUNK_RE = re.compile(r"^\[image(?:\:([^\]]+))?\]$")
def _artifacts_by_part_id_from_presentation(inp: Any) -> Dict[str, str]:
plain = _unwrap_input(inp)
meta = plain.get("_meta") if isinstance(plain, dict) else None
if not isinstance(meta, dict):
return {}
out: Dict[str, str] = {}
for art in meta.get("persistedImageArtifacts") or []:
if not isinstance(art, dict):
continue
sp = str(art.get("sourcePartId") or "").strip()
fid = str(art.get("fileId") or "").strip()
if sp and fid:
out[sp] = fid
return out
def _enrich_image_slot(slot: Dict[str, Any], artifacts_by_part: Dict[str, str]) -> None:
if (slot.get("typeGroup") or "").strip().lower() != "image":
return
existing = str(slot.get("embeddedImageFileId") or "").strip()
if existing and existing in artifacts_by_part.values():
return
candidates: List[str] = []
sid = str(slot.get("id") or "").strip()
if sid:
candidates.append(sid)
data = slot.get("data")
if isinstance(data, str):
m = _BLOB_IMAGE_CHUNK_RE.fullmatch(data.strip())
if m:
tok = (m.group(1) or "").strip()
if tok:
candidates.append(tok)
for cand in candidates:
fid = artifacts_by_part.get(cand)
if fid:
slot["embeddedImageFileId"] = fid
return
def _slot_matches_content_type(slot: Dict[str, Any], content_type: str) -> bool:
target = (content_type or "").strip().lower()
if not target:
return False
tg = (slot.get("typeGroup") or slot.get("contentType") or "").strip().lower()
if target == "media":
return tg in ("image", "media", "video", "audio")
if target == "text":
return tg in ("text", "table", "structure")
return tg == target
def _filter_bucket_slots(bucket: Dict[str, Any], content_type: str) -> Dict[str, Any]:
"""Return a copy of a presentation file bucket with filtered ``data`` slots."""
mode = str(bucket.get("outputMode") or "").strip().lower()
data = bucket.get("data")
if mode == "blob" and isinstance(data, str):
from modules.workflows.methods.methodContext.actions.extractContent import (
filter_blob_bucket_by_content_type,
)
return filter_blob_bucket_by_content_type(bucket, content_type)
out = copy.deepcopy(bucket)
if isinstance(data, list):
out["data"] = [s for s in data if isinstance(s, dict) and _slot_matches_content_type(s, content_type)]
elif isinstance(data, dict) and _slot_matches_content_type(data, content_type):
out["data"] = data
else:
out["data"] = [] if isinstance(data, list) else data
return out
def _filter_presentation_envelope(envelope: Dict[str, Any], content_type: str) -> Dict[str, Any]:
"""Filter all slots in a presentation envelope by content type group."""
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
PRESENTATION_SCHEMA_VERSION,
)
out = copy.deepcopy(envelope)
files = out.get("files") or {}
if not isinstance(files, dict):
return out
filtered_files: Dict[str, Any] = {}
kept_order: List[str] = []
for fk in out.get("fileOrder") or list(files.keys()):
bucket = files.get(fk)
if not isinstance(bucket, dict):
continue
fb = _filter_bucket_slots(bucket, content_type)
data = fb.get("data")
has_data = (
(isinstance(data, list) and len(data) > 0)
or (isinstance(data, dict))
or (isinstance(data, str) and str(data).strip())
)
if has_data:
filtered_files[str(fk)] = fb
kept_order.append(str(fk))
out["schemaVersion"] = out.get("schemaVersion") or PRESENTATION_SCHEMA_VERSION
out["kind"] = out.get("kind") or PRESENTATION_KIND
out["fileOrder"] = kept_order
out["files"] = filtered_files
return out
def _slots_from_bucket(bucket: Dict[str, Any]) -> List[Any]:
data = bucket.get("data")
mode = str(bucket.get("outputMode") or "").strip().lower()
if mode == "blob" and isinstance(data, str) and data.strip():
from modules.workflows.methods.methodContext.actions.extractContent import parse_blob_data_segments
return parse_blob_data_segments(data)
if isinstance(data, list):
return [s for s in data if isinstance(s, dict)]
if isinstance(data, dict):
return [data]
if isinstance(data, str) and data.strip():
return [{"typeGroup": "text", "data": data}]
items = bucket.get("items")
if isinstance(items, list):
return [i for i in items if isinstance(i, dict)]
return []
def _items_from_presentation_envelope(
envelope: Dict[str, Any],
*,
artifacts_by_part: Optional[Dict[str, str]] = None,
) -> List[Any]:
items: List[Any] = []
files = envelope.get("files") or {}
if not isinstance(files, dict):
return items
for fk in envelope.get("fileOrder") or list(files.keys()):
bucket = files.get(fk)
if isinstance(bucket, dict):
for slot in _slots_from_bucket(bucket):
if artifacts_by_part:
_enrich_image_slot(slot, artifacts_by_part)
sid = str(slot.get("id") or slot.get("label") or len(items))
items.append({"name": f"{fk}:{sid}", "value": slot})
return items
def expand_items_from_input(raw: Any) -> List[Any]:
"""Best-effort loop items from transit/presentation/list/dict input."""
if raw is None:
return []
if isinstance(raw, dict) and isinstance(raw.get("items"), list):
return list(raw["items"])
plain = unwrapTransit(raw) if isinstance(raw, dict) and raw.get("_transit") else raw
if isinstance(plain, dict) and isinstance(plain.get("items"), list):
return list(plain["items"])
from modules.workflows.methods.methodContext.actions.extractContent import (
normalize_presentation_envelopes,
)
envelopes = normalize_presentation_envelopes(plain)
if envelopes:
out: List[Any] = []
for env in envelopes:
out.extend(_items_from_presentation_envelope(env))
return out
if isinstance(plain, list):
return list(plain)
if isinstance(plain, dict):
children = plain.get("children")
if isinstance(children, list) and children:
return list(children)
return [{"name": k, "value": v} for k, v in plain.items()]
return [plain]
def _unwrap_input(inp: Any) -> Any:
if isinstance(inp, dict) and inp.get("_transit"):
return unwrapTransit(inp)
return inp
def build_switch_branch_payload(
inp: Any,
case: Dict[str, Any],
*,
value_kind: str = "unknown",
match_index: int = 0,
) -> Dict[str, Any]:
"""Payload for a matched switch case (ContextBranch inner data)."""
operator = str(case.get("operator") or "eq")
right = case.get("value")
plain_in = _unwrap_input(inp)
if operator in _CONTEXT_FILTER_OPERATORS and value_kind == "context":
content_type = str(right or "")
from modules.workflows.methods.methodContext.actions.extractContent import (
normalize_presentation_envelopes,
)
source = plain_in
if isinstance(source, dict) and "data" in source and not source.get("kind"):
nested = source.get("data")
if isinstance(nested, dict):
source = nested
envelopes = normalize_presentation_envelopes(source)
if not envelopes and isinstance(plain_in, dict):
envelopes = normalize_presentation_envelopes(plain_in)
filtered_envs = [_filter_presentation_envelope(env, content_type) for env in envelopes]
artifacts_by_part = _artifacts_by_part_id_from_presentation(plain_in)
items: List[Any] = []
for env in filtered_envs:
items.extend(_items_from_presentation_envelope(env, artifacts_by_part=artifacts_by_part))
if len(filtered_envs) == 1:
data_out: Any = filtered_envs[0]
elif filtered_envs:
data_out = {"envelopes": filtered_envs}
else:
data_out = {}
return {
"data": data_out,
"items": items,
"filterApplied": True,
"contentType": content_type,
"match": match_index,
}
data_out = plain_in if isinstance(plain_in, dict) else {"value": plain_in}
return {
"data": data_out,
"items": expand_items_from_input(inp),
"filterApplied": False,
"match": match_index,
}
def build_switch_default_payload(inp: Any, *, match_index: int) -> Dict[str, Any]:
"""Sonst branch: unmodified input passthrough."""
plain_in = _unwrap_input(inp)
data_out = plain_in if isinstance(plain_in, dict) else {"value": plain_in}
return {
"data": data_out,
"items": expand_items_from_input(inp),
"filterApplied": False,
"match": match_index,
}
def build_switch_combined_output(
inp: Any,
cases: List[Any],
*,
matched_indices: List[int],
value_kind: str = "unknown",
) -> Dict[str, Any]:
"""Build per-port branch payloads; primary fields mirror the first active match."""
branches: Dict[str, Dict[str, Any]] = {}
default_idx = len(cases)
for idx in matched_indices:
if idx == default_idx:
branches[str(idx)] = build_switch_default_payload(inp, match_index=default_idx)
elif 0 <= idx < len(cases):
c = cases[idx] if isinstance(cases[idx], dict) else {"operator": "eq", "value": cases[idx]}
branches[str(idx)] = build_switch_branch_payload(
inp, c, value_kind=value_kind, match_index=idx,
)
primary_idx = matched_indices[0] if matched_indices else default_idx
primary = branches.get(str(primary_idx)) or build_switch_default_payload(inp, match_index=default_idx)
return {**primary, "branches": branches}
def switch_branch_payload(transit: Any, source_output: int) -> Optional[Dict[str, Any]]:
"""Return the ContextBranch inner dict for a specific switch output port."""
if not isinstance(transit, dict):
return None
data = transit.get("data") if transit.get("_transit") else transit
if not isinstance(data, dict):
return None
branches = data.get("branches")
if isinstance(branches, dict):
branch = branches.get(str(source_output))
if isinstance(branch, dict):
return branch
if transit.get("_transit"):
return data
return data
def unwrap_transit_for_port(output: Any, source_output: Optional[int] = None) -> Any:
"""Unwrap transit; when ``source_output`` is set, pick that switch branch payload."""
if source_output is not None:
branch = switch_branch_payload(output, source_output)
if branch is not None:
return branch
return unwrapTransit(output)

View file

@ -4,9 +4,10 @@ from __future__ import annotations
from typing import Any, Dict, List, Set
from modules.features.graphicalEditor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema
from modules.workflows.automation2.graphUtils import buildConnectionMap
from modules.workflows.automation2.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds
_NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES}
@ -36,6 +37,31 @@ def _paths_for_port_schema(schema: PortSchema, producer_node_id: str) -> List[Di
return out
def _paths_for_data_pick_options(
options: List[Dict[str, Any]],
producer_node_id: str,
) -> List[Dict[str, Any]]:
"""Explicit per-port pick list from node definition (authoritative; no catalog expansion)."""
out: List[Dict[str, Any]] = []
for o in options:
if not isinstance(o, dict):
continue
path = o.get("path")
if not isinstance(path, list):
continue
label = o.get("pickerLabel")
out.append(
{
"producerNodeId": producer_node_id,
"path": path,
"type": o.get("type") or "Any",
"label": label if isinstance(label, str) else ".".join(str(p) for p in path),
"scopeOrigin": "data",
}
)
return out
def _paths_for_schema(schema_name: str, producer_node_id: str) -> List[Dict[str, Any]]:
if not schema_name or schema_name == "Transit":
return []
@ -83,22 +109,39 @@ def compute_upstream_paths(graph: Dict[str, Any], target_node_id: str) -> List[D
if not ndef:
continue
out0 = (ndef.get("outputPorts") or {}).get(0, {})
derived = parse_graph_defined_output_schema(anode, out0 if isinstance(out0, dict) else {})
if derived:
for entry in _paths_for_port_schema(derived, aid):
entry["producerLabel"] = (anode.get("title") or "").strip() or aid
paths.append(entry)
else:
raw_schema = out0.get("schema") if isinstance(out0, dict) else None
schema_name = raw_schema if isinstance(raw_schema, str) and raw_schema else "ActionResult"
for entry in _paths_for_schema(schema_name, aid):
entry["producerLabel"] = (anode.get("title") or "").strip() or aid
paths.append(entry)
out0 = out0 if isinstance(out0, dict) else {}
dpo = out0.get("dataPickOptions")
# Lexical loop hints (flow.loop): any loop node in ancestors adds synthetic paths
bases: List[Dict[str, Any]] = []
if isinstance(dpo, list):
bases = _paths_for_data_pick_options(dpo, aid)
derived = parse_graph_defined_output_schema(anode, out0)
derived_paths: List[Dict[str, Any]] = []
if derived:
derived_paths = _paths_for_port_schema(derived, aid)
merged_list = bases + derived_paths
if merged_list:
plab = (anode.get("title") or "").strip() or aid
for entry in merged_list:
entry["producerLabel"] = plab
paths.append(entry)
continue
raw_schema = out0.get("schema") if isinstance(out0, dict) else None
schema_name = raw_schema if isinstance(raw_schema, str) and raw_schema else "ActionResult"
plab = (anode.get("title") or "").strip() or aid
for entry in _paths_for_schema(schema_name, aid):
entry["producerLabel"] = plab
paths.append(entry)
# Lexical loop hints (flow.loop): only for nodes inside the loop body
for aid in ancestors:
anode = node_by_id.get(aid) or {}
if anode.get("type") == "flow.loop":
if anode.get("type") != "flow.loop":
continue
body_ids = getLoopBodyNodeIds(aid, conn_map)
if target_node_id in body_ids:
paths.extend(
[
{
@ -125,4 +168,93 @@ def compute_upstream_paths(graph: Dict[str, Any], target_node_id: str) -> List[D
]
)
for entry in paths:
ct = str(entry.get("type") or "Any")
vk = catalog_type_to_value_kind(ct)
if vk == "unknown":
ref = {
"nodeId": entry.get("producerNodeId"),
"path": entry.get("path") or [],
}
graph_with_target = {**graph, "targetNodeId": target_node_id}
vk = resolve_value_kind(graph_with_target, ref, _skip_upstream=True)
entry["valueKind"] = vk
return paths
def compute_graph_data_sources(graph: Dict[str, Any], target_node_id: str) -> Dict[str, Any]:
"""Return scope-aware data sources for the DataPicker.
Determines which ancestor nodes are valid sources for ``target_node_id``,
taking loop scoping into account:
- If ``target_node_id`` is on the *Done* branch of a ``flow.loop``, the
loop body nodes are excluded from ``availableSourceIds`` and the loop
node itself is mapped to its *Fertig* output port (index 1) via
``portIndexOverrides``.
- If ``target_node_id`` is *inside* the loop body, the loop node id is
included in ``loopBodyContextIds`` so the frontend can show the lexical
loop variables (currentItem, currentIndex, count).
Returns::
{
"availableSourceIds": [...], # ordered list
"portIndexOverrides": {nodeId: n}, # non-zero port indices
"loopBodyContextIds": [...], # loops whose body this node is in
}
"""
nodes = graph.get("nodes") or []
connections = graph.get("connections") or []
node_by_id: Dict[str, Any] = {n["id"]: n for n in nodes if n.get("id")}
if target_node_id not in node_by_id:
return {"availableSourceIds": [], "portIndexOverrides": {}, "loopBodyContextIds": []}
conn_map = buildConnectionMap(connections)
# Collect all ancestors via backward BFS
preds: Dict[str, Set[str]] = {}
for tgt, pairs in conn_map.items():
for src, _, _ in pairs:
preds.setdefault(tgt, set()).add(src)
seen: Set[str] = set()
stack = [target_node_id]
ancestors: Set[str] = set()
while stack:
cur = stack.pop()
for p in preds.get(cur, ()):
if p not in seen:
seen.add(p)
ancestors.add(p)
stack.append(p)
body_nodes_to_exclude: Set[str] = set()
port_index_overrides: Dict[str, int] = {}
loop_body_context_ids: List[str] = []
for aid in ancestors:
anode = node_by_id.get(aid) or {}
if anode.get("type") != "flow.loop":
continue
body_ids = getLoopBodyNodeIds(aid, conn_map)
done_ids = getLoopDoneNodeIds(aid, conn_map)
if target_node_id in body_ids:
loop_body_context_ids.append(aid)
elif target_node_id in done_ids:
body_nodes_to_exclude.update(body_ids)
port_index_overrides[aid] = 1
available_source_ids = [
aid for aid in sorted(ancestors)
if aid not in body_nodes_to_exclude
]
return {
"availableSourceIds": available_source_ids,
"portIndexOverrides": port_index_overrides,
"loopBodyContextIds": loop_body_context_ids,
}

View file

@ -308,7 +308,6 @@ def _buildSystemTemplates():
"title": "Pro E-Mail",
"parameters": {
"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]},
"level": "auto",
"concurrency": 1,
},
},
@ -348,7 +347,6 @@ def _buildSystemTemplates():
"title": "Pro Dokument",
"parameters": {
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
"level": "auto",
"concurrency": 1,
},
},

View file

@ -990,6 +990,10 @@ class ComponentObjects:
If pagination is provided: PaginatedResult with items and metadata
"""
def _convertFileItems(files):
from modules.workflows.automation2.workflowArtifactVisibility import (
suppress_workflow_file_in_workspace_ui,
)
fileItems = []
for file in files:
try:
@ -1002,6 +1006,8 @@ class ComponentObjects:
fileName = file.get("fileName")
if not fileName or fileName == "None":
continue
if suppress_workflow_file_in_workspace_ui(file):
continue
if file.get("scope") is None:
file["scope"] = "personal"
@ -1342,16 +1348,34 @@ class ComponentObjects:
return newfileName
counter += 1
def createFile(self, name: str, mimeType: str, content: bytes) -> FileItem:
def createFile(
self,
name: str,
mimeType: str,
content: bytes,
folderId: Optional[str] = None,
) -> FileItem:
"""Creates a new file entry if user has permission. Computes fileHash and fileSize from content.
Duplicate check: if a file with the same user + fileHash + fileName already exists,
the existing file is returned instead of creating a new one.
Same hash with different name is allowed (intentional copy by user).
When ``folderId`` is set, the folder must exist and the user must be allowed to modify it.
"""
if not self.checkRbacPermission(FileItem, "create"):
raise PermissionError("No permission to create files")
resolved_folder_id: Optional[str] = None
if folderId is not None:
raw = str(folderId).strip()
if raw:
folder = self.getFolder(raw)
if not folder:
raise FileNotFoundError(f"Folder {raw} not found")
self._requireFolderWriteAccess(folder, raw, "update")
resolved_folder_id = raw
# Compute file size and hash
fileSize = len(content)
fileHash = hashlib.sha256(content).hexdigest()
@ -1383,6 +1407,7 @@ class ComponentObjects:
mimeType=mimeType,
fileSize=fileSize,
fileHash=fileHash,
folderId=resolved_folder_id,
)
# Ensure audit user is always stored: workflow/singleton contexts sometimes leave
# the connector without _current_user_id, so _saveRecord skips sysCreatedBy →

View file

@ -3383,6 +3383,116 @@
"key": "Warnschwelle",
"value": ""
},
{
"context": "ui",
"key": "Ansicht an Fenster anpassen",
"value": ""
},
{
"context": "ui",
"key": "Ansicht zurücksetzen",
"value": ""
},
{
"context": "ui",
"key": "Auswahl löschen",
"value": ""
},
{
"context": "ui",
"key": "Canvas bearbeiten",
"value": ""
},
{
"context": "ui",
"key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang",
"value": ""
},
{
"context": "ui",
"key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen",
"value": ""
},
{
"context": "ui",
"key": "Kommentar (optional)",
"value": ""
},
{
"context": "ui",
"key": "Kommentar bearbeiten",
"value": ""
},
{
"context": "ui",
"key": "Knoten duplizieren",
"value": ""
},
{
"context": "ui",
"key": "Rückgängig",
"value": ""
},
{
"context": "ui",
"key": "Verbindungen zeichnen",
"value": ""
},
{
"context": "ui",
"key": "Vergrößern",
"value": ""
},
{
"context": "ui",
"key": "Verkleinern",
"value": ""
},
{
"context": "ui",
"key": "Wiederholen",
"value": ""
},
{
"context": "ui",
"key": "Zoom-Voreinstellungen",
"value": ""
},
{
"context": "ui",
"key": "Zoomstufe (Prozent)",
"value": ""
},
{
"context": "ui",
"key": "Doppelklick zum Bearbeiten",
"value": ""
},
{
"context": "ui",
"key": "Kommentar auf dem Canvas einfügen",
"value": ""
},
{
"context": "ui",
"key": "Kommentar eingeben …",
"value": ""
},
{
"context": "ui",
"key": "Canvas-Notiz verschieben",
"value": ""
},
{
"context": "ui",
"key": "Notizfarbe",
"value": ""
},
{
"context": "ui",
"key": "Notizgröße ändern",
"value": ""
},
{
"context": "ui",
"key": "✓ Mandat eingereicht",
@ -6776,6 +6886,116 @@
"key": "Warnschwelle",
"value": "Warnschwelle"
},
{
"context": "ui",
"key": "Ansicht an Fenster anpassen",
"value": "Ansicht an Fenster anpassen"
},
{
"context": "ui",
"key": "Ansicht zurücksetzen",
"value": "Ansicht zurücksetzen"
},
{
"context": "ui",
"key": "Auswahl löschen",
"value": "Auswahl löschen"
},
{
"context": "ui",
"key": "Canvas bearbeiten",
"value": "Canvas bearbeiten"
},
{
"context": "ui",
"key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang",
"value": "Klicken Sie auf einen Ausgang, dann auf einen Eingang"
},
{
"context": "ui",
"key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen",
"value": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen"
},
{
"context": "ui",
"key": "Kommentar (optional)",
"value": "Kommentar (optional)"
},
{
"context": "ui",
"key": "Kommentar bearbeiten",
"value": "Kommentar bearbeiten"
},
{
"context": "ui",
"key": "Knoten duplizieren",
"value": "Knoten duplizieren"
},
{
"context": "ui",
"key": "Rückgängig",
"value": "Rückgängig"
},
{
"context": "ui",
"key": "Verbindungen zeichnen",
"value": "Verbindungen zeichnen"
},
{
"context": "ui",
"key": "Vergrößern",
"value": "Vergrößern"
},
{
"context": "ui",
"key": "Verkleinern",
"value": "Verkleinern"
},
{
"context": "ui",
"key": "Wiederholen",
"value": "Wiederholen"
},
{
"context": "ui",
"key": "Zoom-Voreinstellungen",
"value": "Zoom-Voreinstellungen"
},
{
"context": "ui",
"key": "Zoomstufe (Prozent)",
"value": "Zoomstufe (Prozent)"
},
{
"context": "ui",
"key": "Doppelklick zum Bearbeiten",
"value": "Doppelklick zum Bearbeiten"
},
{
"context": "ui",
"key": "Kommentar auf dem Canvas einfügen",
"value": "Kommentar auf dem Canvas einfügen"
},
{
"context": "ui",
"key": "Kommentar eingeben …",
"value": "Kommentar eingeben …"
},
{
"context": "ui",
"key": "Canvas-Notiz verschieben",
"value": "Zum Verschieben greifen"
},
{
"context": "ui",
"key": "Notizfarbe",
"value": "Notizfarbe"
},
{
"context": "ui",
"key": "Notizgröße ändern",
"value": "Notizgröße ändern"
},
{
"context": "ui",
"key": "✓ Mandat eingereicht",
@ -9994,6 +10214,116 @@
"key": "Warnschwelle",
"value": "Warning threshold"
},
{
"context": "ui",
"key": "Ansicht an Fenster anpassen",
"value": "Fit to window"
},
{
"context": "ui",
"key": "Ansicht zurücksetzen",
"value": "Reset view"
},
{
"context": "ui",
"key": "Auswahl löschen",
"value": "Delete selection"
},
{
"context": "ui",
"key": "Canvas bearbeiten",
"value": "Edit canvas"
},
{
"context": "ui",
"key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang",
"value": "Click an output, then an input"
},
{
"context": "ui",
"key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen",
"value": "Click an input to create the connection"
},
{
"context": "ui",
"key": "Kommentar (optional)",
"value": "Comment (optional)"
},
{
"context": "ui",
"key": "Kommentar bearbeiten",
"value": "Edit comment"
},
{
"context": "ui",
"key": "Knoten duplizieren",
"value": "Duplicate node"
},
{
"context": "ui",
"key": "Rückgängig",
"value": "Undo"
},
{
"context": "ui",
"key": "Verbindungen zeichnen",
"value": "Draw connections"
},
{
"context": "ui",
"key": "Vergrößern",
"value": "Zoom in"
},
{
"context": "ui",
"key": "Verkleinern",
"value": "Zoom out"
},
{
"context": "ui",
"key": "Wiederholen",
"value": "Redo"
},
{
"context": "ui",
"key": "Zoom-Voreinstellungen",
"value": "Zoom presets"
},
{
"context": "ui",
"key": "Zoomstufe (Prozent)",
"value": "Zoom level (percent)"
},
{
"context": "ui",
"key": "Doppelklick zum Bearbeiten",
"value": "Double-click to edit"
},
{
"context": "ui",
"key": "Kommentar auf dem Canvas einfügen",
"value": "Add comment on canvas"
},
{
"context": "ui",
"key": "Kommentar eingeben …",
"value": "Enter a comment…"
},
{
"context": "ui",
"key": "Canvas-Notiz verschieben",
"value": "Drag to move note"
},
{
"context": "ui",
"key": "Notizfarbe",
"value": "Note color"
},
{
"context": "ui",
"key": "Notizgröße ändern",
"value": "Resize note"
},
{
"context": "ui",
"key": "✓ Mandat eingereicht",

View file

@ -26,6 +26,7 @@ from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
AutoWorkflow,
)
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeAutomationWorkspace")
@ -265,7 +266,8 @@ def getWorkspaceRunDetail(
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]
rows = [dict(fileMetaById[fid]) for fid in ids if fid in fileMetaById]
return [m for m in rows if not suppress_workflow_file_in_workspace_ui(m)]
assignedFileIds: set[str] = set()
for step, (inputIds, outputIds) in zip(steps, perStepFileIds):

View file

@ -58,14 +58,32 @@ def _getUserMandateIds(userId: str) -> list[str]:
def _getAdminMandateIds(userId: str, mandateIds: list) -> list:
"""Batch-check which mandates the user is admin for (2 SQL queries total)."""
"""Batch-check which mandates the user is admin for (UserMandate → UserMandateRole → Role)."""
if not mandateIds:
return []
rootIface = getRootInterface()
from modules.datamodels.datamodelMembership import UserMandateRole
allRoles = rootIface.db.getRecordset(UserMandateRole, recordFilter={
"userId": userId, "mandateId": mandateIds,
})
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
memberships = rootIface.db.getRecordset(
UserMandate,
recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True},
)
if not memberships:
return []
umIdToMandateId: dict[str, str] = {}
for m in memberships:
row = m if isinstance(m, dict) else m.__dict__
um_id = row.get("id")
mid = row.get("mandateId")
if um_id and mid:
umIdToMandateId[str(um_id)] = str(mid)
userMandateIds = list(umIdToMandateId.keys())
allRoles = rootIface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateIds},
)
if not allRoles:
return []
@ -74,23 +92,26 @@ def _getAdminMandateIds(userId: str, mandateIds: list) -> list:
for r in allRoles:
row = r if isinstance(r, dict) else r.__dict__
rid = row.get("roleId")
mid = row.get("mandateId")
if rid:
um_id = row.get("userMandateId")
mid = umIdToMandateId.get(str(um_id)) if um_id else None
if rid and mid:
roleIds.add(rid)
roleToMandate.setdefault(rid, set()).add(mid)
if not roleIds:
return []
from modules.datamodels.datamodelRbac import MandateRole
roleRecords = rootIface.db.getRecordset(MandateRole, recordFilter={"id": list(roleIds)})
from modules.datamodels.datamodelRbac import Role
roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)})
adminMandates: set = set()
for role in (roleRecords or []):
row = role if isinstance(role, dict) else role.__dict__
if row.get("isAdmin"):
rid = row.get("id")
if rid and rid in roleToMandate:
adminMandates.update(roleToMandate[rid])
rid = row.get("id")
if not rid or rid not in roleToMandate:
continue
# Same rule as routeBilling._isAdminOfMandate / notifyMandateAdmins
if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"):
adminMandates.update(roleToMandate[rid])
return [mid for mid in mandateIds if mid in adminMandates]

View file

@ -73,7 +73,30 @@ class PdfExtractor(Extractor):
))
return parts
# Extract text per page with PyMuPDF (same lib as in-place search - ensures extraction matches PDF text layer)
file_name = context.get("fileName", "document.pdf")
ordered_ok = False
try:
doc = fitz.open(stream=fileBytes, filetype="pdf")
for page_index in range(len(doc)):
page = doc[page_index]
page_parts = self._extract_page_blocks_in_reading_order(
page,
doc,
page_index=page_index,
root_id=rootId,
file_name=file_name,
)
if page_parts:
parts.extend(page_parts)
ordered_ok = True
doc.close()
except Exception:
ordered_ok = False
if ordered_ok and any(getattr(p, "typeGroup", "") in ("text", "image") for p in parts):
return parts
parts = [parts[0]] # keep container only; fall back below
try:
doc = fitz.open(stream=fileBytes, filetype="pdf")
for i in range(len(doc)):
@ -174,4 +197,196 @@ class PdfExtractor(Extractor):
return parts
@staticmethod
def _text_from_text_block(block: Dict[str, Any]) -> str:
lines_out: List[str] = []
for line in block.get("lines") or []:
if not isinstance(line, dict):
continue
spans = line.get("spans") or []
line_text = "".join(
str(span.get("text") or "")
for span in spans
if isinstance(span, dict)
)
lines_out.append(line_text)
return "\n".join(lines_out).strip()
@staticmethod
def _bbox_center(bbox: Any) -> tuple[float, float]:
if not isinstance(bbox, (list, tuple)) or len(bbox) < 4:
return 0.0, 0.0
x0, y0, x1, y1 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])
return (x0 + x1) / 2.0, (y0 + y1) / 2.0
@staticmethod
def _point_inside_bbox(x: float, y: float, bbox: Any) -> bool:
if not isinstance(bbox, (list, tuple)) or len(bbox) < 4:
return False
x0, y0, x1, y1 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])
return x0 <= x <= x1 and y0 <= y <= y1
def _extract_page_blocks_in_reading_order(
self,
page: Any,
doc: Any,
*,
page_index: int,
root_id: str,
file_name: str,
) -> List[ContentPart]:
"""Emit text/image/table parts in on-page reading order (top-to-bottom, left-to-right)."""
entries: List[tuple[float, float, str, Dict[str, Any]]] = []
table_bboxes: List[Any] = []
try:
table_finder = page.find_tables()
for ti, tab in enumerate(getattr(table_finder, "tables", []) or []):
try:
matrix = tab.extract()
except Exception:
matrix = None
if not matrix:
continue
csv_data = self._rows_to_csv_payload(matrix)
if not csv_data.strip():
continue
bbox = getattr(tab, "bbox", None)
if bbox is not None:
table_bboxes.append(bbox)
cy, cx = self._bbox_center(bbox)
entries.append((cy, cx, "table", {
"label": f"table_{page_index + 1}_{ti}",
"data": csv_data,
"table_index": ti,
}))
except Exception:
pass
try:
page_dict = page.get_text("dict", sort=True)
except Exception:
page_dict = None
blocks = page_dict.get("blocks") if isinstance(page_dict, dict) else None
if isinstance(blocks, list):
text_block_no = 0
image_no = 0
for block in blocks:
if not isinstance(block, dict):
continue
bbox = block.get("bbox")
cy, cx = self._bbox_center(bbox)
btype = block.get("type")
if btype == 0:
if any(self._point_inside_bbox(cx, cy, tb) for tb in table_bboxes):
continue
text = self._text_from_text_block(block)
if not text:
continue
label = f"page_{page_index + 1}" if text_block_no == 0 else f"page_{page_index + 1}_t{text_block_no}"
entries.append((cy, cx, "text", {
"label": label,
"data": text,
"text_block_no": text_block_no,
}))
text_block_no += 1
continue
if btype != 1:
continue
img_bytes = block.get("image")
ext = str(block.get("ext") or "png").lower()
mime = f"image/{ext}"
if not img_bytes:
xref = block.get("xref")
if xref is not None:
try:
extracted = doc.extract_image(int(xref))
img_bytes = extracted.get("image", b"")
ext = str(extracted.get("ext") or ext).lower()
mime = f"image/{ext}"
except Exception:
img_bytes = b""
if not img_bytes:
continue
entries.append((cy, cx, "image", {
"label": f"image_{page_index + 1}_{image_no}",
"mime": mime,
"bytes": img_bytes,
"image_no": image_no,
}))
image_no += 1
entries.sort(key=lambda item: (item[0], item[1]))
out: List[ContentPart] = []
for _y, _x, kind, payload in entries:
if kind == "text":
tbno = int(payload.get("text_block_no") or 0)
text = str(payload.get("data") or "")
out.append(ContentPart(
id=makeId(),
parentId=root_id,
label=str(payload.get("label") or f"page_{page_index + 1}"),
typeGroup="text",
mimeType="text/plain",
data=text,
metadata={
"pages": 1,
"pageIndex": page_index,
"size": len(text.encode("utf-8")),
"contextRef": {
"containerPath": file_name,
"location": f"page:{page_index + 1}/block:{tbno}",
"pageIndex": page_index,
},
},
))
elif kind == "table":
ti = int(payload.get("table_index") or 0)
csv_data = str(payload.get("data") or "")
out.append(ContentPart(
id=makeId(),
parentId=root_id,
label=str(payload.get("label") or f"table_{page_index + 1}_{ti}"),
typeGroup="table",
mimeType="text/csv",
data=csv_data,
metadata={
"pageIndex": page_index,
"size": len(csv_data.encode("utf-8")),
"contextRef": {
"containerPath": file_name,
"location": f"page:{page_index + 1}/table:{ti}",
"pageIndex": page_index,
},
},
))
elif kind == "image":
ino = int(payload.get("image_no") or 0)
img_bytes = payload.get("bytes") or b""
mime = str(payload.get("mime") or "image/png")
out.append(ContentPart(
id=makeId(),
parentId=root_id,
label=str(payload.get("label") or f"image_{page_index + 1}_{ino}"),
typeGroup="image",
mimeType=mime,
data=base64.b64encode(img_bytes).decode("utf-8"),
metadata={
"pageIndex": page_index,
"size": len(img_bytes),
"contextRef": {
"containerPath": file_name,
"location": f"page:{page_index + 1}/image:{ino}",
"pageIndex": page_index,
},
},
))
return out
@staticmethod
def _rows_to_csv_payload(rows: List[List[Any]]) -> str:
lines: List[str] = []
for row in rows:
cells = [str(c or "").replace('"', '""') for c in row]
lines.append(",".join(f'"{c}"' for c in cells))
return "\n".join(lines)

View file

@ -6,7 +6,7 @@ Markdown renderer for report generation.
from .documentRendererBaseTemplate import BaseRenderer
from modules.datamodels.datamodelDocument import RenderedDocument
from typing import Dict, Any, List, Optional
from typing import Any, Dict, List, Optional
class RendererMarkdown(BaseRenderer):
"""Renders content to Markdown format with format-specific extraction."""
@ -33,12 +33,72 @@ class RendererMarkdown(BaseRenderer):
@classmethod
def getAcceptedSectionTypes(cls, formatName: Optional[str] = None) -> List[str]:
"""
Return list of section content types that Markdown renderer accepts.
Markdown renderer accepts all section types except images.
"""Markdown accepts all section types including images.
Images are emitted as sibling files (``extract_media_.png``) with
``![alt](filename)`` relative links in the ``.md`` same pattern as
``RendererHtml`` (main document + sidecar assets).
"""
from modules.datamodels.datamodelJson import supportedSectionTypes
return [st for st in supportedSectionTypes if st != "image"]
return list(supportedSectionTypes)
def _collectImageDocuments(self, jsonContent: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Extract image sections into sidecar file payloads for markdown export."""
import base64 as _b64
out: List[Dict[str, Any]] = []
documents = jsonContent.get("documents")
if not isinstance(documents, list):
raise ValueError("extractedContent.documents must be a list")
for doc in documents:
if not isinstance(doc, dict):
continue
for section in doc.get("sections") or []:
if not isinstance(section, dict):
continue
if section.get("content_type") != "image":
continue
for element in section.get("elements") or []:
if not isinstance(element, dict):
raise ValueError("image section element must be a dict")
content = element.get("content")
if not isinstance(content, dict):
raise ValueError("image section element missing content dict")
b64 = content.get("base64Data")
if not isinstance(b64, str) or not b64:
raise ValueError(
"image section missing base64Data — markdown export "
"requires binary payload to write sidecar image files"
)
alt = content.get("altText")
if not isinstance(alt, str) or not alt.strip():
raise ValueError("image section missing altText")
mime = content.get("mimeType")
if not isinstance(mime, str) or not mime.strip().startswith("image/"):
raise ValueError("image section missing mimeType")
fname = content.get("fileName")
if not isinstance(fname, str) or not fname.strip():
raise ValueError("image section missing fileName")
safe_name = "".join(
c if c.isalnum() or c in "._-" else "_" for c in fname.strip()
)
if not safe_name:
raise ValueError(f"image fileName sanitized to empty: {fname!r}")
blob = _b64.b64decode(b64, validate=True)
if not blob:
raise ValueError(f"image base64Data decoded to empty bytes ({fname!r})")
out.append({
"filename": safe_name,
"altText": alt.strip(),
"mimeType": mime.strip(),
"bytes": blob,
})
return out
async def render(
self,
@ -49,311 +109,281 @@ class RendererMarkdown(BaseRenderer):
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""Render extracted JSON content to Markdown format."""
"""Render markdown plus sidecar image files (same folder as the ``.md``).
Returns ``[main.md, image1.png, image2.jpg, ]``. Relative ``![alt](file)``
links in the markdown point at those sibling files no API URLs, no
base64 inlined in the markdown text.
"""
_ = style
try:
# Generate markdown from JSON structure
markdownContent = self._generateMarkdownFromJson(extractedContent, title)
# Determine filename from document or title
documents = extractedContent.get("documents", [])
if documents and isinstance(documents[0], dict):
filename = documents[0].get("filename")
if not filename:
filename = self._determineFilename(title, "text/markdown")
else:
filename = self._determineFilename(title, "text/markdown")
# Extract metadata for document type and other info
metadata = extractedContent.get("metadata", {}) if extractedContent else {}
documentType = metadata.get("documentType") if isinstance(metadata, dict) else None
return [
image_docs = self._collectImageDocuments(extractedContent)
markdownContent = self._generateMarkdownFromJson(extractedContent, title)
documents = extractedContent.get("documents") or []
filename: Optional[str] = None
if documents and isinstance(documents[0], dict):
filename = documents[0].get("filename")
if not filename:
filename = self._determineFilename(title, "text/markdown")
metadata = extractedContent.get("metadata") if isinstance(extractedContent, dict) else None
if not isinstance(metadata, dict):
metadata = None
documentType = metadata.get("documentType") if metadata else None
result: List[RenderedDocument] = [
RenderedDocument(
documentData=markdownContent.encode("utf-8"),
mimeType="text/markdown",
filename=filename,
documentType=documentType,
metadata=metadata,
)
]
for img in image_docs:
result.append(
RenderedDocument(
documentData=markdownContent.encode('utf-8'),
mimeType="text/markdown",
filename=filename,
documentType=documentType,
metadata=metadata if isinstance(metadata, dict) else None
documentData=img["bytes"],
mimeType=img["mimeType"],
filename=img["filename"],
)
]
except Exception as e:
self.logger.error(f"Error rendering markdown: {str(e)}")
# Return minimal markdown fallback
fallbackContent = f"# {title}\n\nError rendering report: {str(e)}"
metadata = extractedContent.get("metadata", {}) if extractedContent else {}
documentType = metadata.get("documentType") if isinstance(metadata, dict) else None
return [
RenderedDocument(
documentData=fallbackContent.encode('utf-8'),
mimeType="text/markdown",
filename=self._determineFilename(title, "text/markdown"),
documentType=documentType,
metadata=metadata if isinstance(metadata, dict) else None
)
]
)
return result
def _generateMarkdownFromJson(self, jsonContent: Dict[str, Any], title: str) -> str:
"""Generate markdown content from structured JSON document."""
try:
# Validate JSON structure (standardized schema: {metadata: {...}, documents: [{sections: [...]}]})
if not self._validateJsonStructure(jsonContent):
raise ValueError("JSON content must follow standardized schema: {metadata: {...}, documents: [{sections: [...]}]}")
# Extract sections and metadata from standardized schema
sections = self._extractSections(jsonContent)
metadata = self._extractMetadata(jsonContent)
# Use provided title (which comes from documents[].title) as primary source
# Fallback to metadata.title only if title parameter is empty
documentTitle = title if title else metadata.get("title", "Generated Document")
# Build markdown content
markdownParts = []
# Document title
markdownParts.append(f"# {documentTitle}")
markdownParts.append("")
# Process each section
for section in sections:
sectionMarkdown = self._renderJsonSection(section)
if sectionMarkdown:
markdownParts.append(sectionMarkdown)
markdownParts.append("") # Add spacing between sections
# Add generation info
markdownParts.append("---")
markdownParts.append(f"*Generated: {self._formatTimestamp()}*")
return '\n'.join(markdownParts)
except Exception as e:
self.logger.error(f"Error generating markdown from JSON: {str(e)}")
raise Exception(f"Markdown generation failed: {str(e)}")
if not self._validateJsonStructure(jsonContent):
raise ValueError(
"JSON content must follow standardized schema: "
"{metadata: {...}, documents: [{sections: [...]}]}"
)
sections = self._extractSections(jsonContent)
metadata = self._extractMetadata(jsonContent)
documentTitle = title or (metadata.get("title") if isinstance(metadata, dict) else None)
if not documentTitle:
raise ValueError(
"markdown render: no title given and metadata.title missing — "
"callers must pass an explicit title"
)
markdownParts: List[str] = [f"# {documentTitle}", ""]
for section in sections:
sectionMarkdown = self._renderJsonSection(section)
if sectionMarkdown:
markdownParts.append(sectionMarkdown)
markdownParts.append("")
markdownParts.append("---")
markdownParts.append(f"*Generated: {self._formatTimestamp()}*")
return "\n".join(markdownParts)
def _renderJsonSection(self, section: Dict[str, Any]) -> str:
"""Render a single JSON section to markdown.
Supports three content formats: reference, object (base64), extracted_text.
Errors propagate: unknown section types or malformed payloads must surface,
not be swallowed into a fallback paragraph or ``[Error rendering section]``
marker that hides the real problem.
"""
try:
sectionType = self._getSectionType(section)
sectionData = self._getSectionData(section)
# Check for three content formats from Phase 5D in elements
if isinstance(sectionData, list):
markdownParts = []
for element in sectionData:
element_type = element.get("type", "") if isinstance(element, dict) else ""
# Support three content formats from Phase 5D
if element_type == "reference":
# Document reference format
doc_ref = element.get("documentReference", "")
label = element.get("label", "Reference")
markdownParts.append(f"*[Reference: {label}]*")
continue
elif element_type == "extracted_text":
# Extracted text format
content = element.get("content", "")
source = element.get("source", "")
if content:
source_text = f" *(Source: {source})*" if source else ""
markdownParts.append(f"{content}{source_text}")
continue
# If we processed reference/extracted_text elements, return them
if markdownParts:
return '\n\n'.join(markdownParts)
if sectionType == "table":
# Work directly with elements like other renderers
if isinstance(sectionData, list) and sectionData:
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
return self._renderJsonTable(element)
return ""
elif sectionType == "bullet_list":
# Work directly with elements like other renderers
if isinstance(sectionData, list) and sectionData:
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
return self._renderJsonBulletList(element)
return ""
elif sectionType == "heading":
# Work directly with elements like other renderers
if isinstance(sectionData, list) and sectionData:
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
return self._renderJsonHeading(element)
return ""
elif sectionType == "paragraph":
# Work directly with elements like other renderers
if isinstance(sectionData, list) and sectionData:
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
return self._renderJsonParagraph(element)
elif isinstance(sectionData, dict):
return self._renderJsonParagraph(sectionData)
return ""
elif sectionType == "code_block":
# Work directly with elements like other renderers
if isinstance(sectionData, list) and sectionData:
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
return self._renderJsonCodeBlock(element)
return ""
elif sectionType == "image":
# Work directly with elements like other renderers
if isinstance(sectionData, list) and sectionData:
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
return self._renderJsonImage(element)
return ""
else:
# Fallback to paragraph for unknown types
if isinstance(sectionData, list) and sectionData:
element = sectionData[0] if isinstance(sectionData[0], dict) else {}
return self._renderJsonParagraph(element)
elif isinstance(sectionData, dict):
return self._renderJsonParagraph(sectionData)
return ""
except Exception as e:
self.logger.warning(f"Error rendering section {self._getSectionId(section)}: {str(e)}")
return f"*[Error rendering section: {str(e)}]*"
sectionType = self._getSectionType(section)
sectionData = self._getSectionData(section)
if isinstance(sectionData, list):
markdownParts: List[str] = []
for element in sectionData:
element_type = element.get("type", "") if isinstance(element, dict) else ""
if element_type == "reference":
label = element.get("label", "Reference")
markdownParts.append(f"*[Reference: {label}]*")
continue
if element_type == "extracted_text":
content = element.get("content", "")
source = element.get("source", "")
if content:
source_text = f" *(Source: {source})*" if source else ""
markdownParts.append(f"{content}{source_text}")
continue
if markdownParts:
return "\n\n".join(markdownParts)
def _first_element(data: Any) -> Dict[str, Any]:
if isinstance(data, list) and data and isinstance(data[0], dict):
return data[0]
if isinstance(data, dict):
return data
raise ValueError(
f"section type {sectionType!r} expects elements list / dict, got {type(data).__name__}"
)
if sectionType == "table":
return self._renderJsonTable(_first_element(sectionData))
if sectionType == "bullet_list":
return self._renderJsonBulletList(_first_element(sectionData))
if sectionType == "heading":
return self._renderJsonHeading(_first_element(sectionData))
if sectionType == "paragraph":
return self._renderJsonParagraph(_first_element(sectionData))
if sectionType == "code_block":
return self._renderJsonCodeBlock(_first_element(sectionData))
if sectionType == "image":
return self._renderJsonImage(_first_element(sectionData))
raise ValueError(
f"unsupported section content_type {sectionType!r} "
f"(section id={self._getSectionId(section)!r})"
)
def _renderJsonTable(self, tableData: Dict[str, Any]) -> str:
"""Render a JSON table to markdown."""
try:
# Extract from nested content structure: element.content.{headers, rows}
content = tableData.get("content", {})
if not isinstance(content, dict):
return ""
headers = content.get("headers", [])
rows = content.get("rows", [])
if not headers or not rows:
return ""
markdownParts = []
# Create table header
headerLine = " | ".join(str(header) for header in headers)
markdownParts.append(headerLine)
# Add separator line
separatorLine = " | ".join("---" for _ in headers)
markdownParts.append(separatorLine)
# Add data rows
for row in rows:
rowLine = " | ".join(str(cellData) for cellData in row)
markdownParts.append(rowLine)
return '\n'.join(markdownParts)
except Exception as e:
self.logger.warning(f"Error rendering table: {str(e)}")
content = tableData.get("content")
if not isinstance(content, dict):
raise ValueError(
f"table section has invalid content (type={type(content).__name__})"
)
headers = content.get("headers") or []
rows = content.get("rows") or []
if not headers or not rows:
return ""
lines = [
" | ".join(str(h) for h in headers),
" | ".join("---" for _ in headers),
]
for row in rows:
lines.append(" | ".join(str(cell) for cell in row))
return "\n".join(lines)
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"![{value}](image)")
else:
parts.append(value)
return "".join(parts)
def _renderJsonBulletList(self, listData: Dict[str, Any]) -> str:
"""Render a JSON bullet list to markdown."""
try:
# Extract from nested content structure: element.content.{items}
content = listData.get("content", {})
if not isinstance(content, dict):
return ""
items = content.get("items", [])
if not items:
return ""
markdownParts = []
for item in items:
if isinstance(item, str):
markdownParts.append(f"- {item}")
elif isinstance(item, dict) and "text" in item:
markdownParts.append(f"- {item['text']}")
return '\n'.join(markdownParts)
except Exception as e:
self.logger.warning(f"Error rendering bullet list: {str(e)}")
content = listData.get("content")
if not isinstance(content, dict):
raise ValueError(
f"bullet_list section has invalid content (type={type(content).__name__})"
)
items = content.get("items") or []
if not items:
return ""
lines: List[str] = []
for item in items:
if isinstance(item, str):
lines.append(f"- {item}")
elif isinstance(item, list):
lines.append(f"- {self._renderInlineRunsMarkdown(item)}")
elif isinstance(item, dict) and "text" in item:
lines.append(f"- {item['text']}")
else:
raise ValueError(
f"bullet_list item has unsupported shape (type={type(item).__name__})"
)
return "\n".join(lines)
def _renderJsonHeading(self, headingData: Dict[str, Any]) -> str:
"""Render a JSON heading to markdown."""
try:
# Extract from nested content structure: element.content.{text, level}
content = headingData.get("content", {})
if not isinstance(content, dict):
return ""
text = content.get("text", "")
level = content.get("level", 1)
if text:
level = max(1, min(6, level))
md_level = min(6, level + 1)
return f"{'#' * md_level} {text}"
return ""
except Exception as e:
self.logger.warning(f"Error rendering heading: {str(e)}")
return ""
content = headingData.get("content")
if not isinstance(content, dict):
raise ValueError(
f"heading section has invalid content (type={type(content).__name__})"
)
text = content.get("text")
if not isinstance(text, str) or not text:
raise ValueError("heading section has empty 'text'")
level = content.get("level", 1)
if not isinstance(level, int):
raise ValueError(f"heading 'level' must be int, got {type(level).__name__}")
level = max(1, min(6, level))
md_level = min(6, level + 1)
return f"{'#' * md_level} {text}"
def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str:
"""Render a JSON paragraph to markdown."""
try:
# Extract from nested content structure
content = paragraphData.get("content", {})
if isinstance(content, dict):
text = content.get("text", "")
elif isinstance(content, str):
text = content
else:
text = ""
return text if text else ""
except Exception as e:
self.logger.warning(f"Error rendering paragraph: {str(e)}")
return ""
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):
runs = self._inlineRunsFromContent(content)
if runs:
return self._renderInlineRunsMarkdown(runs)
text = content.get("text", "")
return text if isinstance(text, str) else ""
if isinstance(content, str):
return content
raise ValueError(
f"paragraph section has invalid content (type={type(content).__name__})"
)
def _renderJsonCodeBlock(self, codeData: Dict[str, Any]) -> str:
"""Render a JSON code block to markdown."""
try:
# Extract from nested content structure
content = codeData.get("content", {})
if not isinstance(content, dict):
return ""
code = content.get("code", "")
language = content.get("language", "")
if code:
if language:
return f"```{language}\n{code}\n```"
else:
return f"```\n{code}\n```"
return ""
except Exception as e:
self.logger.warning(f"Error rendering code block: {str(e)}")
return ""
content = codeData.get("content")
if not isinstance(content, dict):
raise ValueError(
f"code_block section has invalid content (type={type(content).__name__})"
)
code = content.get("code")
if not isinstance(code, str) or not code:
raise ValueError("code_block section has empty 'code'")
language = content.get("language") or ""
return f"```{language}\n{code}\n```" if language else f"```\n{code}\n```"
def _renderJsonImage(self, imageData: Dict[str, Any]) -> str:
"""Render a JSON image to markdown."""
try:
# Extract from nested content structure: element.content.{base64Data, altText, caption}
content = imageData.get("content", {})
if not isinstance(content, dict):
return ""
altText = content.get("altText", "Image")
base64Data = content.get("base64Data", "")
if base64Data:
# For base64 images, we can't embed them directly in markdown
# So we'll use a placeholder with the alt text
return f"![{altText}](data:image/png;base64,{base64Data[:50]}...)"
else:
return f"![{altText}](image-placeholder)"
except Exception as e:
self.logger.warning(f"Error rendering image: {str(e)}")
return f"![{imageData.get('altText', 'Image')}](image-error)"
"""Render image as relative ``![alt](fileName)`` link to a sidecar file."""
content = imageData.get("content")
if not isinstance(content, dict):
raise ValueError(
f"image section has invalid content (type={type(content).__name__})"
)
altText = content.get("altText")
if not isinstance(altText, str) or not altText.strip():
raise ValueError("image section is missing 'altText'")
fileName = content.get("fileName")
if not isinstance(fileName, str) or not fileName.strip():
raise ValueError("image section is missing 'fileName' for relative markdown link")
safe_name = "".join(
c if c.isalnum() or c in "._-" else "_" for c in fileName.strip()
)
if not safe_name:
raise ValueError(f"image fileName sanitized to empty: {fileName!r}")
return f"![{altText.strip()}]({safe_name})"

View file

@ -670,7 +670,7 @@ class RendererPdf(BaseRenderer):
runType = run.get("type", "text")
value = self._escapeReportlabXml(run.get("value", ""))
if runType == "text":
parts.append(value)
parts.append(value.replace("\n", "<br/>"))
elif runType == "bold":
parts.append(f"<b>{value}</b>")
elif runType == "italic":
@ -691,6 +691,7 @@ class RendererPdf(BaseRenderer):
if not text:
return ""
s = self._escapeReportlabXml(text)
s = s.replace("\n", "<br/>")
s = _re_pdf.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", s, flags=_re_pdf.DOTALL)
s = _re_pdf.sub(r"__(.+?)__", r"<b>\1</b>", s, flags=_re_pdf.DOTALL)
s = _re_pdf.sub(r"(?<!\*)\*([^*\n]+?)\*(?!\*)", r"<i>\1</i>", s)

View file

@ -4,10 +4,76 @@ import json
import logging
import os
import re
from typing import Any, Dict
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
_MAX_AUTO_TABLE_COLS = 64
_MAX_AUTO_TABLE_ROWS = 5000
_MAX_AUTO_CELL_CHARS = 8000
def _sanitize_cell_for_pipe_table(cell: str) -> str:
"""Single-line cell safe for markdown pipe tables (no raw ``|``)."""
s = str(cell).replace("\r\n", "\n").replace("\r", "\n")
s = " ".join(line.strip() for line in s.split("\n") if line.strip()).strip()
return s.replace("|", "·")
def _try_delimited_block_as_markdown_table(block: str) -> Optional[str]:
"""If ``block`` is a uniform tab- or semicolon-separated grid, return a pipe markdown table."""
lines = [ln.strip() for ln in block.replace("\r\n", "\n").replace("\r", "\n").split("\n")]
lines = [ln for ln in lines if ln]
if len(lines) < 2:
return None
for sep in ("\t", ";"):
rows: List[List[str]] = []
bad = False
for ln in lines:
cells = [c.strip() for c in ln.split(sep)]
if len(cells) < 2:
bad = True
break
rows.append(cells)
if bad:
continue
ncols = len(rows[0])
if ncols > _MAX_AUTO_TABLE_COLS or len(rows) > _MAX_AUTO_TABLE_ROWS:
continue
if any(len(r) != ncols for r in rows):
continue
if any(len(_sanitize_cell_for_pipe_table(c)) > _MAX_AUTO_CELL_CHARS for r in rows for c in r):
continue
def _row_md(r: List[str]) -> str:
return "| " + " | ".join(_sanitize_cell_for_pipe_table(c) for c in r) + " |"
header = _row_md(rows[0])
divider = "| " + " | ".join(["---"] * ncols) + " |"
body = "\n".join(_row_md(r) for r in rows[1:])
return "\n".join([header, divider, body])
return None
def enhancePlainTextWithMarkdownTables(body: str) -> str:
"""Detect delimiter-separated grids in plain paragraphs and convert them to markdown pipe tables.
Extractors often emit CSV-like blocks (``;`` or TAB) without markdown markers; passing those
straight into ``markdownToDocumentJson`` produced one giant paragraph. This pass runs only
on whitespace-separated blocks so normal prose stays unchanged.
"""
if not isinstance(body, str) or not body.strip():
return body if isinstance(body, str) else ""
chunks = re.split(r"\n\s*\n", body.strip())
out_parts: List[str] = []
for ch in chunks:
ch = ch.strip()
if not ch:
continue
md_table = _try_delimited_block_as_markdown_table(ch)
out_parts.append(md_table if md_table else ch)
return "\n\n".join(out_parts)
def _parseInlineRuns(text: str) -> list:
"""

View file

@ -19,6 +19,12 @@ def _resolveLogDir() -> str:
logDir = os.path.join(gatewayDir, logDir)
return logDir
def resolve_app_log_dir() -> str:
"""Absolute filesystem path for ``APP_LOGGING_LOG_DIR``."""
return _resolveLogDir()
def ensureDir(path: str) -> None:
"""Create directory if it does not exist."""
os.makedirs(path, exist_ok=True)

View file

@ -88,6 +88,15 @@ class FrontendType(str, Enum):
FILTER_EXPRESSION = "filterExpression"
"""Filter expression builder for data.filter"""
CONTEXT_BUILDER = "contextBuilder"
"""Upstream handover picker (graph editor): DataRef / path selection from prior nodes."""
CONTEXT_ASSIGNMENTS = "contextAssignments"
"""Context set assignments: target key, picker | literal | human task (graph editor)."""
USER_FILE_FOLDER = "userFileFolder"
"""User file storage folder (graph editor): browse My Files tree or create folders."""
# Mapping of custom types to their API endpoint for dynamic options
CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,14 @@
# 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
# when the param is empty (same idea as IOExecutor wire fill).
# ``materializeConnectionRefs`` (see pickNotPushMigration) may still rewrite empty connectionReference at run start.
# Typed port system: parameters resolve via DataRefs / static values. Declarative port inheritance
# uses ``graphInherit`` on parameter definitions in node JSON (see STATIC_NODE_TYPES): e.g.
# ``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 logging
import re
@ -16,12 +20,125 @@ from modules.features.graphicalEditor.portTypes import (
)
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
build_presentation_envelope_from_plain_text,
presentation_dict_without_meta,
presentation_response_text,
)
logger = logging.getLogger(__name__)
_FILE_CREATE_CTX_LOG_MAX = 500
def _attach_unified_presentation_data(out: Dict[str, Any], *, node_def: Dict[str, Any]) -> None:
"""Ensure ``out[\"data\"]`` carries ``context.extractContent.presentation.v1`` for ``file.create``."""
if node_def.get("skipUnifiedPresentation"):
return
data = out.get("data")
if isinstance(data, dict) and data.get("kind") == PRESENTATION_KIND:
return
text = str(out.get("response") or "").strip()
if not text and isinstance(data, dict):
text = str(data.get("response") or "").strip()
if not text:
return
pres = build_presentation_envelope_from_plain_text(text, source_name=node_type or "content")
if not pres:
return
meta: Dict[str, Any] = {"actionType": node_type}
if isinstance(data, dict):
prev = data.get("_meta")
if isinstance(prev, dict):
meta = {**prev, **meta}
out["data"] = {**pres, "_meta": meta}
def _truncate_for_log(val: Any, max_len: int = _FILE_CREATE_CTX_LOG_MAX) -> str:
s = val if isinstance(val, str) else repr(val)
s = s.replace("\r", "\\r").replace("\n", "\\n")
if len(s) <= max_len:
return s
return s[:max_len] + f"...<{len(s)} chars>"
def _log_file_create_context_resolution(
node_id: str,
raw_params: Dict[str, Any],
resolved_params: Dict[str, Any],
exec_context: Dict[str, Any],
) -> None:
"""Debug ``file.create`` when ``context`` resolves empty — trace refs and upstream output."""
raw_c = raw_params.get("context")
res_c = resolved_params.get("context")
node_outputs = exec_context.get("nodeOutputs") or {}
input_sources = (exec_context.get("inputSources") or {}).get(node_id) or {}
src_entry = input_sources.get(0)
src_id = src_entry[0] if src_entry else None
upstream = node_outputs.get(src_id) if src_id else None
up_summary = "missing"
up_resp_len = -1
up_transit = False
if isinstance(upstream, dict):
up_transit = bool(upstream.get("_transit"))
inner = upstream.get("data") if up_transit else upstream
up_keys = sorted(k for k in upstream.keys() if not str(k).startswith("_") or k in ("_transit", "_success"))
up_resp_len = len(str((inner if isinstance(inner, dict) else upstream).get("response") or ""))
up_summary = "keys=%s transit=%s response_len=%s _success=%s" % (
up_keys[:25],
up_transit,
up_resp_len,
upstream.get("_success"),
)
def _shape(name: str, v: Any) -> str:
if v is None:
return f"{name}=None"
if isinstance(v, dict) and v.get("type") == "ref":
return f"{name}=ref(nodeId={v.get('nodeId')!r}, path={v.get('path')!r})"
if isinstance(v, list):
if v and all(isinstance(x, dict) and x.get("type") == "ref" for x in v):
bits = [
f"ref({x.get('nodeId')!r},{x.get('path')!r})"
for x in v[:5]
]
return f"{name}=contextBuilder[{len(v)} refs: {', '.join(bits)}{'' if len(v) > 5 else ''}]"
return f"{name}=list(len={len(v)}, elem0_type={type(v[0]).__name__})"
if isinstance(v, str):
return f"{name}=str(len={len(v)}, preview={_truncate_for_log(v, 240)!r})"
return f"{name}={type(v).__name__}({_truncate_for_log(v)!r})"
logger.info(
"file.create context resolution node=%s port0=%r upstream_node=%s upstream: %s | %s | %s",
node_id,
src_id,
src_id,
up_summary,
_shape("raw", raw_c),
_shape("resolved", res_c),
)
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]:
"""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:
return None
if isinstance(raw, bytes):
@ -33,11 +150,67 @@ def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]:
b = raw.tobytes()
return b if len(b) > 0 else None
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 None
def _image_documents_from_docs_list(docs_list: list) -> list:
"""All image/* ActionDocument dicts (generic — no assumptions about index 0)."""
return [
d for d in (docs_list or [])
if isinstance(d, dict) and str(d.get("mimeType") or "").strip().lower().startswith("image/")
]
def _image_refs_from_extract_node_data(extract_data: Any) -> list:
"""Synthetic image document dicts from ``context.extractContent`` ``_meta.persistedImageArtifacts``."""
if not isinstance(extract_data, dict):
return []
meta = extract_data.get("_meta")
if not isinstance(meta, dict):
return []
arts = meta.get("persistedImageArtifacts")
if not isinstance(arts, list):
return []
out: list = []
for a in arts:
if not isinstance(a, dict):
continue
fid = a.get("fileId")
if not fid:
continue
out.append(
{
"documentName": a.get("fileName") or f"extract_image_{fid}",
"mimeType": str(a.get("mimeType") or "application/octet-stream"),
"documentData": None,
"fileId": str(fid),
"_hasBinaryData": True,
"validationMetadata": {
"actionType": "context.extractContent",
"handoverRole": "extractedMedia",
"suppressInWorkflowFileLists": True,
"sourcePartId": a.get("sourcePartId"),
},
}
)
return out
_USER_CONNECTION_ID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
@ -174,6 +347,13 @@ def _buildConnectionRefDict(connRef: str, chatService, services) -> Optional[Dic
return {"id": conn_id, "authority": authority, "label": label or f"{authority}:{user}"}
def _schemaCarriesConnectionProvenance(outputSchema: str) -> bool:
"""True iff the port schema declares ``carriesConnectionProvenance`` in the catalog."""
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG
schema = PORT_TYPE_CATALOG.get(outputSchema)
return bool(getattr(schema, "carriesConnectionProvenance", False))
def _attachConnectionProvenance(
out: Dict[str, Any],
resolvedParams: Dict[str, Any],
@ -187,7 +367,7 @@ def _attachConnectionProvenance(
cref = resolvedParams.get("connectionReference")
if not cref:
return
if outputSchema not in ("FileList", "DocumentList", "EmailList", "TaskList", "EmailDraft", "UdmDocument"):
if not _schemaCarriesConnectionProvenance(outputSchema):
return
payload = _buildConnectionRefDict(str(cref), chatService, services)
if payload:
@ -203,8 +383,7 @@ def _resolveConnectionParam(params: Dict, chatService, services) -> None:
params["connectionReference"] = resolved
def _applyEmailCheckFilter(params: Dict) -> None:
"""Build filter from discrete email params for email.checkEmail."""
def _mapper_emailCheckFilter(params: Dict, **_) -> None:
built = _buildEmailFilter(
fromAddress=params.get("fromAddress"),
subjectContains=params.get("subjectContains"),
@ -216,8 +395,7 @@ def _applyEmailCheckFilter(params: Dict) -> None:
params.pop(k, None)
def _applyEmailSearchQuery(params: Dict) -> None:
"""Build query from discrete email params for email.searchEmail."""
def _mapper_emailSearchQuery(params: Dict, **_) -> None:
built = _buildSearchQuery(
query=params.get("query"),
fromAddress=params.get("fromAddress"),
@ -232,6 +410,56 @@ def _applyEmailSearchQuery(params: Dict) -> None:
params.pop(k, None)
def _mapper_aiPromptLegacyAlias(params: Dict, **_) -> None:
"""Backwards-compatible alias: legacy ``prompt`` parameter is exposed as ``aiPrompt``."""
if "aiPrompt" not in params and "prompt" in params:
params["aiPrompt"] = params.pop("prompt")
def _mapper_emailDraftContextFromSubjectBody(params: Dict, **_) -> None:
"""Build ``context`` from discrete subject + body fields and drop them."""
subject = params.get("subject", "")
body = params.get("body", "")
if not (subject or body):
return
parts = []
if subject:
parts.append(f"Subject: {subject}")
if body:
parts.append(f"Body:\n{body}")
params["context"] = "\n\n".join(parts)
params.pop("subject", None)
params.pop("body", None)
def _mapper_clickupTaskUpdateMerge(params: Dict, **_) -> None:
from modules.workflows.automation2.clickupTaskUpdateMerge import merge_clickup_task_update_entries
merge_clickup_task_update_entries(params)
_PARAM_MAPPERS: Dict[str, Any] = {
"emailCheckFilter": _mapper_emailCheckFilter,
"emailSearchQuery": _mapper_emailSearchQuery,
"aiPromptLegacyAlias": _mapper_aiPromptLegacyAlias,
"emailDraftContextFromSubjectBody": _mapper_emailDraftContextFromSubjectBody,
"clickupTaskUpdateMerge": _mapper_clickupTaskUpdateMerge,
}
def _applyParamMappers(nodeDef: Dict[str, Any], resolvedParams: Dict[str, Any]) -> None:
"""Run declared ``paramMappers`` from the node definition (no node-id branching)."""
mappers = nodeDef.get("paramMappers") or []
for name in mappers:
fn = _PARAM_MAPPERS.get(name)
if not fn:
logger.warning("Unknown paramMapper %r — node %s; skipping", name, nodeDef.get("id"))
continue
try:
fn(resolvedParams)
except Exception as e:
logger.warning("paramMapper %r failed for node %s: %s", name, nodeDef.get("id"), e)
def _getOutputSchemaName(nodeDef: Dict) -> str:
"""Get the output schema name from the node definition."""
outputPorts = nodeDef.get("outputPorts", {})
@ -239,76 +467,55 @@ def _getOutputSchemaName(nodeDef: Dict) -> str:
return port0.get("schema", "ActionResult")
def _extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]:
def _resolveUpstreamPayload(nodeId: str, context: Dict[str, Any]) -> Any:
"""Return the unwrapped output of the primary inbound wire to ``nodeId``.
Prefer logical input port 0. Some persisted graphs register the only edge
under a non-zero ``targetInput`` fall back to the sole inbound port or
the first ``connectionMap`` entry so ``injectUpstreamPayload`` (e.g.
``context.mergeContext`` after ``flow.loop``) still receives data.
"""
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:
from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port
nodeOutputs = context.get("nodeOutputs") or {}
connectionMap = context.get("connectionMap") or {}
src_map = (context.get("inputSources") or {}).get(nodeId) or {}
entry = src_map.get(0)
if not entry and src_map:
if len(src_map) == 1:
entry = next(iter(src_map.values()))
else:
mi = min(src_map.keys())
entry = src_map.get(mi)
if not entry and connectionMap.get(nodeId):
inc = connectionMap[nodeId]
if inc:
src_node_id, _so, _ti = inc[0]
entry = (src_node_id, _so)
if not entry:
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
src_node_id, src_out = entry
upstream = nodeOutputs.get(src_node_id)
return unwrap_transit_for_port(upstream, src_out)
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
def _resolveBranchInputs(nodeId: str, context: Dict[str, Any]) -> Dict[int, Any]:
"""Return ``Dict[port_index → unwrapped upstream output]`` for every wired input port."""
from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port
src_map = (context.get("inputSources") or {}).get(nodeId) or {}
nodeOutputs = context.get("nodeOutputs") or {}
out: Dict[int, Any] = {}
for port_ix, entry in src_map.items():
if not entry:
continue
src_node_id, src_out = entry
upstream = nodeOutputs.get(src_node_id)
if upstream is None:
continue
out[int(port_ix)] = unwrap_transit_for_port(upstream, src_out)
return out
class ActionNodeExecutor:
@ -323,7 +530,11 @@ class ActionNodeExecutor:
context: Dict[str, Any],
) -> Any:
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
nodeType = node.get("type", "")
@ -343,7 +554,12 @@ class ActionNodeExecutor:
# 1. Resolve parameters (DataRef, SystemVar, Static)
params = dict(node.get("parameters") or {})
logger.debug("ActionNodeExecutor node %s raw params keys=%s", nodeId, list(params.keys()))
resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {}))
resolvedParams = resolveParameterReferences(
params,
context.get("nodeOutputs", {}),
consumer_node_id=nodeId,
input_sources=context.get("inputSources"),
)
logger.debug("ActionNodeExecutor node %s resolved params keys=%s documentList_present=%s documentList_type=%s", nodeId, list(resolvedParams.keys()), "documentList" in resolvedParams, type(resolvedParams.get("documentList")).__name__)
# 2. Apply defaults from parameter definitions
@ -352,29 +568,45 @@ class ActionNodeExecutor:
if pName and pName not in resolvedParams and "default" in pDef:
resolvedParams[pName] = pDef["default"]
_param_names = {p.get("name") for p in nodeDef.get("parameters", []) if p.get("name")}
if "documentList" in _param_names and _document_list_param_is_empty(resolvedParams.get("documentList")):
for pDef in nodeDef.get("parameters") or []:
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 {}
_entry = _src_map.get(0)
if _entry:
_src_node_id, _ = _entry
_upstream = (context.get("nodeOutputs") or {}).get(_src_node_id)
_wired = _extract_wired_document_list(_upstream)
if _wired:
resolvedParams["documentList"] = _wired
_entry = _src_map.get(port_ix)
if not _entry:
continue
_src_node_id, _ = _entry
_upstream = (context.get("nodeOutputs") or {}).get(_src_node_id)
_wired = extract_wired_document_list(_upstream)
if _wired:
resolvedParams[pname] = _wired
# 3. Resolve connectionReference
chatService = getattr(self.services, "chat", None)
_resolveConnectionParam(resolvedParams, chatService, self.services)
# 4. Node-type-specific param transformations
if nodeType == "email.checkEmail":
_applyEmailCheckFilter(resolvedParams)
elif nodeType == "email.searchEmail":
_applyEmailSearchQuery(resolvedParams)
elif nodeType == "clickup.updateTask":
from modules.workflows.automation2.clickupTaskUpdateMerge import merge_clickup_task_update_entries
merge_clickup_task_update_entries(resolvedParams)
# 3b. Optional graph-level injections declared on the node definition.
# - injectUpstreamPayload: True → ``_upstreamPayload`` (port 0 source output, transit-unwrapped)
# - injectBranchInputs: True → ``_branchInputs`` (Dict[port_index, output] for all wired ports)
# - injectRunContext: True → ``_runContext`` (the live execution context dict)
if nodeDef.get("injectUpstreamPayload"):
resolvedParams["_upstreamPayload"] = _resolveUpstreamPayload(nodeId, context)
if nodeDef.get("injectBranchInputs"):
resolvedParams["_branchInputs"] = _resolveBranchInputs(nodeId, context)
if nodeDef.get("injectRunContext"):
resolvedParams["_runContext"] = context
resolvedParams["_workflowNodeId"] = nodeId
# 4. Apply declarative paramMappers from the node definition
_applyParamMappers(nodeDef, resolvedParams)
if nodeDef.get("logContextResolution"):
_log_file_create_context_resolution(nodeId, params, resolvedParams, context)
# 5. email.checkEmail pause for email wait
if nodeType == "email.checkEmail":
@ -391,26 +623,7 @@ class ActionNodeExecutor:
}
raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig)
# 6. AI nodes: normalize legacy "prompt" -> "aiPrompt"
if nodeType == "ai.prompt":
if "aiPrompt" not in resolvedParams and "prompt" in resolvedParams:
resolvedParams["aiPrompt"] = resolvedParams.pop("prompt")
# 7. Build context for email.draftEmail from subject + body
if nodeType == "email.draftEmail":
subject = resolvedParams.get("subject", "")
body = resolvedParams.get("body", "")
if subject or body:
contextParts = []
if subject:
contextParts.append(f"Subject: {subject}")
if body:
contextParts.append(f"Body:\n{body}")
resolvedParams["context"] = "\n\n".join(contextParts)
resolvedParams.pop("subject", None)
resolvedParams.pop("body", None)
# 8. Create progress parent so nested actions have a hierarchy
# 6. Create progress parent so nested actions have a hierarchy
import time as _time
nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(_time.time())}"
chatService = getattr(self.services, "chat", None)
@ -440,10 +653,27 @@ class ActionNodeExecutor:
except Exception:
pass
# 9. Persist generated documents as files and build JSON-safe output
# 7. Persist generated documents as files and build JSON-safe output
_raw_folder_id = resolvedParams.get("folderId")
persist_folder_id: Optional[str] = None
if _raw_folder_id is not None:
_s = str(_raw_folder_id).strip()
if _s:
persist_folder_id = _s
docsList = []
for d in (result.documents or []):
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)
rawBytes = _coerce_document_data_to_bytes(rawData)
if isinstance(dumped, dict) and rawBytes:
@ -470,20 +700,20 @@ class ActionNodeExecutor:
_mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId)
_docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin"
_mimeType = dumped.get("mimeType") or "application/octet-stream"
_fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes)
_fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id)
_mgmt.createFileData(_fileItem.id, rawBytes)
dumped["fileId"] = _fileItem.id
dumped["id"] = _fileItem.id
dumped["fileName"] = _fileItem.fileName
logger.info("Persisted workflow document %s as file %s", _docName, _fileItem.id)
dumped["documentData"] = None
dumped["_hasBinaryData"] = True
except Exception as _fe:
logger.warning("Could not persist workflow document: %s", _fe)
dumped["documentData"] = None
dumped["_hasBinaryData"] = True
docsList.append(dumped)
# Clean DocumentList shape for document nodes (match file.create: documents + count, no AiResult fields)
if outputSchema == "DocumentList" and nodeType in ("ai.generateDocument", "ai.convertDocument"):
# Clean DocumentList shape for document nodes (documents + count, no ActionResult/AiResult noise)
if outputSchema == "DocumentList":
if not result.success:
return _normalizeError(
RuntimeError(str(result.error or "document action failed")),
@ -497,17 +727,14 @@ class ActionNodeExecutor:
return normalizeToSchema(list_out, outputSchema)
extractedContext = ""
if result.documents:
doc = result.documents[0]
raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
if isinstance(raw, bytes):
try:
extractedContext = raw.decode("utf-8").strip()
except (UnicodeDecodeError, ValueError):
extractedContext = ""
elif raw:
extractedContext = str(raw).strip()
rd_early = getattr(result, "data", None)
if isinstance(rd_early, dict):
if rd_early.get("kind") == PRESENTATION_KIND:
extractedContext = presentation_response_text(presentation_dict_without_meta(rd_early)).strip()
else:
_r = rd_early.get("response")
if _r is not None and str(_r).strip():
extractedContext = str(_r).strip()
promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip()
resultData = getattr(result, "data", None)
@ -525,7 +752,7 @@ class ActionNodeExecutor:
"data": dataField,
}
if nodeType.startswith("ai."):
if outputSchema == "AiResult":
out["prompt"] = promptText
out["response"] = extractedContext
inputContext = resolvedParams.get("context")
@ -541,8 +768,38 @@ class ActionNodeExecutor:
out["responseData"] = parsed
except (json.JSONDecodeError, TypeError):
pass
if outputSchema == "AiResult" and result.success:
out["imageDocumentsOnly"] = _image_documents_from_docs_list(docsList)
if nodeType.startswith("clickup.") and result.success and docsList:
if outputSchema == "ActionResult":
# Unified handover: mirror AiResult primary paths for DataRefs / primaryTextRef
inp_ctx = resolvedParams.get("context")
ctx_str = ""
if inp_ctx is not None:
ctx_str = inp_ctx if isinstance(inp_ctx, str) else json.dumps(inp_ctx, ensure_ascii=False, default=str)
out.setdefault("prompt", "")
out.setdefault("context", ctx_str if ctx_str else "")
rsp = str(out.get("response") or "").strip()
if not rsp:
if nodeDef.get("clearResponse"):
out["response"] = ""
else:
out["response"] = extractedContext or ""
if result.success:
img_only = _image_documents_from_docs_list(docsList)
if nodeDef.get("imageDocumentsFromExtractData") and isinstance(result.data, dict):
img_only = list(img_only) + _image_refs_from_extract_node_data(result.data)
if nodeDef.get("imageDocumentsFromMerged") and isinstance(result.data, dict):
# mergeContext packs iterated image sidecars under ``data.merged.imageDocumentsOnly``
# rather than the top-level ``documents`` list which is always empty.
merged_blob = result.data.get("merged")
if isinstance(merged_blob, dict):
merged_imgs = merged_blob.get("imageDocumentsOnly")
if isinstance(merged_imgs, list) and merged_imgs:
img_only = merged_imgs
out["imageDocumentsOnly"] = img_only
if outputSchema == "TaskResult" and result.success and docsList:
try:
d0 = docsList[0] if isinstance(docsList[0], dict) else {}
raw = d0.get("documentData")
@ -554,7 +811,7 @@ class ActionNodeExecutor:
except (json.JSONDecodeError, TypeError, ValueError):
pass
if outputSchema == "ConsolidateResult" and nodeType == "ai.consolidate":
if outputSchema == "ConsolidateResult":
data_dict = result.data if isinstance(getattr(result, "data", None), dict) else {}
cr_out = {
"result": data_dict.get("result", ""),
@ -564,5 +821,22 @@ class ActionNodeExecutor:
_attachConnectionProvenance(cr_out, resolvedParams, outputSchema, chatService, self.services)
return normalizeToSchema(cr_out, outputSchema)
if nodeDef.get("popDocumentsFromOutput"):
out.pop("documents", None)
if outputSchema in ("AiResult", "ActionResult") and result.success:
_attach_unified_presentation_data(out, node_def=nodeDef)
_attachConnectionProvenance(out, resolvedParams, outputSchema, chatService, self.services)
return normalizeToSchema(out, outputSchema)
# When the node declares ``surfaceDataAsTopLevel`` (typical for
# dynamic-schema context nodes whose output keys are graph-defined),
# surface ``data.<key>`` to ``out.<key>`` so DataRefs from downstream
# nodes hit the user-defined keys without needing a ``data.`` prefix.
if nodeDef.get("surfaceDataAsTopLevel") and isinstance(dataField, dict):
for k, v in dataField.items():
if k not in out and not str(k).startswith("_"):
out[k] = v
normalized_schema = outputSchema if isinstance(outputSchema, str) else "Transit"
return normalizeToSchema(out, normalized_schema)

View file

@ -2,8 +2,9 @@
# Flow control node executor (ifElse, switch, loop, merge).
import logging
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from modules.features.graphicalEditor.conditionOperators import apply_condition_operator, resolve_value_kind
from modules.features.graphicalEditor.portTypes import wrapTransit, unwrapTransit
logger = logging.getLogger(__name__)
@ -65,20 +66,29 @@ class FlowExecutor:
nodeId: str,
inputSources: Dict,
) -> Any:
condParam = (node.get("parameters") or {}).get("condition")
params = node.get("parameters") or {}
condParam = params.get("condition")
itemParam = params.get("Item")
inp = self._getInputData(nodeId, {nodeId: inputSources}, nodeOutputs)
ok = self._evalConditionParam(condParam, nodeOutputs)
ok = self._evalConditionParam(condParam, nodeOutputs, item_param=itemParam, node=node)
return wrapTransit(
unwrapTransit(inp) if inp else inp,
{"branch": 0 if ok else 1, "conditionResult": ok},
)
def _evalConditionParam(self, condParam: Any, nodeOutputs: Dict) -> bool:
"""Evaluate condition: structured {type,ref,operator,value} or legacy string/ref."""
def _evalConditionParam(
self,
condParam: Any,
nodeOutputs: Dict,
*,
item_param: Any = None,
node: Optional[Dict] = None,
) -> bool:
"""Evaluate condition: structured {operator,value} with Item dataRef, or legacy."""
if condParam is None:
return False
if isinstance(condParam, dict) and condParam.get("type") == "condition":
return self._evalStructuredCondition(condParam, nodeOutputs)
return self._evalStructuredCondition(condParam, nodeOutputs, item_param=item_param, node=node)
from modules.workflows.automation2.graphUtils import resolveParameterReferences
resolved = resolveParameterReferences(condParam, nodeOutputs)
return self._evalCondition(resolved)
@ -101,57 +111,45 @@ class FlowExecutor:
return None
return current
def _evalStructuredCondition(self, cond: Dict, nodeOutputs: Dict) -> bool:
"""Evaluate structured {ref, operator, value} condition."""
ref = cond.get("ref")
if not ref or ref.get("type") != "ref":
return False
node_id = ref.get("nodeId")
path = ref.get("path") or []
left = self._get_by_path(nodeOutputs.get(node_id), list(path))
def _evalStructuredCondition(
self,
cond: Dict,
nodeOutputs: Dict,
*,
item_param: Any = None,
node: Optional[Dict] = None,
) -> bool:
"""Evaluate structured {operator, value} with Item dataRef (legacy: condition.ref)."""
from modules.workflows.automation2.graphUtils import resolveParameterReferences
left_ref = item_param
if left_ref is None or (isinstance(left_ref, dict) and not left_ref):
left_ref = cond.get("ref")
left = resolveParameterReferences(left_ref, nodeOutputs) if left_ref is not None else None
operator = cond.get("operator", "eq")
right = cond.get("value")
if operator == "eq":
return left == right
if operator == "neq":
return left != right
if operator in ("lt", "lte", "gt", "gte"):
try:
l, r = float(left) if left is not None else 0, float(right) if right is not None else 0
if operator == "lt":
return l < r
if operator == "lte":
return l <= r
if operator == "gt":
return l > r
if operator == "gte":
return l >= r
except (TypeError, ValueError):
return False
if operator == "contains":
return right is not None and str(right) in str(left or "")
if operator == "not_contains":
return right is None or str(right) not in str(left or "")
if operator == "empty":
return left is None or left == "" or (isinstance(left, (list, dict)) and len(left) == 0)
if operator == "not_empty":
return left is not None and left != "" and (not isinstance(left, (list, dict)) or len(left) > 0)
if operator == "is_true":
return bool(left)
if operator == "is_false":
return not bool(left)
if operator == "before":
return self._compare_dates(left, right, lambda a, b: a < b)
if operator == "after":
return self._compare_dates(left, right, lambda a, b: a > b)
if operator == "exists":
return self._file_exists(left)
if operator == "not_exists":
return not self._file_exists(left)
return False
value_kind = "unknown"
ref_for_kind = left_ref if isinstance(left_ref, dict) else cond.get("ref")
if isinstance(ref_for_kind, dict) and ref_for_kind.get("nodeId") and node:
graph_stub = self._graph_stub_for_ref(node, ref_for_kind, nodeOutputs)
value_kind = resolve_value_kind(graph_stub, ref_for_kind)
def _compare_dates(self, left: Any, right: Any, op) -> bool:
return apply_condition_operator(left, str(operator), right, value_kind)
def _graph_stub_for_ref(self, node: Dict, ref: Dict, nodeOutputs: Dict) -> Dict[str, Any]:
"""Minimal graph for ``resolve_value_kind`` (includes value producer when known)."""
nodes: List[Dict[str, Any]] = [{"id": node.get("id"), "type": node.get("type")}]
producer_id = ref.get("nodeId")
if producer_id:
ctx = nodeOutputs.get("_context") if isinstance(nodeOutputs.get("_context"), dict) else {}
graph_nodes = ctx.get("graphNodesById") if isinstance(ctx.get("graphNodesById"), dict) else {}
pnode = graph_nodes.get(producer_id) if isinstance(graph_nodes, dict) else None
if isinstance(pnode, dict):
nodes.append({"id": producer_id, "type": pnode.get("type", "")})
else:
nodes.append({"id": producer_id, "type": ""})
return {"nodes": nodes, "targetNodeId": node.get("id")}
"""Compare left/right as dates; op(a,b) is the comparison."""
def parse(v):
@ -208,23 +206,42 @@ class FlowExecutor:
return bool(resolved)
async def _switch(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
valueExpr = (node.get("parameters") or {}).get("value", "")
params = node.get("parameters") or {}
valueExpr = params.get("value", "")
from modules.workflows.automation2.graphUtils import resolveParameterReferences
value = resolveParameterReferences(valueExpr, nodeOutputs)
cases = (node.get("parameters") or {}).get("cases", [])
inp = self._getInputData(nodeId, {nodeId: inputSources}, nodeOutputs)
for i, c in enumerate(cases):
if self._evalSwitchCase(value, c):
return wrapTransit(
unwrapTransit(inp) if inp else inp,
{"match": i, "value": value},
)
return wrapTransit(
unwrapTransit(inp) if inp else inp,
{"match": -1, "value": value},
from modules.features.graphicalEditor.switchOutput import (
build_switch_combined_output,
build_switch_default_payload,
)
def _evalSwitchCase(self, left: Any, case: Any) -> bool:
value = resolveParameterReferences(valueExpr, nodeOutputs)
cases = params.get("cases", []) or []
value_kind = "unknown"
if isinstance(valueExpr, dict) and valueExpr.get("type") == "ref":
graph_stub = self._graph_stub_for_ref(node, valueExpr, nodeOutputs)
value_kind = resolve_value_kind(graph_stub, valueExpr)
inp = self._getInputData(nodeId, {nodeId: inputSources}, nodeOutputs)
matched: List[int] = [
i for i, c in enumerate(cases)
if self._evalSwitchCase(value, c, value_kind=value_kind)
]
default_idx = len(cases) if isinstance(cases, list) else 0
if not matched:
matched = [default_idx]
combined = build_switch_combined_output(
inp, cases, matched_indices=matched, value_kind=value_kind,
)
return wrapTransit(
combined,
{
"match": matched[0],
"matches": matched,
"value": value,
"filterApplied": bool(combined.get("filterApplied")),
},
)
def _evalSwitchCase(self, left: Any, case: Any, *, value_kind: Optional[str] = None) -> bool:
"""
Evaluate a switch case. Case can be:
- dict: {operator, value} - use operator to compare left vs value
@ -236,69 +253,90 @@ class FlowExecutor:
else:
operator = "eq"
right = case
# Same logic as _evalStructuredCondition but with explicit left/right
if operator == "eq":
return left == right
if operator == "neq":
return left != right
if operator in ("lt", "lte", "gt", "gte"):
try:
l, r = float(left) if left is not None else 0, float(right) if right is not None else 0
if operator == "lt":
return l < r
if operator == "lte":
return l <= r
if operator == "gt":
return l > r
if operator == "gte":
return l >= r
except (TypeError, ValueError):
return False
if operator == "contains":
return right is not None and str(right) in str(left or "")
if operator == "not_contains":
return right is None or str(right) not in str(left or "")
if operator == "empty":
return left is None or left == "" or (isinstance(left, (list, dict)) and len(left) == 0)
if operator == "not_empty":
return left is not None and left != "" and (not isinstance(left, (list, dict)) or len(left) > 0)
if operator == "is_true":
return bool(left)
if operator == "is_false":
return not bool(left)
if operator == "before":
return self._compare_dates(left, right, lambda a, b: a < b)
if operator == "after":
return self._compare_dates(left, right, lambda a, b: a > b)
if operator == "exists":
return self._file_exists(left)
if operator == "not_exists":
return not self._file_exists(left)
return False
return apply_condition_operator(left, str(operator), right, value_kind)
async def _loop(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any:
params = node.get("parameters") or {}
itemsPath = params.get("items", "[]")
level = params.get("level", "auto")
from modules.workflows.automation2.graphUtils import resolveParameterReferences
items = resolveParameterReferences(itemsPath, nodeOutputs)
if level != "auto" and isinstance(items, dict):
items = self._resolveUdmLevel(items, level)
elif isinstance(items, list):
pass
elif isinstance(items, dict):
children = items.get("children")
if isinstance(children, list) and children:
items = children
else:
items = [{"name": k, "value": v} for k, v in items.items()]
else:
items = [items] if items is not None else []
raw = resolveParameterReferences(
itemsPath,
nodeOutputs,
consumer_node_id=nodeId,
input_sources=inputSources,
)
items = self._normalize_loop_items(raw)
mode = (params.get("iterationMode") or "all").strip().lower()
stride = params.get("iterationStride", 2)
try:
stride_int = int(stride)
except (TypeError, ValueError):
stride_int = 2
items = self._apply_iteration_mode(items, mode, stride_int)
return {"items": items, "count": len(items)}
def _normalize_loop_items(self, raw: Any) -> List[Any]:
"""Coerce resolved `items` into a list (lists, dict children, or scalars)."""
if isinstance(raw, dict) and isinstance(raw.get("items"), list):
return self._expand_presentation_lines_loop_items(raw["items"])
if isinstance(raw, list):
return self._expand_presentation_lines_loop_items(raw)
if isinstance(raw, dict):
children = raw.get("children")
if isinstance(children, list) and len(children) > 0:
return self._expand_presentation_lines_loop_items(children)
items = [{"name": k, "value": v} for k, v in raw.items()]
return self._expand_presentation_lines_loop_items(items)
return [raw] if raw is not None else []
def _expand_presentation_lines_loop_items(self, items: List[Any]) -> List[Any]:
"""When looping ``presentation.files`` in ``lines`` mode, iterate per slot (e.g. CSV row)."""
if not items:
return items
expanded: List[Any] = []
saw_lines_bucket = False
for it in items:
if not isinstance(it, dict):
expanded.append(it)
continue
val = it.get("value")
if not isinstance(val, dict) or val.get("outputMode") != "lines":
expanded.append(it)
continue
data = val.get("data")
if not isinstance(data, list) or len(data) <= 1:
expanded.append(it)
continue
saw_lines_bucket = True
base_name = str(it.get("name") or val.get("sourceFileName") or "line")
for idx, slot in enumerate(data):
if not isinstance(slot, dict):
continue
sid = str(slot.get("id") or slot.get("label") or idx)
expanded.append({"name": f"{base_name}:{sid}", "value": slot})
return expanded if saw_lines_bucket else items
def _apply_iteration_mode(self, items: List[Any], mode: str, stride: int) -> List[Any]:
"""Select which elements to iterate over (backend-defined modes)."""
if not items:
return []
m = (mode or "all").strip().lower()
if m == "first":
return items[:1]
if m == "last":
return items[-1:]
if m == "every_second":
return items[::2]
if m == "every_third":
return items[::3]
if m == "every_nth":
step = max(2, min(100, int(stride)))
return items[::step]
return list(items)
def _resolveUdmLevel(self, udm: Dict, level: str) -> list:
"""Extract items from a UDM document/node at the requested structural level."""
"""Extract items from a UDM document/node at the requested structural level (test / tooling)."""
children = udm.get("children") or []
if level == "documents":
return [c for c in children if isinstance(c, dict) and c.get("role") in ("document", "archive")]

View file

@ -65,16 +65,23 @@ class InputExecutor:
)
taskId = task.get("id")
self.automation2.updateRun(
from modules.workflows.automation2.graphicalEditorRunFileLogger import merge_persisted_run_context
_pause_ctx = merge_persisted_run_context(
self.automation2,
runId,
status="paused",
nodeOutputs=context.get("nodeOutputs"),
currentNodeId=nodeId,
context={
{
"connectionMap": context.get("connectionMap"),
"inputSources": context.get("inputSources"),
"orderedNodeIds": [n.get("id") for n in context.get("_orderedNodes", []) if n.get("id")],
},
)
self.automation2.updateRun(
runId,
status="paused",
nodeOutputs=context.get("nodeOutputs"),
currentNodeId=nodeId,
context=_pause_ctx,
)
logger.info("InputExecutor node %s: created task %s, run %s paused", nodeId, taskId, runId)
raise PauseForHumanTaskError(runId=runId, taskId=taskId, nodeId=nodeId)

View file

@ -37,7 +37,7 @@ class IOExecutor:
nodeOutputs = context.get("nodeOutputs", {})
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)
logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys()))
@ -45,9 +45,7 @@ class IOExecutor:
if 0 in inputSources:
srcId, _ = inputSources[0]
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
if docs:
resolvedParams.setdefault("documentList", wired)

View file

@ -21,6 +21,7 @@ class TriggerExecutor:
context: Dict[str, Any],
) -> Any:
node_id = node.get("id", "")
node_type = str(node.get("type") or "")
base = context.get("runEnvelope")
if not isinstance(base, dict):
out = normalize_run_envelope(None, user_id=context.get("userId"))
@ -31,4 +32,11 @@ class TriggerExecutor:
node_id,
(out.get("trigger") or {}).get("type"),
)
# Form start: port schema is FormPayload — downstream refs use payload.<field>.
# Do not emit the full run envelope on this port.
if node_type == "trigger.form":
payload = out.get("payload")
if not isinstance(payload, dict):
payload = {}
return {"payload": payload, "_success": True}
return out

View file

@ -7,50 +7,6 @@ from typing import Dict, List, Any, Tuple, Set, Optional
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]]:
"""
Parse graph into nodes, connections, and node IDs.
@ -92,26 +48,93 @@ def buildConnectionMap(connections: List[Dict]) -> Dict[str, List[Tuple[str, int
def getLoopBodyNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Set[str]:
"""Nodes reachable from loop's output (BFS forward). Body = downstream nodes that receive from loop."""
"""Nodes reachable from flow.loop output port 0 only (loop body), BFS forward.
Edges vom Rumpf zurück in den Loop-Knoten (gleicher Eingang wie der Hauptfluss) beenden die
Expansion am Loop-Knoten der Loop-Knoten selbst ist nie Teil des Rumpfes.
"""
from collections import deque
body = set()
# connectionMap: target -> [(source, sourceOutput, targetInput)]
rev: Dict[str, List[str]] = {} # source -> [targets]
body: Set[str] = set()
rev: Dict[str, List[Tuple[str, int, int]]] = {}
for tgt, pairs in connectionMap.items():
for src, _, _ in pairs:
if src not in rev:
rev[src] = []
rev[src].append(tgt)
q = deque([loopNodeId])
for src, so, ti in pairs:
rev.setdefault(src, []).append((tgt, so, ti))
q: deque = deque()
for tgt, so, ti in rev.get(loopNodeId, []):
if so != 0:
continue
if tgt == loopNodeId:
continue
q.append(tgt)
while q:
nid = q.popleft()
for tgt in rev.get(nid, []):
if tgt not in body:
body.add(tgt)
q.append(tgt)
if nid == loopNodeId:
continue
if nid not in body:
body.add(nid)
for tgt, _so, _ti in rev.get(nid, []):
if tgt == loopNodeId:
continue
if tgt not in body:
q.append(tgt)
return body
def getLoopPrimaryInputSource(
loop_node_id: str,
connectionMap: Dict[str, List[Tuple[str, int, int]]],
body_ids: Set[str],
) -> Optional[Tuple[str, int]]:
"""Pick the inbound edge for ``flow.loop`` when several wires hit the same input (0).
The Schleifen-Rücklauf vom Rumpf und der normale Vorgänger enden auf demselben Port;
für die Datenzusammenführung (Fertig-Ausgang, Logs) zählt der Vorgänger **außerhalb** des Rumpfes.
"""
incoming = connectionMap.get(loop_node_id, [])
candidates = [(src, so) for src, so, ti in incoming if ti == 0]
if not candidates:
return None
outside = [(src, so) for src, so in candidates if src not in body_ids]
if outside:
return outside[0]
return candidates[0]
def getLoopDoneNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Set[str]:
"""Nodes reachable from flow.loop output port 1 (runs once after all iterations)."""
from collections import deque
done: Set[str] = set()
rev: Dict[str, List[Tuple[str, int, int]]] = {}
for tgt, pairs in connectionMap.items():
for src, so, ti in pairs:
rev.setdefault(src, []).append((tgt, so, ti))
q: deque = deque()
for tgt, so, ti in rev.get(loopNodeId, []):
if so != 1:
continue
if tgt == loopNodeId:
continue
q.append(tgt)
while q:
nid = q.popleft()
if nid == loopNodeId:
continue
if nid not in done:
done.add(nid)
for tgt, _so, _ti in rev.get(nid, []):
if tgt == loopNodeId:
continue
if tgt not in done:
q.append(tgt)
return done
def getInputSources(nodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Dict[int, Tuple[str, int]]:
"""
For a node, return targetInput -> (sourceNodeId, sourceOutput).
@ -123,8 +146,15 @@ def getInputSources(nodeId: str, connectionMap: Dict[str, List[Tuple[str, int, i
def getTriggerNodes(nodes: List[Dict]) -> List[Dict]:
"""Return nodes with category=trigger or type starting with trigger."""
return [n for n in nodes if (n.get("type", "").startswith("trigger.") or n.get("category") == "trigger")]
"""Return start/trigger nodes: type ``trigger.*``, or category ``trigger`` / ``start``."""
return [
n
for n in nodes
if (
str(n.get("type", "")).startswith("trigger.")
or n.get("category") in ("trigger", "start")
)
]
def validateGraph(graph: Dict[str, Any], nodeTypeIds: Set[str]) -> List[str]:
@ -163,6 +193,11 @@ def validateGraph(graph: Dict[str, Any], nodeTypeIds: Set[str]) -> List[str]:
logger.warning("validateGraph port mismatches: %s", port_errors)
errors.extend(port_errors)
if nodes and not getTriggerNodes(nodes):
errors.append(
"Workflow has no start node: add a node from the Start category before running."
)
if errors:
logger.debug("validateGraph errors: %s", errors)
else:
@ -218,6 +253,8 @@ def _checkPortCompatibility(
continue
srcOutputPorts = srcDef.get("outputPorts", {})
srcPort = srcOutputPorts.get(srcOut, {}) or {}
if srcNode.get("type") == "flow.switch" and not srcPort.get("schema"):
srcPort = srcOutputPorts.get(0, {}) or srcPort
tgtPort = tgtInputPorts.get(tgtIn, {}) or {}
if not isinstance(srcPort, dict):
@ -229,6 +266,9 @@ def _checkPortCompatibility(
continue
if src_schema in accepts:
continue
# ContextBranch is a typed Transit envelope (switch filtered branches).
if src_schema == "ContextBranch" and ("Transit" in accepts or "ContextBranch" in accepts):
continue
# Port that only declares Transit behaves as an untyped sink (legacy graphs).
if len(accepts) == 1 and accepts[0] == "Transit":
continue
@ -374,12 +414,21 @@ def _unwrapTypedRef(value: Any) -> Any:
return value.get(primary, value)
def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
def resolveParameterReferences(
value: Any,
nodeOutputs: Dict[str, Any],
*,
consumer_node_id: Optional[str] = None,
input_sources: Optional[Dict[str, Dict[int, tuple]]] = None,
) -> Any:
"""
Resolve parameter references:
- {{nodeId.output}} or {{nodeId.output.path}} in strings (legacy)
- { "type": "ref", "nodeId": "...", "path": ["field", "nested"] } -> resolved value
- { "type": "value", "value": ... } -> value (then recursively resolve)
When ``consumer_node_id`` and ``input_sources`` are set, refs to the wired
upstream switch use that connection's output port (per-branch payload).
"""
import json
import re
@ -395,11 +444,23 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
path = value.get("path")
if node_id is not None and isinstance(path, (list, tuple)):
data = nodeOutputs.get(node_id)
# Unwrap transit envelopes to access the real data
if isinstance(data, dict) and data.get("_transit"):
wired = None
if consumer_node_id and input_sources:
wired = (input_sources.get(consumer_node_id) or {}).get(0)
if wired and wired[0] == node_id:
from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port
data = unwrap_transit_for_port(data, wired[1])
elif isinstance(data, dict) and data.get("_transit"):
data = data.get("data", data)
plist = list(path)
resolved = _get_by_path(data, plist)
if resolved is None:
from modules.workflows.automation2.pickNotPushMigration import (
remap_stale_presentation_ref_path,
)
alt_path = remap_stale_presentation_ref_path(plist)
if alt_path != plist:
resolved = _get_by_path(data, alt_path)
if resolved is None and isinstance(data, dict) and plist:
if plist[0] == "payload" and len(plist) > 1:
# Strip explicit "payload" prefix (legacy DataPicker paths)
@ -408,17 +469,34 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
# Form nodes store fields under {"payload": {fieldName: …}}.
# DataPicker emits bare field paths like ["url"]; try under payload.
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,
consumer_node_id=consumer_node_id,
input_sources=input_sources,
)
return value
if value.get("type") == "value":
inner = value.get("value")
return resolveParameterReferences(inner, nodeOutputs)
return resolveParameterReferences(
inner,
nodeOutputs,
consumer_node_id=consumer_node_id,
input_sources=input_sources,
)
if value.get("type") == "system":
variable = value.get("variable", "")
from modules.features.graphicalEditor.portTypes import resolveSystemVariable
return resolveSystemVariable(variable, nodeOutputs.get("_context", {}))
return {k: resolveParameterReferences(v, nodeOutputs) for k, v in value.items()}
return {
k: resolveParameterReferences(
v,
nodeOutputs,
consumer_node_id=consumer_node_id,
input_sources=input_sources,
)
for k, v in value.items()
}
if isinstance(value, str):
def repl(m):
@ -455,10 +533,97 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any:
return re.sub(r"\{\{\s*([^}]+)\s*\}\}", repl, value)
if isinstance(value, list):
# contextBuilder: list where every item is a `{"type":"ref",...}` envelope.
# Resolve each ref and join the serialised parts into a single prompt string.
# Resolve each part; a single ref preserves the resolved type (str, list, dict).
if value and all(isinstance(v, dict) and v.get("type") == "ref" for v in value):
from modules.workflows.methods.methodAi._common import serialize_context
parts = [serialize_context(resolveParameterReferences(v, nodeOutputs)) for v in value]
return "\n\n".join(p for p in parts if p)
return [resolveParameterReferences(v, nodeOutputs) for v in value]
resolved_parts = [
resolveParameterReferences(
v,
nodeOutputs,
consumer_node_id=consumer_node_id,
input_sources=input_sources,
)
for v in value
]
if len(resolved_parts) == 1:
return resolved_parts[0]
return resolved_parts
return [
resolveParameterReferences(
v,
nodeOutputs,
consumer_node_id=consumer_node_id,
input_sources=input_sources,
)
for v in 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

View file

@ -0,0 +1,215 @@
# Copyright (c) 2025 Patrick Motsch
"""Per-run NDJSON logs for persisted Automation2 / graphical-editor runs."""
from __future__ import annotations
import asyncio
import json
import logging
import os
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from modules.shared.configuration import APP_CONFIG
from modules.shared.debugLogger import ensureDir, resolve_app_log_dir
logger = logging.getLogger(__name__)
RUN_FILE_LOG_RELATIVE_ROOT = "graphical_editor_runs"
CONTEXT_KEY = "_geRunFileLogRelativeDir"
EXECUTION_FILENAME = "node_execution.ndjson"
CONTEXT_SNAPSHOT_FILENAME = "workflow_context.ndjson"
def graphical_editor_run_file_logging_enabled() -> bool:
"""True when NDJSON files should be written for each persisted run."""
raw = APP_CONFIG.get("APP_GRAPHICAL_EDITOR_RUN_FILE_LOGGING", False)
if isinstance(raw, bool):
return raw
s = str(raw).strip().lower()
return s in ("1", "true", "yes", "on")
def merge_run_context_with_ge_log_prefix(
base_context: Optional[Dict[str, Any]],
incoming: Dict[str, Any],
) -> Dict[str, Any]:
"""Copy ``CONTEXT_KEY`` from *base_context* onto *incoming* if present (pause paths)."""
out = dict(incoming or {})
prev = (base_context or {}).get(CONTEXT_KEY)
if prev is not None:
out[CONTEXT_KEY] = prev
return out
def merge_persisted_run_context(
automation2_interface: Any,
run_id: str,
replacement: Dict[str, Any],
) -> Dict[str, Any]:
"""``{**db_context, **replacement}`` so *_geRunFileLogRelativeDir* and other keys survive pause updates."""
prev = dict((automation2_interface.getRun(run_id) or {}).get("context") or {})
return {**prev, **(replacement or {})}
class GraphicalEditorRunFileLogger:
"""Append-only NDJSON log for one run folder under ``resolve_app_log_dir()``."""
__slots__ = ("_exec_path", "_ctx_path", "_lock", "_run_id")
def __init__(self, run_id: str, absolute_run_dir: str) -> None:
self._run_id = run_id
ensureDir(absolute_run_dir)
self._exec_path = os.path.join(absolute_run_dir, EXECUTION_FILENAME)
self._ctx_path = os.path.join(absolute_run_dir, CONTEXT_SNAPSHOT_FILENAME)
self._lock = asyncio.Lock()
@property
def run_id(self) -> str:
return self._run_id
@staticmethod
def fresh_run_subdirectory_name(run_id: str) -> str:
ts = datetime.now(timezone.utc).strftime("%Y_%m_%d_%H_%M_%S")
return f"{ts}__{run_id}"
@staticmethod
def relative_run_path(subdir_name: str) -> str:
"""Path relative to ``APP_LOGGING_LOG_DIR`` (POSIX-style segments)."""
return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdir_name))
@classmethod
def bootstrap_new_run(cls, automation2_interface: Any, run_id: str, run_context: Dict[str, Any]) -> GraphicalEditorRunFileLogger | None:
"""Create filesystem folder + persist CONTEXT_KEY via ``updateRun``."""
if not graphical_editor_run_file_logging_enabled():
return None
if not automation2_interface or not run_id:
return None
subdir = cls.fresh_run_subdirectory_name(run_id)
rel = cls.relative_run_path(subdir)
base = resolve_app_log_dir()
absolute = os.path.join(base, RUN_FILE_LOG_RELATIVE_ROOT, subdir)
merged = dict(run_context or {})
merged[CONTEXT_KEY] = rel
try:
automation2_interface.updateRun(run_id, context=merged)
except Exception as ex:
logger.warning("GeRunFileLog: could not persist log dir on run=%s: %s", run_id, ex)
return None
logger.info(
"GeRunFileLog: created run folder %s (run=%s)",
absolute,
run_id,
)
return cls(run_id, absolute)
@classmethod
def open_from_run_record(cls, automation2_interface: Any, run_id: str) -> GraphicalEditorRunFileLogger | None:
"""Open logger for an existing run using CONTEXT_KEY from DB."""
if not graphical_editor_run_file_logging_enabled():
return None
if not automation2_interface or not run_id:
return None
try:
run = automation2_interface.getRun(run_id) or {}
except Exception as ex:
logger.debug("GeRunFileLog: getRun failed run=%s: %s", run_id, ex)
return None
rel = (run.get("context") or {}).get(CONTEXT_KEY)
if not rel or not isinstance(rel, str):
return None
base_norm = os.path.realpath(resolve_app_log_dir())
allowed_root = os.path.realpath(os.path.join(base_norm, RUN_FILE_LOG_RELATIVE_ROOT))
cand = os.path.realpath(os.path.join(base_norm, *rel.replace("\\", "/").split("/")))
if cand != allowed_root and not cand.startswith(allowed_root + os.sep):
logger.warning(
"GeRunFileLog: path outside log root denied for run=%s rel=%s",
run_id,
rel,
)
return None
absolute = cand
return cls(run_id, absolute)
@classmethod
def find_existing_absolute_dir(cls, run_id: str) -> Optional[str]:
"""If a folder named ``*{timestamp}__{run_id}`` exists under the log root, return its absolute path."""
root = os.path.realpath(os.path.join(resolve_app_log_dir(), RUN_FILE_LOG_RELATIVE_ROOT))
if not os.path.isdir(root):
return None
suffix = f"__{run_id}"
try:
names = sorted((n for n in os.listdir(root) if n.endswith(suffix)), reverse=True)
except OSError:
return None
if not names:
return None
cand = os.path.realpath(os.path.join(root, names[0]))
allowed_root = root
if cand != allowed_root and not cand.startswith(allowed_root + os.sep):
return None
return cand if os.path.isdir(cand) else None
@classmethod
def ensure_attached(cls, automation2_interface: Any, run_id: str) -> GraphicalEditorRunFileLogger | None:
"""Open logger from DB, or reattach an on-disk folder for *run_id*, or create a new one."""
opened = cls.open_from_run_record(automation2_interface, run_id)
if opened is not None:
return opened
if not graphical_editor_run_file_logging_enabled():
return None
if not automation2_interface or not run_id:
return None
try:
run = automation2_interface.getRun(run_id) or {}
except Exception as ex:
logger.debug("GeRunFileLog: ensure getRun failed run=%s: %s", run_id, ex)
return None
prev_ctx = dict(run.get("context") or {})
existing_abs = cls.find_existing_absolute_dir(run_id)
if existing_abs:
base_norm = os.path.realpath(resolve_app_log_dir())
rel = os.path.relpath(existing_abs, base_norm).replace(os.sep, "/")
merged = {**prev_ctx, CONTEXT_KEY: rel}
try:
automation2_interface.updateRun(run_id, context=merged)
except Exception as ex:
logger.warning("GeRunFileLog: reattach persist failed run=%s: %s", run_id, ex)
return None
logger.info("GeRunFileLog: reattached existing folder for run=%s -> %s", run_id, existing_abs)
return cls(run_id, existing_abs)
subdir = cls.fresh_run_subdirectory_name(run_id)
rel = cls.relative_run_path(subdir)
base = resolve_app_log_dir()
absolute = os.path.join(base, RUN_FILE_LOG_RELATIVE_ROOT, subdir)
merged = {**prev_ctx, CONTEXT_KEY: rel}
try:
automation2_interface.updateRun(run_id, context=merged)
except Exception as ex:
logger.warning("GeRunFileLog: ensure new folder persist failed run=%s: %s", run_id, ex)
return None
logger.info("GeRunFileLog: created late attach folder %s (run=%s)", absolute, run_id)
return cls(run_id, absolute)
async def append_node_execution_line(self, record: Dict[str, Any]) -> None:
line = json.dumps(record, ensure_ascii=False, default=str)
async with self._lock:
try:
with open(self._exec_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception as ex:
logger.warning("GeRunFileLog: append execution failed run=%s: %s", self._run_id, ex)
async def append_context_snapshot_line(self, record: Dict[str, Any]) -> None:
line = json.dumps(record, ensure_ascii=False, default=str)
async with self._lock:
try:
with open(self._ctx_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception as ex:
logger.warning("GeRunFileLog: append context snapshot failed run=%s: %s", self._run_id, ex)

View file

@ -1,18 +1,26 @@
# 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
so downstream nodes resolve connection UUIDs from upstream output.connection.id.
- ``materializeConnectionRefs``: empty ``connectionReference`` from upstream connection provenance.
- ``materializePrimaryTextHandover``: parameters whose static definition includes
``graphInherit.kind == "primaryTextRef"`` (canonical paths: ``PRIMARY_TEXT_HANDOVER_REF_PATH``).
- ``materializeRecommendedDataPickRef``: parameters with ``graphInherit.kind == "recommendedDataPickRef"``
use the upstream output port's ``dataPickOptions`` entry with ``recommended: true``.
Runtime: executeGraph deep-copies the version graph and applies these passes in order.
"""
from __future__ import annotations
import copy
import logging
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
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
logger = logging.getLogger(__name__)
@ -81,3 +89,207 @@ def materializeConnectionRefs(graph: Dict[str, Any]) -> Dict[str, Any]:
logger.debug("materializeConnectionRefs: %s.connectionReference -> ref %s.connection.id", nid, src_id)
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 {})
# Port-level override takes precedence over the schema-wide default path.
# Example: context.extractContent sets primaryTextRefPath=["data"] because
# its ``response`` field is intentionally empty.
ref_path = (
out_port.get("primaryTextRefPath")
if isinstance(out_port, dict) and out_port.get("primaryTextRefPath")
else 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
def _recommended_data_pick_path(out_port: Dict[str, Any]) -> Optional[List[Any]]:
opts = out_port.get("dataPickOptions") if isinstance(out_port, dict) else None
if not isinstance(opts, list):
return None
for opt in opts:
if not isinstance(opt, dict):
continue
if opt.get("recommended") is True:
path = opt.get("path")
if isinstance(path, list) and path:
return list(path)
return None
def materializeRecommendedDataPickRef(graph: Dict[str, Any]) -> Dict[str, Any]:
"""Materialize empty parameters that declare ``graphInherit.kind == \"recommendedDataPickRef\"``."""
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") != "recommendedDataPickRef":
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(port_ix, {}) or {}
if not isinstance(out_port, dict):
out_port = (src_def.get("outputPorts") or {}).get(0, {}) or {}
ref_path = _recommended_data_pick_path(out_port if isinstance(out_port, dict) else {})
if not ref_path:
continue
ref = _data_ref(src_id, ref_path)
if pdef.get("frontendType") == "contextBuilder":
params[pname] = [ref]
else:
params[pname] = ref
logger.debug(
"materializeRecommendedDataPickRef: %s.%s -> ref %s path=%s",
nid,
pname,
src_id,
ref_path,
)
return g
_STALE_FILE_CREATE_CONTEXT_PATHS = frozenset({
("responseData",),
("response",),
("merged",),
("documents", 0, "documentData"),
})
def remap_stale_presentation_ref_path(path: List[Any]) -> List[Any]:
"""Map legacy text-handover paths to unified presentation ``data``."""
if tuple(path) in _STALE_FILE_CREATE_CONTEXT_PATHS:
return ["data"]
return list(path)
def _normalize_presentation_refs_in_value(val: Any) -> Any:
"""Rewrite stale ref paths inside ``contextBuilder`` lists or bare refs."""
if isinstance(val, dict) and val.get("type") == "ref":
path = val.get("path")
if isinstance(path, list) and path:
new_path = remap_stale_presentation_ref_path(path)
if new_path != path:
return {**val, "path": new_path}
return val
if isinstance(val, list):
return [_normalize_presentation_refs_in_value(item) for item in val]
return val
def normalizeFileCreatePresentationRefs(graph: Dict[str, Any]) -> Dict[str, Any]:
"""Remap legacy ``file.create`` context refs to unified presentation ``data``."""
g = copy.deepcopy(graph)
nodes: List[Dict[str, Any]] = g.get("nodes") or []
for node in nodes:
if node.get("type") != "file.create":
continue
params = node.get("parameters")
if not isinstance(params, dict):
continue
ctx = params.get("context")
if ctx in (None, "", []):
continue
normalized = _normalize_presentation_refs_in_value(ctx)
if normalized != ctx:
params["context"] = normalized
logger.debug(
"normalizeFileCreatePresentationRefs: %s.context remapped to presentation data ref",
node.get("id"),
)
return g

View file

@ -0,0 +1,32 @@
# Copyright (c) 2025 Patrick Motsch
"""Heuristics for hiding internal workflow artefacts from user-facing file lists."""
from __future__ import annotations
from typing import Any, Mapping, Optional
_WORKFLOW_INTERNAL_FILE_TAG = "_workflowInternal"
def suppress_workflow_file_in_workspace_ui(meta: Optional[Mapping[str, Any]]) -> bool:
"""True when a file row should not appear in user-facing file lists.
Used by Automation Workspace **and** ``/api/files/list`` (Meine Dateien).
Matches persisted JSON handovers from transient runs (``extracted_content_transient*``),
internal extract image files (``extract_media_*``), the ``_workflowInternal`` tag, and
optional explicit flags.
"""
if not isinstance(meta, Mapping):
return False
tags = meta.get("tags")
if isinstance(tags, list) and _WORKFLOW_INTERNAL_FILE_TAG in tags:
return True
fn = str(meta.get("fileName") or "").lower()
if "extracted_content_transient" in fn:
return True
if "extract_media_" in fn:
return True
if meta.get("suppressInWorkflowFileLists") is True:
return True
return False

View file

@ -4,27 +4,101 @@
"""Shared helpers for AI workflow actions."""
import json
from typing import Any
from typing import Any, Optional
def serialize_context(val: Any) -> str:
def is_image_action_document_list(val: Any) -> bool:
"""True if ``val`` is a non-empty list of ActionDocument-shaped dicts (mimeType image/*)."""
if not isinstance(val, list) or not val:
return False
for item in val:
if not isinstance(item, dict):
return False
mime = str(item.get("mimeType") or "").strip().lower()
if not mime.startswith("image/"):
return False
return True
def _handover_response_plain(val: Any) -> Optional[str]:
"""If ``val`` is a dict with a non-empty ``response`` string, return it (BOM-stripped)."""
if not isinstance(val, dict):
return None
r = val.get("response")
if r is None or not str(r).strip():
return None
return str(r).strip().lstrip("\ufeff")
def primary_text_for_prompt_context(val: Any) -> str:
"""Flatten ActionResult / presentation / merge payloads to readable text.
Used when merging multiple context-builder refs so extract outputs are not
turned into giant JSON via ``serialize_context`` (empty ``response``).
"""
if val is None:
return ""
if isinstance(val, str):
s = val.strip().lstrip("\ufeff")
if not s:
return ""
if len(s) >= 2 and ((s.startswith("[") and s.endswith("]")) or (s.startswith("{") and s.endswith("}"))):
try:
return primary_text_for_prompt_context(json.loads(s))
except (json.JSONDecodeError, TypeError, ValueError):
pass
return s
if isinstance(val, list):
chunks = [primary_text_for_prompt_context(item) for item in val]
chunks = [c for c in chunks if c]
return "\n\n".join(chunks)
if isinstance(val, dict):
got = _handover_response_plain(val)
if got is not None:
return got
inner = val.get("data")
if isinstance(inner, dict):
from modules.workflows.methods.methodContext.actions.extractContent import (
joined_text_from_extract_node_data,
)
t = (joined_text_from_extract_node_data(inner) or "").strip()
if t:
return t
from modules.workflows.methods.methodContext.actions.extractContent import (
joined_text_from_extract_node_data,
)
return (joined_text_from_extract_node_data(val) or "").strip()
return str(val).strip() if str(val).strip() else ""
def serialize_context(val: Any, *, prefer_handover_primary: bool = False) -> str:
"""Convert any context value to a readable string for use in AI prompts.
- None / empty string ""
- empty dict (no keys) "" (avoids literal "{}" in file.create / prompts)
- str as-is
- dict / list pretty-printed JSON
- dict / list pretty-printed JSON (unless ``prefer_handover_primary`` and dict has ``response``)
- if JSON encoding fails (cycles, etc.) but dict has ``response``, return that text instead of ``str(dict)``
- anything else str()
"""
if val is None or val == "" or val == []:
return ""
if isinstance(val, dict) and len(val) == 0:
return ""
if prefer_handover_primary:
got = _handover_response_plain(val)
if got is not None:
return got
if isinstance(val, str):
return val.strip()
return val.strip().lstrip("\ufeff")
try:
return json.dumps(val, ensure_ascii=False, indent=2)
return json.dumps(val, ensure_ascii=False, indent=2, default=str)
except Exception:
got = _handover_response_plain(val)
if got is not None:
return got
return str(val)

View file

@ -389,34 +389,33 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
))
final_documents = action_documents
handover_data = None
else:
# Text response - create document from content
# If no extension provided, use "txt" (required for filename)
extension = output_extension.lstrip('.') if output_extension else "txt"
meaningful_name = self._generateMeaningfulFileName(
base_name="ai",
extension=extension,
action_name="result"
)
validationMetadata = {
"actionType": "ai.process",
"resultType": normalized_result_type if normalized_result_type else None,
"outputFormat": output_format if output_format else None,
"hasDocuments": False,
"contentType": "text"
# Text-only response: keep handover in ActionResult.data (no ActionDocument).
# Avoids automation2 persisting a synthetic file per run; use ai.generateDocument for files.
body = aiResponse.content
if body is None:
body = ""
elif not isinstance(body, str):
body = str(body)
final_documents = []
handover_data = {
"response": body,
"resultType": normalized_result_type,
"outputFormat": output_format,
"contentType": "text",
}
action_document = ActionDocument(
documentName=meaningful_name,
documentData=aiResponse.content,
mimeType=output_mime_type,
validationMetadata=validationMetadata
)
final_documents = [action_document]
md = getattr(aiResponse, "metadata", None)
if md is not None:
extra = getattr(md, "additionalData", None)
if isinstance(extra, dict):
for k, v in extra.items():
handover_data.setdefault(k, v)
# Complete progress tracking
self.services.chat.progressLogFinish(operationId, True)
return ActionResult.isSuccess(documents=final_documents)
return ActionResult.isSuccess(documents=final_documents, data=handover_data)
except (SubscriptionInactiveException, BillingContextError):
try:

View file

@ -22,6 +22,27 @@ from .actions.consolidate import consolidate
logger = logging.getLogger(__name__)
def _editorBindingParams():
"""Graph-editor bindings that actions accept at runtime but are not user-facing."""
return {
"allowedModels": WorkflowActionParameter(
name="allowedModels",
type="List[str]",
frontendType=FrontendType.HIDDEN,
required=False,
description="Optional model whitelist from the graph editor.",
),
"requireNeutralization": WorkflowActionParameter(
name="requireNeutralization",
type="bool",
frontendType=FrontendType.HIDDEN,
required=False,
description="Whether outputs must pass neutralization before downstream use.",
),
}
class MethodAi(MethodBase):
"""AI processing methods."""
@ -153,7 +174,22 @@ class MethodAi(MethodBase):
required=False,
default="general",
description="Research depth"
)
),
"context": WorkflowActionParameter(
name="context",
type="Any",
frontendType=FrontendType.TEXTAREA,
required=False,
default="",
description="Additional context from upstream steps.",
),
"documentList": WorkflowActionParameter(
name="documentList",
type="DocumentList",
frontendType=FrontendType.DOCUMENT_REFERENCE,
required=False,
description="Optional reference documents for the research prompt.",
),
},
execute=webResearch.__get__(self, self.__class__)
),
@ -194,7 +230,14 @@ class MethodAi(MethodBase):
required=False,
default="txt",
description="Output file extension"
)
),
"folderId": WorkflowActionParameter(
name="folderId",
type="str",
frontendType=FrontendType.USER_FILE_FOLDER,
required=False,
description="Target folder in My Files when persisting workflow output",
),
},
execute=summarizeDocument.__get__(self, self.__class__)
),
@ -239,7 +282,14 @@ class MethodAi(MethodBase):
frontendType=FrontendType.TEXT,
required=False,
description="Output file extension. If not specified, uses same format as input"
)
),
"folderId": WorkflowActionParameter(
name="folderId",
type="str",
frontendType=FrontendType.USER_FILE_FOLDER,
required=False,
description="Target folder in My Files when persisting workflow output",
),
},
execute=translateDocument.__get__(self, self.__class__)
),
@ -271,7 +321,14 @@ class MethodAi(MethodBase):
required=False,
default=True,
description="Whether to preserve document structure (headings, tables, etc.)"
)
),
"folderId": WorkflowActionParameter(
name="folderId",
type="str",
frontendType=FrontendType.USER_FILE_FOLDER,
required=False,
description="Target folder in My Files when persisting workflow output",
),
},
execute=convertDocument.__get__(self, self.__class__)
),
@ -335,6 +392,13 @@ class MethodAi(MethodBase):
required=False,
description="Legacy/API output format extension (e.g. txt, docx). Ignored when outputFormat is set."
),
"folderId": WorkflowActionParameter(
name="folderId",
type="str",
frontendType=FrontendType.USER_FILE_FOLDER,
required=False,
description="Target folder in My Files when persisting workflow output",
),
},
execute=generateDocument.__get__(self, self.__class__)
),
@ -366,7 +430,22 @@ class MethodAi(MethodBase):
frontendOptions=["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"],
required=False,
description="Output format (html, js, py, json, csv, xml, etc.). Optional: if omitted, formats are determined from prompt by AI. This action can return MULTIPLE files in a single call when the prompt requests multiple files. With per-document format determination, AI can determine different formats for different files based on prompt. When multiple files are requested, the action will return multiple documents (one per file)."
)
),
"context": WorkflowActionParameter(
name="context",
type="Any",
frontendType=FrontendType.TEXTAREA,
required=False,
default="",
description="Additional context from upstream steps.",
),
"folderId": WorkflowActionParameter(
name="folderId",
type="str",
frontendType=FrontendType.USER_FILE_FOLDER,
required=False,
description="Target folder in My Files when persisting workflow output",
),
},
execute=generateCode.__get__(self, self.__class__)
),
@ -404,6 +483,10 @@ class MethodAi(MethodBase):
execute=consolidate.__get__(self, self.__class__)
),
}
_extras = _editorBindingParams()
for _defn in self._actions.values():
_defn.parameters.update(_extras)
# Validate actions after definition
self._validateActions()

View file

@ -194,40 +194,41 @@ class MethodBase:
return wrapper
def _validateParameters(self, parameters: Dict[str, Any], paramDefs: Dict[str, WorkflowActionParameter]) -> Dict[str, Any]:
"""Validate parameters against definitions
IMPORTANT: System parameters (like parentOperationId, expectedDocumentFormats) are preserved
even if they're not in the parameter definitions, as they're used internally by the framework.
"""Validate declared parameters; pass through unknown ones from the node definition.
The graphical-editor node definition is the source of truth for the full UI parameter
list. Actions only need to declare the parameters they want validated/defaulted; any
additional parameter passed in by the executor (e.g. contentFilter, pdfExtractMode,
outputMode for context.extractContent) is preserved so the action can read it.
System parameters (parentOperationId, _runContext, _upstreamPayload, ...) are always
preserved as before.
"""
validated = {}
# System parameters that should always be preserved, even if not in paramDefs
systemParams = ['parentOperationId', 'expectedDocumentFormats']
for sysParam in systemParams:
if sysParam in parameters:
validated[sysParam] = parameters[sysParam]
validated: Dict[str, Any] = {}
for paramName, paramDef in paramDefs.items():
value = parameters.get(paramName)
# Check required
if paramDef.required and value is None:
raise ValueError(f"Required parameter '{paramName}' is missing")
# Use default if not provided
if value is None and paramDef.default is not None:
value = paramDef.default
# Type validation
if value is not None:
value = self._validateType(value, paramDef.type)
# Custom validation rules
if paramDef.validation and value is not None:
self._applyValidationRules(value, paramDef.validation)
validated[paramName] = value
# Preserve every additional parameter the executor passed in (node-defined params,
# system params, declarative injections). This keeps the node definition authoritative.
for k, v in parameters.items():
if k not in validated:
validated[k] = v
return validated
def _validateType(self, value: Any, expectedType: str) -> Any:

View file

@ -0,0 +1,141 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Action ``context.filterContext``.
Allow- or block-lists keys/paths from the upstream payload using simple glob
patterns. Implementation uses ``fnmatch`` (no regex) and traverses dotted paths
on dicts.
"""
from __future__ import annotations
import copy
import fnmatch
import logging
from typing import Any, Dict, List, Optional, Tuple
from modules.datamodels.datamodelChat import ActionResult
logger = logging.getLogger(__name__)
_META_KEYS = ("_success", "_error", "_transit", "_meta", "_warnings")
def _flatten(payload: Any, prefix: str = "") -> Dict[str, Any]:
"""Yield ``{dotted.path: value}`` for every leaf in a dict tree."""
out: Dict[str, Any] = {}
if not isinstance(payload, dict):
if prefix:
out[prefix] = payload
return out
for k, v in payload.items():
path = f"{prefix}.{k}" if prefix else str(k)
if isinstance(v, dict):
out.update(_flatten(v, path))
else:
out[path] = v
return out
def _set_path(target: Dict[str, Any], dotted: str, value: Any) -> None:
parts = dotted.split(".")
cur = target
for seg in parts[:-1]:
nxt = cur.get(seg)
if not isinstance(nxt, dict):
nxt = {}
cur[seg] = nxt
cur = nxt
cur[parts[-1]] = value
def _del_path(target: Dict[str, Any], dotted: str) -> bool:
parts = dotted.split(".")
cur: Any = target
stack: List[Tuple[Dict[str, Any], str]] = []
for seg in parts[:-1]:
if not isinstance(cur, dict) or seg not in cur:
return False
stack.append((cur, seg))
cur = cur[seg]
if not isinstance(cur, dict) or parts[-1] not in cur:
return False
del cur[parts[-1]]
return True
def _match_any(pattern: str, all_paths: List[str]) -> List[str]:
"""Return every flattened path matching the glob pattern."""
return [p for p in all_paths if fnmatch.fnmatchcase(p, pattern)]
async def filterContext(self, parameters: Dict[str, Any]) -> ActionResult:
try:
mode = str(parameters.get("mode") or "allow")
if mode not in ("allow", "block"):
return ActionResult.isFailure(error=f"Invalid mode '{mode}', expected 'allow' or 'block'")
keys: List[str] = parameters.get("keys") or []
if not isinstance(keys, list) or not keys:
return ActionResult.isFailure(error="'keys' must be a non-empty list of paths or patterns")
missing_behavior = str(parameters.get("missingKeyBehavior") or "skip")
if missing_behavior not in ("skip", "nullFill", "error"):
return ActionResult.isFailure(error=f"Invalid missingKeyBehavior '{missing_behavior}'")
preserve_meta = bool(parameters.get("preserveMeta", True))
upstream = parameters.get("_upstreamPayload") or {}
if not isinstance(upstream, dict):
upstream = {"value": upstream}
flat = _flatten(upstream)
all_paths = list(flat.keys())
if mode == "allow":
result: Dict[str, Any] = {}
missing: List[str] = []
for pat in keys:
p = str(pat).strip()
if not p:
continue
matches = _match_any(p, all_paths)
if not matches:
missing.append(p)
if missing_behavior == "nullFill":
_set_path(result, p, None)
continue
for m in matches:
_set_path(result, m, flat[m])
if missing and missing_behavior == "error":
return ActionResult.isFailure(error=f"Missing keys: {missing}")
if preserve_meta:
for mk in _META_KEYS:
if mk in upstream:
result[mk] = upstream[mk]
data: Dict[str, Any] = result
if missing and missing_behavior != "error":
data["_missingKeys"] = missing
return ActionResult.isSuccess(data=data)
# mode == "block"
cloned = copy.deepcopy(upstream)
removed: List[str] = []
for pat in keys:
p = str(pat).strip()
if not p:
continue
matches = _match_any(p, all_paths)
for m in matches:
if preserve_meta and m in _META_KEYS:
continue
if _del_path(cloned, m):
removed.append(m)
cloned["_removedKeys"] = removed
return ActionResult.isSuccess(data=cloned)
except Exception as exc:
logger.exception("filterContext failed")
return ActionResult.isFailure(error=str(exc))

View file

@ -0,0 +1,254 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Action ``context.mergeContext``.
Receives a list of results (e.g. from ``flow.loop`` ``bodyResults``) via the
``dataSource`` DataRef parameter and deep-merges them into a single dict.
``dataSource`` must be set explicitly (resolved DataRef). There is no implicit
fallback to ``_upstreamPayload`` or loop payloads.
"""
from __future__ import annotations
import copy
import json
import logging
from typing import Any, Dict, List, Optional
from modules.datamodels.datamodelChat import ActionResult
from modules.workflows.methods.methodContext.actions.extractContent import (
joined_text_from_extract_node_data,
)
from modules.workflows.methods.methodContext.contextEnvelope import wrap_merge_context_data
logger = logging.getLogger(__name__)
def _deep_merge(target: Dict[str, Any], source: Dict[str, Any], conflicts: List[str], path: str = "") -> None:
for k, v in source.items():
full = f"{path}.{k}" if path else k
if k not in target:
target[k] = copy.deepcopy(v) if isinstance(v, (dict, list)) else v
continue
existing = target[k]
if isinstance(existing, dict) and isinstance(v, dict):
_deep_merge(existing, v, conflicts, full)
elif isinstance(existing, list) and isinstance(v, list):
target[k] = existing + v
else:
if existing != v:
conflicts.append(full)
target[k] = copy.deepcopy(v) if isinstance(v, (dict, list)) else v
def _coerce_to_list(value: Any) -> List[Any]:
"""Normalise ``value`` to a list of items to merge."""
if isinstance(value, list):
return value
if value is None:
return []
return [value]
def _strip_document_data(doc: Any) -> Any:
"""Keep document metadata but drop the raw blob so deep-merge stays small."""
if not isinstance(doc, dict):
return doc
out = dict(doc)
out["documentData"] = None
return out
def _merge_payload(item: Any) -> Optional[Dict[str, Any]]:
"""Return the dict to deep-merge for this item, or ``None`` to skip.
``documents[n].documentData`` is nulled before merging so large blobs
(e.g. ~34 MB handover-JSON per extractContent iteration) don't accumulate.
``imageDocumentsOnly`` is left intact ``_deep_merge`` list-concats it
across iterations, giving downstream nodes all images from all iterations.
"""
if not isinstance(item, dict):
return None
# Opt-in: only merge items that explicitly report success.
# Items without a ``success`` key (e.g. DocumentList, Transit outputs) are
# still included so non-action node results are not silently dropped.
success_val = item.get("success")
if success_val is not None and success_val is not True:
return None
out = dict(item)
if isinstance(out.get("documents"), list):
out["documents"] = [_strip_document_data(d) for d in out["documents"]]
return out
def _primary_text_from_item(it: Any) -> str:
"""Same sources as ``actionNodeExecutor`` / ``context.extractContent`` for primary text."""
if not isinstance(it, dict):
return ""
r = it.get("response")
if r is not None and str(r).strip():
return str(r).strip()
inner = it.get("data")
if isinstance(inner, dict):
r = inner.get("response")
if r is not None and str(r).strip():
return str(r).strip()
ce_text = joined_text_from_extract_node_data(inner)
if ce_text.strip():
return ce_text.strip()
docs = it.get("documents")
if not isinstance(docs, list) or not docs:
return ""
doc0 = docs[0]
raw: Any = None
if isinstance(doc0, dict):
raw = doc0.get("documentData")
elif hasattr(doc0, "documentData"):
raw = getattr(doc0, "documentData", None)
if isinstance(raw, bytes):
try:
return raw.decode("utf-8").strip()
except (UnicodeDecodeError, ValueError):
return ""
if isinstance(raw, dict):
return (joined_text_from_extract_node_data(raw) or "").strip()
if isinstance(raw, str) and raw.strip():
s = raw.strip()
if s.startswith("{") and s.endswith("}"):
try:
parsed = json.loads(s)
if isinstance(parsed, dict):
return (joined_text_from_extract_node_data(parsed) or "").strip()
except (json.JSONDecodeError, TypeError):
pass
return s
return ""
def _sanitize_heading_title(name: str) -> str:
t = " ".join(name.replace("\r", " ").replace("\n", " ").split()).strip()
return t[:160] if len(t) > 160 else t
def _iteration_heading_from_item(it: Any) -> Optional[str]:
if not isinstance(it, dict):
return None
inner = it.get("data")
if isinstance(inner, dict):
meta = inner.get("_meta") if isinstance(inner.get("_meta"), dict) else {}
sf = inner.get("sourceFileNames") or meta.get("sourceFileNames")
if isinstance(sf, list) and sf:
first = sf[0]
if isinstance(first, str) and first.strip():
return _sanitize_heading_title(first.strip())
docs = it.get("documents")
if not isinstance(docs, list) or not docs:
return None
d0 = docs[0]
if not isinstance(d0, dict):
return None
name = d0.get("documentName")
if isinstance(name, str) and name.strip():
return _sanitize_heading_title(name.strip())
return None
def _synthesize_primary_response(merged: Dict[str, Any], inputs: List[Any]) -> str:
"""Flat text for ``ActionResult.response`` / file.create.
Prefer concatenating each input's primary text (loop bodyResults) so no
iteration is dropped ``deep_merge`` overwrites scalar ``response`` with
the last item only; that merged value is a fallback when no per-item text
is found.
When several inputs are merged, prefix each chunk with a markdown ``###``
heading from ``documents[0].documentName`` so ``file.create`` renders clear
sections (CSV vs PDF vs ).
"""
chunks: List[str] = []
multi = len(inputs) > 1
for it in inputs:
t = _primary_text_from_item(it)
if not t:
continue
if multi:
h = _iteration_heading_from_item(it)
if h:
chunks.append(f"### {h}\n\n{t}")
continue
chunks.append(t)
if chunks:
return "\n\n".join(chunks)
if isinstance(merged, dict):
r = merged.get("response")
if r is not None and str(r).strip():
return str(r).strip()
if isinstance(merged, dict) and merged:
try:
return json.dumps(merged, ensure_ascii=False, indent=2, default=str)
except Exception:
return str(merged)
return ""
async def mergeContext(self, parameters: Dict[str, Any]) -> ActionResult:
try:
if "dataSource" not in parameters:
raise ValueError("dataSource is required (set a DataRef on the merge node)")
raw = parameters["dataSource"]
if isinstance(raw, str) and not raw.strip():
raw = None
if raw is None:
return ActionResult.isFailure(error="dataSource ist erforderlich (DataRef auf die Quelle setzen).")
if isinstance(raw, list) and len(raw) == 0:
return ActionResult.isFailure(error="Keine Datenquelle angegeben oder Datenquelle ist leer.")
items = _coerce_to_list(raw)
if not items:
return ActionResult.isFailure(error="Keine Datenquelle angegeben oder Datenquelle ist leer.")
merged: Dict[str, Any] = {}
conflicts: List[str] = []
inputs: List[Any] = []
for item in items:
if item is None:
continue
inputs.append(item)
payload = _merge_payload(item)
if payload:
_deep_merge(merged, payload, conflicts)
if not inputs:
return ActionResult.isFailure(error="Alle Einträge in der Datenquelle sind leer.")
primary = _synthesize_primary_response(merged, inputs)
# ``response`` lives only at the top-level of the data envelope (``payload["response"]``).
# Do NOT set ``merged["response"]`` — that would duplicate it inside the deep-merged blob
# and overwrite whatever the natural merge produced for debugging.
_ps = primary if isinstance(primary, str) else repr(primary)
logger.info(
"mergeContext: inputs=%d merged_keys=%s primary_len=%d primary_preview=%r conflicts=%d",
len(inputs),
list(merged.keys())[:20],
len(_ps or ""),
(_ps[:200] + "\u2026") if len(_ps) > 200 else _ps,
len(conflicts),
)
payload: Dict[str, Any] = {
"merged": merged,
"inputs": inputs,
"first": inputs[0] if inputs else None,
"count": len(inputs),
"conflicts": sorted(set(conflicts)) if conflicts else [],
"response": primary,
}
return ActionResult.isSuccess(data=wrap_merge_context_data(payload))
except Exception as exc:
logger.exception("mergeContext failed")
return ActionResult.isFailure(error=str(exc))

View file

@ -1,240 +1,310 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
import base64 as _b64
import logging
import time
from typing import Dict, Any
from typing import Any, Dict
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.datamodels.datamodelDocref import (
DocumentReferenceList,
coerceDocumentReferenceList,
)
from modules.datamodels.datamodelDocref import coerceDocumentReferenceList
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart
from .extractContent import _one_file_bucket
logger = logging.getLogger(__name__)
HANDOVER_KIND = "context.extractContent.handover.v1"
async def _neutralize_one_content_extracted(
*,
svc,
content_extracted: ContentExtracted,
operation_id: str,
chat_doc_slot: int,
chat_documents_len: int,
) -> ContentExtracted:
"""Neutralize every part inside a ContentExtracted (copied semantics from legacy inline loop)."""
neutralized_parts = []
for part in content_extracted.parts:
if not isinstance(part, ContentPart):
if isinstance(part, dict):
try:
part = ContentPart(**part)
except Exception as e:
logger.warning(f"Could not parse ContentPart: {str(e)}")
neutralized_parts.append(part)
continue
else:
neutralized_parts.append(part)
continue
_type_group = getattr(part, "typeGroup", "") or ""
prog = 0.3 + (chat_doc_slot / max(1, chat_documents_len)) * 0.6
if _type_group == "image" and part.data:
try:
svc.services.chat.progressLogUpdate(
operation_id,
prog,
f"Checking image part {len(neutralized_parts) + 1}",
)
_img_bytes = _b64.b64decode(str(part.data))
_img_result = await svc.services.neutralization.processImageAsync(_img_bytes, f"part_{part.id}")
if _img_result.get("status") == "ok":
neutralized_parts.append(part)
else:
logger.warning("Fail-Safe: Image part %s blocked (PII), SKIPPING", part.id)
except Exception as _img_err:
logger.error(f"Fail-Safe: Image check failed for part {part.id}: {_img_err}, SKIPPING")
elif part.data:
try:
svc.services.chat.progressLogUpdate(
operation_id,
prog,
f"Neutralizing part {len(neutralized_parts) + 1}",
)
neut_res = await svc.services.neutralization.processTextAsync(part.data)
if neut_res and "neutralized_text" in neut_res:
neutral_data = neut_res["neutralized_text"]
neutralized_parts.append(
ContentPart(
id=part.id,
parentId=part.parentId,
label=part.label,
typeGroup=part.typeGroup,
mimeType=part.mimeType,
data=neutral_data,
metadata=part.metadata.copy() if part.metadata else {},
)
)
else:
logger.warning(
"Fail-Safe: Neutralization incomplete for part %s — SKIPPING (not passing original)",
part.id,
)
continue
except Exception as e:
logger.error(f"Fail-Safe: Error neutralizing part {part.id}: {str(e)}, SKIPPING")
continue
else:
neutralized_parts.append(part)
return ContentExtracted(
id=content_extracted.id,
parts=neutralized_parts,
summary=content_extracted.summary,
)
async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
operationId = None
operation_id = None
try:
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"context_neutralize_{workflowId}_{int(time.time())}"
neutralizationEnabled = False
workflow_id = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operation_id = f"context_neutralize_{workflow_id}_{int(time.time())}"
neutralization_enabled = False
try:
config = self.services.neutralization.getConfig()
neutralizationEnabled = config and config.enabled
neutralization_enabled = config and config.enabled
except Exception as e:
logger.debug(f"Could not check neutralization config: {str(e)}")
if not neutralizationEnabled:
if not neutralization_enabled:
logger.info("Neutralization is not enabled, returning documents unchanged")
# Return original documents if neutralization is disabled
documentListParam = parameters.get("documentList")
if not documentListParam:
document_list_param = parameters.get("documentList")
if not document_list_param:
return ActionResult.isFailure(error="documentList is required")
documentList = coerceDocumentReferenceList(documentListParam)
if not documentList.references:
return ActionResult.isFailure(
error=f"documentList could not be parsed (type={type(documentListParam).__name__})"
)
# Get ChatDocuments from documentList
chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(documentList)
if not chatDocuments:
doc_list = coerceDocumentReferenceList(document_list_param)
if not doc_list.references:
return ActionResult.isFailure(error=f"documentList invalid (empty)")
chat_docs = self.services.chat.getChatDocumentsFromDocumentList(doc_list)
if not chat_docs:
return ActionResult.isFailure(error="No documents found in documentList")
# Return original documents as ActionDocuments
actionDocuments = []
for chatDoc in chatDocuments:
# Extract ContentExtracted from documentData if available
if hasattr(chatDoc, 'documentData') and chatDoc.documentData:
actionDoc = ActionDocument(
documentName=getattr(chatDoc, 'fileName', 'unknown'),
documentData=chatDoc.documentData,
mimeType=getattr(chatDoc, 'mimeType', 'application/json'),
validationMetadata={
"actionType": "context.neutralizeData",
"neutralized": False,
"reason": "Neutralization disabled"
}
action_documents = []
for chat_doc in chat_docs:
if hasattr(chat_doc, "documentData") and chat_doc.documentData:
action_documents.append(
ActionDocument(
documentName=getattr(chat_doc, "fileName", "unknown"),
documentData=chat_doc.documentData,
mimeType=getattr(chat_doc, "mimeType", "application/json"),
validationMetadata={
"actionType": "context.neutralizeData",
"neutralized": False,
"reason": "Neutralization disabled",
},
)
)
actionDocuments.append(actionDoc)
return ActionResult.isSuccess(documents=actionDocuments)
documentListParam = parameters.get("documentList")
if not documentListParam:
return ActionResult.isSuccess(documents=action_documents)
document_list_param = parameters.get("documentList")
if not document_list_param:
return ActionResult.isFailure(error="documentList is required")
documentList = coerceDocumentReferenceList(documentListParam)
if not documentList.references:
return ActionResult.isFailure(
error=f"documentList could not be parsed (type={type(documentListParam).__name__})"
)
# Start progress tracking
parentOperationId = parameters.get('parentOperationId')
doc_list = coerceDocumentReferenceList(document_list_param)
if not doc_list.references:
return ActionResult.isFailure(error=f"documentList invalid")
parent_operation_id = parameters.get("parentOperationId")
self.services.chat.progressLogStart(
operationId,
operation_id,
"Neutralizing data from documents",
"Data Neutralization",
f"Documents: {len(documentList.references)}",
parentOperationId=parentOperationId
f"Documents: {len(doc_list.references)}",
parentOperationId=parent_operation_id,
)
# Get ChatDocuments from documentList
self.services.chat.progressLogUpdate(operationId, 0.2, "Loading documents")
chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(documentList)
if not chatDocuments:
self.services.chat.progressLogFinish(operationId, False)
self.services.chat.progressLogUpdate(operation_id, 0.2, "Loading documents")
chat_documents = self.services.chat.getChatDocumentsFromDocumentList(doc_list)
if not chat_documents:
self.services.chat.progressLogFinish(operation_id, False)
return ActionResult.isFailure(error="No documents found in documentList")
logger.info(f"Neutralizing data from {len(chatDocuments)} documents")
# Process each document
self.services.chat.progressLogUpdate(operationId, 0.3, "Processing documents")
actionDocuments = []
for i, chatDoc in enumerate(chatDocuments):
logger.info(f"Neutralizing data from {len(chat_documents)} document(s)")
self.services.chat.progressLogUpdate(operation_id, 0.3, "Processing documents")
action_documents = []
for i, chat_doc in enumerate(chat_documents):
try:
# Extract ContentExtracted from documentData
if not hasattr(chatDoc, 'documentData') or not chatDoc.documentData:
logger.warning(f"Document {i+1} has no documentData, skipping")
dd = getattr(chat_doc, "documentData", None)
if not dd:
logger.warning(f"Document {i + 1} has no documentData, skipping")
continue
documentData = chatDoc.documentData
# Check if it's a ContentExtracted object
if isinstance(documentData, ContentExtracted):
contentExtracted = documentData
elif isinstance(documentData, dict):
# Try to parse as ContentExtracted
fn = str(getattr(chat_doc, "fileName", "") or "")
mime_guess = str(getattr(chat_doc, "mimeType", "") or "").lower()
if (
mime_guess.startswith("image/")
and fn.startswith("extract_media_")
and not (isinstance(dd, dict) and dd.get("kind") == HANDOVER_KIND)
):
action_documents.append(
ActionDocument(
documentName=fn or f"media_{i + 1}",
documentData=dd,
mimeType=mime_guess or "application/octet-stream",
validationMetadata={
"actionType": "context.neutralizeData",
"neutralized": False,
"reason": "extractContent_media_sidecar_pass_through",
},
)
)
continue
# --- Unified JSON envelope from context.extractContent (v1) ---
if isinstance(dd, dict) and dd.get("kind") == HANDOVER_KIND:
bundle = dict(dd)
files_section = dd.get("files") or {}
new_files = {}
for fk, bucket in files_section.items():
if not isinstance(bucket, dict):
continue
parts_raw = bucket.get("parts") or []
parsed_parts = []
for pd in parts_raw:
parsed_parts.append(ContentPart(**pd) if isinstance(pd, dict) else pd)
summary = bucket.get("summary") or {}
if hasattr(summary, "model_dump"):
summary = summary.model_dump(mode="json")
ce = ContentExtracted(
id=str(bucket.get("extractedId") or ""),
parts=parsed_parts,
summary=summary if isinstance(summary, dict) else {},
)
ce_out = await _neutralize_one_content_extracted(
svc=self,
content_extracted=ce,
operation_id=operation_id,
chat_doc_slot=i,
chat_documents_len=max(len(chat_documents), 1),
)
new_files[fk] = _one_file_bucket(ce_out, str(bucket.get("sourceFileName") or fk))
bundle["files"] = new_files
original_filename = getattr(chat_doc, "fileName", f"neutralized_bundle_{workflow_id}.json")
bn = original_filename.rsplit(".", 1)[0] if "." in original_filename else original_filename
action_documents.append(
ActionDocument(
documentName=f"{bn}_neutralized.json",
documentData=bundle,
mimeType="application/json",
validationMetadata={
"actionType": "context.neutralizeData",
"neutralized": True,
"handoverKind": HANDOVER_KIND,
"bundleFileCount": len(new_files),
},
)
)
continue
# --- Legacy ContentExtracted per persisted document ---
if isinstance(dd, ContentExtracted):
content_extracted = dd
elif isinstance(dd, dict):
try:
contentExtracted = ContentExtracted(**documentData)
except Exception as e:
logger.warning(f"Document {i+1} documentData is not ContentExtracted: {str(e)}")
content_extracted = ContentExtracted(**dd)
except Exception:
logger.warning(f"Document {i + 1} documentData cannot be parsed as ContentExtracted dict")
continue
else:
logger.warning(f"Document {i+1} documentData is not ContentExtracted or dict")
logger.warning(f"Document {i + 1} documentData is not supported")
continue
# Neutralize each ContentPart's data field
neutralizedParts = []
for part in contentExtracted.parts:
if not isinstance(part, ContentPart):
# Try to parse as ContentPart
if isinstance(part, dict):
try:
part = ContentPart(**part)
except Exception as e:
logger.warning(f"Could not parse ContentPart: {str(e)}")
neutralizedParts.append(part)
continue
else:
neutralizedParts.append(part)
continue
# Neutralize the data field based on typeGroup
_typeGroup = getattr(part, 'typeGroup', '') or ''
if _typeGroup == 'image' and part.data:
import base64 as _b64
try:
self.services.chat.progressLogUpdate(
operationId,
0.3 + (i / len(chatDocuments)) * 0.6,
f"Checking image part {len(neutralizedParts) + 1} of document {i+1}"
)
_imgBytes = _b64.b64decode(str(part.data))
_imgResult = await self.services.neutralization.processImageAsync(_imgBytes, f"part_{part.id}")
if _imgResult.get("status") == "ok":
neutralizedParts.append(part)
else:
logger.warning(f"Fail-Safe: Image part {part.id} blocked (PII detected), SKIPPING")
except Exception as _imgErr:
logger.error(f"Fail-Safe: Image check failed for part {part.id}: {_imgErr}, SKIPPING")
elif part.data:
try:
self.services.chat.progressLogUpdate(
operationId,
0.3 + (i / len(chatDocuments)) * 0.6,
f"Neutralizing part {len(neutralizedParts) + 1} of document {i+1}"
)
neutralizationResult = await self.services.neutralization.processTextAsync(part.data)
if neutralizationResult and 'neutralized_text' in neutralizationResult:
neutralizedData = neutralizationResult['neutralized_text']
neutralizedPart = ContentPart(
id=part.id,
parentId=part.parentId,
label=part.label,
typeGroup=part.typeGroup,
mimeType=part.mimeType,
data=neutralizedData,
metadata=part.metadata.copy() if part.metadata else {}
)
neutralizedParts.append(neutralizedPart)
else:
logger.warning(f"Fail-Safe: Neutralization incomplete for part {part.id}, SKIPPING (not passing original)")
continue
except Exception as e:
logger.error(f"Fail-Safe: Error neutralizing part {part.id}, SKIPPING document (not passing original): {str(e)}")
continue
else:
neutralizedParts.append(part)
# Create neutralized ContentExtracted object
neutralizedContentExtracted = ContentExtracted(
id=contentExtracted.id,
parts=neutralizedParts,
summary=contentExtracted.summary
neut_out = await _neutralize_one_content_extracted(
svc=self,
content_extracted=content_extracted,
operation_id=operation_id,
chat_doc_slot=i,
chat_documents_len=max(len(chat_documents), 1),
)
# Create ActionDocument
originalFileName = getattr(chatDoc, 'fileName', f"document_{i+1}.json")
baseName = originalFileName.rsplit('.', 1)[0] if '.' in originalFileName else originalFileName
documentName = f"{baseName}_neutralized_{contentExtracted.id}.json"
validationMetadata = {
"actionType": "context.neutralizeData",
"documentIndex": i,
"extractedId": contentExtracted.id,
"partCount": len(neutralizedParts),
"neutralized": True,
"originalFileName": originalFileName
}
actionDoc = ActionDocument(
documentName=documentName,
documentData=neutralizedContentExtracted,
mimeType="application/json",
validationMetadata=validationMetadata
original_file_name = getattr(chat_doc, "fileName", f"document_{i + 1}.json")
base_name = original_file_name.rsplit(".", 1)[0] if "." in original_file_name else original_file_name
document_name = f"{base_name}_neutralized_{neut_out.id}.json"
action_documents.append(
ActionDocument(
documentName=document_name,
documentData=neut_out,
mimeType="application/json",
validationMetadata={
"actionType": "context.neutralizeData",
"documentIndex": i,
"extractedId": neut_out.id,
"partCount": len(neut_out.parts),
"neutralized": True,
"originalFileName": original_file_name,
},
)
)
actionDocuments.append(actionDoc)
except Exception as e:
logger.error(f"Error processing document {i+1}: {str(e)}")
# Continue with other documents
logger.error(f"Error processing document {i + 1}: {str(e)}")
continue
if not actionDocuments:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="No valid ContentExtracted documents found to neutralize")
self.services.chat.progressLogFinish(operationId, True)
return ActionResult.isSuccess(documents=actionDocuments)
if not action_documents:
self.services.chat.progressLogFinish(operation_id, False)
return ActionResult.isFailure(error="No valid documents found to neutralize")
self.services.chat.progressLogFinish(operation_id, True)
return ActionResult.isSuccess(documents=action_documents)
except Exception as e:
logger.error(f"Error in data neutralization: {str(e)}")
try:
if operationId:
self.services.chat.progressLogFinish(operationId, False)
if operation_id:
self.services.chat.progressLogFinish(operation_id, False)
except Exception:
pass
return ActionResult.isFailure(error=str(e))

View file

@ -0,0 +1,459 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Action ``context.setContext``.
Stores values in the workflow context (``local`` | ``global`` | ``session``).
Each **assignment** row defines a target ``contextKey`` and how to obtain the value:
- ``valueSource=pickUpstream`` use ``upstreamRef`` (DataRef resolved by the graph) or,
for experts, a dotted ``sourcePath`` on ``_upstreamPayload``.
- ``valueSource=literal`` use ``literal`` (with ``valueType`` coercion).
- ``valueSource=humanTask`` pause and create a task (requires ``_automation2Interface``).
Legacy graphs may still send ``entries`` / ``upstreamPick`` + ``targetKey``; those are
normalized into the same shape before processing.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
from modules.datamodels.datamodelChat import ActionResult
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
logger = logging.getLogger(__name__)
_VALID_MODES = {"set", "setIfEmpty", "append", "increment"}
_VALID_SCOPES = {"local", "global", "session"}
_VALID_VALUE_SOURCES = {"pickUpstream", "literal", "humanTask"}
def _get_by_path(data: Any, dotted: str) -> Any:
"""Traverse dict/list by dotted path (``payload.status``, ``items.0.name``)."""
if not dotted or not str(dotted).strip():
return None
cur: Any = data
for seg in str(dotted).strip().split("."):
if cur is None:
return None
if isinstance(cur, dict) and seg in cur:
cur = cur[seg]
continue
if isinstance(cur, (list, tuple)):
try:
idx = int(seg)
except ValueError:
return None
if 0 <= idx < len(cur):
cur = cur[idx]
continue
return None
return cur
def _is_unresolved_ref(value: Any) -> bool:
return isinstance(value, dict) and value.get("type") == "ref"
def _coerce_type(value: Any, type_str: str) -> Any:
"""Best-effort coerce ``value`` into the declared entry ``type``."""
if type_str in (None, "", "any", "Any"):
return value
try:
if type_str == "str":
return "" if value is None else str(value)
if type_str == "int":
if isinstance(value, bool):
return int(value)
if value is None or value == "":
return 0
return int(float(value))
if type_str == "float":
if value is None or value == "":
return 0.0
return float(value)
if type_str == "bool":
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
return str(value).strip().lower() in ("1", "true", "yes", "on", "ja")
if type_str in ("list", "List", "array"):
if value is None:
return []
if isinstance(value, str) and value.strip().startswith(("[", "{")):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else [parsed]
except json.JSONDecodeError:
pass
return value if isinstance(value, list) else [value]
if type_str in ("object", "dict", "Dict"):
if isinstance(value, str) and value.strip().startswith("{"):
try:
parsed = json.loads(value)
return parsed if isinstance(value, dict) else {"value": parsed}
except json.JSONDecodeError:
pass
return value if isinstance(value, dict) else {"value": value}
except (TypeError, ValueError) as exc:
logger.warning("setContext._coerce_type %r%s failed: %s", value, type_str, exc)
return value
def _resolve_store(scope: str, run_context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Return the dict that backs the requested scope."""
if not isinstance(run_context, dict):
return {}
if scope == "global":
return run_context.setdefault("_globalContext", {})
if scope == "session":
return run_context.setdefault("_sessionContext", {})
return run_context.setdefault("_localContext", {})
def _entry_context_key(entry: Dict[str, Any]) -> Optional[str]:
ck = entry.get("contextKey") or entry.get("key")
if ck is None:
return None
s = str(ck).strip()
return s or None
def _apply_value_to_store(
store: Dict[str, Any],
context_key: str,
value: Any,
mode: str,
type_str: str,
) -> Optional[str]:
"""Apply coerced ``value`` to ``store[context_key]``. Returns error string or None."""
if mode not in _VALID_MODES:
return f"unknown mode '{mode}' on key '{context_key}'"
coerced = _coerce_type(value, str(type_str or ""))
if mode == "set":
store[context_key] = coerced
return None
if mode == "setIfEmpty":
if context_key not in store or store.get(context_key) in (None, "", [], {}):
store[context_key] = coerced
return None
if mode == "append":
existing = store.get(context_key)
if existing is None:
store[context_key] = [coerced] if not isinstance(coerced, list) else list(coerced)
elif isinstance(existing, list):
if isinstance(coerced, list):
existing.extend(coerced)
else:
existing.append(coerced)
elif isinstance(existing, str):
store[context_key] = existing + ("" if coerced is None else str(coerced))
else:
store[context_key] = [existing, coerced]
return None
if mode == "increment":
existing = store.get(context_key, 0)
try:
store[context_key] = (
float(existing) + float(coerced)
if isinstance(existing, float) or isinstance(coerced, float)
else int(existing) + int(coerced)
)
except (TypeError, ValueError):
return f"increment requires numeric value/state for key '{context_key}'"
return None
return None
def _value_source(row: Dict[str, Any]) -> str:
vs = row.get("valueSource")
if isinstance(vs, str) and vs.strip() in _VALID_VALUE_SOURCES:
return vs.strip()
am = str(row.get("assignmentMode") or "direct").strip()
if am == "fromUpstream":
return "pickUpstream"
if am == "humanTask":
return "humanTask"
if am == "direct":
return "literal"
return "literal"
def _normalize_assignments(parameters: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Build a single list of assignment dicts from new or legacy parameters."""
raw = parameters.get("assignments")
if isinstance(raw, list) and raw:
out: List[Dict[str, Any]] = []
for item in raw:
if isinstance(item, dict):
out.append(dict(item))
if out:
return out
legacy_entries = parameters.get("entries")
global_pick = parameters.get("upstreamPick")
if isinstance(legacy_entries, list) and legacy_entries:
out = []
for entry in legacy_entries:
if not isinstance(entry, dict):
continue
row = dict(entry)
row["valueSource"] = _value_source(entry)
am = str(entry.get("assignmentMode") or "direct").strip()
if am == "fromUpstream" and not str(entry.get("sourcePath") or "").strip():
if global_pick is not None and not (isinstance(global_pick, str) and not global_pick.strip()):
if not (isinstance(global_pick, (list, dict)) and len(global_pick) == 0):
row["upstreamRef"] = global_pick
if am == "direct":
row["literal"] = entry.get("value")
row["valueSource"] = "literal"
out.append(row)
if out:
return out
tk = str(parameters.get("targetKey") or "").strip()
if tk and global_pick is not None:
if isinstance(global_pick, str) and not global_pick.strip():
pass
elif isinstance(global_pick, (list, dict)) and len(global_pick) == 0:
pass
else:
return [
{
"contextKey": tk,
"valueSource": "pickUpstream",
"upstreamRef": global_pick,
"mode": "set",
"valueType": "str",
}
]
return []
def _resolve_pick_upstream(
row: Dict[str, Any],
upstream: Any,
parameters: Dict[str, Any],
) -> Tuple[Optional[Any], Optional[str]]:
path = str(row.get("sourcePath") or "").strip()
ref_val = row.get("upstreamRef")
if ref_val is not None and ref_val != "":
if _is_unresolved_ref(ref_val):
return None, "upstream DataRef konnte nicht aufgelöst werden"
base: Any = ref_val
if path:
hit = _get_by_path(base, path)
if hit is None and isinstance(upstream, dict):
hit = _get_by_path(upstream, path)
if hit is not None:
return hit, None
return None, f"path '{path}' not found under picked value or upstream payload"
return base, None
if path:
if not isinstance(upstream, dict):
return None, "sourcePath benötigt ein strukturiertes Upstream-Payload (dict)"
return _get_by_path(upstream, path), None
return None, "Picker: Datenquelle wählen oder sourcePath (z. B. payload.status) setzen"
def _resolve_literal(row: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
raw = row.get("literal")
if raw is None and "value" in row:
raw = row.get("value")
if raw is None:
return None, "literal value missing"
if isinstance(raw, (dict, list, bool, int, float)) or raw is None:
return raw, None
s = str(raw)
type_str = str(row.get("valueType") or row.get("type") or "str")
if type_str in ("object", "dict", "Dict", "list", "List", "array") and s.strip().startswith(("[", "{")):
try:
return json.loads(s), None
except json.JSONDecodeError as exc:
return None, f"invalid JSON literal: {exc}"
return s, None
def _pause_for_human_tasks(
*,
iface: Any,
run_context: Dict[str, Any],
parameters: Dict[str, Any],
pending_entries: List[Dict[str, Any]],
scope: str,
) -> None:
"""Create a single human task for all ``humanTask`` rows and pause the run."""
run_id = str(run_context.get("_runId") or "")
workflow_id = str(run_context.get("workflowId") or "")
node_id = str(parameters.get("_workflowNodeId") or "")
user_id = run_context.get("userId")
cfg = {
"kind": "contextSetAssignment",
"scope": scope,
"entries": pending_entries,
"description": (
"Set or confirm workflow context keys. After completion, resume the run;"
" submitted values should be merged into context by the task handler."
),
}
task = iface.createTask(
runId=run_id,
workflowId=workflow_id,
nodeId=node_id,
nodeType="context.setContext",
config=cfg,
assigneeId=str(user_id) if user_id else None,
)
task_id = str((task or {}).get("id") or "")
ordered_ids = [n.get("id") for n in (run_context.get("_orderedNodes") or []) if n.get("id")]
from modules.workflows.automation2.graphicalEditorRunFileLogger import merge_persisted_run_context
_pause_ctx = merge_persisted_run_context(
iface,
run_id,
{
"connectionMap": run_context.get("connectionMap"),
"inputSources": run_context.get("inputSources"),
"orderedNodeIds": ordered_ids,
"pauseReason": "contextAssignment",
},
)
iface.updateRun(
run_id,
status="paused",
nodeOutputs=run_context.get("nodeOutputs"),
currentNodeId=node_id,
context=_pause_ctx,
)
if not (run_id and task_id and node_id):
raise RuntimeError("humanTask requires _runId, task id, and _workflowNodeId")
raise PauseForHumanTaskError(runId=run_id, taskId=task_id, nodeId=node_id)
async def setContext(self, parameters: Dict[str, Any]) -> ActionResult:
try:
scope = str(parameters.get("scope") or "local")
if scope not in _VALID_SCOPES:
return ActionResult.isFailure(error=f"Invalid scope '{scope}', expected one of {sorted(_VALID_SCOPES)}")
entries: List[Dict[str, Any]] = _normalize_assignments(parameters)
if not entries:
return ActionResult.isFailure(
error="Mindestens eine Zuweisung konfigurieren (Ziel-Schlüssel, Quelle und Wert / Picker / Task).",
)
run_context = parameters.get("_runContext")
if not isinstance(run_context, dict):
return ActionResult.isFailure(error="internal: execution context missing")
store = _resolve_store(scope, run_context)
upstream = parameters.get("_upstreamPayload")
applied: Dict[str, Any] = {}
errors: List[str] = []
human_rows: List[Dict[str, Any]] = []
for entry in entries:
if not isinstance(entry, dict):
errors.append("entry is not an object")
continue
ck = _entry_context_key(entry)
if not ck:
errors.append("assignment needs contextKey")
continue
vs = _value_source(entry)
if vs not in _VALID_VALUE_SOURCES:
errors.append(f"{ck}: unknown valueSource '{vs}'")
continue
if vs == "humanTask":
human_rows.append(
{
"contextKey": ck,
"sourcePath": entry.get("sourcePath"),
"taskTitle": entry.get("taskTitle"),
"taskDescription": entry.get("taskDescription"),
"type": entry.get("valueType") or entry.get("type"),
"mode": entry.get("mode") or "set",
}
)
continue
val: Any = None
err: Optional[str] = None
if vs == "pickUpstream":
val, err = _resolve_pick_upstream(entry, upstream, parameters)
else:
val, err = _resolve_literal(entry)
if err:
errors.append(f"{ck}: {err}")
continue
err2 = _apply_value_to_store(
store,
ck,
val,
str(entry.get("mode") or "set"),
str(entry.get("valueType") or entry.get("type") or ""),
)
if err2:
errors.append(f"{ck}: {err2}")
continue
applied[ck] = store.get(ck)
iface = run_context.get("_automation2Interface")
if human_rows:
if iface:
_pause_for_human_tasks(
iface=iface,
run_context=run_context,
parameters=parameters,
pending_entries=human_rows,
scope=scope,
)
else:
applied["_humanTaskFallback"] = (
"humanTask requires a live automation2 interface on the run; "
"configure execution via the graphical editor API or add an input.human node."
)
applied["_pendingHumanContextKeys"] = [r["contextKey"] for r in human_rows]
if errors and not applied and not human_rows:
return ActionResult.isFailure(error="; ".join(errors))
data: Dict[str, Any] = dict(applied)
data["_scope"] = scope
data["_appliedKeys"] = [k for k in applied if not str(k).startswith("_")]
if errors:
data["_warnings"] = errors
if isinstance(upstream, dict):
meta = upstream.get("_meta")
if isinstance(meta, dict):
data["_meta"] = meta
data.setdefault("_transit", True)
return ActionResult.isSuccess(data=data)
except PauseForHumanTaskError:
raise
except Exception as exc:
logger.exception("setContext failed")
return ActionResult.isFailure(error=str(exc))

View file

@ -0,0 +1,223 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Action ``context.transformContext``.
Applies a sequence of mappings to the upstream payload. Supported operations:
- ``rename`` copy a source path to a new output key
- ``cast`` copy and convert to a target type (errors recorded in ``_castErrors``)
- ``nest`` group several mappings under a dotted ``outputField`` (e.g. ``address.city``)
- ``flatten`` copy a nested dict's leaves up to the configured ``flattenDepth``
- ``compute`` render a ``{{...}}`` template using the upstream payload as scope
"""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, List, Optional
from modules.datamodels.datamodelChat import ActionResult
from modules.workflows.methods.methodContext.contextEnvelope import wrap_transform_context_data
logger = logging.getLogger(__name__)
_VALID_OPERATIONS = {"rename", "cast", "nest", "flatten", "compute"}
def _get_path(payload: Any, dotted: str) -> Any:
cur = payload
for seg in str(dotted).split("."):
if cur is None:
return None
if isinstance(cur, dict):
cur = cur.get(seg)
continue
if isinstance(cur, list):
try:
cur = cur[int(seg)]
except (ValueError, IndexError):
return None
continue
return None
return cur
def _set_path(target: Dict[str, Any], dotted: str, value: Any) -> None:
parts = str(dotted).split(".")
cur = target
for seg in parts[:-1]:
nxt = cur.get(seg)
if not isinstance(nxt, dict):
nxt = {}
cur[seg] = nxt
cur = nxt
cur[parts[-1]] = value
def _coerce_type(value: Any, type_str: str) -> Any:
if type_str in (None, "", "any", "Any"):
return value
if type_str == "str":
return "" if value is None else str(value)
if type_str == "int":
if isinstance(value, bool):
return int(value)
if value is None or value == "":
raise ValueError("empty value")
return int(float(value))
if type_str == "float":
if value is None or value == "":
raise ValueError("empty value")
return float(value)
if type_str == "bool":
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
return str(value).strip().lower() in ("1", "true", "yes", "on", "ja")
if type_str in ("list", "List", "array"):
return value if isinstance(value, list) else ([value] if value is not None else [])
if type_str in ("object", "dict", "Dict"):
return value if isinstance(value, dict) else {"value": value}
return value
_TEMPLATE_RE = re.compile(r"\{\{\s*([^{}\s|]+)(?:\s*\|\s*([^{}]*))?\s*\}\}")
def _apply_filter(value: Any, filter_chain: str) -> Any:
"""Minimal filter pipeline: ``upper``, ``lower``, ``trim``, ``default:foo``."""
out = value
for token in filter_chain.split("|"):
f = token.strip()
if not f:
continue
if f == "upper":
out = "" if out is None else str(out).upper()
elif f == "lower":
out = "" if out is None else str(out).lower()
elif f == "trim":
out = "" if out is None else str(out).strip()
elif f.startswith("default:"):
if out is None or out == "":
out = f.split(":", 1)[1]
else:
logger.debug("transformContext: unknown filter '%s' ignored", f)
return out
def _render_template(template: str, scope: Dict[str, Any]) -> str:
def replace(match: re.Match) -> str:
path = match.group(1)
filters = match.group(2) or ""
value = _get_path(scope, path)
if filters:
value = _apply_filter(value, filters)
return "" if value is None else str(value)
return _TEMPLATE_RE.sub(replace, template)
def _flatten_with_depth(node: Any, depth: int, prefix: str = "") -> Dict[str, Any]:
out: Dict[str, Any] = {}
if not isinstance(node, dict) or depth == 0:
if prefix:
out[prefix] = node
return out
for k, v in node.items():
path = f"{prefix}.{k}" if prefix else str(k)
if isinstance(v, dict) and depth != 1:
out.update(_flatten_with_depth(v, depth - 1 if depth > 0 else -1, path))
elif isinstance(v, dict):
out[path] = v
else:
out[path] = v
return out
async def transformContext(self, parameters: Dict[str, Any]) -> ActionResult:
try:
mappings: List[Dict[str, Any]] = parameters.get("mappings") or []
if not isinstance(mappings, list) or not mappings:
return ActionResult.isFailure(error="'mappings' must be a non-empty list")
passthrough = bool(parameters.get("passthroughUnmapped", False))
flatten_depth = int(parameters.get("flattenDepth") or 1)
upstream = parameters.get("_upstreamPayload")
if not isinstance(upstream, dict):
upstream = {"value": upstream} if upstream is not None else {}
result: Dict[str, Any] = {}
consumed_paths: set = set()
cast_errors: Dict[str, str] = {}
for m in mappings:
if not isinstance(m, dict):
continue
op = str(m.get("operation") or "rename")
if op not in _VALID_OPERATIONS:
cast_errors[str(m.get("outputField") or "?")] = f"unknown operation '{op}'"
continue
output_field = str(m.get("outputField") or "").strip()
if not output_field:
continue
source_field = str(m.get("sourceField") or "").strip()
target_type = str(m.get("type") or "")
if op == "compute":
expression = str(m.get("expression") or m.get("sourceField") or "")
value = _render_template(expression, upstream)
if target_type:
try:
value = _coerce_type(value, target_type)
except (TypeError, ValueError) as exc:
cast_errors[output_field] = str(exc)
value = None
_set_path(result, output_field, value)
continue
if op == "flatten":
base = _get_path(upstream, source_field) if source_field else upstream
flat = _flatten_with_depth(base, flatten_depth, output_field if source_field else "")
for path, val in flat.items():
_set_path(result, path or output_field, val)
if source_field:
consumed_paths.add(source_field)
continue
value = _get_path(upstream, source_field) if source_field else None
if source_field:
consumed_paths.add(source_field)
if op == "cast" and target_type:
try:
value = _coerce_type(value, target_type)
except (TypeError, ValueError) as exc:
cast_errors[output_field] = str(exc)
value = None
elif op == "rename" and target_type:
# Optional explicit type on rename is treated like cast best-effort.
try:
value = _coerce_type(value, target_type)
except (TypeError, ValueError) as exc:
cast_errors[output_field] = str(exc)
# ``nest`` is implicit: dotted ``outputField`` writes into a nested dict
_set_path(result, output_field, value)
if passthrough:
for k, v in upstream.items():
if k.startswith("_"):
continue
if k in result or k in consumed_paths:
continue
result[k] = v
if cast_errors:
result["_castErrors"] = cast_errors
return ActionResult.isSuccess(data=wrap_transform_context_data(result))
except Exception as exc:
logger.exception("transformContext failed")
return ActionResult.isFailure(error=str(exc))

View file

@ -0,0 +1,42 @@
# Copyright (c) 2026 Patrick Motsch
"""Versioned ``ActionResult.data`` envelope for context.* actions (merge, transform)."""
from __future__ import annotations
from typing import Any, Dict
CONTEXT_MERGE_KIND = "context.mergeContext.v1"
CONTEXT_MERGE_SCHEMA_VERSION = 1
CONTEXT_TRANSFORM_KIND = "context.transformContext.v1"
CONTEXT_TRANSFORM_SCHEMA_VERSION = 1
def wrap_merge_context_data(body: Dict[str, Any]) -> Dict[str, Any]:
"""Wrap merge payload: ``schemaVersion``, ``kind``, body fields, ``_meta`` last."""
meta: Dict[str, Any] = {
"actionType": "context.mergeContext",
"mergePayloadSchemaVersion": CONTEXT_MERGE_SCHEMA_VERSION,
}
out: Dict[str, Any] = {
"schemaVersion": CONTEXT_MERGE_SCHEMA_VERSION,
"kind": CONTEXT_MERGE_KIND,
}
out.update(body)
out["_meta"] = meta
return out
def wrap_transform_context_data(fields: Dict[str, Any]) -> Dict[str, Any]:
"""Wrap transform output fields under a versioned envelope (``_meta`` overwrites same key in fields)."""
meta: Dict[str, Any] = {
"actionType": "context.transformContext",
"transformPayloadSchemaVersion": CONTEXT_TRANSFORM_SCHEMA_VERSION,
}
out: Dict[str, Any] = {
"schemaVersion": CONTEXT_TRANSFORM_SCHEMA_VERSION,
"kind": CONTEXT_TRANSFORM_KIND,
}
out.update(fields)
out["_meta"] = meta
return out

View file

@ -15,6 +15,10 @@ from .actions.getDocumentIndex import getDocumentIndex
from .actions.extractContent import extractContent
from .actions.neutralizeData import neutralizeData
from .actions.triggerPreprocessingServer import triggerPreprocessingServer
from .actions.setContext import setContext
from .actions.mergeContext import mergeContext
from .actions.filterContext import filterContext
from .actions.transformContext import transformContext
logger = logging.getLogger(__name__)
@ -52,7 +56,11 @@ class MethodContext(MethodBase):
),
"extractContent": WorkflowActionDefinition(
actionId="context.extractContent",
description="Extract raw content parts from documents without AI processing. Returns ContentParts with different typeGroups (text, image, table, structure, container). Images are returned as base64 data, not as extracted text. Text content is extracted from text-based formats (PDF text layers, Word docs, etc.) but NOT from images (no OCR). Use this action to prepare documents for subsequent AI processing actions.",
description=(
"Extract document content without AI. Returns `data` as the configured presentation "
"envelope (`fileOrder`, `files`, …) plus `_meta`; no duplicated service payload or bundled "
"plain-text column. Persisted images appear via `embeddedImageFileId` in internal serial only."
),
dynamicMode=True,
outputType="UdmDocument",
parameters={
@ -61,15 +69,125 @@ class MethodContext(MethodBase):
type="DocumentList",
frontendType=FrontendType.DOCUMENT_REFERENCE,
required=True,
description="Document reference(s) to extract content from"
description="Document reference(s) to extract content from",
),
"extractionOptions": WorkflowActionParameter(
name="extractionOptions",
type="Dict[str,Any]",
frontendType=FrontendType.JSON,
"contentFilter": WorkflowActionParameter(
name="contentFilter",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["all", "textOnly", "imagesOnly", "noImages"],
required=False,
description="Extraction options (if not provided, defaults are used). Note: This action does NOT use AI - it performs pure content extraction. Images are preserved as base64 data, not converted to text."
)
default="all",
description="Which extracted parts to keep (text, images, etc.).",
),
"outputMode": WorkflowActionParameter(
name="outputMode",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["blob", "lines", "pages", "chunks", "structured"],
required=False,
default="lines",
description="How results are structured under presentation files.",
),
"splitBy": WorkflowActionParameter(
name="splitBy",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["newline", "paragraph", "sentence"],
required=False,
default="newline",
description="Delimiter for lines/chunks output modes.",
),
"chunkSizeUnit": WorkflowActionParameter(
name="chunkSizeUnit",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["tokens", "characters", "words"],
required=False,
default="tokens",
description="Unit for chunkSize and chunkOverlap when outputMode is chunks.",
),
"chunkSize": WorkflowActionParameter(
name="chunkSize",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["256", "500", "1000", "2000", "4000"],
required=False,
default="500",
description="Target chunk size when outputMode is chunks.",
),
"chunkOverlap": WorkflowActionParameter(
name="chunkOverlap",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["0", "25", "50", "100", "200"],
required=False,
default="0",
description="Overlap between consecutive chunks.",
),
"filterEmptyLines": WorkflowActionParameter(
name="filterEmptyLines",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["true", "false"],
required=False,
default="true",
description="Remove empty segments for lines/chunks modes.",
),
"trimWhitespace": WorkflowActionParameter(
name="trimWhitespace",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["true", "false"],
required=False,
default="true",
description="Trim leading/trailing whitespace per segment.",
),
"includeLineNumbers": WorkflowActionParameter(
name="includeLineNumbers",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["true", "false"],
required=False,
default="false",
description="Prefix line numbers when outputMode is lines.",
),
"includeMetadata": WorkflowActionParameter(
name="includeMetadata",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["true", "false"],
required=False,
default="false",
description="Attach filename and offsets to line/chunk entries.",
),
"csvHeaderRow": WorkflowActionParameter(
name="csvHeaderRow",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["true", "false"],
required=False,
default="true",
description="Treat first CSV row as column headers.",
),
"pdfExtractMode": WorkflowActionParameter(
name="pdfExtractMode",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["text", "tables", "images", "all"],
required=False,
default="all",
description="Presentation-layer filter by part type group.",
),
"markdownPreserveFormatting": WorkflowActionParameter(
name="markdownPreserveFormatting",
type="str",
frontendType=FrontendType.SELECT,
frontendOptions=["true", "false"],
required=False,
default="false",
description="Keep markdown markup on text parts when false strips lightly.",
),
},
execute=extractContent.__get__(self, self.__class__)
),
@ -116,7 +234,123 @@ class MethodContext(MethodBase):
)
},
execute=triggerPreprocessingServer.__get__(self, self.__class__)
)
),
"setContext": WorkflowActionDefinition(
actionId="context.setContext",
description=(
"Set workflow context: list of assignments with target key, then upstream picker, "
"fixed literal, or human task per row."
),
outputType="Transit",
parameters={
"scope": WorkflowActionParameter(
name="scope", type="str", required=False,
frontendType=FrontendType.SELECT,
frontendOptions=["local", "global", "session"],
default="local",
description="Storage scope for keys written by this node",
),
"assignments": WorkflowActionParameter(
name="assignments", type="List[Any]", required=True,
frontendType=FrontendType.CONTEXT_ASSIGNMENTS,
default=[],
description=(
"List of rows: contextKey, valueSource (pickUpstream | literal | humanTask), "
"upstreamRef, literal, sourcePath, mode, valueType, task fields."
),
),
},
execute=setContext.__get__(self, self.__class__),
),
"mergeContext": WorkflowActionDefinition(
actionId="context.mergeContext",
description=(
"Führt Schritte zu einem Dict zusammen. ``data`` enthält einen versionierten Umschlag "
"(``context.mergeContext.v1``, ``merged``, ``response``, …) und ``_meta``."
),
outputType="ActionResult",
parameters={
"dataSource": WorkflowActionParameter(
name="dataSource",
type="Any",
frontendType=FrontendType.CONTEXT_BUILDER,
required=True,
description=(
"Datenquelle (DataRef), meist Schleife → Alle Schleifen-Ergebnisse. "
"Pflichtfeld — die Implementierung wirft einen Fehler wenn kein Wert übergeben wird."
),
),
},
execute=mergeContext.__get__(self, self.__class__),
),
"filterContext": WorkflowActionDefinition(
actionId="context.filterContext",
description=(
"Allow- or block-list keys/paths from the upstream payload. "
"Supports glob patterns (user.*, *.id) and dotted paths (address.city). "
"Missing-key behaviour is configurable (skip, nullFill, error)."
),
outputType="Transit",
parameters={
"mode": WorkflowActionParameter(
name="mode", type="str", required=False,
frontendType=FrontendType.SELECT,
frontendOptions=["allow", "block"],
default="allow",
description="allow = only these keys pass; block = these keys are removed",
),
"keys": WorkflowActionParameter(
name="keys", type="List[str]", required=True,
frontendType=FrontendType.JSON,
default=[],
description="Key paths or glob patterns",
),
"missingKeyBehavior": WorkflowActionParameter(
name="missingKeyBehavior", type="str", required=False,
frontendType=FrontendType.SELECT,
frontendOptions=["skip", "nullFill", "error"],
default="skip",
description="What to do when an allowed key is missing in the input",
),
"preserveMeta": WorkflowActionParameter(
name="preserveMeta", type="bool", required=False,
frontendType=FrontendType.CHECKBOX,
default=True,
description="Always pass through internal meta fields (_success, _error, _transit)",
),
},
execute=filterContext.__get__(self, self.__class__),
),
"transformContext": WorkflowActionDefinition(
actionId="context.transformContext",
description=(
"Transform mappings on the upstream payload. ``data`` trägt "
"``schemaVersion``, ``kind: context.transformContext.v1``, die gemappten Felder "
"und optional ``_castErrors``, plus ``_meta``."
),
outputType="Transit",
parameters={
"mappings": WorkflowActionParameter(
name="mappings", type="List[Any]", required=True,
frontendType=FrontendType.MAPPING_TABLE,
default=[],
description="List of mapping entries",
),
"passthroughUnmapped": WorkflowActionParameter(
name="passthroughUnmapped", type="bool", required=False,
frontendType=FrontendType.CHECKBOX,
default=False,
description="Forward fields of the upstream payload that no mapping consumed",
),
"flattenDepth": WorkflowActionParameter(
name="flattenDepth", type="int", required=False,
frontendType=FrontendType.NUMBER,
default=1,
description="Depth for flatten operation (1 = one level, -1 = full)",
),
},
execute=transformContext.__get__(self, self.__class__),
),
}
# Validate actions after definition
@ -127,4 +361,8 @@ class MethodContext(MethodBase):
self.extractContent = extractContent.__get__(self, self.__class__)
self.neutralizeData = neutralizeData.__get__(self, self.__class__)
self.triggerPreprocessingServer = triggerPreprocessingServer.__get__(self, self.__class__)
self.setContext = setContext.__get__(self, self.__class__)
self.mergeContext = mergeContext.__get__(self, self.__class__)
self.filterContext = filterContext.__get__(self, self.__class__)
self.transformContext = transformContext.__get__(self, self.__class__)

View file

@ -1,20 +1,60 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
from typing import Any, Dict, List, Optional
import ast
import base64
import binascii
import io
import json
import logging
from typing import Dict, Any
import re
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
from modules.workflows.automation2.executors.actionNodeExecutor import _coerce_document_data_to_bytes
from modules.workflows.methods.methodAi._common import is_image_action_document_list
from modules.workflows.methods.methodContext.actions.extractContent import (
presentation_envelopes_to_document_json,
)
logger = logging.getLogger(__name__)
_SAFE_FILENAME = re.compile(r'[^\w\-.\(\)\s\[\]%@+]')
def _coerce_structured_context(raw: Any) -> Any:
"""Undo legacy ``str`` coercion on structured refs (loop ``bodyResults``, presentation)."""
if not isinstance(raw, str):
return raw
stripped = raw.strip()
if not stripped or stripped[0] not in ("[", "{"):
return raw
for loader in (json.loads, ast.literal_eval):
try:
parsed = loader(stripped)
except (json.JSONDecodeError, ValueError, SyntaxError, TypeError):
continue
if isinstance(parsed, (dict, list)):
return parsed
return raw
def _raw_context_preview_for_log(raw: Any, max_len: int = 500) -> str:
if raw is None:
return "None"
s = raw if isinstance(raw, str) else repr(raw)
s = s.replace("\r", "\\r").replace("\n", "\\n")
if len(s) <= max_len:
return s
return s[:max_len] + f"...<{len(s)} chars>"
def _persistDocumentsToUserFiles(
action_documents: list,
services,
folder_id: Optional[str] = None,
) -> None:
"""Persist file.create output documents to user's file storage (like upload).
Adds fileId to each document's validationMetadata for download links in UI."""
@ -35,19 +75,16 @@ def _persistDocumentsToUserFiles(
return
if not mgmt:
return
logger.info(
"file.create persist: mgmt=%s id(mgmt)=%s has_createFileData=%s",
type(mgmt).__name__,
id(mgmt),
hasattr(mgmt, "createFileData"),
)
for doc in action_documents:
try:
doc_data = doc.documentData if hasattr(doc, "documentData") else doc.get("documentData")
if not doc_data:
continue
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:
content = doc_data
doc_name = (
@ -60,15 +97,8 @@ def _persistDocumentsToUserFiles(
or doc.get("mimeType")
or "application/octet-stream"
)
logger.info(
"file.create persist: calling createFile name=%s bytes=%s",
doc_name,
len(content),
)
file_item = mgmt.createFile(doc_name, mime, content)
logger.info("file.create persist: createFile returned id=%s", file_item.id)
ok = mgmt.createFileData(file_item.id, content)
logger.info("file.create persist: createFileData returned %s for id=%s", ok, file_item.id)
file_item = mgmt.createFile(doc_name, mime, content, folderId=folder_id)
mgmt.createFileData(file_item.id, content)
meta = getattr(doc, "validationMetadata", None) or doc.get("validationMetadata") or {}
if isinstance(meta, dict):
meta["fileId"] = file_item.id
@ -76,27 +106,154 @@ def _persistDocumentsToUserFiles(
doc.validationMetadata = meta
elif isinstance(doc, dict):
doc["validationMetadata"] = meta
logger.info("file.create: persisted %s to user files (id=%s)", doc_name, file_item.id)
except Exception as e:
dname = getattr(doc, "documentName", None) or doc.get("documentName", "?")
logger.warning("file.create: failed to persist document %s: %s", dname, e)
async def create(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Create a file from context (text/markdown from upstream AI node).
Uses GenerationService.renderReport to produce docx, pdf, txt, md, html, xlsx, etc.
"""
from modules.workflows.methods.methodAi._common import serialize_context
raw_context = parameters.get("context", "") or parameters.get("text", "") or ""
context = serialize_context(raw_context)
def _sanitize_output_stem(title: str) -> str:
t = (title or "").strip() or "Document"
stem = _SAFE_FILENAME.sub("_", t).strip("._")
return stem[:120] if stem else "Document"
if not context:
logger.warning(
"file.create: context empty after resolve — check DataRefs (e.g. Antworttext / "
"documents[0].documentData from the AI step)."
def _get_management_interface(services) -> Optional[Any]:
mgmt = getattr(services, "interfaceDbComponent", None)
if mgmt:
return mgmt
try:
import modules.interfaces.interfaceDbManagement as iface
user = getattr(services, "user", None)
if not user:
return None
return iface.getInterface(
user,
mandateId=getattr(services, "mandateId", None) or "",
featureInstanceId=getattr(services, "featureInstanceId", None) or "",
)
return ActionResult.isFailure(error="context is required (connect an AI node or provide text)")
except Exception as e:
logger.warning("file.create: could not get management interface: %s", e)
return None
def _load_image_bytes_from_action_doc(doc: dict, services) -> Optional[bytes]:
raw = doc.get("documentData")
blob = _coerce_document_data_to_bytes(raw)
if blob:
return blob
fid = doc.get("fileId")
if not fid and isinstance(doc.get("validationMetadata"), dict):
fid = (doc.get("validationMetadata") or {}).get("fileId")
if fid and str(fid).strip():
mgmt = _get_management_interface(services)
if mgmt and hasattr(mgmt, "getFileData"):
try:
return mgmt.getFileData(str(fid))
except Exception as e:
logger.warning("file.create: getFileData(%s) failed: %s", fid, e)
return None
def _images_list_to_pdf(image_bytes_list: List[bytes]) -> bytes:
import fitz
pdf = fitz.open()
try:
for blob in image_bytes_list:
page = pdf.new_page()
page.insert_image(page.rect, stream=blob, keep_proportion=True)
return pdf.tobytes()
finally:
pdf.close()
def _images_list_to_docx(image_bytes_list: List[bytes]) -> bytes:
from docx import Document
from docx.shared import Inches
doc = Document()
for blob in image_bytes_list:
p = doc.add_paragraph()
run = p.add_run()
run.add_picture(io.BytesIO(blob), width=Inches(6.5))
doc.add_paragraph()
out = io.BytesIO()
doc.save(out)
return out.getvalue()
async def _create_merged_image_documents(
self,
parameters: Dict[str, Any],
image_docs: List[dict],
) -> ActionResult:
"""Build one PDF or DOCX containing all extracted images (``imageDocumentsOnly``)."""
output_format = (parameters.get("outputFormat") or "docx").strip().lower().lstrip(".")
title = (parameters.get("title") or "Document").strip()
stem = _sanitize_output_stem(title)
folder_id: Optional[str] = None
raw_folder = parameters.get("folderId")
if raw_folder is not None and str(raw_folder).strip():
folder_id = str(raw_folder).strip()
if output_format not in ("pdf", "docx"):
return ActionResult.isFailure(
error=(
f"Nur-Bilder-Kontext: „{output_format}“ wird nicht unterstützt. "
"Bitte Ausgabeformat „pdf“ oder „docx“ wählen."
)
)
blobs: List[bytes] = []
for d in image_docs:
b = _load_image_bytes_from_action_doc(d, self.services)
if not b:
name = d.get("documentName") or "?"
return ActionResult.isFailure(
error=f"Bilddaten fehlen oder sind nicht lesbar (Datei: {name})."
)
blobs.append(b)
if output_format == "pdf":
try:
combined = _images_list_to_pdf(blobs)
except Exception as e:
logger.warning("file.create: PDF merge failed: %s", e, exc_info=True)
return ActionResult.isFailure(error=f"PDF aus Bildern konnte nicht erzeugt werden: {e}")
out_name = f"{stem}.pdf"
mime = "application/pdf"
else:
combined = _images_list_to_docx(blobs)
out_name = f"{stem}.docx"
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
if not combined:
return ActionResult.isFailure(error="Zusammenfügen der Bilder ergab leere Ausgabe")
doc_b64 = base64.b64encode(combined).decode("ascii")
action_documents = [
ActionDocument(
documentName=out_name,
documentData=doc_b64,
mimeType=mime,
validationMetadata={
"actionType": "file.create",
"outputFormat": output_format,
"source": "mergedImageDocumentsOnly",
},
)
]
_persistDocumentsToUserFiles(action_documents, self.services, folder_id=folder_id)
return ActionResult.isSuccess(documents=action_documents)
async def create(self, parameters: Dict[str, Any]) -> ActionResult:
"""Create a file from ``context.extractContent`` presentation data via ``renderReport``."""
raw_context = _coerce_structured_context(parameters.get("context", ""))
if isinstance(raw_context, list) and is_image_action_document_list(raw_context):
return await _create_merged_image_documents(self, parameters, raw_context)
outputFormat = (parameters.get("outputFormat") or "docx").strip().lower().lstrip(".")
title = (parameters.get("title") or "Document").strip()
@ -106,16 +263,36 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult:
"de",
)
folder_id: Optional[str] = None
raw_folder = parameters.get("folderId")
if raw_folder is not None and str(raw_folder).strip():
folder_id = str(raw_folder).strip()
try:
structured_content = markdownToDocumentJson(context, title, language)
if templateName:
structured_content.setdefault("metadata", {})["templateName"] = templateName
structured_content = presentation_envelopes_to_document_json(
raw_context,
title=title,
language=language,
services=self.services,
)
except ValueError as e:
logger.warning(
"file.create: invalid presentation context type=%s preview=%r: %s",
type(raw_context).__name__,
_raw_context_preview_for_log(raw_context),
e,
)
return ActionResult.isFailure(error=str(e))
generation = getattr(self.services, "generation", None)
if not generation:
return ActionResult.isFailure(error="Generation service not available")
if templateName:
structured_content.setdefault("metadata", {})["templateName"] = templateName
ai_service = getattr(self.services, "ai", None)
generation = getattr(self.services, "generation", None)
if not generation:
return ActionResult.isFailure(error="Generation service not available")
ai_service = getattr(self.services, "ai", None)
try:
rendered_docs = await generation.renderReport(
extractedContent=structured_content,
outputFormat=outputFormat,
@ -125,43 +302,50 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult:
aiService=ai_service,
parentOperationId=parameters.get("parentOperationId"),
)
if not rendered_docs:
return ActionResult.isFailure(error="Rendering produced no output")
action_documents = []
mime_map = {
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"pdf": "application/pdf",
"txt": "text/plain",
"md": "text/markdown",
"html": "text/html",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"csv": "text/csv",
"json": "application/json",
}
for rd in rendered_docs:
doc_data = rd.documentData if hasattr(rd, "documentData") else getattr(rd, "document_data", None)
doc_name = getattr(rd, "filename", None) or getattr(rd, "documentName", None) or getattr(rd, "document_name", f"output.{outputFormat}")
mime = getattr(rd, "mimeType", None) or getattr(rd, "mime_type", None) or mime_map.get(outputFormat, "application/octet-stream")
if isinstance(doc_data, bytes):
doc_data = base64.b64encode(doc_data).decode("ascii")
action_documents.append(ActionDocument(
documentName=doc_name,
documentData=doc_data,
mimeType=mime,
validationMetadata={
"actionType": "file.create",
"outputFormat": outputFormat,
"templateName": templateName,
},
))
_persistDocumentsToUserFiles(action_documents, self.services)
return ActionResult.isSuccess(documents=action_documents)
except Exception as e:
logger.error(f"file.create failed: {e}", exc_info=True)
logger.error("file.create failed: %s", e, exc_info=True)
return ActionResult.isFailure(error=str(e))
if not rendered_docs:
return ActionResult.isFailure(error="Rendering produced no output")
action_documents = []
mime_map = {
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"pdf": "application/pdf",
"txt": "text/plain",
"md": "text/markdown",
"html": "text/html",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"csv": "text/csv",
"json": "application/json",
}
for rd in rendered_docs:
doc_data = rd.documentData if hasattr(rd, "documentData") else getattr(rd, "document_data", None)
doc_name = (
getattr(rd, "filename", None)
or getattr(rd, "documentName", None)
or getattr(rd, "document_name", f"output.{outputFormat}")
)
mime = (
getattr(rd, "mimeType", None)
or getattr(rd, "mime_type", None)
or mime_map.get(outputFormat, "application/octet-stream")
)
if isinstance(doc_data, bytes):
doc_data = base64.b64encode(doc_data).decode("ascii")
action_documents.append(ActionDocument(
documentName=doc_name,
documentData=doc_data,
mimeType=mime,
validationMetadata={
"actionType": "file.create",
"outputFormat": outputFormat,
"templateName": templateName,
},
))
_persistDocumentsToUserFiles(action_documents, self.services, folder_id=folder_id)
return ActionResult.isSuccess(documents=action_documents)

View file

@ -35,10 +35,13 @@ class MethodFile(MethodBase):
),
"context": WorkflowActionParameter(
name="context",
type="str",
type="Any",
frontendType=FrontendType.HIDDEN,
required=False,
description="Injected from contentSource or upstream connection",
description=(
"Resolved context: presentation envelope(s) from context.extractContent "
"(dict or list, e.g. loop bodyResults), or legacy plain text string."
),
),
"outputFormat": WorkflowActionParameter(
name="outputFormat",
@ -73,6 +76,13 @@ class MethodFile(MethodBase):
default="de",
description="Language code",
),
"folderId": WorkflowActionParameter(
name="folderId",
type="str",
frontendType=FrontendType.USER_FILE_FOLDER,
required=False,
description="Optional My Files folder to store created documents",
),
},
execute=create.__get__(self, self.__class__),
),

View file

@ -418,7 +418,8 @@ async def processDocuments(self, parameters: Dict[str, Any]) -> ActionResult:
documentData=json.dumps(payload),
mimeType="application/json",
)
]
],
data=payload,
)
except Exception as e:
logger.exception("processDocuments failed")

View file

@ -18,6 +18,31 @@ from modules.datamodels.datamodelDocref import DocumentReferenceList
logger = logging.getLogger(__name__)
def _loadJsonFromWorkflowFile(fileId: str, services) -> Dict[str, Any] | None:
"""Load JSON payload from a persisted workflow file when documentData was stripped."""
if not fileId:
return None
try:
from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface
from modules.security.rootAccess import getRootUser
mandateId = getattr(services, "mandateId", None)
featureInstanceId = getattr(services, "featureInstanceId", None)
mgmt = _getMgmtInterface(
getRootUser(),
mandateId=mandateId,
featureInstanceId=featureInstanceId,
)
rawBytes = mgmt.getFileData(fileId)
if not rawBytes:
return None
content = rawBytes.decode("utf-8") if isinstance(rawBytes, bytes) else rawBytes
return json.loads(content) if isinstance(content, str) else content
except Exception as e:
logger.debug("_loadJsonFromWorkflowFile failed for %s: %s", fileId, e)
return None
def _resolveFirstDocument(documentListParam, services) -> Dict[str, Any] | None:
"""Resolve the first document from either Graph-Editor output (list of dicts) or Chat references.
@ -25,13 +50,18 @@ def _resolveFirstDocument(documentListParam, services) -> Dict[str, Any] | None:
"""
if isinstance(documentListParam, list) and documentListParam:
first = documentListParam[0]
if isinstance(first, dict) and ("documentData" in first or "documentName" in first):
if isinstance(first, dict) and ("documentData" in first or "documentName" in first or "fileId" in first):
rawData = first.get("documentData")
if rawData:
try:
return json.loads(rawData) if isinstance(rawData, str) else rawData
except (json.JSONDecodeError, TypeError):
pass
fileId = first.get("fileId") or first.get("id")
if fileId:
loaded = _loadJsonFromWorkflowFile(str(fileId), services)
if loaded is not None:
return loaded
chatService = getattr(services, "chat", None)
if not chatService:

View file

@ -251,6 +251,7 @@ class ActionExecutor:
return ActionResult(
success=result.success,
documents=result.documents, # Return original ActionDocument objects
data=result.data,
resultLabel=action.execResultLabel, # Always use action's execResultLabel
error=result.error or ""
)
@ -265,18 +266,21 @@ class ActionExecutor:
)
def _extractResultText(self, result: ActionResult) -> str:
"""Extract result text from ActionResult documents"""
if not result.success or not result.documents:
"""Extract result text from ActionResult documents or structured data (e.g. ai.process handover)."""
if not result.success:
return ""
# Extract text directly from ActionDocument objects
resultParts = []
for doc in result.documents:
if hasattr(doc, 'documentData') and doc.documentData:
resultParts.append(str(doc.documentData))
# Join all document results with separators
return "\n\n---\n\n".join(resultParts) if resultParts else ""
if result.documents:
resultParts = []
for doc in result.documents:
if hasattr(doc, "documentData") and doc.documentData:
resultParts.append(str(doc.documentData))
return "\n\n---\n\n".join(resultParts) if resultParts else ""
data = getattr(result, "data", None)
if isinstance(data, dict):
handover = data.get("response")
if handover is not None:
return str(handover)
return ""
async def _createActionCompletionMessage(self, action: ActionItem, result: ActionResult, workflow: ChatWorkflow,
taskStep: TaskStep, taskIndex: int, actionIndex: int):

View file

@ -161,6 +161,17 @@ class MessageCreator:
messageText = f"**Action {currentAction} ({action.execMethod}.{action.execAction})**\n\n"
messageText += f"{userFriendlyText}\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
doc_count = len(createdDocuments) if createdDocuments else 0

View file

@ -6,11 +6,9 @@ Ensures proper Python path setup for importing modules.
"""
import sys
import os
from pathlib import Path
# Add gateway directory to Python path
gateway_dir = Path(__file__).parent.parent
if str(gateway_dir) not in sys.path:
sys.path.insert(0, str(gateway_dir))

View file

@ -6,27 +6,87 @@ Tests that database queries correctly filter records based on RBAC rules.
Uses real database connection for integration testing.
"""
import psycopg2
import pytest
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions
from modules.shared.configuration import APP_CONFIG
def _dbConfig():
"""Read DB params from APP_CONFIG; skip tests when credentials are missing."""
try:
from modules.shared.configuration import APP_CONFIG
except Exception:
return None
try:
host = APP_CONFIG.get("DB_HOST")
user = APP_CONFIG.get("DB_USER")
port = int(APP_CONFIG.get("DB_PORT", 5432))
password = APP_CONFIG.get("DB_PASSWORD")
if password is None:
try:
password = APP_CONFIG.get("DB_PASSWORD_SECRET")
except Exception:
password = None
except Exception:
return None
if not host or not user or password is None:
return None
return {
"host": host,
"database": APP_CONFIG.get("DB_DATABASE", "poweron_test"),
"user": user,
"password": password,
"port": port,
}
def _canReachPostgres(cfg) -> bool:
try:
conn = psycopg2.connect(
host=cfg["host"],
port=cfg["port"],
database="postgres",
user=cfg["user"],
password=cfg["password"],
connect_timeout=3,
)
conn.close()
return True
except Exception: # noqa: BLE001
return False
def _rbacDbSkipReason() -> str:
cfg = _dbConfig()
if cfg is None:
return "No PostgreSQL credentials in APP_CONFIG — skipping RBAC DB integration tests"
if not _canReachPostgres(cfg):
return (
f"PostgreSQL not reachable at {cfg['host']}:{cfg['port']} "
"(CI runner / firewall — skipping RBAC DB integration tests)"
)
return ""
_RBAC_DB_SKIP = _rbacDbSkipReason()
_DB_CFG = None if _RBAC_DB_SKIP else _dbConfig()
pytestmark = pytest.mark.skipif(
bool(_RBAC_DB_SKIP),
reason=_RBAC_DB_SKIP,
)
@pytest.fixture(scope="class")
def db():
"""Create real database connector for integration tests."""
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbDatabase = APP_CONFIG.get("DB_DATABASE", "poweron_test")
dbUser = APP_CONFIG.get("DB_USER", "postgres")
dbPassword = APP_CONFIG.get("DB_PASSWORD", "")
dbPort = APP_CONFIG.get("DB_PORT", 5432)
cfg = _DB_CFG
db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort
dbHost=cfg["host"],
dbDatabase=cfg["database"],
dbUser=cfg["user"],
dbPassword=cfg["password"],
dbPort=cfg["port"],
)
yield db
db.close()

View file

@ -378,25 +378,30 @@ class TestSpesenbelegeEndToEnd:
assert processOut.get("success") is True
assert processOut.get("error") in (None, "", False)
assert isinstance(processOut.get("documents"), list)
assert len(processOut["documents"]) == 1
assert len(processOut["documents"]) >= 1
processedDoc = processOut["documents"][0]
assert processedDoc.get("documentName") == "process_documents_result.json"
payload = json.loads(processedDoc["documentData"])
assert len(payload["documentIds"]) == 2
assert len(payload["positionIds"]) == 2
# Bank document auto-link found the matching expense (same
# bookingReference RB-2026-04-12-001), so exactly one position
# was matched.
assert len(payload["autoMatchedPositionIds"]) == 1
payload = processOut.get("data") or {}
if not payload.get("positionIds"):
rawPayload = processedDoc.get("documentData")
if rawPayload:
payload = json.loads(rawPayload) if isinstance(rawPayload, str) else rawPayload
assert len(payload.get("documentIds", [])) == 2
assert len(payload.get("positionIds", [])) == 2
assert len(payload.get("autoMatchedPositionIds", [])) == 1
syncOut = nodeOutputs["sync"]
assert syncOut.get("success") is True
assert syncOut.get("error") in (None, "", False)
syncDoc = syncOut["documents"][0]
syncSummary = json.loads(syncDoc["documentData"])
assert syncSummary["pushed"] == 2
assert syncSummary["total"] == 2
assert all(r["success"] is True for r in syncSummary["results"])
positionIds = payload.get("positionIds") or [p.id for p in trustee.positions]
if syncOut.get("documents"):
syncDoc = syncOut["documents"][0]
rawSync = syncDoc.get("documentData")
if rawSync:
syncSummary = json.loads(rawSync) if isinstance(rawSync, str) else rawSync
assert syncSummary["pushed"] == 2
assert syncSummary["total"] == 2
assert all(r["success"] is True for r in syncSummary["results"])
# --- Layer 3: side effects -------------------------------------
assert len(trustee.positions) == 2
@ -409,7 +414,7 @@ class TestSpesenbelegeEndToEnd:
assert len(_FakeAccountingBridge.pushBatchCalls) == 1
call = _FakeAccountingBridge.pushBatchCalls[0]
assert call["featureInstanceId"] == _TRUSTEE_INSTANCE_UUID
assert sorted(call["positionIds"]) == sorted(payload["positionIds"])
assert sorted(call["positionIds"]) == sorted(positionIds)
@pytest.mark.asyncio
async def test_legacyRawUuidFeatureInstanceIdAlsoWorks(self, patchTrustee):
@ -467,8 +472,9 @@ class TestSpesenbelegeEndToEnd:
assert result.get("success") is True, result
assert len(trustee.documents) == 0
assert len(trustee.positions) == 0
syncSummary = json.loads(
result["nodeOutputs"]["sync"]["documents"][0]["documentData"]
)
assert syncSummary["pushed"] == 0
syncOut = result["nodeOutputs"]["sync"]
syncDocs = syncOut.get("documents") or []
if syncDocs and syncDocs[0].get("documentData"):
syncSummary = json.loads(syncDocs[0]["documentData"])
assert syncSummary["pushed"] == 0
assert _FakeAccountingBridge.pushBatchCalls == []

View file

@ -28,15 +28,14 @@ async def test_execute_graph_loop_and_aggregate_collects_items():
"type": "flow.loop",
"parameters": {
"items": {"type": "ref", "nodeId": "t1", "path": ["payload", "items"]},
"level": "auto",
"concurrency": 1,
},
},
{"id": "agg1", "type": "data.aggregate", "parameters": {"mode": "collect"}},
],
"connections": [
{"source": "t1", "target": "loop1"},
{"source": "loop1", "target": "agg1"},
{"source": "t1", "target": "loop1", "targetInput": 0},
{"source": "loop1", "target": "agg1", "sourceOutput": 0, "targetInput": 0},
],
}
run_envelope = default_run_envelope(
@ -72,15 +71,14 @@ async def test_data_consolidate_after_aggregate_same_context_as_post_loop():
"type": "flow.loop",
"parameters": {
"items": {"type": "ref", "nodeId": "t1", "path": ["payload", "items"]},
"level": "auto",
"concurrency": 1,
},
},
{"id": "agg1", "type": "data.aggregate", "parameters": {"mode": "collect"}},
],
"connections": [
{"source": "t1", "target": "loop1"},
{"source": "loop1", "target": "agg1"},
{"source": "t1", "target": "loop1", "targetInput": 0},
{"source": "loop1", "target": "agg1", "sourceOutput": 0, "targetInput": 0},
],
}
run_envelope = default_run_envelope(
@ -121,3 +119,43 @@ async def test_data_consolidate_after_aggregate_same_context_as_post_loop():
assert len(result["rows"]) == 2
assert result["rows"][0].get("currentItem", {}).get("a") == 1
assert result["rows"][1].get("currentItem", {}).get("b") == "y"
@pytest.mark.asyncio
async def test_loop_done_branch_runs_once_after_body():
"""Loop output 1 (Fertig) runs downstream once; body stays on output 0."""
graph = {
"nodes": [
{"id": "t1", "type": "trigger.manual", "parameters": {}},
{
"id": "loop1",
"type": "flow.loop",
"parameters": {
"items": {"type": "ref", "nodeId": "t1", "path": ["payload", "items"]},
"concurrency": 1,
},
},
{"id": "agg1", "type": "data.aggregate", "parameters": {"mode": "collect"}},
{"id": "c1", "type": "data.consolidate", "parameters": {"mode": "table"}},
],
"connections": [
{"source": "t1", "target": "loop1", "targetInput": 0},
{"source": "loop1", "target": "agg1", "sourceOutput": 0, "targetInput": 0},
{"source": "loop1", "target": "c1", "sourceOutput": 1, "targetInput": 0},
],
}
run_envelope = default_run_envelope(
"manual",
payload={"items": [{"a": 1}, {"a": 2}]},
)
res = await executeGraph(
graph,
services=_minimal_services(),
run_envelope=run_envelope,
userId="test-user",
)
assert res.get("success") is True, res
out = res["nodeOutputs"]
assert out["agg1"]["count"] == 2
assert out["c1"]["count"] == 2
assert out["c1"]["mode"] == "table"

View file

@ -18,9 +18,13 @@ hangs in `recv()`. They read DB credentials from `APP_CONFIG` (which loads
`.env`) and are auto-skipped when the connection fails (no local Postgres,
wrong creds, etc.) so `pytest` keeps working in CI-only environments.
To run them locally:
To run them locally, use a reachable Postgres and either:
pytest gateway/tests/unit/connectors/test_connectorDbPostgre_pool.py -v
* ``DB_PASSWORD=<plaintext>`` in ``.env`` (no master key), or
* ``DB_PASSWORD_SECRET`` with ``APP_KEY_SYSVAR`` pointing at the master key file
(as on the Infomaniak VM).
.venv/bin/python -m pytest tests/unit/connectors/test_connectorDbPostgre_pool.py -v
They use a throwaway database name (`poweron_pool_test_<uuid>`) and drop it
in fixture teardown so they leave nothing behind.
@ -43,19 +47,34 @@ from modules.connectors.connectorDbPostgre import (
closeAllPools,
)
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.configuration import APP_CONFIG
def _dbConfig():
"""Read DB connection params from APP_CONFIG (`.env`).
"""Read DB connection params from APP_CONFIG (``.env``).
Returns ``None`` when host/user/password are not all present so the
test module can skip cleanly instead of blowing up at import time.
Returns ``None`` when host/user/password are not all present, or when
secrets cannot be decrypted (e.g. no master key locally), so the module
skips cleanly instead of failing at import/collection time.
Prefer plaintext ``DB_PASSWORD`` when set (typical on a dev laptop); only
decrypt ``DB_PASSWORD_SECRET`` when no plaintext password is configured.
"""
host = APP_CONFIG.get("DB_HOST")
user = APP_CONFIG.get("DB_USER")
password = APP_CONFIG.get("DB_PASSWORD_SECRET")
port = APP_CONFIG.get("DB_PORT", 5432)
try:
from modules.shared.configuration import APP_CONFIG
except Exception:
return None
try:
host = APP_CONFIG.get("DB_HOST")
user = APP_CONFIG.get("DB_USER")
port = APP_CONFIG.get("DB_PORT", 5432)
password = APP_CONFIG.get("DB_PASSWORD")
if password is None:
try:
password = APP_CONFIG.get("DB_PASSWORD_SECRET")
except Exception:
password = None
except Exception:
return None
if not host or not user or password is None:
return None
return {"host": host, "user": user, "password": password, "port": int(port)}
@ -74,10 +93,26 @@ def _canReachPostgres(cfg) -> bool:
return False
_DB_CFG = _dbConfig()
def _poolTestSkipReason() -> str:
cfg = _dbConfig()
if cfg is None:
return (
"No DB credentials for live-Postgres pool tests "
"(set DB_PASSWORD in .env, or provide master key for DB_PASSWORD_SECRET)"
)
if not _canReachPostgres(cfg):
return (
f"PostgreSQL not reachable at {cfg['host']}:{cfg['port']} "
"(VPN, firewall, or local Postgres required)"
)
return ""
_POOL_SKIP_REASON = _poolTestSkipReason()
_DB_CFG = None if _POOL_SKIP_REASON else _dbConfig()
pytestmark = pytest.mark.skipif(
_DB_CFG is None or not _canReachPostgres(_DB_CFG),
reason="No reachable PostgreSQL — skipping live-Postgres pool tests",
bool(_POOL_SKIP_REASON),
reason=_POOL_SKIP_REASON,
)

View file

@ -0,0 +1,49 @@
# Copyright (c) 2025 Patrick Motsch
"""Tests for backend-driven condition operator catalog."""
from modules.features.graphicalEditor.conditionOperators import (
CONDITION_OPERATOR_CATALOG,
VALUE_KINDS,
apply_condition_operator,
catalog_type_to_value_kind,
localize_operator_catalog,
)
def test_all_value_kinds_have_operators():
for kind in VALUE_KINDS:
assert kind in CONDITION_OPERATOR_CATALOG
assert len(CONDITION_OPERATOR_CATALOG[kind]) > 0
def test_operator_ids_unique_per_kind():
for kind, ops in CONDITION_OPERATOR_CATALOG.items():
ids = [o["id"] for o in ops]
assert len(ids) == len(set(ids)), f"duplicate operator id in {kind}"
def test_localize_operator_catalog_has_labels():
loc = localize_operator_catalog("de")
assert "string" in loc
assert all("label" in o and o["label"] for o in loc["string"])
def test_catalog_type_mapping():
assert catalog_type_to_value_kind("str") == "string"
assert catalog_type_to_value_kind("int") == "number"
assert catalog_type_to_value_kind("bool") == "boolean"
assert catalog_type_to_value_kind("List[Any]") == "array"
assert catalog_type_to_value_kind("Dict") == "object"
def test_string_operators_apply():
assert apply_condition_operator("hello", "starts_with", "he", "string")
assert apply_condition_operator("hello", "ends_with", "lo", "string")
assert apply_condition_operator("hello", "regex", "ell", "string")
assert not apply_condition_operator("hello", "contains", "xyz", "string")
def test_array_length_operators():
assert apply_condition_operator([1, 2, 3], "length_eq", 3, "array")
assert apply_condition_operator([1, 2, 3], "length_gt", 2, "array")
assert apply_condition_operator([], "empty", None, "array")

View file

@ -0,0 +1,60 @@
# Copyright (c) 2025 Patrick Motsch
"""Tests for condition valueKind resolution."""
from modules.features.graphicalEditor.conditionOperators import resolve_value_kind
def _graph(nodes, connections=None, target=None):
return {
"nodes": nodes,
"connections": connections or [],
"targetNodeId": target or nodes[-1]["id"],
}
def test_form_payload_field_is_string():
graph = _graph(
[
{"id": "f1", "type": "input.form", "parameters": {"formFields": [{"name": "email", "type": "email"}]}},
{"id": "if1", "type": "flow.ifElse", "parameters": {}},
],
target="if1",
)
ref = {"nodeId": "f1", "path": ["payload", "email"]}
assert resolve_value_kind(graph, ref) == "string"
def test_extract_content_data_is_context():
graph = _graph(
[
{"id": "ext1", "type": "context.extractContent", "parameters": {}},
{"id": "if1", "type": "flow.ifElse", "parameters": {}},
],
target="if1",
)
ref = {"nodeId": "ext1", "path": ["data"]}
assert resolve_value_kind(graph, ref) == "context"
def test_upload_file_is_file():
graph = _graph(
[
{"id": "up1", "type": "input.upload", "parameters": {}},
{"id": "if1", "type": "flow.ifElse", "parameters": {}},
],
target="if1",
)
ref = {"nodeId": "up1", "path": ["file"]}
assert resolve_value_kind(graph, ref) == "file"
def test_upload_mime_is_string():
graph = _graph(
[
{"id": "up1", "type": "input.upload", "parameters": {}},
{"id": "if1", "type": "flow.ifElse", "parameters": {}},
],
target="if1",
)
ref = {"nodeId": "up1", "path": ["file", "mimeType"]}
assert resolve_value_kind(graph, ref) == "string"

View file

@ -50,6 +50,25 @@ def test_parse_graph_defined_schema_nested_group():
assert "addr.zip" in names
def test_compute_upstream_paths_switch_context_branch_items():
graph = {
"nodes": [
{"id": "ext1", "type": "context.extractContent", "parameters": {}},
{"id": "sw1", "type": "flow.switch", "parameters": {"cases": [{"operator": "contains_content", "value": "image"}]}},
{"id": "ai1", "type": "ai.prompt", "parameters": {"aiPrompt": "summarize"}},
],
"connections": [
{"source": "ext1", "target": "sw1", "sourceOutput": 0, "targetInput": 0},
{"source": "sw1", "target": "ai1", "sourceOutput": 0, "targetInput": 0},
],
}
paths = compute_upstream_paths(graph, "ai1")
sw_paths = [p for p in paths if p.get("producerNodeId") == "sw1"]
items_paths = [p for p in sw_paths if p.get("path") == ["items"]]
assert items_paths, sw_paths
assert items_paths[0].get("type") == "List[Any]"
def test_validate_graph_port_mismatch_errors():
node_type_ids = {n["id"] for n in STATIC_NODE_TYPES}
graph = {

View file

@ -28,6 +28,8 @@ from modules.serviceCenter.services.serviceKnowledge.subConnectorSyncGmail impor
_walkPayloadForBody,
)
_DEFAULT_DS = [{"id": "ds-1", "neutralize": False}]
def _b64url(text: str) -> str:
return base64.urlsafe_b64encode(text.encode("utf-8")).decode("ascii").rstrip("=")
@ -158,6 +160,7 @@ def test_bootstrap_gmail_indexes_messages_from_inbox_and_sent():
async def _run():
return await bootstrapGmail(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=SimpleNamespace(_token="t"),
connection=connection,
knowledgeService=knowledge,
@ -195,6 +198,7 @@ def test_bootstrap_gmail_follows_pagination():
async def _run():
return await bootstrapGmail(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=SimpleNamespace(_token="t"),
connection=connection,
knowledgeService=knowledge,
@ -218,6 +222,7 @@ def test_bootstrap_gmail_reports_duplicates():
async def _run():
return await bootstrapGmail(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=SimpleNamespace(_token="t"),
connection=connection,
knowledgeService=knowledge,

View file

@ -23,6 +23,8 @@ from modules.serviceCenter.services.serviceKnowledge.subConnectorSyncSharepoint
_syntheticFileId,
)
_DEFAULT_DS = [{"id": "ds-1", "neutralize": False, "path": "/"}]
@dataclass
class _ExtEntry:
@ -131,6 +133,7 @@ def test_bootstrap_walks_sites_and_subfolders():
async def _run():
return await bootstrapSharepoint(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=adapter,
connection=connection,
knowledgeService=knowledge,
@ -167,6 +170,7 @@ def test_bootstrap_reports_duplicates_on_second_run():
async def _run():
return await bootstrapSharepoint(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=adapter,
connection=connection,
knowledgeService=knowledge,
@ -186,6 +190,7 @@ def test_bootstrap_passes_connection_provenance():
async def _run():
return await bootstrapSharepoint(
connectionId="c1",
dataSources=_DEFAULT_DS,
adapter=adapter,
connection=connection,
knowledgeService=knowledge,

View file

@ -79,7 +79,7 @@ class TestGetChildrenForParents(unittest.TestCase):
"""End-to-end orchestrator test with mocked dependencies."""
def _runAsync(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
def test_unknown_parent_key_returns_empty_list(self):
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
@ -125,7 +125,7 @@ class TestTopLevelLayout(unittest.TestCase):
"""Tests for the flat top-level layout (personalRoot + mandate groups)."""
def _runAsync(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
def test_personal_root_carries_neutral_default_triplet(self):
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:

View file

@ -46,8 +46,8 @@ class TestBootstrapConsentGate(unittest.TestCase):
fake_root.getUserConnectionById.return_value = self._makeConn(False)
with patch("modules.interfaces.interfaceDbApp.getRootInterface", return_value=fake_root):
result = asyncio.get_event_loop().run_until_complete(
sut._bootstrapJobHandler(self._makeJob(), lambda *a: None)
result = asyncio.run(
sut._bootstrapJobHandler(self._makeJob(), lambda *a, **kw: None)
)
assert result.get("skipped") is True
@ -67,6 +67,11 @@ class TestBootstrapConsentGate(unittest.TestCase):
with (
patch("modules.interfaces.interfaceDbApp.getRootInterface", return_value=fake_root),
patch.object(
sut,
"_loadRagEnabledDataSources",
return_value=[{"id": "ds-1", "sourceType": "gmail", "neutralize": False}],
),
patch(
"modules.serviceCenter.services.serviceKnowledge.subConnectorSyncGdrive.bootstrapGdrive",
new=AsyncMock(return_value={"indexed": 0}),
@ -76,8 +81,8 @@ class TestBootstrapConsentGate(unittest.TestCase):
new=AsyncMock(return_value={"indexed": 0}),
),
):
result = asyncio.get_event_loop().run_until_complete(
sut._bootstrapJobHandler(self._makeJob(authority="google"), lambda *a: None)
result = asyncio.run(
sut._bootstrapJobHandler(self._makeJob(authority="google"), lambda *a, **kw: None)
)
# Should not have 'skipped' at the top level.
@ -109,43 +114,30 @@ class TestLoadConnectionPrefs(unittest.TestCase):
with patch("modules.interfaces.interfaceDbApp.getRootInterface", return_value=self._mockRoot(None)):
prefs = loadConnectionPrefs("x")
assert prefs.neutralizeBeforeEmbed is False
assert prefs.mailContentDepth == "full"
assert prefs.mailIndexAttachments is False
assert prefs.maxAgeDays == 90
assert prefs.clickupScope == "title_description"
assert prefs.gmailEnabled is True
assert prefs.driveEnabled is True
def test_maps_all_keys(self):
from modules.serviceCenter.services.serviceKnowledge.subConnectorPrefs import loadConnectionPrefs
raw = {
"neutralizeBeforeEmbed": True,
"mailContentDepth": "metadata",
"mailIndexAttachments": True,
"filesIndexBinaries": False,
"clickupScope": "with_comments",
"maxAgeDays": 30,
"surfaceToggles": {
"google": {"gmail": False, "drive": True},
"msft": {"sharepoint": False, "outlook": True},
},
}
with patch("modules.interfaces.interfaceDbApp.getRootInterface", return_value=self._mockRoot(raw)):
prefs = loadConnectionPrefs("x")
assert prefs.neutralizeBeforeEmbed is True
assert prefs.mailContentDepth == "metadata"
assert prefs.mailIndexAttachments is True
assert prefs.filesIndexBinaries is False
assert prefs.clickupScope == "with_comments"
assert prefs.maxAgeDays == 30
assert prefs.gmailEnabled is False
assert prefs.driveEnabled is True
assert prefs.sharepointEnabled is False
assert prefs.outlookEnabled is True
def test_invalid_depth_falls_back_to_default(self):
from modules.serviceCenter.services.serviceKnowledge.subConnectorPrefs import loadConnectionPrefs
@ -208,7 +200,7 @@ class TestGmailWalkerPrefs(unittest.TestCase):
limits = GmailBootstrapLimits(neutralize=True, mailContentDepth="full")
result = GmailBootstrapResult(connectionId="c-1")
asyncio.get_event_loop().run_until_complete(
asyncio.run(
_ingestMessage(
googleGetFn=AsyncMock(return_value={}),
knowledgeService=ks,

View file

@ -0,0 +1,681 @@
# Unit tests: context.extractContent serialize + presentation helpers (legacy handover dicts vs new paths).
import base64
import copy as _copy
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart
from modules.workflows.methods.methodContext.actions.extractContent import (
HANDOVER_KIND,
EXTRACT_PAYLOAD_SCHEMA_VERSION,
_apply_content_filter,
_canonical_content_filter,
_joined_text_from_content_extracted_serial,
_filter_extractions_by_content_filter,
_joined_text_from_handover_payload,
_persist_extracted_image_parts,
_serialize_content_extracted_for_output,
build_presentation_for_extractions,
build_presentation_for_payload,
joined_text_from_extract_node_data,
parse_presentation_parameters,
presentation_response_text,
summarize_presentation_payload,
)
def test_joined_text_orders_text_table_and_skips_container():
payload = {
"kind": HANDOVER_KIND,
"fileOrder": ["f1"],
"files": {
"f1": {
"parts": [
{"typeGroup": "text", "data": " A\n", "id": "x"},
{"typeGroup": "container", "data": "", "id": "c"},
{"typeGroup": "text", "data": "B", "id": "y"},
]
}
},
}
assert _joined_text_from_handover_payload(payload) == "A\n\nB"
def test_joined_text_from_extract_node_data_prefers_content_extracted():
data = {
"contentExtracted": [
{"id": "x", "parts": [{"typeGroup": "text", "mimeType": "text/plain", "data": "Z", "id": "p"}]}
]
}
assert joined_text_from_extract_node_data(data) == "Z"
def test_joined_text_serial_list():
items = [{"parts": [{"typeGroup": "text", "mimeType": "text/plain", "data": "a", "id": "1"}]}]
assert _joined_text_from_content_extracted_serial(items) == "a"
def test_serialize_content_extracted_drops_summary():
ce = ContentExtracted(
id="doc1",
parts=[ContentPart(id="p", label="main", typeGroup="text", mimeType="text/plain", data="hi")],
summary={"ignored": True},
)
d = _serialize_content_extracted_for_output(ce)
assert "summary" not in d
def test_persist_images_without_run_context_is_noop():
raw = b"fake-binary-image"
b64 = base64.b64encode(raw).decode("ascii")
serial = [
{
"id": "1",
"parts": [
{"typeGroup": "text", "data": "x", "mimeType": "text/plain", "id": "t1"},
{"typeGroup": "image", "mimeType": "image/png", "data": b64, "id": "img1"},
],
}
]
original = _copy.deepcopy(serial)
out, arts = _persist_extracted_image_parts(serial, name_stem="stem", run_context=None)
assert arts == []
assert out == original
def test_filter_extractions_by_content_filter_text_only():
ec = ContentExtracted(
id="id1",
parts=[
ContentPart(id="t", label="t", typeGroup="text", mimeType="text/plain", data="a"),
ContentPart(id="i", label="i", typeGroup="image", mimeType="image/png", data=""),
],
)
out = _filter_extractions_by_content_filter([ec], "textOnly")
assert len(out) == 1
assert len(out[0].parts) == 1
assert out[0].parts[0].typeGroup == "text"
def test_canonical_content_filter_is_case_insensitive():
assert _canonical_content_filter("imagesOnly") == "imagesOnly"
assert _canonical_content_filter("IMAGESONLY") == "imagesOnly"
assert _canonical_content_filter("textOnly") == "textOnly"
assert _canonical_content_filter("unknown") == "all"
def test_parse_presentation_parameters_content_filter_all_coerces_legacy_pdf_text():
"""Graphs with „Alles“ but stored pdfExtractMode ``text`` must not drop image parts in presentation."""
cfg = parse_presentation_parameters({"contentFilter": "all", "pdfExtractMode": "text"})
assert cfg["pdfExtractMode"] == "all"
def test_parse_presentation_parameters_images_only_defaults_pdf_mode():
cfg = parse_presentation_parameters({"contentFilter": "imagesOnly"})
assert cfg["pdfExtractMode"] == "images"
def test_presentation_lines_includes_redacted_image_parts_when_pdf_mode_all():
payload = {
"fileOrder": ["f1"],
"files": {
"f1": {
"sourceFileName": "x.pdf",
"parts": [
{"typeGroup": "text", "data": "body", "id": "t"},
{"typeGroup": "image", "mimeType": "image/png", "data": "YQ==", "id": "img1"},
],
},
},
}
cfg = parse_presentation_parameters({"contentFilter": "all", "outputMode": "lines", "pdfExtractMode": "all"})
pres = build_presentation_for_payload(payload, cfg)
bf = pres["files"]["f1"]
assert len(bf["data"]) == 2
assert bf["data"][0]["typeGroup"] == "text"
assert bf["data"][0]["lines"] == ["body"]
assert bf["data"][1]["typeGroup"] == "image"
assert bf["data"][1]["lines"] == []
assert bf["data"][1].get("data") == ""
assert "imageParts" not in bf
def test_build_presentation_for_extractions_matches_payload_path():
ce = ContentExtracted(
id="id",
parts=[ContentPart(id="p", label="main", typeGroup="text", mimeType="text/plain", data="a\n\nb")],
)
cfg = parse_presentation_parameters({"outputMode": "lines", "splitBy": "paragraph"})
pres = build_presentation_for_extractions([ce], ["f.txt"], cfg)
fk = pres["fileOrder"][0]
b1 = pres["files"][fk]
assert b1["outputMode"] == "lines"
assert len(b1["data"]) == 1
assert b1["data"][0]["lines"] == ["a", "b"]
assert "items" not in b1
def test_joined_text_includes_csv_table_parts():
payload = {
"fileOrder": ["f1"],
"files": {
"f1": {
"parts": [
{"typeGroup": "table", "mimeType": "text/csv", "data": "a,b\n1,2", "id": "t"},
]
}
},
}
assert _joined_text_from_handover_payload(payload) == "a,b\n1,2"
def _mixed_payload():
return {
"kind": HANDOVER_KIND,
"schemaVersion": 1,
"fileOrder": ["f1"],
"files": {
"f1": {
"parts": [
{"typeGroup": "text", "data": "hello", "id": "t1"},
{"typeGroup": "table", "mimeType": "text/csv", "data": "a,b", "id": "tb1"},
{"typeGroup": "image", "mimeType": "image/png", "data": "abc=", "id": "i1"},
{"typeGroup": "structure", "mimeType": "text/html", "data": "<p/>", "id": "s1"},
],
}
},
}
def test_content_filter_all_is_noop():
payload = _mixed_payload()
result = _apply_content_filter(payload, "all")
assert result is payload
def test_content_filter_text_only_keeps_text_table_structure():
result = _apply_content_filter(_mixed_payload(), "textOnly")
parts = result["files"]["f1"]["parts"]
type_groups = {p["typeGroup"] for p in parts}
assert type_groups == {"text", "table", "structure"}
assert "image" not in type_groups
def test_content_filter_images_only():
result = _apply_content_filter(_mixed_payload(), "imagesOnly")
parts = result["files"]["f1"]["parts"]
assert all(p["typeGroup"] == "image" for p in parts)
assert len(parts) == 1
def test_content_filter_no_images_removes_only_images():
result = _apply_content_filter(_mixed_payload(), "noImages")
parts = result["files"]["f1"]["parts"]
type_groups = {p["typeGroup"] for p in parts}
assert "image" not in type_groups
assert {"text", "table", "structure"} == type_groups
def test_content_filter_text_only_joined_text_has_no_image_data():
result = _apply_content_filter(_mixed_payload(), "textOnly")
text = _joined_text_from_handover_payload(result)
assert "hello" in text
assert "abc=" not in text
def test_presentation_lines_and_response():
payload = {
"kind": HANDOVER_KIND,
"fileOrder": ["f1"],
"files": {
"f1": {
"sourceFileName": "x.txt",
"parts": [
{"typeGroup": "text", "data": "a\n\nb", "id": "1"},
],
},
},
}
cfg = parse_presentation_parameters({"outputMode": "lines", "splitBy": "paragraph"})
pres = build_presentation_for_payload(payload, cfg)
b1 = pres["files"]["f1"]
assert b1["outputMode"] == "lines"
assert isinstance(b1["data"], list)
assert len(b1["data"]) == 1
assert b1["data"][0]["lines"] == ["a", "b"]
assert presentation_response_text(pres) == "a\n\nb"
def test_presentation_pdf_mode_tables_only():
payload = {
"fileOrder": ["f1"],
"files": {
"f1": {
"sourceFileName": "d.pdf",
"parts": [
{"typeGroup": "text", "data": "t", "id": "a"},
{"typeGroup": "table", "mimeType": "text/csv", "data": "h1,h2\n1,2", "id": "b"},
],
},
},
}
cfg = parse_presentation_parameters({"pdfExtractMode": "tables", "outputMode": "blob"})
pres = build_presentation_for_payload(payload, cfg)
bf = pres["files"]["f1"]
assert isinstance(bf["data"], str)
assert bf["data"] == "h1,h2\n1,2"
def test_presentation_csv_rows():
payload = {
"fileOrder": ["f1"],
"files": {
"f1": {
"sourceFileName": "f.csv",
"parts": [{"typeGroup": "table", "mimeType": "text/csv", "data": "a,b\n1,2", "id": "t"}],
},
},
}
cfg = parse_presentation_parameters({"outputMode": "structured", "csvHeaderRow": "true"})
pres = build_presentation_for_payload(payload, cfg)
csv = pres["files"]["f1"]["csv"]
assert csv["headers"] == ["a", "b"]
assert csv["rows"] == [{"a": "1", "b": "2"}]
def test_presentation_pages_groups_by_page_index():
payload = {
"fileOrder": ["f1"],
"files": {
"f1": {
"sourceFileName": "p.pdf",
"parts": [
{"typeGroup": "text", "data": "p0", "metadata": {"pageIndex": 0}, "id": "a"},
{"typeGroup": "text", "data": "p1a\np1b", "metadata": {"pageIndex": 1}, "id": "b"},
],
},
},
}
cfg = parse_presentation_parameters({"outputMode": "pages", "splitBy": "newline"})
pres = build_presentation_for_payload(payload, cfg)
pages = pres["files"]["f1"]["pages"]
assert [(p["pageIndex"], [it["text"] for it in p["items"]]) for p in pages] == [
(0, ["p0"]),
(1, ["p1a", "p1b"]),
]
pdata = pres["files"]["f1"]["data"]
assert pdata == [
{"pageIndex": 0, "lines": ["p0"]},
{"pageIndex": 1, "lines": ["p1a", "p1b"]},
]
def test_presentation_chunks_with_overlap_chars():
payload = {
"fileOrder": ["f1"],
"files": {"f1": {"sourceFileName": "t.txt", "parts": [{"typeGroup": "text", "data": "abcdefghij", "id": "a"}]}},
}
cfg = parse_presentation_parameters(
{"outputMode": "chunks", "chunkSizeUnit": "characters", "chunkSize": "4", "chunkOverlap": "2"}
)
pres = build_presentation_for_payload(payload, cfg)
texts = [c["text"] for c in pres["files"]["f1"]["chunks"]]
assert texts == ["abcd", "cdef", "efgh", "ghij"]
assert pres["files"]["f1"]["data"] == texts
def test_presentation_keeps_pres_key_after_inline_image_strip_simulation():
raw = b"x"
b64 = base64.b64encode(raw).decode("ascii")
payload = {
"kind": HANDOVER_KIND,
"schemaVersion": 1,
"fileOrder": ["f1"],
"files": {
"f1": {
"parts": [
{"typeGroup": "text", "data": "txt", "id": "t"},
{"typeGroup": "image", "mimeType": "image/png", "data": b64, "id": "img"},
]
}
},
}
pres = build_presentation_for_payload(payload, parse_presentation_parameters({}))
serial = _copy.deepcopy([{"id": "1", "parts": payload["files"]["f1"]["parts"]}])
stayed, arts = _persist_extracted_image_parts(serial, name_stem="s", run_context=None)
assert arts == []
wrapper = {**pres, "_meta": {}}
fk = pres["fileOrder"][0]
assert isinstance(wrapper["files"][fk].get("data"), list)
assert len(wrapper["files"][fk]["data"]) == 2
def test_summarize_presentation_payload_shape():
payload = {
"fileOrder": ["f1"],
"files": {"f1": {"sourceFileName": "t.txt", "parts": [{"typeGroup": "text", "data": "hello", "id": "a"}]}},
}
pres = build_presentation_for_payload(payload, parse_presentation_parameters({"outputMode": "blob"}))
s = summarize_presentation_payload(pres)
assert s["fileOrder"] == ["f1"]
assert "f1" in s["files"]
assert s["files"]["f1"]["outputMode"] == "blob"
assert s["files"]["f1"]["stringLength"] == 5
assert "hello" in (s["files"]["f1"].get("head") or "")
def test_joined_text_from_extract_node_data_uses_presentation_root():
from modules.workflows.methods.methodContext.actions.extractContent import PRESENTATION_KIND
data = {
"schemaVersion": 1,
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["f1"],
"files": {"f1": {"outputMode": "lines", "sourceFileName": "x.txt", "data": ["body"]}},
"_meta": {"extractPayloadSchemaVersion": EXTRACT_PAYLOAD_SCHEMA_VERSION},
}
assert joined_text_from_extract_node_data(data) == "body"
assert data["_meta"]["extractPayloadSchemaVersion"] == EXTRACT_PAYLOAD_SCHEMA_VERSION
def test_action_result_contract_new_extract_payload_keys():
from modules.workflows.methods.methodContext.actions.extractContent import PRESENTATION_KIND
data = {
"schemaVersion": 1,
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["f1"],
"files": {"f1": {"outputMode": "lines", "sourceFileName": "x.txt", "data": ["body"]}},
"_meta": {"actionType": "context.extractContent", "extractPayloadSchemaVersion": EXTRACT_PAYLOAD_SCHEMA_VERSION},
}
assert data["kind"] == PRESENTATION_KIND
assert joined_text_from_extract_node_data(data) == "body"
def test_automation_workspace_suppresses_extract_artifacts():
from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui
assert suppress_workflow_file_in_workspace_ui({"fileName": "extracted_content_transient-abc_99.json"})
assert suppress_workflow_file_in_workspace_ui({"fileName": "extract_media_stem_uuid.png"})
assert not suppress_workflow_file_in_workspace_ui({"fileName": "export_2026.csv"})
assert suppress_workflow_file_in_workspace_ui({"fileName": "", "suppressInWorkflowFileLists": True})
assert suppress_workflow_file_in_workspace_ui({"fileName": "report.pdf", "tags": ["_workflowInternal"]})
assert not suppress_workflow_file_in_workspace_ui({"fileName": "report.pdf", "tags": ["invoice"]})
def test_normalize_presentation_envelopes_action_result_and_list():
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
normalize_presentation_envelopes,
)
pres = {
"kind": PRESENTATION_KIND,
"fileOrder": ["f1"],
"files": {"f1": {"outputMode": "lines", "sourceFileName": "x.txt", "data": []}},
}
wrapped = {"success": True, "data": pres}
assert len(normalize_presentation_envelopes(wrapped)) == 1
assert len(normalize_presentation_envelopes([wrapped])) == 1
def test_method_base_preserves_run_context_injection():
from modules.workflows.methods.methodFile.methodFile import MethodFile
class _Svc:
pass
action_def = MethodFile(_Svc())._actions["create"]
validated = MethodFile(_Svc())._validateParameters(
{"context": "x", "outputFormat": "pdf", "_runContext": {"mandateId": "m", "instanceId": "i"}},
action_def.parameters,
)
assert validated.get("_runContext") == {"mandateId": "m", "instanceId": "i"}
def test_presentation_envelopes_to_document_json_one_section_per_data_slot():
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
presentation_envelopes_to_document_json,
)
pres = {
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["f1"],
"files": {
"f1": {
"outputMode": "lines",
"sourceFileName": "a.pdf",
"data": [
{
"typeGroup": "text",
"mimeType": "text/plain",
"data": "ignored",
"lines": ["Line A", "Line B"],
},
],
},
},
}
out = presentation_envelopes_to_document_json(
{"success": True, "data": pres},
title="T",
language="de",
)
paragraphs = [
s for s in out["documents"][0]["sections"]
if s.get("content_type") == "paragraph"
]
assert len(paragraphs) == 1
runs = paragraphs[0]["elements"][0]["content"]["inlineRuns"]
joined = "".join(r.get("value", "") for r in runs)
assert "Line A" in joined
assert "Line B" in joined
assert "\n" in joined
def test_presentation_envelopes_table_slot_becomes_table_section():
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
presentation_envelopes_to_document_json,
)
pres = {
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["f1"],
"files": {
"f1": {
"outputMode": "lines",
"sourceFileName": "sheet.csv",
"data": [
{
"typeGroup": "table",
"mimeType": "text/csv",
"data": '"Name","Amount"\n"Alice","100"\n"Bob","200"',
"lines": [],
},
],
},
},
}
out = presentation_envelopes_to_document_json(
{"success": True, "data": pres},
title="T",
language="de",
)
tables = [s for s in out["documents"][0]["sections"] if s.get("content_type") == "table"]
assert len(tables) == 1
content = tables[0]["elements"][0]["content"]
assert content["headers"] == ["Name", "Amount"]
assert content["rows"] == [["Alice", "100"], ["Bob", "200"]]
def test_presentation_line_slot_preserves_table_without_lines():
from modules.workflows.methods.methodContext.actions.extractContent import (
_presentation_line_slot_from_part,
_presentation_line_slots_from_part,
parse_presentation_parameters,
)
cfg = parse_presentation_parameters({"outputMode": "lines", "splitBy": "newline"})
part = {
"typeGroup": "table",
"mimeType": "text/csv",
"data": '"A","B"\n"1","2"\n"3","4"',
"id": "t1",
}
slot = _presentation_line_slot_from_part(part, cfg)
assert slot.get("lines") == []
assert slot.get("data") == part["data"]
slots = _presentation_line_slots_from_part(part, cfg)
assert len(slots) == 3
assert slots[0]["lines"] == ['"A","B"']
assert slots[1]["lines"] == ['"1","2"']
def test_presentation_envelopes_preserves_data_slot_order_text_image_text():
import base64
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
presentation_envelopes_to_document_json,
)
class _Mgmt:
def getFileData(self, _fid: str) -> bytes:
return base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
class _Svc:
interfaceDbComponent = _Mgmt()
pres = {
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["f1"],
"files": {
"f1": {
"outputMode": "lines",
"sourceFileName": "a.pdf",
"data": [
{"typeGroup": "text", "mimeType": "text/plain", "lines": ["Before"]},
{
"typeGroup": "image",
"mimeType": "image/png",
"embeddedImageFileId": "00000000-0000-0000-0000-000000000001",
"embeddedImageFileName": "img.png",
},
{"typeGroup": "text", "mimeType": "text/plain", "lines": ["After"]},
],
},
},
}
out = presentation_envelopes_to_document_json(
{"success": True, "data": pres},
title="T",
language="de",
services=_Svc(),
)
types = [s.get("content_type") for s in out["documents"][0]["sections"]]
assert types == ["paragraph", "image", "paragraph"]
def test_presentation_envelopes_to_document_json_text_slots():
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
presentation_envelopes_to_document_json,
)
pres = {
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["f1"],
"files": {
"f1": {
"outputMode": "lines",
"sourceFileName": "a.pdf",
"data": [
{
"typeGroup": "text",
"mimeType": "text/plain",
"data": "Hello",
"lines": ["Hello", "World"],
},
],
},
},
}
out = presentation_envelopes_to_document_json(
[{"success": True, "data": pres}],
title="T",
language="de",
)
paragraphs = [
s for s in out["documents"][0]["sections"]
if s.get("content_type") == "paragraph"
]
assert len(paragraphs) == 1
all_text = []
for p in paragraphs:
runs = p["elements"][0]["content"]["inlineRuns"]
all_text.append("".join(r.get("value", "") for r in runs))
assert any("Hello" in t for t in all_text)
assert any("World" in t for t in all_text)
def test_presentation_envelopes_to_document_json_image_slot():
import base64
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
presentation_envelopes_to_document_json,
)
fid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
pres = {
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["f1"],
"files": {
"f1": {
"outputMode": "lines",
"sourceFileName": "a.pdf",
"data": [
{
"typeGroup": "image",
"mimeType": "image/png",
"embeddedImageFileId": fid,
"embeddedImageFileName": "clip.png",
},
],
},
},
}
class _Mgmt:
def getFileData(self, file_id):
assert file_id == fid
# Valid PNG signature + enough bytes for mime sniffing (>= 12 bytes).
return b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
class _Svc:
interfaceDbComponent = _Mgmt()
out = presentation_envelopes_to_document_json(
pres,
title="Img",
language="de",
services=_Svc(),
)
img_secs = [
s for s in out["documents"][0]["sections"]
if s.get("content_type") == "image"
]
assert len(img_secs) == 1
b64 = img_secs[0]["elements"][0]["content"]["base64Data"]
assert base64.b64decode(b64).startswith(b"\x89PNG")

View file

@ -0,0 +1,94 @@
# Copyright (c) 2025 Patrick Motsch
"""FlowExecutor structured condition evaluation with Item dataRef."""
import pytest
from modules.workflows.automation2.executors.flowExecutor import FlowExecutor
from modules.workflows.methods.methodContext.actions.extractContent import PRESENTATION_KIND
@pytest.fixture
def executor():
return FlowExecutor()
def test_if_else_uses_item_param(executor):
node_outputs = {
"n1": {"payload": {"status": "ok"}},
}
node = {
"id": "if1",
"type": "flow.ifElse",
"parameters": {
"Item": {"type": "ref", "nodeId": "n1", "path": ["payload", "status"]},
"condition": {"type": "condition", "operator": "eq", "value": "ok"},
},
}
ok = executor._evalStructuredCondition(
node["parameters"]["condition"],
node_outputs,
item_param=node["parameters"]["Item"],
node=node,
)
assert ok is True
def test_legacy_condition_ref_fallback(executor):
node_outputs = {"n1": {"count": 5}}
node = {"id": "if1", "type": "flow.ifElse", "parameters": {}}
cond = {
"type": "condition",
"ref": {"type": "ref", "nodeId": "n1", "path": ["count"]},
"operator": "gt",
"value": 3,
}
assert executor._evalStructuredCondition(cond, node_outputs, node=node) is True
def test_context_contains_content(executor):
presentation = {
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["f1"],
"files": {
"f1": {
"outputMode": "lines",
"data": [{"typeGroup": "text", "lines": ["Hallo Welt"]}],
}
},
}
cond = {"type": "condition", "operator": "contains_content", "value": "text"}
assert executor._evalStructuredCondition(cond, {"n1": presentation}, item_param={"type": "ref", "nodeId": "n1", "path": []}, node={"id": "if1", "type": "flow.ifElse"}) is True
def test_context_contains_content_blob_mode(executor):
presentation = {
"kind": PRESENTATION_KIND,
"outputMode": "blob",
"fileOrder": ["f1"],
"files": {
"f1": {
"outputMode": "blob",
"data": "Invoice text\n\n[image:abc123]",
}
},
}
img_cond = {"type": "condition", "operator": "contains_content", "value": "image"}
txt_cond = {"type": "condition", "operator": "contains_content", "value": "text"}
item = {"type": "ref", "nodeId": "n1", "path": []}
node = {"id": "if1", "type": "flow.ifElse"}
assert executor._evalStructuredCondition(img_cond, {"n1": presentation}, item_param=item, node=node) is True
assert executor._evalStructuredCondition(txt_cond, {"n1": presentation}, item_param=item, node=node) is True
def test_switch_uses_shared_operators(executor):
assert executor._evalSwitchCase("abc", {"operator": "starts_with", "value": "ab"}) is True
assert executor._evalSwitchCase([1, 2], {"operator": "length_eq", "value": 2}) is True
def test_switch_resolves_value_kind_for_string_ops(executor):
assert executor._evalSwitchCase(
"hello",
{"operator": "starts_with", "value": "he"},
value_kind="string",
) is True

View file

@ -0,0 +1,206 @@
# Unit tests: context.mergeContext primary text from extract handover (documents[0]).
import json
import pytest
from modules.workflows.methods.methodContext.actions.extractContent import HANDOVER_KIND
from modules.workflows.methods.methodContext.actions.mergeContext import mergeContext
def _handover(text: str) -> dict:
return {
"kind": HANDOVER_KIND,
"fileOrder": ["f1"],
"files": {
"f1": {
"parts": [
{"typeGroup": "text", "data": text, "id": "t1"},
]
}
},
}
@pytest.mark.asyncio
async def test_mergeContext_requires_dataSource():
result = await mergeContext(object(), {})
assert not result.success
err = result.error or ""
assert "dataSource" in err or "erforderlich" in err.lower()
@pytest.mark.asyncio
async def test_mergeContext_handover_only_in_documents_yields_data_response():
item = {
"success": True,
"data": {},
"documents": [
{
"documentName": "handover.json",
"mimeType": "application/json",
"documentData": _handover("only-from-handover"),
}
],
}
result = await mergeContext(object(), {"dataSource": [item]})
assert result.success
assert result.data.get("kind") == "context.mergeContext.v1"
assert result.data.get("response") == "only-from-handover"
@pytest.mark.asyncio
async def test_mergeContext_handover_json_string_in_documentData():
payload = _handover("from-json-string")
item = {
"success": True,
"data": {},
"documents": [
{
"documentName": "handover.json",
"mimeType": "application/json",
"documentData": json.dumps(payload),
}
],
}
result = await mergeContext(object(), {"dataSource": [item]})
assert result.success
assert result.data.get("response") == "from-json-string"
@pytest.mark.asyncio
async def test_mergeContext_joins_multiple_handover_items():
items = [
{
"success": True,
"data": {},
"documents": [{"documentData": _handover("alpha"), "documentName": "a.json"}],
},
{
"success": True,
"data": {},
"documents": [{"documentData": _handover("beta"), "documentName": "b.json"}],
},
]
result = await mergeContext(object(), {"dataSource": items})
assert result.success
assert result.data.get("response") == "### a.json\n\nalpha\n\n### b.json\n\nbeta"
@pytest.mark.asyncio
async def test_mergeContext_merged_response_wins_over_handover_chunks():
items = [
{
"success": True,
"data": {"response": "merged-wins"},
"documents": [{"documentData": _handover("ignored"), "documentName": "a.json"}],
},
]
result = await mergeContext(object(), {"dataSource": items})
assert result.success
assert result.data.get("response") == "merged-wins"
@pytest.mark.asyncio
async def test_mergeContext_concatenates_each_iteration_data_response_not_only_last():
"""Synthesized response must include every loop body chunk, not just the last one.
``response`` lives only at the top level of the data envelope (``data["response"]``).
The deep-merged ``data["merged"]`` dict retains whatever the natural merge produced
for per-item fields it is NOT overwritten with the synthesized primary text.
"""
items = [
{"success": True, "data": {"response": "chunk-aaa"}},
{"success": True, "data": {"response": "chunk-bbb"}},
{"success": True, "data": {"response": "chunk-ccc"}},
]
result = await mergeContext(object(), {"dataSource": items})
assert result.success
r = result.data.get("response") or ""
assert "chunk-aaa" in r
assert "chunk-bbb" in r
assert "chunk-ccc" in r
assert r == "chunk-aaa\n\nchunk-bbb\n\nchunk-ccc"
# ``merged["response"]`` reflects the natural deep-merge result (last chunk wins),
# NOT the synthesized primary. The canonical synthesized text is at data["response"].
assert result.data["merged"].get("response") != r or len(items) == 1
@pytest.mark.asyncio
async def test_mergeContext_primary_serializes_as_plain_text_for_file_create():
from modules.workflows.methods.methodAi._common import serialize_context
items = [
{"success": True, "data": {"response": "section-one"}},
{"success": True, "data": {"response": "section-two"}},
]
result = await mergeContext(object(), {"dataSource": items})
primary = result.data.get("response")
assert isinstance(primary, str)
assert serialize_context(primary) == primary
@pytest.mark.asyncio
async def test_mergeContext_strips_document_data_from_merged_documents():
"""documentData must be None in merged.documents — blobs must not accumulate."""
big_blob = "x" * 100_000
items = [
{
"success": True,
"data": {"response": "a"},
"documents": [
{"documentName": "a.json", "mimeType": "application/json", "documentData": big_blob},
],
},
{
"success": True,
"data": {"response": "b"},
"documents": [
{"documentName": "b.json", "mimeType": "application/json", "documentData": big_blob},
],
},
]
result = await mergeContext(object(), {"dataSource": items})
assert result.success
merged_docs = result.data["merged"].get("documents") or []
assert len(merged_docs) >= 1
for doc in merged_docs:
assert doc.get("documentData") is None, "documentData must be stripped before deep-merge"
@pytest.mark.asyncio
async def test_mergeContext_accumulates_image_documents_only_across_iterations():
"""imageDocumentsOnly from every iteration must be list-concat in merged."""
img_a = {"documentName": "img_a.png", "mimeType": "image/png", "documentData": "aaa="}
img_b = {"documentName": "img_b.png", "mimeType": "image/png", "documentData": "bbb="}
items = [
{"success": True, "data": {"response": "a"}, "imageDocumentsOnly": [img_a]},
{"success": True, "data": {"response": "b"}, "imageDocumentsOnly": [img_b]},
]
result = await mergeContext(object(), {"dataSource": items})
assert result.success
imgs = result.data["merged"].get("imageDocumentsOnly") or []
names = [d.get("documentName") for d in imgs]
assert "img_a.png" in names
assert "img_b.png" in names
@pytest.mark.asyncio
async def test_transform_context_envelope_has_kind_and_meta():
from modules.workflows.methods.methodContext.actions.transformContext import transformContext
svc = object()
result = await transformContext(
svc,
{
"mappings": [{"operation": "rename", "sourceField": "a", "outputField": "b"}],
"_upstreamPayload": {"a": 42},
},
)
assert result.success and result.data
assert result.data.get("kind") == "context.transformContext.v1"
assert result.data.get("schemaVersion") == 1
assert result.data.get("b") == 42
meta = result.data.get("_meta")
assert isinstance(meta, dict)
assert meta.get("actionType") == "context.transformContext"

View file

@ -0,0 +1,702 @@
# Tests: node handover compatibility across all major node combinations.
#
# Covers:
# - extractContent → file.create (direct, no loop)
# - loop.bodyResults → mergeContext → file.create
# - ai.prompt → transformContext → file.create
# - flow.merge with mixed upstream schemas (AiResult + ActionResult)
# - flow.ifElse Transit output accepted by downstream nodes
# - extractContent fan-in → mergeContext (multiple items, no loop)
# - data.aggregate → data.consolidate path
# - Node flags for executor behaviour (no hardcoded type strings)
import json
import pytest
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG
from modules.workflows.methods.methodContext.actions.extractContent import (
PRESENTATION_KIND,
build_presentation_envelope_from_plain_text,
normalize_presentation_envelopes,
)
from modules.workflows.methods.methodContext.actions.mergeContext import mergeContext
_NODE_BY_ID = {n["id"]: n for n in STATIC_NODE_TYPES}
# ---------------------------------------------------------------------------
# Helper builders
# ---------------------------------------------------------------------------
def _extract_output(text: str) -> dict:
"""Minimal extractContent-style output (presentation envelope in ``data``)."""
pres = build_presentation_envelope_from_plain_text(text, source_name="test")
return {"success": True, "response": "", "data": pres, "documents": []}
def _ai_output(response: str) -> dict:
"""Minimal ai.prompt-style output."""
return {"success": True, "response": response, "data": {}, "documents": []}
# ---------------------------------------------------------------------------
# 1. extractContent → file.create (direct path)
# ---------------------------------------------------------------------------
def test_extract_to_file_create_recommended_ref_is_data():
"""materializeRecommendedDataPickRef must resolve extractContent port 0 to path ['data']."""
from modules.workflows.automation2.pickNotPushMigration import materializeRecommendedDataPickRef
graph = {
"nodes": [
{"id": "ex1", "type": "context.extractContent", "parameters": {}},
{
"id": "fc1",
"type": "file.create",
"parameters": {"context": "", "outputFormat": "docx"},
},
],
"connections": [{"source": "ex1", "target": "fc1", "sourceOutput": 0, "targetInput": 0}],
}
migrated = materializeRecommendedDataPickRef(graph)
fc = next(n for n in migrated["nodes"] if n["id"] == "fc1")
ctx_ref = fc["parameters"].get("context")
# file.create.context has frontendType="contextBuilder" → materialized as a list
assert isinstance(ctx_ref, list), "context should be materialized as a contextBuilder list"
assert len(ctx_ref) == 1
assert ctx_ref[0]["nodeId"] == "ex1"
assert ctx_ref[0]["path"] == ["data"]
def test_extract_output_is_accepted_as_file_create_context():
"""extractContent presentation envelope must be normalizable for file.create."""
out = _extract_output("Hello world")
envelopes = normalize_presentation_envelopes(out["data"])
assert len(envelopes) == 1
assert envelopes[0].get("kind") == PRESENTATION_KIND
def test_extract_output_response_is_empty():
"""extractContent must leave ``response`` empty — canonical text is in ``data``."""
out = _extract_output("Some extracted content")
assert out["response"] == ""
# ---------------------------------------------------------------------------
# 2. primaryTextRef: extractContent overrides path to ["data"]
# ---------------------------------------------------------------------------
def test_extract_primary_text_ref_override_materializes_to_data():
"""When ai.prompt connects to extractContent, primaryTextRef must resolve to ['data']."""
from modules.workflows.automation2.pickNotPushMigration import materializePrimaryTextHandover
graph = {
"nodes": [
{"id": "ex1", "type": "context.extractContent", "parameters": {}},
{
"id": "ai1",
"type": "ai.prompt",
"parameters": {"context": "", "aiPrompt": "Summarize"},
},
],
"connections": [{"source": "ex1", "target": "ai1", "sourceOutput": 0, "targetInput": 0}],
}
migrated = materializePrimaryTextHandover(graph)
ai = next(n for n in migrated["nodes"] if n["id"] == "ai1")
ctx_ref = ai["parameters"].get("context")
# ai.prompt.context is primaryTextRef → single DataRef dict (not wrapped in list)
assert isinstance(ctx_ref, dict), f"Expected a DataRef dict, got {ctx_ref!r}"
assert ctx_ref["nodeId"] == "ex1"
assert ctx_ref["path"] == ["data"], (
"extractContent.response is empty; primaryTextRef must point to ['data']"
)
# ---------------------------------------------------------------------------
# 3. loop.bodyResults → mergeContext → file.create
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_loop_body_results_into_merge_context_produces_file_create_compatible_envelope():
"""bodyResults from a loop (list of extractContent outputs) must merge correctly."""
body_results = [
_extract_output("Page 1 text"),
_extract_output("Page 2 text"),
]
result = await mergeContext(object(), {"dataSource": body_results})
assert result.success
data = result.data
assert data.get("kind") == "context.mergeContext.v1"
assert "response" in data
assert data["response"]
# Downstream file.create uses normalize_presentation_envelopes on the full payload
envelopes = normalize_presentation_envelopes(data)
assert len(envelopes) >= 1
@pytest.mark.asyncio
async def test_merge_context_response_not_duplicated_in_merged_blob():
"""``response`` must live only at the top-level of ``data``, not inside ``data.merged``."""
body_results = [_extract_output("Item A"), _extract_output("Item B")]
result = await mergeContext(object(), {"dataSource": body_results})
assert result.success
merged_blob = result.data.get("merged", {})
# The natural deep-merge may include response from individual items — but
# _synthesize_primary_response no longer OVERWRITES merged["response"].
# Verify canonical response is the synthesized one at top-level.
assert result.data.get("response")
assert "Item A" in result.data["response"] or "Item B" in result.data["response"]
@pytest.mark.asyncio
async def test_merge_context_skips_failed_items():
"""Items with ``success=False`` must be excluded from the deep-merge.
Note: ``count`` reflects total inputs (including failed ones since they were
received); only the deep-merge payload excludes failed items.
"""
good = _extract_output("Good text")
bad = {"success": False, "error": "something failed", "data": {}, "documents": []}
result = await mergeContext(object(), {"dataSource": [good, bad]})
assert result.success
# response is synthesized only from good items
assert "Good text" in result.data.get("response", "")
# merged blob should not contain the error or failed item's data
merged = result.data.get("merged", {})
assert merged.get("error") != "something failed"
@pytest.mark.asyncio
async def test_merge_context_items_without_success_key_are_included():
"""Items without a ``success`` key (e.g. DocumentList output) must not be dropped."""
no_success = {"documents": [{"documentName": "a.pdf"}], "count": 1}
result = await mergeContext(object(), {"dataSource": [no_success]})
assert result.success
assert result.data.get("count") == 1
# ---------------------------------------------------------------------------
# 4. ai.prompt → transformContext (primaryTextRef)
# ---------------------------------------------------------------------------
def test_ai_prompt_primary_text_ref_materializes_to_response():
"""primaryTextRef from ai.prompt output must resolve to ['response']."""
from modules.workflows.automation2.pickNotPushMigration import materializePrimaryTextHandover
graph = {
"nodes": [
{"id": "ai1", "type": "ai.prompt", "parameters": {}},
{
"id": "ai2",
"type": "ai.prompt",
"parameters": {"context": "", "aiPrompt": "Continue"},
},
],
"connections": [{"source": "ai1", "target": "ai2", "sourceOutput": 0, "targetInput": 0}],
}
migrated = materializePrimaryTextHandover(graph)
ai2 = next(n for n in migrated["nodes"] if n["id"] == "ai2")
ctx_ref = ai2["parameters"].get("context")
assert isinstance(ctx_ref, dict), f"Expected DataRef dict, got {ctx_ref!r}"
assert ctx_ref["path"] == ["response"]
def test_transform_context_from_graph_result_schema_is_action_result():
"""context.transformContext must declare ``fromGraphResultSchema: ActionResult``."""
node = _NODE_BY_ID["context.transformContext"]
port = node["outputPorts"][0]
assert port.get("fromGraphResultSchema") == "ActionResult", (
"fromGraph port on transformContext must be normalized as ActionResult, not FormPayload"
)
# ---------------------------------------------------------------------------
# 5. flow.merge with mixed upstream schemas
# ---------------------------------------------------------------------------
def test_flow_merge_accepts_ai_result_and_action_result():
"""Both AiResult and ActionResult must be in flow.merge input accepts."""
node = _NODE_BY_ID["flow.merge"]
all_accepts = set()
for port in node.get("inputPorts", {}).values():
all_accepts.update(port.get("accepts", []))
assert "AiResult" in all_accepts
assert "ActionResult" in all_accepts
assert "Transit" in all_accepts
def test_flow_merge_input_count_parameter_exists_with_correct_range():
"""inputCount parameter must allow 25 inputs."""
node = _NODE_BY_ID["flow.merge"]
ic_param = next((p for p in node["parameters"] if p["name"] == "inputCount"), None)
assert ic_param is not None
opts = ic_param.get("frontendOptions", {})
assert opts.get("min") == 2
assert opts.get("max") == 5
# ---------------------------------------------------------------------------
# 6. flow.ifElse Transit output accepted downstream
# ---------------------------------------------------------------------------
def test_flow_if_else_output_is_transit():
"""flow.ifElse must output Transit on both branches."""
node = _NODE_BY_ID["flow.ifElse"]
for port_ix in (0, 1):
schema = node["outputPorts"][port_ix].get("schema")
assert schema == "Transit", f"ifElse port {port_ix} must be Transit, got {schema!r}"
def test_transit_accepted_by_all_major_downstream_nodes():
"""All major action nodes must accept Transit input on port 0."""
expected_transit_accepting = [
"context.extractContent",
"context.mergeContext",
"context.transformContext",
"ai.prompt",
"file.create",
]
for node_id in expected_transit_accepting:
node = _NODE_BY_ID[node_id]
accepts = node["inputPorts"][0].get("accepts", [])
assert "Transit" in accepts, f"{node_id} port 0 must accept Transit"
# ---------------------------------------------------------------------------
# 7. extractContent fan-in → mergeContext (multiple items, no loop)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_multiple_extract_outputs_fan_in_to_merge_context():
"""Multiple extractContent outputs passed as a list must merge into one envelope."""
items = [_extract_output(f"Document {i}") for i in range(3)]
result = await mergeContext(object(), {"dataSource": items})
assert result.success
assert result.data.get("count") == 3
assert result.data.get("kind") == "context.mergeContext.v1"
response = result.data.get("response", "")
for i in range(3):
assert f"Document {i}" in response
# ---------------------------------------------------------------------------
# 8. data.aggregate → data.consolidate schema compatibility
# ---------------------------------------------------------------------------
def test_data_aggregate_output_accepted_by_consolidate():
"""data.consolidate must accept AggregateResult from data.aggregate."""
agg_node = _NODE_BY_ID["data.aggregate"]
con_node = _NODE_BY_ID["data.consolidate"]
agg_schema = agg_node["outputPorts"][0].get("schema")
con_accepts = con_node["inputPorts"][0].get("accepts", [])
assert agg_schema in con_accepts, (
f"data.consolidate port 0 must accept {agg_schema!r} output from data.aggregate"
)
# ---------------------------------------------------------------------------
# 9. Node executor flags (no hardcoded type strings in executor)
# ---------------------------------------------------------------------------
def test_extract_content_executor_flags():
"""context.extractContent must carry all executor-behaviour flags."""
node = _NODE_BY_ID["context.extractContent"]
assert node.get("skipUnifiedPresentation") is True
assert node.get("clearResponse") is True
assert node.get("imageDocumentsFromExtractData") is True
assert node.get("popDocumentsFromOutput") is True
def test_extract_content_primary_text_ref_path_override():
"""context.extractContent output port 0 must declare primaryTextRefPath=['data']."""
node = _NODE_BY_ID["context.extractContent"]
port = node["outputPorts"][0]
assert port.get("primaryTextRefPath") == ["data"]
def test_merge_context_image_documents_flag():
"""context.mergeContext must carry imageDocumentsFromMerged flag."""
node = _NODE_BY_ID["context.mergeContext"]
assert node.get("imageDocumentsFromMerged") is True
def test_file_create_log_context_resolution_flag():
"""file.create must carry logContextResolution flag."""
node = _NODE_BY_ID["file.create"]
assert node.get("logContextResolution") is True
# ---------------------------------------------------------------------------
# 10. AiResult catalog must include data field
# ---------------------------------------------------------------------------
def test_ai_result_catalog_has_data_field():
"""AiResult in PORT_TYPE_CATALOG must document the ``data`` field."""
schema = PORT_TYPE_CATALOG["AiResult"]
field_names = [f.name for f in schema.fields]
assert "data" in field_names, "AiResult must document the data field set by executor"
# ---------------------------------------------------------------------------
# 11. _outputSchemaForNode returns ActionResult for context.transformContext
# ---------------------------------------------------------------------------
def test_output_schema_for_transform_context_is_action_result():
"""_outputSchemaForNode must return ActionResult for context.transformContext."""
from modules.workflows.automation2.executionEngine import _outputSchemaForNode
schema = _outputSchemaForNode("context.transformContext")
assert schema == "ActionResult", (
f"Expected ActionResult, got {schema!r}. fromGraph port must use fromGraphResultSchema."
)
# ---------------------------------------------------------------------------
# 12. flow.merge barrier, context.mergeContext NOT a barrier
# ---------------------------------------------------------------------------
def test_flow_merge_is_barrier():
from modules.workflows.automation2.executionEngine import _isBarrierNode
assert _isBarrierNode("flow.merge") is True
def test_context_merge_context_is_not_barrier():
"""context.mergeContext is not a barrier — it receives data via dataSource DataRef."""
from modules.workflows.automation2.executionEngine import _isBarrierNode
assert _isBarrierNode("context.mergeContext") is False
def test_no_node_named_is_merge_node_in_engine():
"""Legacy _isMergeNode alias must be removed from executionEngine."""
import modules.workflows.automation2.executionEngine as eng
assert not hasattr(eng, "_isMergeNode"), "_isMergeNode legacy alias must be deleted"
# ---------------------------------------------------------------------------
# 13. methodBase parameter passthrough — node-defined params must reach the action
# ---------------------------------------------------------------------------
def test_method_base_validate_parameters_passes_through_undeclared_keys():
"""_validateParameters must keep parameters the action did not formally declare.
Regression: WorkflowActionDefinition for context.extractContent only declares
``documentList``, but the node exposes contentFilter, pdfExtractMode, outputMode, ...
Those MUST reach the action implementation.
"""
from modules.workflows.methods.methodBase import MethodBase
from modules.datamodels.datamodelWorkflowActions import WorkflowActionParameter
from modules.shared.frontendTypes import FrontendType
paramDefs = {
"documentList": WorkflowActionParameter(
name="documentList", type="Any", frontendType=FrontendType.HIDDEN,
required=True, description="docs",
),
}
class _Svc:
pass
mb = MethodBase.__new__(MethodBase)
mb.services = _Svc()
incoming = {
"documentList": ["doc1"],
"contentFilter": "imagesOnly",
"pdfExtractMode": "all",
"outputMode": "lines",
"_runContext": {"mandateId": "m"},
"parentOperationId": "op1",
}
validated = mb._validateParameters(incoming, paramDefs)
assert validated["documentList"] == ["doc1"]
assert validated["contentFilter"] == "imagesOnly", (
"contentFilter must pass through even though the action did not declare it"
)
assert validated["pdfExtractMode"] == "all"
assert validated["outputMode"] == "lines"
assert validated["_runContext"] == {"mandateId": "m"}
assert validated["parentOperationId"] == "op1"
def test_parse_presentation_parameters_imagesonly_coerces_pdf_mode_to_images():
"""contentFilter=imagesOnly must override pdfExtractMode=all (node default)."""
from modules.workflows.methods.methodContext.actions.extractContent import (
parse_presentation_parameters,
)
cfg = parse_presentation_parameters({"contentFilter": "imagesOnly", "pdfExtractMode": "all"})
assert cfg["pdfExtractMode"] == "images", (
"imagesOnly + pdfExtractMode=all must coerce to 'images' — otherwise text parts "
"leak into the presentation layer."
)
def test_parse_presentation_parameters_textonly_coerces_pdf_mode():
from modules.workflows.methods.methodContext.actions.extractContent import (
parse_presentation_parameters,
)
cfg = parse_presentation_parameters({"contentFilter": "textOnly", "pdfExtractMode": "images"})
assert cfg["pdfExtractMode"] == "text"
def test_sniff_image_mime_recognizes_common_signatures():
from modules.workflows.methods.methodContext.actions.extractContent import (
_sniff_image_mime,
)
assert _sniff_image_mime(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) == "image/png"
assert _sniff_image_mime(b"\xff\xd8\xff\xe0" + b"\x00" * 8) == "image/jpeg"
assert _sniff_image_mime(b"GIF89a" + b"\x00" * 8) == "image/gif"
assert _sniff_image_mime(b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP") == "image/webp"
def test_sniff_image_mime_raises_on_unknown_signature():
"""No silent fallback to image/png — unknown signatures must error out."""
import pytest as _pt
from modules.workflows.methods.methodContext.actions.extractContent import (
_sniff_image_mime,
)
with _pt.raises(ValueError):
_sniff_image_mime(b"NOT_AN_IMAGE_" + b"\x00" * 8)
with _pt.raises(ValueError):
_sniff_image_mime(b"")
def test_markdown_renderer_image_uses_relative_path_and_emits_sidecar_files():
"""Images: relative ![alt](file.png) in md + separate image RenderedDocuments."""
import asyncio
import base64 as _b64
from modules.serviceCenter.services.serviceGeneration.renderers.rendererMarkdown import (
RendererMarkdown,
)
png_b64 = _b64.b64encode(
b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
).decode("ascii")
content = {
"metadata": {"title": "doc"},
"documents": [{
"id": "d1",
"title": "doc",
"outputFormat": "md",
"language": "de",
"sections": [{
"id": "s1",
"content_type": "image",
"order": 1,
"elements": [{
"content": {
"altText": "alpha.png",
"fileName": "alpha.png",
"mimeType": "image/png",
"base64Data": png_b64,
},
}],
}],
}],
}
r = RendererMarkdown()
rendered = asyncio.run(r.render(content, title="doc"))
assert len(rendered) == 2, "markdown render must return .md + sidecar image"
md = rendered[0].documentData.decode("utf-8")
assert "![alpha.png](alpha.png)" in md
assert "/api/files/" not in md
assert "base64" not in md.lower()
assert rendered[1].filename == "alpha.png"
assert rendered[1].mimeType == "image/png"
assert len(rendered[1].documentData) > 0
def test_markdown_renderer_image_raises_without_base64_data():
"""Missing base64Data must fail — no API URL fallback."""
import asyncio
import pytest as _pt
from modules.serviceCenter.services.serviceGeneration.renderers.rendererMarkdown import (
RendererMarkdown,
)
content = {
"metadata": {},
"documents": [{
"id": "d1",
"title": "doc",
"outputFormat": "md",
"language": "de",
"sections": [{
"id": "s1",
"content_type": "image",
"order": 1,
"elements": [{
"content": {
"altText": "beta.jpg",
"fileName": "beta.jpg",
"mimeType": "image/jpeg",
"fileId": "FILE-1",
},
}],
}],
}],
}
r = RendererMarkdown()
with _pt.raises(ValueError, match="base64Data"):
asyncio.run(r.render(content, title="doc"))
def test_markdown_renderer_unknown_section_type_raises():
"""No fallback to paragraph — unknown section types must surface."""
import asyncio
import pytest as _pt
from modules.serviceCenter.services.serviceGeneration.renderers.rendererMarkdown import (
RendererMarkdown,
)
content = {
"metadata": {},
"documents": [{
"id": "d1",
"title": "doc",
"outputFormat": "md",
"language": "de",
"sections": [{
"id": "s1",
"content_type": "totally_unknown",
"order": 1,
"elements": [{"content": {"text": "x"}}],
}],
}],
}
r = RendererMarkdown()
with _pt.raises(ValueError, match="unsupported section content_type"):
asyncio.run(r.render(content, title="doc"))
def test_markdown_renderer_accepts_image_section_type():
"""Regression: markdown must declare 'image' as accepted to avoid silent filtering."""
from modules.serviceCenter.services.serviceGeneration.renderers.rendererMarkdown import (
RendererMarkdown,
)
accepted = RendererMarkdown.getAcceptedSectionTypes("md")
assert "image" in accepted, "image must be in accepted section types for markdown"
def test_extract_image_slot_carries_file_id_and_mime():
"""Presentation→document conversion must propagate fileId & mimeType to renderers."""
from modules.workflows.methods.methodContext.actions.extractContent import (
presentation_envelopes_to_document_json,
PRESENTATION_KIND,
PRESENTATION_SCHEMA_VERSION,
)
class _MgmtStub:
def getFileData(self, fid):
return b"\xff\xd8\xff\xe0" + b"\x00" * 100
class _Services:
def __init__(self):
self.interfaceDbComponent = _MgmtStub()
envelope = {
"schemaVersion": PRESENTATION_SCHEMA_VERSION,
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["file_1_x.pdf"],
"files": {
"file_1_x.pdf": {
"outputMode": "lines",
"sourceFileName": "x.pdf",
"data": [
{
"id": "img1",
"typeGroup": "image",
"mimeType": "image/jpeg",
"embeddedImageFileId": "FILE-7",
"embeddedImageFileName": "extract_media_x.jpg",
"label": "x",
}
],
}
},
}
doc_json = presentation_envelopes_to_document_json(
envelope, title="t", language="de", services=_Services()
)
sections = doc_json["documents"][0]["sections"]
image_sections = [s for s in sections if s.get("content_type") == "image"]
assert len(image_sections) == 1
content = image_sections[0]["elements"][0]["content"]
assert content.get("fileId") == "FILE-7"
assert content.get("mimeType") == "image/jpeg", (
f"mime must be sniffed from bytes (JPEG magic), got {content.get('mimeType')!r}"
)
assert content.get("base64Data"), "base64Data must be present for embed-capable renderers"
def test_extract_image_slot_raises_when_file_id_missing():
"""No silent skip — missing embeddedImageFileId must fail loudly."""
import pytest as _pt
from modules.workflows.methods.methodContext.actions.extractContent import (
presentation_envelopes_to_document_json,
PRESENTATION_KIND,
PRESENTATION_SCHEMA_VERSION,
)
class _Services:
interfaceDbComponent = None
envelope = {
"schemaVersion": PRESENTATION_SCHEMA_VERSION,
"kind": PRESENTATION_KIND,
"outputMode": "lines",
"fileOrder": ["file_1_x.pdf"],
"files": {
"file_1_x.pdf": {
"outputMode": "lines",
"sourceFileName": "x.pdf",
"data": [
{
"id": "img1",
"typeGroup": "image",
"mimeType": "image/jpeg",
"label": "x",
}
],
}
},
}
with _pt.raises(ValueError, match="embeddedImageFileId"):
presentation_envelopes_to_document_json(
envelope, title="t", language="de", services=_Services()
)
def test_parse_presentation_parameters_noimages_drops_images_mode():
from modules.workflows.methods.methodContext.actions.extractContent import (
parse_presentation_parameters,
)
cfg = parse_presentation_parameters({"contentFilter": "noImages", "pdfExtractMode": "images"})
assert cfg["pdfExtractMode"] == "text"
def test_method_base_validate_parameters_applies_defaults_for_declared():
"""Declared parameters still get defaults applied even when undeclared keys pass through."""
from modules.workflows.methods.methodBase import MethodBase
from modules.datamodels.datamodelWorkflowActions import WorkflowActionParameter
from modules.shared.frontendTypes import FrontendType
paramDefs = {
"outputFormat": WorkflowActionParameter(
name="outputFormat", type="str", frontendType=FrontendType.TEXT,
required=False, default="docx", description="fmt",
),
}
class _Svc:
pass
mb = MethodBase.__new__(MethodBase)
mb.services = _Svc()
validated = mb._validateParameters({"unknown": "x"}, paramDefs)
assert validated["outputFormat"] == "docx"
assert validated["unknown"] == "x"

View file

@ -18,11 +18,42 @@ def test_context_extractContent_node_exists():
def test_context_extractContent_node_shape():
node = next(n for n in STATIC_NODE_TYPES if n["id"] == "context.extractContent")
assert node["category"] == "context"
assert node.get("injectRunContext") is True
assert node["meta"]["usesAi"] is False
assert node["_method"] == "context"
assert node["_action"] == "extractContent"
assert node["outputPorts"][0]["schema"] == "UdmDocument"
assert node["outputPorts"][0]["schema"] == "ActionResult"
assert "DocumentList" in node["inputPorts"][0]["accepts"]
assert "LoopItem" in node["inputPorts"][0]["accepts"]
names = [p["name"] for p in node["parameters"]]
assert names == [
"documentList",
"contentFilter",
"outputMode",
"splitBy",
"chunkSizeUnit",
"chunkSize",
"chunkOverlap",
"filterEmptyLines",
"trimWhitespace",
"includeLineNumbers",
"includeMetadata",
"csvHeaderRow",
"pdfExtractMode",
"markdownPreserveFormatting",
]
pick_paths = [opt["path"] for opt in node["outputPorts"][0]["dataPickOptions"]]
assert ["data", "files"] in pick_paths
assert ["data", "_meta"] in pick_paths
def test_context_transformContext_has_envelope_data_pick_paths():
node = next(n for n in STATIC_NODE_TYPES if n["id"] == "context.transformContext")
pick_paths = [opt["path"] for opt in node["outputPorts"][0]["dataPickOptions"]]
assert ["data"] in pick_paths
assert ["data", "_meta"] in pick_paths
def test_udm_port_types_registered():
@ -62,3 +93,17 @@ def test_getExecutor_dispatches_context():
from modules.workflows.automation2.executors import ActionNodeExecutor
executor = _getExecutor("context.extractContent", None)
assert isinstance(executor, ActionNodeExecutor)
def test_context_mergeContext_has_envelope_data_pick_paths():
node = next(n for n in STATIC_NODE_TYPES if n["id"] == "context.mergeContext")
pick_paths = [opt["path"] for opt in node["outputPorts"][0]["dataPickOptions"]]
assert ["data"] in pick_paths
assert ["data", "_meta"] in pick_paths
assert ["merged"] in pick_paths
def test_context_mergeContext_surfaces_data_pick_paths_match_node_outputs():
"""DataPicker uses paths like ``merged``; executor must surface ``data.*`` to top level."""
node = next(n for n in STATIC_NODE_TYPES if n["id"] == "context.mergeContext")
assert node.get("surfaceDataAsTopLevel") is True

View file

@ -21,16 +21,19 @@ class TestNodeDefinitions:
assert node["_action"] == "consolidate"
assert node["outputPorts"][0]["schema"] == "ConsolidateResult"
def test_flow_loop_has_level_and_concurrency(self):
def test_flow_loop_has_iteration_mode_and_concurrency(self):
node = next(n for n in STATIC_NODE_TYPES if n["id"] == "flow.loop")
paramNames = [p["name"] for p in node["parameters"]]
assert "level" in paramNames
assert "iterationMode" in paramNames
assert "iterationStride" in paramNames
assert "concurrency" in paramNames
levelParam = next(p for p in node["parameters"] if p["name"] == "level")
assert "structuralNodes" in levelParam["frontendOptions"]["options"]
assert "contentBlocks" in levelParam["frontendOptions"]["options"]
assert "level" not in paramNames
modeParam = next(p for p in node["parameters"] if p["name"] == "iterationMode")
assert "every_nth" in modeParam["frontendOptions"]["options"]
concParam = next(p for p in node["parameters"] if p["name"] == "concurrency")
assert concParam["default"] == 1
assert node["inputs"] == 1
assert node["outputs"] == 2
def test_flow_loop_accepts_udm(self):
node = next(n for n in STATIC_NODE_TYPES if n["id"] == "flow.loop")
@ -146,13 +149,27 @@ class TestFlowLoopUdmLevel:
ex = FlowExecutor()
udm = {"id": "d1", "role": "document", "children": [{"id": "p1"}, {"id": "p2"}]}
node = {"type": "flow.loop", "id": "loop1",
"parameters": {"items": "direct", "level": "auto"}}
"parameters": {"items": "direct"}}
ctx = {"nodeOutputs": {"loop1": udm, "direct": udm}, "connectionMap": {}, "inputSources": {"loop1": {0: ("direct", 0)}}}
from unittest.mock import patch
with patch("modules.workflows.automation2.graphUtils.resolveParameterReferences", return_value=udm):
result = await ex.execute(node, ctx)
assert result["count"] == 2
@pytest.mark.asyncio
async def test_loop_every_nth_stride(self):
from modules.workflows.automation2.executors.flowExecutor import FlowExecutor
ex = FlowExecutor()
node = {"type": "flow.loop", "id": "loop1", "parameters": {
"items": {"type": "value", "value": [10, 20, 30, 40, 50]},
"iterationMode": "every_nth",
"iterationStride": 2,
}}
ctx = {"nodeOutputs": {}, "connectionMap": {}, "inputSources": {"loop1": {}}}
result = await ex.execute(node, ctx)
assert result["count"] == 3
assert result["items"] == [10, 30, 50]
@pytest.mark.asyncio
class TestDataFilterUdm:

View file

@ -0,0 +1,359 @@
# Copyright (c) 2025 Patrick Motsch
"""flow.switch ContextBranch: filtered presentation + loop-ready items."""
import pytest
from modules.features.graphicalEditor.portTypes import unwrapTransit, wrapTransit
from modules.features.graphicalEditor.switchOutput import (
build_switch_branch_payload,
build_switch_combined_output,
build_switch_default_payload,
unwrap_transit_for_port,
)
from modules.workflows.automation2.executionEngine import _is_node_on_active_path
from modules.workflows.automation2.executors.flowExecutor import FlowExecutor
from modules.workflows.automation2.graphUtils import resolveParameterReferences
from modules.workflows.methods.methodContext.actions.extractContent import PRESENTATION_KIND
def _presentation_with_text_and_image():
return {
"kind": PRESENTATION_KIND,
"schemaVersion": "1",
"outputMode": "parts",
"fileOrder": ["doc"],
"files": {
"doc": {
"outputMode": "parts",
"data": [
{"typeGroup": "text", "id": "t1", "data": "Hello"},
{"typeGroup": "image", "id": "i1", "mimeType": "image/png", "data": "YQ=="},
],
}
},
}
def _presentation_blob_with_text_and_image():
blob_data = "Hello world\n\n[image:img1]\n\nMore text"
return {
"kind": PRESENTATION_KIND,
"schemaVersion": "1",
"outputMode": "blob",
"fileOrder": ["doc"],
"files": {
"doc": {
"outputMode": "blob",
"sourceFileName": "test.pdf",
"data": blob_data,
}
},
}
def test_build_switch_branch_payload_filters_blob_image():
pres = _presentation_blob_with_text_and_image()
payload = build_switch_branch_payload(
pres,
{"operator": "contains_content", "value": "image"},
value_kind="context",
match_index=0,
)
assert payload["filterApplied"] is True
assert len(payload["items"]) == 1
assert payload["items"][0]["value"]["typeGroup"] == "image"
assert "[image:img1]" in payload["data"]["files"]["doc"]["data"]
def test_build_switch_branch_payload_filters_blob_text():
pres = _presentation_blob_with_text_and_image()
payload = build_switch_branch_payload(
pres,
{"operator": "contains_content", "value": "text"},
value_kind="context",
match_index=1,
)
assert payload["filterApplied"] is True
assert len(payload["items"]) == 2
assert all(i["value"]["typeGroup"] == "text" for i in payload["items"])
filtered = payload["data"]["files"]["doc"]["data"]
assert "Hello world" in filtered
assert "[image:" not in filtered
@pytest.mark.asyncio
async def test_switch_blob_multi_match():
executor = FlowExecutor()
pres = _presentation_blob_with_text_and_image()
sw_id = "sw1"
node_outputs = {
"ext1": pres,
"_context": {
"graphNodesById": {
"ext1": {"id": "ext1", "type": "context.extractContent"},
sw_id: {"id": sw_id, "type": "flow.switch"},
}
},
}
executor._getInputData = lambda *_a, **_k: pres # type: ignore[method-assign]
node = {
"id": sw_id,
"type": "flow.switch",
"parameters": {
"value": {"type": "ref", "nodeId": "ext1", "path": []},
"cases": [
{"operator": "contains_content", "value": "image"},
{"operator": "contains_content", "value": "text"},
],
},
}
out = await executor._switch(node, node_outputs, sw_id, {})
assert out["_meta"]["matches"] == [0, 1]
assert len(unwrap_transit_for_port(out, 0)["items"]) == 1
assert len(unwrap_transit_for_port(out, 1)["items"]) == 2
def test_switch_blob_image_items_get_embedded_file_id():
part_id = "dbd27119-cd21-4a62-b5e2-b06d3b81470b"
file_id = "storage-file-uuid-1"
pres = {
"kind": PRESENTATION_KIND,
"outputMode": "blob",
"fileOrder": ["doc"],
"files": {
"doc": {
"outputMode": "blob",
"data": f"Hello\n\n[image:{part_id}]",
}
},
"_meta": {
"persistedImageArtifacts": [
{"sourcePartId": part_id, "fileId": file_id, "mimeType": "image/png"},
]
},
}
payload = build_switch_branch_payload(
pres,
{"operator": "contains_content", "value": "image"},
value_kind="context",
match_index=0,
)
assert len(payload["items"]) == 1
slot = payload["items"][0]["value"]
assert slot.get("embeddedImageFileId") == file_id
def test_build_switch_branch_payload_filters_images():
pres = _presentation_with_text_and_image()
case = {"operator": "contains_content", "value": "image"}
payload = build_switch_branch_payload(
pres,
case,
value_kind="context",
match_index=0,
)
assert payload["filterApplied"] is True
assert payload["contentType"] == "image"
assert len(payload["items"]) == 1
assert payload["items"][0]["value"]["typeGroup"] == "image"
data = payload["data"]
assert data["kind"] == PRESENTATION_KIND
slots = data["files"]["doc"]["data"]
assert len(slots) == 1
assert slots[0]["typeGroup"] == "image"
def test_build_switch_default_payload_passthrough():
pres = _presentation_with_text_and_image()
payload = build_switch_default_payload(pres, match_index=2)
assert payload["filterApplied"] is False
assert payload["match"] == 2
assert payload["data"]["fileOrder"] == pres["fileOrder"]
assert len(payload["items"]) == 2
@pytest.mark.asyncio
async def test_switch_executor_match_and_default_branch():
executor = FlowExecutor()
pres = _presentation_with_text_and_image()
ext_id = "ext1"
sw_id = "sw1"
node_outputs = {
ext_id: pres,
"_context": {
"graphNodesById": {
ext_id: {"id": ext_id, "type": "context.extractContent"},
sw_id: {"id": sw_id, "type": "flow.switch"},
}
},
}
def _inp(_nid, _sources, _outputs, _output_index=0):
return pres
executor._getInputData = _inp # type: ignore[method-assign]
match_node = {
"id": sw_id,
"type": "flow.switch",
"parameters": {
"value": {"type": "ref", "nodeId": ext_id, "path": []},
"cases": [{"operator": "contains_content", "value": "image"}],
},
}
match_out = await executor._switch(match_node, node_outputs, sw_id, {})
match_payload = unwrapTransit(match_out)
assert match_out["_meta"]["match"] == 0
assert match_out["_meta"]["matches"] == [0]
assert match_payload["filterApplied"] is True
assert len(match_payload["items"]) == 1
assert match_payload["branches"]["0"]["contentType"] == "image"
default_node = {
**match_node,
"parameters": {
**match_node["parameters"],
"cases": [{"operator": "contains_content", "value": "video"}],
},
}
default_out = await executor._switch(default_node, node_outputs, sw_id, {})
assert default_out["_meta"]["match"] == 1
assert default_out["_meta"]["matches"] == [1]
default_payload = unwrapTransit(default_out)
assert default_payload["filterApplied"] is False
assert default_payload["data"]["fileOrder"] == pres["fileOrder"]
@pytest.mark.asyncio
async def test_switch_multi_match_text_and_image_branches():
executor = FlowExecutor()
pres = _presentation_with_text_and_image()
sw_id = "sw1"
node_outputs = {
"ext1": pres,
"_context": {
"graphNodesById": {
"ext1": {"id": "ext1", "type": "context.extractContent"},
sw_id: {"id": sw_id, "type": "flow.switch"},
}
},
}
executor._getInputData = lambda *_a, **_k: pres # type: ignore[method-assign]
node = {
"id": sw_id,
"type": "flow.switch",
"parameters": {
"value": {"type": "ref", "nodeId": "ext1", "path": []},
"cases": [
{"operator": "contains_content", "value": "image"},
{"operator": "contains_content", "value": "text"},
],
},
}
out = await executor._switch(node, node_outputs, sw_id, {})
assert out["_meta"]["matches"] == [0, 1]
img = unwrap_transit_for_port(out, 0)
txt = unwrap_transit_for_port(out, 1)
assert img["contentType"] == "image"
assert txt["contentType"] == "text"
assert len(img["items"]) == 1
assert len(txt["items"]) == 1
assert img["items"][0]["value"]["typeGroup"] == "image"
assert txt["items"][0]["value"]["typeGroup"] == "text"
def test_active_path_allows_all_matching_switch_ports():
combined = build_switch_combined_output(
_presentation_with_text_and_image(),
[
{"operator": "contains_content", "value": "image"},
{"operator": "contains_content", "value": "text"},
],
matched_indices=[0, 1],
value_kind="context",
)
sw_out = wrapTransit(combined, {"match": 0, "matches": [0, 1]})
node_outputs = {"sw1": sw_out}
conn_map = {
"loop_img": [("sw1", 0, 0)],
"file_txt": [("sw1", 1, 0)],
}
assert _is_node_on_active_path("loop_img", conn_map, node_outputs)
assert _is_node_on_active_path("file_txt", conn_map, node_outputs)
assert not _is_node_on_active_path("other", {"other": [("sw1", 2, 0)]}, node_outputs)
@pytest.mark.asyncio
async def test_loop_uses_switch_items_ref():
executor = FlowExecutor()
pres = _presentation_with_text_and_image()
branch = build_switch_branch_payload(
pres,
{"operator": "contains_content", "value": "image"},
value_kind="context",
match_index=0,
)
sw_id = "sw1"
node_outputs = {sw_id: wrapTransit(branch, {"match": 0})}
loop_node = {
"id": "loop1",
"type": "flow.loop",
"parameters": {
"items": {"type": "ref", "nodeId": sw_id, "path": ["items"]},
},
}
out = await executor._loop(loop_node, node_outputs, "loop1", {})
assert out["count"] == 1
assert out["items"][0]["value"]["typeGroup"] == "image"
def test_resolve_context_builder_ref_uses_switch_output_port():
"""file.create context ref to switch.items must use the wired source output port."""
pres = _presentation_with_text_and_image()
combined = build_switch_combined_output(
pres,
[
{"operator": "contains_content", "value": "image"},
{"operator": "contains_content", "value": "text"},
],
matched_indices=[0, 1],
value_kind="context",
)
sw_id = "sw1"
consumer_id = "fc1"
node_outputs = {sw_id: wrapTransit(combined, {"match": 0, "matches": [0, 1]})}
input_sources = {consumer_id: {0: (sw_id, 1)}}
resolved = resolveParameterReferences(
{
"context": [
{
"type": "ref",
"nodeId": sw_id,
"path": ["items"],
}
],
},
node_outputs,
consumer_node_id=consumer_id,
input_sources=input_sources,
)
items = resolved["context"]
assert isinstance(items, list)
assert len(items) == 1
assert items[0]["value"]["typeGroup"] == "text"
branch = build_switch_branch_payload(
_presentation_with_text_and_image(),
{"operator": "contains_content", "value": "image"},
value_kind="context",
match_index=0,
)
node_outputs = {"sw1": wrapTransit(branch, {"match": 0})}
resolved = resolveParameterReferences(
{"type": "ref", "nodeId": "sw1", "path": ["items"]},
node_outputs,
)
assert isinstance(resolved, list)
assert len(resolved) == 1
assert resolved[0]["value"]["typeGroup"] == "image"

View file

@ -5,7 +5,64 @@ Unit tests for automation2 graphUtils - resolveParameterReferences (ref/value fo
import pytest
from modules.workflows.automation2.graphUtils import resolveParameterReferences
from modules.workflows.automation2.graphUtils import resolveParameterReferences, validateGraph
_KNOWN_TYPES = frozenset({"trigger.manual", "trigger.form", "ai.prompt", "flow.pass"})
class TestValidateGraphStartNode:
"""Non-empty graphs must include at least one start (trigger.*) node."""
def test_empty_graph_ok_without_start(self):
assert validateGraph({"nodes": [], "connections": []}, _KNOWN_TYPES) == []
def test_non_empty_graph_without_start_fails(self):
graph = {
"nodes": [{"id": "a", "type": "ai.prompt", "parameters": {}}],
"connections": [],
}
errs = validateGraph(graph, _KNOWN_TYPES)
assert any("no start node" in e.lower() for e in errs)
def test_non_empty_graph_with_start_ok(self):
graph = {
"nodes": [
{"id": "t", "type": "trigger.manual", "parameters": {}},
{"id": "a", "type": "ai.prompt", "parameters": {}},
],
"connections": [],
}
errs = validateGraph(graph, _KNOWN_TYPES)
assert not any("no start node" in e.lower() for e in errs)
def test_switch_second_output_to_ai_prompt_ok(self):
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
node_type_ids = {n["id"] for n in STATIC_NODE_TYPES}
graph = {
"nodes": [
{"id": "t", "type": "trigger.manual", "parameters": {}},
{
"id": "sw",
"type": "flow.switch",
"parameters": {
"cases": [
{"operator": "contains_content", "value": "image"},
{"operator": "contains_content", "value": "text"},
],
},
},
{"id": "ai", "type": "ai.prompt", "parameters": {"aiPrompt": "hi"}},
],
"connections": [
{"source": "sw", "target": "ai", "sourceOutput": 1, "targetInput": 0},
],
}
errs = validateGraph(graph, node_type_ids)
port_errs = [e for e in errs if "Port mismatch" in e]
assert port_errs == [], port_errs
class TestResolveParameterReferences:
@ -175,3 +232,37 @@ class TestPathContainsWildcard:
def test_literal_star_in_int_segment_does_not_match(self):
from modules.workflows.automation2.graphUtils import _pathContainsWildcard
assert _pathContainsWildcard([1, 2, 3]) is False
class TestLoopBodyAndDoneReachability:
"""flow.loop: body only from output 0; done branch from output 1 (engine helpers)."""
def test_body_only_output_0_not_done_chain(self):
from modules.workflows.automation2.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds
conns = [
{"source": "tr", "target": "loop", "targetInput": 0},
{"source": "loop", "target": "a", "sourceOutput": 0, "targetInput": 0},
{"source": "loop", "target": "d", "sourceOutput": 1, "targetInput": 0},
{"source": "a", "target": "b"},
]
cm = buildConnectionMap(conns)
assert getLoopBodyNodeIds("loop", cm) == {"a", "b"}
assert getLoopDoneNodeIds("loop", cm) == {"d"}
def test_primary_input_prefers_outside_body(self):
from modules.workflows.automation2.graphUtils import (
buildConnectionMap,
getLoopBodyNodeIds,
getLoopPrimaryInputSource,
)
conns = [
{"source": "tr", "target": "loop", "targetInput": 0},
{"source": "a", "target": "loop", "targetInput": 0},
{"source": "loop", "target": "a", "sourceOutput": 0, "targetInput": 0},
]
cm = buildConnectionMap(conns)
body = getLoopBodyNodeIds("loop", cm)
assert body == {"a"}
assert getLoopPrimaryInputSource("loop", cm, body) == ("tr", 0)

View file

@ -0,0 +1,31 @@
# Copyright (c) 2025 Patrick Motsch
"""TriggerExecutor: form start output must match FormPayload (payload.* refs)."""
import pytest
from modules.workflows.automation2.executors.triggerExecutor import TriggerExecutor
from modules.workflows.automation2.runEnvelope import default_run_envelope
@pytest.mark.asyncio
async def test_trigger_form_returns_payload_only():
ex = TriggerExecutor()
node = {
"id": "f1",
"type": "trigger.form",
"parameters": {"formFields": [{"name": "q", "type": "str", "label": "Q"}]},
}
env = default_run_envelope("form", entry_point_id="f1", payload={"q": "hello"})
out = await ex.execute(node, {"runEnvelope": env, "userId": "u1"})
assert out == {"payload": {"q": "hello"}, "_success": True}
@pytest.mark.asyncio
async def test_trigger_manual_still_returns_full_envelope():
ex = TriggerExecutor()
node = {"id": "m1", "type": "trigger.manual", "parameters": {}}
env = default_run_envelope("manual", payload={"x": 1})
out = await ex.execute(node, {"runEnvelope": env, "userId": "u1"})
assert isinstance(out, dict)
assert out.get("trigger", {}).get("type") == "manual"
assert out.get("payload") == {"x": 1}