From 19bb1a377349866c6d8ff6d337848c0616c3d0b8 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 1 Aug 2026 21:40:23 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BF=D0=B0=D1=81=D0=BF=D0=BE=D1=80=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=E2=80=94=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C,=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B8,=20=D1=80=D0=B5=D1=84=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - восемь типовых сценариев работы, у каждого проверяемое «успех» и ссылка на шаг плана - референсы по задачам проекта, с оговорками где чужое решение не годится, и три вопроса без готового ответа - правило «развилка или блокер — сперва prior art» продублировано в CLAUDE.md --- CLAUDE.md | 8 +- README.md | 3 + docs/passport.md | 186 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 docs/passport.md diff --git a/CLAUDE.md b/CLAUDE.md index de7178f..c3dd3e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ # CLAUDE.md Памятка для работы над healthlog. Перед задачей прочитай также +[docs/passport.md](docs/passport.md) (цель, сценарии, референсы), [README.md](README.md), [docs/architecture.md](docs/architecture.md), [docs/conventions.md](docs/conventions.md) и [docs/plan.md](docs/plan.md). @@ -94,7 +95,12 @@ Module path — `git.vakhrushev.me/av/healthlog`. решать не мне, **вынимается блокером** в секцию `блокеры` беклога (что решить, варианты с ценой каждого, что стоит без решения, рекомендация), задача переформулируется на остаток, остаток доводится до коммита. Блокеры -разбираются пачками. Спрашиваем только про **необратимое**: деплой, выкладку +разбираются пачками. + +**Развилка или блокер — сперва prior art.** Проект не уникален: прежде чем +проектировать своё, смотрим, как это решено в референсах +[паспорта](docs/passport.md) и в интернете. Готовое решение либо берётся, либо +отвергается с названной причиной — и причина идёт в `architecture.md`. Спрашиваем только про **необратимое**: деплой, выкладку наружу, удаление или перезапись данных в `./data`. Гейт блокирует: пока `task gate` красный, опиниативные проходы ревью не diff --git a/README.md b/README.md index 07c0c36..fdfe71b 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}' ## Документация +- [docs/passport.md](docs/passport.md) — цель проекта, типовые сценарии + работы, референсы: чужие проекты, у которых смотрим решения, прежде чем + придумывать своё - [docs/architecture.md](docs/architecture.md) — устройство, схема данных, API, принятые решения - [docs/conventions.md](docs/conventions.md) — как пишем код - [docs/plan.md](docs/plan.md) — шаги и отложенное diff --git a/docs/passport.md b/docs/passport.md new file mode 100644 index 0000000..d856251 --- /dev/null +++ b/docs/passport.md @@ -0,0 +1,186 @@ +# Паспорт проекта + +Куда мы идём, как выглядит работа проекта в жизни и у кого подсматривать, +когда упёрлись. Самый верхний документ: [plan.md](plan.md) отвечает «в каком +порядке», [architecture.md](architecture.md) — «как устроено», паспорт — +**«зачем и для кого»**. + +## Цель + +Все мои данные Apple Health лежат в одном месте, откуда их берёт любой мой +проект — так, чтобы ни один из них не писал приём, дедупликацию и хранение +заново. + +Цель достигнута, когда одновременно верно: + +- телефон шлёт непрерывно, и поток не требует внимания неделями; +- история не начинается с даты запуска сервиса — родной экспорт заливает всё + с 2019 года, и дальше она только дополняется; +- потребитель получает нужный разрез **одним запросом**, ничего не зная про + слои, часовые объекты и особенности HAE; +- любое повреждение — включая нашу же ошибку в разборе годовой давности — + лечится пересборкой из журнала, а не восстановлением из бекапа; +- когда поток встанет, это будет видно, а не обнаружится через месяц по + пустому графику. + +**Мера трезвая:** проект удался, если про него не вспоминают, а данные при +этом на месте и полны. + +### Что целью не является + +- **Аналитика.** Ни норм, ни трендов, ни рекомендаций, ни дашборда. Смысл + значениям придаёт тот, кто их читает; наше дело — отдать их неискажёнными. +- **Полнота покрытия HealthKit ради полноты.** Разбираем то, что реально + приходит в поток, и признаёмся в неразобранном, а не гонимся за списком + типов из документации Apple. +- **Многопользовательность.** Один человек, свои устройства, свой VPS. + Никакой модели доступа сложнее двух токенов. +- **Реальное время.** Поток пачечный по природе (iOS не пускает к Health при + заблокированном телефоне), задержка в минуты — норма, а не дефект. + +## Типовые сценарии + +Восемь ситуаций, ради которых всё написано. Номера шагов — по +[plan.md](plan.md). + +**1. Молчаливый приём** (шаги 2–3). Телефон каждые 5 минут шлёт доставку; +сервис кладёт тело в архив, отвечает `200`, разбирает метрики в часовые +объекты. Никто ничего не спрашивает и не смотрит. +*Успех:* сутки работы не порождают ни одной строки лога уровня `WARN` и ни +одного действия человека. + +**2. Дыра закрывается сама** (шаг 3). Телефон был заблокирован ночью, +автоматизация не отработала, часть дня отсутствует. Средний проход (сутки) и +глубокий (неделя) переприсылают окно целиком, точки доезжают. +*Успех:* дыра моложе недели закрывается без вмешательства; никто о ней даже не +узнаёт. + +**3. Квартальный экспорт** (шаги 8–9). Раз в 2–3 месяца владелец выгружает +родной экспорт Apple Health и скармливает его `healthlog import`. Нижний слой +за прошлое становится честным (настоящие сэмплы вместо посекундной развёртки +HAE), а сырой архив получает право быть подчищенным до даты снапшота. +*Успех:* экспорт разобран, покрытие периода проверено, объём архива вернулся к +норме, ничего не потеряно. + +**4. Агент спрашивает про здоровье** (шаги 4, 5, 7). Агент-медик по MCP +спрашивает каталог («что у тебя вообще есть»), затем «шаги по дням за месяц» +или «пульс за вчера». Получает свёрнутый ряд с честным указанием слоя, сетки и +рода агрегации. +*Успех:* ответ влезает в контекст агента, число не завышено вдвое, и агенту не +пришлось знать про слои, чтобы спросить правильно. + +**5. Приложение берёт тренировки** (шаг 5). Разборщик тренировок запрашивает +заголовки за период, потом одну тренировку целиком — с маршрутом и рядом +пульса. +*Успех:* тренировка отдана одним пакетом в том виде, в каком её прислал Apple, +без нашей интерпретации того, что в ней главное. + +**6. Разбор поменялся** (шаг 3, `reindex`). Мы начали разбирать секцию, которую +раньше пропускали, или нашли ошибку в старом разборе. Запускается пересборка +по сырому архиву: `import(экспорт) + replay(доставки по received_at)`. +*Успех:* состояние пересобрано детерминированно, повтор даёт то же самое, +доставки со снятым статусом `partial` подобраны. + +**7. Владелец проверяет, жив ли поток** (шаг 10). Раз в сколько-то дней — +взгляд в `/stats`: когда была последняя доставка, сколько точек, есть ли +тишина, какие строки не легли в словарь кодов. +*Успех:* один экран отвечает «всё идёт» или «встало тогда-то», без залезания +в SQLite. + +**8. Приехало незнакомое** (шаг 3). HAE обновился и прислал новую метрику, +новую форму точки или новую секцию. Тело сохраняется, ответ — `200`, разбор +честно помечает доставку `partial` и перечисляет непокрытое. +*Успех:* данные в архиве и восстановимы, факт виден в логе и `/stats`, а +доразобрать их можно потом — задним числом и без потерь. + +## Мы не делаем уникального + +Ни одна задача этого проекта не нова. Приём данных с телефона, дедупликация по +координатам, несколько разрешений одного ряда, накопительное против +мгновенного, свёртка состояния по журналу событий, ретеншен сырых тел — всё +это решалось десятки раз, и чаще всего лучше, чем выйдет с первого раза у нас. +Своё здесь ровно одно: **удобство под себя** — свои источники, свои +потребители, свой объём. + +Отсюда правило работы: + +> **Развилка или блокер — сперва prior art.** Прежде чем проектировать своё, +> посмотреть, как это сделано в проектах ниже и в интернете. Готовое решение +> либо берётся, либо отвергается **с названной причиной** — и тогда причина +> идёт в [architecture.md](architecture.md), а не теряется. + +Формулировка «у всех так, а у нас иначе, потому что…» — это готовое +обоснование решения. Формулировка «я придумал вот так» — ещё нет. + +## Референсы + +### Приём данных Health Auto Export + +| Проект | Что смотреть | Оговорка | +| --- | --- | --- | +| [Lybron/health-auto-export](https://github.com/Lybron/health-auto-export) | Документация формата от автора приложения — единственная, что есть | Местами расходится с тем, что приложение шлёт: см. [local-research.md](local-research.md) | +| [HealthyApps/health-auto-export-server](https://github.com/HealthyApps/health-auto-export-server) | Как приём видят сами авторы HAE: какие поля считают опорными | Их цель — Grafana, то есть аналитика; хранения журнала нет | +| [irvinlim/apple-health-ingester](https://github.com/irvinlim/apple-health-ingester) | **Ближайший по стеку**: Go, HTTP-приём HAE, конфиг, токен, разведение бэкендов | Приём синхронный, слияние по метке при записи, сырого журнала нет — ровно тот дизайн, от которого мы ушли осознанно | +| [po4yka/apple-health-export-automation-backup](https://github.com/po4yka/apple-health-export-automation-backup) | Заявлены дедупликация, tombstones и dead-letter queue — смотреть, когда встанет вопрос «куда девать неразобранную доставку» | Python/FastAPI + InfluxDB; модель хранения нам не подходит | + +### Родной экспорт Apple Health + +| Проект | Что смотреть | +| --- | --- | +| [dogsheep/healthkit-to-sqlite](https://github.com/dogsheep/healthkit-to-sqlite) | Прямой прототип `healthlog import` (шаг 8): разбор `export.xml` в SQLite, включая маршруты тренировок из GPX и обход того, что файл не влезает в память | + +### Отдача агентам + +| Проект | Что смотреть | Оговорка | +| --- | --- | --- | +| [HealthyApps/health-auto-export-mcp-server](https://github.com/HealthyApps/health-auto-export-mcp-server) | Официальный MCP поверх тех же данных: набор инструментов и их именование (шаг 7) | Правило размера ответа там **не решено** — в инструкции честно написано «помните про контекст, данные может понадобиться агрегировать». Значит на шаге 5 брать готовое неоткуда | +| [meltforce/FreeReps](https://github.com/meltforce/FreeReps) | Self-hosted сервер здоровья с MCP-интерфейсом — второй взгляд на тот же контракт | Тянет за собой дашборд и визуализацию, то есть нашу границу «хранилище, а не аналитика» не держит | + +### Слои, свёртка и род агрегации (шаги 4–5) + +Здесь чужого опыта больше всего, и он старше нашей задачи на двадцать лет. + +- **HealthKit сам** — деление на `HKQuantityTypeCumulative` и + `HKQuantityTypeDiscrete`, а в `HKStatisticsQuery` — `.cumulativeSum` против + `.discreteAverage`. Первоисточник того самого рода, который мы измеряем. + У Apple он объявлен типом; у нас типа нет, потому что HAE его не шлёт. +- **Home Assistant, recorder и long-term statistics** — `state_class` + (`measurement` / `total` / `total_increasing`) и то, что для одних метрик + хранится `mean`/`min`/`max`, а для других `sum`. Плюс их правило пересчёта + при смене класса. Разница с нами: там род **объявляет** интеграция, у нас + его некому объявить, поэтому измеряем сверкой слоёв. +- **Graphite** — `storage-schemas.conf` (несколько разрешений одного ряда в + одном файле) и `storage-aggregation.conf` (`aggregationMethod`: + average / sum / last / max, плюс `xFilesFactor` — доля точек, ниже которой + свёртка не делается). Это буквально «слои + род», только заданы конфигом. + `xFilesFactor` — готовый ответ на вопрос, который у нас ещё не задан: что + делать со свёрткой неполного ведра. +- **Prometheus** — counter против gauge и правило «счётчик не суммируют, а + берут `rate`». Полезен как формулировка ошибки, которую мы боимся: сложить + не тот разрез и получить завышение. + +### Идентичность, слияние, журнал + +- **Литература по CRDT** (state-based merge, LWW-Register, semilattice) — наше + «выигрывает более полная точка» это merge в полурешётке, а отвергнутое + «выигрывает последняя» — LWW. Оттуда же требование коммутативности и + идемпотентности, и оттуда же понятно, почему нетранзитивное отношение победы + ломает воспроизводимость. +- **Event sourcing и log compaction** (Kafka) — «состояние = свёртка журнала», + снапшот плюс хвост событий, требование детерминированности реплея. Наша + формула `import(экспорт) + replay(доставки)` — ровно этот шаблон. +- **FHIR Observation** — `component` для составных измерений (давление), + `effectiveDateTime` против `effectivePeriod`. Прямой аналог решения «конец + равен началу у точки-измерения». Семантику FHIR при этом не берём: мы храним + дословно, а не переводим Apple в чужую онтологию. + +### Чего в референсах нет + +Стоит знать заранее, что по трём вопросам готового ответа не нашлось и +проектировать придётся самим: + +- **предел размера ответа агенту** — все известные MCP-адаптеры перекладывают + его на человека; +- **измерение рода агрегации из данных** — везде он объявляется руками; +- **честность после чистки архива** — ни один из проектов не признаётся + клиенту, что за старый период у него стало меньше разрезов.