пересмотрена архитектура под слои, свёртку в ответе и 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,
хранит их и отдаёт другим моим проектам через 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` и с обрезкой.
+31 -15
View File
@@ -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
View File
@@ -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
View File
@@ -4,12 +4,13 @@
## Ближайшая цель
Минимальное ядро, которое принимает и сохраняет пакет. Проверяем его
**локально**: сервис поднят на рабочей машине, телефон шлёт на её IP по
локальной сети. Ни деплоя, ни домена, ни TLS для этого не нужно — они
понадобятся, когда сервис поедет на rivendell (шаг 8).
Приём работает, поток настоящих пакетов копится в сыром архиве. Дальше —
разбор и хранилище (шаг 3): всё, что нужно, чтобы данные перестали быть
недифференцированной кучей тел запросов.
Это шаги 12.
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
проверены на живом потоке, выводы — в
[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.
- **Слой агрегаций** поверх сырья (суточные/недельные срезы).
- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится
клиент, которому мало отдачи тренировки одним пакетом.