# 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 (
setSearchTerm(e.target.value)} className="form-input" /> {Object.entries(filteredCatalog).map(([category, items]) => (

{category}

{items.map(p => (
onSelect && onSelect(p.placeholder)} >
{p.placeholder}
{p.description}
{p.example !== 'N/A' && (
Beispiel: {p.example}
)}
))}
))}
) } ``` **API Functions hinzufügen:** ```javascript // frontend/src/utils/api.js export const api = { // ... existing functions ... // Placeholder System getPlaceholderCatalog: async (withExamples = false) => { return await apiFetch(`/api/placeholders/catalog?with_examples=${withExamples}`) }, listPlaceholders: async () => { return await apiFetch('/api/placeholders/list') }, getPlaceholderMetadata: async (name) => { return await apiFetch(`/api/placeholders/metadata/${name}`) }, resolvePlaceholders: async (template) => { return await apiFetch('/api/placeholders/resolve', { method: 'POST', body: JSON.stringify({ template }) }) } } ``` --- ## Vorteile nach Implementierung ### Developer Experience - ✅ Nur 1 Stelle ändern (Decorator) - ✅ Automatische Validierung (Signatur-Check) - ✅ IDE Auto-Complete für Decorator-Parameter - ✅ Weniger Fehler (kein out-of-sync) ### API Features - ✅ `GET /api/placeholders/list` - Alle verfügbaren Platzhalter - ✅ `GET /api/placeholders/catalog` - Gruppierter Katalog - ✅ `GET /api/placeholders/metadata/{name}` - Details zu Platzhalter - ✅ `POST /api/placeholders/resolve` - Template auflösen ### Frontend Features - ✅ Placeholder Browser mit Suche - ✅ Live-Beispielwerte aus User-Daten - ✅ Click-to-Insert in Prompt-Editor - ✅ Auto-Complete beim Tippen --- ## Migration-Plan ### Phase 1: Backwards Compatible (2h) ```python # Beide Systeme parallel unterstützen # 1. Decorator-System implementieren @placeholder(...) def resolve_weight_aktuell(profile_id: str) -> str: ... # 2. Legacy PLACEHOLDER_FUNCTIONS weiter unterstützen PLACEHOLDER_FUNCTIONS = PLACEHOLDER_REGISTRY # Alias # 3. get_placeholder_catalog() nutzt Registry ``` ### Phase 2: Migration (3h) ```python # Alle 50+ Platzhalter mit Decorator versehen # Ein Commit pro Kategorie: # - commit 1: Profil (5 Platzhalter) # - commit 2: Körper (12 Platzhalter) # - commit 3: Ernährung (10 Platzhalter) # - commit 4: Training (10 Platzhalter) # - commit 5: Schlaf & Erholung (8 Platzhalter) # - commit 6: Vitalwerte (6 Platzhalter) # - commit 7: Rest (Scores, Focus Areas, Zeitraum) ``` ### Phase 3: Cleanup (1h) ```python # Legacy Code entfernen # - PLACEHOLDER_FUNCTIONS Dictionary löschen # - Alte get_placeholder_catalog() Logik löschen ``` --- ## Testing ### Unit Tests ```python # backend/tests/test_placeholder_system.py def test_decorator_registration(): """Test that decorator registers placeholder""" @placeholder(name="test_ph", category="Test", description="Test") def resolve_test(profile_id: str) -> str: return "test_value" assert "test_ph" in PLACEHOLDER_REGISTRY assert PLACEHOLDER_REGISTRY["test_ph"]["category"] == "Test" def test_invalid_signature(): """Test that decorator validates function signature""" with pytest.raises(ValueError): @placeholder(name="bad", category="Test", description="Test") def resolve_bad(profile_id: str, extra: str) -> str: # Wrong signature! return "bad" def test_catalog_generation(): """Test catalog generation from registry""" catalog = get_placeholder_catalog() assert isinstance(catalog, dict) assert "Körper" in catalog assert len(catalog["Körper"]) > 0 def test_placeholder_resolution(): """Test resolving placeholders in template""" template = "Gewicht: {{weight_aktuell}}" resolved = resolve_placeholders(template, "test_profile") assert "{{weight_aktuell}}" not in resolved assert "kg" in resolved or "Nicht genug Daten" in resolved ``` ### Integration Tests ```python def test_api_catalog_endpoint(client, auth_token): """Test /api/placeholders/catalog endpoint""" response = client.get( "/api/placeholders/catalog", headers={"X-Auth-Token": auth_token} ) assert response.status_code == 200 data = response.json() assert "Körper" in data assert len(data["Körper"]) > 0 def test_api_resolve_endpoint(client, auth_token): """Test /api/placeholders/resolve endpoint""" response = client.post( "/api/placeholders/resolve", headers={"X-Auth-Token": auth_token}, json={"template": "Gewicht: {{weight_aktuell}}"} ) assert response.status_code == 200 data = response.json() assert "resolved" in data assert "{{weight_aktuell}}" not in data["resolved"] ``` --- ## Acceptance Criteria ✅ **Issue #54 ist abgeschlossen, wenn:** ### Backend - ✅ `@placeholder` Decorator implementiert - ✅ `PLACEHOLDER_REGISTRY` automatisch gefüllt - ✅ `get_placeholder_catalog()` nutzt Registry - ✅ Alle 50+ Platzhalter mit Decorator versehen - ✅ Legacy `PLACEHOLDER_FUNCTIONS` entfernt - ✅ API Endpoints implementiert (/list, /catalog, /metadata, /resolve) - ✅ Unit Tests geschrieben (>80% coverage) ### Frontend - ✅ `PlaceholderBrowser` Komponente erstellt - ✅ Suche funktioniert - ✅ Click-to-Insert funktioniert - ✅ Live-Beispielwerte werden angezeigt - ✅ Integration in Prompt-Editor ### Dokumentation - ✅ `PLACEHOLDER_DEVELOPMENT_GUIDE.md` aktualisiert - ✅ API-Dokumentation erstellt - ✅ CLAUDE.md aktualisiert --- ## Ausblick: Future Enhancements ### Auto-Discovery von Data Layer Funktionen **Nach Phase 0c:** Data Layer Funktionen könnten automatisch als Platzhalter erkannt werden: ```python # backend/data_layer/body_metrics.py @data_function( provides_placeholders=[ ("weight_7d_median", "Gewicht 7d Median (kg)"), ("weight_28d_slope", "Gewichtstrend 28d (kg/Tag)"), ] ) def get_weight_trend_data(profile_id: str, days: int = 90) -> dict: ... # Automatisch generierte Platzhalter: @placeholder(name="weight_7d_median", category="Körper", description="...") def resolve_weight_7d_median(profile_id: str) -> str: data = get_weight_trend_data(profile_id, days=7) return f"{data['rolling_median_7d'][-1][1]:.1f} kg" ``` **Vorteil:** Data Layer Funktionen automatisch als Platzhalter verfügbar. --- **Erstellt:** 28. März 2026 **Autor:** Claude Sonnet 4.5 **Status:** Planned (Post Phase 0c) **Geschätzter Aufwand:** 6-8h