пересмотрена архитектура под слои, свёртку в ответе и MCP
- агрегация появилась в ответе на запрос: род метрики измеряется сверкой слоёв, нижний слой HAE не суммируется никогда - переведённые строки хранятся дословно с приписанным кодом HealthKit; снято правило «настройки данных у всех проходов одинаковы» — слой в ключе - добавлены устаревание нижнего слоя по проверенному экспорту и MCP поверх read API; план вырос до 11 шагов
This commit is contained in:
@@ -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` и с обрезкой.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+254
-31
@@ -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
|
||||
остаётся отдельной командой на случай тяжёлой аналитики снаружи.
|
||||
|
||||
+53
-34
@@ -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.
|
||||
- **Слой агрегаций** поверх сырья (суточные/недельные срезы).
|
||||
- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится
|
||||
клиент, которому мало отдачи тренировки одним пакетом.
|
||||
|
||||
Reference in New Issue
Block a user