пересмотрена архитектура под слои, свёртку в ответе и 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
+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
остаётся отдельной командой на случай тяжёлой аналитики снаружи.