diff --git a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
index 4aacfbd..a516e2b 100644
--- a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
+++ b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
@@ -57,6 +57,8 @@
- Platzhalter / Charts, die Session-Details brauchen: **nur** diese Layer-1-Helfer erweitern oder aufrufen (z. B. `activity_metrics.get_training_sessions_recent_weeks_data` nutzt `enrich_sessions_with_metrics`).
- Router: `get_db`, `get_cursor`, Auth; Business-Validierung delegieren an `activity_session_metrics`.
+**KI-Kontext:** In `training_sessions_recent_json` enthält jedes Element von `session_metrics` neben `key`/`value` die Felder `name_de`, `name_en`, `description_de`, `description_en` (aus dem effektiven Schema). Für nicht selbsterklärende Keys soll im Katalog `training_parameters.description_*` gepflegt werden (Admin). Ergänzend liefert der Platzhalter `{{training_parameters_glossary_md}}` die gesamte aktive Parameter-Legende als Markdown-Tabelle (`get_training_parameters_ki_glossary_data` → `get_training_parameters_glossary_md`).
+
---
## 4. API (Ist / geplant)
@@ -137,6 +139,7 @@ Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_val
- Migration 004/014: `training_types`, `activity_log`-Erweiterungen
- Pattern Admin-Katalog: `routers/admin_reference_value_types.py`
- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data`
+- KI-Legende: `get_training_parameters_ki_glossary_data`, Platzhalter `{{training_parameters_glossary_md}}`
---
diff --git a/CLAUDE.md b/CLAUDE.md
index be58c60..7b3291c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -120,7 +120,7 @@ frontend/src/
- **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`).
- **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable).
-- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` (wenn befüllt).
+- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` inkl. `name_*` / `description_*`; **`{{training_parameters_glossary_md}}`** = Markdown-Legende aller aktiven Parameter (KI).
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
### Updates (16.04.2026 - Aktivität Phase A abgeschlossen, Phase B gestartet)
diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py
index cfdd940..9c27451 100644
--- a/backend/data_layer/activity_metrics.py
+++ b/backend/data_layer/activity_metrics.py
@@ -10,6 +10,7 @@ Functions:
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
- get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
+ - get_training_parameters_ki_glossary_data(): Parameter-Katalog (Feld, Namen, Beschreibungen) für KI
All functions return structured data (dict) without formatting.
Use placeholder_resolver.py for formatted strings for AI.
@@ -1179,3 +1180,32 @@ def get_training_sessions_recent_weeks_data(
},
}
)
+
+
+def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]:
+ """
+ Alle aktiven ``training_parameters`` für KI-Kontext (z. B. neben ``training_sessions_recent_json``).
+
+ Enthält technischen key, name_de/name_en, description_de/description_en, data_type, unit, category.
+
+ Args:
+ profile_id: Reserviert für spätere Einschränkung (z. B. nur im Profil vorkommende Keys);
+ aktuell ungenutzt, Signatur bleibt für Platzhalter-Resolver.
+ """
+ _ = profile_id
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT key, name_de, name_en, description_de, description_en,
+ data_type, unit, category
+ FROM training_parameters
+ WHERE is_active = true
+ ORDER BY category, key
+ """
+ )
+ rows = [r2d(r) for r in cur.fetchall()]
+ return {
+ "parameters": rows,
+ "meta": {"count": len(rows), "scope": "global_active_catalog"},
+ }
diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py
index fcd0c68..e354d89 100644
--- a/backend/data_layer/activity_session_metrics.py
+++ b/backend/data_layer/activity_session_metrics.py
@@ -71,6 +71,8 @@ def merge_parameter_schema_rows(
"key": r["key"],
"name_de": r["name_de"],
"name_en": r["name_en"],
+ "description_de": r.get("description_de"),
+ "description_en": r.get("description_en"),
"param_category": r["param_category"],
"data_type": r["data_type"],
"unit": r["unit"],
@@ -90,6 +92,8 @@ def merge_parameter_schema_rows(
"key": r["key"],
"name_de": r["name_de"],
"name_en": r["name_en"],
+ "description_de": r.get("description_de"),
+ "description_en": r.get("description_en"),
"param_category": r["param_category"],
"data_type": r["data_type"],
"unit": r["unit"],
@@ -133,7 +137,9 @@ def resolve_activity_attribute_schema(
tcp.sort_order AS cat_sort,
tcp.required AS cat_required,
tcp.ui_group AS cat_ui_group,
- tp.key, tp.name_de, tp.name_en, tp.category AS param_category,
+ tp.key, tp.name_de, tp.name_en,
+ tp.description_de, tp.description_en,
+ tp.category AS param_category,
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
FROM training_category_parameter tcp
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
@@ -151,7 +157,9 @@ def resolve_activity_attribute_schema(
ttp.sort_order AS typ_sort,
ttp.required AS typ_required,
ttp.ui_group AS typ_ui_group,
- tp.key, tp.name_de, tp.name_en, tp.category AS param_category,
+ tp.key, tp.name_de, tp.name_en,
+ tp.description_de, tp.description_en,
+ tp.category AS param_category,
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
FROM training_type_parameter ttp
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
@@ -164,6 +172,16 @@ def resolve_activity_attribute_schema(
return merge_parameter_schema_rows(category_rows, type_rows)
+def _metric_human_labels(schema_row: Mapping[str, Any]) -> Dict[str, Any]:
+ """Bezeichnung + Kurzbeschreibung aus training_parameters (KI / Export)."""
+ return {
+ "name_de": schema_row.get("name_de"),
+ "name_en": schema_row.get("name_en"),
+ "description_de": schema_row.get("description_de"),
+ "description_en": schema_row.get("description_en"),
+ }
+
+
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
@@ -357,6 +375,7 @@ def merge_column_backed_and_eav_metrics(
"data_type": dt,
"unit": unit,
"value": val,
+ **_metric_human_labels(s),
}
)
used_column = True
@@ -377,6 +396,7 @@ def merge_column_backed_and_eav_metrics(
"data_type": dt,
"unit": unit,
"value": val,
+ **_metric_human_labels(s),
}
)
keys_handled.add(k)
@@ -395,6 +415,7 @@ def merge_column_backed_and_eav_metrics(
"data_type": dt,
"unit": unit,
"value": val,
+ **_metric_human_labels(s),
}
)
keys_handled.add(k)
@@ -403,7 +424,9 @@ def merge_column_backed_and_eav_metrics(
pass
if k in eav_by_key:
- merged.append(dict(eav_by_key[k]))
+ row = dict(eav_by_key[k])
+ row.update(_metric_human_labels(s))
+ merged.append(row)
keys_handled.add(k)
merged.sort(key=lambda x: x["key"])
@@ -699,6 +722,15 @@ def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None:
eav_list = by_act.get(aid, [])
merged = merge_column_backed_and_eav_metrics(header, schema, eav_list)
s["session_metrics"] = [
- {"key": m["key"], "data_type": m["data_type"], "unit": m["unit"], "value": m["value"]}
+ {
+ "key": m["key"],
+ "data_type": m["data_type"],
+ "unit": m["unit"],
+ "value": m["value"],
+ "name_de": m.get("name_de"),
+ "name_en": m.get("name_en"),
+ "description_de": m.get("description_de"),
+ "description_en": m.get("description_en"),
+ }
for m in merged
]
diff --git a/backend/placeholder_registrations/activity_session_insights.py b/backend/placeholder_registrations/activity_session_insights.py
index 38fcd63..0e49eb9 100644
--- a/backend/placeholder_registrations/activity_session_insights.py
+++ b/backend/placeholder_registrations/activity_session_insights.py
@@ -143,7 +143,8 @@ def register_activity_session_insights():
"duration_min, kcal_active, hr_avg, hr_max, rpe, training_category, training_type_name, "
"session_metrics[]. "
"session_metrics: effektive Liste nach merge_column_backed_and_eav_metrics — Einträge mit "
- "training_parameter_id, key, data_type, unit, value; nur Parameter aus Attributschema "
+ "training_parameter_id, key, data_type, unit, value, name_de/name_en, description_de/description_en; "
+ "nur Parameter aus Attributschema "
"(training_category_parameter + training_type_parameter Overrides), keys sortiert. "
"Kanon Lesen: activity_log-Spalte vor EAV bei Konflikt. "
"meta: weeks_requested, days_loaded, session_count, confidence. "
@@ -170,6 +171,7 @@ def register_activity_session_insights():
"session_metrics oft [] (kein Typ, kein Profil, keine gespeicherten Werte). "
"Anzahl und Namen der Metrik-Keys sind instanz-/adminabhängig — JSON nicht als festes Schema "
"für Downstream-Parsing harter Logik verwenden. "
+ "Für KI-Semantik zusätzlich {{training_parameters_glossary_md}} (gesamter aktiver Katalog) in den Prompt legen. "
"Composite-Parameter (JSON in EAV) noch nicht im MVP expandiert; ggf. Roh-value_text in späterer Phase."
),
layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
@@ -192,5 +194,61 @@ def register_activity_session_insights():
_ev(pj, "known_limitations", EvidenceType.MIXED)
register_placeholder(pj)
+ md_gloss = PlaceholderMetadata(
+ key="training_parameters_glossary_md",
+ category="Aktivität",
+ description=(
+ "Markdown-Tabelle: alle aktiven training_parameters (key, DE/EN, Beschreibungen, Typ, Einheit, Kategorie). "
+ "Ergänzung zu training_sessions_recent_json für KI (Bedeutung dynamischer Metrik-Keys)."
+ ),
+ resolver_module="backend/placeholder_resolver.py",
+ resolver_function="get_training_parameters_glossary_md",
+ data_layer_module="backend/data_layer/activity_metrics.py",
+ data_layer_function="get_training_parameters_ki_glossary_data",
+ source_tables=["training_parameters"],
+ semantic_contract=(
+ "SELECT auf training_parameters WHERE is_active; sortiert category, key. "
+ "profile_id-Parameter im Resolver reserviert, aktuell globaler Katalog."
+ ),
+ business_meaning="KI: Legende zu session_metrics-Keys und Custom-Parametern",
+ unit="Markdown",
+ time_window="n/a (Katalog-Snapshot)",
+ output_type=OutputType.TEXT_SUMMARY,
+ placeholder_type=PlaceholderType.INTERPRETED,
+ format_hint="GitHub-Flavored Markdown-Tabelle",
+ example_output="| Feld (key) | DE | EN | Beschreibung DE | … |",
+ minimum_data_requirements="Optional leer → Kurztext statt Tabelle",
+ quality_filter_policy=None,
+ confidence_logic="Immer verfügbar wenn DB erreichbar",
+ missing_value_policy=MissingValuePolicy(
+ available=False,
+ value_raw=None,
+ missing_reason="no_data",
+ legacy_display="Keine aktiven Trainingsparameter im Katalog.",
+ ),
+ known_limitations=(
+ "Keine profil-spezifische Einschränkung auf tatsächlich genutzte Keys (V2). "
+ "Tabellen können bei großem Katalog lang werden."
+ ),
+ layer_1_decision="activity_metrics.get_training_parameters_ki_glossary_data",
+ layer_2a_decision="get_training_parameters_glossary_md",
+ layer_2b_reuse_possible=True,
+ architecture_alignment="Phase 0c",
+ issue_53_alignment="Layer 2a",
+ evidence={},
+ )
+ for f in (
+ "key", "category", "description", "resolver_module", "resolver_function",
+ "data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
+ "unit", "time_window", "output_type", "placeholder_type", "format_hint",
+ "example_output", "minimum_data_requirements", "confidence_logic",
+ "missing_value_policy", "layer_1_decision", "layer_2a_decision",
+ "layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
+ ):
+ _ev(md_gloss, f)
+ _ev(md_gloss, "business_meaning", EvidenceType.DRAFT_DERIVED)
+ _ev(md_gloss, "known_limitations", EvidenceType.MIXED)
+ register_placeholder(md_gloss)
+
register_activity_session_insights()
diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py
index 7e35202..6f635c2 100644
--- a/backend/placeholder_resolver.py
+++ b/backend/placeholder_resolver.py
@@ -35,6 +35,7 @@ from data_layer.activity_metrics import (
get_training_frequency_by_type_data,
get_training_inter_session_gap_data,
get_training_sessions_recent_weeks_data,
+ get_training_parameters_ki_glossary_data,
)
from data_layer.recovery_metrics import (
get_sleep_duration_data,
@@ -426,7 +427,8 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str:
k, v = m.get("key"), m.get("value")
if k is None or v is None:
continue
- eav_parts.append(f"{k}={v}")
+ label = m.get("name_de") or m.get("name_en") or k
+ eav_parts.append(f"{label} ({k})={v}")
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
lines.append(
f"{activity['date']}: {activity['activity_type']} "
@@ -456,6 +458,45 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
return ", ".join(parts)
+def get_training_parameters_glossary_md(profile_id: str) -> str:
+ """
+ Markdown-Tabelle: alle aktiven training_parameters (key, Namen, Beschreibungen, Typ, Einheit).
+ Für KI neben session_metrics / training_sessions_recent_json.
+ """
+ data = get_training_parameters_ki_glossary_data(profile_id)
+ params = data.get("parameters") or []
+ if not params:
+ return "Keine aktiven Trainingsparameter im Katalog."
+
+ def cell(x: object) -> str:
+ if x is None:
+ return "—"
+ return str(x).replace("|", "·").replace("\n", " ").strip()[:400]
+
+ lines = [
+ "| Feld (key) | DE | EN | Beschreibung DE | Beschreibung EN | Typ | Einheit | Kategorie |",
+ "|---|---|---|---|---|---|---|---|",
+ ]
+ for p in params:
+ lines.append(
+ "| "
+ + " | ".join(
+ [
+ cell(p.get("key")),
+ cell(p.get("name_de")),
+ cell(p.get("name_en")),
+ cell(p.get("description_de")),
+ cell(p.get("description_en")),
+ cell(p.get("data_type")),
+ cell(p.get("unit")),
+ cell(p.get("category")),
+ ]
+ )
+ + " |"
+ )
+ return "\n".join(lines)
+
+
def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str:
"""
Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min.
@@ -1545,6 +1586,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{training_frequency_by_type_md}}': get_training_frequency_by_type_md,
'{{training_inter_session_gap_md}}': get_training_inter_session_gap_md,
'{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid),
+ '{{training_parameters_glossary_md}}': get_training_parameters_glossary_md,
# Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores)
'{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
@@ -1749,6 +1791,7 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
'{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
+ '{{training_parameters_glossary_md}}',
],
'schlaf': [
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py
index 2178b0f..a2bc11a 100644
--- a/backend/tests/test_activity_session_metrics.py
+++ b/backend/tests/test_activity_session_metrics.py
@@ -97,6 +97,52 @@ def test_merge_type_overrides_required_and_sort():
assert merged[0]["required"] is True
+def test_merge_parameter_schema_includes_descriptions():
+ cat = [
+ {
+ "training_parameter_id": 1,
+ "cat_sort": 0,
+ "cat_required": False,
+ "cat_ui_group": None,
+ "key": "custom_w",
+ "name_de": "Leistung",
+ "name_en": "Watts",
+ "description_de": "Mittlere 5-Min-Leistung",
+ "description_en": "5 min average power",
+ "param_category": "performance",
+ "data_type": "integer",
+ "unit": "W",
+ "validation_rules": {},
+ "source_field": None,
+ }
+ ]
+ merged = merge_parameter_schema_rows(cat, [])
+ assert merged[0]["description_de"] == "Mittlere 5-Min-Leistung"
+ assert merged[0]["description_en"] == "5 min average power"
+
+
+def test_merge_column_backed_includes_human_labels_from_schema():
+ schema = [
+ {
+ "training_parameter_id": 1,
+ "key": "watts",
+ "data_type": "integer",
+ "unit": "W",
+ "validation_rules": {},
+ "source_field": "avg_power",
+ "name_de": "Leistung",
+ "name_en": "Power",
+ "description_de": "Gerätewert",
+ "description_en": "Device reading",
+ }
+ ]
+ out = merge_column_backed_and_eav_metrics({"avg_power": 200}, schema, [])
+ assert len(out) == 1
+ assert out[0]["value"] == 200
+ assert out[0]["name_de"] == "Leistung"
+ assert out[0]["description_en"] == "Device reading"
+
+
def test_merge_type_adds_parameter_not_in_category():
typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")]
merged = merge_parameter_schema_rows([], typ)
diff --git a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
index f0e21cd..89b0af2 100644
--- a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
+++ b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
@@ -10,6 +10,8 @@ const emptyParamForm = () => ({
key: '',
name_de: '',
name_en: '',
+ description_de: '',
+ description_en: '',
category: 'physical',
data_type: 'float',
unit: '',
@@ -130,6 +132,8 @@ export default function AdminActivityAttributeProfilesPage() {
key: paramForm.key.trim().toLowerCase(),
name_de: paramForm.name_de.trim(),
name_en: paramForm.name_en.trim(),
+ description_de: paramForm.description_de.trim() || null,
+ description_en: paramForm.description_en.trim() || null,
category: paramForm.category,
data_type: paramForm.data_type,
unit: paramForm.unit.trim() || null,
@@ -153,6 +157,8 @@ export default function AdminActivityAttributeProfilesPage() {
await api.adminUpdateTrainingParameter(editParam.id, {
name_de: editParam.name_de.trim(),
name_en: editParam.name_en.trim(),
+ description_de: editParam.description_de?.trim() || null,
+ description_en: editParam.description_en?.trim() || null,
category: editParam.category,
data_type: editParam.data_type,
unit: editParam.unit?.trim() || null,
@@ -302,6 +308,11 @@ export default function AdminActivityAttributeProfilesPage() {
Nach Migration 055 werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
activity_log-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
+
training_sessions_recent_json,{' '}
+ {'{{training_parameters_glossary_md}}'}).
+