From 12dadf02c680fe2e476213edf9d515b2fd10878d Mon Sep 17 00:00:00 2001 From: Sergey Shchukin Date: Fri, 17 Apr 2026 19:40:50 +0300 Subject: [PATCH] Make healthkit full-sync recompute current state end-to-end --- backend/README.md | 2 + backend/backend/app.py | 11 +- .../backend/services/healthkit_pipeline.py | 15 +- backend/backend/services/readiness_query.py | 47 ++++++ backend/tests/test_healthkit_pipeline.py | 147 ++++++++++++++++++ docs/product/CURRENT_STATE.md | 14 +- 6 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 backend/backend/services/readiness_query.py create mode 100644 backend/tests/test_healthkit_pipeline.py diff --git a/backend/README.md b/backend/README.md index 5ed46ae..12ff0dd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -174,12 +174,14 @@ POST /api/v1/healthkit/full-sync/{user_id} -> process latest raw payload into normalized tables -> collect affected dates -> recompute health_recovery_daily for affected dates + -> recompute load_state_daily_v2 up to latest recovery/training date -> recompute readiness_daily for affected dates ``` Важно: - recovery пересчитывается поверх normalized health tables +- load_state_daily_v2 пересчитывается перед readiness, чтобы freshness был актуален - readiness пересчитывается как отдельный слой - public API уже работает end-to-end через VPS и Caddy diff --git a/backend/backend/app.py b/backend/backend/app.py index 455d2de..28b1a3e 100644 --- a/backend/backend/app.py +++ b/backend/backend/app.py @@ -37,6 +37,7 @@ from backend.services.load_state_v2 import recompute_load_state_daily_v2 from backend.services.readiness_daily import recompute_readiness_daily_for_date from backend.services.healthkit_pipeline import ingest_and_process_healthkit_payload +from backend.services.readiness_query import get_readiness_daily_for_date app = FastAPI(title="Human Engine API", version="0.1.0") @@ -1227,4 +1228,12 @@ def full_sync_healthkit_payload_endpoint(user_id: str, payload: HealthSyncPayloa raise HTTPException( status_code=500, detail=f"failed to run healthkit full sync: {str(e)[:300]}", - ) from e \ No newline at end of file + ) from e + + +@app.get("/api/v1/model/readiness-daily/{user_id}/{target_date}") +def get_readiness_daily_endpoint(user_id: str, target_date: str): + return get_readiness_daily_for_date( + user_id=user_id, + target_date=target_date, + ) \ No newline at end of file diff --git a/backend/backend/services/healthkit_pipeline.py b/backend/backend/services/healthkit_pipeline.py index 50a355c..72aa661 100644 --- a/backend/backend/services/healthkit_pipeline.py +++ b/backend/backend/services/healthkit_pipeline.py @@ -6,6 +6,7 @@ from backend.services.health_recovery_daily import recompute_health_recovery_daily_for_date from backend.services.healthkit_ingest import save_healthkit_ingest_raw from backend.services.healthkit_processing import process_latest_healthkit_raw +from backend.services.load_state_v2 import recompute_load_state_daily_v2 from backend.services.readiness_daily import recompute_readiness_daily_for_date @@ -36,11 +37,12 @@ def ingest_and_process_healthkit_payload(user_id: str, payload: HealthSyncPayloa # 3. Determine affected dates from payload affected_dates = _collect_affected_dates(payload) + max_affected_date = affected_dates[-1] if affected_dates else None recovery_results = [] readiness_results = [] - # 4. Recompute recovery + readiness for all affected dates + # 4. Recompute recovery for all affected dates for target_date in affected_dates: recovery_result = recompute_health_recovery_daily_for_date( user_id=user_id, @@ -48,6 +50,11 @@ def ingest_and_process_healthkit_payload(user_id: str, payload: HealthSyncPayloa ) recovery_results.append(recovery_result) + # 5. Recompute load state after recovery so freshness is available to readiness. + load_result = recompute_load_state_daily_v2(user_id=user_id) + + # 6. Recompute readiness for all affected dates + for target_date in affected_dates: readiness_result = recompute_readiness_daily_for_date( user_id=user_id, target_date=target_date, @@ -58,11 +65,15 @@ def ingest_and_process_healthkit_payload(user_id: str, payload: HealthSyncPayloa "ok": True, "user_id": user_id, "affected_dates": affected_dates, + "max_affected_date": max_affected_date, "sleep_nights_count": len(payload.sleepNights), "resting_hr_count": len(payload.restingHeartRateDaily), "hrv_count": len(payload.hrvSamples), "latest_weight_included": payload.latestWeight is not None, "normalized": processing_result, "recovery_days_recomputed": len(recovery_results), + "load_recomputed": True, + "load_days_recomputed": load_result.get("days_processed"), + "load_last_date": load_result.get("last_date"), "readiness_days_recomputed": len(readiness_results), - } \ No newline at end of file + } diff --git a/backend/backend/services/readiness_query.py b/backend/backend/services/readiness_query.py new file mode 100644 index 0000000..f53d7be --- /dev/null +++ b/backend/backend/services/readiness_query.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from backend.db import get_conn + + +def get_readiness_daily_for_date(user_id: str, target_date: str) -> dict[str, Any]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + select + user_id, + date, + readiness_score, + good_day_probability, + status_text, + explanation_json + from readiness_daily + where user_id = %s + and date = %s + and version = 'v2'; + """, + (user_id, target_date), + ) + row = cur.fetchone() + + if not row: + raise HTTPException( + status_code=404, + detail=f"readiness not found for user_id={user_id} date={target_date}", + ) + + db_user_id, db_date, readiness_score, good_day_probability, status_text, explanation_json = row + + return { + "ok": True, + "user_id": db_user_id, + "date": str(db_date), + "readiness_score": readiness_score, + "good_day_probability": good_day_probability, + "status_text": status_text, + "explanation": explanation_json, + } \ No newline at end of file diff --git a/backend/tests/test_healthkit_pipeline.py b/backend/tests/test_healthkit_pipeline.py new file mode 100644 index 0000000..692bfd3 --- /dev/null +++ b/backend/tests/test_healthkit_pipeline.py @@ -0,0 +1,147 @@ +from datetime import date, datetime + +from backend.schemas.healthkit import ( + HRVSampleDTO, + HealthSyncPayload, + LatestWeightDTO, + RestingHRDailyDTO, + SleepNightDTO, +) +from backend.services import healthkit_pipeline + + +def test_ingest_and_process_healthkit_payload_runs_full_current_state_pipeline(monkeypatch): + call_order: list[tuple[str, str | None]] = [] + + payload = HealthSyncPayload( + generatedAt=datetime(2026, 4, 17, 10, 0, 0), + timezone="Europe/Moscow", + sleepNights=[ + SleepNightDTO( + wakeDate=date(2026, 4, 15), + sleepStart=datetime(2026, 4, 14, 23, 0, 0), + sleepEnd=datetime(2026, 4, 15, 7, 0, 0), + totalSleepMinutes=480.0, + awakeMinutes=20.0, + coreMinutes=260.0, + remMinutes=120.0, + deepMinutes=100.0, + ) + ], + restingHeartRateDaily=[ + RestingHRDailyDTO( + date=date(2026, 4, 16), + bpm=52.0, + ) + ], + hrvSamples=[ + HRVSampleDTO( + startAt=datetime(2026, 4, 16, 6, 30, 0), + valueMs=64.0, + ), + HRVSampleDTO( + startAt=datetime(2026, 4, 15, 6, 20, 0), + valueMs=62.0, + ), + ], + latestWeight=LatestWeightDTO( + measuredAt=datetime(2026, 4, 16, 8, 0, 0), + kilograms=72.5, + ), + ) + + def fake_save_healthkit_ingest_raw(*, user_id, payload): + call_order.append(("ingest", user_id)) + + def fake_process_latest_healthkit_raw(user_id): + call_order.append(("normalize", user_id)) + return { + "ok": True, + "user_id": user_id, + "sleep_nights_processed": 1, + "resting_hr_processed": 1, + "hrv_processed": 2, + "weight_processed": 1, + } + + def fake_recompute_health_recovery_daily_for_date(user_id, target_date): + call_order.append(("recovery", target_date)) + return {"ok": True, "date": target_date} + + def fake_recompute_load_state_daily_v2(user_id): + call_order.append(("load", user_id)) + return { + "ok": True, + "user_id": user_id, + "days_processed": 11, + "last_date": "2026-04-16", + } + + def fake_recompute_readiness_daily_for_date(user_id, target_date): + call_order.append(("readiness", target_date)) + return {"ok": True, "date": target_date} + + monkeypatch.setattr( + healthkit_pipeline, + "save_healthkit_ingest_raw", + fake_save_healthkit_ingest_raw, + ) + monkeypatch.setattr( + healthkit_pipeline, + "process_latest_healthkit_raw", + fake_process_latest_healthkit_raw, + ) + monkeypatch.setattr( + healthkit_pipeline, + "recompute_health_recovery_daily_for_date", + fake_recompute_health_recovery_daily_for_date, + ) + monkeypatch.setattr( + healthkit_pipeline, + "recompute_load_state_daily_v2", + fake_recompute_load_state_daily_v2, + ) + monkeypatch.setattr( + healthkit_pipeline, + "recompute_readiness_daily_for_date", + fake_recompute_readiness_daily_for_date, + ) + + result = healthkit_pipeline.ingest_and_process_healthkit_payload( + user_id="user-1", + payload=payload, + ) + + assert result == { + "ok": True, + "user_id": "user-1", + "affected_dates": ["2026-04-15", "2026-04-16"], + "max_affected_date": "2026-04-16", + "sleep_nights_count": 1, + "resting_hr_count": 1, + "hrv_count": 2, + "latest_weight_included": True, + "normalized": { + "ok": True, + "user_id": "user-1", + "sleep_nights_processed": 1, + "resting_hr_processed": 1, + "hrv_processed": 2, + "weight_processed": 1, + }, + "recovery_days_recomputed": 2, + "load_recomputed": True, + "load_days_recomputed": 11, + "load_last_date": "2026-04-16", + "readiness_days_recomputed": 2, + } + + assert call_order == [ + ("ingest", "user-1"), + ("normalize", "user-1"), + ("recovery", "2026-04-15"), + ("recovery", "2026-04-16"), + ("load", "user-1"), + ("readiness", "2026-04-15"), + ("readiness", "2026-04-16"), + ] diff --git a/docs/product/CURRENT_STATE.md b/docs/product/CURRENT_STATE.md index 1aeba8c..203dff1 100644 --- a/docs/product/CURRENT_STATE.md +++ b/docs/product/CURRENT_STATE.md @@ -118,14 +118,20 @@ Endpoint: `POST /api/v1/healthkit/full-sync/{user_id}` +Статус: + +- full-sync теперь является self-sufficient orchestration endpoint +- current state после одного full-sync считается end-to-end на backend + Flow: 1. raw ingest в `healthkit_ingest_raw` 2. latest raw -> normalized health tables 3. сбор affected dates 4. recompute `health_recovery_daily` -5. recompute `readiness_daily` -6. response обратно в клиент +5. recompute `load_state_daily_v2` минимум до max affected date +6. recompute `readiness_daily` +7. агрегированный response обратно в клиент ### 3.2 Load model v2 recompute @@ -215,11 +221,13 @@ good_day_probability = readiness_score / 100 ## 5. Что уже работает end-to-end - iOS приложение отправляет HealthKit payload -- backend принимает `full-sync` +- backend принимает `full-sync` как self-sufficient orchestration endpoint - данные попадают в raw таблицу - latest raw раскладывается в normalized health tables - пересчитывается `health_recovery_daily` +- current state дотягивается на backend через `load_state_daily_v2` - пересчитывается `readiness_daily` +- `readiness_daily.explanation_json` содержит recovery breakdown - результат возвращается в iOS через public API Публичный API уже проксируется через VPS / Caddy по пути `/api/*`.