diff --git a/CLAUDE.md b/CLAUDE.md index 364d006..580deb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,10 +6,12 @@ ## Что это -Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export, -хранит их и отдаёт другим моим проектам через HTTP API. Это **хранилище, а не -аналитика**: принять, дедуплицировать, сохранить, отдать. Не считать агрегаты, -не переименовывать поля Apple, не интерпретировать значения. +Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export и +родного экспорта Apple, хранит их и отдаёт другим моим проектам — через HTTP +API и через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать, +сохранить, отдать. Не переименовывать поля Apple, не интерпретировать +значения. Агрегат считается только в ответе на запрос и только там, где род +метрики измерен. ## Стек @@ -27,18 +29,26 @@ Module path — `git.vakhrushev.me/av/healthlog`. отбрасывать внутри точки — срок хранения архива станет сроком жизни данных. - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200. -- **Ничего не теряем молча.** Идентичность — хеш **канонизированного** - (рекурсивно отсортированного) содержимого: повтор не меняет ничего, - различие сохраняется. Изменение запечатанного часа — `WARN`, но данные - всё равно пишутся. -- **Дыры закрываются сами.** Три прохода синхронизации разной глубины - (5 минут / час / сутки), настройки данных у всех одинаковы. +- **Ничего не теряем молча.** Идентичность — координаты + (`метрика + слой + метка`); `source` в ключ не входит, он нестабилен. Хеш + канонизированного содержимого остался детектором изменений. При + столкновении выигрывает **более полная** точка, а не последняя. Изменение + запечатанного часа — `WARN`, но данные всё равно пишутся. +- **Дыры закрываются сами.** Три прохода разной глубины (5 минут / сутки / + неделя). Настройки данных у проходов теперь **разные** — намеренно, они + наполняют разные слои; это безопасно ровно потому, что слой входит в ключ. - **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано - только время (`ts_utc` + офсет исходной зоны). -- **Своей агрегации нет — есть слои.** Метрика хранится в той подробности, в - какой пришла (`raw`/`minute`/`hour`); слой выводится из выравнивания меток, - а не из заголовка HAE — тот врёт. Клиенту показываем каталог разрезов, выбор - за ним. + только время (`ts_utc` + офсет исходной зоны). Единственное добавление — + стабильный код рядом с переведённой строкой: HAE отдаёт «БДГ» и «Сидячий + образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без + словаря эти два источника не сойтись. +- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в той + подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится + из выравнивания меток, а не из заголовка HAE — тот врёт. +- **Агрегация в ответе — только измеренная.** Род свёртки выводится сверкой + слоёв между собой (часовое = сумма минутных → накопительная, = среднее → + мгновенная), а не размечается руками. Род неизвестен — свёртки нет. И + никогда не суммируем нижний слой HAE: это интерполяция, а не сэмплы. - **Секреты не в логах** — токены приёма и чтения. Данные о здоровье чувствительны: тела запросов только на `DEBUG` и с обрезкой. diff --git a/README.md b/README.md index 065103f..ff2301a 100644 --- a/README.md +++ b/README.md @@ -17,41 +17,57 @@ healthlog делает это один раз. Телефон шлёт данн ## Границы Это **хранилище**, а не аналитика. healthlog принимает, дедуплицирует, -хранит и отдаёт. Он не считает агрегаты, не переименовывает поля Apple и не -интерпретирует значения — этим занимается тот, кто данные читает. +хранит и отдаёт. Он не переименовывает поля Apple и не интерпретирует +значения — этим занимается тот, кто данные читает. -Единственный источник — Health Auto Export (куплен, пожизненный премиум). -Другие источники не поддерживаем. +Одну уступку хранилище всё же делает: оно умеет свести метрику к запрошенной +сетке («шаги по дням»). Иначе каждый из клиентов повторял бы одну и ту же +логику выбора слоя, а ошибиться в ней легко — просуммировать не тот разрез и +получить завышение втрое. Но род свёртки не проставлен вручную, а **измерен** +сверкой слоёв между собой; где измерить не вышло, свёртка не предлагается +вовсе. + +Источников два: Health Auto Export (куплен, пожизненный премиум) — ежедневный +поток, и родной экспорт Apple Health раз в 2–3 месяца — источник истины для +нижнего слоя. ## Как устроено ``` -iPhone ──HTTPS POST──► healthlog ──► сырой архив (файлы, .json.gz) +iPhone ──HTTPS POST──► healthlog ──► сырой архив (.json.gz, 14 дней) │ │ - │ └── источник истины, не трогаем + │ └── страховка разбора, не склад ▼ - SQLite-витрина ──► HTTP read API ──► мои приложения - (пересобирается из архива) + SQLite ──┬──► HTTP read API ──► мои приложения + (точки по │ + слоям) └──► MCP ───────────► агенты + ▲ +экспорт Apple ──────────────┘ нижний слой, раз в 2–3 месяца ``` Приём сначала кладёт тело запроса на диск как есть и только потом разбирает. -Значит, ошибка в нашем разборе не может привести к потере данных: витрина -пересобирается из архива командой `healthlog reindex`. +Значит, ошибка в нашем разборе не может привести к потере данных: хранилище +пересобирается из архива командой `healthlog reindex`. Архив при этом +недолговечен — дальше истина в самих точках, и потому точки хранятся +дословно. Подробности — [docs/architecture.md](docs/architecture.md). ## Состояние -В разработке. Готовы шаги 1–2 из 8: сервис принимает пакеты и складывает их в -сырой архив. Разбора, витрины и read API ещё нет — план в +В разработке. Готовы шаги 1–2 из 11: сервис принимает пакеты и складывает их в +сырой архив. Разбора, хранилища и read API ещё нет — план в [docs/plan.md](docs/plan.md). +Разведка формата закончена: 41 находка на живом потоке, половина расходится с +документацией Health Auto Export — [docs/local-research.md](docs/local-research.md). + ## Команды ``` -healthlog serve приём + read API -healthlog import заливка файлов в архив и витрину (шаг 6) -healthlog reindex пересборка витрины из сырого архива (шаг 3) +healthlog serve приём + read API + MCP +healthlog import родной экспорт Apple Health (шаг 8) +healthlog reindex пересборка хранилища из архива (шаг 3) healthlog healthcheck проверка живости для docker HEALTHCHECK ``` diff --git a/docs/architecture.md b/docs/architecture.md index f7f3655..dcd17b8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,8 +3,10 @@ ## Назначение healthlog принимает выгрузки Apple Health из приложения Health Auto Export -(далее HAE), хранит их и отдаёт другим приложениям. Он не обрабатывает данные: -не считает агрегаты, не переименовывает поля, не интерпретирует значения. +(далее HAE) и родного экспорта Apple Health, хранит их и отдаёт другим +приложениям — в том числе агентам, через MCP. Он не переименовывает поля и не +интерпретирует значения; агрегаты считает только в ответе на запрос и только +там, где род метрики измерен, а не угадан. ## Принципы @@ -20,18 +22,25 @@ healthlog принимает выгрузки Apple Health из приложен объекты — прямое следствие пункта выше, см. «Хранилище». - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор (см. «Приём»). -- **Ничего не теряем молча.** Идентичность — хеш канонизированного - содержимого: повтор не меняет ничего, различие сохраняется. Схлопывания «на - всякий случай» нет. +- **Ничего не теряем молча.** Идентичность — устойчивые координаты + (`метрика + слой + метка времени`); `source` в ключ не входит, он + нестабилен. Хеш канонизированного содержимого остаётся детектором изменений, + чтобы не писать зря. При столкновении выигрывает более полная точка, а не + последняя пришедшая: бедная доставка не должна стирать поля у богатой. - **Дыры закрываются сами.** Данные приходят несколькими проходами разной глубины, поэтому пропущенная доставка не оставляет постоянного пробела — см. «Модель синхронизации». - **Форма Apple не транслируется.** Значения отдаются такими, какими пришли; - нормализовано только время. -- **Своей агрегации нет — есть разрезы.** Метрика хранится в тех слоях - подробности, в которых пришла (`raw`/`minute`/`hour`); сводить их к одному - или досчитывать свои значило бы принимать предметные решения, которых - хранилище принять не может. + нормализовано только время. Единственное добавление — стабильный код рядом + с переведённой строкой (см. «Категориальные значения»): он приписывается, а + не подменяет. +- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех + разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`); + переагрегирования при записи не происходит никогда. +- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к + запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв + между собой, а не проставлен вручную. Где род неизвестен, агрегация не + предлагается: отдаются значения как есть. - **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и внешних зависимостей. @@ -99,8 +108,11 @@ HRV); у накопительных — только `date`. Поэтому то ## Модель синхронизации -Данные приходят **тремя проходами разной глубины** — три автоматизации HAE с -одинаковыми настройками данных, различающиеся только расписанием и периодом: +У модели два независимых измерения: **глубина окна** (как далеко назад +переспрашиваем) и **подробность** (в какой слой попадут данные). Проходы +задаются их сочетанием. + +По глубине — три прохода, догоняющие друг друга: | проход | расписание | период | зачем | |---|---|---|---| @@ -108,6 +120,25 @@ HRV); у накопительных — только `date`. Поэтому то | средний | 3–4 раза в день | **Today** | чинит пропуски за сутки | | глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное | +По подробности — что в какой слой: + +| подробность | набор метрик | слой | +|---|---|---| +| без группировки | только несуммируемые: сон, пульс, HRV | `raw` | +| минутная | все метрики здоровья | `minute` | +| часовая | все метрики здоровья | `hour` | +| ручной экспорт | всё, раз в 2–3 месяца | `sample` | + +Набор нижнего слоя определяется не важностью метрики, а тем, **можно ли её +складывать**. Для пульса и HRV посекундная подробность несёт форму сигнала, +которой в минутном разрезе нет. Для шагов и энергии нижний слой HAE — это +интерполяция, которая не сходится в сверке (находка 34); держать её значило бы +хранить втрое больший объём ради худших чисел. + +Секции без группировки в интерфейсе HAE (`stateOfMind`, `symptoms`, `ecg`, +`heartRateNotifications`, `cycleTracking`, `medications`) идут как есть — у них +подробности нет, есть только глубина окна. + Средний и глубокий проходы используют **фиксированные окна, а не метку синхронизации** — это принципиально. Инкрементальный режим проверен и **теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла @@ -133,10 +164,35 @@ HRV); у накопительных — только `date`. Поэтому то переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша, почти все из которых сойдутся, и записи не будет. -**Обязательное правило: настройки данных у всех трёх проходов одинаковы** — -тот же набор метрик, то же суммирование, та же группировка. Иначе одна метрика -приезжает из разных источников с разной гранулярностью, значения по одному -ключу расходятся и проходы начинают перетирать друг друга (находка 14). +**Прежнее правило «настройки данных у всех проходов одинаковы» снято.** Оно +существовало потому, что метрика, приехавшая с разной группировкой, +перетирала сама себя по одному ключу (находка 14). С тех пор слой вошёл в +ключ, и минутная точка с часовой больше не сталкиваются — они в разных рядах. +Именно это и позволяет наполнять слои разными автоматизациями намеренно. + +Условие, при котором это безопасно: слой выводится **из выравнивания меток, а +не из настройки автоматизации**. Перенастроил автоматизацию — данные просто +пойдут в другой слой, без порчи уже накопленного. + +### Досчёт задним числом + +Метрики правятся после факта, и глубина правки резко разная по классам: + +- **Количественные** (пульс, шаги, энергия) человек руками не правит; они + опаздывают на часы. Наблюдались правки хвоста возрастом до 22 минут. + Недельного глубокого прохода достаточно. +- **Ручные записи** (`symptoms`, `medications`, `stateOfMind`, + `cycleTracking`) заводятся задним числом на недели и месяцы — симптом или + приём лекарства можно отметить за прошлую дату. + +Поэтому окно досчёта **не единое**. Растягивать глубокий проход на месяц по +всем метрикам в минутном разрезе нельзя: тела запросов и так доходили до +42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую +доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их +единицы записей, и месячное окно там почти ничего не стоит. + +Последние пришедшие данные всегда актуализируют картину — правило слияния +одинаково для всех проходов, порядок прихода значения не имеет. Автоматизации различимы по заголовку `automation-id`; имена стоит задать, иначе `automation-name` приходит пустым (находка 12). @@ -191,6 +247,36 @@ HRV); у накопительных — только `date`. Поэтому то хватает. Вечно хранить его незачем — часовые объекты держат те же точки дословно, так что архив дублировал бы данные, а не страховал их. +### Устаревание нижнего слоя + +Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3 +месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же +период лежит в слое `sample` подробнее и честнее. + +Два ограничения, без которых правило опасно: + +**Пометка вешается по загруженному экспорту, а не по сделанному.** Условие — +экспорт разобран, и проверено, что он **покрывает период**: непрерывность по +дням и сходимость сумм с часовым слоем. Иначе срок жизни данных повисает на +ручной операции, которую можно забыть или сделать наполовину, — а этот +механизм уже протекал: «Since Last Sync» молча потерял 13 961 точку, включая +ночь сна целиком (находка 29). + +**Чистится только нижний слой.** Разница между слоями — три порядка: `hour` +это ~100 координат в сутки, `minute` ~3 700, `raw` ~100 000 (находка 41). +Удаление верхних слоёв не экономит ничего, но ломает ответы на исторические +запросы. Всё давление по объёму создаёт нижний слой, и ровно там экспорт — +настоящее надмножество. + +Пометка «устарело» **не равна удалению**: сперва данные помечаются и остаются +доступными, удаление — отдельный шаг с собственным сроком. Пока восстановление +из экспорта не проверено на живых данных хотя бы раз, удаление не включается +вовсе. + +Оговорка: для секций, которых в экспорте нет (ЭКГ выгружается отдельными CSV, +судьба `stateOfMind` и лекарств не проверена), экспорт источником истины не +является и правило к ним неприменимо — их держим всегда. + **Это осознанная смена источника истины.** Пока тело в архиве, истина — оно; после удаления истиной остаются часовые объекты. Инвариант, который держит конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то @@ -245,10 +331,24 @@ minute метки выровнены на минуту heart_rate 00:02: hour метки выровнены на час heart_rate 00:00:00 ``` -Почему не своя агрегация: сложить точки в часовые средние нетрудно, но -правильный способ зависит от метрики (сумма для энергии, среднее для пульса, -максимум для чего-то ещё), и это уже предметное знание, которого у хранилища -нет. Разрезы — честнее. +Почему не переагрегируем **при записи**: правильный способ свёртки зависит от +метрики (сумма для энергии, среднее для пульса), и ошибка здесь необратима — +исходные точки уже не вернуть. При записи слои остаются раздельными всегда. + +Свести их **в ответе** можно, и Read API это делает, — но род свёртки не +проставляется вручную, а **измеряется**: одна метрика лежит в минутном и +часовом разрезе одновременно, и если часовое значение сходится с суммой +минутных, метрика накопительная; если со средним — мгновенная. Форма точки +рода не выдаёт (`Avg`/`Min`/`Max` есть только у `heart_rate`), единицы дают +процентов девяносто и ломаются на краях — `six_minute_walking_test_distance` +в метрах складывать нельзя, а `walking_running_distance` в километрах можно +(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и +агрегация по метрике не предлагается вовсе. + +Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а +посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему +даёт завышение. Накопительные метрики агрегируются только из `minute`, +`hour` или `sample`. **Слой — это режим выгрузки, которым пришли данные**, а не измеренное разрешение каждой метрики. Различие принципиально: частота метрик разная — @@ -285,6 +385,16 @@ hour метки выровнены на час heart_rate 00:00:00 точностью. Классификация каждой по отдельности растащила бы одну выгрузку по трём слоям. +**Исключение — `sleep_analysis`.** Под этим именем HAE шлёт две несовместимые +схемы: поэпизодную (`start`/`end`/`value`/`qty`) и суточную сводку +(`totalSleep`/`core`/`rem`/`deep`/`awake`, метка на местной полуночи). Общих +полей, кроме `date` и `source`, у них нет, источники тоже разные — эпизоды от +стороннего приложения, сводка от часов (находка 38). Правило выравнивания на +сводке даст `hour`, хотя это суточный итог, а не часовой разрез. Поэтому в +каталоге они разводятся на два имени — `sleep_analysis` и +`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как +`day`. Хранение остаётся дословным: разводятся имена, а не содержимое. + Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на приёме: это ещё одна причина держать сырой архив. @@ -335,6 +445,39 @@ hour метки выровнены на час heart_rate 00:00:00 `WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из эксплуатации, а не из предположений. +### Категориальные значения + +HAE отдаёт перечислимые значения строками из локали телефона, а не кодами: +фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип +тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При +этом `stateOfMind` в том же пакете шлёт честные коды HealthKit +(`momentary_emotion`, `slightly_pleasant`, `drained`) — значит дело не в +приложении, а в том, что старые секции тянут строки из UI (находка 37). + +Оставить как есть нельзя по трём причинам, и третья решающая: + +1. Клиент вынужден угадывать словарь вместо того, чтобы сравнивать с кодом. +2. Смена языка телефона молча расколет историю: та же фаза сна станет другим + значением, и по координатному ключу это неотличимо от изменения данных. +3. **Родной экспорт Apple говорит кодами** (`HKCategoryValueSleepAnalysisAsleepREM`). + Он объявлен источником истины, и на нём держится ретеншен нижнего слоя — + но сверить покрытие по этим полям было бы нечем. + +Поэтому строка **хранится дословно, а рядом кладётся выведенный код**: + +``` +value "БДГ" ← как прислал HAE +value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю +``` + +Словарь ключуется парой `(локаль, строка)`; локаль берётся из +`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой +строки код пустой — пустота честнее догадки, и она же видна в `/stats` как +список того, что пора добавить в словарь. + +Дословность инварианта не нарушена: код **приписывается**, а не подменяет +строку. Обратное преобразование всегда возможно. + ### Тренировки и прочие секции Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она @@ -369,8 +512,8 @@ hour метки выровнены на час heart_rate 00:00:00 ## Read API ``` -GET /api/v1/metrics каталог: имя, units, слои с диапазонами -GET /api/v1/metrics/{name}?layer&from&to&cursor точки метрики +GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами +GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые GET /api/v1/workouts?from&to заголовки тренировок GET /api/v1/workouts/{id} тренировка целиком, с маршрутом GET /api/v1/records/{kind}?from&to прочие секции @@ -388,30 +531,89 @@ GET /healthz за какой период: ```json -{"metric": "heart_rate", +{"metric": "heart_rate", "units": "count/min", "aggregation": "instant", "layers": [ {"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078}, {"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203} ]} ``` -Параметр `layer` выбирает разрез. Если он не указан — отдаём **самый мелкий -слой, покрывающий весь запрошенный диапазон**, и в ответе всегда называем, -какой слой отдан. Молча переключать слой на границе периода нельзя: ряд поедет -незаметно для клиента. +`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него +зависит, что вообще можно спросить. -Форма точки в ответе — нормализованная оболочка, сырое содержимое: +Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий +слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на +границе периода нельзя: ряд поедет незаметно для клиента. + +### Свёртка и размер ответа + +Запросов к метрике ровно два, и это один запрос с необязательным параметром: + +``` +?from&to все значения за период вес, лекарства, симптомы +?from&to&bucket=day значения с разбивкой шаги, энергия +``` + +Главный потребитель — агент, у которого ограничен контекст. «Пульс за неделю» +без разбивки — это десятки тысяч точек в минутном слое и сотни тысяч в +нижнем. Правило: + +- **Разбивка не задана, ответ не влезает** — сервер сам берёт сетку погрубее, + чтобы влезло, и называет её в ответе. Агент всегда получает ответ и может + переспросить уже. +- **Разбивка задана явно, ответ не влезает** — это ошибка, а не тихая + подмена. В теле ошибки — число точек по каждой доступной сетке, чтобы + второй запрос был заведомо успешным. + +Различие существенно: «указали уровень» работает как информация в первом +случае и как защита во втором. Иначе агент, попросивший минутную сетку, +получил бы суточные суммы и заметил бы это, только прочитав поле, которое +вполне может не прочитать. + +Свёртка применяет род из каталога: `cumulative` — сумма, `instant` — +среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр +`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются +из нижнего слоя HAE — только из `minute`, `hour` или `sample`. + +### Форма ответа + +Нормализованная оболочка, сырое содержимое: ```json -{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count", - "values": {"qty": 8}} +{"layer": "minute", "bucket": "hour", "aggregation": "sum", + "points": [ + {"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count", + "values": {"qty": 812}} + ]} ``` +`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не +было (`"bucket": null`): клиент не должен выводить их наличием или +отсутствием поля. + Время приведено к единому виду, значения отданы как пришли: ни переименований, ни пересчёта единиц. Метрик у Apple много и они разные — семантику разбирает клиент по имени метрики. Полная нормализация означала бы, что каждая новая метрика требует правки коллектора, а незнакомая теряется. +### MCP + +Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного +кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог. +Собственной логики в адаптере нет: он переводит вызовы в те же обработчики. + +**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент +ходит к нему по сети. Отсюда следствия: + +- MCP — это **эндпоинт того же процесса**, а не отдельная подкоманда: тот же + бинарь, тот же порт, тот же Caddy впереди с TLS. +- Аутентификация — **тот же токен чтения** в `Authorization: Bearer`, что и у + Read API. Отдельного контура доступа не заводим: MCP не даёт ничего, чего + не даёт HTTP, и права у них обязаны совпадать. +- Правило размера ответа (см. выше) здесь не украшение, а необходимость: + сетевой агент не имеет возможности «посмотреть поближе» иначе, чем + повторным вызовом. + ## Самоописание Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен @@ -454,7 +656,11 @@ GET /healthz токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно. Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий -данные, не может писать. +данные, не может писать. MCP пользуется токеном чтения — отдельного контура +у него нет, см. «MCP». + +Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и +приложения). Оба через Caddy с TLS, оба с разными токенами. ## Деплой @@ -472,3 +678,20 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв ## Открытые вопросы - Механизм доставки образа и запуска на rivendell (compose руками / плейбук). +- **Предел размера ответа** — в точках или в оценке байт. Точки считать + проще, но у `heart_rate_variability` с `heartbeatSeries` точка на два + порядка тяжелее, чем у `step_count` (находка 39). +- **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV ради данных, + которых нет ни в одном из планируемых запросов. +- **Есть ли `stateOfMind`, симптомы и лекарства в родном экспорте.** От этого + зависит, применимо ли к ним устаревание нижнего слоя. +- **Как проверять покрытие экспортом** — до какой строгости. Непрерывности по + дням и сходимости сумм, вероятно, хватит, но порог не выбран. +- **Хранилище под аналитику.** Сейчас SQLite: часовые объекты дают ~260 тыс. + строк на слой в год независимо от плотности точек, а плоская таблица по + грубым слоям (`hour` ~36 тыс. строк в год, `minute` ~1.4 млн) делает + `GROUP BY` дешёвым без разжатия блобов. DuckDB рассматривался и отложен: + чистого Go-драйвера нет, любой требует cgo, что стоит нам + `CGO_ENABLED=0` и одного статического бинаря. Дверь при этом открыта — + DuckDB читает и parquet, и файл SQLite напрямую, так что выгрузка в parquet + остаётся отдельной командой на случай тяжёлой аналитики снаружи. diff --git a/docs/plan.md b/docs/plan.md index 2a9953a..7ab3e61 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -4,12 +4,13 @@ ## Ближайшая цель -Минимальное ядро, которое принимает и сохраняет пакет. Проверяем его -**локально**: сервис поднят на рабочей машине, телефон шлёт на её IP по -локальной сети. Ни деплоя, ни домена, ни TLS для этого не нужно — они -понадобятся, когда сервис поедет на rivendell (шаг 8). +Приём работает, поток настоящих пакетов копится в сыром архиве. Дальше — +разбор и хранилище (шаг 3): всё, что нужно, чтобы данные перестали быть +недифференцированной кучей тел запросов. -Это шаги 1–2. +Разведка закончена: правило вывода слоя, модель идентичности и формы точки +проверены на живом потоке, выводы — в +[local-research.md](local-research.md), 41 находка. ## Шаги @@ -20,23 +21,40 @@ ← **подключаем телефон по локальной сети** - [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик (`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим - `id`, вывод слоя из выравнивания меток, канонизация с округлением чисел - и хеш содержимого, слияние точек в объект, два формата дат, - `healthlog reindex`. -- [ ] **4. Read API.** Каталог метрик со слоями и диапазонами, точки с - пагинацией и выбором слоя (сборка из часовых объектов), тренировки, + `id`. Вывод слоя из выравнивания меток; развод `sleep_analysis` на два + имени (находка 38). Канонизация с округлением чисел и хеш содержимого + как детектор изменений. Слияние точек: при столкновении выигрывает + **более полная** точка, а не последняя (находка 41). Три формата + времени: локальное со смещением, RFC 3339 Z, Unix-эпоха внутри + `heartbeatSeries` (находка 39). Словарь `(локаль, строка) → код + HealthKit` для переведённых значений (находка 37). `healthlog reindex`. +- [ ] **4. Каталог и род агрегации.** Род (`cumulative`/`instant`/`unknown`) + **измеряется** сверкой слоёв между собой, а не размечается руками + (находка 40). Каталог метрик со слоями, диапазонами и родом. +- [ ] **5. Read API.** Точки метрики из часовых объектов, выбор слоя, + необязательная свёртка по сетке. Огрубление, когда сетка не задана; + ошибка со списком доступных сеток, когда задана явно. Тренировки, записи. -- [ ] **5. Самоописание.** Каталог разрезов + выведенные из данных схемы - содержимого со статистикой + статичная схема контракта API. -- [ ] **6. `healthlog import`.** Заливка полной истории кусками по годам. -- [ ] **7. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина. -- [ ] **8. Деплой.** `Dockerfile`, сборка образа локально, доставка на - rivendell, конфиг Caddy, поддомен, токены. +- [ ] **6. Самоописание.** Выведенные из данных схемы содержимого со + статистикой + статичная схема контракта API. +- [ ] **7. MCP.** Эндпоинт того же процесса, транспорт Streamable HTTP, токен + чтения общий с Read API. Три инструмента: каталог, значения за период, + значения с разбивкой. +- [ ] **8. `healthlog import`.** Родной экспорт Apple Health: разбор + `экспорт.xml` в слой `sample`, маршруты GPX, ЭКГ из CSV. Заливка полной + истории кусками по годам. +- [ ] **9. Устаревание нижнего слоя.** Пометка данных HAE старше проверенного + экспорта. Проверка покрытия — непрерывность по дням и сходимость сумм с + часовым слоем. Пометка ≠ удаление: удаление включаем только после того, + как восстановление из экспорта отработает на живых данных хотя бы раз. +- [ ] **10. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина по + потоку, список строк без кода в словаре. +- [ ] **11. Деплой.** `Dockerfile`, сборка образа локально, доставка на + rivendell, конфиг Caddy, поддомены приёма и чтения, токены. -Порядок неслучаен. После шага 2 автоматизация в Health Auto Export включена и -копит настоящие пакеты в сыром архиве. Документация формата HAE скудная, -поэтому разбор на шаге 3 пишем по реальным данным, а не по догадкам — и -заодно видим фактический объём и характер потока. +Порядок неслучаен. Шаг 4 стоит перед Read API, потому что без измеренного рода +свёртка на шаге 5 неотличима от угадывания. Шаг 8 стоит перед 9: пока импорт +экспорта не написан, помечать что-либо устаревшим не на основании чего. ## Отложено @@ -45,12 +63,21 @@ и теми же данными почти никогда не совпадают побайтно — хеш тела не сработает. Дедупликация возможна только по канонизированному содержимому, а это и делает хеш часового объекта. Отдельная механика не нужна. +- ~~Вторая автоматизация без группировки.~~ **Сделано на телефоне:** метрики + здоровья идут в трёх разрезах — несуммированном для несуммируемых метрик, + минутном и часовом для всех. +- ~~Пометка локализованных полей в схемах.~~ **Переросло в шаг 3:** одной + пометки мало, нужен словарь кодов, иначе не сойтись с родным экспортом + (находка 37). - **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление старых тел. Пока архив не подчищается; включить после того, как разбор устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна. - **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10). +- **Месячный проход по ручным секциям.** Симптомы и лекарства заводятся задним + числом на недели; количественным метрикам недельного прохода хватает. + Заводить, когда эти секции появятся в потоке живьём. - **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное уведомление добавим после. @@ -60,20 +87,12 @@ а это отслеживается. - **Схема тренировок** — глубину вывода определим по факту, когда увидим, как приходят маршруты. -- **Пометка локализованных полей в схемах.** `context`, `value` и подобные - приходят на языке телефона и изменятся при смене языка iOS — клиентам не - стоит завязываться на конкретные строки - ([local-research.md](local-research.md), находка 8). -- **Вторая автоматизация без группировки** для `sleep_analysis` и - `heart_rate_variability` — минутная группировка съедает фазы сна и - межударные интервалы ценой ~130 точек в сутки (находка 6). -- **Переход на более грубый нижний слой.** Посекундный слой стоит ~730 МБ в - год против ~20 МБ у минутного. Когда решим, что мелкая подробность не нужна, - достаточно выключить несуммированную автоматизацию — старые данные останутся - в своих слоях, переписывать ничего не придётся. Обратный путь тоже есть: - ручной экспорт из Apple Health + `healthlog import` восстанавливает нижний - слой. +- **Отказ от `heartbeatSeries`.** 93% объёма HRV (находка 39) ради данных, + которых нет ни в одном планируемом запросе. Решать, когда станет ясна цена + хранения нижнего слоя за год. +- **Выгрузка в parquet** — отдельной командой, на случай тяжёлой аналитики + снаружи. DuckDB читает и parquet, и файл SQLite напрямую, поэтому спешить + некуда: дверь открыта без миграции. - **NDJSON-поток** для больших выборок из read API. -- **Слой агрегаций** поверх сырья (суточные/недельные срезы). - **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится клиент, которому мало отдачи тренировки одним пакетом.