diff --git a/.claude/docs/GITEA_ISSUES_INDEX.md b/.claude/docs/GITEA_ISSUES_INDEX.md index 3b711b6..7c44f43 100644 --- a/.claude/docs/GITEA_ISSUES_INDEX.md +++ b/.claude/docs/GITEA_ISSUES_INDEX.md @@ -1,6 +1,6 @@ # Gitea Issues – Landkarte (Auswertung) -**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-09** (Abfrage `state=all`, ergänzt: #71). +**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #76). **URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung. @@ -88,6 +88,7 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver | # | Titel | |---|--------| | 15 | [FEAT-002] Quality-Filter für KI-Auswertungen & Charts integrieren | +| 76 | Trainings-Qualität: zielbezogene Logik + Listen-Filter statt globalem „Hochwertig“-Hide | | 36 | BUG-009: Trainingstyp-Erstellung führt zu Internal Server Error | --- diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index dea96f0..4b57358 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -604,26 +604,23 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No from data_layer.scores import get_user_focus_weights focus_weights = get_user_focus_weights(profile_id) - # Activity-related focus areas (English keys from DB) - # Strength training - strength = focus_weights.get('strength', 0) - strength_endurance = focus_weights.get('strength_endurance', 0) - power = focus_weights.get('power', 0) + # Activity-related focus areas (English keys from DB); Gewichte float (kein Decimal×float) + strength = float(focus_weights.get('strength', 0) or 0) + strength_endurance = float(focus_weights.get('strength_endurance', 0) or 0) + power = float(focus_weights.get('power', 0) or 0) total_strength = strength + strength_endurance + power - # Endurance training - aerobic = focus_weights.get('aerobic_endurance', 0) - anaerobic = focus_weights.get('anaerobic_endurance', 0) - cardiovascular = focus_weights.get('cardiovascular_health', 0) + aerobic = float(focus_weights.get('aerobic_endurance', 0) or 0) + anaerobic = float(focus_weights.get('anaerobic_endurance', 0) or 0) + cardiovascular = float(focus_weights.get('cardiovascular_health', 0) or 0) total_cardio = aerobic + anaerobic + cardiovascular - # Mobility/Coordination - flexibility = focus_weights.get('flexibility', 0) - mobility = focus_weights.get('mobility', 0) - balance = focus_weights.get('balance', 0) - reaction = focus_weights.get('reaction', 0) - rhythm = focus_weights.get('rhythm', 0) - coordination = focus_weights.get('coordination', 0) + flexibility = float(focus_weights.get('flexibility', 0) or 0) + mobility = float(focus_weights.get('mobility', 0) or 0) + balance = float(focus_weights.get('balance', 0) or 0) + reaction = float(focus_weights.get('reaction', 0) or 0) + rhythm = float(focus_weights.get('rhythm', 0) or 0) + coordination = float(focus_weights.get('coordination', 0) or 0) total_ability = flexibility + mobility + balance + reaction + rhythm + coordination total_activity_weight = total_strength + total_cardio + total_ability diff --git a/backend/data_layer/body_metrics.py b/backend/data_layer/body_metrics.py index 4bfde2b..3fcbaba 100644 --- a/backend/data_layer/body_metrics.py +++ b/backend/data_layer/body_metrics.py @@ -445,12 +445,16 @@ def calculate_weight_7d_median(profile_id: str) -> Optional[float]: ORDER BY date DESC """, (profile_id,)) - weights = [row['weight'] for row in cur.fetchall()] + weights = [ + safe_float(row['weight']) + for row in cur.fetchall() + if row['weight'] is not None + ] if len(weights) < 4: # Need at least 4 measurements return None - return round(statistics.median(weights), 1) + return round(float(statistics.median(weights)), 1) def calculate_weight_28d_slope(profile_id: str) -> Optional[float]: @@ -478,7 +482,11 @@ def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]: ORDER BY date """, (profile_id, days)) - data = [(row['date'], row['weight']) for row in cur.fetchall()] + data = [ + (row['date'], safe_float(row['weight'])) + for row in cur.fetchall() + if row['weight'] is not None + ] # Need minimum data points based on period min_points = max(18, int(days * 0.6)) # 60% coverage @@ -488,21 +496,21 @@ def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]: # Convert dates to days since start start_date = data[0][0] x_values = [(date - start_date).days for date, _ in data] - y_values = [weight for _, weight in data] + y_values = [w for _, w in data] - # Linear regression + # Linear regression (alles float: PostgreSQL numeric → Decimal in Python) n = len(data) - x_mean = sum(x_values) / n - y_mean = sum(y_values) / n + x_mean = float(sum(x_values)) / n + y_mean = float(sum(y_values)) / n - numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values)) - denominator = sum((x - x_mean) ** 2 for x in x_values) + numerator = sum(float(x - x_mean) * float(y - y_mean) for x, y in zip(x_values, y_values)) + denominator = float(sum((x - x_mean) ** 2 for x in x_values)) if denominator == 0: return None slope = numerator / denominator - return round(slope, 4) # kg/day + return round(float(slope), 4) # kg/day def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]: @@ -594,19 +602,24 @@ def _calculate_body_composition_change(profile_id: str, metric: str, days: int) recent = data[0] oldest = data[-1] - # Calculate FM and LBM - recent_fm = recent['weight'] * (recent['bf_pct'] / 100) - recent_lbm = recent['weight'] - recent_fm + # Calculate FM and LBM (DB numeric → Decimal; für Regression/Scores nur float) + rw = float(safe_float(recent['weight']) or 0) + ob = float(safe_float(recent['bf_pct']) or 0) + ow = float(safe_float(oldest['weight']) or 0) + obf = float(safe_float(oldest['bf_pct']) or 0) - oldest_fm = oldest['weight'] * (oldest['bf_pct'] / 100) - oldest_lbm = oldest['weight'] - oldest_fm + recent_fm = rw * (ob / 100) + recent_lbm = rw - recent_fm + + oldest_fm = ow * (obf / 100) + oldest_lbm = ow - oldest_fm if metric == 'fm': change = recent_fm - oldest_fm else: change = recent_lbm - oldest_lbm - return round(change, 2) + return round(float(change), 2) # ── Circumference Calculations ────────────────────────────────────────────── @@ -731,9 +744,9 @@ def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict] from data_layer.scores import get_user_focus_weights focus_weights = get_user_focus_weights(profile_id) - weight_loss = focus_weights.get('weight_loss', 0) - muscle_gain = focus_weights.get('muscle_gain', 0) - body_recomp = focus_weights.get('body_recomposition', 0) + weight_loss = float(focus_weights.get('weight_loss', 0) or 0) + muscle_gain = float(focus_weights.get('muscle_gain', 0) or 0) + body_recomp = float(focus_weights.get('body_recomposition', 0) or 0) total_body_weight = weight_loss + muscle_gain + body_recomp diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 6c17cf0..7ce9fa7 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -844,14 +844,16 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N from data_layer.scores import get_user_focus_weights focus_weights = get_user_focus_weights(profile_id) - # Nutrition-related focus areas (English keys from DB) - protein_intake = focus_weights.get('protein_intake', 0) - calorie_balance = focus_weights.get('calorie_balance', 0) - macro_consistency = focus_weights.get('macro_consistency', 0) - meal_timing = focus_weights.get('meal_timing', 0) - hydration = focus_weights.get('hydration', 0) + # Nutrition-related focus areas (English keys from DB; Gewichte immer float) + protein_intake = float(focus_weights.get('protein_intake', 0) or 0) + calorie_balance = float(focus_weights.get('calorie_balance', 0) or 0) + macro_consistency = float(focus_weights.get('macro_consistency', 0) or 0) + meal_timing = float(focus_weights.get('meal_timing', 0) or 0) + hydration = float(focus_weights.get('hydration', 0) or 0) - total_nutrition_weight = protein_intake + calorie_balance + macro_consistency + meal_timing + hydration + total_nutrition_weight = ( + protein_intake + calorie_balance + macro_consistency + meal_timing + hydration + ) if total_nutrition_weight == 0: return None # No nutrition goals diff --git a/backend/data_layer/scores.py b/backend/data_layer/scores.py index eca5b1f..fd9dd56 100644 --- a/backend/data_layer/scores.py +++ b/backend/data_layer/scores.py @@ -224,8 +224,8 @@ def calculate_goal_progress_score(profile_id: str) -> Optional[int]: if total_weight == 0: return None - # Normalize to 0-100 - final_score = total_score / total_weight + # Normalize to 0-100 (Explizit float: Zwischensummen können Decimal aus DB sein) + final_score = float(total_score) / float(total_weight) return int(final_score) @@ -533,11 +533,19 @@ def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Option if not goals: return None - # Weighted average by contribution_weight (Numeric → float) - total_progress = sum( - float(g['progress_pct']) * float(g['contribution_weight']) for g in goals - ) - total_weight = sum(float(g['contribution_weight']) for g in goals) + # Weighted average; progress_pct darf NULL sein (Ziele ohne quantitative Berechnung) + parts: List[tuple] = [] + for g in goals: + pct = g['progress_pct'] + if pct is None: + continue + parts.append((float(pct), float(g['contribution_weight']))) + + if not parts: + return None + + total_progress = sum(p * w for p, w in parts) + total_weight = sum(w for _, w in parts) return int(total_progress / total_weight) if total_weight > 0 else None diff --git a/docs/README.md b/docs/README.md index 9f39c3f..d3c7aea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,7 @@ Dieser Ordner ist **immer mit Git versioniert**. Er ergänzt **`.claude/docs/`** | `issue-53-phase-0c-multi-layer-architecture.md` | | `issue-54-dynamic-placeholder-system.md` | | `issue-55-dynamic-aggregation-methods.md` | +| `issue-76-training-quality-goal-list-filter.md` | | `PHASE_PLAN_RESPONSIVE_UI.md` | | `REVIEW_OPEN_ISSUES_2026-04-04.md` | | `UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md` | diff --git a/docs/issues/issue-76-training-quality-goal-list-filter.md b/docs/issues/issue-76-training-quality-goal-list-filter.md new file mode 100644 index 0000000..9fe5344 --- /dev/null +++ b/docs/issues/issue-76-training-quality-goal-list-filter.md @@ -0,0 +1,49 @@ +# Issue #76: Trainings-Qualität – Zielbezug + Listen-Filter + +**Gitea:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/76 +**Status:** OFFEN +**Erstellt:** 11.04.2026 +**Typ:** Enhancement (Backend, Frontend, Produkt) +**Verwandt:** #31 (globaler Quality-Filter), #15 (Quality-Filter KI/Charts – historisch) + +--- + +## Kurzfassung + +Die heutige **`quality_label` / `quality_filter_level`**-Logik soll überdacht werden: nicht nur **Akzeptanz** (formales Label), sondern langfristig **Nutzen für die Zielerreichung**. Parallel: der **globale Profil-Filter** in der **Aktivitätsliste** versteckt u. a. **fehlende Einstufungen** (`NULL`) – das erschwert Datenpflege und Transparenz. + +--- + +## Ist-Zustand (technisch) + +| Bereich | Filter nach `quality_filter_level`? | +|---------|--------------------------------------| +| `GET` Aktivitätsliste (`routers/activity.py`) | Ja (`get_quality_filter_sql`) | +| KI-Rohkontext (`routers/insights.py`, `_get_profile_data`) | Ja (letzte 90 Zeilen `activity_log`) | +| Data-Layer / Platzhalter / Chart-Aggregate (`data_layer/activity_metrics.py`) | Nein – alle Einträge im Zeitfenster | + +Bei Stufen ≠ `all` schließt SQL-`IN` typischerweise **`NULL`** und nicht erlaubte Labels aus. + +--- + +## Ziele + +1. **Konzept „Qualität“:** Richtung Ziel-/Focus-/Phasenbezug; Umsetzung kann in Folge-Issues zerlegt werden. +2. **Listendarstellung:** Standard soll **alle** relevanten Einträge zeigen; **optionale lokale** Filter (inkl. „ohne Einstufung“), kein unbemerktes globales Ausblenden. +3. **KI/Insights:** Klarstellen, ob gefilterte vs. vollständige Aktivitätsmenge; ggf. konfigurierbar oder im Prompt kenntlich. +4. **Dokumentation:** Kurz festhalten, wo `get_quality_filter_sql` greift und wie das zu den Aggregationen passt. + +--- + +## Code-Referenzen + +- `backend/quality_filter.py` +- `backend/routers/activity.py` (`list_activity`) +- `backend/routers/insights.py` (`_get_profile_data`) +- `backend/data_layer/activity_metrics.py` + +--- + +## Akzeptanz (aus Gitea, Checkliste) + +Siehe Issue-Body in Gitea #76; bei Umsetzung dort Häkchen setzen und diese Datei ggf. um **Umsetzungsnotizen / Abnahme** ergänzen.