Workflow V1 #72

Merged
Lars merged 10 commits from develop into main 2026-04-11 10:59:39 +02:00
20 changed files with 758 additions and 137 deletions

View File

@ -12,6 +12,7 @@ Dieser Ordner ist der **primäre Orientierungspunkt** für Claude Code / Cursor-
| 2 | **`rules/DOCUMENTATION.md`** Ablage- und Dokumentationsregeln |
| 3 | `rules/ARCHITECTURE.md`, `rules/CODING_RULES.md`, `rules/LESSONS_LEARNED.md` |
| 4 | Issue-Landkarte: **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
| 5 | **Universal CSV Import** (Modul/Executor/Vorlagen): **`docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** (unter `.claude/`) |
Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im **Projekt**-`docs/`, nicht hier).

View File

@ -1,6 +1,6 @@
# Gitea Issues Landkarte (Auswertung)
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-08** (Abfrage `state=all`).
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-09** (Abfrage `state=all`, ergänzt: #71).
**URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung.
@ -88,7 +88,6 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver
| # | Titel |
|---|--------|
| 15 | [FEAT-002] Quality-Filter für KI-Auswertungen & Charts integrieren |
| 21 | [FEATURE] Universeller CSV-Parser mit lernbarem Feldmapping |
| 36 | BUG-009: Trainingstyp-Erstellung führt zu Internal Server Error |
---

View File

@ -54,6 +54,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
---
@ -111,6 +112,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| `PROFILE_REFERENCE_VALUES.md` | Profil-Referenzwerte |
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
---
@ -174,4 +176,4 @@ Siehe [`audit/README.md`](./audit/README.md).
---
**Letzte Aktualisierung:** 8. April 2026 (Struktur-Index, Duplikatbereinigung, Abgleich-Hinweise)
**Letzte Aktualisierung:** 9. April 2026 (Universal CSV Agent-Guide, Abgleich-Tabelle)

View File

@ -0,0 +1,63 @@
# Universal CSV Import Agent-Leitfaden
**Stand:** 2026-04-09 · **Kontext:** Issue #21 (Universeller CSV-Parser), Prod-Migrationen u. a. 051053.
Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul anlegen oder bestehende Import-Pfade (Executor, Vorlagen, DB) ändern.
---
## 1. Architektur (Kurz)
| Komponente | Pfad / Rolle |
|------------|----------------|
| Modul-Definitionen | `backend/csv_parser/module_registry.py` (`MODULE_DEFINITIONS`) |
| Typ-/Einheiten-Konvertierung | `backend/csv_parser/type_converter.py`, `field_units.py` |
| Zeilen-Aggregation (z. B. Ernährung pro Tag) | `backend/csv_parser/import_row_processing.py` |
| Import-Ausführung | `backend/csv_parser/executor.py` |
| Fehlertexte / Transaktions-Hinweise | `backend/csv_parser/import_errors.py` (`enrich_row_error`) |
| Admin-Systemvorlagen | `backend/routers/admin_csv_templates.py` |
| Nutzer-Import (Profil-Mappings) | `backend/routers/csv_import.py` |
| Vorlagen-Validierung (strukturell + Sample) | `backend/csv_parser/template_validator.py` (`validate_csv_template`) |
**Single Source of Truth** für erlaubte Zielfelder, Typen und Duplikat-Keys ist **`module_registry.py`**. Keine parallele Feldliste in Routern duplizieren.
---
## 2. Checkliste: Neues Zielmodul
1. **`MODULE_DEFINITIONS`** um Eintrag erweitern: `table`, `fields` (Typen `date` / `datetime` / `float` / `int` / `string`), `duplicate_key`, `duplicate_strategy`, ggf. `derive_date_from_datetime_field`, `import_mode` (Spezialpfade wie Schlaf).
2. **DB:** Migration nur nach Projektregel (`backend/migrations/NNN_*.sql`). Spaltenbreiten/Typen so wählen, dass importierte Werte (z. B. kJ→kcal, große Energiebeträge) **keinen NUMERIC-Overflow** verursachen.
3. **`source` / CHECK-Constraints:** Wenn die Zieltabelle `source` hat, muss der Wert **`csv`** (oder der vereinbarte Import-Tag) in der DB erlaubt sein (Migration anpassen, nicht nur App-Code).
4. **Executor:** Einfügen/Aktualisieren in `executor.py` nur über bestehende Muster (ein Cursor, **kein** verschachteltes `get_db()` im gleichen Request). Bei mehreren Zeilen pro Transaktion: bei **Zeilenfehlern** SAVEPOINT pro Zeile nutzen (siehe Activity-Pattern), damit die Transaktion nicht dauerhaft abgebrochen ist.
5. **Trainingstyp / FK-Auflösung:** DB-Zugriffe für abhängige Entitäten (z. B. `get_training_type_for_activity_with_cursor`) **mit dem gleichen Cursor** wie der Import keine zweite Connection aus dem Importpfad.
6. **Vorlagen:** System-Templates in Migration/Seed pflegen (`csv_field_mappings`, `is_system=true`). `type_conversions` und `source_unit` dort setzen, wo Einheiten aus Exporten abweichen (z. B. Apple kJ).
7. **Validierung:** Neue/angepasste Admin-Vorlagen müssen **`validate_csv_template`** passieren (Create/Update liefert bei Fehlern **422** mit `validation`). Tests für Randfälle ergänzen (`tests/test_template_validator.py` o. ä.).
8. **API / Frontend:** Neue Admin-Endpunkte in `main.py` registrieren; Frontend **nur** über `api.js`. Bei strukturierten FastAPI-Fehlern (`detail` als Objekt/Liste) bestehende Hilfen (`formatFastApiDetail`) nutzen.
---
## 3. Checkliste: Bestehendes Modul ändern
- Schema-Änderung: Migration + ggf. **`module_registry`**-Felder anpassen.
- Neue Spalte im Import: Executor-Mapping, optional `type_conversions` / Validator.
- Änderung an Duplikatlogik: `duplicate_key` / `ON CONFLICT`-Pfad im Executor prüfen.
- Datums-/Zeit-Parsing: **`type_converter`** ISO-Daten `YYYY-MM-DD` konsistent (**`dayfirst=False`**), Zeiten `HH:MM` ohne Sekunden unterstützen wo nötig.
---
## 4. Bekannte Einschränkungen (Follow-up in Gitea)
- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import.
- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71).
---
## 5. Verwandte Regeln
- `.claude/rules/ARCHITECTURE.md` Router, DB, `source`-Tracking
- `.claude/rules/CODING_RULES.md` Kurzverweis Universal CSV
- `.claude/rules/DOCUMENTATION.md` Ablage technischer Specs
---
**Version:** 1.0

View File

@ -216,8 +216,11 @@ updated_at TIMESTAMP DEFAULT NOW()
Tabellen die Daten aus externen Quellen empfangen brauchen:
```sql
source VARCHAR(50) DEFAULT 'manual'
-- Werte: 'manual' | 'apple_health' | 'garmin' | 'withings'
-- Werte u. a.: 'manual' | 'apple_health' | 'garmin' | 'withings' | 'csv'
```
Importe über den **Universal CSV**-Pfad setzen `source = 'csv'`, sofern die Tabelle ein `source`-Feld hat; CHECK-Constraints und Migrationen müssen diesen Wert erlauben.
**Agent-Pflicht bei neuen Import-Zielen oder Executor-Änderungen:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`
Manuelle Einträge (`source = 'manual'`) haben IMMER Vorrang bei Reimport:
```sql

View File

@ -39,6 +39,13 @@ from slowapi import Limiter
def sensitive(request: Request, ...):
```
### 6. Universal CSV Import / Admin-Vorlagen
Neues **Import-Zielmodul**, Änderungen an **`csv_parser`**, Executor, DB-`source`/`CHECK`, oder System-CSV-Vorlagen:
- Pflichtlektüre und Checkliste: **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`**
- Keine zweite DB-Connection im Importpfad; Zeilenfehler ohne „aborted transaction“ (SAVEPOINT-Muster wo nötig)
- Admin Create/Update von Systemvorlagen: Validierung über `validate_csv_template` nicht umgehen
## Frontend
### 1. api.js für alle API-Calls

View File

@ -8,6 +8,7 @@
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
> | **Universal CSV Import** (neues Modul / Executor / Vorlagen) | **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** |
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
> | **Agent-Einstieg** | **`.claude/README.md`** |
@ -98,6 +99,12 @@ frontend/src/
**Branch:** develop
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
### Updates (09.04.2026 - Universal CSV Import, Prod-Migration abgeschlossen)
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
- **Regeln:** Verweise in `.claude/rules/ARCHITECTURE.md` (§3.2 `source`), `.claude/rules/CODING_RULES.md` (§6)
- **Follow-ups:** **Gitea #71** Dry-Run inkl. `import_row_processing`, Nutzer-Mapping-Validierung, Fehler-Hints in der Import-UI ([Issue](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71))
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md`**P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.

View File

@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
- PATCH: Bugfix, kleine Änderung, Refactor
"""
APP_VERSION = "0.9p"
BUILD_DATE = "2026-04-09"
APP_VERSION = "0.9q"
BUILD_DATE = "2026-04-11"
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
MODULE_VERSIONS = {
@ -29,13 +29,26 @@ MODULE_VERSIONS = {
"exportdata": "1.1.0",
"importdata": "1.0.0",
"membership": "2.1.0",
"workflow": "0.6.0", # Phase 4: End Node Template Engine
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
}
CHANGELOG = [
{
"version": "0.9q",
"date": "2026-04-11",
"changes": [
"Workflow Engine Part 3: Inline Prompts",
"Frontend: Radio Buttons (Reference/Inline), InlineTemplateEditor Component",
"Frontend: Placeholder Picker für Inline-Templates, Cursor-Position Tracking",
"Backend: load_prompt_template() unterstützt inline_template",
"Backend: WorkflowNode.inline_template Feld hinzugefügt",
"Serialization: inline_template speichern/laden in graph_data",
"Validation: Prüft dass entweder prompt_slug ODER inline_template gesetzt",
],
},
{
"version": "0.9p",
"date": "2026-04-09",

View File

@ -18,7 +18,7 @@ import json
from jinja2 import Environment, ChainableUndefined, TemplateError
from workflow_models import (
WorkflowGraph, NodeExecutionState, ExecutionResult,
WorkflowGraph, WorkflowNode, NodeExecutionState, ExecutionResult,
NodeStatus, NormalizedSignal, FallbackStrategy, SignalStatus,
EndNodeOutputMode
)
@ -278,9 +278,10 @@ async def execute_node(
# Analysis Nodes
if node.type == "analysis":
# 1. Lade Prompt
prompt_template = await load_prompt_template(node.prompt_slug, context)
logger.debug(f"Node {node.id}: Loaded prompt '{node.prompt_slug}'")
# 1. Lade Prompt (Part 3: inline_template support)
prompt_template = await load_prompt_template(node, context)
source_type = "inline" if node.inline_template else "reference"
logger.debug(f"Node {node.id}: Loaded prompt from {source_type}")
# 2. Parse question_augmentations
questions = []
@ -812,39 +813,64 @@ def _has_active_incoming_edge(node, graph: WorkflowGraph, context: Dict[str, Any
return False
async def load_prompt_template(prompt_slug: str, context: Dict[str, Any]) -> str:
async def load_prompt_template(node: WorkflowNode, context: Dict[str, Any]) -> str:
"""
Lädt Prompt-Template aus DB und resolved Platzhalter.
Lädt Prompt-Template aus DB (reference mode) oder direkt vom Node (inline mode).
Part 3: Inline Prompts - Unterstützt zwei Modi:
- Reference Mode: prompt_slug Template aus ai_prompts Tabelle
- Inline Mode: inline_template Template direkt vom Node
Args:
prompt_slug: Slug des Prompts (z.B. "pipeline_body")
node: WorkflowNode mit prompt_slug ODER inline_template
context: {"variables": {"name": "Lars", ...}, "profile_id": "..."}
Returns:
Resolved prompt template
Raises:
HTTPException: Wenn weder prompt_slug noch inline_template gesetzt
Beispiel:
>>> template = await load_prompt_template("pipeline_body", {"profile_id": "123"})
>>> node = WorkflowNode(id="n1", prompt_slug="pipeline_body")
>>> template = await load_prompt_template(node, {"profile_id": "123"})
>>> "{{name}}" not in template
True
"""
from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog
from prompt_executor import resolve_placeholders
from fastapi import HTTPException
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT template FROM ai_prompts WHERE slug = %s AND active = true",
(prompt_slug,)
# Mode 1: Inline Template (NEU)
if node.inline_template:
logger.debug(f"Node {node.id}: Using inline template ({len(node.inline_template)} chars)")
template = node.inline_template
# Mode 2: Reference (bestehend)
elif node.prompt_slug:
logger.debug(f"Node {node.id}: Loading prompt '{node.prompt_slug}' from DB")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT template FROM ai_prompts WHERE slug = %s AND active = true",
(node.prompt_slug,)
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Prompt not found: {node.prompt_slug}")
template = row['template']
# Mode 3: Error - weder inline noch reference
else:
raise HTTPException(
status_code=400,
detail=f"Node {node.id}: Either prompt_slug or inline_template required"
)
row = cur.fetchone()
if not row:
raise ValueError(f"Prompt not found: {prompt_slug}")
template = row['template']
# Resolve Placeholders using modern prompt_executor method
profile_id = context.get("profile_id")
if not profile_id:
raise HTTPException(status_code=400, detail="profile_id required in context")
# Build variables dict with ALL registered placeholders
variables = {}
@ -852,14 +878,51 @@ async def load_prompt_template(prompt_slug: str, context: Dict[str, Any]) -> str
try:
# Get all placeholder values from registry
processed_placeholders = get_placeholder_example_values(profile_id)
logger.info(f"🔍 DEBUG: Loaded {len(processed_placeholders)} placeholders from registry")
logger.info(f"🔍 DEBUG: Sample keys (first 3): {list(processed_placeholders.keys())[:3]}")
# Remove {{ }} from keys (placeholder_resolver returns them with wrappers)
cleaned_placeholders = {
key.replace('{{', '').replace('}}', ''): value
key.replace('{{', '').replace('}}', '').strip(): value
for key, value in processed_placeholders.items()
}
logger.info(f"🔍 DEBUG: Cleaned keys (first 3): {list(cleaned_placeholders.keys())[:3]}")
logger.info(f"🔍 DEBUG: Sample values: name={cleaned_placeholders.get('name')}, age={cleaned_placeholders.get('age')}, geschlecht={cleaned_placeholders.get('geschlecht')}")
variables.update(cleaned_placeholders)
except Exception as e:
logger.warning(f"Failed to load placeholders for workflow: {e}")
logger.error(f"❌ CRITICAL: Failed to load placeholders for workflow: {e}", exc_info=True)
# Add workflow node outputs as placeholders (Part 3: Inline Prompts)
# Format: node_id.analysis_core, node_id.signal_xyz, node_id.question_xyz
node_results = context.get("node_results", {})
if node_results:
logger.info(f"🔍 DEBUG: Adding {len(node_results)} node outputs as placeholders")
for node_id, node_state in node_results.items():
# analysis_core
if hasattr(node_state, 'analysis_core') and node_state.analysis_core:
key = f"{node_id}.analysis_core"
variables[key] = node_state.analysis_core
logger.debug(f" Added placeholder: {key} = {node_state.analysis_core[:50]}...")
# decision_signals (keyed by question ID)
if hasattr(node_state, 'decision_signals') and node_state.decision_signals:
for signal_id, signal_value in node_state.decision_signals.items():
# Signal placeholder: node_id.signal_question_id
signal_key = f"{node_id}.signal_{signal_id}"
variables[signal_key] = signal_value
logger.debug(f" Added placeholder: {signal_key} = {signal_value}")
# Question texts (from graph metadata if available)
# NOTE: Question text placeholders are populated from graph in PlaceholderPicker
# Here we only add if available in node_state metadata
if hasattr(node_state, 'metadata') and isinstance(node_state.metadata, dict):
questions = node_state.metadata.get('questions', [])
for q in questions:
if isinstance(q, dict) and 'id' in q and 'question' in q:
question_key = f"{node_id}.question_{q['id']}"
variables[question_key] = q['question']
logger.debug(f" Added placeholder: {question_key}")
# Load catalog for |d modifier support
try:
@ -868,13 +931,22 @@ async def load_prompt_template(prompt_slug: str, context: Dict[str, Any]) -> str
catalog = None
logger.warning(f"Failed to load placeholder catalog for workflow: {e}")
logger.info(f"🔍 DEBUG: Template before resolution:\n{template[:200]}...")
logger.info(f"🔍 DEBUG: Variables dict has {len(variables)} entries")
# Resolve with modern executor
debug_info = {}
resolved = resolve_placeholders(
template=template,
variables=variables,
debug_info=debug_info,
catalog=catalog
)
logger.info(f"🔍 DEBUG: Resolved placeholders: {debug_info.get('resolved_placeholders', {})}")
logger.info(f"🔍 DEBUG: Unresolved placeholders: {debug_info.get('unresolved_placeholders', [])}")
logger.info(f"🔍 DEBUG: Template after resolution:\n{resolved[:200]}...")
return resolved

View File

@ -190,7 +190,8 @@ class WorkflowNode(BaseModel):
position: Optional[Position] = Field(None, description="Position im visuellen Editor")
# ANALYSIS-Knoten
prompt_slug: Optional[str] = Field(None, description="Slug des auszuführenden Prompts")
prompt_slug: Optional[str] = Field(None, description="Slug des auszuführenden Prompts (reference mode)")
inline_template: Optional[str] = Field(None, description="Inline-Prompt-Template (inline mode, Part 3)")
question_augmentations: Optional[List[QuestionAugmentation]] = Field(None, description="Fragenergänzungen (knotengebunden, überschreiben Prompt-Defaults)")
# LOGIC-Knoten

View File

@ -0,0 +1,106 @@
/**
* ConfirmDialog Component
*
* Modal confirmation dialog
*
* Props:
* - message: string
* - onConfirm: callback when confirmed
* - onCancel: callback when cancelled
* - confirmText: string (default: 'OK')
* - cancelText: string (default: 'Abbrechen')
* - type: 'warning' | 'danger' | 'info' (default: 'warning')
*/
export function ConfirmDialog({
message,
onConfirm,
onCancel,
confirmText = 'OK',
cancelText = 'Abbrechen',
type = 'warning'
}) {
const colors = {
warning: '#FFC107',
danger: 'var(--danger)',
info: 'var(--accent)'
}
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 20000
}}
onClick={onCancel}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '24px',
maxWidth: '500px',
width: '90%',
boxShadow: '0 8px 24px rgba(0,0,0,0.3)'
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
fontSize: '16px',
marginBottom: '24px',
color: 'var(--text1)',
lineHeight: '1.5'
}}
>
{message}
</div>
<div
style={{
display: 'flex',
gap: '12px',
justifyContent: 'flex-end'
}}
>
<button
onClick={onCancel}
style={{
padding: '8px 16px',
border: '1px solid var(--border)',
borderRadius: '6px',
background: 'var(--surface2)',
color: 'var(--text1)',
cursor: 'pointer',
fontSize: '14px'
}}
>
{cancelText}
</button>
<button
onClick={onConfirm}
style={{
padding: '8px 16px',
border: 'none',
borderRadius: '6px',
background: colors[type],
color: type === 'warning' ? '#000' : 'white',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500
}}
>
{confirmText}
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,95 @@
import { useEffect } from 'react'
/**
* Toast Notification Component
*
* Auto-closing notification that appears at the top of the screen
*
* Props:
* - message: string
* - type: 'success' | 'error' | 'warning' | 'info'
* - duration: number (ms, default 3000)
* - onClose: callback when toast closes
*/
export function Toast({ message, type = 'info', duration = 3000, onClose }) {
useEffect(() => {
const timer = setTimeout(() => {
if (onClose) onClose()
}, duration)
return () => clearTimeout(timer)
}, [duration, onClose])
const styles = {
success: {
background: '#4CAF50',
color: 'white',
icon: '✅'
},
error: {
background: 'var(--danger)',
color: 'white',
icon: '❌'
},
warning: {
background: '#FFC107',
color: '#856404',
icon: '⚠️'
},
info: {
background: 'var(--accent)',
color: 'white',
icon: ''
}
}
const style = styles[type] || styles.info
return (
<div
style={{
position: 'fixed',
top: '80px',
left: '50%',
transform: 'translateX(-50%)',
background: style.background,
color: style.color,
padding: '12px 24px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: 500,
minWidth: '300px',
maxWidth: '600px',
animation: 'slideDown 0.3s ease-out'
}}
onClick={onClose}
>
<span style={{ fontSize: '18px' }}>{style.icon}</span>
<span>{message}</span>
</div>
)
}
// Add animation CSS if not already in global styles
const styleElement = document.createElement('style')
styleElement.textContent = `
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
`
if (!document.querySelector('style[data-toast-styles]')) {
styleElement.setAttribute('data-toast-styles', 'true')
document.head.appendChild(styleElement)
}

View File

@ -5,16 +5,22 @@ import { Handle, Position } from 'reactflow'
*
* Properties:
* - data.label: Node-Label
* - data.prompt_slug: Slug des referenzierten Basis-Prompts
* - data.prompt_slug: Slug des referenzierten Basis-Prompts (Reference Mode)
* - data.inline_template: Inline Prompt-Template (Inline Mode)
* - data.prompt_name: Name des Prompts (optional, für Display)
* - data.questions: Array von Question Augmentations
* - selected: Boolean
*/
export function AnalysisNode({ data, selected }) {
const hasQuestions = data.questions?.length > 0
const promptName = data.prompt_name || (data.prompt_slug ? `Prompt: ${data.prompt_slug}` : 'Kein Prompt')
const questionCount = data.questions?.length || 0
// Part 3: Inline Prompts - Zeige "Inline-Template" oder Prompt-Namen
const isInlineMode = data.inline_template !== null && data.inline_template !== undefined
const promptName = isInlineMode
? '✏️ Inline-Template'
: (data.prompt_name || (data.prompt_slug ? `Prompt: ${data.prompt_slug}` : 'Kein Prompt'))
return (
<div className={`workflow-node analysis-node ${selected ? 'selected' : ''}`}>
<div className="node-header">

View File

@ -42,24 +42,54 @@ export function JoinNode({ data, selected }) {
</div>
</div>
{/* Mehrere Target Handles für eingehende Pfade */}
{/* Mehrere Target Handles für eingehende Pfade (bis zu 8) */}
<Handle
type="target"
position={Position.Top}
id="path_1"
style={{ left: '25%', background: '#17A2B8' }}
style={{ left: '11%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_2"
style={{ left: '50%', background: '#17A2B8' }}
style={{ left: '22%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_3"
style={{ left: '75%', background: '#17A2B8' }}
style={{ left: '33%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_4"
style={{ left: '44%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_5"
style={{ left: '56%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_6"
style={{ left: '67%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_7"
style={{ left: '78%', background: '#17A2B8' }}
/>
<Handle
type="target"
position={Position.Top}
id="path_8"
style={{ left: '89%', background: '#17A2B8' }}
/>
{/* Ein Source Handle für konsolidierten Ausgang */}

View File

@ -0,0 +1,69 @@
import { useRef } from 'react'
/**
* InlineTemplateEditor - Template-Editor für Inline-Prompts
*
* Props:
* - value: Template-String
* - onChange: (template) => void
* - onPlaceholderPick: () => void - Öffnet Placeholder Picker
* - textareaRef: Ref für Cursor-Position (von Parent)
*/
export function InlineTemplateEditor({ value, onChange, onPlaceholderPick, textareaRef }) {
return (
<div className="inline-template-editor" style={{ marginTop: '12px' }}>
<label className="form-label">Template</label>
<div style={{ position: 'relative' }}>
<textarea
ref={textareaRef}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="Analysiere folgende Daten:&#10;&#10;Gewicht: {{ weight_current }}&#10;Ziel: {{ goal_weight }}&#10;&#10;Gib eine Empfehlung..."
rows={12}
style={{
width: '100%',
fontFamily: 'monospace',
fontSize: '13px',
padding: '12px',
paddingRight: '120px', // Platz für Button
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--text1)',
resize: 'vertical',
lineHeight: '1.5'
}}
/>
<button
className="btn-secondary"
onClick={onPlaceholderPick}
style={{
position: 'absolute',
top: '8px',
right: '8px',
fontSize: '11px',
padding: '6px 10px',
whiteSpace: 'nowrap'
}}
>
{'{{ }}'} Platzhalter
</button>
</div>
<div
className="help-text"
style={{
fontSize: '12px',
color: 'var(--text3)',
marginTop: '6px'
}}
>
💡 Tipp: Verwende <code style={{
background: 'var(--surface2)',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'monospace'
}}>{'{{ placeholder_name }}'}</code> für dynamische Werte
</div>
</div>
)
}

View File

@ -6,17 +6,19 @@ import { api } from '../../../utils/api'
*
* Props:
* - nodes: Array of workflow nodes (to extract workflow-specific placeholders)
* - currentNodeId: ID des aktuellen Nodes (wird aus Placeholders ausgeschlossen)
* - onSelect: (placeholderString) => void - Callback when placeholder is selected
* - onClose: () => void
*
* Features:
* - Lädt registrierte Platzhalter vom Backend (~120+)
* - Extrahiert Workflow-spezifische Node-Outputs
* - Filtert Selbst-Referenzierung (Node kann sich nicht selbst referenzieren)
* - Zeigt Node-Namen (nicht nur IDs)
* - Kategorisiert: System + Workflow
* - Suchfunktion über alle Kategorien
*/
export function PlaceholderPicker({ nodes, onSelect, onClose }) {
export function PlaceholderPicker({ nodes, currentNodeId, onSelect, onClose }) {
const [searchQuery, setSearchQuery] = useState('')
const [systemPlaceholders, setSystemPlaceholders] = useState([])
const [loading, setLoading] = useState(true)
@ -61,8 +63,8 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
loadPlaceholders()
}, [])
// Extrahiere Workflow-spezifische Platzhalter
const workflowPlaceholders = extractWorkflowPlaceholders(nodes)
// Extrahiere Workflow-spezifische Platzhalter (ohne aktuellen Node)
const workflowPlaceholders = extractWorkflowPlaceholders(nodes, currentNodeId)
// Kombiniere beide Listen
const allPlaceholders = [
@ -341,14 +343,19 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
/**
* Extrahiert Workflow-spezifische Platzhalter aus Nodes
*
* @param {Array} nodes - Alle Workflow-Nodes
* @param {string} currentNodeId - ID des aktuellen Nodes (wird ausgeschlossen)
*/
function extractWorkflowPlaceholders(nodes) {
function extractWorkflowPlaceholders(nodes, currentNodeId) {
const placeholders = []
console.log('🔍 Extracting workflow placeholders from nodes:', nodes)
console.log('🚫 Excluding current node:', currentNodeId)
nodes.forEach(node => {
if (node.type === 'end') return // End Node hat keine Outputs
if (node.id === currentNodeId) return // Selbst-Referenzierung verhindern
const nodeId = node.id
const nodeLabel = node.data?.label || nodeId

View File

@ -18,6 +18,9 @@ import { EndNodeConfig } from '../components/workflow/panels/EndNodeConfig'
import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPicker'
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
import { Toast } from '../components/Toast'
import { ConfirmDialog } from '../components/ConfirmDialog'
import '../styles/workflowEditor.css'
// Node-Type Mapping
@ -50,7 +53,13 @@ export default function WorkflowEditorPage() {
const [availablePrompts, setAvailablePrompts] = useState([])
const [executionResult, setExecutionResult] = useState(null)
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
const [placeholderPickerTarget, setPlaceholderPickerTarget] = useState('end') // 'end' | 'inline'
const endNodeTextareaRef = useRef(null)
const inlineTemplateTextareaRef = useRef(null)
// Toast & Confirm Dialog
const [toast, setToast] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
// Load available basis prompts for Analysis nodes
useEffect(() => {
@ -152,7 +161,7 @@ export default function WorkflowEditorPage() {
description: workflowDescription,
graph_data
})
alert('Workflow gespeichert!')
setToast({ message: '✅ Workflow gespeichert!', type: 'success' })
} else {
// Create new
console.log('✨ Creating new workflow')
@ -164,7 +173,7 @@ export default function WorkflowEditorPage() {
})
console.log('✅ Workflow created:', result)
setCurrentPrompt({ id: result.id, slug: result.slug, name: workflowName })
alert('Workflow erstellt!')
setToast({ message: '✅ Workflow erstellt!', type: 'success' })
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
navigate(`/workflow-editor/${result.id}`)
}
@ -262,30 +271,60 @@ export default function WorkflowEditorPage() {
}
const handlePlaceholderSelect = (placeholderString) => {
if (!selectedNode || selectedNode.type !== 'end') return
if (!selectedNode) return
const textarea = endNodeTextareaRef.current
const currentTemplate = selectedNode.data.template || ''
// Target bestimmen: End Node oder Inline Template
if (placeholderPickerTarget === 'end' && selectedNode.type === 'end') {
const textarea = endNodeTextareaRef.current
const currentTemplate = selectedNode.data.template || ''
// Wenn Textarea Ref verfügbar, an Cursor-Position einfügen
if (textarea) {
const cursorPos = textarea.selectionStart || currentTemplate.length
const before = currentTemplate.substring(0, cursorPos)
const after = currentTemplate.substring(cursorPos)
const newTemplate = before + placeholderString + after
// Wenn Textarea Ref verfügbar, an Cursor-Position einfügen
if (textarea) {
const cursorPos = textarea.selectionStart || currentTemplate.length
const before = currentTemplate.substring(0, cursorPos)
const after = currentTemplate.substring(cursorPos)
const newTemplate = before + placeholderString + after
handleNodeUpdate(selectedNode.id, { template: newTemplate })
handleNodeUpdate(selectedNode.id, { template: newTemplate })
// Cursor nach eingefügtem Platzhalter positionieren
setTimeout(() => {
const newPos = cursorPos + placeholderString.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
} else {
// Fallback: Am Ende einfügen
const newTemplate = currentTemplate + placeholderString
handleNodeUpdate(selectedNode.id, { template: newTemplate })
// Cursor nach eingefügtem Platzhalter positionieren
setTimeout(() => {
const newPos = cursorPos + placeholderString.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
} else {
// Fallback: Am Ende einfügen
const newTemplate = currentTemplate + placeholderString
handleNodeUpdate(selectedNode.id, { template: newTemplate })
}
}
// Inline Template (Analysis Node)
else if (placeholderPickerTarget === 'inline' && selectedNode.type === 'analysis') {
const textarea = inlineTemplateTextareaRef.current
const currentTemplate = selectedNode.data.inline_template || ''
// Wenn Textarea Ref verfügbar, an Cursor-Position einfügen
if (textarea) {
const cursorPos = textarea.selectionStart || currentTemplate.length
const before = currentTemplate.substring(0, cursorPos)
const after = currentTemplate.substring(cursorPos)
const newTemplate = before + placeholderString + after
handleNodeUpdate(selectedNode.id, { inline_template: newTemplate })
// Cursor nach eingefügtem Platzhalter positionieren
setTimeout(() => {
const newPos = cursorPos + placeholderString.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
} else {
// Fallback: Am Ende einfügen
const newTemplate = currentTemplate + placeholderString
handleNodeUpdate(selectedNode.id, { inline_template: newTemplate })
}
}
}
@ -429,6 +468,33 @@ export default function WorkflowEditorPage() {
</button>
</div>
{/* Inline Validation Display */}
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
<div style={{
background: validationErrors.length > 0 ? '#ffebee' : '#fff3cd',
border: `1px solid ${validationErrors.length > 0 ? 'var(--danger)' : '#FFC107'}`,
borderRadius: '8px',
padding: '12px',
marginBottom: '16px'
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', color: validationErrors.length > 0 ? 'var(--danger)' : '#856404' }}>
{validationErrors.length > 0 ? `${validationErrors.length} Fehler` : `⚠️ ${validationWarnings.length} Warnungen`}
</div>
{validationErrors.map((err, i) => (
<div key={`err-${i}`} style={{ fontSize: '13px', marginBottom: '4px', color: 'var(--danger)', cursor: err.nodeId ? 'pointer' : 'default' }}
onClick={() => err.nodeId && setSelectedNodeId(err.nodeId)}>
{err.message}
</div>
))}
{validationWarnings.map((warn, i) => (
<div key={`warn-${i}`} style={{ fontSize: '13px', marginBottom: '4px', color: '#856404', cursor: warn.nodeId ? 'pointer' : 'default' }}
onClick={() => warn.nodeId && setSelectedNodeId(warn.nodeId)}>
{warn.message}
</div>
))}
</div>
)}
{/* Basis-Konfiguration */}
<div className="config-section">
<label>Node-Name</label>
@ -453,49 +519,112 @@ export default function WorkflowEditorPage() {
</div>
{/* Type-spezifische Konfiguration */}
{selectedNode.type === 'analysis' && (
<>
<div className="config-section">
<label>KI-Prompt auswählen</label>
<select
value={selectedNode.data.prompt_slug ? String(selectedNode.data.prompt_slug) : ''}
onChange={(e) => {
const promptSlug = e.target.value
console.log('🎯 Prompt selected:', promptSlug, 'Type:', typeof promptSlug)
const selectedPrompt = availablePrompts.find(p => p.slug === promptSlug)
console.log('📋 Selected prompt object:', selectedPrompt)
handleNodeUpdate(selectedNode.id, {
prompt_slug: promptSlug || null,
prompt_name: selectedPrompt?.name || null
})
}}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)'
}}
>
<option value="">-- Basis-Prompt wählen --</option>
{availablePrompts.map(prompt => (
<option key={prompt.id} value={prompt.slug}>
{prompt.name}
</option>
))}
</select>
{selectedNode.data.prompt_slug && (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text3)' }}>
Prompt: {selectedNode.data.prompt_slug} ({selectedNode.data.prompt_name || 'unbekannt'})
{selectedNode.type === 'analysis' && (() => {
// Helper: Bestimme aktuellen Mode basierend auf node.data
const isInlineMode = selectedNode.data.inline_template !== null && selectedNode.data.inline_template !== undefined
const isReferenceMode = !isInlineMode
return (
<>
{/* Prompt Source Selector */}
<div className="config-section">
<label className="form-label">Prompt-Quelle</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="radio"
name={`promptSource-${selectedNode.id}`}
checked={isReferenceMode}
onChange={() => {
// Wechsel zu Reference Mode
handleNodeUpdate(selectedNode.id, {
inline_template: null, // Inline löschen
prompt_slug: selectedNode.data.prompt_slug || '' // Behalte existierenden slug
})
}}
style={{ marginRight: '8px' }}
/>
<span style={{ fontSize: '14px' }}>📚 Basis-Prompt referenzieren</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="radio"
name={`promptSource-${selectedNode.id}`}
checked={isInlineMode}
onChange={() => {
// Wechsel zu Inline Mode
// WICHTIG: prompt_slug NICHT löschen, damit er beim Zurückwechseln noch da ist
handleNodeUpdate(selectedNode.id, {
inline_template: selectedNode.data.inline_template || '' // Aktiviere Inline Mode
})
}}
style={{ marginRight: '8px' }}
/>
<span style={{ fontSize: '14px' }}> Inline-Template erstellen</span>
</label>
</div>
</div>
{/* Conditional Rendering: Reference Mode */}
{isReferenceMode && (
<div className="config-section">
<label>Basis-Prompt auswählen</label>
<select
value={selectedNode.data.prompt_slug || ''}
onChange={(e) => {
const promptSlug = e.target.value
const selectedPrompt = availablePrompts.find(p => p.slug === promptSlug)
handleNodeUpdate(selectedNode.id, {
prompt_slug: promptSlug,
prompt_name: selectedPrompt?.name || null,
inline_template: null
})
}}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)'
}}
>
<option value="">-- Basis-Prompt wählen --</option>
{availablePrompts.map(prompt => (
<option key={prompt.id} value={prompt.slug}>
{prompt.name}
</option>
))}
</select>
{selectedNode.data.prompt_slug && (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text3)' }}>
Prompt: {selectedNode.data.prompt_slug} ({selectedNode.data.prompt_name || 'unbekannt'})
</div>
)}
</div>
)}
</div>
<QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
</>
)}
{/* Conditional Rendering: Inline Mode */}
{isInlineMode && (
<InlineTemplateEditor
value={selectedNode.data.inline_template || ''}
onChange={(template) => handleNodeUpdate(selectedNode.id, {
inline_template: template,
prompt_slug: null
})}
onPlaceholderPick={() => {
setPlaceholderPickerTarget('inline')
setShowPlaceholderPicker(true)
}}
textareaRef={inlineTemplateTextareaRef}
/>
)}
<QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
</>
)
})()}
{selectedNode.type === 'logic' && (
<>
@ -517,7 +646,10 @@ export default function WorkflowEditorPage() {
<EndNodeConfig
node={selectedNode}
onChange={handleNodeUpdate}
onOpenPlaceholderPicker={() => setShowPlaceholderPicker(true)}
onOpenPlaceholderPicker={() => {
setPlaceholderPickerTarget('end')
setShowPlaceholderPicker(true)
}}
textareaRef={endNodeTextareaRef}
/>
)}
@ -525,36 +657,7 @@ export default function WorkflowEditorPage() {
)}
</div>
{/* Validation Panel */}
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
<div className="validation-panel">
{validationErrors.map((err, i) => (
<div key={i} className="validation-error" onClick={() => {
if (err.nodeId) {
setSelectedNodeId(err.nodeId)
}
}}>
{err.message}
</div>
))}
{validationWarnings.map((warn, i) => (
<div key={i} className="validation-warning" onClick={() => {
if (warn.nodeId) {
setSelectedNodeId(warn.nodeId)
}
}}>
{warn.message}
</div>
))}
{validationErrors.length === 0 && validationWarnings.length > 0 && (
<div className="validation-success">
Workflow ist valide ({validationWarnings.length} Warnungen)
</div>
)}
</div>
)}
{/* Validation Panel - REMOVED (moved to config panel header) */}
{/* Execution Result Viewer */}
{executionResult && (
@ -568,10 +671,33 @@ export default function WorkflowEditorPage() {
{showPlaceholderPicker && (
<PlaceholderPicker
nodes={nodes}
currentNodeId={selectedNode?.id}
onSelect={handlePlaceholderSelect}
onClose={() => setShowPlaceholderPicker(false)}
/>
)}
{/* Toast Notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
duration={toast.duration || 3000}
onClose={() => setToast(null)}
/>
)}
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
confirmText={confirmDialog.confirmText}
cancelText={confirmDialog.cancelText}
type={confirmDialog.type}
/>
)}
</div>
)
}

View File

@ -27,7 +27,7 @@
/* ── Sidebar (Node Palette) ─────────────────────────────────────────────── */
.workflow-sidebar {
width: 250px;
width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 16px;
@ -268,7 +268,7 @@
/* ── Config Panel ────────────────────────────────────────────────────────── */
.workflow-config-panel {
width: 400px;
width: 520px;
background: var(--surface);
border-left: 1px solid var(--border);
padding: 16px;
@ -453,7 +453,7 @@
}
.workflow-config-panel {
width: 350px;
width: 450px;
}
}

View File

@ -22,6 +22,7 @@ export function serializeToWorkflowGraph(nodes, edges, metadata = {}) {
// Type-spezifische Felder
...(node.type === 'analysis' && {
prompt_slug: node.data.prompt_slug || null,
inline_template: node.data.inline_template || null, // Part 3: Inline Prompts
prompt_name: node.data.prompt_name || null,
question_augmentations: node.data.questions || [], // Backend erwartet question_augmentations
fallback_strategy: node.data.fallback_strategy || 'conservative_skip'
@ -84,6 +85,7 @@ export function deserializeFromWorkflowGraph(jsonbData) {
...(node.type === 'analysis' && {
prompt_slug: node.prompt_slug || node.prompt_id || null, // Fallback für alte Workflows mit prompt_id
inline_template: node.inline_template || null, // Part 3: Inline Prompts
prompt_name: node.prompt_name || null, // Falls vom Backend mitgeliefert
questions: node.question_augmentations || node.questions || [], // Backend sendet question_augmentations
fallback_strategy: node.fallback_strategy || 'conservative_skip'

View File

@ -86,17 +86,29 @@ function validateLogic(nodes, edges, errors, warnings) {
// Analysis Nodes
if (node.type === 'analysis') {
const questions = node.data.questions || []
const hasPromptSlug = node.data.prompt_slug != null && node.data.prompt_slug !== ''
const hasInlineTemplate = node.data.inline_template != null && node.data.inline_template.trim() !== ''
// Prompt ausgewählt?
if (!node.data.prompt_slug) {
// Part 3: Validation - Entweder prompt_slug ODER inline_template
if (!hasPromptSlug && !hasInlineTemplate) {
errors.push({
type: 'config',
message: `Analysis-Node "${node.data.label}" hat keinen Prompt`,
message: `Analysis-Node "${node.data.label}" benötigt entweder Basis-Prompt oder Inline-Template`,
nodeId: node.id,
severity: 'error'
})
}
// Warning wenn beide gesetzt (sollte nicht passieren, aber zur Sicherheit)
if (hasPromptSlug && hasInlineTemplate) {
warnings.push({
type: 'config',
message: `Analysis-Node "${node.data.label}" hat sowohl Basis-Prompt als auch Inline-Template - Inline hat Vorrang`,
nodeId: node.id,
severity: 'warning'
})
}
// Fragen validieren
questions.forEach((q, idx) => {
if (!q.question?.trim()) {