From 2070ef438c12da5052a6c74d97c9700e62eb1d22 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 2 Aug 2026 07:02:07 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=D0=BD=20=D0=B4=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=D0=BA=D0=B0,=20=D0=BE=D1=82=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B5=D1=85=D0=B0=D0=BB=D0=BE=20=D0=B2=20=D0=B1=D0=B5=D0=BA?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - содержимое шагов не перечисляется: список работ жил в плане и в беклоге и расходился с каждой закрытой задачей - десять пунктов «Отложено» заведены задачами [idea]; два из них (ретеншен, алерт) уже были в беклоге — в плане лежал дубль - обоснование порядка расписано по шагам: это единственное, чего беклог структурно не вмещает --- README.md | 4 +- docs/backlog/README.md | 7 + docs/backlog/annotacii-k-shemam.md | 21 +++ .../mesyachnyj-prohod-ruchnye-sekcii.md | 21 +++ docs/backlog/ndjson-potok.md | 20 +++ docs/backlog/otkaz-ot-heartbeatseries.md | 19 +++ docs/backlog/porog-sealed.md | 17 +++ docs/backlog/razvorachivanie-marshrutov.md | 17 +++ docs/backlog/samoopisanie-shemy.md | 5 + docs/backlog/vygruzka-v-parquet.md | 18 +++ docs/plan.md | 127 +++++------------- 11 files changed, 181 insertions(+), 95 deletions(-) create mode 100644 docs/backlog/annotacii-k-shemam.md create mode 100644 docs/backlog/mesyachnyj-prohod-ruchnye-sekcii.md create mode 100644 docs/backlog/ndjson-potok.md create mode 100644 docs/backlog/otkaz-ot-heartbeatseries.md create mode 100644 docs/backlog/porog-sealed.md create mode 100644 docs/backlog/razvorachivanie-marshrutov.md create mode 100644 docs/backlog/vygruzka-v-parquet.md diff --git a/README.md b/README.md index bf858e9..361d852 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,9 @@ curl -X POST localhost:8080/api/v1/ingest -d '{"data":{"metrics":[]}}' придумывать своё - [docs/architecture.md](docs/architecture.md) — устройство, схема данных, API, принятые решения - [docs/conventions.md](docs/conventions.md) — как пишем код -- [docs/plan.md](docs/plan.md) — шаги и отложенное +- [docs/plan.md](docs/plan.md) — шаги и обоснование их порядка +- [docs/backlog](docs/backlog/README.md) — что брать следующим, включая + отложенные идеи - [docs/local-research.md](docs/local-research.md) — что показал реальный поток Health Auto Export; источник истины по формату, документация приложения местами расходится с тем, что оно шлёт diff --git a/docs/backlog/README.md b/docs/backlog/README.md index ffe0418..4c2cea3 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -40,4 +40,11 @@ - [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен - [Ретеншен сырого архива](retenshen-syrogo-arhiva.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает - [Активный алерт «данных нет N часов»](alert-tishina-potoka.md) — Пропажу потока сейчас замечает человек, а не сервис +- [[idea] Порог sealed: с какого возраста час считается запечатанным](porog-sealed.md) — WARN на изменение старого часа уже пишется, но порог не выбран — ставим по факту, когда накопится статистика досчёта +- [[idea] Месячный проход по ручным секциям](mesyachnyj-prohod-ruchnye-sekcii.md) — Симптомы и лекарства заводятся задним числом на недели — недельного глубокого прохода им не хватит +- [[idea] Человеческие аннотации поверх выведенных схем](annotacii-k-shemam.md) — Выведенная схема говорит форму, но не смысл метрики — нужно ли описание сверху, зависит от стабильности формата +- [[idea] Отказ от heartbeatSeries](otkaz-ot-heartbeatseries.md) — 93% объёма HRV ради данных, которых нет ни в одном планируемом запросе +- [[idea] Выгрузка в parquet отдельной командой](vygruzka-v-parquet.md) — Дверь для тяжёлой аналитики снаружи: DuckDB читает и parquet, и файл SQLite — спешить некуда, но и закрывать не нужно +- [[idea] NDJSON-поток для больших выборок Read API](ndjson-potok.md) — Выборка нижнего слоя за месяц не влезает в один JSON-ответ — либо поток, либо пагинация +- [[idea] Разворачивание маршрутов тренировок в отдельную таблицу](razvorachivanie-marshrutov.md) — Маршрут лежит блобом внутри тренировки — понадобится, только если появится клиент, которому мало отдачи одним пакетом diff --git a/docs/backlog/annotacii-k-shemam.md b/docs/backlog/annotacii-k-shemam.md new file mode 100644 index 0000000..6572d24 --- /dev/null +++ b/docs/backlog/annotacii-k-shemam.md @@ -0,0 +1,21 @@ +# [idea] Человеческие аннотации поверх выведенных схем + +**Приоритет:** низкий + +Схема содержимого выводится из данных и говорит **форму** — какие поля есть, +какого типа, с какой заполненностью. Чего она не говорит — что метрика значит, +в каких единицах разумны значения и чем `apple_stand_hour` отличается от +`apple_exercise_time`. + +Два пути, и выбор между ними преждевременен: + +- **аннотации поверх выведенных схем** — человеческое описание рядом с + машинным выводом, дописывается по мере надобности; +- **рукописный каталог метрик** — полнее, но описывал бы документацию HAE, а не + то, что он реально прислал. + +Почему идея, а не задача: выбор зависит от того, насколько стабильным окажется +формат. Меняться он может только с обновлением Health Auto Export, а это +отслеживается — значит ответ придёт сам. + +Связано: `docs/architecture.md` → «Самоописание», задача `samoopisanie-shemy`. diff --git a/docs/backlog/mesyachnyj-prohod-ruchnye-sekcii.md b/docs/backlog/mesyachnyj-prohod-ruchnye-sekcii.md new file mode 100644 index 0000000..cb42fa4 --- /dev/null +++ b/docs/backlog/mesyachnyj-prohod-ruchnye-sekcii.md @@ -0,0 +1,21 @@ +# [idea] Месячный проход по ручным секциям + +**Приоритет:** низкий + +Окно досчёта не единое, и это измеренное различие, а не предположение. +Количественные метрики (пульс, шаги, энергия) человек руками не правит — они +опаздывают на часы, и недельного глубокого прохода им хватает. Ручные записи +(`symptoms`, `medications`, `stateOfMind`, `cycleTracking`) заводятся задним +числом на недели и месяцы: симптом или приём лекарства можно отметить за +прошлую дату. + +Растянуть общий глубокий проход на месяц нельзя: тела запросов доходили до +42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую +доставку. Отсюда решение: редкий широкий проход **только по ручным секциям** — +их единицы записей, и месячное окно там почти ничего не стоит. + +Почему идея, а не задача: этих секций в живом потоке ещё не было. Заводить, +когда они появятся, — иначе проход пишется вслепую и проверяется не на чем. + +Связано: `docs/architecture.md` → «Досчёт задним числом», задача +`proverka-novyh-sekcij`. diff --git a/docs/backlog/ndjson-potok.md b/docs/backlog/ndjson-potok.md new file mode 100644 index 0000000..16bae90 --- /dev/null +++ b/docs/backlog/ndjson-potok.md @@ -0,0 +1,20 @@ +# [idea] NDJSON-поток для больших выборок Read API + +**Приоритет:** низкий + +Read API отдаёт ответ одним JSON. Для выборок нижнего слоя за длинный период +это не работает: `heart_rate` в слое `raw` — порядка сотни тысяч координат в +сутки, и месяц такого ряда не влезет ни в память клиента, ни в разумный ответ. + +Сейчас проблема закрыта с другой стороны — правилом размера ответа: сервер сам +берёт сетку погрубее, когда разбивка не задана, и отвечает ошибкой со списком +доступных сеток, когда задана явно. Это защищает агента с ограниченным +контекстом, но не помогает клиенту, которому действительно нужен весь ряд — +например, разовой выгрузке в другой инструмент. + +Почему идея, а не задача: неизвестно, появится ли такой клиент. Если появится, +выбор между NDJSON-потоком и курсорной пагинацией зависит от того, читает он +последовательно или с возвратами. + +Связано: `docs/architecture.md` → «Свёртка и размер ответа», задача +`read-api-tochki`. diff --git a/docs/backlog/otkaz-ot-heartbeatseries.md b/docs/backlog/otkaz-ot-heartbeatseries.md new file mode 100644 index 0000000..d21f530 --- /dev/null +++ b/docs/backlog/otkaz-ot-heartbeatseries.md @@ -0,0 +1,19 @@ +# [idea] Отказ от heartbeatSeries + +**Приоритет:** низкий + +`heart_rate_variability` приезжает вместе с `heartbeatSeries` — рядом +межударных интервалов внутри точки. Это **93% объёма метрики** (находка 39) +ради данных, которых нет ни в одном из планируемых запросов: ни агент, ни +трекер, ни игра межударными интервалами не оперируют. + +Отбросить их означало бы нарушить инвариант «точки хранятся дословно» — и это +не мелочь: срок жизни сырого архива держится ровно на том, что объект является +полной копией. Поэтому вопрос не «выбросить или нет», а «когда цена хранения +нижнего слоя станет заметной». + +Почему идея, а не задача: цена пока не измерена в годовом масштабе, а решение +необратимо — выброшенные ряды не вернуть иначе как из экспорта Apple, где их +может не быть вовсе. + +Связано: `docs/architecture.md` → «Открытые вопросы», находка 39. diff --git a/docs/backlog/porog-sealed.md b/docs/backlog/porog-sealed.md new file mode 100644 index 0000000..a154526 --- /dev/null +++ b/docs/backlog/porog-sealed.md @@ -0,0 +1,17 @@ +# [idea] Порог sealed: с какого возраста час считается запечатанным + +**Приоритет:** низкий + +Флаг `sealed` отмечает часы, которые уже не должны меняться. Механика готова: +изменение запечатанного объекта не отвергается, а пишется `WARN`, и данные +всё равно сохраняются. Не выбрано одно — **с какого возраста** ставить флаг. + +Почему идея, а не задача: правильный порог выводится из эксплуатации, а не из +рассуждения. Наблюдалась глубина досчёта до 22 минут (находка 10), но одного +наблюдения мало — ручные секции правятся задним числом на недели, а +количественные метрики опаздывают на часы. Ставить порог сейчас значит угадать. + +Что нужно, чтобы стало задачей: статистика `WARN` за несколько недель живого +потока и распределение возраста изменённых часов по классам метрик. + +Связано: `docs/architecture.md` → «Часовые объекты метрик». diff --git a/docs/backlog/razvorachivanie-marshrutov.md b/docs/backlog/razvorachivanie-marshrutov.md new file mode 100644 index 0000000..9f99254 --- /dev/null +++ b/docs/backlog/razvorachivanie-marshrutov.md @@ -0,0 +1,17 @@ +# [idea] Разворачивание маршрутов тренировок в отдельную таблицу + +**Приоритет:** низкий + +Тренировка хранится нераскрытой: заголовок — колонками, всё остальное, включая +маршрут и внутренние ряды, — блобом `payload`. Решение осознанное: структура +тренировки разнородна и избыточна (сводки дублируют ряды, находка 15), и +раскладывать её в таблицы значило бы решить за Apple, что в ней главное. + +Разворачивание маршрута в отдельную таблицу точек имело бы смысл для запросов +вида «все пробежки, проходившие через эту область» или «набор высоты по +сегментам» — то есть когда маршрут нужен не целиком, а выборочно. + +Почему идея, а не задача: такого клиента нет. Трекер тренировок берёт +тренировку целиком одним пакетом, и этого ему достаточно. + +Связано: `docs/architecture.md` → «Тренировки и прочие секции». diff --git a/docs/backlog/samoopisanie-shemy.md b/docs/backlog/samoopisanie-shemy.md index 8f22531..f0130e7 100644 --- a/docs/backlog/samoopisanie-shemy.md +++ b/docs/backlog/samoopisanie-shemy.md @@ -13,6 +13,11 @@ Вывод ограничивается по глубине вложенности, иначе схема тренировки с маршрутом разрастётся до размеров самих данных. +**Глубину вывода для тренировок выбираем по факту**, когда увидим, как приходят +маршруты: структура тренировки разнородна, и заранее назначенный предел либо +срежет полезное, либо не срежет ничего. Точка маршрута при этом описываться +должна — блоб трека не непрозрачен, это массив однотипных объектов. + Готово, когда клиент по `/api/v1/metrics/{name}/schema` видит поля, их типы и присутствие, не выкачивая выборку. diff --git a/docs/backlog/vygruzka-v-parquet.md b/docs/backlog/vygruzka-v-parquet.md new file mode 100644 index 0000000..a298ac6 --- /dev/null +++ b/docs/backlog/vygruzka-v-parquet.md @@ -0,0 +1,18 @@ +# [idea] Выгрузка в parquet отдельной командой + +**Приоритет:** низкий + +Отдельная команда, выгружающая хранилище в parquet, — дверь для тяжёлой +аналитики снаружи, без миграции самого хранилища. + +Контекст решения: DuckDB рассматривался как основное хранилище и отложен — +чистого Go-драйвера нет, любой требует cgo, а это стоит нам `CGO_ENABLED=0` и +одного статического бинаря. Но дверь при этом осталась открытой: DuckDB читает +и parquet, и файл SQLite напрямую. Значит спешить некуда — выгрузка добавляется +тогда, когда появится тяжёлый аналитический запрос, а не заранее. + +Почему идея, а не задача: такого запроса пока нет. Ни один из трёх потребителей +(агент-медик, трекер, игра) в аналитике по всей истории не нуждается. + +Связано: `docs/architecture.md` → «Открытые вопросы» → «Хранилище под +аналитику». diff --git a/docs/plan.md b/docs/plan.md index 7e1b1f4..0a5750f 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,121 +1,60 @@ # План -Живой документ: шаги отмечаем по мере готовности, отложенное копится внизу. - Это **порядок и его обоснование**, а не список работ. Единицы работы живут в [беклоге](backlog/README.md) — одна задача, один файл, свой приоритет. План отвечает «почему в таком порядке», беклог — «что брать следующим». +Отсюда правило: **содержимое шага здесь не перечисляется.** Шаг — это название +и статус; что именно в нём делается, знает задача. Иначе список работ живёт в +двух местах и расходится с каждой закрытой задачей. Меняется этот файл, когда +меняется порядок, а не когда закрывается задача. + ## Ближайшая цель Метрики разбираются и ложатся в часовые объекты: тела перестали быть недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в беклоге ноль. -Дальше — **`reindex`**, и он сейчас срочнее остального остатка шага 3. После +Дальше — **`reindex`**, и он сейчас срочнее остального остатка разбора. После миграции 00005 доставки числятся `pending`, а подобрать их некому: код пересборки не написан. Данные целы (тела в архиве, объекты в витрине), но учёт честно говорит «этим разбором не смотрели», и так будет, пока пересборки нет. Тем же кодом закрывается половина задачи «разнести ответ и свёртку». -Потом — остаток шага 3: тренировки и записи со своими `id` (это половина +Потом — остаток разбора: тренировки и записи со своими `id` (это половина потока: `workouts` и `stateOfMind` принимаются и хранятся, но не разбираются), словарь категориальных значений. Разведка закончена: правило вывода слоя, модель идентичности и формы точки -проверены на живом потоке, выводы — в -[local-research.md](local-research.md), 50 находок. +проверены на живом потоке, выводы — в [local-research.md](local-research.md). ## Шаги -- [x] **1. Каркас.** `Taskfile.yml`, `.golangci.yml`, `CLAUDE.md`, TOML-конфиг - с валидацией на старте, логгер, подкоманда `serve` с `/healthz`. -- [x] **2. Приём без разбора.** `POST /api/v1/ingest`: лимит тела, gzip, - запись тела в архив, строка в `delivery`. Разбора ещё нет. - ← **подключаем телефон по локальной сети** -- [~] **3. Разбор и хранилище.** Метрики — сделано (`bucket`, `internal/hae`, - `internal/canon`, `internal/fold`), вместе с частичным разбором: секции, - которых разбор не покрывает, перечисляются, доставка получает статус - `partial` и список непокрытых ключей. Остались тренировки и записи со - своими `id`, `reindex` и словарь категориальных значений. Миграции, - часовые объекты метрик - (`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим - `id`. Вывод слоя из выравнивания меток; развод `sleep_analysis` на два - имени (находка 38). Канонизация с округлением чисел и хеш содержимого - как детектор изменений. Слияние точек: при столкновении выигрывает - **более полная** точка, а не последняя (находка 41). Три формата - времени: локальное со смещением, RFC 3339 Z, Unix-эпоха внутри - `heartbeatSeries` (находка 39). Словарь `(локаль, строка) → код - HealthKit` для переведённых значений (находка 37). `healthlog reindex`. -- [ ] **4. Каталог и род агрегации.** Род (`cumulative`/`instant`/`unknown`) - **измеряется** сверкой слоёв между собой, а не размечается руками - (находка 40). Каталог метрик со слоями, диапазонами и родом. -- [ ] **5. Read API.** Точки метрики из часовых объектов, выбор слоя, - необязательная свёртка по сетке. Огрубление, когда сетка не задана; - ошибка со списком доступных сеток, когда задана явно. Тренировки, - записи. -- [ ] **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, поддомены приёма и чтения, токены. +- [x] **1. Каркас.** +- [x] **2. Приём без разбора.** ← **подключаем телефон по локальной сети** +- [~] **3. Разбор и хранилище.** Метрики — сделано; тренировки и записи со + своими `id`, `reindex` и словарь категориальных значений — нет. +- [ ] **4. Каталог и род агрегации.** +- [ ] **5. Read API.** +- [ ] **6. Самоописание.** +- [ ] **7. MCP.** +- [ ] **8. `healthlog import`.** +- [ ] **9. Устаревание нижнего слоя.** +- [ ] **10. Наблюдаемость.** +- [ ] **11. Деплой.** -Порядок неслучаен. Шаг 4 стоит перед Read API, потому что без измеренного рода -свёртка на шаге 5 неотличима от угадывания. Шаг 8 стоит перед 9: пока импорт -экспорта не написан, помечать что-либо устаревшим не на основании чего. +Порядок неслучаен, и это единственное, чего нет в беклоге: -## Отложено +- **Каталог и род агрегации — перед Read API.** Без измеренного рода свёртка в + ответе неотличима от угадывания, а ошибиться здесь дорого: просуммировать + нижний слой значит завысить втрое. +- **`healthlog import` — перед устареванием нижнего слоя.** Пока импорт + экспорта не написан, помечать что-либо устаревшим не на основании чего. +- **Read API — перед MCP.** Адаптер собственной логики не несёт, он переводит + вызовы в те же обработчики; переводить пока нечего. -- ~~Отсев идентичных тел доставок по `sha256`.~~ **Вычеркнуто:** находка 2 - показала, что порядок ключей в JSON нестабилен, поэтому два пакета с одними - и теми же данными почти никогда не совпадают побайтно — хеш тела не - сработает. Дедупликация возможна только по канонизированному содержимому, - а это и делает хеш часового объекта. Отдельная механика не нужна. -- ~~Вторая автоматизация без группировки.~~ **Сделано на телефоне:** метрики - здоровья идут в трёх разрезах — несуммированном для несуммируемых метрик, - минутном и часовом для всех. -- ~~Пометка локализованных полей в схемах.~~ **Переросло в шаг 3:** одной - пометки мало, нужен словарь кодов, иначе не сойтись с родным экспортом - (находка 37). -- **Ретеншен сырого архива** — удаление доставок старше последнего - проверенного экспорта (не фиксированный срок: журнал не должен рваться). - Пока архив не подчищается; включить после того, как разбор устоится. - Предусловие снято: статус `partial` и список непокрытых секций готовы, и - ретеншен обязан спрашивать статус, а не считать `parsed` разрешением. - Вместе с ним действует правило — задача, которая начинает разбирать секцию, - тем же изменением переводит `partial`-строки с этим ключом в `pending`. -- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по - факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая - глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10). -- **Месячный проход по ручным секциям.** Симптомы и лекарства заводятся задним - числом на недели; количественным метрикам недельного прохода хватает. - Заводить, когда эти секции появятся в потоке живьём. -- **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный - эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное - уведомление добавим после. -- **Аннотации к схемам** — человеческие описания метрик поверх выведенных - схем, либо рукописный каталог. Выбор зависит от того, насколько стабильным - окажется формат; меняться он может только с обновлением Health Auto Export, - а это отслеживается. -- **Схема тренировок** — глубину вывода определим по факту, когда увидим, - как приходят маршруты. -- **Отказ от `heartbeatSeries`.** 93% объёма HRV (находка 39) ради данных, - которых нет ни в одном планируемом запросе. Решать, когда станет ясна цена - хранения нижнего слоя за год. -- **Выгрузка в parquet** — отдельной командой, на случай тяжёлой аналитики - снаружи. DuckDB читает и parquet, и файл SQLite напрямую, поэтому спешить - некуда: дверь открыта без миграции. -- **NDJSON-поток** для больших выборок из read API. -- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится - клиент, которому мало отдачи тренировки одним пакетом. +## Отложенное + +Отложенных идей в плане нет: их место — [беклог](backlog/README.md) с пометкой +`[idea]`. Два дома для одной идеи расходятся, и тогда полного списка не даёт ни +один; вопрос «что мы решили отложить» задаётся беклогу.