feat: enhance formatting and normalization of activity metrics
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced `format_scalar_for_prompt_text` function to standardize the representation of scalar values in activity summaries and details.
- Updated `get_activity_summary` and `get_activity_detail` functions to utilize the new formatting for improved readability.
- Added normalization for float values in session metrics to prevent excessively long representations.
- Enhanced unit tests to verify the new formatting and normalization behavior.
This commit is contained in:
Lars 2026-04-18 10:32:29 +02:00
parent 6756dc60f3
commit 178534e9eb
5 changed files with 103 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@ -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 = [
{

View File

@ -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 = [
{