Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion backend/backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
) 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,
)
15 changes: 13 additions & 2 deletions backend/backend/services/healthkit_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -36,18 +37,24 @@ 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,
target_date=target_date,
)
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,
Expand All @@ -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),
}
}
47 changes: 47 additions & 0 deletions backend/backend/services/readiness_query.py
Original file line number Diff line number Diff line change
@@ -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,
}
147 changes: 147 additions & 0 deletions backend/tests/test_healthkit_pipeline.py
Original file line number Diff line number Diff line change
@@ -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"),
]
14 changes: 11 additions & 3 deletions docs/product/CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/*`.
Expand Down
Loading