
Привет, Хабр.
Хочу рассказать про наш проект Exam AI — внутреннюю платформу для аттестации и тренировки сотрудников.
Это не “ещё один тестик на 20 вопросов”, а система, где:
контент живёт в управляемом банке вопросов;
вопросы можно генерировать из нормативных документов через LLM;
экзамен идёт как stateful runtime со сложным сценарием;
есть роли, назначения, апелляции, аналитика и multi-tenant модель организаций.
Текст будет не маркетинговый. Больше про инженерные решения, компромиссы и то, что реально ломалось.
Что мы решали
Во многих компаниях подготовка экзаменов выглядит примерно так:
Появился новый регламент.
Методист руками пишет вопросы.
Дальше вручную правит формулировки, удаляет дубли и снова согласует.
Проблемы понятны:
долго;
дорого;
плохо масштабируется;
при изменении документов весь цикл почти с нуля.
Наша цель была прагматичной: сократить путь “документ -> экзамен”, но не потерять контроль качества.
То есть не “отдать всё ИИ”, а сделать управляемый конвейер.
Технический контур
Backend
Python 3.14
FastAPI
SQLAlchemy 2 (async) + PostgreSQL
pgvector для эмбеддингов
pydantic-ai / Pydantic models
TaskIQ + Redis для фоновых задач
MinIO для медиа
Frontend
React 19 + TypeScript
Vite
TanStack Query
Zustand
xState 5 (runtime flow)
Orval (генерация API-клиента из OpenAPI)
Tailwind + DaisyUI
Инфраструктура
OIDC/SSO (Keycloak через внутренний SDK)
Docker / Traefik
CI с quality/security гейтами
Почему DDD-слои реально помогли
На backend мы изначально держали жёсткое разделение:
domain— сущности, value objects, протоколы;application— use cases;infrastructure— репозитории, внешние адаптеры, агенты;presentation— API/схемы.
Это банально звучит, но на длинной дистанции спасает.
Когда у тебя появляется:
новый источник контента;
новый LLM-провайдер;
новый вариант scoring/validation;
ты меняешь адаптеры и оркестрацию, а не переписываешь всё ядро.
AI-генерация: почему “просто попросить модель” не работает
Первая ошибка, которую мы (как и многие) сделали:
“Дадим модели большой документ и попросим сгенерировать N вопросов”.
Результат:
тематические перекосы;
поверхностные вопросы;
дубли в разных формулировках;
плохая воспроизводимость между запусками.
Поэтому мы перешли к пайплайну с явными стадиями.
1) Scan
Документ режется на фрагменты, строятся эмбеддинги, формируется карта тем.
2) Plan
Планируем генерацию как отдельный шаг:
сколько вопросов на тему;
какой тип контента;
приоритеты;
какие области знаний покрываем.
3) Generate
Генерируем не свободный текст, а строго типизированную структуру (Pydantic-схемы).
Невалидный ответ -> retry с уточнением требований.
4) Validate
Автопроверки кандидатов:
semantic near-duplicate;
валидность ожидаемого ответа;
привязка к knowledge area;
проверка формата/полноты.
5) Review
Последнее слово у эксперта: approve/edit/reject.
Мы сознательно оставили “человека в контуре” как quality gate.
Кейс, который съел много времени: “дубликаты при question_count > 1”
Один из самых неприятных продовых багов: пользователь просит 2 вопроса по теме, а получает два перефраза одного и того же кейса.
Проблема была архитектурная:
пользователь мыслит “тема = широкая область”;
модель часто мыслит “тема = конкретный сценарий”.
Что сделали:
На этапе scan начали нормализовать
check_focusкак нумерованный список независимых граней темы.На этапе generate стали жёстко выбирать одну грань на текущий вопрос.
Добавили отрицательный контекст: последние формулировки + повторяющиеся опорные токены.
В prompt зафиксировали требование менять минимум 2 измерения кейса (контекст, тип ошибки, роль проверяющего, решение, нормативный акцент).
После этого “два вопроса = два аспекта” стало воспроизводиться намного стабильнее.
Экзаменационный runtime: state machine вместо “кучи if”
Экзамен — это не просто POST /answer.
Есть:
текущий шаг;
таймеры;
переходы между фазами;
ограничения по действиям;
восстановление сессии.
Мы вынесли логику на фронте в xState, а на бэке поддержали событийную модель для сессии.
Профит: исчезает класс “невозможных UI-состояний”, когда кнопка активна, но по бизнес-логике действие уже запрещено.
Multi-tenant: тонкая зона, где легко получить data leak
У нас есть изоляция по организациям + режим platform-admin (god-view с org-switcher).
Самая частая ошибка в такой схеме:
фильтр по
organization_idдобавили в один эндпоинт;забыли в другом;
в UI кэш не инвалидировали при переключении org.
Один из реальных дефектов: при переключении организации на экране пользователей продолжали отображаться данные не той org.
Что исправляли:
backend: org-context обязателен на user-list эндпоинте, фильтрация через связь пользователь -> департамент -> организация;
frontend: централизованное прокидывание
organization_idи инвалидация query-кэша при смене активной организации;тесты: регресс-guard на org-isolation в ключевых сценариях.
Вывод: multi-tenant лучше проектировать как “системный инвариант”, а не как “пару where в SQL”.
Frontend-часть: почему Orval и строгий контракт окупаются
Мы генерируем API-хуки из OpenAPI (Orval), поэтому:
контракт backend/frontend синхронизируется автоматически;
типовые поломки ловятся на
type-check, а не от пользователей;меньше ручного кода вокруг сетевого слоя.
Если у команды много изменяющихся эндпоинтов, это экономит массу времени.
Безопасность и quality-гейты в CI
У нас в check-pipeline входят:
форматирование;
линт;
mypy/ts type-check;
unit/integration тесты;
security-аудит зависимостей.
Практически:
уязвимости ловятся рано, до релиза;
обновление библиотек становится регулярной рутиной, а не “пожаром раз в полгода”.
Отдельный урок: security-гейты должны быть настроены реалистично (что блокирует релиз, а что — warning с трекингом).
Что оказалось самым дорогим по времени
1. Достоверность AI-контента
Не генерация как таковая, а пост-валидация и антидублирование.
2. UX долгих операций
Пользователь не должен смотреть в “вечный спиннер” во время scan -> plan -> generate -> validate.
3. Согласованность org-scoping
Сложно не написать фильтр, а не забыть его во всех read-path.
4. Эволюция без “большого взрыва”
Когда проект растёт, важнее не “идеальный рефактор за месяц”, а последовательные безопасные изменения с регресс-тестами.

Что бы я сделал так же в следующем проекте
Сразу закладывал бы typed output от модели + строгую валидацию.
Сразу фиксировал бы anti-dup pipeline как часть бизнес-логики, а не как “косметику”.
Сразу проектировал бы multi-tenant контур и test-guards на него.
Сразу ставил бы contract-first между backend и frontend.
Сразу держал бы “человека в контуре” для контента высокого риска.
Итого
Мы получили не “демо с LLM”, а производственную платформу, где:
экзамены и тренировки живут в едином контуре;
генерация контента ускоряет подготовку, но остаётся контролируемой;
архитектура выдерживает изменения без постоянного хаоса.
Если интересно, могу в следующем посте разобрать один узкий блок с кодом и метриками:
антидублирование вопросов (от prompt до валидатора);
org-isolation end-to-end (backend + frontend + тесты);
runtime экзамена на xState и восстановление сессии.