feat: Enhance training parameters handling and documentation
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced new fields for descriptions in training parameters, improving clarity for AI context in `training_sessions_recent_json`.
- Added a glossary placeholder `{{training_parameters_glossary_md}}` to provide a Markdown table of active training parameters, including names and descriptions.
- Updated the `placeholder_resolver.py` and `activity_metrics.py` to support the new glossary functionality.
- Enhanced the `AdminActivityAttributeProfilesPage` to allow input for descriptions in both German and English, ensuring better context for metrics.
- Revised tests to validate the inclusion of description fields in parameter schema merges and metrics handling.
This commit is contained in:
Lars 2026-04-17 20:42:11 +02:00
parent c3be745efa
commit bc8e9fb7fa
8 changed files with 262 additions and 7 deletions

View File

@ -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}}`
---

View File

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

View File

@ -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"},
}

View File

@ -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
]

View File

@ -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()

View File

@ -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}}',

View File

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

View File

@ -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 <strong>055</strong> werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
<code>activity_log</code>-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
</li>
<li>
<strong>KI:</strong> Bei eigenen / unklaren Metriken kurze <strong>Beschreibung DE/EN</strong> im Katalog
pflegen sie erscheinen in Export/Platzhalter-Kontext (<code>training_sessions_recent_json</code>,{' '}
<code>{'{{training_parameters_glossary_md}}'}</code>).
</li>
</ul>
</div>
@ -394,6 +405,23 @@ export default function AdminActivityAttributeProfilesPage() {
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung DE / EN (optional, für KI)</label>
<textarea
className="form-input"
rows={2}
placeholder="Was bedeutet der Wert? Einheit/Skala?"
value={paramForm.description_de}
onChange={(e) => setParamForm((f) => ({ ...f, description_de: e.target.value }))}
/>
<textarea
className="form-input"
rows={2}
placeholder="Short meaning for prompts / EN users"
value={paramForm.description_en}
onChange={(e) => setParamForm((f) => ({ ...f, description_en: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe / Datentyp</label>
<select
@ -468,6 +496,21 @@ export default function AdminActivityAttributeProfilesPage() {
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung DE / EN (optional, für KI)</label>
<textarea
className="form-input"
rows={2}
value={editParam.description_de || ''}
onChange={(e) => setEditParam((p) => ({ ...p, description_de: e.target.value }))}
/>
<textarea
className="form-input"
rows={2}
value={editParam.description_en || ''}
onChange={(e) => setEditParam((p) => ({ ...p, description_en: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe / Typ</label>
<select