Контекст

Любой, кто лечился, знает это состояние: после приёма у врача остаётся список назначений, куча вопросов «а это точно можно сочетать?», «а когда перестанет болеть?», и гуглить это страшно — интернет подсовывает форумы с диагнозами на любой вкус.

Мы — ООО «СМАРТСОФТ», аккредитованная ИТ-компания из Самары. Делаем сайты, мобильные приложения и AI-системы для бизнеса. Но этот проект начался не с заказа, а с личной истории.

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

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

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

Так появился Patient: мобильное приложение, в котором пациент ведёт дневник лечения, медкарту и календарь приёмов, а на вопросы о лечении отвечает AI-ассистент. Не «говорящая Википедия», а ассистент, который отвечает по данным конкретного пациента: назначения, диагнозы, история. И который честно говорит «я не знаю», когда не знает.

В этой статье — как мы его собирали: стек, архитектура RAG, как выбирается модель, главные ошибки и пять уроков, которые сэкономили бы нам недели, знай мы их в начале.

Задача и требования

От заказчика и здравого смысла получился такой список:

  • Приложение на Android **и** веб-версия — из одной кодовой базы (бюджет не резиновый).
  • Ассистент отвечает **по данным пациента** — назначения, диагнозы, дневник — а не «в общем про медицину».
  • Ассистент **не должен выдумывать**: ни диагнозов, ни дозировок, ни «пейте это — проверено».
  • Ответы должны работать, даже если облачные сервисы недоступны.
  • Русский язык и человеческий тон, а не выписка из справочника.
  • Плюс стандарт, который мы ставим во все проекты: договор, этапы, тесты на каждом шаге, мобильная версия не «адаптация», а первичный дизайн.

    Что мы собрали

    Бэкенд: Python, FastAPI, PostgreSQL. API для приложения, хранение медкарты, дневника, назначений, история диалогов, логи вызовов LLM.

    Клиент: React Native (Expo). Один код — Android и веб. Это дало примерно 30–40% экономии бюджета против двух отдельных разработок.

    Как устроен RAG — по шагам

    Ассистент не «думает сам»: перед ответом он собирает контекст, и только потом модель формулирует ответ. Конкретика:

  • **Документы извлекаются на устройстве.** PDF, Word, TXT, фотографии. PDF разбирает pdfjs-dist, Word — mammoth, для фото работает OCR на бэкенде.
  • **Чанки по ~800 символов** с русскими ключевыми словами складываются в локальный индекс — это SQLite, а не векторная база. Для MVP с персональной базой из десятков документов векторный поиск не нужен: поиск по ключевым словам с транслитерацией (чтобы ловить ошибки OCR, например «аналгин» вместо «анальгин») находит топ-5 релевантных фрагментов.
  • **В контекст попадает не только поиск:** всегда добавляются список всех документов с превью (чтобы отвечать на мета-вопросы вроде «какие у меня документы»), сегодняшняя дата, напоминания с датами, медкарта с датами и временем. Отдельный фикс: раньше модель не видела даты событий календаря и отвечала «в ближайшее время» вместо конкретики — теперь видит.
  • **Лимиты защищают от переполнения окна:** контекст ≤ 8000 символов, история ≤ 10 сообщений по 2000 символов, ответ ≤ 768 токенов. Сервер хранит только кэш ответов — без персональных данных.
  • **Если локальной базы не хватает**, подключается веб-поиск (DuckDuckGo). Это самое опасное место, и мы его ограничили: результаты помечаются как источники в ответе, а приоритет всегда у назначений лечащего врача из контекста пациента — если веб-источник им противоречит, ассистент отвечает по данным врача и предупреждает о расхождении.
  • Схема RAG-пайплайна медицинского AI-ассистента
    RAG-пайплайн: контекст пациента + база знаний, основная LLM и локальный резерв

    Одна LLM за раз: как мы выбираем модель

    У нас два провайдера: облачный DeepSeek API и локальный llama.cpp. Выбор — конфигурационным переключателем (LLM_PROVIDER), а не «умным роутером»: осознанно, потому что автоматический фолбэк с переключением между моделями — это отдельный класс инженерных задач (health-check, таймауты, консистентность ответов между моделями), который для MVP не окупался. Зато поведение при недоступности — честное: если выбранный провайдер не отвечает, сервис возвращает «ИИ временно недоступен», а не молчит и не сочиняет.

    Сейчас в проде стоит локальный вариант: llama.cpp с дистиллятом Qwen3.5-9B на одной GPU. Он отвечает за несколько секунд, стоит ноль по тарифам API, и данные пациента не покидают сервер. Качество на части вопросов ниже облака, но для типовых обращений его достаточно — и это легко проверить, потому что у нас есть автооценка (о ней ниже). Переключение на DeepSeek API — одной строкой в конфиге.

    Как мы проверяли качество — и почему это спасло проект

    Мы быстро поняли: «попробуйте поговорить с ботом» — не метод. Один и тот же вопрос ассистент может ответить по-разному, и на глаз это не проверишь.

    Поэтому мы собрали золотой набор из 40 вопросов — от «можно ли мне принимать это вместе с тем» до «у меня болит бок, что делать». После каждой правки прогоняем автотесты: вопросы задаются ассистенту, ответы проверяются по критериям:

    • не выдумал ли диагноз/дозировку;
    • не нарушил ли запреты (проверяем автоматически, по паттернам);
    • сослался ли на данные пациента там, где должен;
    • что сказал, когда базы знаний недостаточно.

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

    Экран AI-ассистента в приложении ТыНеОдин
    AI-ассистент отвечает по содержимому подключённых документов и честно предупреждает: он не заменяет врача

    Типовой пример «до/после»

    Схематичный пример эволюции ответов (обобщённый, не реальный диалог):

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

    Разница — не в «умности» модели, а в том, что она больше не отвечает из воздуха.

    Наши ошибки — честно

    1. «Просто дайте модели свободу».

    Первая версия отвечала красиво — и уверенно объясняла то, чего нет в данных пациента. Лечится это только архитектурой: RAG с жёстким контекстом, правила-запреты и автооценка. Никакой промпт-магии не хватит.

    2. Кроссплатформенные грабли, о которых не пишут в гайдах.

    Мы делали Android и веб из одной базы — и собрали классику: localStorage есть только на вебе, btoa/atob нет в Hermes (JS-движок React Native), а Alert.alert с двумя кнопками на вебе просто не работает. Как лечили: сделали абстракцию над хранилищем — на native токены и настройки лежат в SQLite и поднимаются при старте, на вебе — localStorage; для btoa/atob — полифилы; вместо системных алертов — свой компонент диалога, единый для обеих платформ. Каждая такая мелочь — день-два отладки, если не знать заранее; теперь это стандартный чек-лист в наших проектах.

    3. Тесты на хосте, а не в контейнере.

    Пока гоняли тесты на машине разработчика, «у всех всё работало». В контейнере с версиями зависимостей как на проде — всплыло то, что и должно было всплыть. Теперь правило железное: тесты — только в docker-контейнерах проекта, зависимости совпадают с рантаймом.

    4. Мы поздно начали мерить деньги.

    Облачные LLM стоят денег, и до того, как мы сделали учёт стоимости по каждому диалогу, бюджет утекал незаметно. Сейчас у нас лог каждого вызова: провайдер, токены, латентность, стоимость. Локальная модель в этом логе честно показывает ноль — и это аргумент в её пользу, который видно в цифрах.

    5. Грабль возвращается, пока не абстрагируешь.

    Классика кроссплатформы: Alert.alert с кнопками не работает на вебе. В одном экране мы это починили через window.confirm, в другом — забыли, и удаление записи медкарты на вебе молча не работало. Пока не вынесли общий confirmAction(): на web — window.confirm, на native — Alert.alert. Урок: первый же грабль — в общую утилиту, иначе он вернётся в каждом новом экране.

    6. Тестовая переменная уехала в прод.

    Сборка подхватила EXPO_PUBLIC_API_BASE_URL из тестового .env — прод-бандл ходил на localhost, и запросы клиентов ломались молча. Лечится сборочным скриптом, который явно проверяет окружение перед сборкой. Ещё один пункт в чек-лист: переменные окружения — только через явную конфигурацию сборки.

    Экономика проекта: почему локальная LLM — это не только про SLA

    Проект стартовал по ТЗ, но развивали мы его на собственные средства — до первой оплаты. Это заставило принимать решения по критерию «максимум результата на рубль»: одна кодовая база на две платформы, SQLite вместо векторной базы, локальная LLM вместо облачных API. Ни одно из этих решений мы не считаем компромиссом — при текущей нагрузке они закрывают задачи полностью. А когда появится необходимость в масштабе, переключение на облако осталось одной строкой в конфиге.

    Что дальше

    Сейчас Patient работает: веб на patient.ananas.guru и мобильное приложение. Ассистент отвечает по данным пациента, держит историю диалогов, не выдумывает и при серьёзных симптомах отправляет к врачу. Дневник, медкарта, календарь приёмов — в одном приложении.

    Дальше — интеграция с носимыми устройствами, и здесь мы упёрлись в интересный юридический барьер: доступ к медицинским данным с носимых устройств в России требует либо уставного капитала 6,5 млн ₽, либо медицинской лицензии; индивидуальному разработчику открыт только шагомер. Это отдельная история о том, как регулирование догоняет носимую электронику — и мы её сейчас проходим на практике. Плюс в планах — версия для родственников, которые ведут дневник за пожилых родителей.

    Пять уроков для тех, кто делает AI-продукт

  • **Начинайте с оценки качества, а не с «умности».** 40 тестовых вопросов и автооценка ответов дадут больше, чем самая модная модель. Помните: это защита от регрессий, а не валидация.
  • **RAG с правилами, а не «свободная LLM».** Ограничения (что нельзя говорить) важнее, чем «что может говорить». Проверяйте запреты автоматически.
  • **Одна кодовая база для Android и веба** экономит 30–40% бюджета — но помните про платформенные грабли и закрывайте их абстракциями (хранилище, диалоги, полифилы).
  • **Локальная модель на llama.cpp** — это не паранойя, а SLA: данные не покидают сервер, API-стоимость ноль, а при недоступности честный отказ вместо тишины. Переключение провайдера конфигом — достаточно для MVP.
  • **Тесты — в контейнерах с прод-зависимостями, учёт стоимости LLM — с первого дня.**
  • Мы — ООО «СМАРТСОФТ» (Самара), делаем сайты, мобильные приложения и AI-системы под ключ: от брифа до запуска и поддержки. Расчёт по брифу — за 1–2 дня, бесплатно: brief.ananas.guru

    А как вы решали проблему «ассистент красиво говорит, но не по фактам»? Поделитесь в комментариях — обсудим.