Merge pull request 'Workflow V1' (#72) from develop into main
Reviewed-on: #72
This commit is contained in:
commit
8b731313df
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
63
.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md
Normal file
63
.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Universal CSV Import – Agent-Leitfaden
|
||||
|
||||
**Stand:** 2026-04-09 · **Kontext:** Issue #21 (Universeller CSV-Parser), Prod-Migrationen u. a. 051–053.
|
||||
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
frontend/src/components/ConfirmDialog.jsx
Normal file
106
frontend/src/components/ConfirmDialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
frontend/src/components/Toast.jsx
Normal file
95
frontend/src/components/Toast.jsx
Normal 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)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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: Gewicht: {{ weight_current }} Ziel: {{ goal_weight }} 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user