feat: Workflow Engine Part 3 - Inline Prompts (v0.9q)
Some checks failed
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Failing after 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

Ermöglicht Analysis Nodes zwischen zwei Prompt-Modi zu wählen:
- Reference Mode: Basis-Prompt aus DB referenzieren (bestehend)
- Inline Mode: Template direkt im Node editieren (NEU)

Frontend:
- InlineTemplateEditor Component (~80 Zeilen)
- Radio Buttons in WorkflowEditorPage für Mode-Auswahl
- Placeholder Picker für beide Modi (End Node + Inline Template)
- Cursor-Position Tracking mit textareaRef
- Conditional Rendering basierend auf promptSource
- Validation: Entweder prompt_slug ODER inline_template

Backend:
- load_prompt_template() akzeptiert ganzen WorkflowNode (statt nur slug)
- Unterstützt inline_template (Mode 1) und prompt_slug (Mode 2)
- WorkflowNode.inline_template Feld hinzugefügt
- Validation: HTTPException wenn weder slug noch template

Serialization:
- inline_template in graph_data speichern/laden
- Backward-compatible mit bestehenden Workflows

Version: 0.9q
Module: workflow 0.7.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-04-11 08:45:00 +02:00
parent b453ce63c6
commit a1723db387
7 changed files with 295 additions and 79 deletions

View File

@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
- PATCH: Bugfix, kleine Änderung, Refactor - PATCH: Bugfix, kleine Änderung, Refactor
""" """
APP_VERSION = "0.9p" APP_VERSION = "0.9q"
BUILD_DATE = "2026-04-09" BUILD_DATE = "2026-04-11"
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
MODULE_VERSIONS = { MODULE_VERSIONS = {
@ -29,13 +29,26 @@ MODULE_VERSIONS = {
"exportdata": "1.1.0", "exportdata": "1.1.0",
"importdata": "1.0.0", "importdata": "1.0.0",
"membership": "2.1.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 "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 "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) "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
} }
CHANGELOG = [ 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", "version": "0.9p",
"date": "2026-04-09", "date": "2026-04-09",

View File

@ -278,9 +278,10 @@ async def execute_node(
# Analysis Nodes # Analysis Nodes
if node.type == "analysis": if node.type == "analysis":
# 1. Lade Prompt # 1. Lade Prompt (Part 3: inline_template support)
prompt_template = await load_prompt_template(node.prompt_slug, context) prompt_template = await load_prompt_template(node, context)
logger.debug(f"Node {node.id}: Loaded prompt '{node.prompt_slug}'") source_type = "inline" if node.inline_template else "reference"
logger.debug(f"Node {node.id}: Loaded prompt from {source_type}")
# 2. Parse question_augmentations # 2. Parse question_augmentations
questions = [] questions = []
@ -812,39 +813,64 @@ def _has_active_incoming_edge(node, graph: WorkflowGraph, context: Dict[str, Any
return False 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: Args:
prompt_slug: Slug des Prompts (z.B. "pipeline_body") node: WorkflowNode mit prompt_slug ODER inline_template
context: {"variables": {"name": "Lars", ...}, "profile_id": "..."} context: {"variables": {"name": "Lars", ...}, "profile_id": "..."}
Returns: Returns:
Resolved prompt template Resolved prompt template
Raises:
HTTPException: Wenn weder prompt_slug noch inline_template gesetzt
Beispiel: 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 >>> "{{name}}" not in template
True True
""" """
from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog
from prompt_executor import resolve_placeholders from prompt_executor import resolve_placeholders
from fastapi import HTTPException
with get_db() as conn: # Mode 1: Inline Template (NEU)
cur = get_cursor(conn) if node.inline_template:
cur.execute( logger.debug(f"Node {node.id}: Using inline template ({len(node.inline_template)} chars)")
"SELECT template FROM ai_prompts WHERE slug = %s AND active = true", template = node.inline_template
(prompt_slug,)
# 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 # Resolve Placeholders using modern prompt_executor method
profile_id = context.get("profile_id") 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 # Build variables dict with ALL registered placeholders
variables = {} variables = {}

View File

@ -190,7 +190,8 @@ class WorkflowNode(BaseModel):
position: Optional[Position] = Field(None, description="Position im visuellen Editor") position: Optional[Position] = Field(None, description="Position im visuellen Editor")
# ANALYSIS-Knoten # 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)") question_augmentations: Optional[List[QuestionAugmentation]] = Field(None, description="Fragenergänzungen (knotengebunden, überschreiben Prompt-Defaults)")
# LOGIC-Knoten # LOGIC-Knoten

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

@ -18,6 +18,7 @@ import { EndNodeConfig } from '../components/workflow/panels/EndNodeConfig'
import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPicker' import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPicker'
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel' import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer' import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
import '../styles/workflowEditor.css' import '../styles/workflowEditor.css'
// Node-Type Mapping // Node-Type Mapping
@ -50,7 +51,9 @@ export default function WorkflowEditorPage() {
const [availablePrompts, setAvailablePrompts] = useState([]) const [availablePrompts, setAvailablePrompts] = useState([])
const [executionResult, setExecutionResult] = useState(null) const [executionResult, setExecutionResult] = useState(null)
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false) const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
const [placeholderPickerTarget, setPlaceholderPickerTarget] = useState('end') // 'end' | 'inline'
const endNodeTextareaRef = useRef(null) const endNodeTextareaRef = useRef(null)
const inlineTemplateTextareaRef = useRef(null)
// Load available basis prompts for Analysis nodes // Load available basis prompts for Analysis nodes
useEffect(() => { useEffect(() => {
@ -262,30 +265,60 @@ export default function WorkflowEditorPage() {
} }
const handlePlaceholderSelect = (placeholderString) => { const handlePlaceholderSelect = (placeholderString) => {
if (!selectedNode || selectedNode.type !== 'end') return if (!selectedNode) return
const textarea = endNodeTextareaRef.current // Target bestimmen: End Node oder Inline Template
const currentTemplate = selectedNode.data.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 // Wenn Textarea Ref verfügbar, an Cursor-Position einfügen
if (textarea) { if (textarea) {
const cursorPos = textarea.selectionStart || currentTemplate.length const cursorPos = textarea.selectionStart || currentTemplate.length
const before = currentTemplate.substring(0, cursorPos) const before = currentTemplate.substring(0, cursorPos)
const after = currentTemplate.substring(cursorPos) const after = currentTemplate.substring(cursorPos)
const newTemplate = before + placeholderString + after const newTemplate = before + placeholderString + after
handleNodeUpdate(selectedNode.id, { template: newTemplate }) handleNodeUpdate(selectedNode.id, { template: newTemplate })
// Cursor nach eingefügtem Platzhalter positionieren // Cursor nach eingefügtem Platzhalter positionieren
setTimeout(() => { setTimeout(() => {
const newPos = cursorPos + placeholderString.length const newPos = cursorPos + placeholderString.length
textarea.setSelectionRange(newPos, newPos) textarea.setSelectionRange(newPos, newPos)
textarea.focus() textarea.focus()
}, 0) }, 0)
} else { } else {
// Fallback: Am Ende einfügen // Fallback: Am Ende einfügen
const newTemplate = currentTemplate + placeholderString const newTemplate = currentTemplate + placeholderString
handleNodeUpdate(selectedNode.id, { template: newTemplate }) 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 */} {/* Type-spezifische Konfiguration */}
{selectedNode.type === 'analysis' && ( {selectedNode.type === 'analysis' && (
<> <>
{/* Prompt Source Selector */}
<div className="config-section"> <div className="config-section">
<label>KI-Prompt auswählen</label> <label className="form-label">Prompt-Quelle</label>
<select <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
value={selectedNode.data.prompt_slug ? String(selectedNode.data.prompt_slug) : ''} <label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
onChange={(e) => { <input
const promptSlug = e.target.value type="radio"
console.log('🎯 Prompt selected:', promptSlug, 'Type:', typeof promptSlug) name={`promptSource-${selectedNode.id}`}
const selectedPrompt = availablePrompts.find(p => p.slug === promptSlug) checked={!!selectedNode.data.prompt_slug}
console.log('📋 Selected prompt object:', selectedPrompt) onChange={() => {
handleNodeUpdate(selectedNode.id, { handleNodeUpdate(selectedNode.id, {
prompt_slug: promptSlug || null, prompt_slug: '',
prompt_name: selectedPrompt?.name || null inline_template: null
}) })
}} }}
style={{ style={{ marginRight: '8px' }}
width: '100%', />
padding: '8px', <span style={{ fontSize: '14px' }}>📚 Basis-Prompt referenzieren</span>
borderRadius: '4px', </label>
border: '1px solid var(--border)', <label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
background: 'var(--surface)', <input
color: 'var(--text1)' type="radio"
}} name={`promptSource-${selectedNode.id}`}
> checked={!!selectedNode.data.inline_template || (!selectedNode.data.prompt_slug && !selectedNode.data.inline_template)}
<option value="">-- Basis-Prompt wählen --</option> onChange={() => {
{availablePrompts.map(prompt => ( handleNodeUpdate(selectedNode.id, {
<option key={prompt.id} value={prompt.slug}> prompt_slug: null,
{prompt.name} inline_template: ''
</option> })
))} }}
</select> style={{ marginRight: '8px' }}
{selectedNode.data.prompt_slug && ( />
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text3)' }}> <span style={{ fontSize: '14px' }}> Inline-Template erstellen</span>
Prompt: {selectedNode.data.prompt_slug} ({selectedNode.data.prompt_name || 'unbekannt'}) </label>
</div> </div>
)}
</div> </div>
{/* Conditional Rendering: Reference oder Inline */}
{selectedNode.data.prompt_slug !== null && !selectedNode.data.inline_template && (
<div className="config-section">
<label>Basis-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,
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>
)}
{/* Inline Template Editor */}
{(selectedNode.data.inline_template !== null || !selectedNode.data.prompt_slug) && (
<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} /> <QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} /> <FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
</> </>
@ -517,7 +607,10 @@ export default function WorkflowEditorPage() {
<EndNodeConfig <EndNodeConfig
node={selectedNode} node={selectedNode}
onChange={handleNodeUpdate} onChange={handleNodeUpdate}
onOpenPlaceholderPicker={() => setShowPlaceholderPicker(true)} onOpenPlaceholderPicker={() => {
setPlaceholderPickerTarget('end')
setShowPlaceholderPicker(true)
}}
textareaRef={endNodeTextareaRef} textareaRef={endNodeTextareaRef}
/> />
)} )}

View File

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

View File

@ -86,17 +86,29 @@ function validateLogic(nodes, edges, errors, warnings) {
// Analysis Nodes // Analysis Nodes
if (node.type === 'analysis') { if (node.type === 'analysis') {
const questions = node.data.questions || [] 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? // Part 3: Validation - Entweder prompt_slug ODER inline_template
if (!node.data.prompt_slug) { if (!hasPromptSlug && !hasInlineTemplate) {
errors.push({ errors.push({
type: 'config', 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, nodeId: node.id,
severity: 'error' 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 // Fragen validieren
questions.forEach((q, idx) => { questions.forEach((q, idx) => {
if (!q.question?.trim()) { if (!q.question?.trim()) {