feat: add reference values snapshot endpoints and data layer functions
All checks were successful
Deploy Development / deploy (push) Successful in 1m3s
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 `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.
This commit is contained in:
Lars 2026-04-19 10:52:31 +02:00
parent df0165bee3
commit 42ae796448
7 changed files with 359 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@ -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}}',
],

View File

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

View File

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

View File

@ -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' }),