Merge pull request 'Workflow Analysen V1.1' (#73) from develop into main
Reviewed-on: #73
This commit is contained in:
commit
2443b5ac3a
11
.claude/docs/working/Test_status_Wkf.md
Normal file
11
.claude/docs/working/Test_status_Wkf.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
Folgende Ergebnisse des Tests:
|
||||
- Valididierung gibt immer noch keine Aufschlüsse was für Fehler und Warning es sind, Es zeigt immer nur noch die Anzahl der entsprechenden Fehler/Warnungen
|
||||
- Speichern als kurzes PopUp -- gut
|
||||
- In der Node selbst wird nun eine Fehlermeldung ausgegeben. Das ist gut. In größen Workflows aber schwierig den Fehler zu lokalisieren.
|
||||
- In der automatischen Zusammenfassung in der Endnode kommt als Überschrift, z.B. Node 10, anstatt den Node-Name auszugeben.
|
||||
- Alle Änderungen an Nodes scheinen automatisch in den Gesamtflow übernommen zu werden. Diese werden dann nach dem Speichern aktiv. Da muss man sehr vorsichtig sein, bei kurzen Änderungen und dem Ausprobieren.
|
||||
- Der Testlauf "Execute" sollte auf dem aktuellen Workflowstand ausgeführt werden, auch wenn dieser vom gespeicherten Abweicht. Ich würde natürlich vor dem Speichern den Workflow testen können. Prüfe und bewerte diesen Punkt, setze ihn aber noch nicht um.
|
||||
- Die Workflows werden aktuell nicht in Analyse und den verfügbaren KI-Asuwertungen angezeigt. ggf. weil wir sie aktuell noch keinem Bereich zuordnen können. Diesen könnten wir ggf. über die Start-Node im Workflow konfigurieren.
|
||||
- Das löschen von Knoten und Kanten funktioniert aktuell nur über Backspace nicht über entfernen
|
||||
- Wir sollten auch dafür sorgen, dass jeweils nur eine Start-Node, End-Node in einem Workflow existiert, Prüfe ob mehrere End-Nodes sinnvoll sind, da wir ja auch Logik-Pfade abbilden und ggf. auch eine route beschreiten, die ein anderes Ende hat. (Prüfe, ob das heute schon möglich wäre!)
|
||||
- Als zukünftige Ausbaustufe sollten wir überlegen, ob wir auch Trigger implementieren, z.B. um Kurzstatements zu generieren, wenn neue Daten hereinkommen und wir diese Bewertungen aktualisieren wollen
|
||||
|
|
@ -31,6 +31,42 @@ OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
|
|||
|
||||
router = APIRouter(prefix="/api/prompts", tags=["prompts"])
|
||||
|
||||
# Metadaten-Schlüssel in workflow aggregate_results (nicht als „einziger“ Nutzer-Output)
|
||||
_WORKFLOW_AGG_META_KEYS = frozenset({
|
||||
"combined_analysis",
|
||||
"all_signals",
|
||||
"total_nodes",
|
||||
"executed_nodes",
|
||||
"failed_nodes",
|
||||
"skipped_nodes",
|
||||
})
|
||||
|
||||
|
||||
def _workflow_user_facing_content(agg: object) -> str:
|
||||
"""
|
||||
Nutzer-sichtbarer Text wie im Admin WorkflowResultViewer („Final Output“):
|
||||
primär aggregated_result['analysis_core'], nicht das gesamte JSON.
|
||||
"""
|
||||
if agg is None:
|
||||
return ""
|
||||
if isinstance(agg, str):
|
||||
return agg
|
||||
if not isinstance(agg, dict):
|
||||
return json.dumps(agg, ensure_ascii=False)
|
||||
core = agg.get("analysis_core")
|
||||
if isinstance(core, str) and core.strip():
|
||||
return core
|
||||
combined = agg.get("combined_analysis")
|
||||
if isinstance(combined, str) and combined.strip():
|
||||
return combined
|
||||
non_meta = [k for k in agg.keys() if k not in _WORKFLOW_AGG_META_KEYS]
|
||||
if len(non_meta) == 1:
|
||||
v = agg[non_meta[0]]
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
return json.dumps(v, ensure_ascii=False)
|
||||
return json.dumps(agg, ensure_ascii=False)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_prompts(session: dict=Depends(require_auth)):
|
||||
|
|
@ -1253,6 +1289,8 @@ async def execute_unified_prompt(
|
|||
content = list(final_output.values())[0]
|
||||
else:
|
||||
content = json.dumps(final_output, ensure_ascii=False)
|
||||
elif result['type'] == 'workflow':
|
||||
content = _workflow_user_facing_content(result.get('aggregated_result'))
|
||||
else:
|
||||
# For base prompts, use output directly
|
||||
content = result.get('output', '')
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
--header-h: 52px;
|
||||
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--capture-content-max: 800px;
|
||||
/* Admin: nutzt volle Hauptspalte bis zu dieser Obergrenze (siehe .app-main:has(.admin-shell)) */
|
||||
--admin-main-max: min(1560px, calc(100vw - 220px));
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
|
|
@ -619,13 +621,16 @@ a.analysis-split__nav-item {
|
|||
|
||||
.admin-page {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Desktop: volle Breite der Admin-Spalte (nicht wie Erfassung 800px); Lesegröße leicht skaliert */
|
||||
@media (min-width: 1024px) {
|
||||
.admin-page {
|
||||
max-width: var(--capture-content-max);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
font-size: clamp(15px, 0.88rem + 0.25vw, 18px);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -841,6 +846,11 @@ a.analysis-split__nav-item {
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Admin: mehr horizontaler Raum für Tabellen auf großen Screens (:has ~2022+, sonst bleibt 1200px) */
|
||||
.app-main:has(.admin-shell) {
|
||||
max-width: var(--admin-main-max);
|
||||
}
|
||||
|
||||
/* Dashboard (P3): Begrüßung + Kennzahlen-Zeile */
|
||||
.dashboard-greeting {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { Handle, Position } from 'reactflow'
|
|||
/**
|
||||
* StartNode - Workflow Einstiegspunkt
|
||||
*
|
||||
* Properties:
|
||||
* - data.label: Node-Label (default: "Start")
|
||||
* - selected: Boolean (Node ist ausgewählt)
|
||||
* - data.label: Anzeige auf dem Canvas (Node-Name)
|
||||
* - data.analysis_title: nur für KI-Analyse-UI, nicht auf dem Canvas
|
||||
*/
|
||||
export function StartNode({ data, selected }) {
|
||||
return (
|
||||
|
|
|
|||
21
frontend/src/config/analysisCategories.js
Normal file
21
frontend/src/config/analysisCategories.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** DB `ai_prompts.category` – Reihenfolge der Gruppen in der KI-Analyse-Navigation */
|
||||
|
||||
export const ANALYSIS_CATEGORY_ORDER = [
|
||||
'körper',
|
||||
'ernährung',
|
||||
'training',
|
||||
'schlaf',
|
||||
'vitalwerte',
|
||||
'ziele',
|
||||
'ganzheitlich',
|
||||
]
|
||||
|
||||
export const ANALYSIS_CATEGORY_LABELS = {
|
||||
körper: 'Körper',
|
||||
ernährung: 'Ernährung',
|
||||
training: 'Training',
|
||||
schlaf: 'Schlaf',
|
||||
vitalwerte: 'Vitalwerte',
|
||||
ziele: 'Ziele',
|
||||
ganzheitlich: 'Ganzheitlich',
|
||||
}
|
||||
|
|
@ -2,6 +2,11 @@ import React, { useState, useEffect, useMemo } from 'react'
|
|||
import { Brain, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../utils/api'
|
||||
import { getWorkflowDisplayContent } from '../utils/workflowDisplay'
|
||||
import {
|
||||
ANALYSIS_CATEGORY_ORDER,
|
||||
ANALYSIS_CATEGORY_LABELS,
|
||||
} from '../config/analysisCategories'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Markdown from '../utils/Markdown'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
|
|
@ -14,19 +19,6 @@ const SLUG_LABELS = {
|
|||
pipeline: '🔬 Mehrstufige Gesamtanalyse'
|
||||
}
|
||||
|
||||
/** DB `ai_prompts.category` – Reihenfolge der Gruppen in der Analyse-Navigation */
|
||||
const ANALYSIS_CATEGORY_ORDER = ['körper', 'ernährung', 'training', 'schlaf', 'vitalwerte', 'ziele', 'ganzheitlich']
|
||||
|
||||
const ANALYSIS_CATEGORY_LABELS = {
|
||||
körper: 'Körper',
|
||||
ernährung: 'Ernährung',
|
||||
training: 'Training',
|
||||
schlaf: 'Schlaf',
|
||||
vitalwerte: 'Vitalwerte',
|
||||
ziele: 'Ziele',
|
||||
ganzheitlich: 'Ganzheitlich',
|
||||
}
|
||||
|
||||
function sortAnalysisCategoryKeys(keys) {
|
||||
return [...keys].sort((a, b) => {
|
||||
const na = String(a).toLowerCase()
|
||||
|
|
@ -45,6 +37,11 @@ function analysisCategoryLabel(key) {
|
|||
return ANALYSIS_CATEGORY_LABELS[k] || String(key)
|
||||
}
|
||||
|
||||
/** Analyse-Angebote: klassische Pipelines + graphbasierte KI-Workflows (`type === 'workflow'`) */
|
||||
function isAnalysisOfferPrompt(p) {
|
||||
return p.active && (p.type === 'pipeline' || p.type === 'workflow')
|
||||
}
|
||||
|
||||
/** Pipeline-Prompts nach `category` gruppieren (Backend-Feld), innerhalb Gruppe nach sort_order */
|
||||
function buildPipelineGroups(pipelinePrompts) {
|
||||
const m = new Map()
|
||||
|
|
@ -81,7 +78,8 @@ function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
|
|||
|
||||
// Find matching prompt to get display_name
|
||||
const prompt = prompts.find(p => p.slug === ins.scope)
|
||||
const displayName = prompt?.display_name || SLUG_LABELS[ins.scope] || ins.scope
|
||||
const displayName =
|
||||
prompt?.display_name || prompt?.name || SLUG_LABELS[ins.scope] || ins.scope
|
||||
|
||||
// Use already-parsed metadata
|
||||
const metadata = metadataRaw
|
||||
|
|
@ -360,7 +358,7 @@ export default function Analysis() {
|
|||
},[])
|
||||
|
||||
useEffect(() => {
|
||||
const list = prompts.filter(p => p.active && p.type === 'pipeline')
|
||||
const list = prompts.filter(isAnalysisOfferPrompt)
|
||||
setActiveCategoryKey(prev => {
|
||||
if (!list.length) return null
|
||||
const groups = buildPipelineGroups(list)
|
||||
|
|
@ -372,7 +370,7 @@ export default function Analysis() {
|
|||
|
||||
useEffect(() => {
|
||||
if (!newResult?.scope) return
|
||||
const list = prompts.filter(p => p.active && p.type === 'pipeline')
|
||||
const list = prompts.filter(isAnalysisOfferPrompt)
|
||||
const groups = buildPipelineGroups(list)
|
||||
const g = groups.find(gg => gg.prompts.some(p => p.slug === newResult.scope))
|
||||
if (g) setActiveCategoryKey(g.categoryKey)
|
||||
|
|
@ -394,6 +392,8 @@ export default function Analysis() {
|
|||
} else {
|
||||
content = JSON.stringify(finalOutput, null, 2)
|
||||
}
|
||||
} else if (result.type === 'workflow') {
|
||||
content = getWorkflowDisplayContent(result.aggregated_result)
|
||||
} else {
|
||||
// For base prompts, use output directly
|
||||
content = typeof result.output === 'string' ? result.output : JSON.stringify(result.output, null, 2)
|
||||
|
|
@ -405,7 +405,7 @@ export default function Analysis() {
|
|||
const placeholders = {}
|
||||
const resolved = result.debug.resolved_placeholders
|
||||
|
||||
// For pipeline, collect from all stages
|
||||
// For pipeline, collect from all stages (Workflow: kein gleiches debug-Schema)
|
||||
if (result.type === 'pipeline' && result.debug.stages) {
|
||||
for (const stage of result.debug.stages) {
|
||||
for (const promptDebug of (stage.prompts || [])) {
|
||||
|
|
@ -456,9 +456,9 @@ export default function Analysis() {
|
|||
grouped[key].push(ins)
|
||||
})
|
||||
|
||||
// Show only active pipeline-type prompts (und nach DB-Kategorie gruppiert)
|
||||
// Aktive Pipeline- + Workflow-Prompts (nach DB-Kategorie gruppiert)
|
||||
const { pipelinePrompts, pipelineGroups } = useMemo(() => {
|
||||
const list = prompts.filter(p => p.active && p.type === 'pipeline')
|
||||
const list = prompts.filter(isAnalysisOfferPrompt)
|
||||
return { pipelinePrompts: list, pipelineGroups: buildPipelineGroups(list) }
|
||||
}, [prompts])
|
||||
|
||||
|
|
@ -538,9 +538,10 @@ export default function Analysis() {
|
|||
{canUseAI && pipelinePrompts.length > 0 && (
|
||||
<>
|
||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
|
||||
Zuerst die <strong>Kategorie</strong> wählen (Chip-Leiste bzw. Seitenleiste). Alle Pipeline-Analysen
|
||||
dieser Kategorie erscheinen im Detailbereich (rechts auf Desktop, darunter auf Mobil).
|
||||
Kategorien kommen aus dem Feld „Kategorie“ beim jeweiligen Prompt im Admin.
|
||||
Zuerst die <strong>Kategorie</strong> wählen (Chip-Leiste bzw. Seitenleiste). Alle{' '}
|
||||
<strong>Pipeline- und Workflow-Auswertungen</strong> dieser Kategorie erscheinen im Detailbereich
|
||||
(rechts auf Desktop, darunter auf Mobil). Bei Workflows legst du Kategorie, Titel und Kurztext in der{' '}
|
||||
<strong>Start-Node</strong> des Workflow-Editors fest; bei Pipelines im Admin unter KI-Prompts.
|
||||
</p>
|
||||
<div className="analysis-split">
|
||||
<div className="analysis-split__nav-wrap">
|
||||
|
|
@ -641,9 +642,9 @@ export default function Analysis() {
|
|||
|
||||
{canUseAI && pipelinePrompts.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>Keine aktiven Pipeline-Prompts verfügbar.</p>
|
||||
<p>Keine aktiven Pipeline- oder Workflow-Auswertungen verfügbar.</p>
|
||||
<p style={{fontSize:12,color:'var(--text3)',marginTop:8}}>
|
||||
Erstelle Pipeline-Prompts im Admin-Bereich unter Admin → KI-Prompts.
|
||||
Pipelines und Workflows werden im Admin unter KI-Prompts bzw. Workflow-Editor angelegt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -667,7 +668,10 @@ export default function Analysis() {
|
|||
onClick={() => setHistoryScopePick(scope)}
|
||||
aria-current={activeHistoryScope === scope ? 'page' : undefined}
|
||||
>
|
||||
{prompts.find(pr => pr.slug === scope)?.display_name || SLUG_LABELS[scope] || scope}
|
||||
{(() => {
|
||||
const pr = prompts.find((p) => p.slug === scope)
|
||||
return pr?.display_name || pr?.name || SLUG_LABELS[scope] || scope
|
||||
})()}
|
||||
<span className="muted" style={{ fontSize: 12 }}> ({grouped[scope].length})</span>
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,28 @@ import { InlineTemplateEditor } from '../components/workflow/panels/InlineTempla
|
|||
import { Toast } from '../components/Toast'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import '../styles/workflowEditor.css'
|
||||
import {
|
||||
ANALYSIS_CATEGORY_ORDER,
|
||||
ANALYSIS_CATEGORY_LABELS,
|
||||
} from '../config/analysisCategories'
|
||||
|
||||
/** Aus Start-Node → Felder für Analyse-UI / ai_prompts (display_name, description, category) */
|
||||
function getWorkflowAnalysisPublishFields(nodes, fallbackName) {
|
||||
const start = nodes.find((n) => n.type === 'start')
|
||||
const d = start?.data || {}
|
||||
const title =
|
||||
String(d.analysis_title ?? '').trim() ||
|
||||
String(fallbackName ?? '').trim() ||
|
||||
'Workflow'
|
||||
const description = String(d.analysis_description ?? '').trim()
|
||||
const category =
|
||||
String(d.analysis_category ?? 'ganzheitlich').toLowerCase().trim() || 'ganzheitlich'
|
||||
return {
|
||||
display_name: title,
|
||||
description: description || null,
|
||||
category,
|
||||
}
|
||||
}
|
||||
|
||||
// Node-Type Mapping
|
||||
const nodeTypes = {
|
||||
|
|
@ -45,7 +67,6 @@ export default function WorkflowEditorPage() {
|
|||
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) : null
|
||||
const [currentPrompt, setCurrentPrompt] = useState(null)
|
||||
const [workflowName, setWorkflowName] = useState('Neuer Workflow')
|
||||
const [workflowDescription, setWorkflowDescription] = useState('')
|
||||
const [validationErrors, setValidationErrors] = useState([])
|
||||
const [validationWarnings, setValidationWarnings] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
|
@ -103,16 +124,26 @@ export default function WorkflowEditorPage() {
|
|||
}, [])
|
||||
|
||||
const handleAddNode = (nodeType) => {
|
||||
const newNode = {
|
||||
const base = {
|
||||
id: `node_${nodeIdCounter++}`,
|
||||
type: nodeType,
|
||||
position: { x: 250, y: 100 + nodes.length * 100 },
|
||||
data: {
|
||||
label: `${nodeType.charAt(0).toUpperCase() + nodeType.slice(1)} ${nodeIdCounter - 1}`
|
||||
label: `${nodeType.charAt(0).toUpperCase() + nodeType.slice(1)} ${nodeIdCounter - 1}`,
|
||||
},
|
||||
}
|
||||
if (nodeType === 'start') {
|
||||
base.data = {
|
||||
label: 'Start',
|
||||
analysis_title:
|
||||
workflowName && workflowName !== 'Neuer Workflow'
|
||||
? workflowName
|
||||
: '',
|
||||
analysis_description: '',
|
||||
analysis_category: 'ganzheitlich',
|
||||
}
|
||||
}
|
||||
|
||||
setNodes((nds) => [...nds, newNode])
|
||||
setNodes((nds) => [...nds, base])
|
||||
}
|
||||
|
||||
const handleNodeUpdate = (nodeId, updates) => {
|
||||
|
|
@ -152,13 +183,20 @@ export default function WorkflowEditorPage() {
|
|||
})
|
||||
console.log('📊 Serialized graph_data:', graph_data)
|
||||
|
||||
const { display_name, description, category } = getWorkflowAnalysisPublishFields(
|
||||
nodes,
|
||||
workflowName
|
||||
)
|
||||
|
||||
if (currentPrompt) {
|
||||
// Update existing
|
||||
console.log('📝 Updating existing workflow:', currentPrompt.id)
|
||||
await api.updateUnifiedPrompt(currentPrompt.id, {
|
||||
type: 'workflow',
|
||||
name: workflowName,
|
||||
description: workflowDescription,
|
||||
display_name,
|
||||
description: description ?? undefined,
|
||||
category,
|
||||
graph_data
|
||||
})
|
||||
setToast({ message: '✅ Workflow gespeichert!', type: 'success' })
|
||||
|
|
@ -168,7 +206,9 @@ export default function WorkflowEditorPage() {
|
|||
const result = await api.createUnifiedPrompt({
|
||||
type: 'workflow',
|
||||
name: workflowName,
|
||||
description: workflowDescription,
|
||||
display_name,
|
||||
description: description ?? undefined,
|
||||
category,
|
||||
graph_data
|
||||
})
|
||||
console.log('✅ Workflow created:', result)
|
||||
|
|
@ -203,11 +243,30 @@ export default function WorkflowEditorPage() {
|
|||
const { nodes: loadedNodes, edges: loadedEdges } = deserializeFromWorkflowGraph(prompt.graph_data)
|
||||
console.log('🎯 Deserialized:', { nodes: loadedNodes, edges: loadedEdges })
|
||||
|
||||
setNodes(loadedNodes)
|
||||
const mergedNodes = loadedNodes.map((n) => {
|
||||
if (n.type !== 'start') return n
|
||||
const hasStoredTitle = Object.prototype.hasOwnProperty.call(n.data || {}, 'analysis_title')
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
label: n.data?.label || 'Start',
|
||||
...n.data,
|
||||
analysis_title: hasStoredTitle
|
||||
? n.data.analysis_title
|
||||
: (prompt.display_name || prompt.name || ''),
|
||||
analysis_description: Object.prototype.hasOwnProperty.call(n.data || {}, 'analysis_description')
|
||||
? n.data.analysis_description
|
||||
: (prompt.description || ''),
|
||||
analysis_category:
|
||||
(n.data?.analysis_category || prompt.category || 'ganzheitlich').toLowerCase(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
setNodes(mergedNodes)
|
||||
setEdges(loadedEdges)
|
||||
setCurrentPrompt(prompt)
|
||||
setWorkflowName(prompt.name)
|
||||
setWorkflowDescription(prompt.description || '')
|
||||
|
||||
// nodeIdCounter aktualisieren
|
||||
const maxId = Math.max(
|
||||
|
|
@ -242,7 +301,6 @@ export default function WorkflowEditorPage() {
|
|||
setEdges([])
|
||||
setCurrentPrompt(null)
|
||||
setWorkflowName('Neuer Workflow')
|
||||
setWorkflowDescription('')
|
||||
setSelectedNodeId(null)
|
||||
navigate('/workflow-editor/new')
|
||||
}
|
||||
|
|
@ -342,7 +400,8 @@ export default function WorkflowEditorPage() {
|
|||
type="text"
|
||||
value={workflowName}
|
||||
onChange={(e) => setWorkflowName(e.target.value)}
|
||||
placeholder="Workflow-Name"
|
||||
placeholder="Interner Workflow-Name (Slug-Basis)"
|
||||
title="Technischer Name in der Datenbank. Den sichtbaren Titel für die KI-Analyse setzt du in der Start-Node."
|
||||
style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }}
|
||||
/>
|
||||
|
||||
|
|
@ -495,9 +554,88 @@ export default function WorkflowEditorPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Start-Node: Metadaten für KI-Analyse (wie Pipeline: display_name, description, category) */}
|
||||
{selectedNode.type === 'start' && (
|
||||
<div className="config-section" style={{ marginBottom: 20 }}>
|
||||
<h3 style={{ margin: '0 0 10px', fontSize: 15 }}>Anzeige in KI-Analyse</h3>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.5, marginBottom: 12 }}>
|
||||
Titel, Kurzbeschreibung und Kategorie erscheinen auf der Analyse-Seite, im Verlauf und in der
|
||||
Kategorie-Navigation – analog zu Pipeline-Prompts im Admin.
|
||||
</p>
|
||||
<label style={{ display: 'block', marginBottom: 6, fontWeight: 600, fontSize: 13 }}>
|
||||
Titel (sichtbar für Nutzer)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedNode.data.analysis_title ?? ''}
|
||||
onChange={(e) =>
|
||||
handleNodeUpdate(selectedNode.id, { analysis_title: e.target.value })
|
||||
}
|
||||
placeholder="z. B. Ganzheitliche Auswertung"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text1)',
|
||||
fontSize: '14px',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
<label style={{ display: 'block', marginBottom: 6, fontWeight: 600, fontSize: 13 }}>
|
||||
Kurzbeschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={selectedNode.data.analysis_description ?? ''}
|
||||
onChange={(e) =>
|
||||
handleNodeUpdate(selectedNode.id, { analysis_description: e.target.value })
|
||||
}
|
||||
placeholder="Ein Satz, worum es bei dieser Auswertung geht …"
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text1)',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.5,
|
||||
marginBottom: 12,
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
<label style={{ display: 'block', marginBottom: 6, fontWeight: 600, fontSize: 13 }}>
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
value={selectedNode.data.analysis_category || 'ganzheitlich'}
|
||||
onChange={(e) =>
|
||||
handleNodeUpdate(selectedNode.id, { analysis_category: e.target.value })
|
||||
}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text1)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{ANALYSIS_CATEGORY_ORDER.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{ANALYSIS_CATEGORY_LABELS[key] || key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basis-Konfiguration */}
|
||||
<div className="config-section">
|
||||
<label>Node-Name</label>
|
||||
<label>Node-Name {selectedNode.type === 'start' && '(auf dem Canvas)'}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedNode.data.label || ''}
|
||||
|
|
|
|||
37
frontend/src/utils/workflowDisplay.js
Normal file
37
frontend/src/utils/workflowDisplay.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Nutzer-sichtbare Textausgabe eines Workflow-Laufs – gleiche Logik wie
|
||||
* WorkflowResultViewer („Final Output“): primär aggregated_result.analysis_core.
|
||||
*/
|
||||
const AGG_META_KEYS = new Set([
|
||||
'combined_analysis',
|
||||
'all_signals',
|
||||
'total_nodes',
|
||||
'executed_nodes',
|
||||
'failed_nodes',
|
||||
'skipped_nodes',
|
||||
])
|
||||
|
||||
export function getWorkflowDisplayContent(aggregatedResult) {
|
||||
if (aggregatedResult == null) return ''
|
||||
if (typeof aggregatedResult !== 'object' || Array.isArray(aggregatedResult)) {
|
||||
return typeof aggregatedResult === 'string'
|
||||
? aggregatedResult
|
||||
: JSON.stringify(aggregatedResult, null, 2)
|
||||
}
|
||||
|
||||
const core = aggregatedResult.analysis_core
|
||||
if (typeof core === 'string' && core.trim() !== '') return core
|
||||
|
||||
const combined = aggregatedResult.combined_analysis
|
||||
if (typeof combined === 'string' && combined.trim() !== '') return combined
|
||||
|
||||
const keys = Object.keys(aggregatedResult).filter((k) => !AGG_META_KEYS.has(k))
|
||||
if (keys.length === 1) {
|
||||
const v = aggregatedResult[keys[0]]
|
||||
if (typeof v === 'string') return v
|
||||
if (v != null && typeof v === 'object') return JSON.stringify(v, null, 2)
|
||||
return String(v)
|
||||
}
|
||||
|
||||
return JSON.stringify(aggregatedResult, null, 2)
|
||||
}
|
||||
|
|
@ -42,6 +42,12 @@ export function serializeToWorkflowGraph(nodes, edges, metadata = {}) {
|
|||
...(node.type === 'end' && {
|
||||
output_mode: node.data.output_mode || 'auto',
|
||||
template: node.data.template || null
|
||||
}),
|
||||
|
||||
...(node.type === 'start' && {
|
||||
analysis_title: node.data.analysis_title || '',
|
||||
analysis_description: node.data.analysis_description || '',
|
||||
analysis_category: node.data.analysis_category || 'ganzheitlich',
|
||||
})
|
||||
}))
|
||||
|
||||
|
|
@ -105,6 +111,12 @@ export function deserializeFromWorkflowGraph(jsonbData) {
|
|||
...(node.type === 'end' && {
|
||||
output_mode: node.output_mode || 'auto',
|
||||
template: node.template || null
|
||||
}),
|
||||
|
||||
...(node.type === 'start' && {
|
||||
analysis_title: node.analysis_title || '',
|
||||
analysis_description: node.analysis_description || '',
|
||||
analysis_category: node.analysis_category || 'ganzheitlich',
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
|
|
|||
85
test_placeholder_resolution.py
Normal file
85
test_placeholder_resolution.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test placeholder resolution in inline templates
|
||||
|
||||
This script simulates what happens in workflow_executor.load_prompt_template()
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, 'backend')
|
||||
|
||||
from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog
|
||||
from prompt_executor import resolve_placeholders
|
||||
|
||||
# Test profile_id (use first profile in dev DB)
|
||||
PROFILE_ID = "019601b5-d65a-738f-a1e7-b3f69bb97f69" # Lars profile from dev
|
||||
|
||||
def test_placeholder_resolution():
|
||||
"""Test the exact same logic as in workflow_executor.load_prompt_template()"""
|
||||
|
||||
# Test template with spaces in placeholders (as user showed)
|
||||
template = "Hallo {{ name }}, du bist {{ age }} Jahre alt und {{ geschlecht }}."
|
||||
|
||||
print("=" * 80)
|
||||
print("PLACEHOLDER RESOLUTION TEST")
|
||||
print("=" * 80)
|
||||
print(f"\nTemplate:\n{template}\n")
|
||||
|
||||
# Step 1: Load placeholders (same as workflow_executor)
|
||||
print("Step 1: Loading placeholders...")
|
||||
processed_placeholders = get_placeholder_example_values(PROFILE_ID)
|
||||
print(f" Loaded {len(processed_placeholders)} placeholders")
|
||||
print(f" Sample keys (first 5): {list(processed_placeholders.keys())[:5]}")
|
||||
|
||||
# Step 2: Clean keys (same as workflow_executor)
|
||||
print("\nStep 2: Cleaning keys...")
|
||||
cleaned_placeholders = {
|
||||
key.replace('{{', '').replace('}}', '').strip(): value
|
||||
for key, value in processed_placeholders.items()
|
||||
}
|
||||
print(f" Cleaned keys (first 5): {list(cleaned_placeholders.keys())[:5]}")
|
||||
print(f" Sample values:")
|
||||
print(f" name = {cleaned_placeholders.get('name')}")
|
||||
print(f" age = {cleaned_placeholders.get('age')}")
|
||||
print(f" geschlecht = {cleaned_placeholders.get('geschlecht')}")
|
||||
|
||||
variables = cleaned_placeholders
|
||||
|
||||
# Step 3: Load catalog
|
||||
print("\nStep 3: Loading catalog...")
|
||||
try:
|
||||
catalog = get_placeholder_catalog(PROFILE_ID)
|
||||
print(f" Catalog loaded with {len(catalog)} categories")
|
||||
except Exception as e:
|
||||
catalog = None
|
||||
print(f" Catalog failed: {e}")
|
||||
|
||||
# Step 4: Resolve placeholders
|
||||
print("\nStep 4: Resolving placeholders...")
|
||||
debug_info = {}
|
||||
resolved = resolve_placeholders(
|
||||
template=template,
|
||||
variables=variables,
|
||||
debug_info=debug_info,
|
||||
catalog=catalog
|
||||
)
|
||||
|
||||
print(f" Resolved placeholders: {debug_info.get('resolved_placeholders', {})}")
|
||||
print(f" Unresolved placeholders: {debug_info.get('unresolved_placeholders', [])}")
|
||||
|
||||
# Result
|
||||
print("\n" + "=" * 80)
|
||||
print("RESULT")
|
||||
print("=" * 80)
|
||||
print(f"\nResolved template:\n{resolved}\n")
|
||||
|
||||
# Check if placeholders were resolved
|
||||
if '{{' in resolved:
|
||||
print("❌ FAILED: Some placeholders were not resolved!")
|
||||
return False
|
||||
else:
|
||||
print("✅ SUCCESS: All placeholders resolved!")
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_placeholder_resolution()
|
||||
sys.exit(0 if success else 1)
|
||||
Loading…
Reference in New Issue
Block a user