Полтора месяца назад разработчик получил претензию на 44 тысячи рублей за фотографию восьмилетней давности, размещённую на сайте отеля. Это типичный «фотобанковский» иск с QR-кодом, печатью и таблицей коэффициентов. Спор удалось закрыть на 20 тысячах рублей без суда и без найма юриста — разбираться помогал Claude. История на VC.ru не привлекла внимания, и автор перенёс техническую часть на Хабр: как из личного кейса вырос Telegram-бот и мини-апп с 11 персонажами-юристами.
Стек сознательно выбран без экзотики, чтобы соло-разработчик успевал поддерживать проект: aiogram 3 для Telegram-бота, FastAPI для бэкенда мини-аппа, общая SQLite, python-docx для генерации файлов. Продакшен — обычный VPS, nginx перед FastAPI, systemd-юниты, Let's Encrypt. Ключевое архитектурное решение — и бот, и мини-апп дёргают один и тот же orchestrator.handle_turn(). Вся бизнес-логика: классификация, диалог с персонажем, генерация документов, кросс-агентные консультации — живёт в одном месте. bot/main.py и bot/webapi.py — это два тонких клиента поверх одного оркестратора. Из-за этого баги в логике чинятся сразу для обоих интерфейсов, а UI-специфичные вещи легко потерять в одном из клиентов, если добавлять их сбоку. Так и случилось с текстовой квитанцией «Принял в работу…»: она была в боте, но не было в мини-аппе, пока её не завели в общий рендер.
| Компонент | Технология |
|---|---|
| Telegram-бот | aiogram 3 |
| Бэкенд мини-аппа | FastAPI (webapi.py) |
| База данных | SQLite |
| Генерация документов | python-docx |
| Продакшен | VPS, nginx, systemd, Let's Encrypt |
Список персонажей — простой Python-словарь PERSONAS в personas.py, каждый с ролью, стилем и описанием специализации. CLASSIFIER_PROMPT для дешёвой модели Haiku собирается динамически из этого словаря при импорте модуля. Значит, добавление нового персонажа не требует трогать ни классификатор, ни оркестратор, ни инструмент кросс-консультации consult_colleague, который тоже строит список «коллег» из PERSONAS в рантайме. Когда понадобился персонаж по недвижимости и земле — пользователь спросил про покупку земли под ЛПХ, а классификатор по смыслу отправил его к «Бизнес, ИП, ООО», — добавление нового юриста заняло один блок в словаре плюс место в порядке роутинга. Ноль изменений в остальном коде.
11 персонажей-юристов описаны словарём PERSONAS в personas.py: авторские права, трудовые споры, ЖКХ, недвижимость, бизнес.
Если персонаж понимает, что вопрос выходит за его специализацию, он сам вызывает consult_colleague как обычный tool call. Бот делает отдельный запрос к другому персонажу, показывает клиенту его ответ отдельным сообщением («🔔 Николай подключает коллегу — Виктора Кольцова…»), и основной персонаж резюмирует уже с учётом мнения коллеги. Со стороны это выглядит как консилиум, а по факту — обычная агентная композиция инструментов поверх Claude API. Документы генерируются тем же путём, что и обычный ответ: никакой отдельной команды «сгенерировать претензию» нет — Claude сам решает вызвать create_document, когда видит, что разговор дошёл до готовности оформить претензию, жалобу или ответ. Событие kind: "document" уходит клиенту и рендерится и ботом, и мини-аппом как файл для скачивания. Нумерация файлов у пользователя сквозная («01. …», «02. …») и общая для обоих интерфейсов, потому что оба идут через один и тот же счётчик в storage.count_documents().
Изначально вся переписка с одним юристом была одной бесконечной лентой — единственный способ дать «чистый» контекст на новую тему заодно прятал старую переписку от самого пользователя. Решение — таблица cases: у каждой пары (пользователь, юрист) может быть несколько обращений подряд, активное — всегда самое позднее, старые доступны целиком: просмотр, экспорт в.docx, пересылка. Тут и всплыл баг на смоук-тесте с FastAPI TestClient и временной SQLite. Метод получения активного обращения сортировал ORDER BY started_at DESC, и всё было отлично, пока тест не завёл два обращения в одну и ту же секунду. started_at секундной точности недостаточно как единственного ключа сортировки: два case с одинаковой меткой времени, и «активным» мог оказаться не тот, что реально создан последним — новое сообщение улетало не в то обращение. Починилось добавлением rowid DESC вторым уровнем сортировки как тайбрейкера.
Ещё один баг касался кнопки «Начать заново»: история молча обнулялась, и на реальном использовании это оказалось неприятным сюрпризом — человек мог случайно нажать не туда и потерять контекст. Автор упоминает пять реальных багов с продакшена и отдельно — как срезал расходы на Claude API почти на порядок. Бот бесплатный, монетизации пока нет. Для рынка юридических ИИ-сервисов это показательный пример: вместо одного универсального ассистента разработчик собрал набор узких персонажей с явным роутингом и кросс-консультациями, а инфраструктуру удержал на минимальном стеке, который под силу поддерживать одному человеку.



