fix: placeholder picker improvements + insight display names (Issue #28)
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Backend:
- get_placeholder_catalog(): grouped placeholders with descriptions
- Returns {category: [{key, description, example}]} format
- Categories: Profil, Körper, Ernährung, Training, Schlaf, Vitalwerte, Zeitraum

Frontend - Placeholder Picker:
- Grouped by category with visual separation
- Search/filter across keys and descriptions
- Hover effects for better UX
- Insert at cursor position (not at end)
- Shows: key + description + example value
- 'Keine Platzhalter gefunden' message when filtered

Frontend - Insight Display Names:
- InsightCard receives prompts array
- Finds matching prompt by scope/slug
- Shows prompt.display_name instead of hardcoded SLUG_LABELS
- History tab also shows display_name in group headers
- Fallback chain: display_name → SLUG_LABELS → scope

User-facing improvements:
✓ Platzhalter zeigen echte Daten statt Zahlen
✓ Durchsuchbar + filterbar
✓ Einfügen an Cursor-Position
✓ Insights zeigen custom Namen (z.B. '🍽️ Meine Ernährung')

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-25 06:44:22 +01:00
parent 0c4264de44
commit 5e7ef718e0
4 changed files with 208 additions and 43 deletions

View File

@ -306,3 +306,79 @@ def get_placeholder_example_values(profile_id: str) -> Dict[str, str]:
examples[placeholder] = f"[Fehler: {str(e)}]" examples[placeholder] = f"[Fehler: {str(e)}]"
return examples return examples
def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]:
"""
Get grouped placeholder catalog with descriptions and example values.
Args:
profile_id: User profile ID
Returns:
Dict mapping category to list of {key, description, example}
"""
# Placeholder definitions with descriptions
placeholders = {
'Profil': [
('name', 'Name des Nutzers'),
('age', 'Alter in Jahren'),
('height', 'Körpergröße in cm'),
('geschlecht', 'Geschlecht'),
],
'Körper': [
('weight_aktuell', 'Aktuelles Gewicht in kg'),
('weight_trend', 'Gewichtstrend (7d/30d)'),
('kf_aktuell', 'Aktueller Körperfettanteil in %'),
('bmi', 'Body Mass Index'),
],
'Ernährung': [
('kcal_avg', 'Durchschn. Kalorien (30d)'),
('protein_avg', 'Durchschn. Protein in g (30d)'),
('carb_avg', 'Durchschn. Kohlenhydrate in g (30d)'),
('fat_avg', 'Durchschn. Fett in g (30d)'),
],
'Training': [
('activity_summary', 'Aktivitäts-Zusammenfassung (7d)'),
('trainingstyp_verteilung', 'Verteilung nach Trainingstypen'),
],
'Schlaf & Erholung': [
('sleep_avg_duration', 'Durchschn. Schlafdauer (7d)'),
('sleep_avg_quality', 'Durchschn. Schlafqualität (7d)'),
('rest_days_count', 'Anzahl Ruhetage (30d)'),
],
'Vitalwerte': [
('vitals_avg_hr', 'Durchschn. Ruhepuls (7d)'),
('vitals_avg_hrv', 'Durchschn. HRV (7d)'),
('vitals_vo2_max', 'Aktueller VO2 Max'),
],
'Zeitraum': [
('datum_heute', 'Heutiges Datum'),
('zeitraum_7d', '7-Tage-Zeitraum'),
('zeitraum_30d', '30-Tage-Zeitraum'),
],
}
catalog = {}
for category, items in placeholders.items():
catalog[category] = []
for key, description in items:
placeholder = f'{{{{{key}}}}}'
# Get example value if resolver exists
resolver = PLACEHOLDER_MAP.get(placeholder)
if resolver:
try:
example = resolver(profile_id)
except Exception:
example = '[Nicht verfügbar]'
else:
example = '[Nicht implementiert]'
catalog[category].append({
'key': key,
'description': description,
'example': str(example)
})
return catalog

View File

@ -17,7 +17,8 @@ from placeholder_resolver import (
resolve_placeholders, resolve_placeholders,
get_unknown_placeholders, get_unknown_placeholders,
get_placeholder_example_values, get_placeholder_example_values,
get_available_placeholders get_available_placeholders,
get_placeholder_catalog
) )
# Environment variables # Environment variables
@ -204,13 +205,13 @@ def preview_prompt(data: dict, session: dict=Depends(require_auth)):
@router.get("/placeholders") @router.get("/placeholders")
def list_placeholders(session: dict=Depends(require_auth)): def list_placeholders(session: dict=Depends(require_auth)):
""" """
Get list of available placeholders with example values. Get grouped catalog of available placeholders with descriptions and examples.
Returns: Returns:
Dict mapping placeholder to example value using current user's data Dict mapping category to list of {key, description, example}
""" """
profile_id = session['profile_id'] profile_id = session['profile_id']
return get_placeholder_example_values(profile_id) return get_placeholder_catalog(profile_id)
# ── KI-Assisted Prompt Engineering ─────────────────────────────────────────── # ── KI-Assisted Prompt Engineering ───────────────────────────────────────────

View File

@ -15,7 +15,9 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
const [unknownPlaceholders, setUnknownPlaceholders] = useState([]) const [unknownPlaceholders, setUnknownPlaceholders] = useState([])
const [showGenerator, setShowGenerator] = useState(false) const [showGenerator, setShowGenerator] = useState(false)
const [showPlaceholders, setShowPlaceholders] = useState(false) const [showPlaceholders, setShowPlaceholders] = useState(false)
const [placeholders, setPlaceholders] = useState([]) const [placeholders, setPlaceholders] = useState({})
const [placeholderFilter, setPlaceholderFilter] = useState('')
const [templateRef, setTemplateRef] = useState(null)
const [optimization, setOptimization] = useState(null) const [optimization, setOptimization] = useState(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -146,22 +148,36 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
const loadPlaceholders = async () => { const loadPlaceholders = async () => {
try { try {
const data = await api.listPlaceholders() const data = await api.listPlaceholders()
// Flatten nested structure into simple list setPlaceholders(data)
const flatList = []
Object.entries(data).forEach(([category, items]) => {
Object.entries(items).forEach(([key, value]) => {
flatList.push({ key, value, category })
})
})
setPlaceholders(flatList)
setShowPlaceholders(true) setShowPlaceholders(true)
setPlaceholderFilter('')
} catch (e) { } catch (e) {
alert('Fehler beim Laden der Platzhalter: ' + e.message) alert('Fehler beim Laden der Platzhalter: ' + e.message)
} }
} }
const insertPlaceholder = (key) => { const insertPlaceholder = (key) => {
setTemplate(prev => prev + ` {{${key}}}`) if (!templateRef) {
// Fallback: append at end
setTemplate(prev => prev + ` {{${key}}}`)
} else {
// Insert at cursor position
const start = templateRef.selectionStart
const end = templateRef.selectionEnd
const text = template
const before = text.substring(0, start)
const after = text.substring(end)
const inserted = `{{${key}}}`
setTemplate(before + inserted + after)
// Set cursor position after inserted placeholder
setTimeout(() => {
templateRef.selectionStart = templateRef.selectionEnd = start + inserted.length
templateRef.focus()
}, 0)
}
setShowPlaceholders(false) setShowPlaceholders(false)
} }
@ -281,6 +297,7 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
<div> <div>
<label className="form-label" style={{display:'block', marginBottom:6}}>Template *</label> <label className="form-label" style={{display:'block', marginBottom:6}}>Template *</label>
<textarea <textarea
ref={el => setTemplateRef(el)}
className="form-input" className="form-input"
value={template} value={template}
onChange={e => setTemplate(e.target.value)} onChange={e => setTemplate(e.target.value)}
@ -303,43 +320,108 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
</div> </div>
{/* Placeholders Dropdown */} {/* Placeholders Dropdown */}
{showPlaceholders && placeholders.length > 0 && ( {showPlaceholders && Object.keys(placeholders).length > 0 && (
<div style={{ <div style={{
marginTop:8, padding:12, background:'var(--surface)', marginTop:8, padding:16, background:'var(--surface)',
border:'1px solid var(--border)', borderRadius:8, border:'1px solid var(--border)', borderRadius:8,
maxHeight:300, overflow:'auto' maxHeight:400, overflow:'auto'
}}> }}>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:8}}> <div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:12}}>
<strong style={{fontSize:13}}>Verfügbare Platzhalter:</strong> <strong style={{fontSize:14}}>Platzhalter einfügen</strong>
<button <button
onClick={() => setShowPlaceholders(false)} onClick={() => setShowPlaceholders(false)}
style={{ style={{
background:'none', border:'none', cursor:'pointer', background:'none', border:'none', cursor:'pointer',
fontSize:18, color:'var(--text3)' fontSize:20, color:'var(--text3)', padding:0
}} }}
> >
× ×
</button> </button>
</div> </div>
<div style={{display:'grid', gap:6}}>
{placeholders.map((p, i) => ( {/* Search Filter */}
<button <input
key={i} type="text"
onClick={() => insertPlaceholder(p.key)} placeholder="Suchen..."
style={{ value={placeholderFilter}
textAlign:'left', padding:'6px 10px', onChange={e => setPlaceholderFilter(e.target.value)}
background:'var(--surface2)', border:'1px solid var(--border)', style={{
borderRadius:6, cursor:'pointer', fontSize:12, width:'100%', padding:'6px 10px', marginBottom:12,
fontFamily:'monospace' background:'var(--surface2)', border:'1px solid var(--border)',
}} borderRadius:6, fontSize:12
> }}
<div style={{fontWeight:600, color:'var(--accent)'}}>{`{{${p.key}}}`}</div> />
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
Beispiel: {p.value} {/* Grouped Placeholders */}
{Object.entries(placeholders).map(([category, items]) => {
// Filter items
const filteredItems = items.filter(p =>
placeholderFilter === '' ||
p.key.toLowerCase().includes(placeholderFilter.toLowerCase()) ||
p.description.toLowerCase().includes(placeholderFilter.toLowerCase())
)
if (filteredItems.length === 0) return null
return (
<div key={category} style={{marginBottom:16}}>
<div style={{
fontSize:11, fontWeight:600, color:'var(--text3)',
textTransform:'uppercase', letterSpacing:'0.5px',
marginBottom:6
}}>
{category}
</div> </div>
</button> <div style={{display:'grid', gap:6}}>
))} {filteredItems.map((p, i) => (
</div> <button
key={i}
onClick={() => insertPlaceholder(p.key)}
style={{
textAlign:'left', padding:'8px 10px',
background:'var(--surface2)', border:'1px solid var(--border)',
borderRadius:6, cursor:'pointer',
transition:'all 0.15s'
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'var(--accent-light)'
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'var(--surface2)'
e.currentTarget.style.borderColor = 'var(--border)'
}}
>
<div style={{
fontWeight:600, fontSize:12, fontFamily:'monospace',
color:'var(--accent)', marginBottom:2
}}>
{`{{${p.key}}}`}
</div>
<div style={{fontSize:11, color:'var(--text2)', marginBottom:2}}>
{p.description}
</div>
<div style={{fontSize:10, color:'var(--text3)', fontStyle:'italic'}}>
Beispiel: {p.example}
</div>
</button>
))}
</div>
</div>
)
})}
{/* No results */}
{placeholderFilter && Object.values(placeholders).every(items =>
items.filter(p =>
p.key.toLowerCase().includes(placeholderFilter.toLowerCase()) ||
p.description.toLowerCase().includes(placeholderFilter.toLowerCase())
).length === 0
) && (
<div style={{textAlign:'center', padding:20, color:'var(--text3)', fontSize:12}}>
Keine Platzhalter gefunden für "{placeholderFilter}"
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -23,15 +23,20 @@ const SLUG_LABELS = {
pipeline_goals: '🔬 Pipeline: Zielabgleich', pipeline_goals: '🔬 Pipeline: Zielabgleich',
} }
function InsightCard({ ins, onDelete, defaultOpen=false }) { function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
// 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
return ( return (
<div className="card section-gap" style={{borderLeft:`3px solid var(--accent)`}}> <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'}} <div style={{display:'flex',alignItems:'center',gap:8,marginBottom:open?12:0,cursor:'pointer'}}
onClick={()=>setOpen(o=>!o)}> onClick={()=>setOpen(o=>!o)}>
<div style={{flex:1}}> <div style={{flex:1}}>
<div style={{fontSize:13,fontWeight:600}}> <div style={{fontSize:13,fontWeight:600}}>
{ins.display_name || SLUG_LABELS[ins.scope] || ins.scope} {displayName}
</div> </div>
<div style={{fontSize:11,color:'var(--text3)'}}> <div style={{fontSize:11,color:'var(--text3)'}}>
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')} {dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
@ -235,6 +240,7 @@ export default function Analysis() {
ins={{...newResult, created: new Date().toISOString()}} ins={{...newResult, created: new Date().toISOString()}}
onDelete={deleteInsight} onDelete={deleteInsight}
defaultOpen={true} defaultOpen={true}
prompts={prompts}
/> />
</div> </div>
)} )}
@ -340,7 +346,7 @@ export default function Analysis() {
{/* Show existing result collapsed */} {/* Show existing result collapsed */}
{existing && newResult?.id !== existing.id && ( {existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}> <div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false}/> <InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
</div> </div>
)} )}
</div> </div>
@ -361,9 +367,9 @@ export default function Analysis() {
<div key={scope} style={{marginBottom:20}}> <div key={scope} style={{marginBottom:20}}>
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)', <div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}> textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
{SLUG_LABELS[scope]||scope} ({ins.length}) {prompts.find(p => p.slug === scope)?.display_name || SLUG_LABELS[scope] || scope} ({ins.length})
</div> </div>
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight}/>)} {ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>)}
</div> </div>
)) ))
} }