заведён беклог проекта

- 20 задач: 8 высоких, 9 средних, 3 низких; две заведены идеями — сутки при
  смене часового пояса и пересекающиеся источники одной метрики
- план сведён к порядку и его обоснованию, единицы работы переехали в беклог
This commit is contained in:
av
2026-08-01 14:11:42 +03:00
parent 36908b774c
commit 59ec613184
24 changed files with 483 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
# Кладбище беклога
Задачи, покинувшие беклог без реализации. Пишется `backlog.py close`.
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … -->
+32
View File
@@ -0,0 +1,32 @@
# Беклог
Одна задача = один файл `<slug>.md` + строка в этом индексе.
Приоритет — грубая оценка «ценность / стоимость». Спекулятивные
задачи помечены `[idea]` в заголовке. Ведётся скиллом `backlog`.
## высокий
- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Разбор будет ошибаться, а окно на исправление — 14 дней жизни архива
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Резервное копирование ./data](bekap-dannyh.md) — История здоровья существует в единственном экземпляре на рабочей машине — потеря каталога необратима
## средний
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
- [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
- [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [Деплой на rivendell](deploy-rivendell.md) — Сервис живёт на рабочей машине — телефон достаёт до него только дома
- [Управление токенами и секретами](upravlenie-sekretami.md) — Проверка токенов выключена, config.docker.toml коммитится — так нельзя выезжать наружу
- [[idea] Что считать сутками при смене часового пояса](sutki-i-chasovoj-poyas.md) — Шаги за день — базовый запрос трекера и игры, но чей это день при перелёте, не решено
- [[idea] Пересекающиеся источники одной метрики](peresekayushchiesya-istochniki.md) — Сон пишут и часы, и стороннее приложение — сумма по обоим задвоит ночь
## низкий
- [Устаревание нижнего слоя после экспорта](ustarevanie-nizhnego-sloya.md) — Нижний слой растёт на ~100 тысяч координат в сутки, а после экспорта Apple он избыточен
- [Ретеншен сырого архива](retenshen-syrogo-arhiva.md) — Архив не подчищается вовсе — 14-дневный срок объявлен, но не работает
- [Активный алерт «данных нет N часов»](alert-tishina-potoka.md) — Пропажу потока сейчас замечает человек, а не сервис
+17
View File
@@ -0,0 +1,17 @@
# Активный алерт «данных нет N часов»
**Приоритет:** низкий
Пропажу потока сейчас замечает человек. `/stats` покажет факт, но только если
туда заглянуть — а заглядывают ровно тогда, когда уже что-то заподозрили.
Активное уведомление закрывает разрыв: сервис сам сообщает, что данных нет
дольше порога. Канал — тот же, что у остальных моих проектов.
Порог не единый: быстрый проход идёт каждые 5 минут, но ночью телефон
заблокирован и тишина штатна (находка 28). Значит порог считается по времени
суток или по последней успешной доставке каждой автоматизации отдельно.
Приоритет низкий, пока сервис на рабочей машине и я вижу его каждый день.
После деплоя на rivendell поднимется.
+17
View File
@@ -0,0 +1,17 @@
# Резервное копирование ./data
**Приоритет:** высокий
`./data` — это база и 16 МБ сырого архива в единственном экземпляре на рабочей
машине. Родной экспорт Apple делается раз в 2–3 месяца, то есть потеря каталога
стоит всей истории с момента последнего экспорта.
Отдельная тонкость: SQLite нельзя копировать `cp` под нагрузкой, а телефон шлёт
непрерывно — нужен `VACUUM INTO` или backup API, а не копирование файла.
Приоритет высокий не по ценности, а по асимметрии: задача на пару часов
страхует данные, которые иначе не восстановить ничем.
Готово, когда есть команда снятия копии, она отрабатывает на живой базе под
приёмом, и копия проверяется восстановлением хотя бы раз.
+22
View File
@@ -0,0 +1,22 @@
# Деплой на rivendell
**Приоритет:** средний
Сервис живёт в контейнере на рабочей машине, телефон достаёт до него только
дома. Вне дома экспорт копится и уезжает пачкой при возвращении — работает, но
это не то, ради чего заводился всегда доступный VPS.
Есть готовый образец: jellybit собирает образ локально и отправляет на сервер
через `docker save`/`load`, Caddy впереди терминирует TLS. Здесь то же самое.
Шаги:
- сборка образа локально, доставка на rivendell;
- Caddy: поддомен приёма и поддомен чтения (плюс MCP на нём же);
- тома под `./data`, конфиг с токенами отдельно, права `0600`.
Готово, когда телефон шлёт на публичный адрес из любой сети, а агент читает по
тому же домену.
Зависит от задачи про секреты: выезжать наружу с выключенной проверкой токенов
нельзя.
+22
View File
@@ -0,0 +1,22 @@
# Импорт родного экспорта Apple Health
**Приоритет:** средний
Слой `sample` пуст: настоящих сэмплов HealthKit в потоке нет вовсе — HAE отдаёт
посекундную развёртку, а не измерения (находка 34). Полная история и точные
сэмплы лежат в zip родного экспорта.
Это же основание для устаревания нижнего слоя и единственный способ поднять
историю глубже недели: дыра старше недели проходами синхронизации не чинится.
Шаги:
- разбор `экспорт.xml` (HealthKit Export Version 14, `<Record>` с
`startDate`/`endDate`/`value`/`sourceName`/`device`) в слой `sample`;
- маршруты GPX и ЭКГ отдельными CSV — они не в XML;
- заливка кусками по годам: файл измеряется сотнями мегабайт.
Готово, когда история за несколько лет лежит в слое `sample`, а суммы по нему
сходятся с часовым слоем HAE на пересечении периодов.
Есть готовый файл для проверки: `/home/av/MediaEverything/HealthData/apple_health/`.
+23
View File
@@ -0,0 +1,23 @@
# MCP-сервер поверх Read API
**Приоритет:** высокий
Агент-медик — первый заказчик проекта. Ему нужна актуальная сводка, а не срез
на дату последнего ручного экспорта.
Транспорт — **Streamable HTTP**, не stdio: сервис живёт на VPS, агент ходит по
сети. Отсюда: MCP — эндпоинт того же процесса и того же порта, аутентификация —
тот же токен чтения, что у Read API. Отдельного контура доступа не заводим:
MCP не даёт ничего, чего не даёт HTTP, и права обязаны совпадать.
Инструментов три: каталог разрезов, значения за период, значения с разбивкой.
Собственной логики в адаптере нет.
Правило размера ответа здесь не украшение, а необходимость: у сетевого агента
нет способа «посмотреть поближе» иначе, чем повторным вызовом.
Готово, когда агент подключается по URL и отвечает на «как я спал на прошлой
неделе» без промежуточного кода.
Связано: `docs/architecture.md` → «MCP», план шаг 7.
+24
View File
@@ -0,0 +1,24 @@
# OpenAPI-спека и Swagger UI
**Приоритет:** высокий
Потребителей три, и один из них — агент, который читает контракт машиной.
Пересказывать форму ответа в чате не годится, а самоописание из плана покрывает
только **содержимое** метрик; форма конверта, коды ответов и параметры запроса —
это OpenAPI.
Заодно снимает вопрос, чем быть «схеме контракта API» из раздела самоописания:
ею и будет OpenAPI-документ, а не собственный формат.
Шаги:
- спека OpenAPI 3.1 на приём, каталог, точки, тренировки, записи, `/stats`;
- Swagger UI на отдельном пути, отдаётся самим сервисом (без внешних CDN —
он должен работать в локальной сети без интернета);
- проверка актуальности спеки в гейте: контракт разъезжается молча.
Готово, когда по спеке можно сгенерировать клиент, а Swagger UI открывается
локально и выполняет запрос к живому сервису.
Развилка на решение: спека пишется руками как источник истины или выводится из
кода. Для маленького API рукописная спека честнее — но это стоит обсудить.
@@ -0,0 +1,20 @@
# [idea] Пересекающиеся источники одной метрики
**Приоритет:** средний
Одну метрику пишут несколько источников: сон — часы и стороннее приложение
AutoSleep, шаги — часы и телефон одновременно. Поле `source` при этом не
источник, а множество вкладчиков (`Apple Watch Ultra 3|iPhone (Anton)`), и
состав меняется от группировки (находка 36).
По координатному ключу столкновений почти нет — источники пишут в разные метки.
Но по **времени** интервалы пересекаются, и сумма по обоим задвоит ночь сна или
дневные шаги.
Почему идея: неясно, чья это ответственность. Варианты — отдавать как есть и
предупреждать в каталоге, выбирать источник по приоритету, отдавать разбивку по
источникам отдельным разрезом. Первое честнее всего, третье полезнее всего.
Для агента-медика вопрос практический: «сколько я спал» не должно давать
двойной ответ.
+21
View File
@@ -0,0 +1,21 @@
# Проверка секций, которых поток ещё не приносил
**Приоритет:** средний
Разбор пишется по тем данным, что видел поток, а он приносил только `metrics`,
`workouts` и `stateOfMind`. Не виденны живьём: `symptoms`, `ecg`,
`heartRateNotifications`, `cycleTracking`, `medications`, а также вес — а вес
агенту-медику нужен наверняка.
Пользователь настраивает оставшиеся метрики на телефоне, так что данные
появятся сами. Задача — не пропустить момент: убедиться, что новые секции
разбираются, а не молча падают в `parse_status`.
Отдельный вопрос, на который ответят эти же данные: есть ли эти секции в родном
экспорте Apple. Если нет — экспорт им не источник истины, и устаревание
нижнего слоя к ним неприменимо, держим всегда.
Готово, когда каждая новая секция либо разобрана, либо явно описана в
`docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках
разбора.
+31
View File
@@ -0,0 +1,31 @@
# Разбор метрик в часовые объекты
**Приоритет:** высокий
Приём работает, но дальше архива данные не идут: 89 доставок лежат телами
`.json.gz`, а в SQLite только строки `delivery`. Всё остальное — каталог,
Read API, MCP — стоит на этой задаче.
Правила выведены на живом потоке и проверены, изобретать заново не нужно
(`docs/local-research.md`, находки 33, 35, 36, 38, 39, 41):
- ключ точки — координаты `метрика + слой + метка`; `source` в ключ не входит;
- слой выводится из выравнивания меток: плотная метрика (≥10 точек)
классифицируется сама, редкая наследует преобладающий слой доставки;
- при столкновении выигрывает более полная точка, а не последняя пришедшая
(0.66% координат различаются набором полей, а не значением);
- три формата времени: локальное со смещением, RFC 3339 Z, Unix-эпоха внутри
`heartbeatSeries`;
- `sleep_analysis` разводится на два имени — поэпизодное и суточную сводку.
Шаги:
- миграция `bucket` (`метрика + слой + час`, payload gzip-BLOB, `sealed`);
- разбор метрик, вывод слоя, канонизация с округлением до ~12 значащих цифр;
- слияние точек в объект read-modify-write, хеш объекта как детектор изменений;
- `docs/database.md` — ER-схема (её требует шаг гейта `er-schema`).
Готово, когда по существующим 89 доставкам собирается хранилище, а суммы по
часовому слою сходятся с проверкой из `tmp/research/`.
Связано: `docs/architecture.md` → «Хранилище», план шаг 3.
+21
View File
@@ -0,0 +1,21 @@
# Read API: точки, выбор слоя, свёртка по сетке
**Приоритет:** высокий
Сейчас данные достаются только `sqlite3` на хосте. Все три сценария —
агент-медик, трекер тренировок, фитнес-игра — упираются в отсутствие чтения.
Формы запроса ровно две, и это один запрос с необязательным параметром:
`?from&to` — все значения за период (вес, лекарства, симптомы), `?from&to&bucket`
— с разбивкой (шаги, энергия).
Решение по размеру ответа (вариант «б»): разбивка не задана и ответ не влезает —
сервер сам берёт сетку погрубее и **называет её в ответе**; разбивка задана явно
и не влезает — ошибка со списком доступных сеток, а не тихая подмена. Различие
существенно: иначе агент, попросивший минутную сетку, получит суточные суммы.
Готово, когда «шаги за неделю по дням» и «вес за год» отвечаются одним запросом
каждый, а в ответе всегда видно `layer`, `bucket` и `aggregation`.
Связано: `docs/architecture.md` → «Read API», план шаг 5.
+16
View File
@@ -0,0 +1,16 @@
# Пересборка хранилища из сырого архива
**Приоритет:** высокий
Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск.
Риск в другом: сырой архив живёт 14 дней, и окно на исправление ошибки равно
этому сроку. Без `reindex` ошибка разбора становится потерей данных.
Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род
агрегации на полном ряду доставок надёжнее, чем на одной.
Готово, когда `healthlog reindex` пересобирает хранилище с нуля из `data/raw`
и результат совпадает с накопленным приёмом.
Связано: план шаг 3, `docs/architecture.md` → «Сырой архив».
+14
View File
@@ -0,0 +1,14 @@
# Ретеншен сырого архива
**Приоритет:** низкий
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является.
Включать **после** того, как разбор устоится и `reindex` докажет, что
хранилище действительно пересобирается: иначе страховка исчезнет раньше, чем
перестанет быть нужна.
Готово, когда старые тела удаляются по расписанию, а `/stats` показывает
глубину архива в днях.
+21
View File
@@ -0,0 +1,21 @@
# Измеренный род агрегации и каталог разрезов
**Приоритет:** высокий
Решено (вариант «б» груминга): свёртка живёт в ответе, но род метрики
**измеряется**, а не размечается руками. Форма точки рода не выдаёт —
`Avg`/`Min`/`Max` есть только у `heart_rate`, всё остальное приходит в `qty`
(находка 40). Единицы дают процентов девяносто и ломаются на краях.
Метод: одна метрика лежит в минутном и часовом разрезе одновременно. Часовое
значение сходится с суммой минутных — накопительная; со средним — мгновенная;
данных не хватило — `unknown`, и свёртка по такой метрике не предлагается вовсе.
Жёсткое правило: накопительные метрики никогда не сворачиваются из нижнего слоя
HAE. Он не сэмплы, а посекундная развёртка (находка 34), сумма по нему завышена.
Готово, когда каталог отдаёт по каждой метрике единицы, род и список слоёв с
диапазонами, а род проставлен измерением на живой истории.
Связано: `docs/architecture.md` → «Слои гранулярности», план шаг 4.
+21
View File
@@ -0,0 +1,21 @@
# Выведенные из данных схемы содержимого
**Приоритет:** средний
Метрик у Apple больше сотни, формы точек разные, и рукописный каталог описывал
бы документацию HAE, а не то, что он реально прислал. Схема содержимого
**выводится из данных** тем же проходом разбора: незнакомая метрика описывает
себя сама, без релиза.
Схема отдаётся вместе со статистикой — для потребителя она важнее формального
типа: сколько точек, первая и последняя метка, единицы, доля присутствия поля.
Вывод ограничивается по глубине вложенности, иначе схема тренировки с маршрутом
разрастётся до размеров самих данных.
Готово, когда клиент по `/api/v1/metrics/{name}/schema` видит поля, их типы и
присутствие, не выкачивая выборку.
Связано: `docs/architecture.md` → «Самоописание». Форму конверта API описывает
не эта задача, а OpenAPI.
@@ -0,0 +1,22 @@
# Словарь категориальных значений → коды HealthKit
**Приоритет:** средний
HAE отдаёт перечислимые значения строками локали телефона: «БДГ», «Сидячий
образ жизни», «В помещении Ходьба». Родной экспорт Apple при этом говорит
кодами (`HKCategoryValueSleepAnalysisAsleepREM`) — источники несопоставимы
(находка 37).
Три следствия, и третье решающее: клиент угадывает словарь; смена языка
телефона молча расколет историю; сверить покрытие экспортом нечем — а на этой
сверке стоит устаревание нижнего слоя.
Решение (вариант «б»): строка хранится **дословно**, рядом кладётся выведенный
код. Словарь ключуется парой `(локаль, строка)`, локаль берётся из
`Accept-Language`. Незнакомая строка → пустой код, а не догадка.
Готово, когда фазы сна из потока и из экспорта Apple сравниваются напрямую, а
`/stats` показывает строки, для которых кода ещё нет.
`stateOfMind` в словаре не нуждается — он и так шлёт коды HealthKit.
+18
View File
@@ -0,0 +1,18 @@
# Наблюдаемость: /stats
**Приоритет:** средний
Тихо сломавшаяся автоматизация — главный эксплуатационный риск коллектора:
данные просто перестают приходить, и заметить это можно только по молчанию.
Расписание HAE — пожелание, а не гарантия (находка 28), так что молчание
случается штатно.
`/stats` отвечает на «жив ли поток» без чтения логов: последняя доставка по
каждой автоматизации, счётчики за сутки, тишина в часах, доля доставок с
ошибкой разбора, строки без кода в словаре категориальных значений.
Готово, когда по одному запросу видно, какая из автоматизаций замолчала и
когда.
Активное уведомление — отдельная задача, здесь только факт.
+20
View File
@@ -0,0 +1,20 @@
# [idea] Что считать сутками при смене часового пояса
**Приоритет:** средний
«Шаги за день» — базовый запрос трекера и фитнес-игры. Но точка несёт метку с
офсетом исходной зоны, и при перелёте сутки перестают быть однозначными: день
по локальному времени в момент измерения, по текущей зоне телефона или по
фиксированной зоне пользователя — три разных числа.
Apple эту неоднозначность не решает, а перекладывает: в экспорте у записи есть
и время, и офсет. Мы храним так же — значит выбор всплывает ровно в момент
свёртки.
Почему идея, а не задача: непонятно, что должно стать наблюдаемо иначе. Нужно
решить, чей это выбор — сервера (одна зона в конфиге), клиента (параметр
запроса) или обоих (умолчание плюс переопределение).
Вопрос не гипотетический: походы и хайкинг из второго сценария — это как раз
поездки со сменой зоны.
+23
View File
@@ -0,0 +1,23 @@
# Тренировки и секции с собственными id
**Приоритет:** высокий
Тренировки приезжают с геотреком, состояние разума — с кодами HealthKit. Ни то,
ни другое сейчас не разбирается. Тренировки нужны трекеру (второй сценарий),
состояние разума — агенту-медику.
Модель отличается от метрик: у этих сущностей есть собственный `id`, они редки,
и по часам их группировать незачем. Тренировка **перезаписывается** целиком —
она приезжает повторно, когда доедет маршрут.
Шаги:
- миграции `workout` и `record` (секции `stateOfMind`, `ecg`, `symptoms`,
`cycleTracking`, `medications`, `heartRateNotifications` — модель одна);
- заголовок тренировки колонками, маршрут и внутренние ряды — блобом;
- пульс внутри тренировки не смешивать с метрикой `heart_rate`: разные таблицы.
Готово, когда тренировка отдаётся одним пакетом вместе с маршрутом, а
`stateOfMind` виден записями.
Связано: `docs/architecture.md` → «Тренировки и прочие секции».
+22
View File
@@ -0,0 +1,22 @@
# Управление токенами и секретами
**Приоритет:** средний
Сейчас проверка токенов выключена сознательно — доверенная локальная сеть, — и
`config.docker.toml` коммитится без секретов. Для локальной разработки это
правильно, но это же делает выезд наружу опасным: одна забытая настройка
открывает историю здоровья всему интернету.
Решается перед деплоем, не раньше — так договорились.
Шаги:
- раздельные токены приёма и чтения, генерация и хранение вне репозитория;
- сервис громко предупреждает на старте, если проверка выключена (уже есть),
и **отказывается стартовать**, если адрес прослушивания публичный, а токенов
нет;
- проверка в гейте, что в коммит не уехал файл с токеном (частично закрыта
`gitleaks`).
Готово, когда запуск без токенов возможен только на localhost, а на rivendell
оба контура закрыты разными токенами.
@@ -0,0 +1,22 @@
# Устаревание нижнего слоя после экспорта
**Приоритет:** низкий
Нижний слой растёт примерно на 100 тысяч координат в сутки против ~3 700 у
минутного и ~100 у часового — разница в три порядка (находка 41). Всё давление
по объёму создаёт он один, и ровно там родной экспорт Apple оказывается
настоящим надмножеством.
Два ограничителя, без которых правило опасно:
- пометка вешается по **загруженному и проверенному** экспорту, а не по
сделанному: проверка — непрерывность по дням и сходимость сумм с часовым
слоем;
- пометка ≠ удаление. Удаление включается только после того, как восстановление
из экспорта отработает на живых данных хотя бы раз.
Приоритет низкий: пока история измеряется днями, экономить нечего. Задача
станет актуальной, когда нижний слой перевалит за несколько гигабайт.
Зависит от импорта экспорта Apple — до него помечать нечем.
+4
View File
@@ -2,6 +2,10 @@
Живой документ: шаги отмечаем по мере готовности, отложенное копится внизу.
Это **порядок и его обоснование**, а не список работ. Единицы работы живут в
[беклоге](backlog/README.md) — одна задача, один файл, свой приоритет. План
отвечает «почему в таком порядке», беклог — «что брать следующим».
## Ближайшая цель
Приём работает, поток настоящих пакетов копится в сыром архиве. Дальше —