gateway/modules/workflows/methods/methodContext/actions/transformContext.py

222 lines
8 KiB
Python

# 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
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=result)
except Exception as exc:
logger.exception("transformContext failed")
return ActionResult.isFailure(error=str(exc))