diff --git a/backend/version.py b/backend/version.py
index c90e6c0..bb05fce 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -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",
diff --git a/backend/workflow_executor.py b/backend/workflow_executor.py
index e238479..4b99bde 100644
--- a/backend/workflow_executor.py
+++ b/backend/workflow_executor.py
@@ -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 = {}
diff --git a/backend/workflow_models.py b/backend/workflow_models.py
index 12134c9..5b92126 100644
--- a/backend/workflow_models.py
+++ b/backend/workflow_models.py
@@ -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
diff --git a/frontend/src/components/workflow/panels/InlineTemplateEditor.jsx b/frontend/src/components/workflow/panels/InlineTemplateEditor.jsx
new file mode 100644
index 0000000..356a511
--- /dev/null
+++ b/frontend/src/components/workflow/panels/InlineTemplateEditor.jsx
@@ -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 (
+
+
+
+
+
+ 💡 Tipp: Verwende {'{{ placeholder_name }}'} für dynamische Werte
+
+
+ )
+}
diff --git a/frontend/src/pages/WorkflowEditorPage.jsx b/frontend/src/pages/WorkflowEditorPage.jsx
index 5edc4ea..39b6457 100644
--- a/frontend/src/pages/WorkflowEditorPage.jsx
+++ b/frontend/src/pages/WorkflowEditorPage.jsx
@@ -18,6 +18,7 @@ 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 '../styles/workflowEditor.css'
// Node-Type Mapping
@@ -50,7 +51,9 @@ 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)
// Load available basis prompts for Analysis nodes
useEffect(() => {
@@ -262,30 +265,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 })
+ }
}
}
@@ -455,43 +488,100 @@ export default function WorkflowEditorPage() {
{/* Type-spezifische Konfiguration */}
{selectedNode.type === 'analysis' && (
<>
+ {/* Prompt Source Selector */}
-
-
- {selectedNode.data.prompt_slug && (
-
- Prompt: {selectedNode.data.prompt_slug} ({selectedNode.data.prompt_name || 'unbekannt'})
-
- )}
+
+
+
+
+
+ {/* Conditional Rendering: Reference oder Inline */}
+ {selectedNode.data.prompt_slug !== null && !selectedNode.data.inline_template && (
+
+
+
+ {selectedNode.data.prompt_slug && (
+
+ Prompt: {selectedNode.data.prompt_slug} ({selectedNode.data.prompt_name || 'unbekannt'})
+
+ )}
+
+ )}
+
+ {/* Inline Template Editor */}
+ {(selectedNode.data.inline_template !== null || !selectedNode.data.prompt_slug) && (
+ handleNodeUpdate(selectedNode.id, {
+ inline_template: template,
+ prompt_slug: null
+ })}
+ onPlaceholderPick={() => {
+ setPlaceholderPickerTarget('inline')
+ setShowPlaceholderPicker(true)
+ }}
+ textareaRef={inlineTemplateTextareaRef}
+ />
+ )}
+
>
@@ -517,7 +607,10 @@ export default function WorkflowEditorPage() {
setShowPlaceholderPicker(true)}
+ onOpenPlaceholderPicker={() => {
+ setPlaceholderPickerTarget('end')
+ setShowPlaceholderPicker(true)
+ }}
textareaRef={endNodeTextareaRef}
/>
)}
diff --git a/frontend/src/utils/workflowSerializer.js b/frontend/src/utils/workflowSerializer.js
index 40048f6..f60dcbc 100644
--- a/frontend/src/utils/workflowSerializer.js
+++ b/frontend/src/utils/workflowSerializer.js
@@ -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'
diff --git a/frontend/src/utils/workflowValidation.js b/frontend/src/utils/workflowValidation.js
index f715132..67011ad 100644
--- a/frontend/src/utils/workflowValidation.js
+++ b/frontend/src/utils/workflowValidation.js
@@ -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()) {