fix: placeholder picker improvements + insight display names (Issue #28)
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:
parent
0c4264de44
commit
5e7ef718e0
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user