# Analyse: Typed Node Handover System für den Graphical Editor ## 1 Problemstellung Der Graphical Editor besitzt heute **keine formale Typisierung** der Daten, die zwischen Nodes fließen. Jede Node produziert ein freiformiges Dict; die Folge-Node versucht heuristisch, daraus das Passende zu extrahieren. Das führt zu: | Symptom | Wo sichtbar | |---------|-------------| | Spezialcode pro Paar (z.B. `_extractEmailContentFromUpstream`, `_getContextFromUpstream`, `_gatherAttachmentDocumentsFromUpstream`) | `actionNodeExecutor.py` (~860 Zeilen, >50 % handover Heuristik) | | DataPicker zeigt **hartcodierte** Beispiel-Schemas (`outputPreviewRegistry.ts`) — stimmt oft nicht mit echten Laufzeit-Outputs überein | Frontend `outputPreviewRegistry.ts` | | Kein `data.transform`, `data.filter`, `data.aggregate` implementiert — nur in `automation.md` als Placeholder aufgelistet | `nodeDefinitions/` hat keine Datei dafür | | Kein „Aggregate" (Gegenstück zu `flow.loop`'s For-Each) | Engine sammelt Loop-Body-Resultate nicht auf | | Frontend-Parameter haben **eigene** Typdefinitionen (node-level `type: "string"`) statt der in `MethodBase` etablierten `WorkflowActionParameter` mit `FrontendType`-Enum + Validierung | `ai.py`, `email.py` etc. vs. `methodBase.py` | **Kern:** Die Method/Action-Schicht hat bereits eine saubere Parameter-Typisierung (`WorkflowActionParameter`, `_validateType`, `_validateParameters`, `FrontendType`). Aber der Graph-Editor nutzt sie weder für Input-Konfiguration noch für die Handover-Logik. --- ## 2 Ist-Zustand (Layer für Layer) ### 2.1 Node-Definitionen (`nodeDefinitions/*.py`) Jede Node ist ein Dict mit: ```python { "id": "email.draftEmail", "parameters": [ {"name": "subject", "type": "string", "required": True, ...}, ], "inputs": 1, "outputs": 1, "_method": "outlook", "_action": "composeAndDraftEmailWithContext", "_paramMap": {"connectionId": "connectionReference", ...}, } ``` **Was fehlt:** - Kein `outputSchema` — was liefert die Node zurück? - Kein `inputSchema` — was erwartet die Node am Eingang? - `type: "string"` ist Node-Editor-intern, nicht identisch mit `WorkflowActionParameter.type` (`str`, `int`, `List[str]`, …). - Kein `frontendType` (d.h. Frontend baut eigene Config-Components per Node-Typ statt generisch). ### 2.2 Execution Engine (`executionEngine.py`) - Topologische Sortierung → iteriert Nodes → ruft Executor auf → speichert Output in `nodeOutputs[nodeId]`. - **Kein Output-Vertrag**: der Executor liefert `Any`, das Dict wird 1:1 in `nodeOutputs` gelegt. - Folge-Nodes holen via `inputSources[nodeId][0]` den Vorgänger-Output und _hoffen_, dass er das richtige Format hat. ### 2.3 ActionNodeExecutor (`actionNodeExecutor.py`) - ~860 Zeilen, davon ~60 % dedizierte Merge-Logik für Paare: `email→AI`, `AI→email.draftEmail`, `file.create` content-gathering, ClickUp merge, etc. - Aufruft am Ende `ActionExecutor.executeAction(method, action, params)` — dessen `MethodBase._validateParameters` prüft die **Action**-Parameter, nicht den Graph-Port-Kontrakt. - Baut ein Output-Dict mit teilweise inkonsistenten Keys (`documents` vs. `documentList` vs. `data` vs. `context`). ### 2.4 DataPicker + outputPreviewRegistry (Frontend) - `outputPreviewRegistry.ts` registriert **statische Beispiele** pro Node-Typ. - DataPicker zeigt daraus einen Baum und erzeugt `DataRef { type: "ref", nodeId, path }`. - `resolveParameterReferences` (Backend) löst `DataRef` gegen echte `nodeOutputs`. - **Problem:** Die Preview-Struktur ist manuell gepflegt und **divergiert** vom echten Output. Der User wählt Pfade, die zur Laufzeit nicht existieren oder anders heißen. ### 2.5 Method/Action Parameter-System (bereits existent) `WorkflowActionParameter` + `MethodBase._validateParameters`: ```python class WorkflowActionParameter(BaseModel): name: str type: str # 'str', 'int', 'List[str]', 'Dict[str, Any]' frontendType: FrontendType # TEXT, TEXTAREA, SELECT, USER_CONNECTION, … frontendOptions: Optional[...] required: bool default: Optional[Any] validation: Optional[Dict] ``` `_validateType` konvertiert + prüft: `str→str`, `int→int`, `List[str]→[str,…]`, usw. **Dieses System funktioniert bereits** für Workspace-Actions. Es wird aber **nicht** für Node-Ein-/Ausgänge genutzt. --- ## 3 Ziel-Architektur: Typed Port System ### 3.1 Kern-Konzept: Port-Schema Jede Node deklariert **typisierte Ports** für Ein- und Ausgänge — analog zu `WorkflowActionParameter`, aber für den Graphen. ``` ┌──────────────────────────────────────────────────────────────────────┐ │ Node Definition (Beispiel: email.draftEmail) │ │ │ │ inputPorts: │ │ [0]: { schema: EmailDraft } ← structured type │ │ EmailDraft = { subject: str, body: str, to: List[str] } │ │ │ │ outputPorts: │ │ [0]: { schema: ActionResult } │ │ ActionResult = { success: bool, error: str?, │ │ documents: List[Document] } │ │ │ │ parameters: (config, unabhängig von Ports) │ │ connectionId: { type: str, frontendType: USER_CONNECTION } │ └──────────────────────────────────────────────────────────────────────┘ ``` ### 3.2 Port-Schema Definition ```python class PortField(BaseModel): name: str type: str # str, int, bool, List[str], List[Document], Dict[str,Any], … description: Dict[str, str] # {en, de, fr} required: bool = True class PortSchema(BaseModel): fields: List[PortField] class NodePortDefinition(BaseModel): inputPorts: Dict[int, PortSchema] # port-index → schema outputPorts: Dict[int, PortSchema] # port-index → schema ``` ### 3.3 Einheitliche Output-Typen (Port-Typen-Katalog) Statt freier Dicts gibt es benannte Schemas, die Node-übergreifend wiederverwendbar sind: | Port-Typ | Felder | Produziert von | Konsumiert von | |----------|--------|----------------|----------------| | `DocumentList` | `documents: List[{name, data, mimeType, metadata}]` | sharepoint.readFile, sharepoint.downloadFile, ai.*, file.create, input.upload | ai.*, file.create, email.draftEmail, sharepoint.uploadFile | | `FileList` | `files: List[{url, name, path, size}]` | sharepoint.listFiles, sharepoint.findFile | sharepoint.readFile, sharepoint.downloadFile, flow.loop | | `EmailDraft` | `subject: str, body: str, to: List[str], cc?: List[str], attachments?: DocumentList` | ai.prompt (mode=email) | email.draftEmail, email.sendEmail | | `EmailList` | `emails: List[{subject, from, to, body, date, attachments}]` | email.checkEmail, email.searchEmail | ai.prompt, flow.loop, flow.ifElse | | `TaskList` | `tasks: List[{id, name, status, url, …}]` | clickup.searchTasks, clickup.listTasks | flow.loop, clickup.updateTask | | `TaskResult` | `success: bool, taskId: str, task: {id, name, status}` | clickup.createTask, clickup.updateTask | flow.ifElse | | `FormPayload` | `payload: Dict[str, Any]` (dynamisch, Keys = Feldnamen) | trigger.form, input.form | Alle (via Referenz auf Einzelfelder) | | `AiResult` | `prompt: str, response: str, context: str, documents: List[Document]` | ai.* | email.draftEmail, file.create, sharepoint.uploadFile | | `BoolResult` | `result: bool, reason?: str` | input.approval, input.confirmation | flow.ifElse | | `TextResult` | `text: str` | input.comment | ai.*, file.create | | `LoopItem` | `currentItem: Any, currentIndex: int, items: List[Any], count: int` | flow.loop (pro Iteration) | Loop-Body Nodes | | `AggregateResult` | `items: List[Any], count: int` | **data.aggregate** (NEU) | Alle | ### 3.4 Schema auf Node-Definitions Erweiterung der bestehenden Node-Definitions um `outputPorts` und `inputPorts`: ```python { "id": "ai.prompt", "category": "ai", "parameters": [...], # Config — bleibt wie bisher "inputs": 1, "outputs": 1, # NEU ───────────────────────────────────────── "inputPorts": { 0: {"accepts": ["DocumentList", "TextResult", "FormPayload", "EmailList", "AiResult", "Any"]}, }, "outputPorts": { 0: {"schema": "AiResult"}, }, } ``` ```python { "id": "email.draftEmail", "inputPorts": { 0: {"accepts": ["EmailDraft", "AiResult", "TextResult"]}, }, "outputPorts": { 0: {"schema": "ActionResult"}, }, } ``` ### 3.5 Konsequenzen für bestehende Schichten #### A) Node-Definitionen — einmalige Migration Jede Node bekommt `inputPorts` + `outputPorts`. Bestehende `inputs`/`outputs` (Zählwerte) bleiben für Rückwärtskompatibilität, aber die Schemas werden die Quelle der Wahrheit. #### B) Executors — Output normalisieren Jeder Executor erhält eine `_normalizeOutput(rawResult, portSchema) → Dict` Funktion. Sie sorgt dafür, dass die Keys/Typen dem deklarierten Schema entsprechen. Für `AiResult` z.B.: ```python def _normalizeAiResult(raw: Any) -> Dict: return { "prompt": raw.get("prompt", ""), "response": raw.get("response", raw.get("context", "")), "context": raw.get("context", ""), "documents": _ensureDocumentList(raw.get("documents", [])), } ``` Der Spezialcode in `actionNodeExecutor.py` (~400 Zeilen Paar-Heuristik) fällt dann weg und wird durch generische Normalizer pro Port-Typ ersetzt. #### C) ExecutionEngine — Handover-Validierung Nach jedem Node-Execute: `nodeOutputs[nodeId]` wird gegen `outputPorts[0].schema` geprüft (Warnung oder Fehler bei Mismatch). Vor jedem Node-Execute: Input-Port-Kompatibilitäts-Check (`accepts` enthält den Schema-Typ des Vorgänger-Outputs). #### D) Frontend — DataPicker aus Schema generieren `outputPreviewRegistry.ts` wird ersetzt durch eine generische Funktion, die aus dem PortSchema den Preview-Baum baut. Kein manuelles Pflegen mehr: ```typescript function buildPreviewFromSchema(schema: PortSchema): Record { // Iteriert schema.fields → Beispielwert pro Typ } ``` #### E) Frontend — Generisches Parameter-Rendering Statt pro Node-Typ eine eigene Config-Component zu schreiben (aktuell 12+ Dateien), können die meisten Parameter-Felder generisch aus dem `WorkflowActionParameter`-Schema gerendert werden. Spezial-UIs (FormBuilder, ClickUp Browse, etc.) bleiben als Override. #### F) Verbindungs-Validierung im Editor Beim Ziehen einer Kante: prüfe `sourceNode.outputPorts[outputIdx].schema` ∈ `targetNode.inputPorts[inputIdx].accepts`. Falls inkompatibel: Kante rot markieren / verhindern. --- ## 4 Fehlende Nodes / Konzepte ### 4.1 data.aggregate (Gegenstück zu flow.loop) Loop verteilt Items → Body → aber am Ende gibt es keinen Collector. Heute liegt nur das letzte Body-Ergebnis in `nodeOutputs`. **Lösung: `data.aggregate`-Node** ```python { "id": "data.aggregate", "category": "data", "label": {"en": "Aggregate", "de": "Sammeln", "fr": "Agréger"}, "description": {"en": "Collect results from loop body into a list", ...}, "parameters": [ {"name": "mode", "type": "string", "options": ["collect", "concat", "sum", "count"], "default": "collect"}, ], "inputs": 1, "outputs": 1, "inputPorts": {0: {"accepts": ["Any"]}}, "outputPorts": {0: {"schema": "AggregateResult"}}, "executor": "data", } ``` **Engine-Änderung:** Im Loop-Body erkennt die Engine eine `data.aggregate`-Node und sammelt pro Iteration das Ergebnis in einer Liste. Nach Loop-Ende: `nodeOutputs[aggregateNodeId] = { items: [...], count: N }`. ### 4.2 data.transform Reiner Mapping-Node: Felder umbenennen, extrahieren, umstrukturieren. Konfiguration per Key-Value-Mapping oder einfachem Expression-Sprache. ```python { "id": "data.transform", "category": "data", "parameters": [ {"name": "mapping", "type": "json", "description": "Key-Value Mapping: {outputField: inputRef}"}, ], "inputPorts": {0: {"accepts": ["Any"]}}, "outputPorts": {0: {"schema": "Dict"}}, } ``` ### 4.3 data.filter Filtert Items einer Liste nach Bedingung (wie WHERE in SQL). Eingabe: List, Ausgabe: gefilterter List. ```python { "id": "data.filter", "category": "data", "parameters": [ {"name": "condition", "type": "string", "description": "Filter expression (z.B. item.status == 'open')"}, ], "inputPorts": {0: {"accepts": ["AggregateResult", "FileList", "TaskList", "EmailList"]}}, "outputPorts": {0: {"schema": "AggregateResult"}}, } ``` ### 4.4 flow.merge Heute in `automation.md` gelistet, aber nicht implementiert. Wartet auf N Eingänge und kombiniert sie. --- ## 5 Migrations-Strategie ### Phase 1: Port-Schema-Deklaration (Backend, kein Breaking Change) 1. `PortSchema` als Pydantic-Modell definieren. 2. Katalog der Port-Typen (`DocumentList`, `AiResult`, `EmailDraft`, …) anlegen. 3. Jede Node-Definition in `nodeDefinitions/*.py` um `inputPorts` / `outputPorts` erweitern. 4. API `GET /node-types` liefert die Schemas mit. ### Phase 2: Output-Normalizer (Backend) 1. Pro Port-Typ einen Normalizer schreiben (10–15 Funktionen). 2. In `executionEngine.py`: nach jedem Execute `_normalizeOutput(result, schema)` aufrufen. 3. Spezialcode in `actionNodeExecutor.py` schrittweise durch Normalizer ersetzen. 4. Step-Logs mit normalisiertem Output (Debugging/Tracing sofort besser). ### Phase 3: Frontend — DataPicker aus Schema (Frontend) 1. `outputPreviewRegistry.ts` durch Schema-basierte Preview-Generierung ersetzen. 2. DataPicker zeigt korrekte Felder + Typen an. 3. Verbindungs-Validierung (rote Kanten bei Typ-Mismatch). ### Phase 4: Generisches Parameter-Rendering (Frontend) 1. `NodeConfigPanel` rendert Parameter generisch aus `WorkflowActionParameter`-Schema. 2. Spezial-Components (`FormNodeConfig`, `ClickUpNodeConfig`, …) bleiben als Override für komplexe UIs. 3. Neue Nodes brauchen **keine eigene Config-Component** mehr. ### Phase 5: Neue Data-Nodes 1. `data.aggregate` implementieren (Engine + Node-Definition + Frontend). 2. `data.transform` implementieren. 3. `data.filter` implementieren. 4. `flow.merge` implementieren. --- ## 6 Konkretes Beispiel: AI-Mail-Entwurf → Mail-Versand ### Heute (heuristisch) ``` [email.checkEmail] ──→ [ai.prompt] ──→ [email.draftEmail] ↓ ↓ ↓ freeform Dict freeform Dict ~100 Zeilen Spezialcode (data.emails…) (context, docs) in actionNodeExecutor: _extractEmailContentFromUpstream _getIncomingEmailFromUpstream _unpackIncomingEmail _gatherAttachmentDocumentsFromUpstream ``` ### Ziel (typisiert) ``` [email.checkEmail] [ai.prompt] [email.draftEmail] outputPort[0]: outputPort[0]: inputPort[0]: schema: EmailList schema: AiResult accepts: [EmailDraft, AiResult] ──────────────────→ ──────────────────→ Normalizer kennt AiResult und Input: EmailList Input: EmailList mapped response → body, Engine prüft Kompatibilität Engine prüft kein Spezialcode nötig. ``` **Entscheidend:** Der AI-Node kann so konfiguriert werden, dass sein Output-Typ `EmailDraft` statt `AiResult` ist (z.B. via Parameter `outputMode: "emailDraft"` oder via Prompt-Instruktion + structured output). Dann ist der Downstream-Merge trivial. --- ## 7 Offene Fragen / Entscheidungen | # | Frage | Optionen | |---|-------|----------| | 1 | Soll Typ-Mismatch eine Kante verhindern (hard) oder nur warnen (soft)? | **Empfehlung: soft** (Warnung + gelbe Kante). Hard-Block wäre zu restriktiv bei generischen Nodes. | | 2 | Port-Typ `Any` erlauben? | Ja, als Fallback. Nodes wie `flow.ifElse` leiten den Input transparent durch. | | 3 | Dynamische Schemas (z.B. `input.form` Payload hängt von Felddefinition ab)? | Ja. `outputPorts` kann eine Funktion referenzieren, die das Schema aus den `parameters` ableitet (wie heute `outputPreviewRegistry`). | | 4 | Sollen bestehende Workflows beim Upgrade automatisch migriert werden? | **Nein.** Alte Workflows laufen weiter (Normalizer fängt fehlende Keys ab). Neue Workflows profitieren von Validierung. | | 5 | Separater `data.aggregate`-Node oder implizit in `flow.loop`? | **Empfehlung: separater Node.** Explizit ist klarer; der Loop-Node bleibt rein für Iteration. | | 6 | Braucht es einen `data.join`-Node (merge zwei Listen)? | Später. Erst aggregate + filter + transform etablieren. | --- ## 8 Aufwand-Schätzung | Phase | Geschätzter Aufwand | Abhängigkeiten | |-------|-------------------|----------------| | 1 — Port-Schema-Deklaration | 2–3 Tage | Keine | | 2 — Output-Normalizer | 3–5 Tage | Phase 1 | | 3 — Frontend DataPicker aus Schema | 2–3 Tage | Phase 1 | | 4 — Generisches Parameter-Rendering | 3–4 Tage | Phase 1 | | 5 — Data Nodes (aggregate, transform, filter) | 3–5 Tage | Phase 2 | | **Gesamt** | **~13–20 Tage** | Phasen 1–3 sind der kritische Pfad | --- ## 9 Schlüssel-Dateien | Bereich | Pfade | |---------|-------| | Node-Definitionen | `gateway/modules/features/graphicalEditor/nodeDefinitions/*.py` | | Execution Engine | `gateway/modules/workflows/automation2/executionEngine.py` | | ActionNodeExecutor (Handover-Heuristik) | `gateway/modules/workflows/automation2/executors/actionNodeExecutor.py` | | Graph-Utilities | `gateway/modules/workflows/automation2/graphUtils.py` | | Method/Action Typsystem | `gateway/modules/workflows/methods/methodBase.py`, `gateway/modules/datamodels/datamodelWorkflowActions.py` | | FrontendType Enum | `gateway/modules/shared/frontendTypes.py` | | Output-Preview (Frontend) | `frontend_nyla/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts` | | DataPicker (Frontend) | `frontend_nyla/src/components/FlowEditor/nodes/shared/DataPicker.tsx` | | DataRef/DynamicValue (Frontend) | `frontend_nyla/src/components/FlowEditor/nodes/shared/dataRef.ts` | | Node Config Registry (Frontend) | `frontend_nyla/src/components/FlowEditor/nodes/configs/index.ts` | | DataFlow Context (Frontend) | `frontend_nyla/src/components/FlowEditor/context/Automation2DataFlowContext.tsx` |