Neue Docs
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

This commit is contained in:
Lars 2026-03-28 21:47:35 +01:00
parent ffa99f10fb
commit fb6d37ecfd
3 changed files with 3317 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,765 @@
# 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 <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:**
```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

View File

@ -0,0 +1,422 @@
# Phase 0c: Placeholder Migration Analysis
**Erstellt:** 28. März 2026
**Zweck:** Analyse welche Platzhalter zu Data Layer migriert werden müssen
---
## Gesamt-Übersicht
**Aktuelle Platzhalter:** 116
**Nach Phase 0c Migration:**
- ✅ **Bleiben einfach (kein Data Layer):** 8 Platzhalter
- 🔄 **Gehen zu Data Layer:** 108 Platzhalter
---
## Kategorisierung: BLEIBEN EINFACH (8 Platzhalter)
Diese Platzhalter bleiben im KI Layer (placeholder_resolver.py) weil sie:
- Keine Berechnungen durchführen
- Keine Daten-Aggregation benötigen
- Einfache Getter oder Konstanten sind
### Zeitraum (4 Platzhalter)
```python
'{{datum_heute}}': lambda pid: datetime.now().strftime('%d.%m.%Y')
'{{zeitraum_7d}}': lambda pid: 'letzte 7 Tage'
'{{zeitraum_30d}}': lambda pid: 'letzte 30 Tage'
'{{zeitraum_90d}}': lambda pid: 'letzte 90 Tage'
```
**Begründung:** Konstanten oder einfache Datum-Formatierung. Kein Data Layer nötig.
### Profil - Basis (4 Platzhalter)
```python
'{{name}}': lambda pid: get_profile_data(pid).get('name', 'Nutzer')
'{{age}}': lambda pid: calculate_age(get_profile_data(pid).get('dob'))
'{{height}}': lambda pid: str(get_profile_data(pid).get('height', 'unbekannt'))
'{{geschlecht}}': lambda pid: 'männlich' if get_profile_data(pid).get('sex') == 'm' else 'weiblich'
```
**Begründung:** Direkte Getter aus profiles Tabelle. Keine Aggregation.
---
## GEHEN ZU DATA LAYER (108 Platzhalter)
### 1. Körper (20 Platzhalter) → `data_layer.body_metrics`
#### Basis-Metriken (8):
```python
'{{weight_aktuell}}' → get_weight_trend_data()['last_value']
'{{weight_trend}}' → get_weight_trend_data() (formatiert)
'{{kf_aktuell}}' → get_body_composition_data()['body_fat_pct'][-1]
'{{bmi}}' → get_body_composition_data() (berechnet)
'{{caliper_summary}}' → get_caliper_summary_data()
'{{circ_summary}}' → get_circumference_summary()
'{{goal_weight}}' → get_active_goals() (filtered)
'{{goal_bf_pct}}' → get_active_goals() (filtered)
```
#### Phase 0b - Advanced Body (12):
```python
'{{weight_7d_median}}' → get_weight_trend_data()['rolling_median_7d'][-1]
'{{weight_28d_slope}}' → get_weight_trend_data()['slope_28d']
'{{weight_90d_slope}}' → get_weight_trend_data()['slope_90d']
'{{fm_28d_change}}' → get_body_composition_data()['fm_delta_28d']
'{{lbm_28d_change}}' → get_body_composition_data()['lbm_delta_28d']
'{{waist_28d_delta}}' → get_circumference_summary()['changes']['waist_28d']
'{{hip_28d_delta}}' → get_circumference_summary()['changes']['hip_28d']
'{{chest_28d_delta}}' → get_circumference_summary()['changes']['chest_28d']
'{{arm_28d_delta}}' → get_circumference_summary()['changes']['arm_28d']
'{{thigh_28d_delta}}' → get_circumference_summary()['changes']['thigh_28d']
'{{waist_hip_ratio}}' → get_circumference_summary()['ratios']['waist_to_hip']
'{{recomposition_quadrant}}'→ get_body_composition_data()['recomposition_score']
```
**Data Layer Funktionen benötigt:**
- `get_weight_trend_data(profile_id, days=90)`
- `get_body_composition_data(profile_id, days=90)`
- `get_circumference_summary(profile_id, days=90)`
- `get_caliper_summary_data(profile_id, days=90)`
---
### 2. Ernährung (14 Platzhalter) → `data_layer.nutrition_metrics`
#### Basis-Metriken (7):
```python
'{{kcal_avg}}' → get_energy_balance_data()['avg_intake']
'{{protein_avg}}' → get_protein_adequacy_data()['avg_protein_g']
'{{carb_avg}}' → get_macro_distribution_data()['avg_carbs_g']
'{{fat_avg}}' → get_macro_distribution_data()['avg_fat_g']
'{{nutrition_days}}' → get_energy_balance_data()['data_points']
'{{protein_ziel_low}}' → get_protein_adequacy_data()['target_protein_g'] (low)
'{{protein_ziel_high}}' → get_protein_adequacy_data()['target_protein_g'] (high)
```
#### Phase 0b - Advanced Nutrition (7):
```python
'{{energy_balance_7d}}' → get_energy_balance_data()['avg_net']
'{{energy_deficit_surplus}}'→ get_energy_balance_data()['deficit_surplus_avg']
'{{protein_g_per_kg}}' → get_protein_adequacy_data()['avg_protein_per_kg']
'{{protein_days_in_target}}'→ get_protein_adequacy_data()['adherence_pct']
'{{protein_adequacy_28d}}' → get_protein_adequacy_data()['adherence_score']
'{{macro_consistency_score}}'→ get_macro_distribution_data()['balance_score']
'{{intake_volatility}}' → get_macro_distribution_data()['variability']
```
**Data Layer Funktionen benötigt:**
- `get_protein_adequacy_data(profile_id, days=28, goal_mode=None)`
- `get_energy_balance_data(profile_id, days=28)`
- `get_macro_distribution_data(profile_id, days=28)`
---
### 3. Training (16 Platzhalter) → `data_layer.activity_metrics`
#### Basis-Metriken (3):
```python
'{{activity_summary}}' → get_training_volume_data()['weekly_totals'] (formatted)
'{{activity_detail}}' → get_training_volume_data()['by_type'] (formatted)
'{{trainingstyp_verteilung}}'→ get_activity_quality_distribution()
```
#### Phase 0b - Advanced Activity (13):
```python
'{{training_minutes_week}}' → get_training_volume_data()['weekly_totals'][0]['duration_min']
'{{training_frequency_7d}}' → get_training_volume_data()['weekly_totals'][0]['sessions']
'{{quality_sessions_pct}}' → get_activity_quality_distribution()['high_quality_pct']
'{{ability_balance_strength}}' → get_ability_balance_data()['abilities']['strength']
'{{ability_balance_endurance}}'→ get_ability_balance_data()['abilities']['cardio']
'{{ability_balance_mental}}' → get_ability_balance_data()['abilities']['mental']
'{{ability_balance_coordination}}'→ get_ability_balance_data()['abilities']['coordination']
'{{ability_balance_mobility}}' → get_ability_balance_data()['abilities']['mobility']
'{{proxy_internal_load_7d}}'→ get_training_volume_data()['strain']
'{{monotony_score}}' → get_training_volume_data()['monotony']
'{{strain_score}}' → get_training_volume_data()['strain']
'{{rest_day_compliance}}' → get_recovery_score_data()['components']['rest_compliance']['score']
'{{vo2max_trend_28d}}' → get_vitals_baseline_data()['vo2_max']['trend']
```
**Data Layer Funktionen benötigt:**
- `get_training_volume_data(profile_id, weeks=4)`
- `get_activity_quality_distribution(profile_id, days=28)`
- `get_ability_balance_data(profile_id, weeks=4)`
---
### 4. Schlaf & Erholung (10 Platzhalter) → `data_layer.recovery_metrics`
#### Basis-Metriken (3):
```python
'{{sleep_avg_duration}}' → get_sleep_regularity_data()['avg_duration_h']
'{{sleep_avg_quality}}' → get_sleep_regularity_data()['avg_quality']
'{{rest_days_count}}' → get_recovery_score_data()['components']['rest_compliance']['rest_days']
```
#### Phase 0b - Advanced Recovery (7):
```python
'{{hrv_vs_baseline_pct}}' → get_vitals_baseline_data()['hrv']['deviation_pct']
'{{rhr_vs_baseline_pct}}' → get_vitals_baseline_data()['rhr']['deviation_pct']
'{{sleep_avg_duration_7d}}' → get_sleep_regularity_data()['avg_duration_h']
'{{sleep_debt_hours}}' → get_sleep_regularity_data()['sleep_debt_h']
'{{sleep_regularity_proxy}}'→ get_sleep_regularity_data()['regularity_score']
'{{recent_load_balance_3d}}'→ get_recovery_score_data()['load_balance']
'{{sleep_quality_7d}}' → get_sleep_regularity_data()['avg_quality']
```
**Data Layer Funktionen benötigt:**
- `get_recovery_score_data(profile_id, days=7)`
- `get_sleep_regularity_data(profile_id, days=28)`
- `get_vitals_baseline_data(profile_id, days=7)`
---
### 5. Vitalwerte (3 Platzhalter) → `data_layer.health_metrics`
```python
'{{vitals_avg_hr}}' → get_vitals_baseline_data()['rhr']['current']
'{{vitals_avg_hrv}}' → get_vitals_baseline_data()['hrv']['current']
'{{vitals_vo2_max}}' → get_vitals_baseline_data()['vo2_max']['current']
```
**Data Layer Funktionen benötigt:**
- `get_vitals_baseline_data(profile_id, days=7)` (bereits in recovery)
---
### 6. Scores (6 Platzhalter) → Diverse Module
```python
'{{goal_progress_score}}' → get_goal_progress_data() → goals.py
'{{body_progress_score}}' → get_body_composition_data() → body_metrics.py
'{{nutrition_score}}' → get_protein_adequacy_data() → nutrition_metrics.py
'{{activity_score}}' → get_training_volume_data() → activity_metrics.py
'{{recovery_score}}' → get_recovery_score_data()['score'] → recovery_metrics.py
'{{data_quality_score}}' → get_data_quality_score() → utils.py (NEW)
```
**Hinweis:** Scores nutzen bestehende Data Layer Funktionen, nur Formatierung nötig.
---
### 7. Top Goals/Focus (5 Platzhalter) → `data_layer.goals`
```python
'{{top_goal_name}}' → get_active_goals()[0]['name']
'{{top_goal_progress_pct}}' → get_active_goals()[0]['progress_pct']
'{{top_goal_status}}' → get_active_goals()[0]['status']
'{{top_focus_area_name}}' → get_weighted_focus_areas()[0]['name']
'{{top_focus_area_progress}}'→ get_weighted_focus_areas()[0]['progress']
```
**Data Layer Funktionen benötigt:**
- `get_active_goals(profile_id)` (already exists from Phase 0b)
- `get_weighted_focus_areas(profile_id)` (already exists from Phase 0b)
---
### 8. Category Scores (14 Platzhalter) → Formatierung nur
```python
'{{focus_cat_körper_progress}}' → _format_from_aggregated_data()
'{{focus_cat_körper_weight}}' → _format_from_aggregated_data()
'{{focus_cat_ernährung_progress}}' → _format_from_aggregated_data()
'{{focus_cat_ernährung_weight}}' → _format_from_aggregated_data()
# ... (7 Kategorien × 2 = 14 total)
```
**Hinweis:** Diese nutzen bereits aggregierte Daten aus Phase 0b.
**Migration:** Nur KI Layer Formatierung, Data Layer nicht nötig (Daten kommen aus anderen Funktionen).
---
### 9. Korrelationen (7 Platzhalter) → `data_layer.correlations`
```python
'{{correlation_energy_weight_lag}}' → get_correlation_data(pid, 'energy', 'weight')
'{{correlation_protein_lbm}}' → get_correlation_data(pid, 'protein', 'lbm')
'{{correlation_load_hrv}}' → get_correlation_data(pid, 'load', 'hrv')
'{{correlation_load_rhr}}' → get_correlation_data(pid, 'load', 'rhr')
'{{correlation_sleep_recovery}}' → get_correlation_data(pid, 'sleep', 'recovery')
'{{plateau_detected}}' → detect_plateau(pid, 'weight')
'{{top_drivers}}' → get_top_drivers(pid)
```
**Data Layer Funktionen benötigt:**
- `get_correlation_data(profile_id, metric_a, metric_b, days=90, max_lag=7)`
- `detect_plateau(profile_id, metric, days=28)`
- `get_top_drivers(profile_id)` (NEW - identifies top correlations)
---
### 10. JSON/Markdown (8 Platzhalter) → Formatierung nur
```python
'{{active_goals_json}}' → json.dumps(get_active_goals(pid))
'{{active_goals_md}}' → format_as_markdown(get_active_goals(pid))
'{{focus_areas_weighted_json}}' → json.dumps(get_weighted_focus_areas(pid))
'{{focus_areas_weighted_md}}' → format_as_markdown(get_weighted_focus_areas(pid))
'{{focus_area_weights_json}}' → json.dumps(get_focus_area_weights(pid))
'{{top_3_focus_areas}}' → format_top_3(get_weighted_focus_areas(pid))
'{{top_3_goals_behind_schedule}}' → format_goals_behind(get_active_goals(pid))
'{{top_3_goals_on_track}}' → format_goals_on_track(get_active_goals(pid))
```
**Hinweis:** Diese nutzen bereits existierende Data Layer Funktionen.
**Migration:** Nur KI Layer Formatierung (json.dumps, markdown, etc.).
---
## Data Layer Funktionen - Zusammenfassung
### Neue Funktionen zu erstellen (Phase 0c):
#### body_metrics.py (4 Funktionen):
- ✅ `get_weight_trend_data()`
- ✅ `get_body_composition_data()`
- ✅ `get_circumference_summary()`
- ✅ `get_caliper_summary_data()`
#### nutrition_metrics.py (3 Funktionen):
- ✅ `get_protein_adequacy_data()`
- ✅ `get_energy_balance_data()`
- ✅ `get_macro_distribution_data()`
#### activity_metrics.py (3 Funktionen):
- ✅ `get_training_volume_data()`
- ✅ `get_activity_quality_distribution()`
- ✅ `get_ability_balance_data()`
#### recovery_metrics.py (2 Funktionen):
- ✅ `get_recovery_score_data()`
- ✅ `get_sleep_regularity_data()`
#### health_metrics.py (2 Funktionen):
- ✅ `get_vitals_baseline_data()`
- ✅ `get_blood_pressure_data()` (aus Spec)
#### goals.py (3 Funktionen):
- ✅ `get_active_goals()` (exists from Phase 0b)
- ✅ `get_weighted_focus_areas()` (exists from Phase 0b)
- ✅ `get_goal_progress_data()` (aus Spec)
#### correlations.py (3 Funktionen):
- ✅ `get_correlation_data()`
- ✅ `detect_plateau()`
- 🆕 `get_top_drivers()` (NEW - not in spec)
#### utils.py (Shared):
- ✅ `calculate_confidence()`
- ✅ `calculate_baseline()`
- ✅ `detect_outliers()`
- ✅ `aggregate_data()`
- ✅ `serialize_dates()`
- 🆕 `get_data_quality_score()` (NEW)
**Total neue Funktionen:** 20 (aus Spec) + 2 (zusätzlich) = **22 Data Layer Funktionen**
---
## Migration-Aufwand pro Kategorie
| Kategorie | Platzhalter | Data Layer Funcs | Aufwand | Priorität |
|-----------|-------------|------------------|---------|-----------|
| Körper | 20 | 4 | 3-4h | High |
| Ernährung | 14 | 3 | 2-3h | High |
| Training | 16 | 3 | 3-4h | Medium |
| Recovery | 10 | 2 | 2-3h | Medium |
| Vitalwerte | 3 | 1 (shared) | 0.5h | Low |
| Scores | 6 | 0 (use others) | 1h | Low |
| Goals/Focus | 5 | 0 (exists) | 0.5h | Low |
| Categories | 14 | 0 (formatting) | 1h | Low |
| Korrelationen | 7 | 3 | 2-3h | Medium |
| JSON/Markdown | 8 | 0 (formatting) | 0.5h | Low |
| **TOTAL** | **108** | **22** | **16-22h** | - |
---
## KI Layer Refactoring-Muster
**VORHER (Phase 0b):**
```python
def get_latest_weight(profile_id: str) -> str:
"""Returns latest weight with SQL + formatting"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT weight FROM weight_log
WHERE profile_id = %s
ORDER BY date DESC LIMIT 1
""", (profile_id,))
row = cur.fetchone()
if not row:
return "nicht verfügbar"
return f"{row['weight']:.1f} kg"
PLACEHOLDER_MAP = {
'{{weight_aktuell}}': get_latest_weight,
}
```
**NACHHER (Phase 0c):**
```python
from data_layer.body_metrics import get_weight_trend_data
def resolve_weight_aktuell(profile_id: str) -> str:
"""Returns latest weight (formatted for KI)"""
data = get_weight_trend_data(profile_id, days=7)
if data['confidence'] == 'insufficient':
return "nicht verfügbar"
return f"{data['last_value']:.1f} kg"
PLACEHOLDER_MAP = {
'{{weight_aktuell}}': resolve_weight_aktuell,
}
```
**Reduzierung:** Von ~15 Zeilen (SQL + Logic) zu ~7 Zeilen (Call + Format)
---
## Erwartetes Ergebnis nach Phase 0c
### Zeilen-Reduktion:
- **placeholder_resolver.py:**
- Vorher: ~1200 Zeilen
- Nachher: ~400 Zeilen (67% Reduktion)
### Code-Qualität:
- ✅ Keine SQL queries in placeholder_resolver.py
- ✅ Keine Berechnungslogik in placeholder_resolver.py
- ✅ Nur Formatierung für KI-Consumption
### Wiederverwendbarkeit:
- ✅ 22 Data Layer Funktionen nutzbar für:
- KI Layer (108 Platzhalter)
- Charts Layer (10+ Charts)
- API Endpoints (beliebig erweiterbar)
---
## Checkliste: Migration pro Platzhalter
Für jeden der **108 Platzhalter**:
```
[ ] Data Layer Funktion existiert
[ ] KI Layer ruft Data Layer Funktion auf
[ ] Formatierung für KI korrekt
[ ] Fehlerbehandlung (insufficient data)
[ ] Test: Platzhalter liefert gleichen Output wie vorher
[ ] In PLACEHOLDER_MAP registriert
[ ] Dokumentiert
```
---
**Erstellt:** 28. März 2026
**Status:** Ready for Phase 0c Implementation
**Nächster Schritt:** Data Layer Funktionen implementieren (Start mit utils.py)