
Привет, Хабр.
Хочу рассказать про наш проект 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 и восстановление сессии.
ссылка на оригинал статьи https://habr.com/ru/articles/1053514/