Как я собрал бота‑команду из 11 ИИ‑юристов и что под капотом пошло не так первым же смоук‑тестом. aiogram.. aiogram. anthropic.. aiogram. anthropic. Claude API.. aiogram. anthropic. Claude API. fastapi.. aiogram. anthropic. Claude API. fastapi. llm.. aiogram. anthropic. Claude API. fastapi. llm. pet-project.. aiogram. anthropic. Claude API. fastapi. llm. pet-project. python.. aiogram. anthropic. Claude API. fastapi. llm. pet-project. python. SQLite.. aiogram. anthropic. Claude API. fastapi. llm. pet-project. python. SQLite. telegram bot.. aiogram. anthropic. Claude API. fastapi. llm. pet-project. python. SQLite. telegram bot. Анализ и проектирование систем.. aiogram. anthropic. Claude API. fastapi. llm. pet-project. python. SQLite. telegram bot. Анализ и проектирование систем. Мессенджеры.. aiogram. anthropic. Claude API. fastapi. llm. pet-project. python. SQLite. telegram bot. Анализ и проектирование систем. Мессенджеры. Проектирование и рефакторинг.. aiogram. anthropic. Claude API. fastapi. llm. pet-project. python. SQLite. telegram bot. Анализ и проектирование систем. Мессенджеры. Проектирование и рефакторинг. Развитие стартапа.. aiogram. anthropic. Claude API. fastapi. llm. pet-project. python. SQLite. telegram bot. Анализ и проектирование систем. Мессенджеры. Проектирование и рефакторинг. Развитие стартапа. чат-бот.

Полтора месяца назад мне пришла претензия на 44 тысячи рублей за фото восьмилетней давности на сайте нашего отеля — типичный «фотобанковский» иск с QR‑кодом, печатью и таблицей коэффициентов. Разбирался сам, с помощью Claude, закрыл спор на 20 тысячах вместо 44 без суда и без найма юриста. Написал об этом на VC.ru — сюда, на Хабр, тащить ту же историю смысла нет, тут расскажу, как это в итоге превратилось в бота и что было интересного технически: архитектура, пять реальных багов с продакшена и то, как я срезал расходы на Claude API почти на порядок.

Получился Telegram‑бот и мини‑апп (бесплатный, пока без монетизации), за которым стоит не один универсальный ассистент, а 11 персонажей‑юристов с разной специализацией: авторские права, трудовые споры, ЖКХ, недвижимость, бизнес и так далее. Ниже — не питч, а разбор архитектуры и конкретных багов, которые всплыли по пути, с кодом и цифрами.

Стек

aiogram 3 (Telegram‑бот) + FastAPI (webapi.py, бэкенд мини‑аппа) поверх Claude API (Anthropic), общая SQLite, python‑docx для генерации файлов. Продакшен — обычный VPS, nginx перед FastAPI (/api/*), systemd‑юниты, Let’s Encrypt. Никакого экзотического стека — сознательно, чтобы соло‑разработчик успевал это всё поддерживать.

Ключевое архитектурное решение, которое потом много раз окупилось: и Telegram‑бот, и мини‑апп дергают один и тот же orchestrator.handle_turn(). Вся бизнес‑логика — классификация, диалог с персонажем, генерация документов, кросс‑агентные консультации — живёт в одном месте. bot/main.py и bot/webapi.py — это просто два тонких клиента поверх одного оркестратора. Из‑за этого баги в логике чинятся сразу для обоих интерфейсов, а вот UI‑специфичные вещи (например, текстовая квитанция «Принял в работу…», которую раньше отправлял только main.py в своём хендлере) наоборот легко потерять в одном из клиентов, если добавлять их не в оркестратор, а сбоку. Поймал это на практике — квитанция была в боте, но не было в мини‑аппе, пока не завёл её в общий рендер.

Как я собрал бота‑команду из 11 ИИ‑юристов и что под капотом пошло не так первым же смоук‑тестом - 1

Как выбирается юрист

Список персонажей — простой Python‑словарь PERSONAS в personas.py, каждый с ролью, стилем и описанием специализации. CLASSIFIER_PROMPT для дешёвой модели (Haiku) собирается динамически из этого словаря при импорте модуля — значит, добавление нового персонажа не требует трогать ни классификатор, ни оркестратор, ни инструмент кросс‑консультации (consult_colleague, который тоже строит список «коллег» из PERSONAS в рантайме). Когда понадобился персонаж по недвижимости и земле (пользователь спросил про покупку земли под ЛПХ — классификатор по смыслу отправил его к «Бизнес, ИП, ООО», это было близко, но не то), добавление нового юриста заняло один блок в словаре плюс место в порядке роутинга — ноль изменений в остальном коде.

Если персонаж в разговоре понимает, что вопрос выходит за его специализацию, он сам вызывает consult_colleague как обычный tool call — бот реально делает отдельный запрос к другому персонажу, показывает клиенту его ответ отдельным сообщением («🔔 Николай подключает коллегу — Виктора Кольцова…«), и основной персонаж резюмирует уже с учётом мнения коллеги. Со стороны это выглядит как консилиум, а по факту — обычная агентная композиция инструментов поверх Claude API.»

Документы генерируются тем же путём, что и обычный ответ

Никакой отдельной команды «сгенерировать претензию» нет — Claude сам решает вызвать create_document, когда видит, что разговор дошёл до готовности оформить претензию/жалобу/ответ. Событие kind: "document" уходит клиенту и рендерится и ботом, и мини‑аппом как файл для скачивания (через python-docx). Нумерация файлов у пользователя сквозная («01. …», «02. …») и общая для обоих интерфейсов, потому что оба идут через один и тот же счётчик в storage.count_documents().

Обращения (cases) и баг с секундной точностью

Изначально вся переписка с одним юристом была одной бесконечной лентой — единственный способ дать «чистый» контекст на новую тему заодно прятал старую переписку от самого пользователя. Решение — таблица cases: у каждой пары (пользователь, юрист) может быть несколько обращений подряд, активное — всегда самое позднее, старые доступны целиком (просмотр, экспорт в.docx, пересылка).

Тут словил забавный баг на собственном смоук‑тесте (FastAPI TestClient + временная SQLite): метод получения активного обращения сортировал ORDER BY started_at DESC — и всё было отлично, пока тест не завёл два обращения в одну и ту же секунду. started_at секундной точности недостаточно как единственного ключа сортировки: два case с одинаковой меткой времени, и «активным» мог оказаться не тот, что реально создан последним — новое сообщение улетало не в то обращение. Починилось добавлением rowid DESC вторым уровнем сортировки как тайбрейкера. Мелочь, но именно из тех, что не всплывают на глаз — только когда пишешь тест, который специально бьёт по граничному случаю.

Изначально при нажатии «Начать заново» история молча обнулялась — на реальном использовании это оказалось неприятным сюрпризом: человек мог случайно нажать не туда и потерять контекст спора, который печатал 20 минут. Переделал на подтверждение: кнопка ведёт на экран «Точно начать новое обращение? Текущая переписка останется доступна в истории» с двумя кнопками, и только явное «Да» создаёт новый case. Заодно завёл колонку history_reset_at, чтобы отличать «пользователь осознанно начал новую тему» от технического сбоя при дебаге.

Кеширование промптов: как не платить по 100% за системный промпт в каждом сообщении

У каждого персонажа довольно длинный системный промпт — роль, стиль, ограничения, описание инструментов (consult_colleague, create_document). Он почти не меняется от сообщения к сообщению внутри одного диалога, но Claude API по умолчанию тарифицирует все input‑токены одинаково на каждый запрос — то есть один и тот же системный промпт оплачивался заново на каждой реплике пользователя.

Anthropic даёт для этого prompt caching: помечаешь блок контента cache_control: {"type": "ephemeral", "ttl": "1h"}, и повторное использование того же префикса в течение TTL тарифицируется по сниженной ставке вместо полной цены — а не читается из кеша бесплатно, это тоже важно понимать, экономия ощутимая, но не нулевая. Взял часовой TTL вместо базового пятиминутного: диалоги с юристом типично идут с паузами на подумать/сходить проверить документы, и пятиминутное окно попросту не доживало до следующей реплики у реального пользователя.

Завёл маленький хелпер withcache_breakpoint(), который оборачивает последний блок системного промпта нужным полем — чтобы не размазывать это по всем местам, где собирается запрос к API. Отдельно пришлось разобраться, где точно ставить breakpoint: кеш работает как префиксное совпадение, так что если в системный промпт подмешивать что‑то переменное (например, текущую дату) до брейкпоинта, а не после, кеш просто не будет попадать.

Проверял не по документации, а по факту — реальными вызовами API и разбором usage в ответе: там отдельно приходят cache_creation_input_tokens (первый запрос, кеш ещё не создан) и cache_read_input_tokens (все последующие в течение TTL). Разница в цене между этими двумя полями и обычными input_tokens — это и есть проверка, что кеш действительно работает, а не просто «в доке написано, что должно».

Отдельно важный нюанс — для классификатора (CLASSIFIER_PROMPT на Haiku) кеш не подключен, и это не недосмотр. У Anthropic есть минимальный размер промпта для кеширования, и для моделей семейства Haiku порог — 4096 токенов. CLASSIFIER_PROMPT, даже динамически собранный из всех персон, короче этого порога — кеш для него просто не создастся, сколько ни ставь cache_control. Это отдельная сущность от уже существовавшего FAQ‑кеша (словарь точных совпадений на частые вопросы, который даёт 100% экономию, но только когда вопрос буквально совпадает с заготовленным) — здесь же кешируется не ответ, а сам системный промпт, и работает это на любые вопросы персонажа, а не только на частые.

Telegram, лимит в 4096 символов и как он ронял генерацию претензий

Telegram Bot API жёстко ограничивает sendMessage — 4096 UTF-16 code units на сообщение. Обычный диалоговый ответ юриста в это спокойно укладывается, а вот сгенерированная претензия или исковое заявление — не всегда: юридический текст с реквизитами, перечислением статей закона и описанием обстоятельств дела на реальном кейсе спокойно уходит за лимит.

Первая версия просто отправляла, что вернула модель, — и на длинном документе Telegram API возвращал ошибку, которая в логах терялась среди прочего шума, а пользователь молча не получал ответ. Поймал это не на юнит‑тесте, а на живом использовании — самый неприятный вид бага, потому что воспроизвести его нарочно оказалось не так просто (нужен именно достаточно длинный сгенерированный текст, а не любой длинный).

Решение — splitinto_chunks(): режет текст в первую очередь по границам абзацев (двойной перенос строки), и только если один абзац сам по себе длиннее лимита — по границам слов, чтобы не рвать текст посреди слова. Написал юнит‑тест на синтетическом тексте с абзацами разной длины (от совсем коротких до сильно превышающих лимит), который проверяет, что: ни один чанк не превышает 4096 символов, ни один разрыв не приходится на середину слова, и склейка всех чанков обратно даёт исходный текст без потерь. Без этого теста было бы легко сломать функцию при следующей правке и не заметить — баг на генерации длинного документа проявляется не на каждом сообщении.

systemd, dotenv и crash‑loop на проде

Отдельный урок про то, как процессы читают переменные окружения. bot/main.py (Telegram‑бот) стартует через run.py, который в самом начале явно вызывает load_dotenv() и только потом читает конфиг из os.environ. Когда добавлял отдельный systemd‑юнит legalbot-api.service для FastAPI‑бэкенда мини‑аппа (bot/webapi.py), просто запустил его напрямую через uvicorn — без прохода через run.py.

Результат — crash‑loop прямо на проде: webapi.py читал os.environ точно так же, как main.py, но .env никто не грузил, потому что load_dotenv() был вызван только внутри run.py, а не где‑то на уровне процесса или окружения по умолчанию. Юнит рестартовал сам себя каждые несколько секунд с ошибкой отсутствующих переменных, пока не дошли руки посмотреть journalctl -u legalbot-api -n 50.

Фикс — тривиальный, одна строчка load_dotenv() в начале webapi.py. Но вывод для себя сделал более общий: .env — это не «настройка проекта», которая подхватывается сама по себе, а зависимость конкретного процесса. Каждая новая точка входа (новый systemd‑юнит, новый скрипт, новый воркер) должна сама явно загрузить конфиг, а не полагаться на то, что «где‑то там уже загружено» — иначе первый же деплой отдельного процесса ловит эту ошибку заново.

Мини‑апп: устаревшая история и особенности WebView в Telegram на iOS

Мини‑апп при открытии вызывал startChat(), который поднимал сессию, но реальную историю переписки не рендерил — экран просто начинался «с чистого листа» при каждом заходе, даже если пользователь уже писал юристу вчера. На Android это было просто неудобно; на iOS — заметно хуже, потому что Telegram Mini Apps там переиспользует WebView между открытиями и может не перезагружать JS‑контекст полностью при сворачивании/разворачивании — то есть состояние экрана могло застрять на устаревшем, даже если данные на бэкенде уже другие.

Решение — отдельный эндпоинт POST /api/history, который явно запрашивает актуальную историю по case_id и рендерит её при старте, а не полагается на то, что чат сам знает своё состояние. Отдельно добавил historyLoadToken — простой инкрементирующийся счётчик, который защищает от гонки: если пользователь успел быстро переключиться между обращениями, пока первый запрос истории ещё летит, ответ на устаревший запрос просто игнорируется по несовпадению токена, вместо того чтобы перезаписать экран старыми данными поверх уже отрисованных новых.

И отдельно — слушатель visibilitychange: когда мини‑апп возвращается в фокус после сворачивания (как раз тот самый iOS WebView‑кейс), принудительно перезапрашивает историю заново. Без этого можно было свернуть Telegram, получить ответ юриста в виде пуша, вернуться в мини‑апп — и увидеть старый экран без этого ответа, пока не сделаешь что‑то, что триггерит перерендер вручную.

Приватность по минимуму, но не по умолчанию доверия

Прежде чем текст пользователя уйдёт в модель, регулярки в bot/privacy.py вырезают паспортные данные, СНИЛС, ИНН, телефон, номер счёта. Это осознанно не «серебряная пуля» — в модуле честно написано про ограничения такого подхода, особенно для фото и сканов (OCR документов пока не реализован — бот просит описать содержание текстом вместо распознавания). Лучше прозрачно объявить границы, чем создавать иллюзию защиты, которой на самом деле нет.

Что дальше

Сейчас бот бесплатный, монетизации нет — сначала важно понять, что вообще ценно живым пользователям, прежде чем строить тарифы. Из технического: OCR фото/сканов, генерация docx не только вручную по ходу диалога, а полноценным пайплайном, и, возможно, отдельный тариф white‑label для небольших юрфирм — инфраструктура (персоны, история обращений) для этого уже готова, не готова только мультиарендность.

Если у кого‑то есть похожий пет‑проект на Claude API/aiogram — интересно сравнить архитектурные решения, особенно по части агентной композиции, кеширования промптов и кросс‑агентных вызовов. Пишите в комментарии, welcome.

Автор: Ilya049

Источник