# 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))