mitai-jinkendo/frontend/src/pages/Analysis.jsx
Lars da803da816
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: extract individual values from stage outputs (Issue #47)
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
2026-03-26 12:55:53 +01:00

462 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}