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)}]"
|
||||
|
||||
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,
|
||||
get_unknown_placeholders,
|
||||
get_placeholder_example_values,
|
||||
get_available_placeholders
|
||||
get_available_placeholders,
|
||||
get_placeholder_catalog
|
||||
)
|
||||
|
||||
# Environment variables
|
||||
|
|
@ -204,13 +205,13 @@ def preview_prompt(data: dict, session: dict=Depends(require_auth)):
|
|||
@router.get("/placeholders")
|
||||
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:
|
||||
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']
|
||||
return get_placeholder_example_values(profile_id)
|
||||
return get_placeholder_catalog(profile_id)
|
||||
|
||||
|
||||
# ── KI-Assisted Prompt Engineering ───────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
|
|||
const [unknownPlaceholders, setUnknownPlaceholders] = useState([])
|
||||
const [showGenerator, setShowGenerator] = 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 [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
@ -146,22 +148,36 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
|
|||
const loadPlaceholders = async () => {
|
||||
try {
|
||||
const data = await api.listPlaceholders()
|
||||
// Flatten nested structure into simple list
|
||||
const flatList = []
|
||||
Object.entries(data).forEach(([category, items]) => {
|
||||
Object.entries(items).forEach(([key, value]) => {
|
||||
flatList.push({ key, value, category })
|
||||
})
|
||||
})
|
||||
setPlaceholders(flatList)
|
||||
setPlaceholders(data)
|
||||
setShowPlaceholders(true)
|
||||
setPlaceholderFilter('')
|
||||
} catch (e) {
|
||||
alert('Fehler beim Laden der Platzhalter: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const insertPlaceholder = (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)
|
||||
}
|
||||
|
||||
|
|
@ -281,6 +297,7 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
|
|||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>Template *</label>
|
||||
<textarea
|
||||
ref={el => setTemplateRef(el)}
|
||||
className="form-input"
|
||||
value={template}
|
||||
onChange={e => setTemplate(e.target.value)}
|
||||
|
|
@ -303,44 +320,109 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
|
|||
</div>
|
||||
|
||||
{/* Placeholders Dropdown */}
|
||||
{showPlaceholders && placeholders.length > 0 && (
|
||||
{showPlaceholders && Object.keys(placeholders).length > 0 && (
|
||||
<div style={{
|
||||
marginTop:8, padding:12, background:'var(--surface)',
|
||||
marginTop:8, padding:16, background:'var(--surface)',
|
||||
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}}>
|
||||
<strong style={{fontSize:13}}>Verfügbare Platzhalter:</strong>
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:12}}>
|
||||
<strong style={{fontSize:14}}>Platzhalter einfügen</strong>
|
||||
<button
|
||||
onClick={() => setShowPlaceholders(false)}
|
||||
style={{
|
||||
background:'none', border:'none', cursor:'pointer',
|
||||
fontSize:18, color:'var(--text3)'
|
||||
fontSize:20, color:'var(--text3)', padding:0
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={placeholderFilter}
|
||||
onChange={e => setPlaceholderFilter(e.target.value)}
|
||||
style={{
|
||||
width:'100%', padding:'6px 10px', marginBottom:12,
|
||||
background:'var(--surface2)', border:'1px solid var(--border)',
|
||||
borderRadius:6, fontSize:12
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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 style={{display:'grid', gap:6}}>
|
||||
{placeholders.map((p, i) => (
|
||||
{filteredItems.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => insertPlaceholder(p.key)}
|
||||
style={{
|
||||
textAlign:'left', padding:'6px 10px',
|
||||
textAlign:'left', padding:'8px 10px',
|
||||
background:'var(--surface2)', border:'1px solid var(--border)',
|
||||
borderRadius:6, cursor:'pointer', fontSize:12,
|
||||
fontFamily:'monospace'
|
||||
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, color:'var(--accent)'}}>{`{{${p.key}}}`}</div>
|
||||
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
|
||||
Beispiel: {p.value}
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,15 +23,20 @@ const SLUG_LABELS = {
|
|||
pipeline_goals: '🔬 Pipeline: Zielabgleich',
|
||||
}
|
||||
|
||||
function InsightCard({ ins, onDelete, defaultOpen=false }) {
|
||||
function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
|
||||
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 (
|
||||
<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}}>
|
||||
{ins.display_name || SLUG_LABELS[ins.scope] || ins.scope}
|
||||
{displayName}
|
||||
</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)'}}>
|
||||
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
|
||||
|
|
@ -235,6 +240,7 @@ export default function Analysis() {
|
|||
ins={{...newResult, created: new Date().toISOString()}}
|
||||
onDelete={deleteInsight}
|
||||
defaultOpen={true}
|
||||
prompts={prompts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -340,7 +346,7 @@ export default function Analysis() {
|
|||
{/* 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}/>
|
||||
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -361,9 +367,9 @@ export default function Analysis() {
|
|||
<div key={scope} style={{marginBottom:20}}>
|
||||
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
|
||||
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>
|
||||
{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>
|
||||
))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user