Как мы строим корпоративную экзаменационную платформу с AI: архитектура, дубли, мульти-tenant и продовые шишки

от автора

Привет, Хабр.

Хочу рассказать про наш проект Exam AI — внутреннюю платформу для аттестации и тренировки сотрудников.
Это не “ещё один тестик на 20 вопросов”, а система, где:

  • контент живёт в управляемом банке вопросов;

  • вопросы можно генерировать из нормативных документов через LLM;

  • экзамен идёт как stateful runtime со сложным сценарием;

  • есть роли, назначения, апелляции, аналитика и multi-tenant модель организаций.

Текст будет не маркетинговый. Больше про инженерные решения, компромиссы и то, что реально ломалось.

Что мы решали

Во многих компаниях подготовка экзаменов выглядит примерно так:

  1. Появился новый регламент.

  2. Методист руками пишет вопросы.

  3. Дальше вручную правит формулировки, удаляет дубли и снова согласует.

Проблемы понятны:

  • долго;

  • дорого;

  • плохо масштабируется;

  • при изменении документов весь цикл почти с нуля.

Наша цель была прагматичной: сократить путь “документ -> экзамен”, но не потерять контроль качества.
То есть не “отдать всё ИИ”, а сделать управляемый конвейер.


Технический контур

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 вопроса по теме, а получает два перефраза одного и того же кейса.

Проблема была архитектурная:

  • пользователь мыслит “тема = широкая область”;

  • модель часто мыслит “тема = конкретный сценарий”.

Что сделали:

  1. На этапе scan начали нормализовать check_focus как нумерованный список независимых граней темы.

  2. На этапе generate стали жёстко выбирать одну грань на текущий вопрос.

  3. Добавили отрицательный контекст: последние формулировки + повторяющиеся опорные токены.

  4. В 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. Эволюция без “большого взрыва”

Когда проект растёт, важнее не “идеальный рефактор за месяц”, а последовательные безопасные изменения с регресс-тестами.

Экран генерации вопросов

Экран генерации вопросов

Что бы я сделал так же в следующем проекте

  1. Сразу закладывал бы typed output от модели + строгую валидацию.

  2. Сразу фиксировал бы anti-dup pipeline как часть бизнес-логики, а не как “косметику”.

  3. Сразу проектировал бы multi-tenant контур и test-guards на него.

  4. Сразу ставил бы contract-first между backend и frontend.

  5. Сразу держал бы “человека в контуре” для контента высокого риска.


Итого

Мы получили не “демо с LLM”, а производственную платформу, где:

  • экзамены и тренировки живут в едином контуре;

  • генерация контента ускоряет подготовку, но остаётся контролируемой;

  • архитектура выдерживает изменения без постоянного хаоса.

Если интересно, могу в следующем посте разобрать один узкий блок с кодом и метриками:

  • антидублирование вопросов (от prompt до валидатора);

  • org-isolation end-to-end (backend + frontend + тесты);

  • runtime экзамена на xState и восстановление сессии.

ссылка на оригинал статьи https://habr.com/ru/articles/1053514/