diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index 6894559..2681b68 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -13,9 +13,31 @@ from data_layer.activity_data_canon import ( ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM, ACTIVITY_MODULE_REGISTRY_FIELD_KEYS, ) +from data_layer.prompt_output_compact import normalize_prompt_number logger = logging.getLogger(__name__) + +def _normalize_metric_value_for_read(data_type: str, val: Any) -> Any: + """Lesepfad (Layer 1): keine unnötig langen Float-Strings für KI/UI (Issue 53 / Platzhalter).""" + if val is None: + return None + dt = (data_type or "").strip().lower() + if dt == "string": + return val + if dt == "boolean": + return bool(val) + if dt == "integer": + try: + if isinstance(val, bool): + return int(val) + return int(val) + except (TypeError, ValueError): + return normalize_prompt_number(val) + if dt == "float": + return normalize_prompt_number(val) + return normalize_prompt_number(val) + # Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System). ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset( { @@ -430,6 +452,8 @@ def merge_column_backed_and_eav_metrics( keys_handled.add(k) merged.sort(key=lambda x: x["key"]) + for m in merged: + m["value"] = _normalize_metric_value_for_read(m.get("data_type") or "", m.get("value")) return merged diff --git a/backend/data_layer/prompt_output_compact.py b/backend/data_layer/prompt_output_compact.py index d74994a..7949c6d 100644 --- a/backend/data_layer/prompt_output_compact.py +++ b/backend/data_layer/prompt_output_compact.py @@ -70,6 +70,31 @@ def compact_json_payload_for_prompts(obj: Any) -> Any: return normalize_prompt_number(obj) +def format_scalar_for_prompt_text(x: Any) -> str: + """ + Kurzdarstellung für Text-Platzhalter (activity_detail, Tabellen, …). + Nutzt dieselbe Komprimierung wie JSON (normalize_prompt_number). + """ + if x is None: + return "—" + if isinstance(x, bool): + return "ja" if x else "nein" + if isinstance(x, str): + return x + n = normalize_prompt_number(x) + if isinstance(n, bool): + return "ja" if n else "nein" + if isinstance(n, int) and not isinstance(n, bool): + return str(n) + if isinstance(n, float): + if not math.isfinite(n): + return str(n) + if abs(n - round(n)) < 1e-9: + return str(int(round(n))) + return str(n) + return str(n) + + def session_metrics_list_to_key_value_compact(metrics: list[Any] | None) -> dict[str, Any]: """ Session-Metriken für KI-JSON: nur key → Wert (keine wiederholten Namen/Beschreibungen). diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index bdb248f..8f8973d 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -28,6 +28,8 @@ from data_layer.nutrition_metrics import ( get_nutrition_days_data, get_protein_targets_data ) +from data_layer.prompt_output_compact import format_scalar_for_prompt_text + from data_layer.activity_metrics import ( get_activity_summary_data, get_activity_detail_data, @@ -350,7 +352,11 @@ def get_activity_summary(profile_id: str, days: int = 14) -> str: if data['confidence'] == 'insufficient': return f"Keine Aktivitäten in den letzten {days} Tagen" - return f"{data['activity_count']} Einheiten in {days} Tagen (Ø {data['avg_duration_min']} min/Einheit, {data['total_kcal']} kcal gesamt)" + return ( + f"{data['activity_count']} Einheiten in {days} Tagen (Ø " + f"{format_scalar_for_prompt_text(data['avg_duration_min'])} min/Einheit, " + f"{format_scalar_for_prompt_text(data['total_kcal'])} kcal gesamt)" + ) def calculate_age(dob) -> str: @@ -423,18 +429,23 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str: # Format as readable list (max 20 entries to avoid token bloat) lines = [] for activity in data["activities"][:20]: - hr_str = f", HF={activity['hr_avg']}" if activity.get("hr_avg") else "" + hr_str = ( + f", HF={format_scalar_for_prompt_text(activity['hr_avg'])}" + if activity.get("hr_avg") is not None + else "" + ) eav_parts = [] for m in activity.get("session_metrics") or []: k, v = m.get("key"), m.get("value") if k is None or v is None: continue label = m.get("name_de") or m.get("name_en") or k - eav_parts.append(f"{label} ({k})={v}") + eav_parts.append(f"{label} ({k})={format_scalar_for_prompt_text(v)}") eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else "" lines.append( f"{activity['date']}: {activity['activity_type']} " - f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str}{eav_str})" + f"({format_scalar_for_prompt_text(activity['duration_min'])}min, " + f"{format_scalar_for_prompt_text(activity['kcal_active'])}kcal{hr_str}{eav_str})" ) return "\n".join(lines) diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py index a2bc11a..0de2bdf 100644 --- a/backend/tests/test_activity_session_metrics.py +++ b/backend/tests/test_activity_session_metrics.py @@ -121,6 +121,38 @@ def test_merge_parameter_schema_includes_descriptions(): assert merged[0]["description_en"] == "5 min average power" +def test_merge_eav_float_value_normalized_no_long_tail(): + """Layer 1: lange Floats (z. B. kcal_per_km) für Lesepfad kompakt.""" + schema = [ + { + "training_parameter_id": 1, + "key": "kcal_per_km", + "data_type": "float", + "unit": "kcal/km", + "validation_rules": {}, + "source_field": None, + "name_de": "Kcal/km", + "name_en": "kcal/km", + "description_de": None, + "description_en": None, + "param_category": "performance", + } + ] + eav = [ + { + "training_parameter_id": 1, + "key": "kcal_per_km", + "data_type": "float", + "unit": "kcal/km", + "value": 51.5818181818181818, + } + ] + out = merge_column_backed_and_eav_metrics({}, schema, eav) + assert len(out) == 1 + v = out[0]["value"] + assert "581818" not in repr(v) + + def test_merge_column_backed_includes_human_labels_from_schema(): schema = [ { diff --git a/backend/tests/test_prompt_output_compact.py b/backend/tests/test_prompt_output_compact.py index cefae36..f00b627 100644 --- a/backend/tests/test_prompt_output_compact.py +++ b/backend/tests/test_prompt_output_compact.py @@ -5,6 +5,7 @@ import pytest from data_layer.prompt_output_compact import ( compact_float_for_prompt, compact_json_payload_for_prompts, + format_scalar_for_prompt_text, normalize_prompt_number, session_metrics_list_to_key_value_compact, ) @@ -38,6 +39,12 @@ def test_compact_json_nested(): assert out["d"][0] == 1.11 +def test_format_scalar_no_long_float_tail(): + s = format_scalar_for_prompt_text(51.5818181818181818) + assert "181818" not in s + assert len(s) <= 8 + + def test_session_metrics_key_value_only(): sm = [ {