diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 5e7d76f..8112ece 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -103,6 +103,156 @@ def list_placeholders_endpoint(session: dict=Depends(require_auth)): return get_placeholder_catalog(profile_id) +@router.get("/export-all") +def export_all_prompts(session: dict = Depends(require_admin)): + """ + Export all prompts as JSON array. + Admin only. Used for backup and dev→prod sync. + + IMPORTANT: Must be defined BEFORE /{prompt_id} route to avoid routing conflict. + """ + from datetime import datetime + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug") + prompts = [r2d(row) for row in cur.fetchall()] + + # Convert to export format (clean up DB-specific fields) + export_data = [] + for p in prompts: + export_item = { + 'slug': p['slug'], + 'name': p['name'], + 'display_name': p.get('display_name'), + 'description': p.get('description'), + 'type': p.get('type', 'pipeline'), + 'category': p.get('category', 'ganzheitlich'), + 'template': p.get('template'), + 'stages': p.get('stages'), + 'output_format': p.get('output_format', 'text'), + 'output_schema': p.get('output_schema'), + 'question_augmentations': p.get('question_augmentations'), + 'graph_data': p.get('graph_data'), + 'active': p.get('active', True), + 'sort_order': p.get('sort_order', 0) + } + export_data.append(export_item) + + return { + 'export_date': datetime.now().isoformat(), + 'count': len(export_data), + 'prompts': export_data + } + + +@router.post("/import") +def import_prompts( + data: dict, + overwrite: bool = False, + session: dict = Depends(require_admin) +): + """ + Import prompts from JSON export. + + Args: + data: Export data from /export-all endpoint + overwrite: If true, update existing prompts. If false, skip duplicates. + + Returns: + Summary of import results (created, updated, skipped) + + IMPORTANT: Must be defined BEFORE /{prompt_id} route to avoid routing conflict. + """ + if 'prompts' not in data: + raise HTTPException(400, "Invalid import data: missing 'prompts' key") + + prompts = data['prompts'] + created = 0 + updated = 0 + skipped = 0 + errors = [] + + with get_db() as conn: + cur = get_cursor(conn) + + for p in prompts: + slug = p.get('slug') + if not slug: + errors.append('Prompt without slug skipped') + continue + + # Check if exists + cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (slug,)) + existing = cur.fetchone() + + if existing and not overwrite: + skipped += 1 + continue + + # Prepare JSON fields if present + stages_json = None + if p.get('stages'): + stages_json = json.dumps(p['stages']) if isinstance(p['stages'], list) else p['stages'] + + output_schema_json = None + if p.get('output_schema'): + output_schema_json = json.dumps(p['output_schema']) if isinstance(p['output_schema'], dict) else p['output_schema'] + + question_aug_json = None + if p.get('question_augmentations'): + question_aug_json = json.dumps(p['question_augmentations']) if isinstance(p['question_augmentations'], (dict, list)) else p['question_augmentations'] + + graph_data_json = None + if p.get('graph_data'): + graph_data_json = json.dumps(p['graph_data']) if isinstance(p['graph_data'], dict) else p['graph_data'] + + if existing: + # Update existing + cur.execute(""" + UPDATE ai_prompts SET + name=%s, display_name=%s, description=%s, type=%s, + category=%s, template=%s, stages=%s, output_format=%s, + output_schema=%s, question_augmentations=%s, graph_data=%s, + active=%s, sort_order=%s, + updated=CURRENT_TIMESTAMP + WHERE slug=%s + """, ( + p.get('name'), p.get('display_name'), p.get('description'), + p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), + p.get('template'), stages_json, p.get('output_format', 'text'), + output_schema_json, question_aug_json, graph_data_json, + p.get('active', True), p.get('sort_order', 0), slug + )) + updated += 1 + else: + # Create new + cur.execute(""" + INSERT INTO ai_prompts ( + slug, name, display_name, description, type, category, + template, stages, output_format, output_schema, + question_augmentations, graph_data, + active, sort_order, created, updated + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) + """, ( + slug, p.get('name'), p.get('display_name'), p.get('description'), + p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), + p.get('template'), stages_json, p.get('output_format', 'text'), + output_schema_json, question_aug_json, graph_data_json, + p.get('active', True), p.get('sort_order', 0) + )) + created += 1 + + conn.commit() + + return { + 'success': True, + 'created': created, + 'updated': updated, + 'skipped': skipped, + 'errors': errors if errors else None + } + + @router.get("/{prompt_id}") def get_prompt(prompt_id: str, session: dict=Depends(require_auth)): """Get single AI prompt by ID (UUID).""" @@ -214,11 +364,31 @@ def duplicate_prompt(prompt_id: str, session: dict=Depends(require_admin)): new_display_name = f"{original.get('display_name') or original['name']} (Kopie)" + # Prepare JSONB fields (convert dict/list to JSON string if needed) + stages_json = json.dumps(original['stages']) if original.get('stages') else None + output_schema_json = json.dumps(original['output_schema']) if original.get('output_schema') else None + question_aug_json = json.dumps(original['question_augmentations']) if original.get('question_augmentations') else None + graph_data_json = json.dumps(original['graph_data']) if original.get('graph_data') else None + cur.execute( - """INSERT INTO ai_prompts (id, name, slug, display_name, description, template, category, active, sort_order, created, updated) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""", - (new_id, new_name, new_slug, new_display_name, original['description'], original['template'], - original.get('category', 'ganzheitlich'), original['active'], original['sort_order']) + """INSERT INTO ai_prompts ( + id, name, slug, display_name, description, template, category, + type, stages, output_format, output_schema, + question_augmentations, graph_data, + active, sort_order, created, updated + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, + %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + )""", + (new_id, new_name, new_slug, new_display_name, + original.get('description'), original.get('template'), + original.get('category', 'ganzheitlich'), + original.get('type', 'pipeline'), stages_json, + original.get('output_format', 'text'), output_schema_json, + question_aug_json, graph_data_json, + original.get('active', True), original.get('sort_order', 0)) ) return {"id": new_id, "slug": new_slug, "name": new_name} @@ -1607,132 +1777,3 @@ def update_unified_prompt(prompt_id: str, p: UnifiedPromptUpdate, session: dict ) return {"ok": True} - - -@router.get("/export-all") -def export_all_prompts(session: dict = Depends(require_admin)): - """ - Export all prompts as JSON array. - Admin only. Used for backup and dev→prod sync. - """ - from datetime import datetime - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug") - prompts = [r2d(row) for row in cur.fetchall()] - - # Convert to export format (clean up DB-specific fields) - export_data = [] - for p in prompts: - export_item = { - 'slug': p['slug'], - 'name': p['name'], - 'display_name': p.get('display_name'), - 'description': p.get('description'), - 'type': p.get('type', 'pipeline'), - 'category': p.get('category', 'ganzheitlich'), - 'template': p.get('template'), - 'stages': p.get('stages'), - 'output_format': p.get('output_format', 'text'), - 'output_schema': p.get('output_schema'), - 'active': p.get('active', True), - 'sort_order': p.get('sort_order', 0) - } - export_data.append(export_item) - - return { - 'export_date': datetime.now().isoformat(), - 'count': len(export_data), - 'prompts': export_data - } - - -@router.post("/import") -def import_prompts( - data: dict, - overwrite: bool = False, - session: dict = Depends(require_admin) -): - """ - Import prompts from JSON export. - - Args: - data: Export data from /export-all endpoint - overwrite: If true, update existing prompts. If false, skip duplicates. - - Returns: - Summary of import results (created, updated, skipped) - """ - if 'prompts' not in data: - raise HTTPException(400, "Invalid import data: missing 'prompts' key") - - prompts = data['prompts'] - created = 0 - updated = 0 - skipped = 0 - errors = [] - - with get_db() as conn: - cur = get_cursor(conn) - - for p in prompts: - slug = p.get('slug') - if not slug: - errors.append('Prompt without slug skipped') - continue - - # Check if exists - cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (slug,)) - existing = cur.fetchone() - - if existing and not overwrite: - skipped += 1 - continue - - # Prepare stages JSON if present - stages_json = None - if p.get('stages'): - stages_json = json.dumps(p['stages']) if isinstance(p['stages'], list) else p['stages'] - - if existing: - # Update existing - cur.execute(""" - UPDATE ai_prompts SET - name=%s, display_name=%s, description=%s, type=%s, - category=%s, template=%s, stages=%s, output_format=%s, - output_schema=%s, active=%s, sort_order=%s, - updated=CURRENT_TIMESTAMP - WHERE slug=%s - """, ( - p.get('name'), p.get('display_name'), p.get('description'), - p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), - p.get('template'), stages_json, p.get('output_format', 'text'), - p.get('output_schema'), p.get('active', True), - p.get('sort_order', 0), slug - )) - updated += 1 - else: - # Create new - cur.execute(""" - INSERT INTO ai_prompts ( - slug, name, display_name, description, type, category, - template, stages, output_format, output_schema, - active, sort_order, created, updated - ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) - """, ( - slug, p.get('name'), p.get('display_name'), p.get('description'), - p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), - p.get('template'), stages_json, p.get('output_format', 'text'), - p.get('output_schema'), p.get('active', True), p.get('sort_order', 0) - )) - created += 1 - - conn.commit() - - return { - 'success': True, - 'created': created, - 'updated': updated, - 'skipped': skipped, - 'errors': errors if errors else None - } diff --git a/backend/workflow_executor.py b/backend/workflow_executor.py index a516727..e5a7152 100644 --- a/backend/workflow_executor.py +++ b/backend/workflow_executor.py @@ -594,10 +594,20 @@ def execute_end_node( # AUTO mode: Concatenate all analysis_core values logger.debug(f"End node {node.id}: Using AUTO output mode") + # Get graph from context for node label lookup + graph = context.get("graph") + combined_analysis = [] for node_id, node_state in context.get("node_results", {}).items(): if node_state.status == NodeStatus.EXECUTED and node_state.analysis_core: - combined_analysis.append(f"## {node_id}\n{node_state.analysis_core}") + # Get node label from graph (fallback to node_id if not found) + node_label = node_id # default + if graph: + node_obj = next((n for n in graph.nodes if n.id == node_id), None) + if node_obj and hasattr(node_obj, 'data') and node_obj.data: + node_label = node_obj.data.get('label', node_id) + + combined_analysis.append(f"## {node_label}\n{node_state.analysis_core}") final_output = "\n\n".join(combined_analysis) if combined_analysis else "[No analysis generated]" @@ -999,7 +1009,13 @@ def aggregate_results(node_states: List[NodeExecutionState], graph) -> Dict[str, final_output = state.analysis_core else: # Regular node - add to combined analysis - combined_analysis.append(f"## {state.node_id}\n{state.analysis_core}") + # Get node label from graph (fallback to node_id) + node_label = state.node_id # default + node_obj = next((n for n in graph.nodes if n.id == state.node_id), None) + if node_obj and hasattr(node_obj, 'data') and node_obj.data: + node_label = node_obj.data.get('label', state.node_id) + + combined_analysis.append(f"## {node_label}\n{state.analysis_core}") if state.normalized_signals: all_signals.extend([s.model_dump() for s in state.normalized_signals]) diff --git a/frontend/src/components/workflow/WorkflowCanvas.jsx b/frontend/src/components/workflow/WorkflowCanvas.jsx index 447d68c..930f8c4 100644 --- a/frontend/src/components/workflow/WorkflowCanvas.jsx +++ b/frontend/src/components/workflow/WorkflowCanvas.jsx @@ -39,6 +39,7 @@ export function WorkflowCanvas({ className="workflow-canvas" minZoom={0.2} maxZoom={2} + deleteKeyCode={['Backspace', 'Delete']} defaultEdgeOptions={{ animated: false, style: { strokeWidth: 2 }, diff --git a/frontend/src/components/workflow/panels/ValidationPanel.jsx b/frontend/src/components/workflow/panels/ValidationPanel.jsx new file mode 100644 index 0000000..804a3e4 --- /dev/null +++ b/frontend/src/components/workflow/panels/ValidationPanel.jsx @@ -0,0 +1,246 @@ +import { useState } from 'react' +import { AlertCircle, AlertTriangle, ChevronDown, ChevronRight, X } from 'lucide-react' + +/** + * ValidationPanel - Zeigt Fehler und Warnungen mit Details + * + * Features: + * - Aufklappbar (collapsible) + * - Click-to-Jump zu betroffener Node + * - Gruppierung nach Severity + * - Klare visuelle Trennung + */ +export function ValidationPanel({ errors, warnings, onNodeClick, onClose }) { + const [isExpanded, setIsExpanded] = useState(true) + const [showErrors, setShowErrors] = useState(true) + const [showWarnings, setShowWarnings] = useState(true) + + const totalCount = errors.length + warnings.length + + if (totalCount === 0) return null + + const handleItemClick = (item) => { + if (item.nodeId && onNodeClick) { + onNodeClick(item.nodeId) + } + } + + return ( +
+ Wie sollen existierende Prompts behandelt werden? +
+ +