# Issue #54: Dynamic Placeholder System **Status:** 📋 Planned (Post Phase 0c) **Priorität:** Medium **Aufwand:** 6-8h **Erstellt:** 28. März 2026 **Abhängigkeiten:** Phase 0c ✅ --- ## Problem **Aktuell (Phase 0b/0c):** ```python # backend/placeholder_resolver.py PLACEHOLDER_FUNCTIONS = { "weight_aktuell": resolve_weight_aktuell, "weight_trend": resolve_weight_trend, # ... 50+ manual entries ... } def get_placeholder_catalog(profile_id: str): placeholders = { 'Körper': [ ('weight_aktuell', 'Aktuelles Gewicht in kg'), ('weight_trend', 'Gewichtstrend (7d/30d)'), # ... 50+ manual entries ... ], } ``` **Probleme:** - ❌ Neue Platzhalter erfordern 3 Code-Änderungen: 1. Funktion implementieren 2. In `PLACEHOLDER_FUNCTIONS` registrieren 3. In `get_placeholder_catalog()` dokumentieren - ❌ Fehleranfällig (vergisst man einen Schritt → Bug) - ❌ Katalog kann out-of-sync mit tatsächlich verfügbaren Platzhaltern sein - ❌ Keine Introspection möglich (welche Platzhalter gibt es?) --- ## Lösung: Auto-Discovery mit Decorators ### Konzept ```python # 1. Decorator registriert Funktionen automatisch @placeholder( name="weight_aktuell", category="Körper", description="Aktuelles Gewicht in kg" ) def resolve_weight_aktuell(profile_id: str) -> str: ... # 2. Registry sammelt alle registrierten Platzhalter PLACEHOLDER_REGISTRY = {} # Wird automatisch gefüllt # 3. Katalog wird aus Registry generiert def get_placeholder_catalog(): return generate_catalog_from_registry() ``` **Vorteile:** - ✅ Nur 1 Stelle zu ändern (Decorator über Funktion) - ✅ Auto-Sync: Katalog immer aktuell - ✅ Introspection: Alle verfügbaren Platzhalter abrufbar - ✅ Metadata direkt bei Funktion (Single Source of Truth) --- ## Implementierung ### Step 1: Decorator + Registry erstellen (2h) **Datei:** `backend/placeholder_resolver.py` ```python from functools import wraps from typing import Dict, List, Callable # ── REGISTRY ───────────────────────────────────────────────────── PLACEHOLDER_REGISTRY: Dict[str, dict] = {} def placeholder( name: str, category: str, description: str, example: str = None ): """ Decorator to register a placeholder function. Usage: @placeholder( name="weight_aktuell", category="Körper", description="Aktuelles Gewicht in kg", example="85.3 kg" ) def resolve_weight_aktuell(profile_id: str) -> str: ... Args: name: Placeholder key (used in templates as {{name}}) category: Category for grouping (e.g., "Körper", "Ernährung") description: Human-readable description example: Optional example output Returns: Decorated function (registered in PLACEHOLDER_REGISTRY) """ def decorator(func: Callable[[str], str]) -> Callable[[str], str]: # Validate function signature import inspect sig = inspect.signature(func) params = list(sig.parameters.keys()) if len(params) != 1 or params[0] != 'profile_id': raise ValueError( f"Placeholder function {func.__name__} must have signature: " f"(profile_id: str) -> str" ) if sig.return_annotation != str: raise ValueError( f"Placeholder function {func.__name__} must return str" ) # Register in global registry PLACEHOLDER_REGISTRY[name] = { 'function': func, 'category': category, 'description': description, 'example': example or "N/A", 'function_name': func.__name__ } @wraps(func) def wrapper(profile_id: str) -> str: return func(profile_id) return wrapper return decorator # ── CATALOG GENERATION ─────────────────────────────────────────── def get_placeholder_catalog(profile_id: str = None) -> Dict[str, List[Dict[str, str]]]: """ Generate placeholder catalog from registry. Args: profile_id: Optional - if provided, generates example values Returns: { "category": [ { "key": "placeholder_name", "description": "...", "example": "..." or computed value }, ... ], ... } """ catalog = {} for name, meta in PLACEHOLDER_REGISTRY.items(): category = meta['category'] if category not in catalog: catalog[category] = [] # Generate example value if profile_id provided example = meta['example'] if profile_id and example == "N/A": try: example = meta['function'](profile_id) except Exception as e: example = f"Error: {str(e)}" catalog[category].append({ 'key': name, 'description': meta['description'], 'example': example, 'placeholder': f'{{{{{name}}}}}' # {{name}} }) # Sort categories sorted_catalog = {} category_order = [ 'Profil', 'Körper', 'Ernährung', 'Training', 'Schlaf & Erholung', 'Vitalwerte', 'Scores', 'Focus Areas', 'Zeitraum' ] for cat in category_order: if cat in catalog: sorted_catalog[cat] = sorted(catalog[cat], key=lambda x: x['key']) # Add any remaining categories not in order for cat, items in catalog.items(): if cat not in sorted_catalog: sorted_catalog[cat] = sorted(items, key=lambda x: x['key']) return sorted_catalog # ── PLACEHOLDER RESOLUTION ─────────────────────────────────────── def resolve_placeholders(template: str, profile_id: str) -> str: """ Resolve all placeholders in template. Uses PLACEHOLDER_REGISTRY (auto-populated by decorators). """ result = template for name, meta in PLACEHOLDER_REGISTRY.items(): placeholder = f'{{{{{name}}}}}' if placeholder in result: try: value = meta['function'](profile_id) result = result.replace(placeholder, str(value)) except Exception as e: # Log error but don't crash import traceback print(f"Error resolving {{{{{{name}}}}}}: {e}") traceback.print_exc() result = result.replace(placeholder, f"[Error: {name}]") return result # ── API ENDPOINT ───────────────────────────────────────────────── def list_available_placeholders() -> List[str]: """ List all available placeholder names. Returns: ["weight_aktuell", "weight_trend", ...] """ return sorted(PLACEHOLDER_REGISTRY.keys()) def get_placeholder_metadata(name: str) -> dict: """ Get metadata for a specific placeholder. Args: name: Placeholder key Returns: { "name": "weight_aktuell", "category": "Körper", "description": "...", "example": "...", "function_name": "resolve_weight_aktuell" } Raises: KeyError: If placeholder doesn't exist """ if name not in PLACEHOLDER_REGISTRY: raise KeyError(f"Placeholder '{name}' not found") meta = PLACEHOLDER_REGISTRY[name].copy() del meta['function'] # Don't expose function reference in API meta['name'] = name return meta ``` ### Step 2: Platzhalter mit Decorator versehen (3-4h) **Migration-Strategie:** ```python # ALT (Phase 0b/0c): def resolve_weight_aktuell(profile_id: str) -> str: """Returns current weight""" ... PLACEHOLDER_FUNCTIONS = { "weight_aktuell": resolve_weight_aktuell, } # NEU (Issue #54): @placeholder( name="weight_aktuell", category="Körper", description="Aktuelles Gewicht in kg", example="85.3 kg" ) def resolve_weight_aktuell(profile_id: str) -> str: """Returns current weight""" ... # PLACEHOLDER_FUNCTIONS wird nicht mehr benötigt! ``` **Alle ~50 Platzhalter konvertieren:** ```python # Profil @placeholder(name="name", category="Profil", description="Name des Nutzers") def resolve_name(profile_id: str) -> str: ... @placeholder(name="age", category="Profil", description="Alter in Jahren") def resolve_age(profile_id: str) -> str: ... # Körper @placeholder(name="weight_aktuell", category="Körper", description="Aktuelles Gewicht in kg") def resolve_weight_aktuell(profile_id: str) -> str: ... @placeholder(name="weight_7d_median", category="Körper", description="Gewicht 7d Median (kg)") def resolve_weight_7d_median(profile_id: str) -> str: ... # ... etc. für alle 50+ Platzhalter ``` ### Step 3: API Endpoints erstellen (1h) **Datei:** `backend/routers/placeholders.py` (NEU) ```python from fastapi import APIRouter, Depends, HTTPException from auth import require_auth from placeholder_resolver import ( get_placeholder_catalog, list_available_placeholders, get_placeholder_metadata, resolve_placeholders ) router = APIRouter(prefix="/api/placeholders", tags=["placeholders"]) @router.get("/catalog") def get_catalog( with_examples: bool = False, session: dict = Depends(require_auth) ): """ Get grouped placeholder catalog. Args: with_examples: If true, generates example values using user's data Returns: { "category": [ { "key": "placeholder_name", "description": "...", "example": "...", "placeholder": "{{placeholder_name}}" }, ... ], ... } """ profile_id = session['profile_id'] if with_examples else None return get_placeholder_catalog(profile_id) @router.get("/list") def list_placeholders(): """ List all available placeholder names (no auth required). Returns: ["weight_aktuell", "weight_trend", ...] """ return list_available_placeholders() @router.get("/metadata/{name}") def get_metadata(name: str): """ Get metadata for a specific placeholder (no auth required). Returns: { "name": "weight_aktuell", "category": "Körper", "description": "...", "example": "...", "function_name": "resolve_weight_aktuell" } """ try: return get_placeholder_metadata(name) except KeyError: raise HTTPException(status_code=404, detail=f"Placeholder '{name}' not found") @router.post("/resolve") def resolve_template( template: str, session: dict = Depends(require_auth) ): """ Resolve all placeholders in template. Args: template: String with placeholders (e.g., "Dein Gewicht ist {{weight_aktuell}}") Returns: { "original": "...", "resolved": "...", "placeholders_found": ["weight_aktuell", ...], "placeholders_resolved": ["weight_aktuell", ...], "placeholders_failed": [] } """ profile_id = session['profile_id'] # Find all placeholders in template import re found = re.findall(r'\{\{([^}]+)\}\}', template) # Resolve template resolved = resolve_placeholders(template, profile_id) # Check which placeholders were resolved resolved_list = [p for p in found if f'{{{{{p}}}}}' not in resolved] failed_list = [p for p in found if f'{{{{{p}}}}}' in resolved] return { "original": template, "resolved": resolved, "placeholders_found": found, "placeholders_resolved": resolved_list, "placeholders_failed": failed_list } ``` **Router in main.py registrieren:** ```python # backend/main.py from routers import placeholders # NEU app.include_router(placeholders.router) ``` ### Step 4: Frontend Integration (1-2h) **Placeholder Browser Komponente:** ```javascript // frontend/src/components/PlaceholderBrowser.jsx import { useState, useEffect } from 'react' import { api } from '../utils/api' export default function PlaceholderBrowser({ onSelect }) { const [catalog, setCatalog] = useState({}) const [loading, setLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') useEffect(() => { loadCatalog() }, []) async function loadCatalog() { try { const data = await api.getPlaceholderCatalog(true) // with examples setCatalog(data) } catch (err) { console.error('Failed to load catalog:', err) } finally { setLoading(false) } } function filterPlaceholders() { if (!searchTerm) return catalog const filtered = {} for (const [category, items] of Object.entries(catalog)) { const matching = items.filter(p => p.key.toLowerCase().includes(searchTerm.toLowerCase()) || p.description.toLowerCase().includes(searchTerm.toLowerCase()) ) if (matching.length > 0) { filtered[category] = matching } } return filtered } if (loading) return
const filteredCatalog = filterPlaceholders() return (