Health Dashboard, часть 8: сначала спросить, потом говорить
Часть 8 моего build log по Health Dashboard: самый свежий элемент системы — утренний one-tap check-in через Telegram. Он ещё не доказал свою пользу, находится под наблюдением и имеет явные non-uses, чтобы не повторить failure modes, о которых предупреждала часть 6.
В части 6 было сформулировано правило: subjective input не может подтвердить формулу стресса. Это правило остается в силе. Формула, которая автоматически настраивается на user self-report, становится замкнутым циклом без внешней опоры. Пользователь рационализирует post-hoc, формула обновляется в сторону рационализации, и следующий раунд самоотчёта искажается предыдущим результатом формулы. Лекарство от этого — держать rubric внешней: HRV на следующее утро, сдвиг RHR, архитектура сна. Числа, которые пользователь не записывает сознательно.
Почему же тогда, спустя несколько недель, я выпустил утренний one-tap check-in, который спрашивает пользователя, как он себя чувствует?
Потому что правило относилось к калибровке. Оно не запрещало subjective input в любых других ролях, по крайней мере в теории. Эта часть о том, какими могут быть эти другие роли, и честная заметка наперед: это самая свежая функция во всей серии и та, которая ещё не доказала свою пользу. Несколько дней использования в производстве — не приговор. Check-in находится под наблюдением, а не объявляется успешным. Все, что следует, — это дизайн в его нынешнем виде и гипотезы, лежащие в его основе, а не ретроспектива по уже решенному вопросу.
Как check-in устроен механически
Каждое утро, в тот же такт планировщика, который должен был отправить утренний отчёт, сервер отправляет сообщение в Telegram:
How do you feel today?
[ Great ] [ OK ]
[ Meh ] [ Sick ]
Четыре кнопки inline keyboard1. Нажимаешь одну — сообщение обновляется на «Сохранено», утренний отчёт отправляется через несколько секунд. Если до наступления morning cap (MorningCapHour, обычно 11:00 по местному времени) не происходит нажатия, строка помечается expired, отчёт всё равно отправляется с короткой мягкой заметкой («Сегодня утренний чек-ин не пройден»), а нажатие позже в тот же день фиксируется как late_answered только для аналитики.
Формат передачи данных — это Telegram callback. callback_data2 — это checkin:<answer>:<YYYY-MM-DD>. В худшем случае 26 байт, что укладывается в лимит Telegram в 64 байта. Когда пользователь нажимает, Telegram отправляет POST-запрос с callback на /api/telegram/webhook/<secret>3 на сервер. Сервер проверяет секрет в пути, валидирует X-Telegram-Bot-Api-Secret-Token-заголовок, ищет tenant по chat_id (так что callback, подписанный Telegram, направленный не тому tenant, отклоняется), парсит payload и записывает строку.
Вся эта функция занимает всего несколько сотен строк Go, одну таблицу, два хука планировщика и один обработчик webhook.
Почему именно эти четыре кнопки
Изначальная идея заключалась в использовании slider 1–5, аналогично тому, как Whoop и Oura собирают субъективные данные. Я отказался от этого. Причины такие же, как и в части 6, которая объясняет проверку стресса, и они одинаково применимы к любому субъективному интерфейсу, независимо от того, для чего используются данные:
Непрерывная шкала вызывает confirmation bias. «У меня сегодня был дедлайн, значит, я, должно быть, был в стрессе, запишу 4». Дискретные категории без явного порядка между «низким» и «средне-низким» уменьшают искушение искать ответ, подтверждающий оценку.
Ползунки 1–5 по умолчанию выставляются на «3» в загруженные утренние часы, а большинство утренних часов — это занятое время. Три из четырех дискретных кнопок помещаются в одну строку мобильного Telegram, и ни одна из них явно не является опцией «я не думаю».
Данные downstream всё равно хотят категории. «Sick» — отдельный flag, а не 4 на slider. Action расходится: illness signature из части 6, AI recommendation уходит в rest. Не стоит терять это из-за сдвига slider на 1 пиксель.
Четыре категории, которые я выбрал, примерно соответствуют: «выше моей нормы» (Great), «в моей норме» (OK), «ниже моей нормы» (Meh), «я болею» (Sick). Асимметрия из трёх кнопок для более-менее обычного дня и одной для болезни намеренная. Болезнь — случай, где системе особенно нужен явный сигнал, который она не может вывести только из physiology: wrist temperature есть только с октября 2025, RHR запаздывает, HRV sparse. И именно здесь downstream behaviour меняется сильнее всего. Отдельная кнопка дешевле, чем заставлять пользователя выбирать между «Meh» и «Sick» на пятибалльной шкале.
Labels — короткие английские слова сознательно. Telegram client рендерит inline-keyboard buttons в узкой горизонтальной раскладке, и длинные переводы ломались бы некрасиво. Локализация происходит в другом месте: prompt text над кнопками полностью локализован, но сами кнопки остаются короткими.
Для чего ответ не используется
Три явных non-uses, каждый из которых был соблазном, от которого пришлось отказаться:
Ответ не настраивает β в формуле стресса. Rubric из части 6 требует четыре objective channels: HRV на следующее утро, RHR на следующее утро, sleep architecture, test-retest stability. Check-in не становится пятым. Добавление self-reported channel открыло бы тот самый feedback loop, который rubric должна закрывать.
Ответ не переобучает readiness sub-scores. Часть 4 закрыла Фазу 1 на naive baselines, потому что linear и tree models не прошли заранее объявленный floor. Добавить feature «user said meh today» в модели — ровно тот ход, который поднял бы AUC, но этот lift был бы artefact. Tap пользователя сильно коррелирует с inputs, которые модель уже видит: «Meh» коррелирует со сном прошлой ночи, а модель уже читает сон. Это было бы предсказанием самого себя.
Ответ не меняет вычисленные числа на dashboard. Readiness остаётся таким, каким сервер его посчитал. Check-in появляется отдельной строкой-подтверждением на dashboard: «Ваш утренний ответ: Meh». Рядом с числами, но никогда внутри них.
Каждый из этих non-uses — конкретный кусок кода, которого не существует. Строка в subjective_checkins читается ровно в двух местах codebase: morning-report scheduler, чтобы gate-ить отправку, и dashboard renderer, чтобы вывести однострочное подтверждение. Больше нигде.
Для чего ответ вообще нужен
Три предполагаемые роли, ни одна из которых не похожа на «калибровку». Каждая — гипотеза, которую ближайшие недели production run либо подтвердят, либо тихо опровергнут. Я записываю их здесь как design intent, а не как outcomes:
Gating утреннего отчёта. Это основная гипотетическая цель. Утренний отчёт не отправляется, пока пользователь не нажал кнопку или пока не прошёл morning cap. Ставка в том, что одно это правило делает большую часть работы по формированию привычки: пользователь утром открывает Telegram, сначала видит вопрос, нажимает, затем видит свои числа. Акт ответа становится точкой входа. Числа приходят после маленького субъективного commitment, а не до него. Это переворачивает обычную связку «устройство говорит тебе, как ты себя чувствуешь». Закрепит ли такая инверсия привычку или я просто научусь каждое утро на автопилоте нажимать «OK» — открытый вопрос, ради которого и нужен production run.
Coverage signal на dashboard. Hero block на / показывает строку «Ваш утренний ответ: …», когда ответ есть. Если день истёк без ответа, dashboard не показывает ничего. Не CTA «нажми, чтобы записать!», просто отсутствие. Отсутствие — это signal: ряд дней со статусом expired рядом с днями answered становится coverage map самой привычки, отдельно от coverage maps для sleep и HRV.
Будущий narrative review. Часть 4 пометила spike strict-event-rate в 2022 — 5.7% против 1–2% в другие годы — как «возможно, illness cluster, который стоит вручную пересмотреть до обучения моделей». Эта фраза была placeholder для «мне не с чем это cross-reference». Когда check-in log накопится, через пару лет я смогу вернуться к любому anomaly cluster и спросить: были ли эти дни tagged sick? Был ли перед ними устойчивый meh run? Это не training data. Ни одна model на них не обучается, ни одна formula по ним не calibrate-ится. Это evidence, который помогает решить, был ли anomaly cluster физиологией или instrumentation, причём через независимый канал, который не видел output формулы.
Третий пункт — причина, почему late_answered существует отдельным state. Tap, пришедший через час после cap, не является частью primary validation pool. Слишком большая часть дня уже развернулась; ответ уже не «до утреннего отчёта», а «постфактум, после того как я увидел числа». Смешать его с answered — значит позволить answer-latency drift тихо загрязнить будущий cohort analysis. Поэтому есть оба buckets, dashboard может показать оба варианта — «Ваш утренний ответ: …» / «Записано поздно: …», — а аналитические запросы сами выбирают нужный pool.
State machine
Четыре состояния, с явными переходами в pure Go helper, который существует прежде всего затем, чтобы policy была auditable в одном месте:
nextCheckinStatus(current, action, expiresAt, now)
// actions:
// "tap" : user pressed a button
// "expire" : scheduler decided the cap has passed
prompted→answered, когда пользователь нажимает доexpires_atprompted→expired, когда scheduler запускаетExpireCheckinпослеexpires_atprompted→late_answered, когда tap приходит послеexpires_atexpired→late_answered, когда tap всё-таки приходитanswered→answeredпри повторном tap — idempotent4, closed set защищает от double-counting
Показать nextCheckinStatus полностью (Go, 22 строки)
// nextCheckinStatus computes the target status for a row given its
// current state, the action applied, the row's expires_at, and the
// current wall clock. Pure function, exercised by tests without a
// DB so the policy stays explicit and trivially auditable.
func nextCheckinStatus(current, action string, expiresAt, now time.Time) (string, error) {
switch action {
case "tap":
if current == CheckinStatusAnswered {
return CheckinStatusAnswered, nil // idempotent re-tap
}
if now.Before(expiresAt) && current == CheckinStatusPrompted {
return CheckinStatusAnswered, nil
}
// expired / prompted-past-cap / late_answered all collapse here
return CheckinStatusLateAnswered, nil
case "expire":
if current != CheckinStatusPrompted {
return "", errors.New("can only expire a prompted row")
}
if now.Before(expiresAt) {
return "", errors.New("expires_at not reached yet")
}
return CheckinStatusExpired, nil
}
return "", fmt.Errorf("unknown action %q", action)
}
Двадцать две строки. Вся state machine check-in функции. Каждая ветка — один test case в table-driven test.
nextCheckinStatus — канонический policy document. Это pure function: без DB, без clock, без Telegram. Table-driven test, который покрывает все transitions, выполняется за миллисекунды. DB-стороны ExpireCheckin и SaveCheckinAnswer повторяют те же правила в SQL через FOR UPDATE5 row locks и conditional UPDATE, потому что policy должна применяться atomically, когда scheduler-expire и user-tap race-ятся в одну миллисекунду.
Такой race не ловится в первом PR. PR #121 прошёл примерно шесть rounds review-and-fix до стабилизации. По порядку: feature-flag gap — check-in gate срабатывал в morning scheduler, даже когда webhook secret не был настроен, так что пользователи без check-in setup могли ждать tap, который никогда не придёт; race в первой версии ExpireCheckin между read-then-update из двух statement и входящим tap, схлопнутый в один conditional UPDATE в 7a50aa1; забытый render expired-day note в morning report — i18n key был добавлен, но не протянут в cap-path, 983125f; stale-callback case, где tap по вчерашнему prompt мог изменить вчерашнюю answer row, отклонён до save в 4e45212; lazy-init secrets mismatch, когда scheduler gate читал не тот источник, что registration path (60e4a34); и наконец partial-POST case в admin settings endpoint, который мог случайно deleteWebhook для env-backed installs.
Шесть rounds на функции, которая в schema выглядит маленькой. Каждый round был багом другого типа. Race — про concurrency, feature flag — про boot-time integration, expired-note — про product-copy contract в cap-path, который легко пропустить, потому что это не happy path; stale callback — про idempotency semantics, secrets mismatch — про lazy-init order, partial POST — про API surface. Ни один из них не поймали unit tests изолированных компонентов. Они всплыли на code review интеграции между компонентами.
Безопасность webhook-а
Check-in — первая функция проекта, которая принимает inbound HTTP от third-party service (Telegram). Всё остальное outbound: сервер получает данные из HealthKit через iOS app, отправляет Telegram messages, запрашивает Gemini. Inbound webhooks — другая threat model.
Четыре слоя validation callback-а:
Path secret. Webhook URL —
/api/telegram/webhook/<secret>, где<secret>— per-tenant значение, которое ротируется независимо от bot token. Утечка Telegram bot token не даёт attacker-у webhook path.Header secret.
X-Telegram-Bot-Api-Secret-Tokenзадаётся при регистрации webhook и валидируется на каждом incoming request. Это собственный anti-spoofing механизм Telegram.chat_idlookup. Даже валидный Telegram-signed callback для неправильного chat отклоняется. Handler проходит по multi-tenant pool map и ищет tenant, у которого configuredTELEGRAM_CHAT_IDсовпадает с chat из callback. Нет match → reject.HTTPS-only на старте. Сервер отказывается запускаться, если webhook URL не HTTPS. Telegram и так требует HTTPS для webhook delivery, но misconfigured deployment, который откатился на HTTP, тихо потерял бы каждый callback. Startup guard показывает проблему до первого morning prompt.
Комбинация (1) и (3) — load-bearing. Path secret останавливает общего attacker-а, который угадал URL pattern webhook-а. chat_id lookup останавливает attacker-а, который каким-то образом знает path, но не знает, какой tenant chat_id соответствует какому path, и не даёт attacker-у с любым Telegram account выдавать себя за произвольного tenant на валидном path.
Auto-registration при смене token — PR #122 — закрывает операционный случай, когда bot token rotates. Сервер видит изменение, вызывает Telegram setWebhook с новым secret и обновляет registry. Неудачная registration всплывает в admin UI цветным badge: «Webhook · failed · …», чтобы тихая rotation не ломала check-in молча.
Telegram-first, но с местом для dashboard
До любого кода brainstorm doc сравнил три entry points:
A. Telegram-first. Один tap там, где уже приходит morning report. Привычка формируется в существующей notification surface.
B. Dashboard-first. Нативный widget в dashboard hero. Требует daily dashboard opens, а я не делаю этого надёжно.
C. Hybrid MVP. Оба варианта, общий backend и одна row на
(tenant, date). Лучший долгосрочный shape, самый большой первый PR.
Verdict из brainstorm: «A для первого slice, но спроектировать так, чтобы C был лёгким следующим шагом». Schema это отражает. Primary key в subjective_checkins — (date, source), а не просто date. Enum source сейчас имеет одно значение — telegram; будущий dashboard source сможет жить на ту же дату, не перезаписывая Telegram answer. Pure transition helper source-agnostic. Webhook handler не предполагает, что Telegram — единственный producer.
Причина, по которой hybrid не вошёл в первый PR, — принцип из части 7: вещь имеет право появиться, как только она может быть полезной сама по себе. Telegram path полезен сам по себе: habit, gate, log, narrative. Dashboard path был бы additive, не required. Делать оба в PR #1 означало бы писать dashboard surface до того, как storage layer получил production use и смог поспорить с его assumptions.
Путь expired-note
Когда cap проходит без ответа, morning report всё равно отправляется. Пользователь всё ещё имеет право на свои numbers. Но report несёт однострочную note, локализованную по tenant report_lang:
Сегодня нет утреннего check-in. Нажмите «Как вы себя чувствуете?» во вчерашнем prompt, чтобы записать ответ ретроспективно.
Note существует по двум причинам. Во-первых, silence не должно быть invisible. Dashboard coverage signal должен отражать, что сегодня не откалиброван subjective channel. Во-вторых, silence не должно быть punishment: report всё равно приходит, numbers читаются, день пользователя не становится заложником tap.
Это решение той же формы, что data-quality gates в частях 5 и 6. Когда sensor data missing, bank не рендерится; когда stress coverage недостаточен, drain откатывается на v2.0; когда check-in expired, report отправляется с note. У каждого случая есть свой degraded-mode UI, который честно признаёт gap вместо того, чтобы fabricating placeholder.
render checkin_expired_note in morning report cap-path — commit, который это добавил (983125f). Он же заставил меня остановиться и проверить, что note text не editorial. Не «вы пропустили!», не «не забудьте check in завтра», а голый факт: сегодня subjective channel не откалиброван, и есть link назад к prompt. Смысл — coverage signal, не nudge.
Что можно спрашивать, а что нельзя
Простая рамка, которая появилась при сборке: системе разрешено спрашивать пользователя, как он себя чувствует. Системе не разрешено показывать, что этот вопрос изменил какие-либо другие числа. Это разные действия с разными методологическими последствиями.
Вопрос — UX surface. Он закрепляет morning habit, создаёт coverage signal, генерирует audit trail для будущего narrative review. Вопрос также признаёт, что у пользователя есть perceptions, которые sensors не могут полностью поймать: в дни, когда formulas говорят «readiness 73, ты в порядке», пользователь всё ещё может легитимно нажать «Sick».
Показать, что вопрос что-то изменил, — это calibration. Calibration по self-report — закрытая петля, о которой часть 6 две тысячи слов объясняла, как её избегать. Система может спрашивать, не меняя. Check-in row лежит рядом с дневными numbers в базе, но никогда внутри их computation.
Искушение соединить эти вещи — «пользователь сказал Sick, давайте снизим сегодняшнюю readiness с 73 до 50» — самый большой скрытый lever в personal health system. Я не буду его тянуть. Числа, которые я показываю, должны быть числами, которые произвели formulas. Tap пользователя — отдельная независимая запись. Если они не согласны, это интересный signal, и я хочу оставить его видимым, а не замазать.
Что ещё не доказало свою пользу
Всё в этой части — это design и code. Ничто здесь не является verdict.
Check-in запущен достаточно недавно, поэтому у меня нет честного production read о том, заслуживает ли он место в системе. Пять вопросов остаются открытыми, и каждый — причина, почему эта часть build log не закрыта:
Закрепляет ли gating привычку на самом деле, или я прохожу его на автопилоте? Утро, когда я нажимаю «OK», не прочитав вопрос, ничем не отличается от утра без check-in — кроме того, что теперь есть row в
subjective_checkins, которая утверждает, что check-in был. Signal тихо деградирует. У меня нет инструментированного способа отличить reflexive taps от considered ones, и, вероятно, не будет. Пользователь — единственный sensor для этого различия.Нажимаю ли я честно в дни, где это важно? Кнопка Sick — с самыми большими downstream consequences: illness signature, AI recommendation pivots. И это же кнопка, которую я с меньшей вероятностью нажму в пограничный день, когда не уверен. Если я её недоиспользую, coverage signal хуже, чем ничего. Он подразумевает «not sick», когда правда была «uncertain».
Выдержит ли four-category design? Возможно, на практике я буду использовать только две из четырёх кнопок, и discrete scale окажется в основном декоративной. Хуже, если OK съест Meh, потому что нажать Meh в обычный low day психологически будет казаться саможалением. Спроектированная асимметрия может схлопнуться не туда.
Полезен ли late-answered pool сам по себе? Я добавил
late_answered, потому что смешивать post-hoc taps в validation pool явно неправильно. Будет ли у late pool собственная аналитическая польза или он просто чуть вежливее, чем «потеряно», покажут месяцы данных.Работает ли вообще аргумент про narrative review? «Через два года я смогу вернуться к anomaly clusters и спросить, коррелируют ли они с Sick taps» предполагает, что tap record будет чистым. Если ответы на вопросы 1–3 плохие, record будет polluted, и аргумент о будущей археологии развалится.
Честный путь — наблюдать квартал, а потом задать те же вопросы по данным, а не по догадкам. Schema и четыре states специально устроены так, чтобы ответы можно было получить без изменений в коде. status, answer, prompted_at и answered_at достаточно, чтобы посчитать response latency, button distribution и coverage over time.
Если данные покажут, что check-in в основном шум, правильное решение — убрать его, а не крутить methodology, пока он не станет казаться полезным. Отказ части 6 tune-иться на слабый signal применим и здесь: feature должна пройти собственный threshold, прежде чем заслужить право остаться в системе. Я оставляю себе личное напоминание вернуться к этому осенью.
Настоящий урок
Это первый момент в build log, где архитектурный вопрос перестал звучать как «что должна считать formula» и стал вопросом «что должен иметь право сказать пользователь». Оба вопроса реальны, оба требуют аккуратных ответов. Это разные вопросы. И, в отличие от остальных частей серии, ответ на этот всё ещё находится под наблюдением. Design существует, code работает, production data только начинает накапливаться.
Первая статья была о доверии к данным. Вторая — о доверии к колонкам. Третья — о доверии к источнику каждой колонки. Четвёртая — о доверии к target. Пятая — о доверии к молчанию формулы. Шестая — о доверии к verdict. Седьмая — о доверии к границе между сервером и клиентом. Эта — о доверии к вопросу: ясно понимать, что система может спросить у пользователя, для чего нужен ответ, для чего он не нужен, и честно признавать, что ответ на вопрос «стоит ли вообще спрашивать» ещё не получен.
Здесь build log догоняет настоящее. Пять readiness sub-scores, bank, flags, validation rubrics, тонкий iOS-клиент и morning check-in ещё не закончены. Это система, которая будет генерировать больше данных, обнаруживать больше anomalies и вынуждать новые redesigns. Особенно check-in: он самый свежий элемент, наименее проверенный и наиболее вероятный кандидат на пересмотр через несколько месяцев. Цель этой серии была не объявить endpoint. Цель была изложить порядок, в котором система перестала быть красивым графиком и стала чем-то, что я готов читать первым делом утром, а также ясно сказать, каким частям я доверяю, потому что они это заслужили, и какие всё ещё на испытании.
Вложение к сообщению в Telegram: сетка нажимаемых кнопок, отображаемая прямо под текстом сообщения. Когда пользователь нажимает одну из них, Telegram отправляет POST-запрос с обратным вызовом на вебхук бота с полезной нагрузкой, указывающей, какая кнопка была нажата и к какому сообщению она относится. В отличие от “ответной клавиатуры” (которая заменяет клавиатуру пользователя), встроенные клавиатуры остаются привязанными к конкретному сообщению. ↩︎
Непрозрачная строка, которую бот Telegram прикрепляет к каждой кнопке встроенной клавиатуры. Когда пользователь нажимает кнопку, именно эта строка отправляется Telegram обратно на вебхук бота. Ограничение — 64 байта на кнопку. Обычно она кодирует «какое действие и в каком контексте»; здесь
checkin:<answer>:<date>достаточно, чтобы определить, какая кнопка была нажата и к какому дню относится подсказка. ↩︎Шаблон, при котором сервер регистрирует HTTPS-URL у внешнего сервиса, и этот сервис отправляет POST-запрос на этот URL при возникновении события. Обратный процесс опроса: сервер получает уведомление о событии вместо того, чтобы постоянно запрашивать его. Для ботов Telegram альтернатива — long polling (клиент бота в цикле запрашивает обновления у Telegram). Вебхуки дешевле и имеют меньшую задержку, но требуют наличия у сервера публичной HTTPS-точки. ↩︎
Свойство операции: её применение один раз даёт тот же результат, что и многократное применение.
UPDATE foo SET status='X' WHERE status='X'является идемпотентным (второе применение не изменяет состояние).UPDATE foo SET counter=counter+1— нет. Это важно для безопасной повторной попытки в распределённых системах, поскольку сеть может доставить один и тот же запрос дважды. ↩︎Клауза Postgres на
SELECT, которая накладывает блокировку уровня строки на каждую возвращаемую строку на время транзакции. Другие транзакции, пытающиеся обновить те же строки, блокируются до тех пор, пока первая транзакция не завершится коммитом или откатом. Стандартный инструмент для «прочитать эту строку, а затем обновить её, не давая другим конкурировать». Без него две параллельные транзакции могут одновременно прочитать один и тот же статусpromptedи оба его изменить, что приведёт к несогласованному состоянию. ↩︎