ГлавнаяБлогПриложение для анализа здоровья: как я избежал 6 ловушек данных
AI / Нейросети

Приложение для анализа здоровья: как я избежал 6 ловушек данных

Разбираю архитектуру веб-приложения для вопросов о здоровье: ловушки данных, дизайн инструментов и проверка гипотез. Читайте, чтобы не наступить на грабли.

Al
Редакция Algolitalgolit.ru
12 мин чтения18 августа 2026 г.

Зачем вам читать эту статью

Вы когда-нибудь строили систему, которая должна отвечать на вопросы по вашим данным, и обнаруживали, что она даёт неверные ответы в 10% случаев? Я построил веб-приложение для вопросов о собственном здоровье, и в процессе выявил шесть ловушек, которые сломали бы любого наивного ИИ-ассистента. В этой статье я покажу, как структура инструментов может предотвратить ошибки лучше, чем любые промпты, и почему проверка гипотез на реальных данных — единственный способ избежать самообмана.

Два режима: детерминированный и LLM

Моё приложение отвечает на два типа вопросов, и для каждого — свой подход. Первый — режим тренера — полностью детерминированный. Это чистые функции в lib/signals/, покрытые юнит-тестами: соблюдение белка, дефицит калорий, динамика веса, перетренированность, застой в упражнениях, свежесть данных. Никакого ИИ — только правила. Важно, что unknown — это отдельный результат, отличный от ok, потому что эти данные постоянно дают неизвестность.

Второй — режим вопросов — использует LLM (Claude) на серверной стороне с тринадцатью read-only инструментами. Он обрабатывает вопросы, для которых нет правил: «Я застрял в приседаниях?», «Какой у меня реальный дефицит?», «Что происходит с моим VO2max?». Вся интересная архитектурная работа — именно здесь, и она в основном о том, от чего я отказался строить.

Очевидный дизайн, который я отверг

Самый простой вариант — дать модели SQL: схему, read-only подключение, и пусть пишет запросы. Так делают все демо «поговори с базой». На это уходит полдня, и для маленькой базы стоимость запросов неважна. Но я отказался, потому что в этих данных есть шесть ловушек, и каждая уже давала неверный ответ хотя бы раз.

Ловушки данных

  1. Сегодня — частичный день. Его числа ещё накапливаются. Если сравнить с завершённым днём, вы получите ложный «быстрый» результат.
  2. Пропуск — это не ноль. Незаписанный день — это не день, когда я ничего не ел.
  3. Apple дублирует сессии Liftosaur. Если наивно запросить представление тренировок, объём удвоится.
  4. energy_balance завышает дефицит в ~2.7 раза. За 30 дней он показывает средний intake 1602 ккал против 3216 потраченных, т.е. −1614 в день, что предсказывает потерю 3.2 фунта в неделю. Но весы говорят 1.2. Базальный обмен — это формула, а активная энергия от часов — щедрая оценка. Разница этих чисел — не измерение.
  5. Одно взвешивание — шум. Дневные колебания веса — это вода.
  6. reps: 0 — это подход, который был начат и не удался, а не отсутствующий подход.

Если дать модели SQL, она наступит на все шесть граблей. Это говорит не о модели, а о схеме: ловушки невидимы из неё. Ничто в колонке calories не скажет, что сегодняшнее значение — неполное.

Почему промпт не решает проблему

Я мог бы перечислить все шесть ловушек в системном промпте, и модель бы их учитывала в большинстве случаев. Это и определило дизайн, потому что «в большинстве случаев» — худший исход. Инструмент, который ошибается всегда, будет выброшен в первый же день. Инструмент, который прав в 90+% случаев, вызывает доверие, и редкая ошибка приходит в том же уверенном форматировании, что и правильные ответы. Я не могу её заметить, потому что спрашиваю именно потому, что не знаю ответа.

Поэтому ловушки исключены формой инструментов, а не инструкциями:

  • Оконные запросы заканчиваются AND observed_on < today_local(). Частичный день не исключается моделью — он просто недостижим.
  • Пропуски остаются отсутствующими строками. Никакого заполнения нулями, значит, ноль не с чем перепутать.
  • Ни один инструмент не обращается к представлению тренировок Apple — двойной подсчёт недостижим через API.
  • energy_balance нельзя получить без проверки реальностью в том же ответе. Невозможно получить вводящее в заблуждение число отдельно.

Промпт всё ещё описывает все шесть ловушек, потому что модель, понимающая почему окно заканчивается, даёт лучшие ответы, чем та, что получает усечённые данные. Но не промпт их удерживает. Если бы промпт удалили завтра, ответы стали бы хуже, но не в этих шести конкретных направлениях.

Кроме того, нет инструмента записи — буквально, а не как отключённого. Программа тренировок меняется в Liftosaur, цели по макронутриентам — в MacroFactor, поэтому у чата нет причин что-либо мутировать.

Каталог, который не был индексом

Затем я чуть не выпустил инструмент, который был неправильным способом, не защищённым структурой. Чату нужно знать, какие метрики существуют. Есть таблица metric_catalog с каноническими единицами и оценкой внимания, поэтому list_metrics читает из неё. Всё компилировалось, типы сходились, возвращались правдоподобные строки: 38 из 81 метрики каталогизированы, остальные 43 — не мусор: это дистанция ходьбы и бега (3865 дней данных, обновлено сегодня), этажи, средний пульс при ходьбе, скорость, длина шага, а также вся панель микронутриентов — всё актуально, потому что MacroFactor логирует микронутриенты.

Инструмент работал бы как написано и заставлял чат отвечать «у меня нет этого» про десять лет данных о ходьбе, которые лежат в базе. Ни один тест бы это не поймал. Я заметил, когда вывел .length из скрипта и увидел 38 вместо ожидаемых 81.

Исправление — инвертировать join: идти от observations_daily, LEFT JOIN каталог, и некаталогизированная метрика появится с catalogued: false и null-единицей, а не пропадёт. Это стоит один агрегат по непрерывному агрегату, что по EXPLAIN ANALYZE укладывается в 100 мс — приемлемо для вызова раз за разговор. Общий принцип: справочная таблица не является индексом реальности, если что-то не гарантирует этого. Ничего не гарантировало. Сейчас тоже, но отсутствие видно в выводе.

Ложная точность — реальная цена

Сырые строки из представлений выглядят так: 1370.9033333354562 и 187.9353333412348. Суммирование float даёт такое. Две проблемы, когда это доходит до языковой модели. Во-первых, ложная точность: весы, измеряющие с точностью до пятой фунта, не измеряли тринадцать знаков. Во-вторых, это чистые токены: восемнадцать символов вместо шести, на каждой точке каждого ряда.

Я округлил до двух знаков на границе инструмента, а не в слое запросов, по принципу: слой данных возвращает то, что хранит база, а слой инструмента уже отвечает за форматирование для модели. Сделано внутри единого хелпера json(), через который проходят все возвраты инструментов, так что новый инструмент не сможет забыть. Это сократило полезную нагрузку get_nutrition примерно на треть для семидневного окна и пропорционально больше для 90-дневного ряда.

Противовес: модель оказалась умнее структуры

Я не хочу переоценивать структурный аргумент: модель несколько раз превзошла ожидания. Я прогнал девять намеренно каверзных вопросов через скрипт проверки, чтобы результаты можно было воспроизвести. Три ответа содержали рассуждения, которых нет в промпте.

На вопрос о VO2max модель сообщила о восходящем тренде, но предупредила, что Apple вычисляет эту цифру на основе данных пульса при ходьбе и беге на улице, и что «оценка зависит от снижения веса, а не только от физической формы», сделав вывод «предположительно, но не окончательно». В промпте нет ни слова о VO2max.

На вопрос о застое в приседаниях модель разделила подходы T1 и T2, изучив веса и количество повторений — подсказка об уровнях, существующая в кодовой базе, не доступна ни одному инструменту.

На вопрос о сне модель прочитала показатели покрытия и отказалась оценивать 21 ночь. В этом ответе была ошибка, полностью моя, которую я заметил через час. Это последний раздел поста.

Последний случай — мой любимый, потому что он случайный. Я добавил поле days365, чтобы сделать индекс метрик честным о покрытии. Модель использовала его как общий фильтр «достаточно ли данных для ответа». Поле, добавленное по одной причине, пригодилось для лучшей — это стоит помнить, прежде чем урезать полезную нагрузку инструмента ради токенов. Структура ограничивает режимы отказа, но не ограничивает потенциал.

Сигналы, которые я хотел, но не смог построить

Очевидная следующая фича — кросс-доменная: влияет ли плохой сон на пропущенные тренировки, проявляется ли глубокий дефицит в маркерах восстановления. Каждый сигнал в приложении читает ровно один домен, и некоторые содержат советы, ссылающиеся на связи, которые код не проверяет.

Прежде чем писать что-либо, я проверил три самые очевидные гипотезы на реальной истории. Они не прошли проверку, и вот почему.

Единственный подъём, который правило застоя выделяет — тот, что оно должно игнорировать. Прогон stalling() без фильтра по 2545 подходам и 190 сессиям даёт ровно одно срабатывание: разгибание трицепса, застрявшее на 47.5 фунтах на две сессии. Это вспомогательное упражнение T3, и остановка на одном весе — это программа работает как задумано, а не застой. Именно тот ложный позитив, для подавления которого существует фильтр уровней, и с фильтром ничего не остаётся.

Правило перетренированности срабатывало один раз за девять лет. Я прогнал реальную функцию overreaching() по всей истории, а не аппроксимировал её математику в SQL, потому что получить правило слегка неправильным вручную — это то самое, на чём проект меня постоянно ловит. Из 2409 дней: ok — 2344, unknown — 48, watch — 16, и act — один раз, 25 апреля 2022. Ничего не выходило за ok с июня 2024.

Последний абзац — исправление. Мои заметки говорили, что act никогда не срабатывал, и я верил в это месяцы, потому что скрипт, создавший это утверждение, был разовым, я его не коммитил и не перезапускал. Оформление как npm run verify-signals заняло пятнадцать минут и сразу противоречило заметке. Сделайте проверку запускаемой — и она рано или поздно скажет вам то, что вы не хотели слышать.

Последовательное логирование питания началось 13 июля, так что четыре полные недели — вот с чем я бы коррелировал. Это анекдот в одежде выборки.

Поэтому в приложении нет кросс-доменных сигналов: событий, которые они бы коррелировали, в моих данных ещё нет. Записано как отложенное продолжение, а не закрыто, потому что гипотезы не отвергнуты — они просто неразрешимы на текущих данных. Я нахожу это более удовлетворительным, чем выпуск фичи: версия меня, пропустившая проверку, построила бы четыре сигнала корреляции, они бы вечно висели на unknown, и я бы не узнал ничего, кроме того, что страница шумная.

Что осталось непроверенным

Две честные дыры, потому что пост о проверке, заканчивающийся чисто, — это пост, который недостаточно старался. Интерфейс вопросов никогда не рендерился в браузере. Расширение Chrome не могло внедриться в localhost, поэтому формат потоковой передачи проверен через curl и юнит-тесты декодера, и всё. Второе: я не проверял, как система поведёт себя с данными другого человека — все пороги калиброваны под меня.

Практический вывод: что делать прямо сейчас

Если вы строите ИИ-ассистента поверх своих данных, сделайте три вещи:

  1. Перечислите ловушки ваших данных и убедитесь, что они недостижимы через API, а не просто описаны в промпте.
  2. Проверяйте справочные таблицы: убедитесь, что отсутствие записи видно, а не скрыто.
  3. Сделайте проверку гипотез запускаемой (npm run verify-signals) — и запускайте её, даже если не хотите знать результат.

Это сэкономит вам месяцы самообмана.

#ИИ-ассистенты#обработка данных#Python#архитектура#ловушки данных
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →