From 42ae796448b220430fd3576459c3aaa079b42eda Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 19 Apr 2026 10:52:31 +0200 Subject: [PATCH] feat: add reference values snapshot endpoints and data layer functions - Introduced `get_profile_reference_values_current_snapshot` and `get_profile_reference_values_recent_snapshot` functions to retrieve current and recent reference values for profiles. - Updated the placeholder resolver to include new placeholders for current and recent reference values. - Added new API endpoints for fetching current and recent reference values snapshots. - Enhanced the frontend API utility to support the new snapshot endpoints. - Improved unit tests to validate the new data layer functions and their behavior. --- backend/data_layer/reference_values.py | 193 ++++++++++++++++++ backend/placeholder_registrations/__init__.py | 2 + .../profile_reference_values.py | 68 ++++++ backend/placeholder_resolver.py | 14 ++ backend/routers/reference_values.py | 44 ++++ .../tests/test_reference_values_data_layer.py | 29 ++- frontend/src/utils/api.js | 10 + 7 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 backend/placeholder_registrations/profile_reference_values.py diff --git a/backend/data_layer/reference_values.py b/backend/data_layer/reference_values.py index bc4f8b2..625cb44 100644 --- a/backend/data_layer/reference_values.py +++ b/backend/data_layer/reference_values.py @@ -9,11 +9,34 @@ Dates are normalized to ISO strings; Decimals to float — suitable for JSON/API from __future__ import annotations +from datetime import date from decimal import Decimal from typing import Any, Optional from db import get_cursor, get_db, r2d +# Spalten des Messwerts (ohne Typ-Metadaten) für Snapshot-Payloads / Platzhalter-JSON +_REFERENCE_ENTRY_KEYS = frozenset( + { + "id", + "profile_id", + "reference_value_type_id", + "effective_date", + "value_numeric", + "value_text", + "unit", + "source", + "confidence", + "method", + "notes", + "extra", + "created_at", + "updated_at", + "type_key", + "type_label", + } +) + def normalize_reference_row(d: Optional[dict[str, Any]]) -> dict[str, Any]: """Normalize DB row dict for JSON (dates → ISO, Decimal → float).""" @@ -177,3 +200,173 @@ def get_profile_reference_values_summary(profile_id: str) -> dict[str, Any]: tiles = build_summary_tiles_from_ranked_rows(raw_rows) return {"tiles": tiles} + + +def _entry_dict_from_ranked_row(d: dict[str, Any]) -> dict[str, Any]: + """Eintragsfelder inkl. type_key/type_label für KI-Kontext.""" + out = {k: d[k] for k in _REFERENCE_ENTRY_KEYS if k in d} + return normalize_reference_row(out) + + +def get_profile_reference_values_current_snapshot(profile_id: str) -> dict[str, Any]: + """ + Layer 1: Alle **aktuellen** Referenzwerte (jüngster Eintrag pro aktivem Typ), Katalog-Sortierung. + + Struktur: ``items`` = Liste mit ``type_key``, ``type_label``, ``value_data_type``, + ``type_sort_order``, ``latest`` (vollständiger Eintrag). + """ + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + WITH ranked AS ( + SELECT + v.id, + v.profile_id, + v.reference_value_type_id, + v.effective_date, + v.value_numeric, + v.value_text, + v.unit, + v.source, + v.confidence, + v.method, + v.notes, + v.extra, + v.created_at, + v.updated_at, + rt.key AS type_key, + rt.label AS type_label, + rt.sort_order AS type_sort_order, + rt.value_data_type, + ROW_NUMBER() OVER ( + PARTITION BY v.reference_value_type_id + ORDER BY v.effective_date DESC, v.created_at DESC + ) AS rn + FROM profile_reference_values v + JOIN reference_value_types rt ON rt.id = v.reference_value_type_id + WHERE v.profile_id = %s AND rt.active = TRUE + ) + SELECT * FROM ranked WHERE rn = 1 + ORDER BY type_sort_order ASC, type_key ASC + """, + (profile_id,), + ) + raw_rows = [r2d(r) for r in cur.fetchall()] + + items: list[dict[str, Any]] = [] + for row in raw_rows: + row.pop("rn", None) + vdt = (row.get("value_data_type") or "decimal").strip().lower() + latest = _entry_dict_from_ranked_row(row) + items.append( + { + "type_key": row.get("type_key"), + "type_label": row.get("type_label"), + "value_data_type": vdt, + "type_sort_order": int(row.get("type_sort_order") or 0), + "latest": latest, + } + ) + + return { + "schema": "profile_reference_values_current_v1", + "count": len(items), + "items": items, + } + + +def get_profile_reference_values_recent_snapshot( + profile_id: str, + *, + limit_per_type: int = 5, + date_from: Optional[date | str] = None, + date_to: Optional[date | str] = None, +) -> dict[str, Any]: + """ + Layer 1: Pro Referenztyp die **letzten N** Einträge (neueste zuerst), optional nach + ``effective_date`` gefiltert. + + ``date_from`` / ``date_to``: inclusive; als ``date`` oder ISO-``YYYY-MM-DD``-String. + """ + lim = max(1, min(int(limit_per_type), 50)) + + df = date_from + dt = date_to + if isinstance(df, str) and df.strip(): + df = date.fromisoformat(df.strip()) + elif df is not None and not isinstance(df, date): + df = None + if isinstance(dt, str) and dt.strip(): + dt = date.fromisoformat(dt.strip()) + elif dt is not None and not isinstance(dt, date): + dt = None + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + WITH filtered AS ( + SELECT + v.id, + v.profile_id, + v.reference_value_type_id, + v.effective_date, + v.value_numeric, + v.value_text, + v.unit, + v.source, + v.confidence, + v.method, + v.notes, + v.extra, + v.created_at, + v.updated_at, + rt.key AS type_key, + rt.label AS type_label, + rt.sort_order AS type_sort_order, + rt.value_data_type + FROM profile_reference_values v + JOIN reference_value_types rt ON rt.id = v.reference_value_type_id + WHERE v.profile_id = %s + AND rt.active = TRUE + AND (%s::date IS NULL OR v.effective_date >= %s::date) + AND (%s::date IS NULL OR v.effective_date <= %s::date) + ), + ranked AS ( + SELECT + f.*, + ROW_NUMBER() OVER ( + PARTITION BY f.reference_value_type_id + ORDER BY f.effective_date DESC, f.created_at DESC + ) AS rn + FROM filtered f + ) + SELECT * FROM ranked WHERE rn <= %s + ORDER BY type_sort_order ASC, type_key ASC, rn ASC + """, + (profile_id, df, df, dt, dt, lim), + ) + raw_rows = [r2d(r) for r in cur.fetchall()] + + by_type: dict[str, list[dict[str, Any]]] = {} + type_order: list[str] = [] + seen: set[str] = set() + + for row in raw_rows: + row.pop("rn", None) + tk = row.get("type_key") or "" + if tk not in seen: + seen.add(tk) + type_order.append(tk) + entry = _entry_dict_from_ranked_row(row) + by_type.setdefault(tk, []).append(entry) + + return { + "schema": "profile_reference_values_recent_v1", + "limit_per_type": lim, + "date_from": df.isoformat() if isinstance(df, date) else None, + "date_to": dt.isoformat() if isinstance(dt, date) else None, + "ordered_type_keys": type_order, + "by_type_key": by_type, + } diff --git a/backend/placeholder_registrations/__init__.py b/backend/placeholder_registrations/__init__.py index a70185a..4843949 100644 --- a/backend/placeholder_registrations/__init__.py +++ b/backend/placeholder_registrations/__init__.py @@ -19,6 +19,7 @@ from . import profil_zeitraum from . import phase_0b_meta_scores from . import phase_0b_ziele_fokus from . import korrelationen +from . import profile_reference_values __all__ = [ 'nutrition_part_a', @@ -35,4 +36,5 @@ __all__ = [ 'phase_0b_meta_scores', 'phase_0b_ziele_fokus', 'korrelationen', + 'profile_reference_values', ] diff --git a/backend/placeholder_registrations/profile_reference_values.py b/backend/placeholder_registrations/profile_reference_values.py new file mode 100644 index 0000000..41a3283 --- /dev/null +++ b/backend/placeholder_registrations/profile_reference_values.py @@ -0,0 +1,68 @@ +""" +Registry: Persönliche Referenzwerte (Profil) — Layer 1 reference_values, JSON-Platzhalter 2a. +""" + +from placeholder_registry import ( + PlaceholderMetadata, + MissingValuePolicy, + OutputType, + PlaceholderType, + register_placeholder, +) +from ._evidence import tag_standard_evidence + +CAT = "Referenzwerte" +MVP = lambda reason, disp: MissingValuePolicy( + available=False, value_raw=None, missing_reason=reason, legacy_display=disp +) + + +def register_profile_reference_values(): + for key, dl_fn, desc, sem in [ + ( + "reference_values_current_json", + "get_profile_reference_values_current_snapshot", + "JSON: aktuelle Referenzwerte (jüngster Eintrag pro Typ, Katalog-Reihenfolge)", + "reference_values.get_profile_reference_values_current_snapshot(profile_id)", + ), + ( + "reference_values_recent_json", + "get_profile_reference_values_recent_snapshot", + "JSON: Verlauf — bis zu 5 Einträge pro Referenztyp (neueste zuerst), optional Datumsfilter in Layer-1-API", + "reference_values.get_profile_reference_values_recent_snapshot(profile_id, limit_per_type=5)", + ), + ]: + m = PlaceholderMetadata( + key=key, + category=CAT, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_json", + data_layer_module="backend/data_layer/reference_values.py", + data_layer_function=dl_fn, + source_tables=["profile_reference_values", "reference_value_types"], + semantic_contract=sem, + business_meaning="Persönliche Referenzkennwerte für KI-Kontext (Messmethode, Vertrauen, Historie)", + unit="JSON", + time_window="aktuell bzw. letzte N Einträge pro Typ", + output_type=OutputType.JSON, + placeholder_type=PlaceholderType.RAW_DATA, + format_hint="JSON-String (schema *_v1)", + example_output='{"count":0,"items":[]}', + minimum_data_requirements="Keine Pflicht — leere Listen möglich", + quality_filter_policy=None, + confidence_logic="Rohdaten aus Erfassung (confidence-Feld pro Eintrag)", + missing_value_policy=MVP("optional_module", "{}"), + known_limitations="recent_json: fest 5 pro Typ im Platzhalter; Datumsfilter nur über API/Layer-1-Parameter", + layer_1_decision="data_layer.reference_values", + layer_2a_decision="_safe_json + compact_json_payload_for_prompts", + layer_2b_reuse_possible=True, + architecture_alignment="Issue 53 / Phase 0c Layer 1", + issue_53_alignment="Strukturierte L1-Daten für Prompts", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + +register_profile_reference_values() diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 4444254..9d6e826 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -51,6 +51,10 @@ from data_layer.health_metrics import ( ) from data_layer.prompt_output_compact import compact_json_payload_for_prompts +from data_layer.reference_values import ( + get_profile_reference_values_current_snapshot, + get_profile_reference_values_recent_snapshot, +) from placeholder_registry import build_ai_placeholder_caption, get_registry @@ -810,6 +814,8 @@ _SAFE_JSON_NONE_REASON: Dict[str, str] = { "active_goals_json": "Aktive Ziele als JSON nicht ermittelbar", "focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar", "focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar", + "reference_values_current_json": "Referenzwerte (aktuell) nicht ermittelbar", + "reference_values_recent_json": "Referenzwerte (Verlauf) nicht ermittelbar", } @@ -1025,6 +1031,8 @@ def _safe_json(func_name: str, profile_id: str) -> str: 'active_goals_json': lambda pid: _get_active_goals_json(pid), 'focus_areas_weighted_json': lambda pid: _get_focus_areas_weighted_json(pid), 'focus_area_weights_json': lambda pid: json.dumps(scores.get_user_focus_weights(pid), ensure_ascii=False), + 'reference_values_current_json': get_profile_reference_values_current_snapshot, + 'reference_values_recent_json': get_profile_reference_values_recent_snapshot, } func = func_map.get(func_name) @@ -1674,6 +1682,8 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { '{{focus_areas_weighted_json}}': lambda pid: _safe_json('focus_areas_weighted_json', pid), '{{focus_areas_weighted_md}}': lambda pid: _safe_str('focus_areas_weighted_md', pid), '{{focus_area_weights_json}}': lambda pid: _safe_json('focus_area_weights_json', pid), + '{{reference_values_current_json}}': lambda pid: _safe_json('reference_values_current_json', pid), + '{{reference_values_recent_json}}': lambda pid: _safe_json('reference_values_recent_json', pid), '{{top_3_focus_areas}}': lambda pid: _safe_str('top_3_focus_areas', pid), '{{top_3_goals_behind_schedule}}': lambda pid: _safe_str('top_3_goals_behind_schedule', pid), '{{top_3_goals_on_track}}': lambda pid: _safe_str('top_3_goals_on_track', pid), @@ -1823,6 +1833,10 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s 'zeitraum': [ '{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}' ], + 'referenzwerte': [ + '{{reference_values_current_json}}', + '{{reference_values_recent_json}}', + ], 'phase0b_meta': [ '{{goal_progress_score}}', '{{data_quality_score}}', ], diff --git a/backend/routers/reference_values.py b/backend/routers/reference_values.py index ddbfaa5..0efafc5 100644 --- a/backend/routers/reference_values.py +++ b/backend/routers/reference_values.py @@ -19,6 +19,8 @@ from psycopg2.extras import Json from auth import require_auth from data_layer.reference_values import ( fetch_reference_type_by_key, + get_profile_reference_values_current_snapshot, + get_profile_reference_values_recent_snapshot, get_profile_reference_values_summary, list_active_reference_value_types_data, list_profile_reference_values_for_type, @@ -93,6 +95,48 @@ def profile_reference_values_summary( return get_profile_reference_values_summary(pid) +@router.get("/profile-reference-values/snapshot-current") +def profile_reference_values_snapshot_current( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """ + Layer 1: Alle aktuellen Referenzwerte (jüngster Eintrag pro Typ), wie Platzhalter + ``{{reference_values_current_json}}``. + """ + pid = get_pid(x_profile_id) + return get_profile_reference_values_current_snapshot(pid) + + +@router.get("/profile-reference-values/snapshot-recent") +def profile_reference_values_snapshot_recent( + limit_per_type: int = Query(5, ge=1, le=50, description="Einträge pro Referenztyp (neueste zuerst)"), + date_from: Optional[str] = Query(None, description="Filter effective_date >= (YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter effective_date <= (YYYY-MM-DD)"), + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """ + Layer 1: Bis zu N Einträge pro Typ (Verlauf), wie Platzhalter ``{{reference_values_recent_json}}``. + """ + pid = get_pid(x_profile_id) + df = date_from + dto = date_to + for label, raw in (("date_from", df), ("date_to", dto)): + if raw is None or not str(raw).strip(): + continue + try: + datetime.strptime(str(raw).strip(), "%Y-%m-%d") + except ValueError: + raise HTTPException(400, f"{label}: Ungültiges Datum, Format YYYY-MM-DD") + return get_profile_reference_values_recent_snapshot( + pid, + limit_per_type=limit_per_type, + date_from=df, + date_to=dto, + ) + + @router.get("/profile-reference-values") def list_profile_reference_values( type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"), diff --git a/backend/tests/test_reference_values_data_layer.py b/backend/tests/test_reference_values_data_layer.py index aca4dd6..28b4740 100644 --- a/backend/tests/test_reference_values_data_layer.py +++ b/backend/tests/test_reference_values_data_layer.py @@ -1,6 +1,9 @@ """Unit tests for data_layer.reference_values (summary assembly, no DB).""" -from data_layer.reference_values import build_summary_tiles_from_ranked_rows +from datetime import date +from decimal import Decimal + +from data_layer.reference_values import _entry_dict_from_ranked_row, build_summary_tiles_from_ranked_rows def test_build_summary_tiles_single_type_two_rows(): @@ -46,3 +49,27 @@ def test_build_summary_tiles_multi_type_order(): ] tiles = build_summary_tiles_from_ranked_rows(raw) assert [x["type_key"] for x in tiles] == ["a", "b"] + + +def test_entry_dict_from_ranked_row_normalizes_decimal(): + row = { + "id": 1, + "profile_id": "p", + "reference_value_type_id": 9, + "effective_date": date(2026, 4, 1), + "value_numeric": Decimal("42.5"), + "value_text": None, + "unit": "bpm", + "source": "lab", + "confidence": "high", + "method": "spiro", + "notes": None, + "extra": None, + "created_at": date(2026, 4, 2), + "updated_at": date(2026, 4, 2), + "type_key": "hr_max", + "type_label": "HF max", + } + out = _entry_dict_from_ranked_row(row) + assert out["value_numeric"] == 42.5 + assert out["effective_date"] == "2026-04-01" diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 7cddb43..3dd10a2 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -102,6 +102,16 @@ export const api = { listProfileReferenceValues: (typeKey) => req(`/profile-reference-values?type_key=${encodeURIComponent(typeKey)}`), listProfileReferenceValuesSummary: () => req('/profile-reference-values/summary'), + /** Layer-1-Snapshots (gleiche Daten wie KI-Platzhalter reference_values_*_json) */ + listProfileReferenceValuesSnapshotCurrent: () => req('/profile-reference-values/snapshot-current'), + listProfileReferenceValuesSnapshotRecent: (opts = {}) => { + const p = new URLSearchParams() + if (opts.limit_per_type != null) p.set('limit_per_type', String(opts.limit_per_type)) + if (opts.date_from) p.set('date_from', opts.date_from) + if (opts.date_to) p.set('date_to', opts.date_to) + const q = p.toString() + return req(`/profile-reference-values/snapshot-recent${q ? `?${q}` : ''}`) + }, createProfileReferenceValue: (d) => req('/profile-reference-values', json(d)), updateProfileReferenceValue: (id, d) => req(`/profile-reference-values/${id}`, jput(d)), deleteProfileReferenceValue: (id) => req(`/profile-reference-values/${id}`, { method: 'DELETE' }), -- 2.43.0