mitai-jinkendo/docs/issues/issue-54-dynamic-placeholder-system.md
Lars fb6d37ecfd
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Neue Docs
2026-03-28 21:47:35 +01:00

20 KiB

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):

# 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

# 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

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:

# 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:

# 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)

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:

# backend/main.py

from routers import placeholders  # NEU

app.include_router(placeholders.router)

Step 4: Frontend Integration (1-2h)

Placeholder Browser Komponente:

// 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 <div className="spinner" />

  const filteredCatalog = filterPlaceholders()

  return (
    <div className="placeholder-browser">
      <input
        type="text"
        placeholder="Platzhalter suchen..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        className="form-input"
      />

      {Object.entries(filteredCatalog).map(([category, items]) => (
        <div key={category} className="category-section">
          <h3>{category}</h3>
          <div className="placeholder-grid">
            {items.map(p => (
              <div
                key={p.key}
                className="placeholder-card"
                onClick={() => onSelect && onSelect(p.placeholder)}
              >
                <div className="placeholder-key">{p.placeholder}</div>
                <div className="placeholder-desc">{p.description}</div>
                {p.example !== 'N/A' && (
                  <div className="placeholder-example">
                    Beispiel: {p.example}
                  </div>
                )}
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  )
}

API Functions hinzufügen:

// 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)

# 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)

# 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)

# Legacy Code entfernen
# - PLACEHOLDER_FUNCTIONS Dictionary löschen
# - Alte get_placeholder_catalog() Logik löschen

Testing

Unit Tests

# 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

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:

# 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