# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Node Type Registry for graphicalEditor - static node definitions (ai, email, sharepoint, trigger, flow, data, input). 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.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.nodeAdapter import _bindsActionFromLegacy from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText logger = logging.getLogger(__name__) def getNodeTypes( services: Any = None, language: str = "de", ) -> List[Dict[str, Any]]: """ Return static node types. No dynamic I/O derivation from methodDiscovery. services: Optional (kept for API compatibility, not used). """ return list(STATIC_NODE_TYPES) def _pickFromLangMap(d: Any, lang: str) -> Any: """Resolve multilingual dict: ``lang`` → ``xx`` → ``de`` → ``en`` → first non-empty value.""" if not isinstance(d, dict) or not d: return None for k in (lang, "xx", "de", "en"): v = d.get(k) if v is not None and v != "": return v for v in d.values(): if v is not None and v != "": return v return None def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]: """Apply request language via resolveText (t() keys + multilingual dicts). Also exposes Schicht-3 metadata (`bindsAction`) derived from the legacy `_method`/`_action` pair, so frontend consumers can resolve back to the Schicht-2 Action signature without parsing internal underscore-prefixed fields. """ lang = normalizePrimaryLanguageTag(language, "en") bindsAction = _bindsActionFromLegacy(node) out = dict(node) for key in list(out.keys()): if key.startswith("_"): del out[key] if bindsAction: out["bindsAction"] = bindsAction lbl = node.get("label") if lbl is not None: out["label"] = resolveText(lbl, lang) or node.get("id", "") desc = node.get("description") if desc is not None: out["description"] = resolveText(desc, lang) ol = node.get("outputLabels") if ol is not None: if isinstance(ol, list): out["outputLabels"] = [resolveText(x, lang) for x in ol] elif isinstance(ol, dict) and ol: first = next(iter(ol.values()), None) if isinstance(first, (list, tuple)): picked = _pickFromLangMap(ol, lang) raw = list(picked) if picked is not None else list(first) out["outputLabels"] = [resolveText(x, lang) for x in raw] params = [] for p in node.get("parameters", []): pc = dict(p) pd = p.get("description") if pd is not None: pc["description"] = resolveText(pd, lang) params.append(pc) out["parameters"] = params return out def getNodeTypesForApi( services: Any, language: str = "de", ) -> Dict[str, Any]: """ API-ready response: nodeTypes with localized strings, plus categories, portTypeCatalog, systemVariables. """ nodes = getNodeTypes(services, language) localized = [_localizeNode(n, language) for n in nodes] categories = [ {"id": "trigger", "label": "Trigger"}, {"id": "input", "label": "Eingabe/Mensch"}, {"id": "flow", "label": "Ablauf"}, {"id": "data", "label": "Daten"}, {"id": "context", "label": "Kontext"}, {"id": "ai", "label": "KI"}, {"id": "file", "label": "Datei"}, {"id": "email", "label": "E-Mail"}, {"id": "sharepoint", "label": "SharePoint"}, {"id": "clickup", "label": "ClickUp"}, {"id": "trustee", "label": "Treuhand"}, ] catalogSerialized = {} for name, schema in PORT_TYPE_CATALOG.items(): catalogSerialized[name] = { "name": schema.name, "fields": [f.model_dump() for f in schema.fields], } return { "nodeTypes": localized, "categories": categories, "portTypeCatalog": catalogSerialized, "systemVariables": SYSTEM_VARIABLES, } def getNodeTypeToMethodAction() -> Dict[str, tuple]: """ Mapping from node type id to (method, action) for execution. Used by ActionNodeExecutor. """ mapping = {} for node in STATIC_NODE_TYPES: method = node.get("_method") action = node.get("_action") if method and action: mapping[node["id"]] = (method, action) return mapping def validateAdaptersAgainstMethods(methodInstances: Optional[Dict[str, Any]] = None) -> Optional[str]: """Run the Schicht-3 Adapter validator (5 drift rules) against the live methods. Intended to be called once at startup after methodDiscovery has populated the methods registry. Returns a human-readable report (None when healthy) so the caller decides whether to log, raise, or surface to operators. Pass `methodInstances` directly for testability; defaults to importing the live registry from `methodDiscovery.methods`. """ from modules.features.graphicalEditor.adapterValidator import ( _buildActionsRegistryFromMethods, _formatAdapterReport, _validateAllAdapters, ) if methodInstances is None: try: from modules.workflows.processing.shared.methodDiscovery import methods except Exception as exc: logger.warning("Adapter validator skipped: cannot import methodDiscovery (%s)", exc) return None methodInstances = {} for fullName, info in (methods or {}).items(): shortName = fullName.replace("Method", "").lower() if fullName[:1].isupper() else fullName instance = info.get("instance") if isinstance(info, dict) else None if instance is not None: methodInstances[shortName] = instance if not methodInstances: return None actionsRegistry = _buildActionsRegistryFromMethods(methodInstances) report = _validateAllAdapters(list(STATIC_NODE_TYPES), actionsRegistry) formatted = _formatAdapterReport(report) if not report.isHealthy: logger.warning("[adapterValidator] %s", formatted) elif report.warnings: logger.info("[adapterValidator] %s", formatted) return formatted