222 lines
8 KiB
Python
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))
|