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

View File

@ -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 = {}

View File

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

View File

@ -0,0 +1,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 { 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 */}
<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'})
</div>
)}
<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={!!selectedNode.data.prompt_slug}
onChange={() => {
handleNodeUpdate(selectedNode.id, {
prompt_slug: '',
inline_template: null
})
}}
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={!!selectedNode.data.inline_template || (!selectedNode.data.prompt_slug && !selectedNode.data.inline_template)}
onChange={() => {
handleNodeUpdate(selectedNode.id, {
prompt_slug: null,
inline_template: ''
})
}}
style={{ marginRight: '8px' }}
/>
<span style={{ fontSize: '14px' }}> Inline-Template erstellen</span>
</label>
</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} />
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
</>
@ -517,7 +607,10 @@ export default function WorkflowEditorPage() {
<EndNodeConfig
node={selectedNode}
onChange={handleNodeUpdate}
onOpenPlaceholderPicker={() => setShowPlaceholderPicker(true)}
onOpenPlaceholderPicker={() => {
setPlaceholderPickerTarget('end')
setShowPlaceholderPicker(true)
}}
textareaRef={endNodeTextareaRef}
/>
)}

View File

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

View File

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