diff --git a/backend/data_layer/vitals_fitness_insights.py b/backend/data_layer/vitals_fitness_insights.py index 964a3af..b64caa0 100644 --- a/backend/data_layer/vitals_fitness_insights.py +++ b/backend/data_layer/vitals_fitness_insights.py @@ -83,6 +83,16 @@ def _trailing_window_means(vals: List[float], window: int = 7) -> List[float]: return out +def _de_num(x: float) -> str: + """Dezimalzahl mit Komma für Fließtext.""" + return f"{x:.1f}".replace(".", ",") + + +def _de_num_signed(x: float) -> str: + """Wie _de_num, mit explizitem Vorzeichen (für %-Abweichungen).""" + return f"{x:+.1f}".replace(".", ",") + + def _build_consolidated_paragraphs( series: Dict[str, Any], hrv_vs_baseline_pct: Optional[float], @@ -90,37 +100,33 @@ def _build_consolidated_paragraphs( r_pearson: Optional[float], pairs_n: int, ) -> List[str]: - """Eine zusammenhängende Einordnung statt vieler einzelner Karten zu Puls/HRV/Basis.""" + """ + Thematisch zusammengeführte Absätze — inhaltlich alle früheren Einzel-Karten (Bullets), + ohne die Aussagen zu streichen (Redundanz nur bei wörtlicher Doppelung vermeiden). + """ paras: List[str] = [] + # ── Referenzlage (HRV/Ruhepuls vs. ältere Basis), wie zuvor in KPI/Narrativ genutzt basis_bits: List[str] = [] if hrv_vs_baseline_pct is not None: basis_bits.append( - f"HRV liegt gegenüber der älteren Referenz bei {hrv_vs_baseline_pct:+.1f} %".replace(".", ",") + f"HRV liegt gegenüber der älteren Referenz bei {_de_num_signed(float(hrv_vs_baseline_pct))} %" ) if rhr_vs_baseline_pct is not None: basis_bits.append( - f"Ruhepuls relativ zur Referenz bei {rhr_vs_baseline_pct:+.1f} %".replace(".", ",") + f"Ruhepuls relativ zur Referenz bei {_de_num_signed(float(rhr_vs_baseline_pct))} %" ) if basis_bits: paras.append( " ".join(basis_bits) - + " (Vergleich kurzfristiges Mittel vs. ältere Basis — individuell interpretieren)." + + " — Vergleich kurzfristiges Mittel gegenüber älterer Basis; individuell interpretieren." ) rhr = series.get("resting_hr") hrv_s = series.get("hrv") - var_bits: List[str] = [] - if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 3: - var_bits.append(f"Ruhepuls Schwankungsbreite im Fenster etwa σ = {rhr['stdev']} bpm") - if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 3: - var_bits.append(f"HRV etwa σ = {hrv_s['stdev']} ms") - if var_bits: - paras.append( - "Einzelwerte können stark springen; die gestrichelte Linie zeigt einen gleitenden Mittelwert (max. 7 Messungen). " - + "Im Fenster: " + "; ".join(var_bits) + "." - ) + # ── Ruhepuls: letzte 7 Messungen vs. vorangehendes Fenster (wie frühere Karten) + rhr_short_compare = "" if rhr and rhr.get("points") and len(rhr["points"]) >= 10: pts = rhr["points"] last7 = [p["value"] for p in pts[-7:]] @@ -129,34 +135,86 @@ def _build_consolidated_paragraphs( m7 = statistics.mean(last7) mb = statistics.mean(before) diff = m7 - mb - if abs(diff) > 3: - paras.append( - f"Kurzfristig liegt der Ruhepuls im Mittel der letzten 7 Messungen " - f"{'über' if diff > 0 else 'unter'} dem vorherigen Fenster (Δ ca. {abs(diff):.1f} bpm) — Kontext: Belastung, Schlaf, Stress." + if diff > 3: + rhr_short_compare = ( + f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — " + "kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen." + ) + elif diff < -3: + rhr_short_compare = ( + "Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder " + "besserer Regeneration vereinbar (individuell)." ) + # ── Streuung: frühere Schwellen n ≥ 6 für die ausführlichen Varianz-Hinweise + rhr_var_sentence = "" + if ( + rhr + and rhr.get("stdev") is not None + and rhr.get("n", 0) >= 6 + ): + rhr_var_sentence = ( + f"Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen sind normal; " + "extreme Sprünge mit Kontext (Training, Schlaf) betrachten." + ) + + hrv_var_sentence = "" + if ( + hrv_s + and hrv_s.get("stdev") is not None + and hrv_s.get("n", 0) >= 6 + ): + hrv_var_sentence = ( + f"HRV schwankt im Fenster (σ ≈ {_de_num(float(hrv_s['stdev']))} ms). " + "Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte." + ) + + # Gestrichelte Linie = gleitender Mittelwert (neuer Kontext, ergänzt nicht ersetzt) + ma_hint = ( + "Einzelwerte können stark springen; die gestrichelte Linie im Diagramm zeigt einen gleitenden Mittelwert " + "über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)." + ) + + block_b_parts: List[str] = [] + if rhr_short_compare: + block_b_parts.append(rhr_short_compare) + if rhr_var_sentence: + block_b_parts.append(rhr_var_sentence) + if hrv_var_sentence: + block_b_parts.append(hrv_var_sentence) + if block_b_parts: + paras.append(ma_hint + " " + " ".join(block_b_parts)) + elif series: + # Kein Kurzvergleich/keine σ-Sätze, aber mindestens eine Vital-Zeitreihe: MA-Hinweis (Diagramm) + paras.append(ma_hint) + + # ── VO2max: Wortlaut wie in den früheren Bullet-Karten vo2 = series.get("vo2_max") if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None: s = vo2["slope_per_day"] if s > 0.002: paras.append( - "VO2max steigt im gewählten Fenster tendenziell — oft mit Trainingsreiz oder stabilen Messungen vereinbar." + "Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder " + "besserer Datenlage vereinbar." ) elif s < -0.002: paras.append( - "VO2max fällt im Fenster leicht — kann Pause, Krankheit oder Messrauschen widerspiegeln." + "VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen " + "entstehen; Verlauf beobachten." ) + # ── Belastung vs. Folge-Ruhepuls: frühere Formulierungen + r/n wo berechnet if r_pearson is not None and pairs_n >= 8: if r_pearson > 0.35: paras.append( - f"Korrelation Trainingsminuten (Tag) → Ruhepuls (Folgetag): r ≈ {r_pearson:.2f} (n = {pairs_n} Paare). " - "Höhere Belastung und etwas höherer Ruhepuls am nächsten Morgen kommen in den Daten häufig zusammen — kein Kausalbeweis." + "An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen " + "Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis). " + f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren." ) elif r_pearson < -0.25: paras.append( - f"Zwischen Tages-Belastung und Folge-Ruhepuls zeigt sich ein leicht negatives Zusammenspiel (r ≈ {r_pearson:.2f}, n = {pairs_n}). " - "Stark von Ausreißern und Datenlücken abhängig." + "Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem " + f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare." ) return [p for p in paras if p]