Neue Docs
This commit is contained in:
parent
ffa99f10fb
commit
fb6d37ecfd
2130
docs/issues/issue-53-phase-0c-multi-layer-architecture.md
Normal file
2130
docs/issues/issue-53-phase-0c-multi-layer-architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
765
docs/issues/issue-54-dynamic-placeholder-system.md
Normal file
765
docs/issues/issue-54-dynamic-placeholder-system.md
Normal 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
|
||||
422
docs/phase-0c-placeholder-migration-analysis.md
Normal file
422
docs/phase-0c-placeholder-migration-analysis.md
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user