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.
This commit is contained in:
parent
df0165bee3
commit
42ae796448
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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}}',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user