feat: add German number formatting functions and enhance narrative context in vital signs insights
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Introduced `_de_num` and `_de_num_signed` functions for formatting decimal numbers with a comma, improving text presentation in German.
- Updated `_build_consolidated_paragraphs` to utilize new formatting functions for HRV and resting heart rate comparisons, enhancing clarity in insights.
- Refined narrative descriptions for better contextual understanding of vital signs trends and their implications.
This commit is contained in:
Lars 2026-04-20 10:55:49 +02:00
parent 8cb5ad992f
commit ce84f330f0

View File

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