/home/agent/.hermes/repos/HealthManager /home/agent/.hermes/repos/HealthManager origin https://github.com/Gamexgit/HealthManager.git (fetch) origin https://github.com/Gamexgit/HealthManager.git (push) main M database/schema.sql M database/schema_manifest.json M scripts/health/assets/health-assets/dashboard-v5-capture.js M scripts/health/assets/health-assets/dashboard-v5-day-controller.js M scripts/health/assets/health-assets/dashboard-v5-record.js M scripts/health/assets/health-assets/dashboard-v5.js M scripts/health/dashboard_v5/capture_contract.py M scripts/health/dashboard_v5/medication_contract.py M scripts/health/dashboard_v5/medication_schema.py M scripts/health/dashboard_v5/read_api.py M scripts/health/health_dashboard_action_worker.py M scripts/health/health_dashboard_server.py M scripts/health/migrate_sprint7c_f_medication_schema.py M tests/fixtures/dashboard_v5_fixture.py M tests/test_dashboard_v5_sprint7c_f.py ?? docs/sprint7c-f1-capture-hotfix.md ?? scripts/health/migrate_sprint7c_f1_capture_hotfix.py ?? tests/test_dashboard_v5_sprint7c_f1.py diff --git a/scripts/health/assets/health-assets/dashboard-v5-capture.js b/scripts/health/assets/health-assets/dashboard-v5-capture.js index 78e0179..5ff2f7f 100644 --- a/scripts/health/assets/health-assets/dashboard-v5-capture.js +++ b/scripts/health/assets/health-assets/dashboard-v5-capture.js @@ -124,11 +124,12 @@ function closeCurrent(fromHistory = false) { if (hub.open) hub.close(); if (dialog.open) dialog.close(); state.files.forEach(item => URL.revokeObjectURL(item.url)); state.files = []; if (!fromHistory && new URL(location.href).searchParams.has('capture')) history.back(); } const launcher=document.querySelector('#open-capture-hub');if(launcher&&window.matchMedia('(max-width: 680px)').matches)document.body.append(launcher);launcher?.addEventListener('click', () => openHub()); document.querySelector('[data-day-capture]')?.addEventListener('click', () => openHub()); document.querySelectorAll('[data-close-dialog]').forEach(button => button.addEventListener('click', () => closeCurrent())); - hub.querySelectorAll('[data-capture-type]').forEach(button => button.addEventListener('click', async () => {if(['medication','supplement'].includes(button.dataset.captureType))await ensurePlans();openType(button.dataset.captureType)})); + function openMedicationRecord() { hub.close(); updateUrl(null,{replace:true}); if(typeof window.healthRecordOpenTab==='function')window.healthRecordOpenTab('medications'); else status.textContent='Die Medikationsakte ist technisch nicht verfügbar.'; } + hub.querySelectorAll('[data-capture-type]').forEach(button => button.addEventListener('click', async () => {if(button.dataset.captureType==='medication'){openMedicationRecord();return;}if(button.dataset.captureType==='supplement')await ensurePlans();openType(button.dataset.captureType)})); hub.querySelector('[data-capture-document]')?.addEventListener('click',()=>{hub.close();updateUrl(null,{replace:true});window.healthRecordOpenDocumentUpload?.();}); hub.querySelector('[data-open-full-checkin]')?.addEventListener('click', () => { hub.close(); const checkin=document.querySelector('#checkin-dialog'); if(checkin){document.body.append(checkin);checkin.showModal();} updateUrl(null, {replace:true}); }); - window.addEventListener('popstate', async () => { const capture = new URL(location.href).searchParams.get('capture'); closeCurrent(true); if (capture === 'menu') openHub(false); else if (labels[capture]) {if(['medication','supplement'].includes(capture))await ensurePlans();openType(capture, false);} }); - const initial = new URL(location.href).searchParams.get('capture'); if (initial === 'menu') openHub(false); else if (labels[initial]) {if(['medication','supplement'].includes(initial))ensurePlans().then(()=>openType(initial,false));else openType(initial, false);} + window.addEventListener('popstate', async () => { const capture = new URL(location.href).searchParams.get('capture'); closeCurrent(true); if (capture === 'menu') openHub(false); else if(capture==='medication')openMedicationRecord(); else if (labels[capture]) {if(capture==='supplement')await ensurePlans();openType(capture, false);} }); + const initial = new URL(location.href).searchParams.get('capture'); if (initial === 'menu') openHub(false); else if(initial==='medication')openMedicationRecord(); else if (labels[initial]) {if(initial==='supplement')ensurePlans().then(()=>openType(initial,false));else openType(initial, false);} function addSelectedFiles(input) { warning.textContent = ''; diff --git a/scripts/health/dashboard_v5/capture_contract.py b/scripts/health/dashboard_v5/capture_contract.py index 9d1c0ed..b0fc9e2 100644 --- a/scripts/health/dashboard_v5/capture_contract.py +++ b/scripts/health/dashboard_v5/capture_contract.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import Any from zoneinfo import ZoneInfo -from dashboard_v5.medication_contract import ACTION_CONTRACT_VERSION, validate_action_data +from dashboard_v5.medication_contract import ACTION_CONTRACT_VERSIONS, validate_action_data CONTRACT_VERSION = 1 TIMEZONE = ZoneInfo("Europe/Zurich") @@ -229,7 +229,7 @@ def validate_capture_payload(payload: Any) -> dict[str, Any]: "body_region": _text(data["body_region"], 80), "ongoing": data["ongoing"], } - elif capture_type == "medication" and data.get("contract") == ACTION_CONTRACT_VERSION: + elif capture_type == "medication" and data.get("contract") in ACTION_CONTRACT_VERSIONS: normalized = validate_action_data(data) elif capture_type in {"medication", "supplement"}: expected = common | { @@ -379,7 +379,7 @@ def validate_capture_payload(payload: Any) -> dict[str, Any]: "body_region": _text(data["body_region"], 80), "description": _text(data["description"], 160), } - if not (capture_type == "medication" and normalized.get("contract") == ACTION_CONTRACT_VERSION): + if not (capture_type == "medication" and normalized.get("contract") in ACTION_CONTRACT_VERSIONS): normalized["title"] = _text(data["title"], 100, required=capture_type == "event") normalized["note"] = _text(data["note"], 500) if capture_type == "event" and normalized.get("event_kind") in {"sauna", "training"}: diff --git a/scripts/health/dashboard_v5/medication_contract.py b/scripts/health/dashboard_v5/medication_contract.py index 1d45091..0f044dd 100644 --- a/scripts/health/dashboard_v5/medication_contract.py +++ b/scripts/health/dashboard_v5/medication_contract.py @@ -9,7 +9,9 @@ import sqlite3 from typing import Any, Mapping PUBLIC_CONTRACT_VERSION = "health.medication_history.v1" -ACTION_CONTRACT_VERSION = "health.medication_action.v1" +ACTION_CONTRACT_VERSION_V1 = "health.medication_action.v1" +ACTION_CONTRACT_VERSION = "health.medication_action.v2" +ACTION_CONTRACT_VERSIONS = frozenset({ACTION_CONTRACT_VERSION_V1, ACTION_CONTRACT_VERSION}) ROUTES = frozenset( { "oral", @@ -57,6 +59,12 @@ EVENT_FIELDS = ( "corrected_target_status", "correction_reason", "business_revision", + "planned_quantity_value", + "planned_dosage_form", + "planned_strength", + "actual_quantity_value", + "actual_dosage_form", + "actual_strength", ) PRESCRIPTION_FIELDS = ( "id", @@ -68,9 +76,24 @@ PRESCRIPTION_FIELDS = ( "prescription_status_source", "prescription_status_provenance", "business_revision", + "administration_preset_quantity_value", + "administration_preset_dosage_form", + "administration_preset_strength", + "administration_preset_route_original", + "administration_preset_route", + "administration_preset_source", + "administration_preset_provenance", + "administration_preset_revision", ) +class MedicationFieldError(ValueError): + def __init__(self, field: str, code: str = "invalid") -> None: + super().__init__(f"invalid medication field: {field}") + self.field = field + self.code = code + + def _canonical(value: Any) -> str: return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) @@ -116,6 +139,65 @@ def event_ref(row: sqlite3.Row | Mapping[str, Any], key: bytes) -> str: )[:24] +def prescription_preset( + row: sqlite3.Row | Mapping[str, Any], key: bytes +) -> dict[str, str] | None: + fields = ( + "administration_preset_quantity_value", + "administration_preset_dosage_form", + "administration_preset_strength", + "administration_preset_source", + "administration_preset_provenance", + "administration_preset_revision", + ) + if any(not _value(row, field) for field in fields): + return None + route = str(_value(row, "administration_preset_route") or "") + route_original = str(_value(row, "administration_preset_route_original") or "") + if route and (route not in ROUTES or not route_original): + return None + revision = str(_value(row, "administration_preset_revision")) + if not REVISION_RE.fullmatch(revision): + return None + return { + "quantity_value": str(_value(row, "administration_preset_quantity_value")), + "dosage_form": str(_value(row, "administration_preset_dosage_form")), + "strength": str(_value(row, "administration_preset_strength")), + "route_original": route_original, + "route_normalized": route, + "preset_revision": _opaque_digest( + key, + "health-medication-administration-preset-v1", + [revision, _value(row, "id"), _value(row, "medikament_name")], + ), + } + + +def planned_event_preset( + row: sqlite3.Row | Mapping[str, Any], key: bytes +) -> dict[str, str] | None: + values = tuple( + str(_value(row, field) or "") + for field in ("planned_quantity_value", "planned_dosage_form", "planned_strength") + ) + if not all(values): + return None + route = str(_value(row, "route_normalized") or "") + if route and route not in ROUTES: + return None + return { + "quantity_value": values[0], + "dosage_form": values[1], + "strength": values[2], + "route_normalized": route, + "preset_revision": _opaque_digest( + key, + "health-medication-planned-administration-preset-v1", + [event_revision(row), event_ref(row, key)], + ), + } + + def _available_fields(connection: sqlite3.Connection, table: str, fields: tuple[str, ...]) -> tuple[str, ...]: actual = {str(row[1]) for row in connection.execute(f'PRAGMA table_info("{table}")')} return tuple(field for field in fields if field in actual) @@ -293,15 +375,67 @@ def resolve_action_preview( and prescription["prescription_status_provenance"] and prescription["business_revision"] ) - if data["status"] != "corrected" and not trusted_active: + is_v2 = data.get("contract") == ACTION_CONTRACT_VERSION + if is_v2: + if data["mode"] == "planned" and not trusted_active: + raise RuntimeError("planned administration requires an explicitly active prescription") + elif data["status"] != "corrected" and not trusted_active: raise RuntimeError("medication prescription is not explicitly active") planned = resolve_event(connection, data["planned_event_ref"]) if data["planned_event_ref"] else None - target = resolve_event(connection, data["correction_target_ref"]) if data["correction_target_ref"] else None + target_ref = data.get("correction_target_ref", "") + target = resolve_event(connection, target_ref) if target_ref else None + if is_v2: + if data["mode"] == "historical" and planned is not None: + raise RuntimeError("historical administration cannot consume a plan") + if data["mode"] == "planned" and planned is None: + raise RuntimeError("planned administration requires a real plan") if planned is not None and str(planned["event_type"] or "").strip().casefold() != "planned": raise RuntimeError("planned event reference changed") consumed = plan_is_consumed(connection, int(planned["id"])) if planned is not None else False if consumed: raise RuntimeError("planned medication event is already consumed") + if is_v2: + key = public_identity_key(connection) + preset = prescription_preset(prescription, key) + baseline: tuple[str, str, str, str] | None = None + expected_revision = "" + if data["mode"] == "planned" and planned is not None: + baseline_values = tuple( + str(_value(planned, field) or "") + for field in ( + "planned_quantity_value", "planned_dosage_form", + "planned_strength", "route_normalized", + ) + ) + if not all(baseline_values[:3]): + raise RuntimeError("planned medication event lacks a structured administration preset") + baseline = ( + baseline_values[0], baseline_values[1], + baseline_values[2], baseline_values[3], + ) + expected_revision = _opaque_digest( + key, + "health-medication-planned-administration-preset-v1", + [event_revision(planned), event_ref(planned, key)], + ) + elif preset is not None: + baseline = ( + preset["quantity_value"], preset["dosage_form"], + preset["strength"], preset["route_normalized"], + ) + expected_revision = preset["preset_revision"] + if baseline is None: + if data["preset_revision"]: + raise RuntimeError("administration preset is no longer available") + else: + if not hmac.compare_digest(expected_revision, data["preset_revision"]): + raise RuntimeError("administration preset changed") + actual = ( + data["quantity_value"], data["dosage_form"], + data["strength"], data["route_normalized"], + ) + if actual != baseline and not data["deviation_confirmed"]: + raise RuntimeError("administration differs from verified preset") if target is not None and connection.execute( "SELECT 1 FROM medication_administrations WHERE corrects_event_id=? LIMIT 1", (int(target["id"]),), @@ -334,13 +468,22 @@ def _text(value: Any, maximum: int, *, required: bool = False) -> str: if ( (required and not cleaned) or len(cleaned) > maximum - or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\|\.hermes", cleaned, re.I) + or re.search(r"[\x00-\x1f\x7f]|https?://|\\|\.hermes", cleaned, re.I) + or cleaned.startswith("/") + or re.search(r"(?:^|/)\.\.(?:/|$)", cleaned) ): raise ValueError("invalid medication text") return cleaned -def validate_action_data(data: Any) -> dict[str, Any]: +def _field_text(field: str, value: Any, maximum: int, *, required: bool = False) -> str: + try: + return _text(value, maximum, required=required) + except ValueError as error: + raise MedicationFieldError(field) from error + + +def _validate_action_data_v1(data: Any) -> dict[str, Any]: required = { "contract", "status", @@ -366,7 +509,7 @@ def validate_action_data(data: Any) -> dict[str, Any]: "deviation_confirmed", "duplicate_confirmed", } - if not isinstance(data, dict) or set(data) != required or data.get("contract") != ACTION_CONTRACT_VERSION: + if not isinstance(data, dict) or set(data) != required or data.get("contract") != ACTION_CONTRACT_VERSION_V1: raise ValueError("invalid medication action shape") status = data["status"] if status not in STATUSES: @@ -425,7 +568,7 @@ def validate_action_data(data: Any) -> dict[str, Any]: elif actual_value or actual_unit or planned_value or planned_unit: raise ValueError("missed or unknown correction cannot assert dose values") return { - "contract": ACTION_CONTRACT_VERSION, + "contract": ACTION_CONTRACT_VERSION_V1, "status": status, "medication_ref": medication, "planned_event_ref": planned, @@ -449,3 +592,74 @@ def validate_action_data(data: Any) -> dict[str, Any]: "deviation_confirmed": data["deviation_confirmed"], "duplicate_confirmed": data["duplicate_confirmed"], } + + +def _validate_action_data_v2(data: Any) -> dict[str, Any]: + required = { + "contract", "mode", "status", "medication_ref", "planned_event_ref", + "name", "quantity_value", "dosage_form", "strength", "route_original", + "route_normalized", "injection_region", "injection_side", "injection_detail", + "note", "preset_revision", "preview_revision", "deviation_confirmed", + "duplicate_confirmed", + } + if not isinstance(data, dict) or set(data) != required or data.get("contract") != ACTION_CONTRACT_VERSION: + raise ValueError("invalid medication action shape") + if data["mode"] not in {"historical", "planned"}: + raise ValueError("invalid medication capture mode") + if data["status"] != "administered": + raise ValueError("historical hotfix accepts documented administrations only") + medication = data["medication_ref"] + planned = data["planned_event_ref"] + if not isinstance(medication, str) or not OPAQUE_RE.fullmatch(medication) or not medication.startswith("medrx_"): + raise ValueError("invalid medication reference") + if planned and (not isinstance(planned, str) or not OPAQUE_RE.fullmatch(planned) or not planned.startswith("medevt_")): + raise ValueError("invalid medication event reference") + if data["mode"] == "historical" and planned: + raise ValueError("historical administration cannot reference a plan") + if data["mode"] == "planned" and not planned: + raise ValueError("planned administration requires a real plan") + for field in ("preview_revision", "preset_revision"): + value = data[field] + if value and (not isinstance(value, str) or not REVISION_RE.fullmatch(value)): + raise ValueError(f"invalid {field.replace('_', ' ')}") + route = data["route_normalized"] + side = data["injection_side"] + if route not in ROUTES or side not in (SIDES | {""}): + raise ValueError("invalid medication route or side") + quantity = _field_text("quantity_value", data["quantity_value"], 40, required=True) + dosage_form = _field_text("dosage_form", data["dosage_form"], 40, required=True) + strength = _field_text("strength", data["strength"], 80, required=True) + injection_region = _field_text("injection_region", data["injection_region"], 80) + injection_detail = _field_text("injection_detail", data["injection_detail"], 120) + if (injection_region or side or injection_detail) and route not in INJECTION_ROUTES: + raise ValueError("injection details require injection route") + for flag in ("deviation_confirmed", "duplicate_confirmed"): + if type(data[flag]) is not bool: + raise ValueError("invalid medication confirmation") + return { + "contract": ACTION_CONTRACT_VERSION, + "mode": data["mode"], + "status": "administered", + "medication_ref": medication, + "planned_event_ref": planned, + "name": _field_text("medication", data["name"], 120, required=True), + "quantity_value": quantity, + "dosage_form": dosage_form, + "strength": strength, + "route_original": _field_text("route_original", data["route_original"], 60), + "route_normalized": route, + "injection_region": injection_region, + "injection_side": side, + "injection_detail": injection_detail, + "note": _field_text("note", data["note"], 300), + "preset_revision": data["preset_revision"], + "preview_revision": data["preview_revision"], + "deviation_confirmed": data["deviation_confirmed"], + "duplicate_confirmed": data["duplicate_confirmed"], + } + + +def validate_action_data(data: Any) -> dict[str, Any]: + if isinstance(data, dict) and data.get("contract") == ACTION_CONTRACT_VERSION_V1: + return _validate_action_data_v1(data) + return _validate_action_data_v2(data) diff --git a/scripts/health/health_dashboard_action_worker.py b/scripts/health/health_dashboard_action_worker.py index 5efcc3a..f8662fe 100644 --- a/scripts/health/health_dashboard_action_worker.py +++ b/scripts/health/health_dashboard_action_worker.py @@ -35,7 +35,7 @@ try: from dashboard_v5.capture_contract import validate_capture_payload from dashboard_v5.medication_schema import assert_schema as assert_medication_schema from dashboard_v5.medication_contract import ( - ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + ACTION_CONTRACT_VERSIONS as MEDICATION_ACTION_CONTRACTS, resolve_action_preview, ) from dashboard_v5.capture_media import cleanup_expired, promote_attachment @@ -92,7 +92,7 @@ except ModuleNotFoundError: # direct importlib fixture execution from dashboard_v5.capture_contract import validate_capture_payload from dashboard_v5.medication_schema import assert_schema as assert_medication_schema from dashboard_v5.medication_contract import ( - ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + ACTION_CONTRACT_VERSIONS as MEDICATION_ACTION_CONTRACTS, resolve_action_preview, ) from dashboard_v5.capture_media import cleanup_expired, promote_attachment @@ -1043,7 +1043,7 @@ def apply_capture_action(payload: dict[str, Any]) -> str: str(row[0]) for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") } - if payload["capture_type"] == "medication" and data.get("contract") == MEDICATION_ACTION_CONTRACT: + if payload["capture_type"] == "medication" and data.get("contract") in MEDICATION_ACTION_CONTRACTS: assert_medication_schema(connection) expected_revision, prescription, planned, correction_target = resolve_action_preview( connection, payload @@ -1051,28 +1051,47 @@ def apply_capture_action(payload: dict[str, Any]) -> str: if not hmac.compare_digest(expected_revision, data["preview_revision"]): raise RuntimeError("stale medication preview revision") medication_id = int(prescription["id"]) - duplicate = connection.execute( - """SELECT 1 FROM medication_administrations - WHERE business_revision IS NOT NULL AND medication_id=? - AND COALESCE(planned_event_id,-1)=COALESCE(?,-1) - AND COALESCE(occurred_at,'')=? - AND lower(trim(COALESCE(event_type,'')))=? - AND COALESCE(actual_dose_value,'')=? - AND COALESCE(actual_dose_unit,'')=? LIMIT 1""", - ( - medication_id, - int(planned["id"]) if planned is not None else None, - payload["occurred_at"], - data["status"], - data["actual_dose_value"], - data["actual_dose_unit"], - ), - ).fetchone() + if data["contract"] == "health.medication_action.v2": + duplicate = connection.execute( + """SELECT 1 FROM medication_administrations + WHERE business_revision IS NOT NULL AND medication_id=? + AND COALESCE(planned_event_id,-1)=COALESCE(?,-1) + AND COALESCE(occurred_at,'')=? + AND lower(trim(COALESCE(event_type,'')))=? + AND COALESCE(actual_quantity_value,'')=? + AND COALESCE(actual_dosage_form,'')=? + AND COALESCE(actual_strength,'')=? LIMIT 1""", + ( + medication_id, + int(planned["id"]) if planned is not None else None, + payload["occurred_at"], data["status"], + data["quantity_value"], data["dosage_form"], data["strength"], + ), + ).fetchone() + else: + duplicate = connection.execute( + """SELECT 1 FROM medication_administrations + WHERE business_revision IS NOT NULL AND medication_id=? + AND COALESCE(planned_event_id,-1)=COALESCE(?,-1) + AND COALESCE(occurred_at,'')=? + AND lower(trim(COALESCE(event_type,'')))=? + AND COALESCE(actual_dose_value,'')=? + AND COALESCE(actual_dose_unit,'')=? LIMIT 1""", + ( + medication_id, + int(planned["id"]) if planned is not None else None, + payload["occurred_at"], data["status"], + data["actual_dose_value"], data["actual_dose_unit"], + ), + ).fetchone() if duplicate and not data["duplicate_confirmed"]: raise RuntimeError("possible duplicate medication event") medication_context = { "medication_id": medication_id, "planned_event_id": int(planned["id"]) if planned is not None else None, + "planned_quantity_value": str(planned["planned_quantity_value"] or "") if planned is not None else "", + "planned_dosage_form": str(planned["planned_dosage_form"] or "") if planned is not None else "", + "planned_strength": str(planned["planned_strength"] or "") if planned is not None else "", "corrects_event_id": int(correction_target["id"]) if correction_target is not None else None, } elif payload["capture_type"] == "medication" and "medication_administrations" in known_tables: @@ -1093,7 +1112,11 @@ def apply_capture_action(payload: dict[str, Any]) -> str: } if data["name"] not in known_supplements: raise RuntimeError("supplement is not in the known exact plan") - if payload["capture_type"] in {"medication", "supplement"} and data["status"] == "administered": + if ( + payload["capture_type"] in {"medication", "supplement"} + and data["status"] == "administered" + and data.get("contract") != "health.medication_action.v2" + ): if not (data["plan_value_confirmed"] or data["deviation_confirmed"]): raise RuntimeError("planned administration requires conscious confirmation") if payload["capture_type"] == "measurement": @@ -1223,44 +1246,57 @@ def apply_capture_action(payload: dict[str, Any]) -> str: ) elif payload["capture_type"] == "medication" and "medication_administrations" in available_tables: if medication_context is not None: - actual_dose = " ".join( - part for part in (data["actual_dose_value"], data["actual_dose_unit"]) if part - ) - legacy_route = data["route_original"] or data["route_normalized"] - connection.execute( - """INSERT INTO medication_administrations( - datum,medication_name,dose,route,event_type,scheduled_next_date,notes,source,occurred_at, - medication_id,planned_event_id,planned_dose_value,planned_dose_unit,actual_dose_value, - actual_dose_unit,route_original,route_normalized,injection_region,injection_side, - injection_detail,lot_number,corrects_event_id,corrected_target_status,correction_reason, - business_revision) VALUES(?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - day, - data["name"], - actual_dose or None, - legacy_route or None, - data["status"], - data["note"] or None, - "dashboard_v5_medication_action", - payload["occurred_at"], - medication_context["medication_id"], - medication_context["planned_event_id"], - data["planned_dose_value"] or None, - data["planned_dose_unit"] or None, - data["actual_dose_value"] or None, - data["actual_dose_unit"] or None, - data["route_original"] or None, - data["route_normalized"] or None, - data["injection_region"] or None, - data["injection_side"] or None, - data["injection_detail"] or None, - data["lot_number"] or None, - medication_context["corrects_event_id"], - data["corrected_target_status"] or None, - data["correction_reason"] or None, - action_hash, - ), - ) + if data["contract"] == "health.medication_action.v2": + legacy_dose = f"{data['quantity_value']} {data['dosage_form']} · {data['strength']}" + legacy_route = data["route_original"] or data["route_normalized"] + connection.execute( + """INSERT INTO medication_administrations( + datum,medication_name,dose,route,event_type,scheduled_next_date,notes,source,occurred_at, + medication_id,planned_event_id,planned_quantity_value,planned_dosage_form, + planned_strength,route_original,route_normalized,injection_region, + injection_side,injection_detail,business_revision,actual_quantity_value, + actual_dosage_form,actual_strength) + VALUES(?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + day, data["name"], legacy_dose, legacy_route or None, + data["status"], data["note"] or None, + "dashboard_medication_action_version_two", payload["occurred_at"], + medication_context["medication_id"], medication_context["planned_event_id"], + medication_context["planned_quantity_value"] or None, + medication_context["planned_dosage_form"] or None, + medication_context["planned_strength"] or None, + data["route_original"] or None, data["route_normalized"] or None, + data["injection_region"] or None, data["injection_side"] or None, + data["injection_detail"] or None, action_hash, + data["quantity_value"], data["dosage_form"], data["strength"], + ), + ) + else: + actual_dose = " ".join( + part for part in (data["actual_dose_value"], data["actual_dose_unit"]) if part + ) + legacy_route = data["route_original"] or data["route_normalized"] + connection.execute( + """INSERT INTO medication_administrations( + datum,medication_name,dose,route,event_type,scheduled_next_date,notes,source,occurred_at, + medication_id,planned_event_id,planned_dose_value,planned_dose_unit,actual_dose_value, + actual_dose_unit,route_original,route_normalized,injection_region,injection_side, + injection_detail,lot_number,corrects_event_id,corrected_target_status,correction_reason, + business_revision) VALUES(?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + day, data["name"], actual_dose or None, legacy_route or None, + data["status"], data["note"] or None, + "dashboard_v5_medication_action", payload["occurred_at"], + medication_context["medication_id"], medication_context["planned_event_id"], + data["planned_dose_value"] or None, data["planned_dose_unit"] or None, + data["actual_dose_value"] or None, data["actual_dose_unit"] or None, + data["route_original"] or None, data["route_normalized"] or None, + data["injection_region"] or None, data["injection_side"] or None, + data["injection_detail"] or None, data["lot_number"] or None, + medication_context["corrects_event_id"], data["corrected_target_status"] or None, + data["correction_reason"] or None, action_hash, + ), + ) else: dose = " ".join( str(part) diff --git a/scripts/health/health_dashboard_server.py b/scripts/health/health_dashboard_server.py index 58e878b..8345b68 100644 --- a/scripts/health/health_dashboard_server.py +++ b/scripts/health/health_dashboard_server.py @@ -35,7 +35,7 @@ from dashboard_v5.observation_contract import validate_action as validate_observ from dashboard_v5.capture_contract import validate_capture_payload from dashboard_v5.medication_schema import assert_schema as assert_medication_schema from dashboard_v5.medication_contract import ( - ACTION_CONTRACT_VERSION as MEDICATION_ACTION_CONTRACT, + ACTION_CONTRACT_VERSIONS as MEDICATION_ACTION_CONTRACTS, resolve_action_preview, ) from dashboard_v5.capture_media import MAX_ORIGINAL_BYTES as MAX_CAPTURE_MEDIA_BYTES, quarantine_bytes @@ -1193,6 +1193,44 @@ def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> str: ) +def _medication_preview_error(error: Exception) -> tuple[int, dict[str, Any]]: + field = getattr(error, "field", None) + allowed_fields = { + "quantity_value", "dosage_form", "strength", "route_original", + "injection_region", "injection_detail", "note", "medication", + } + if isinstance(error, ValueError) and field in allowed_fields: + messages = { + "quantity_value": "Bitte eine gültige Menge eingeben.", + "dosage_form": "Bitte eine gültige Darreichungsform eingeben.", + "strength": "Bitte eine gültige Wirkstoffstärke eingeben.", + "route_original": "Bitte den dokumentierten Applikationsweg prüfen.", + "injection_region": "Bitte die Injektionsregion prüfen.", + "injection_detail": "Bitte die Injektionsstelle prüfen.", + "note": "Bitte die Notiz prüfen.", + "medication": "Bitte das ausgewählte Medikament prüfen.", + } + return 422, {"error": {"kind": "validation", "code": f"{field}_invalid", "field": field, "message": messages.get(field, "Bitte die markierte Angabe prüfen.")}} + reason = str(error) + rules = ( + ("invalid medication action shape", 422, "payload_invalid", "medication", "Die Medikationsangaben sind unvollständig oder ungültig."), + ("invalid medication text", 422, "medication_text_invalid", "medication", "Name, Stärke oder Notiz enthält eine unzulässige Angabe."), + ("requires a real plan", 422, "planned_event_required", "capture_mode", "Für diesen Modus ist kein belastbarer Plantermin verknüpft."), + ("lacks a structured administration preset", 422, "planned_event_unstructured", "capture_mode", "Der Plantermin enthält keine revisionssichere Mengenangabe."), + ("differs from verified preset", 422, "preset_deviation_unconfirmed", "deviation_confirmed", "Die Eingabe weicht von der verifizierten Vorauswahl ab und muss bewusst bestätigt werden."), + ("administration preset changed", 409, "preset_changed", "preset", "Die verifizierte Vorauswahl hat sich geändert. Bitte Vorschau neu laden."), + ("stale medication preview", 409, "preview_changed", "preview", "Die Vorschau ist nicht mehr aktuell. Bitte erneut prüfen."), + ("already consumed", 409, "planned_event_consumed", "capture_mode", "Der Plantermin wurde bereits dokumentiert."), + ("explicitly active prescription", 422, "prescription_not_active_for_plan", "capture_mode", "Ein Plantermin kann nur gegen eine ausdrücklich aktive Verordnung dokumentiert werden."), + ) + for fragment, status_code, code, field, message in rules: + if fragment in reason: + return status_code, {"error": {"kind": "validation", "code": code, "field": field, "message": message}} + if isinstance(error, ValueError): + return 422, {"error": {"kind": "validation", "code": "medication_validation_failed", "field": "medication", "message": "Die Medikationsangaben sind ungültig. Bitte die Felder prüfen."}} + return 503, {"error": {"kind": "technical", "code": "medication_preview_unavailable", "field": "", "message": "Die Erfassung ist technisch nicht verfügbar. Es wurde nichts vorgemerkt."}} + + class Handler(BaseHTTPRequestHandler): def send_error( self, @@ -1356,7 +1394,7 @@ class Handler(BaseHTTPRequestHandler): if ( payload["capture_type"] != "medication" or not isinstance(preview_data, dict) - or preview_data.get("contract") != MEDICATION_ACTION_CONTRACT + or preview_data.get("contract") not in MEDICATION_ACTION_CONTRACTS ): raise ValueError("invalid medication preview contract") preview_connection = connect_read_only(API_DB) @@ -1365,8 +1403,6 @@ class Handler(BaseHTTPRequestHandler): medication_preview_revision = resolve_action_preview( preview_connection, payload )[0] - except RuntimeError as error: - raise ValueError("medication preview context unavailable") from error finally: preview_connection.close() return_to = "v5" @@ -1395,8 +1431,12 @@ class Handler(BaseHTTPRequestHandler): else: self.send_error(409) return - except ValueError: - self.send_error(400) + except (ValueError, RuntimeError, sqlite3.Error) as error: + if action_path == MEDICATION_PREVIEW_ROUTE: + status_code, body = _medication_preview_error(error) + self._send_api_json(status_code, body, True) + else: + self.send_error(400) return except QueueFullError: self.send_error(503) @@ -1411,6 +1451,13 @@ class Handler(BaseHTTPRequestHandler): True, ) return + if action_path == CAPTURE_ROUTE and "application/json" in self.headers.get("Accept", ""): + self._send_api_json( + 202, + {"status": "queued", "status_key": payload["idempotency_key"]}, + True, + ) + return if action_path in {CHECKIN_ROUTE, NUTRITION_MAPPING_ROUTE} and "application/json" in self.headers.get( "Accept", "" ): __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/repos/HealthManager__HERMES_CWD_8d46a20096ed__