пересмотрена архитектура под слои, свёртку в ответе и MCP

- агрегация появилась в ответе на запрос: род метрики измеряется сверкой
  слоёв, нижний слой HAE не суммируется никогда
- переведённые строки хранятся дословно с приписанным кодом HealthKit;
  снято правило «настройки данных у всех проходов одинаковы» — слой в ключе
- добавлены устаревание нижнего слоя по проверенному экспорту и MCP поверх
  read API; план вырос до 11 шагов
This commit is contained in:
av
2026-08-01 13:48:22 +03:00
parent 18d76d9610
commit 9d06509446
4 changed files with 363 additions and 95 deletions
+25 -15
View File
@@ -6,10 +6,12 @@
## Что это ## Что это
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export, Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export и
хранит их и отдаёт другим моим проектам через HTTP API. Это **хранилище, а не родного экспорта Apple, хранит их и отдаёт другим моим проектам через HTTP
аналитика**: принять, дедуплицировать, сохранить, отдать. Не считать агрегаты, API и через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать,
не переименовывать поля Apple, не интерпретировать значения. сохранить, отдать. Не переименовывать поля Apple, не интерпретировать
значения. Агрегат считается только в ответе на запрос и только там, где род
метрики измерен.
## Стек ## Стек
@@ -27,18 +29,26 @@ Module path — `git.vakhrushev.me/av/healthlog`.
отбрасывать внутри точки — срок хранения архива станет сроком жизни данных. отбрасывать внутри точки — срок хранения архива станет сроком жизни данных.
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
битый JSON — 400, непонятое содержимое — 200. битый JSON — 400, непонятое содержимое — 200.
- **Ничего не теряем молча.** Идентичность — хеш **канонизированного** - **Ничего не теряем молча.** Идентичность — координаты
(рекурсивно отсортированного) содержимого: повтор не меняет ничего, (`метрика + слой + метка`); `source` в ключ не входит, он нестабилен. Хеш
различие сохраняется. Изменение запечатанного часа — `WARN`, но данные канонизированного содержимого остался детектором изменений. При
всё равно пишутся. столкновении выигрывает **более полная** точка, а не последняя. Изменение
- **Дыры закрываются сами.** Три прохода синхронизации разной глубины запечатанного часа — `WARN`, но данные всё равно пишутся.
(5 минут / час / сутки), настройки данных у всех одинаковы. - **Дыры закрываются сами.** Три прохода разной глубины (5 минут / сутки /
неделя). Настройки данных у проходов теперь **разные** — намеренно, они
наполняют разные слои; это безопасно ровно потому, что слой входит в ключ.
- **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано - **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано
только время (`ts_utc` + офсет исходной зоны). только время (`ts_utc` + офсет исходной зоны). Единственное добавление —
- **Своей агрегации нет — есть слои.** Метрика хранится в той подробности, в стабильный код рядом с переведённой строкой: HAE отдаёт «БДГ» и «Сидячий
какой пришла (`raw`/`minute`/`hour`); слой выводится из выравнивания меток, образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без
а не из заголовка HAE — тот врёт. Клиенту показываем каталог разрезов, выбор словаря эти два источника не сойтись.
за ним. - **Своей агрегации в хранении нет — есть слои.** Метрика лежит в той
подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится
из выравнивания меток, а не из заголовка HAE — тот врёт.
- **Агрегация в ответе — только измеренная.** Род свёртки выводится сверкой
слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
мгновенная), а не размечается руками. Род неизвестен — свёртки нет. И
никогда не суммируем нижний слой HAE: это интерполяция, а не сэмплы.
- **Секреты не в логах** — токены приёма и чтения. Данные о здоровье - **Секреты не в логах** — токены приёма и чтения. Данные о здоровье
чувствительны: тела запросов только на `DEBUG` и с обрезкой. чувствительны: тела запросов только на `DEBUG` и с обрезкой.
+31 -15
View File
@@ -17,41 +17,57 @@ healthlog делает это один раз. Телефон шлёт данн
## Границы ## Границы
Это **хранилище**, а не аналитика. 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). Подробности — [docs/architecture.md](docs/architecture.md).
## Состояние ## Состояние
В разработке. Готовы шаги 1–2 из 8: сервис принимает пакеты и складывает их в В разработке. Готовы шаги 1–2 из 11: сервис принимает пакеты и складывает их в
сырой архив. Разбора, витрины и read API ещё нет — план в сырой архив. Разбора, хранилища и read API ещё нет — план в
[docs/plan.md](docs/plan.md). [docs/plan.md](docs/plan.md).
Разведка формата закончена: 41 находка на живом потоке, половина расходится с
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
## Команды ## Команды
``` ```
healthlog serve приём + read API healthlog serve приём + read API + MCP
healthlog import заливка файлов в архив и витрину (шаг 6) healthlog import родной экспорт Apple Health (шаг 8)
healthlog reindex пересборка витрины из сырого архива (шаг 3) healthlog reindex пересборка хранилища из архива (шаг 3)
healthlog healthcheck проверка живости для docker HEALTHCHECK healthlog healthcheck проверка живости для docker HEALTHCHECK
``` ```
+253 -30
View File
@@ -3,8 +3,10 @@
## Назначение ## Назначение
healthlog принимает выгрузки Apple Health из приложения Health Auto Export healthlog принимает выгрузки Apple Health из приложения Health Auto Export
(далее HAE), хранит их и отдаёт другим приложениям. Он не обрабатывает данные: (далее HAE) и родного экспорта Apple Health, хранит их и отдаёт другим
не считает агрегаты, не переименовывает поля, не интерпретирует значения. приложениям — в том числе агентам, через MCP. Он не переименовывает поля и не
интерпретирует значения; агрегаты считает только в ответе на запрос и только
там, где род метрики измерен, а не угадан.
## Принципы ## Принципы
@@ -20,18 +22,25 @@ healthlog принимает выгрузки Apple Health из приложен
объекты — прямое следствие пункта выше, см. «Хранилище». объекты — прямое следствие пункта выше, см. «Хранилище».
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
(см. «Приём»). (см. «Приём»).
- **Ничего не теряем молча.** Идентичность — хеш канонизированного - **Ничего не теряем молча.** Идентичность — устойчивые координаты
содержимого: повтор не меняет ничего, различие сохраняется. Схлопывания «на (`метрика + слой + метка времени`); `source` в ключ не входит, он
всякий случай» нет. нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной - **Дыры закрываются сами.** Данные приходят несколькими проходами разной
глубины, поэтому пропущенная доставка не оставляет постоянного пробела — глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
см. «Модель синхронизации». см. «Модель синхронизации».
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли; - **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
нормализовано только время. нормализовано только время. Единственное добавление — стабильный код рядом
- **Своей агрегации нет — есть разрезы.** Метрика хранится в тех слоях с переведённой строкой (см. «Категориальные значения»): он приписывается, а
подробности, в которых пришла (`raw`/`minute`/`hour`); сводить их к одному не подменяет.
или досчитывать свои значило бы принимать предметные решения, которых - **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
хранилище принять не может. разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
переагрегирования при записи не происходит никогда.
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
предлагается: отдаются значения как есть.
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и - **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
внешних зависимостей. внешних зависимостей.
@@ -99,8 +108,11 @@ HRV); у накопительных — только `date`. Поэтому то
## Модель синхронизации ## Модель синхронизации
Данные приходят **тремя проходами разной глубины** — три автоматизации HAE с У модели два независимых измерения: **глубина окна** (как далеко назад
одинаковыми настройками данных, различающиеся только расписанием и периодом: переспрашиваем) и **подробность** (в какой слой попадут данные). Проходы
задаются их сочетанием.
По глубине — три прохода, догоняющие друг друга:
| проход | расписание | период | зачем | | проход | расписание | период | зачем |
|---|---|---|---| |---|---|---|---|
@@ -108,6 +120,25 @@ HRV); у накопительных — только `date`. Поэтому то
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки | | средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное | | глубокий | раз в сутки | **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 сравнений хеша, переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
почти все из которых сойдутся, и записи не будет. почти все из которых сойдутся, и записи не будет.
**Обязательное правило: настройки данных у всех трёх проходов одинаковы** **Прежнее правило «настройки данных у всех проходов одинаковы» снято.** Оно
тот же набор метрик, то же суммирование, та же группировка. Иначе одна метрика существовало потому, что метрика, приехавшая с разной группировкой,
приезжает из разных источников с разной гранулярностью, значения по одному перетирала сама себя по одному ключу (находка 14). С тех пор слой вошёл в
ключу расходятся и проходы начинают перетирать друг друга (находка 14). ключ, и минутная точка с часовой больше не сталкиваются — они в разных рядах.
Именно это и позволяет наполнять слои разными автоматизациями намеренно.
Условие, при котором это безопасно: слой выводится **из выравнивания меток, а
не из настройки автоматизации**. Перенастроил автоматизацию — данные просто
пойдут в другой слой, без порчи уже накопленного.
### Досчёт задним числом
Метрики правятся после факта, и глубина правки резко разная по классам:
- **Количественные** (пульс, шаги, энергия) человек руками не правит; они
опаздывают на часы. Наблюдались правки хвоста возрастом до 22 минут.
Недельного глубокого прохода достаточно.
- **Ручные записи** (`symptoms`, `medications`, `stateOfMind`,
`cycleTracking`) заводятся задним числом на недели и месяцы — симптом или
приём лекарства можно отметить за прошлую дату.
Поэтому окно досчёта **не единое**. Растягивать глубокий проход на месяц по
всем метрикам в минутном разрезе нельзя: тела запросов и так доходили до
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
единицы записей, и месячное окно там почти ничего не стоит.
Последние пришедшие данные всегда актуализируют картину — правило слияния
одинаково для всех проходов, порядок прихода значения не имеет.
Автоматизации различимы по заголовку `automation-id`; имена стоит задать, Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
иначе `automation-name` приходит пустым (находка 12). иначе `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 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` идёт по всей истории сразу и потому точнее, чем на Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на
приёме: это ещё одна причина держать сырой архив. приёме: это ещё одна причина держать сырой архив.
@@ -335,6 +445,39 @@ hour метки выровнены на час heart_rate 00:00:00
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из `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 и **перезаписывается**: она Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она
@@ -369,8 +512,8 @@ hour метки выровнены на час heart_rate 00:00:00
## Read API ## Read API
``` ```
GET /api/v1/metrics каталог: имя, units, слои с диапазонами GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
GET /api/v1/metrics/{name}?layer&from&to&cursor точки метрики GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые
GET /api/v1/workouts?from&to заголовки тренировок GET /api/v1/workouts?from&to заголовки тренировок
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
GET /api/v1/records/{kind}?from&to прочие секции GET /api/v1/records/{kind}?from&to прочие секции
@@ -388,30 +531,89 @@ GET /healthz
за какой период: за какой период:
```json ```json
{"metric": "heart_rate", {"metric": "heart_rate", "units": "count/min", "aggregation": "instant",
"layers": [ "layers": [
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078}, {"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": "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 ```json
{"layer": "minute", "bucket": "hour", "aggregation": "sum",
"points": [
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count", {"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
"values": {"qty": 8}} "values": {"qty": 812}}
]}
``` ```
`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не
было (`"bucket": null`): клиент не должен выводить их наличием или
отсутствием поля.
Время приведено к единому виду, значения отданы как пришли: ни Время приведено к единому виду, значения отданы как пришли: ни
переименований, ни пересчёта единиц. Метрик у Apple много и они разные — переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
семантику разбирает клиент по имени метрики. Полная нормализация означала бы, семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
что каждая новая метрика требует правки коллектора, а незнакомая теряется. что каждая новая метрика требует правки коллектора, а незнакомая теряется.
### MCP
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
ходит к нему по сети. Отсюда следствия:
- MCP — это **эндпоинт того же процесса**, а не отдельная подкоманда: тот же
бинарь, тот же порт, тот же Caddy впереди с TLS.
- Аутентификация — **тот же токен чтения** в `Authorization: Bearer`, что и у
Read API. Отдельного контура доступа не заводим: MCP не даёт ничего, чего
не даёт HTTP, и права у них обязаны совпадать.
- Правило размера ответа (см. выше) здесь не украшение, а необходимость:
сетевой агент не имеет возможности «посмотреть поближе» иначе, чем
повторным вызовом.
## Самоописание ## Самоописание
Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен
@@ -454,7 +656,11 @@ GET /healthz
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно. токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
данные, не может писать. данные, не может писать. MCP пользуется токеном чтения — отдельного контура
у него нет, см. «MCP».
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
приложения). Оба через Caddy с TLS, оба с разными токенами.
## Деплой ## Деплой
@@ -472,3 +678,20 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв
## Открытые вопросы ## Открытые вопросы
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук). - Механизм доставки образа и запуска на 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
остаётся отдельной командой на случай тяжёлой аналитики снаружи.
+53 -34
View File
@@ -4,12 +4,13 @@
## Ближайшая цель ## Ближайшая цель
Минимальное ядро, которое принимает и сохраняет пакет. Проверяем его Приём работает, поток настоящих пакетов копится в сыром архиве. Дальше —
**локально**: сервис поднят на рабочей машине, телефон шлёт на её IP по разбор и хранилище (шаг 3): всё, что нужно, чтобы данные перестали быть
локальной сети. Ни деплоя, ни домена, ни TLS для этого не нужно — они недифференцированной кучей тел запросов.
понадобятся, когда сервис поедет на rivendell (шаг 8).
Это шаги 12. Разведка закончена: правило вывода слоя, модель идентичности и формы точки
проверены на живом потоке, выводы — в
[local-research.md](local-research.md), 41 находка.
## Шаги ## Шаги
@@ -20,23 +21,40 @@
**подключаем телефон по локальной сети** **подключаем телефон по локальной сети**
- [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик - [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик
(`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим (`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим
`id`, вывод слоя из выравнивания меток, канонизация с округлением чисел `id`. Вывод слоя из выравнивания меток; развод `sleep_analysis` на два
и хеш содержимого, слияние точек в объект, два формата дат, имени (находка 38). Канонизация с округлением чисел и хеш содержимого
`healthlog reindex`. как детектор изменений. Слияние точек: при столкновении выигрывает
- [ ] **4. Read API.** Каталог метрик со слоями и диапазонами, точки с **более полная** точка, а не последняя (находка 41). Три формата
пагинацией и выбором слоя (сборка из часовых объектов), тренировки, времени: локальное со смещением, RFC 3339 Z, Unix-эпоха внутри
`heartbeatSeries` (находка 39). Словарь `(локаль, строка) → код
HealthKit` для переведённых значений (находка 37). `healthlog reindex`.
- [ ] **4. Каталог и род агрегации.** Род (`cumulative`/`instant`/`unknown`)
**измеряется** сверкой слоёв между собой, а не размечается руками
(находка 40). Каталог метрик со слоями, диапазонами и родом.
- [ ] **5. Read API.** Точки метрики из часовых объектов, выбор слоя,
необязательная свёртка по сетке. Огрубление, когда сетка не задана;
ошибка со списком доступных сеток, когда задана явно. Тренировки,
записи. записи.
- [ ] **5. Самоописание.** Каталог разрезов + выведенные из данных схемы - [ ] **6. Самоописание.** Выведенные из данных схемы содержимого со
содержимого со статистикой + статичная схема контракта API. статистикой + статичная схема контракта API.
- [ ] **6. `healthlog import`.** Заливка полной истории кусками по годам. - [ ] **7. MCP.** Эндпоинт того же процесса, транспорт Streamable HTTP, токен
- [ ] **7. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина. чтения общий с Read API. Три инструмента: каталог, значения за период,
- [ ] **8. Деплой.** `Dockerfile`, сборка образа локально, доставка на значения с разбивкой.
rivendell, конфиг Caddy, поддомен, токены. - [ ] **8. `healthlog import`.** Родной экспорт Apple Health: разбор
`экспорт.xml` в слой `sample`, маршруты GPX, ЭКГ из CSV. Заливка полной
истории кусками по годам.
- [ ] **9. Устаревание нижнего слоя.** Пометка данных HAE старше проверенного
экспорта. Проверка покрытия — непрерывность по дням и сходимость сумм с
часовым слоем. Пометка ≠ удаление: удаление включаем только после того,
как восстановление из экспорта отработает на живых данных хотя бы раз.
- [ ] **10. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина по
потоку, список строк без кода в словаре.
- [ ] **11. Деплой.** `Dockerfile`, сборка образа локально, доставка на
rivendell, конфиг Caddy, поддомены приёма и чтения, токены.
Порядок неслучаен. После шага 2 автоматизация в Health Auto Export включена и Порядок неслучаен. Шаг 4 стоит перед Read API, потому что без измеренного рода
копит настоящие пакеты в сыром архиве. Документация формата HAE скудная, свёртка на шаге 5 неотличима от угадывания. Шаг 8 стоит перед 9: пока импорт
поэтому разбор на шаге 3 пишем по реальным данным, а не по догадкам — и экспорта не написан, помечать что-либо устаревшим не на основании чего.
заодно видим фактический объём и характер потока.
## Отложено ## Отложено
@@ -45,12 +63,21 @@
и теми же данными почти никогда не совпадают побайтно — хеш тела не и теми же данными почти никогда не совпадают побайтно — хеш тела не
сработает. Дедупликация возможна только по канонизированному содержимому, сработает. Дедупликация возможна только по канонизированному содержимому,
а это и делает хеш часового объекта. Отдельная механика не нужна. а это и делает хеш часового объекта. Отдельная механика не нужна.
- ~~Вторая автоматизация без группировки.~~ **Сделано на телефоне:** метрики
здоровья идут в трёх разрезах — несуммированном для несуммируемых метрик,
минутном и часовом для всех.
- ~~Пометка локализованных полей в схемах.~~ **Переросло в шаг 3:** одной
пометки мало, нужен словарь кодов, иначе не сойтись с родным экспортом
(находка 37).
- **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление - **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление
старых тел. Пока архив не подчищается; включить после того, как разбор старых тел. Пока архив не подчищается; включить после того, как разбор
устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна. устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна.
- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по - **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по
факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая
глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10). глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).
- **Месячный проход по ручным секциям.** Симптомы и лекарства заводятся задним
числом на недели; количественным метрикам недельного прохода хватает.
Заводить, когда эти секции появятся в потоке живьём.
- **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный - **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный
эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное
уведомление добавим после. уведомление добавим после.
@@ -60,20 +87,12 @@
а это отслеживается. а это отслеживается.
- **Схема тренировок** — глубину вывода определим по факту, когда увидим, - **Схема тренировок** — глубину вывода определим по факту, когда увидим,
как приходят маршруты. как приходят маршруты.
- **Пометка локализованных полей в схемах.** `context`, `value` и подобные - **Отказ от `heartbeatSeries`.** 93% объёма HRV (находка 39) ради данных,
приходят на языке телефона и изменятся при смене языка iOS — клиентам не которых нет ни в одном планируемом запросе. Решать, когда станет ясна цена
стоит завязываться на конкретные строки хранения нижнего слоя за год.
([local-research.md](local-research.md), находка 8). - **Выгрузка в parquet** — отдельной командой, на случай тяжёлой аналитики
- **Вторая автоматизация без группировки** для `sleep_analysis` и снаружи. DuckDB читает и parquet, и файл SQLite напрямую, поэтому спешить
`heart_rate_variability` — минутная группировка съедает фазы сна и некуда: дверь открыта без миграции.
межударные интервалы ценой ~130 точек в сутки (находка 6).
- **Переход на более грубый нижний слой.** Посекундный слой стоит ~730 МБ в
год против ~20 МБ у минутного. Когда решим, что мелкая подробность не нужна,
достаточно выключить несуммированную автоматизацию — старые данные останутся
в своих слоях, переписывать ничего не придётся. Обратный путь тоже есть:
ручной экспорт из Apple Health + `healthlog import` восстанавливает нижний
слой.
- **NDJSON-поток** для больших выборок из read API. - **NDJSON-поток** для больших выборок из read API.
- **Слой агрегаций** поверх сырья (суточные/недельные срезы).
- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится - **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится
клиент, которому мало отдачи тренировки одним пакетом. клиент, которому мало отдачи тренировки одним пакетом.