FEATURE: Basis-Analysen Einzelwerte
Vorher: stage_1_body → {"bmi": 26.6, "weight": "85.2kg"} (1 Zeile)
Jetzt: bmi → 26.6 (eigene Zeile)
weight → 85.2kg (eigene Zeile)
BACKEND: JSON-Extraktion
- Stage outputs (JSON) → extract individual fields
- extracted_values dict sammelt alle Einzelwerte
- Deduplizierung: Gleiche Keys nur einmal
- Flags:
- is_extracted: true → Wert aus Stage-Output extrahiert
- is_stage_raw: true → Rohdaten (JSON) nur Experten-Modus
BEISPIEL Stage 1 Output:
{
"stage_1_body": {
"bmi": 26.6,
"weight": "85.2 kg",
"trend": "sinkend"
}
}
→ Metadata:
{
"bmi": {
value: "26.6",
description: "Aus Stage 1 (stage_1_body)",
is_extracted: true
},
"weight": {
value: "85.2 kg",
description: "Aus Stage 1 (stage_1_body)",
is_extracted: true
},
"stage_1_body": {
value: "{\"bmi\": 26.6, ...}",
description: "Rohdaten Stage 1 (Basis-Analyse JSON)",
is_stage_raw: true
}
}
FRONTEND: Smart Filtering
Normal-Modus:
- Zeigt: Einzelwerte (bmi, weight, trend)
- Versteckt: Rohdaten (stage_1_body JSON)
- Filter: is_stage_raw === false
Experten-Modus:
- Zeigt: Alles (Einzelwerte + Rohdaten)
- Rohdaten: Grauer Hintergrund + 🔬 Icon
VISUAL Indicators:
↳ bmi → Extrahierter Wert (grün)
weight → Normaler Platzhalter (accent)
🔬 stage_1_* → Rohdaten JSON (grau, klein, nur Experten)
ERGEBNIS:
┌──────────────────────────────────────────┐
│ 📊 Verwendete Werte (8) (+2 ausgeblendet)│
│ ┌────────────────────────────────────────┐│
│ │ weight_aktuell │ 85.2 kg │ Gewicht ││ ← Normal
│ │ ↳ bmi │ 26.6 │ Aus St..││ ← Extrahiert
│ │ ↳ trend │ sinkend │ Aus St..││ ← Extrahiert
│ └────────────────────────────────────────┘│
└──────────────────────────────────────────┘
Experten-Modus zusätzlich:
│ 🔬 stage_1_body │ {"bmi":...│ Rohdaten││ ← JSON
version: 9.9.0 (feature)
module: prompts 2.4.0, insights 1.7.0
462 lines
19 KiB
JavaScript
462 lines
19 KiB
JavaScript
import { useState, useEffect } from 'react'
|
||
import { Brain, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
||
import { api } from '../utils/api'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import Markdown from '../utils/Markdown'
|
||
import UsageBadge from '../components/UsageBadge'
|
||
import dayjs from 'dayjs'
|
||
import 'dayjs/locale/de'
|
||
dayjs.locale('de')
|
||
|
||
// Legacy fallback labels (display_name takes precedence)
|
||
const SLUG_LABELS = {
|
||
pipeline: '🔬 Mehrstufige Gesamtanalyse'
|
||
}
|
||
|
||
function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
|
||
const [open, setOpen] = useState(defaultOpen)
|
||
|
||
// Parse metadata early to determine showOnlyValues
|
||
const metadataRaw = ins.metadata ? (typeof ins.metadata === 'string' ? JSON.parse(ins.metadata) : ins.metadata) : null
|
||
const isBasePrompt = metadataRaw?.prompt_type === 'base'
|
||
const isJsonOutput = ins.content && (ins.content.trim().startsWith('{') || ins.content.trim().startsWith('['))
|
||
const placeholdersRaw = metadataRaw?.placeholders || {}
|
||
const showOnlyValues = isBasePrompt && isJsonOutput && Object.keys(placeholdersRaw).length > 0
|
||
|
||
const [showValues, setShowValues] = useState(showOnlyValues) // Auto-expand for base prompts with JSON
|
||
const [expertMode, setExpertMode] = useState(false) // Show empty/technical placeholders
|
||
|
||
// 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
|
||
|
||
// Use already-parsed metadata
|
||
const metadata = metadataRaw
|
||
const allPlaceholders = placeholdersRaw
|
||
|
||
// Filter placeholders: In normal mode, hide empty values and raw stage outputs
|
||
const placeholders = expertMode
|
||
? allPlaceholders
|
||
: Object.fromEntries(
|
||
Object.entries(allPlaceholders).filter(([key, data]) => {
|
||
// Hide raw stage outputs (JSON) in normal mode
|
||
if (data.is_stage_raw) return false
|
||
|
||
// Hide empty values
|
||
const val = data.value || ''
|
||
return val.trim() !== '' && val !== 'nicht verfügbar' && val !== '[Nicht verfügbar]'
|
||
})
|
||
)
|
||
|
||
const placeholderCount = Object.keys(placeholders).length
|
||
const hiddenCount = Object.keys(allPlaceholders).length - placeholderCount
|
||
|
||
return (
|
||
<div className="card section-gap" style={{borderLeft:`3px solid var(--accent)`}}>
|
||
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:open?12:0,cursor:'pointer'}}
|
||
onClick={()=>setOpen(o=>!o)}>
|
||
<div style={{flex:1}}>
|
||
<div style={{fontSize:13,fontWeight:600}}>
|
||
{displayName}
|
||
</div>
|
||
<div style={{fontSize:11,color:'var(--text3)'}}>
|
||
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
|
||
</div>
|
||
</div>
|
||
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)',padding:4}}
|
||
onClick={e=>{e.stopPropagation();onDelete(ins.id)}}>
|
||
<Trash2 size={13}/>
|
||
</button>
|
||
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
|
||
</div>
|
||
{open && (
|
||
<>
|
||
{/* For base prompts with JSON: Only show value table */}
|
||
{showOnlyValues && (
|
||
<div style={{ padding: '12px 16px', background: 'var(--surface)', borderRadius: 8, marginBottom: 12 }}>
|
||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
|
||
ℹ️ Basis-Prompt Rohdaten (JSON-Struktur für technische Nutzung)
|
||
</div>
|
||
<details style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||
<summary style={{ cursor: 'pointer' }}>Technische Daten anzeigen</summary>
|
||
<pre style={{
|
||
marginTop: 8,
|
||
padding: 8,
|
||
background: 'var(--bg)',
|
||
borderRadius: 4,
|
||
overflow: 'auto',
|
||
fontSize: 10,
|
||
fontFamily: 'monospace'
|
||
}}>
|
||
{ins.content}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
)}
|
||
|
||
{/* For other prompts: Show full content */}
|
||
{!showOnlyValues && <Markdown text={ins.content}/>}
|
||
|
||
{/* Value Table */}
|
||
{placeholderCount > 0 && (
|
||
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<div
|
||
onClick={() => setShowValues(!showValues)}
|
||
style={{
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
color: 'var(--text2)',
|
||
fontWeight: 600,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6
|
||
}}
|
||
>
|
||
{showValues ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||
📊 Verwendete Werte ({placeholderCount})
|
||
{hiddenCount > 0 && !expertMode && (
|
||
<span style={{ fontSize: 10, color: 'var(--text3)', fontWeight: 400 }}>
|
||
(+{hiddenCount} ausgeblendet)
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{showValues && Object.keys(allPlaceholders).length > 0 && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setExpertMode(!expertMode)
|
||
}}
|
||
className="btn"
|
||
style={{
|
||
fontSize: 10,
|
||
padding: '4px 8px',
|
||
background: expertMode ? 'var(--accent)' : 'var(--surface)',
|
||
color: expertMode ? 'white' : 'var(--text2)'
|
||
}}
|
||
>
|
||
🔬 Experten-Modus
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{showValues && (
|
||
<div style={{ marginTop: 12, fontSize: 11 }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
|
||
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Platzhalter</th>
|
||
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Wert</th>
|
||
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Beschreibung</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{Object.entries(placeholders).map(([key, data]) => {
|
||
const isExtracted = data.is_extracted
|
||
const isStageRaw = data.is_stage_raw
|
||
|
||
return (
|
||
<tr key={key} style={{
|
||
borderBottom: '1px solid var(--border)',
|
||
background: isStageRaw && expertMode ? 'var(--surface)' : 'transparent'
|
||
}}>
|
||
<td style={{
|
||
padding: '6px 8px',
|
||
fontFamily: 'monospace',
|
||
color: isStageRaw ? 'var(--text3)' : (isExtracted ? '#6B8E23' : 'var(--accent)'),
|
||
whiteSpace: 'nowrap',
|
||
verticalAlign: 'top',
|
||
fontSize: isStageRaw ? 10 : 11
|
||
}}>
|
||
{isExtracted && '↳ '}
|
||
{isStageRaw && '🔬 '}
|
||
{key}
|
||
</td>
|
||
<td style={{
|
||
padding: '6px 8px',
|
||
fontFamily: 'monospace',
|
||
wordBreak: 'break-word',
|
||
maxWidth: '400px',
|
||
verticalAlign: 'top',
|
||
fontSize: isStageRaw ? 9 : 11,
|
||
color: isStageRaw ? 'var(--text3)' : 'var(--text1)'
|
||
}}>
|
||
{data.value}
|
||
</td>
|
||
<td style={{
|
||
padding: '6px 8px',
|
||
color: 'var(--text3)',
|
||
fontSize: 10,
|
||
verticalAlign: 'top',
|
||
fontStyle: isExtracted ? 'italic' : 'normal'
|
||
}}>
|
||
{data.description || '—'}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
export default function Analysis() {
|
||
const { canUseAI } = useAuth()
|
||
const [prompts, setPrompts] = useState([])
|
||
const [allInsights, setAllInsights] = useState([])
|
||
const [loading, setLoading] = useState(null)
|
||
const [error, setError] = useState(null)
|
||
const [tab, setTab] = useState('run')
|
||
const [newResult, setNewResult] = useState(null)
|
||
const [aiUsage, setAiUsage] = useState(null)
|
||
|
||
const loadAll = async () => {
|
||
const [p, i] = await Promise.all([
|
||
api.listPrompts(),
|
||
api.listInsights()
|
||
])
|
||
setPrompts(Array.isArray(p)?p:[])
|
||
setAllInsights(Array.isArray(i)?i:[])
|
||
}
|
||
|
||
useEffect(()=>{
|
||
loadAll()
|
||
// Load feature usage for badges
|
||
api.getFeatureUsage().then(features => {
|
||
const aiFeature = features.find(f => f.feature_id === 'ai_calls')
|
||
setAiUsage(aiFeature)
|
||
}).catch(err => console.error('Failed to load usage:', err))
|
||
},[])
|
||
|
||
const runPrompt = async (slug) => {
|
||
setLoading(slug); setError(null); setNewResult(null)
|
||
try {
|
||
// Use new unified executor with save=true
|
||
const result = await api.executeUnifiedPrompt(slug, null, null, false, true)
|
||
|
||
// Transform result to match old format for InsightCard
|
||
let content = ''
|
||
if (result.type === 'pipeline') {
|
||
// For pipeline, extract final output
|
||
const finalOutput = result.output || {}
|
||
if (typeof finalOutput === 'object' && Object.keys(finalOutput).length === 1) {
|
||
content = Object.values(finalOutput)[0]
|
||
} else {
|
||
content = JSON.stringify(finalOutput, null, 2)
|
||
}
|
||
} else {
|
||
// For base prompts, use output directly
|
||
content = typeof result.output === 'string' ? result.output : JSON.stringify(result.output, null, 2)
|
||
}
|
||
|
||
// Build metadata from debug info (same logic as backend)
|
||
let metadata = null
|
||
if (result.debug && result.debug.resolved_placeholders) {
|
||
const placeholders = {}
|
||
const resolved = result.debug.resolved_placeholders
|
||
|
||
// For pipeline, collect from all stages
|
||
if (result.type === 'pipeline' && result.debug.stages) {
|
||
for (const stage of result.debug.stages) {
|
||
for (const promptDebug of (stage.prompts || [])) {
|
||
const stageResolved = promptDebug.resolved_placeholders || promptDebug.ref_debug?.resolved_placeholders || {}
|
||
for (const [key, value] of Object.entries(stageResolved)) {
|
||
if (!placeholders[key]) {
|
||
placeholders[key] = { value, description: '' }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// For base prompts
|
||
for (const [key, value] of Object.entries(resolved)) {
|
||
placeholders[key] = { value, description: '' }
|
||
}
|
||
}
|
||
|
||
if (Object.keys(placeholders).length > 0) {
|
||
metadata = { prompt_type: result.type, placeholders }
|
||
}
|
||
}
|
||
|
||
setNewResult({ scope: slug, content, metadata })
|
||
await loadAll()
|
||
setTab('run')
|
||
} catch(e) {
|
||
setError('Fehler: ' + e.message)
|
||
} finally { setLoading(null) }
|
||
}
|
||
|
||
const deleteInsight = async (id) => {
|
||
if (!confirm('Analyse löschen?')) return
|
||
try {
|
||
await api.deleteInsight(id)
|
||
if (newResult?.id === id) setNewResult(null)
|
||
await loadAll()
|
||
} catch (e) {
|
||
setError('Löschen fehlgeschlagen: ' + e.message)
|
||
}
|
||
}
|
||
|
||
// Group insights by scope for history view
|
||
const grouped = {}
|
||
allInsights.forEach(ins => {
|
||
const key = ins.scope || 'sonstige'
|
||
grouped[key] = grouped[key] || []
|
||
grouped[key].push(ins)
|
||
})
|
||
|
||
// Show only active pipeline-type prompts
|
||
const pipelinePrompts = prompts.filter(p => p.active && p.type === 'pipeline')
|
||
|
||
return (
|
||
<div>
|
||
<h1 className="page-title">KI-Analyse</h1>
|
||
|
||
<div className="tabs">
|
||
<button className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
|
||
<button className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
|
||
Verlauf
|
||
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
|
||
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div style={{padding:'10px 14px',background:'#FCEBEB',borderRadius:8,fontSize:13,
|
||
color:'#D85A30',marginBottom:12,lineHeight:1.5}}>
|
||
{error.includes('nicht aktiviert') || error.includes('Limit')
|
||
? <>🔒 <strong>KI-Zugang eingeschränkt</strong><br/>
|
||
Dein Profil hat keinen Zugang zu KI-Analysen oder das Tageslimit wurde erreicht.
|
||
Bitte den Admin kontaktieren.</>
|
||
: error}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Analysen starten ── */}
|
||
{tab==='run' && (
|
||
<div>
|
||
{/* Fresh result shown immediately */}
|
||
{newResult && (
|
||
<div style={{marginBottom:16}}>
|
||
<div style={{fontSize:12,fontWeight:600,color:'var(--accent)',marginBottom:8}}>
|
||
✅ Neue Analyse erstellt:
|
||
</div>
|
||
<InsightCard
|
||
ins={{...newResult, created: new Date().toISOString()}}
|
||
onDelete={deleteInsight}
|
||
defaultOpen={true}
|
||
prompts={prompts}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{!canUseAI && (
|
||
<div style={{padding:'14px 16px',background:'#FCEBEB',borderRadius:10,
|
||
border:'1px solid #D85A3033',marginBottom:16}}>
|
||
<div style={{fontSize:14,fontWeight:600,color:'#D85A30',marginBottom:4}}>
|
||
🔒 KI-Analysen nicht freigeschaltet
|
||
</div>
|
||
<div style={{fontSize:13,color:'var(--text2)',lineHeight:1.5}}>
|
||
Dein Profil hat keinen Zugang zu KI-Analysen.
|
||
Bitte den Admin bitten, KI für dein Profil zu aktivieren
|
||
(Einstellungen → Admin → Profil bearbeiten).
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{canUseAI && pipelinePrompts.length > 0 && (
|
||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
|
||
Wähle eine mehrstufige KI-Analyse:
|
||
</p>
|
||
)}
|
||
|
||
{pipelinePrompts.map(p => {
|
||
const existing = allInsights.find(i=>i.scope===p.slug)
|
||
return (
|
||
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||
<div style={{flex:1}}>
|
||
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
|
||
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
|
||
{aiUsage && <UsageBadge {...aiUsage} />}
|
||
</div>
|
||
{p.description && (
|
||
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
|
||
{p.description}
|
||
</div>
|
||
)}
|
||
{existing && (
|
||
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
|
||
Letzte Analyse: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div
|
||
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||
style={{display:'inline-block'}}
|
||
>
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||
onClick={()=>runPrompt(p.slug)}
|
||
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
|
||
>
|
||
{loading===p.slug
|
||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||
: <><Brain size={13}/> Starten</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/* Show existing result collapsed */}
|
||
{existing && newResult?.id !== existing.id && (
|
||
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
|
||
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{canUseAI && pipelinePrompts.length === 0 && (
|
||
<div className="empty-state">
|
||
<p>Keine aktiven Pipeline-Prompts verfügbar.</p>
|
||
<p style={{fontSize:12,color:'var(--text3)',marginTop:8}}>
|
||
Erstelle Pipeline-Prompts im Admin-Bereich (Einstellungen → Admin → KI-Prompts).
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Verlauf gruppiert ── */}
|
||
{tab==='history' && (
|
||
<div>
|
||
{allInsights.length===0
|
||
? <div className="empty-state"><h3>Noch keine Analysen</h3></div>
|
||
: Object.entries(grouped).map(([scope, ins]) => (
|
||
<div key={scope} style={{marginBottom:20}}>
|
||
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
|
||
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
|
||
{prompts.find(p => p.slug === scope)?.display_name || SLUG_LABELS[scope] || scope} ({ins.length})
|
||
</div>
|
||
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>)}
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|