Compare commits

...

5 Commits

Author SHA1 Message Date
Aleksey Razorvin 51df045220 feat: редизайн страницы создания/редактирования теста 2 weeks ago
Aleksey Razorvin f6fc92298a docs: добавить ТЗ на доработку v1.0 для джуниор-разработчика 2 weeks ago
Aleksey Razorvin 6416a72f29 docs: update ТЗ to v1.3 — детализировать версионность и AI-помощник 2 weeks ago
Aleksey Razorvin fc684e7c7d Спринт 5: Трекер результатов 2 months ago
Aleksey Razorvin 9a0b3ba92c Спринт 4: AI-помощник на базе DeepSeek 2 months ago
  1. 118
      DOC/ИТОГИ_2026-03-21.md
  2. 140
      DOC/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md
  3. 54
      DOC/СПРИНТЫ.md
  4. 43
      DOC/ТЗ.md
  5. 272
      DOC/ТЗ_доработка_v1.md
  6. 101
      DOC/ШАГИ/ШАГ_2026-03-21_011.md
  7. 78
      DOC/ШАГИ/ШАГ_2026-03-21_012.md
  8. 72
      DOC/ШАГИ/ШАГ_2026-03-21_013.md
  9. 6
      README.md
  10. 2
      backend/alembic/env.py
  11. 33
      backend/alembic/versions/004_settings.py
  12. 31
      backend/alembic/versions/005_attempt_user.py
  13. 69
      backend/app/api/attempts.py
  14. 132
      backend/app/api/llm.py
  15. 34
      backend/app/api/settings.py
  16. 4
      backend/app/main.py
  17. 1
      backend/app/models/attempt.py
  18. 17
      backend/app/models/setting.py
  19. 26
      backend/app/schemas/attempt.py
  20. 14
      backend/app/schemas/setting.py
  21. 0
      backend/app/services/__init__.py
  22. 208
      backend/app/services/llm.py
  23. 1
      backend/requirements.txt
  24. 55
      frontend/src/App.tsx
  25. 33
      frontend/src/api/attempts.ts
  26. 48
      frontend/src/api/llm.ts
  27. 14
      frontend/src/api/settings.ts
  28. 770
      frontend/src/components/TestForm/index.tsx
  29. 124
      frontend/src/pages/Settings/index.tsx
  30. 6
      frontend/src/pages/TestEdit/index.tsx
  31. 145
      frontend/src/pages/Tracker/index.tsx

118
DOC/ИТОГИ_2026-03-21.md

@ -0,0 +1,118 @@
# Итоги разработки — 21 марта 2026
## Общая информация
| Параметр | Значение |
|----------|---------|
| Дата | 21 марта 2026 |
| Начало работы | 10:56 |
| Текущее состояние | Спринты 1–5 завершены |
| Затрачено времени | ~5 часов |
| Коммитов | 26 |
---
## Хронология по коммитам
| Время | Этап |
|-------|------|
| 10:56–11:41 | Подготовка: структура проекта, ТЗ v1.1, стек, план спринтов, README |
| 12:05–12:46 | **Спринт 1** — инфраструктура + создание тестов |
| 12:53–13:18 | **Спринт 2** — прохождение теста + результаты + UX |
| 13:22–14:08 | **Спринт 3** — редактирование + версионирование |
| 14:08–15:11 | **Спринт 4** — AI-помощник (DeepSeek) |
| 15:11–16:00 | **Спринт 5** — трекер результатов |
---
## Что реализовано
### Спринт 1 — Инфраструктура + создание тестов (~40 мин)
- Docker Compose: PostgreSQL 16, FastAPI, React + Vite, Nginx
- Alembic: миграция `001_init` (таблицы tests, questions, answers)
- API: `POST /api/tests`, `GET /api/tests`, `GET /api/tests/{id}`
- Фронт: создание теста, список тестов, просмотр теста
- Исправлено 4 бага при тестировании (entrypoint, PYTHONPATH, nginx DNS, FastAPI docs URL)
### Спринт 2 — Прохождение теста + результаты (~25 мин)
- Миграция `002_attempts` (таблицы test_attempts, attempt_answers)
- API: `POST /api/attempts`, `POST /api/attempts/{id}/submit`, `GET /api/attempts/{id}/result`
- Прохождение: случайный порядок вопросов, таймер с автосабмитом, навигация назад
- Страница результатов: балл, зачёт/незачёт, разбор ошибок по каждому вопросу
- UX: разделение вида сотрудника (`/tests/:id`) и автора (`/tests/:id/edit`), выпадающее меню «⋯» в списке тестов
### Спринт 3 — Редактирование + версионирование (~46 мин)
- Миграция `003_test_versioning` (поле `parent_id` в tests)
- API: `PUT /api/tests/{id}`, `GET /api/tests/{id}/versions`, `POST /api/tests/{id}/activate`
- Логика: нет попыток → редактировать на месте; есть попытки → создать новую версию (`version+1`, `parent_id=old.id`)
- Фронт: общий компонент `TestForm`, страница редактирования с историей версий и активацией
- Исправлено 2 бага (FK cascade при bulk DELETE, отображение статуса «Активная»)
### Спринт 4 — AI-помощник (DeepSeek) (~63 мин)
- Миграция `004_settings` (таблица settings, key-value)
- Страница `/settings`: ввод API ключа DeepSeek + кнопка «Проверить подключение»
- 6 AI-эндпоинтов:
- `POST /api/llm/check` — проверка подключения
- `POST /api/llm/generate` — генерация вопросов по теме (= название теста)
- `POST /api/llm/improve` — улучшение формулировки вопроса + ответов
- `POST /api/llm/distractors` — генерация дистракторов
- `POST /api/llm/review` — рецензия всего теста
- `POST /api/llm/improve_all` — улучшение всего теста целиком
- В форме теста: 4 AI-кнопки с модалами и постатейным сравнением (галочки для применения изменений)
- Шапка приложения с навигацией
### Спринт 5 — Трекер результатов (~49 мин)
- Миграция `005_attempt_user` (поле `user_id` в test_attempts, дефолт 1 = «Гость»)
- API: `GET /api/attempts` с фильтрами (test_id, date_from, date_to) и пагинацией
- Страница `/tracker`: таблица всех попыток, фильтр по тесту и диапазону дат, пагинация
- Колонки: Сотрудник / Тест + версия / Начало / Завершение / Результат / Зачёт
---
## Объём кода
| Слой | Файлы |
|------|-------|
| Backend — модели | `test.py`, `attempt.py`, `setting.py` |
| Backend — схемы | `test.py`, `attempt.py`, `setting.py` |
| Backend — API роутеры | `tests.py`, `attempts.py`, `llm.py`, `settings.py` |
| Backend — сервисы | `llm.py` |
| Backend — миграции | `001``005` |
| Frontend — страницы | TestList, TestCreate, TestDetail, TestEdit, TestTake, AttemptResult, Settings, Tracker |
| Frontend — компоненты | `TestForm` (с AI-функциями) |
| Frontend — API клиенты | `tests.ts`, `attempts.ts`, `llm.ts`, `settings.ts`, `client.ts` |
| Документация | ТЗ v1.2, СТЕК, СПРИНТЫ, README, 12 файлов ШАГИ |
| **Итого** | **~46 файлов** |
---
## Текущий статус спринтов
| Спринт | Содержание | Статус |
|--------|-----------|--------|
| 1 | Инфраструктура + создание тестов | ✅ Завершён |
| 2 | Прохождение теста + результаты | ✅ Завершён |
| 3 | Редактирование + версионирование | ✅ Завершён |
| 4 | AI-помощник (DeepSeek) | ✅ Завершён |
| 5 | Трекер результатов | ✅ Завершён |
| 6 | Авторизация, роли, подразделения | ⬜ Следующий |
| 7 | Уведомления в MAX | ⬜ Запланирован |
---
## Технический долг перед Спринтом 6
- `user_id = 1` (Гость) в `test_attempts` — заменить на ID авторизованного пользователя
- `GUEST_USER_NAME = "Гость"` в `api/attempts.py` — заменить на JOIN с таблицей `users`
- Все эндпоинты открыты без авторизации — добавить JWT Middleware
---
## Запуск проекта
```bash
docker compose up --build
```
Приложение: `http://localhost`
API документация: `http://localhost/api/docs`

140
DOC/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md

@ -0,0 +1,140 @@
# Предложение по редизайну страницы «Создание теста»
**Ветка:** `dev-new-design-page-createtest`
**Затронутые файлы:** `frontend/src/components/TestForm/index.tsx`, `frontend/src/pages/TestCreate/index.tsx` (через общий компонент), частично `frontend/src/api/llm.ts` (расширение сигнатуры `generate`).
**Бэкенд:** менять не обязательно — у нас уже есть `POST /api/llm/generate` (см. `backend/app/api/llm.py`); нужно лишь принять параметры `questions_count` и `answers_count`.
---
## 1. Цель
Сделать форму создания теста читаемее: разбить «полотно» на три смысловые группы и чётко отделить редактируемое содержимое от служебных команд. Заодно — дать пользователю возможность сгенерировать тест сразу нужной формы, не нащёлкивая «+ вопрос» / «+ вариант» вручную.
## 2. Текущее состояние (что есть)
`TestForm/index.tsx` сейчас визуально устроен так:
```
┌─────────────────────────────────────────┐
│ ← Назад Заголовок │
├─────────────────────────────────────────┤
│ Card «Основные настройки» │
│ • название │
│ • описание │
│ • порог зачёта │
│ • таймер │
│ • разрешить возврат │
├─────────────────────────────────────────┤
│ [Сгенерировать с AI] [Проверить тест] │ ← вне карточек, между мета и вопросами
├─────────────────────────────────────────┤
│ Card «Вопрос 1» │
│ ... │
│ Card «Вопрос N» │
│ [+ Добавить вопрос] │
├─────────────────────────────────────────┤
│ [Создать тест] [Отмена] │
└─────────────────────────────────────────┘
```
Замечания:
- AI-кнопки висят «в воздухе» — не видно, к какой части формы они относятся.
- «Сгенерировать с AI» жёстко создаёт **7 вопросов** (см. `TestForm/index.tsx:123``llmApi.generate(title.trim(), 7)`), без выбора структуры.
- В fallback-сообщении ошибки упоминается «API ключ в настройках» (`TestForm/index.tsx:244`).
## 3. Что меняем
### 3.1. Три смысловых блока
| Блок | Содержит | Визуально |
|------|----------|-----------|
| **Метаинформация** | название, описание, порог, таймер, навигация назад | `Card title="Метаинформация"` |
| **Содержание** | кнопка «Сгенерировать с AI», список вопросов, «+ Добавить вопрос» | `Card title="Содержание"` (вложенные карточки вопросов остаются) |
| **Команды** | «Создать тест», «Отмена», «Проверить тест» | блок `Space` снизу страницы |
Кнопка **«Проверить тест»** логически — это команда над всем тестом, поэтому переезжает в нижнюю панель команд. Кнопка **«Сгенерировать с AI»** становится первым элементом блока «Содержание» — у неё там естественное место (она формирует именно содержание).
### 3.2. Wireframe после редизайна
```
┌─────────────────────────────────────────┐
│ ← Назад Создание теста │
├─────────────────────────────────────────┤
│ Card «Метаинформация» │
│ • название │
│ • описание │
│ • порог зачёта │
│ • таймер │
│ • разрешить возврат │
├─────────────────────────────────────────┤
│ Card «Содержание» │
│ ┌─ AI-генерация ────────────────────┐ │
│ │ тема: [_________________] │ │
│ │ вопросов: [7] вариантов: [3] │ │
│ │ [🤖 Сгенерировать] │ │
│ └──────────────────────────────────┘ │
│ │
│ Card «Вопрос 1» ... │
│ Card «Вопрос N» ... │
│ [+ Добавить вопрос] │
├─────────────────────────────────────────┤
│ [Создать тест] [Проверить тест] [Отмена] │
└─────────────────────────────────────────┘
```
### 3.3. Форма AI-генерации с тремя полями
Внутри блока «Содержание» — компактный мини-блок (не модалка) с тремя полями:
| Поле | Тип | По умолчанию | Лимиты |
|------|-----|--------------|--------|
| Тема | `Input` | значение `title` из метаинформации (auto-fill) | непустая |
| Количество вопросов | `InputNumber` | 7 | 1…30 |
| Количество вариантов на вопрос | `InputNumber` | 3 | 2…8 |
Кнопка **«Сгенерировать»** запускает текущий поток `handleGenerate` (модалка-превью → «Применить все вопросы»), но передаёт оба числа в API. Превью показывает то же, что и сейчас.
Поведение существующих кнопок «Улучшить» и «Дистракторы» внутри карточки вопроса **не меняется** — они и так локальные.
### 3.4. Уход от текста про API-ключи
Единственное место в форме, где упоминается ключ, — fallback-сообщение об ошибке:
```ts
// TestForm/index.tsx:244
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.')
```
Заменяем на нейтральное:
```ts
setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.')
```
Сама страница `Settings` остаётся как есть — это её прямое назначение, и её редактируют только администраторы.
## 4. План работ (чек-лист для исполнителя)
- [ ] **Backend** (опционально, если ещё не принимает параметры): расширить схему `LLMGenerateRequest` в `backend/app/schemas/llm.py` полями `questions_count: int = 7`, `answers_count: int = 3`; передать их в промпт сервиса `app/services/llm.py`.
- [ ] **API-клиент**: в `frontend/src/api/llm.ts` обновить сигнатуру `generate(topic, questionsCount, answersCount)`.
- [ ] **TestForm**: обернуть текущий блок «Основные настройки» в `Card title="Метаинформация"`.
- [ ] **TestForm**: создать новый `Card title="Содержание"`, в него поместить:
- мини-блок AI-генерации (3 поля + кнопка),
- текущий `Form.List` вопросов с кнопкой «+ Добавить вопрос».
- [ ] **TestForm**: убрать текущий ряд из двух AI-кнопок между мета и вопросами; «Проверить тест» перенести в нижнюю панель команд рядом с «Создать»/«Отмена».
- [ ] **TestForm**: добавить локальный стейт `aiQuestionsCount`, `aiAnswersCount`, инициализация: 7 / 3.
- [ ] **TestForm**: в `handleGenerate` передавать оба числа; при `Применить` создавать соответствующее число пустых ответов в каждом вопросе (если бэк прислал меньше — добить пустыми, если больше — обрезать).
- [ ] **TestForm**: заменить fallback-текст про API-ключ.
- [ ] Прогнать `docker compose up`, проверить вручную: создание с дефолтами, генерация на 5 вопросов × 4 ответа, проверка теста, отмена.
- [ ] Документ-шаг в `DOC/ШАГИ/ШАГ_<дата>_<NNN>.md` по факту выполнения.
## 5. Что **не** делаем в этой ветке
- Не трогаем форму редактирования (`TestEdit`) — формально использует тот же `TestForm`, но эффект редизайна нужно отдельно проверить с уже заполненными вопросами и опциями версионирования.
- Не меняем поведение `Form.List` валидации (минимум 7 вопросов / 3 варианта) — это отдельная история.
- Не вводим drag-and-drop переупорядочивание вопросов.
## 6. Открытые вопросы для согласования
1. Нужно ли визуально подсвечивать мини-блок генерации (фон, бордер), чтобы он отличался от карточек вопросов?
2. После применения сгенерированных вопросов — нужно ли скрывать мини-блок, чтобы он не путал?
3. Лимиты «1…30 вопросов / 2…8 вариантов» — устраивают или нужны другие?

54
DOC/СПРИНТЫ.md

@ -102,46 +102,58 @@
--- ---
## Спринт 4 — AI-помощник (DeepSeek) ## Спринт 4 — AI-помощник (DeepSeek)
**Результат:** При создании и редактировании теста доступен AI-ассистент на базе DeepSeek. Ключ API настраивается через страницу настроек. **Результат:** При создании и редактировании теста доступен AI-ассистент на базе DeepSeek. Ключ API настраивается через страницу настроек.
**Статус:** Завершён и протестирован вручную в браузере.
### Страница настроек (`/settings`) ### Страница настроек (`/settings`)
- [ ] Модель БД: `Setting` (key-value, ключ `deepseek_api_key`) - [x] Модель БД: `Setting` (key-value, ключ `deepseek_api_key`)
- [ ] Миграция `004` - [x] Миграция `004`
- [ ] API: `GET /api/settings/{key}`, `PUT /api/settings/{key}` - [x] API: `GET /api/settings/{key}`, `PUT /api/settings/{key}`
- [ ] API: `POST /api/llm/check` — проверить подключение (тестовый запрос к DeepSeek) - [x] API: `POST /api/llm/check` — проверить подключение (тестовый запрос к DeepSeek)
- [ ] Фронт: страница `/settings` — поле для ввода ключа + кнопка «Проверить подключение» - [x] Фронт: страница `/settings` — поле для ввода ключа + кнопка «Проверить подключение»
### AI-функции в форме создания/редактирования теста ### AI-функции в форме создания/редактирования теста
- [ ] API: `POST /api/llm/generate` — сгенерировать вопросы и ответы по теме - [x] API: `POST /api/llm/generate` — сгенерировать вопросы и ответы по теме
- [ ] API: `POST /api/llm/improve` — улучшить формулировку вопроса - [x] API: `POST /api/llm/improve` — улучшить формулировку вопроса
- [ ] API: `POST /api/llm/distractors` — добавить варианты-дистракторы к вопросу - [x] API: `POST /api/llm/distractors` — добавить варианты-дистракторы к вопросу
- [ ] API: `POST /api/llm/review` — проверить качество всего теста - [x] API: `POST /api/llm/review` — проверить качество всего теста
### Интеграция в UI ### Интеграция в UI
- [ ] Кнопка «Сгенерировать с AI» на странице создания теста — вводишь тему, получаешь готовый набор вопросов - [x] Кнопка «Сгенерировать с AI» над списком вопросов — ввод темы → превью → «Применить все вопросы»
- [ ] Кнопка «✨» рядом с каждым вопросом — улучшить формулировку - [x] Кнопка «Улучшить» в шапке каждого вопроса — заменяет формулировку AI-версией
- [ ] Кнопка «+ Дистракторы» рядом с каждым вопросом — дополнить неправильные варианты - [x] Кнопка «Дистракторы» в шапке каждого вопроса — добавляет 3 новых неправильных варианта
- [ ] Кнопка «Проверить тест» — AI анализирует весь тест и выдаёт рекомендации - [x] Кнопка «Проверить тест» — AI анализирует весь тест и выдаёт рекомендации в модале
- [ ] Ссылка на страницу `/settings` в шапке приложения - [x] Ссылка «Настройки» в шапке приложения (новый Layout с AppHeader)
### Технические детали ### Технические детали
- DeepSeek API совместим с форматом OpenAI — используем библиотеку `openai` с `base_url=https://api.deepseek.com` - DeepSeek API совместим с форматом OpenAI — используем библиотеку `openai` с `base_url=https://api.deepseek.com`
- Модель: `deepseek-chat` - Модель: `deepseek-chat`
- Ключ хранится в таблице `settings`, передаётся из бэкенда — фронт ключ не видит - Ключ хранится в таблице `settings`, передаётся из бэкенда — фронт ключ не видит
- `response_format={"type": "json_object"}` для generate, distractors, improve, improve_all — гарантирует структурированный ответ
### Доработки после тестирования
- [x] «Сгенерировать с AI»: убран вопрос про тему — используется название теста; кнопка задизаблена пока название не заполнено
- [x] «Улучшить»: открывает модал с постатейным сравнением (вопрос + каждый ответ) и галочками вместо прямой замены
- [x] «Проверить тест»: добавлена кнопка «Предложить вариант» — вызывает `POST /api/llm/improve_all`, показывает сравнение всего теста с галочками
--- ---
## Спринт 5 — Трекер результатов ## Спринт 5 — Трекер результатов
**Результат:** Таблица всех попыток прохождения тестов. **Результат:** Таблица всех попыток прохождения тестов.
**Статус:** Завершён и протестирован вручную в браузере.
- [x] Миграция `005`: поле `user_id` в `test_attempts` (дефолт 1 = «Гость»)
- [x] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате, пагинацией)
- [x] Фронт: страница `/tracker`
- Таблица: сотрудник, тест + версия, дата начала, дата завершения, результат, зачёт
- Фильтрация по тесту и диапазону дат
- Пагинация (20 записей на страницу)
- [x] Ссылка «Трекер» в шапке приложения
- [ ] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате) **Примечание:** `user_id = 1` («Гость») — временно до Спринта 6 (авторизация).
- [ ] Фронт: страница трекера
- Таблица: тест, версия, дата начала, дата завершения, результат, зачёт
- Фильтрация по тесту и дате
- Пагинация
--- ---

43
DOC/ТЗ.md

@ -1,8 +1,8 @@
# Техническое задание # Техническое задание
## Система тестирования сотрудников клиники ## Система тестирования сотрудников клиники
**Версия:** 1.2 **Версия:** 1.3
**Дата:** 2026-03-21 **Дата:** 2026-04-23
**Статус:** Согласовано **Статус:** Согласовано
--- ---
@ -58,12 +58,14 @@
**Настройки теста (задаются автором при создании):** **Настройки теста (задаются автором при создании):**
- Разрешить возврат к предыдущему вопросу: да / нет - Разрешить возврат к предыдущему вопросу: да / нет
**Правила работы с тестом:** **Правила работы с тестом (версионность):**
- Автор может редактировать тест пока никто его не проходил
- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется - Пока по тесту не было ни одной попытки, автор редактирует тест **на месте** — номер версии не меняется.
- Все версии теста хранятся; результаты привязаны к конкретной версии - Как только появилась хотя бы одна попытка, любое сохранение изменений создаёт **новую версию** теста (`version + 1`, связь со старой версией через `parent_id`). Старая версия становится неактивной, но сохраняется в истории.
- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию - Все версии теста хранятся в виде связанной цепочки. Каждая попытка прохождения привязана к конкретной версии, по которой сотрудник проходил тест, — разбор ошибок по старым результатам остаётся корректным.
- Тест можно деактивировать (скрыть из списка, не удалять) - В списке тестов сотрудникам и авторам показывается только **одна активная версия** каждой цепочки.
- Автор может открыть страницу истории версий теста и вручную переключить активную версию на любую из цепочки — остальные версии при этом автоматически становятся неактивными.
- Тест можно деактивировать целиком (скрыть цепочку из списка, данные не удаляются).
### 4.3. Назначение теста ### 4.3. Назначение теста
@ -113,21 +115,28 @@ HR-менеджер видит трекер по всей клинике.
### 4.7. AI-помощник при создании и редактировании тестов ### 4.7. AI-помощник при создании и редактировании тестов
Интеграция с LLM (DeepSeek) доступна авторам тестов в форме создания и редактирования. Интеграция с LLM (DeepSeek) доступна авторам тестов в форме создания и редактирования. AI работает на двух уровнях: **над всем тестом целиком** и **над отдельным вопросом с блоком его вариантов ответов**.
**Функции уровня всего теста:**
| Функция | Описание |
|---------|----------|
| Сгенерировать тест | На основе названия теста AI генерирует готовый набор вопросов с вариантами ответов. Кнопка доступна только когда название теста заполнено; отдельный ввод темы не требуется. Результат показывается превью, автор применяет его целиком кнопкой «Применить все вопросы». |
| Проверить тест | AI анализирует весь тест и выдаёт структурированные рекомендации по улучшению (чёткость формулировок, качество дистракторов, охват темы). Показывается в модальном окне. |
| Предложить вариант (улучшить весь тест) | AI предлагает улучшенные формулировки всех вопросов и всех вариантов ответов. Результат отображается как **постатейное сравнение** (старая версия → новая) с чекбоксами — автор выбирает, какие изменения применить. |
**Функции AI-помощника:** **Функции уровня одного вопроса:**
| Функция | Описание | | Функция | Описание |
|---------|----------| |---------|----------|
| Генерация теста | Автор вводит тему — AI генерирует готовый набор вопросов с вариантами ответов | | Улучшить вопрос | AI переформулирует выбранный вопрос и его варианты ответов более чётко и однозначно. Результат показывается в модале **с постатейным сравнением и чекбоксами** (вопрос + каждый вариант ответа отдельно), прямой замены без подтверждения не происходит. |
| Улучшение формулировки | AI переформулирует выбранный вопрос более чётко и однозначно | | Дистракторы | AI генерирует 3 новых правдоподобных неправильных варианта ответа к вопросу; они добавляются к существующим, а не заменяют их. |
| Добавление дистракторов | AI генерирует правдоподобные неправильные варианты ответов к вопросу |
| Проверка качества | AI анализирует весь тест и выдаёт рекомендации по улучшению |
**Настройки:** **Настройки:**
- API-ключ DeepSeek вводится на странице `/settings` и хранится в базе данных - API-ключ DeepSeek вводится на странице `/settings` и хранится в базе данных.
- Страница настроек содержит кнопку «Проверить подключение» — выполняет тестовый запрос к API - Страница настроек содержит кнопку «Проверить подключение» — выполняет тестовый запрос к API.
- Ключ хранится только на бэкенде и не передаётся на фронтенд - Ключ хранится только на бэкенде, на фронтенд не передаётся.
- Все AI-функции требуют настроенного ключа: при его отсутствии возвращается понятная ошибка с предложением перейти в «Настройки».
--- ---

272
DOC/ТЗ_доработка_v1.md

@ -0,0 +1,272 @@
# Техническое задание на доработку
## Система тестирования сотрудников клиники
**Версия:** 1.0
**Дата:** 2026-04-23
**Статус:** Черновик
**Адресат:** Константин Л. (разработчик)
**Базовый репозиторий:** https://git.pirogov.ai/l_konstantin/TestingWebApp
---
## 1. Контекст и зачем это делается
### Зачем клинике система тестирования
Система нужна, чтобы **сделать проверку знаний сотрудников управляемым процессом**, а не бумажной рутиной. Сегодня проверка знаний по регламентам, стандартам работы с пациентами, новым протоколам и внутренним правилам проходит устно или на бумаге — это тяжело воспроизводить, невозможно сравнивать результаты между людьми и отделами, а руководитель не видит целостной картины по своему подразделению.
Что система даёт каждой роли:
- **Руководителю подразделения** — единое место, где видно, кто из сотрудников прошёл обязательные тесты, кто не справился, у кого приближается дедлайн. Вместо рассылки регламентов «в чат и на почту» — проверяемая практика: назначил тест → увидел результат → понял, где у отдела пробелы. AI-помощник снимает главный барьер: придумать и сформулировать хороший тест — это отдельная работа, которой у руководителя обычно нет времени. С помощником создание теста занимает минуты, а не часы.
- **Сотруднику** — понятный личный кабинет: какие тесты назначены, когда сдать, какой результат был в прошлый раз, какие ошибки стоит разобрать. Разбор ошибок после теста превращает проверку в короткий обучающий цикл. Если автор составит задорный тест — с живыми формулировками, неочевидными вариантами ответов, лёгким юмором, — прохождение перестаёт восприниматься как рутинная формальность и приобретает элементы фана и геймификации. Это снижает сопротивление и делает регулярные тесты нормальной частью рабочего дня.
- **Директору и его помощнику** — объективная картина по всей клинике: какие подразделения сильнее, какие отстают, где нужно вмешательство. Это инструмент управленческих решений по обучению и кадрам, а не просто отчётность.
- **HR** — структурированная история знаний каждого сотрудника, которая в будущем ляжет в общий HR-контур (онбординг, индивидуальные планы развития, кадровые решения).
### Стратегическая роль: точка входа в HR-приложение в MAX
У модуля тестирования есть ещё одна важная роль — **он становится одной из первых регулярно используемых частей общего HR-приложения в боте MAX**. Тесты назначаются регулярно и требуют обязательного прохождения, а значит, сотрудник будет заходить в HR-приложение не время от времени, а стабильно. Это формирует привычку: открыть HR в MAX — привычное действие. Следом подтянутся и другие HR-сервисы (справочная информация, заявки, графики, отпуска, обратная связь), которые без точки притяжения могли бы оставаться «пустым модулем». Таким образом, система тестирования **популяризует само HR-приложение внутри MAX** и помогает ему стать ежедневным рабочим инструментом, а не редко открываемым разделом.
Отдельно важен **формат взаимодействия**. Внутри бота MAX система тестирования открывается как полноценное веб-приложение (мини-приложение) — с нормальной вёрсткой форм, навигацией, медиа в вопросах, удобными таблицами результатов. Это принципиально лучший пользовательский опыт, чем предыдущая реализация в Telegram, где интерфейс был ограничен форматом бота (сообщения, кнопки, линейные диалоги) и не давал ни удобного ввода, ни нормального отображения сложного контента. Перевод в веб-формат внутри MAX — это не просто смена канала, а качественный скачок в удобстве работы с тестами для всех ролей.
### Текущее состояние
Уже реализовано: авторизация по логину/паролю, создание тестов автором, назначение теста сотрудникам из списка, прохождение теста сотрудником в браузере.
Доработка расширяет эту реализацию новыми возможностями и готовит её к встраиванию в общую HR-систему клиники. В HR-системе уже есть собственная авторизация и собственная система разграничения прав (кто какие разделы и данные видит), поэтому на более поздних этапах собственная авторизация модуля тестирования будет отключена в пользу HR-авторизации. До этого момента текущая авторизация используется как есть.
---
## 2. Ключевые дополнительные возможности
Помимо базового функционала (создание тестов, назначение, прохождение), который уже реализован, в этой доработке добавляются пять возможностей, радикально расширяющих ценность системы. Они перечислены в порядке значимости для пользователя.
### 2.1. AI-помощники при создании тестов — ключевая возможность
Это **главный выигрыш всей доработки**. Создание теста с нуля — это отдельная работа: нужно придумать вопросы, сформулировать варианты ответов, подобрать неочевидные неправильные ответы (дистракторы), проверить качество формулировок. У руководителя подразделения этого времени обычно нет. Без AI-помощника сама задача «писать тесты регулярно» фактически невыполнима — она превращается в проект.
AI-помощник меняет это кардинально:
- **Сокращение времени в разы.** Вместо нескольких часов ручной работы — несколько минут на редактирование черновика, который сгенерировал AI.
- **Более удобный формат работы.** Автор не пишет с пустого листа, а работает в режиме редактора: получает готовое, правит, подтверждает. Отдельные кнопки позволяют улучшить конкретный вопрос, добавить дистракторы, сгенерировать подсказку, проверить весь тест на качество.
- **Выше качество формулировок.** AI предлагает варианты, о которых автор мог не подумать — правдоподобные дистракторы, более чёткие формулировки, корректную подсказку.
Без этой возможности регулярное создание тестов останется узким местом и система не заработает в полную силу. С ней — создание теста становится быстрой повседневной операцией, которую может делать любой руководитель.
### 2.2. Версионирование тестов
Руководитель может править тест после первых прохождений, не теряя корректность старых результатов. Старые попытки по-прежнему корректно разбираются по той версии теста, которая была на момент их прохождения.
### 2.3. Медиа в вопросах
К вопросу можно прикрепить изображение или видео: фото инструмента, рентгеновский снимок, ролик с правильной техникой манипуляции. Тесты перестают быть «только про текст» — это особенно важно в медицинской тематике.
### 2.4. Подсказки и режимы прохождения (таймер, мгновенная оценка)
Один и тот же тест можно проводить как строгую проверку (с таймером, без подсказок, итог в конце) или как мягкий обучающий тренажёр (с подсказками, без таймера, мгновенная обратная связь по каждому вопросу). Набор режимов — независимые настройки, автор комбинирует их под задачу.
### 2.5. Дашборды для всех ролей
Замена ручного сбора статистики на один экран с нужным срезом: сотрудник видит свои тесты и историю, руководитель — своё подразделение, директор — всю клинику с возможностью посмотреть любое подразделение и любого сотрудника.
---
## 3. Этапы
| № | Этап | Формат | Кто принимает |
|---|------|--------|---------------|
| 1 | Доработка редактора тестов | Web desktop | Руководители подразделений |
| 2 | Дашборды (сотрудника / руководителя / директора) | Web desktop | Руководители подразделений + директор |
| 3 | Интеграция с HR-системой | Backend-интеграция + изменения авторизации | Совместно с командой HR |
| 4 | Адаптация под мини-приложение в боте MAX | Mini-app | Совместно с командой HR |
| 5 | Уведомления | В рамках общей системы HR | Совместно с командой HR |
Этапы 1 и 2 реализуются как отдельные desktop-приложения и принимаются независимо друг от друга. Этапы 3–5 выполняются позже, совместно с командой большой HR-системы — в этом ТЗ описаны верхнеуровнево.
---
## 4. Этап 1 — Доработка редактора тестов
Этап расширяет существующий редактор пятью возможностями. Все пять — независимые фичи, могут реализовываться в любом порядке и приниматься отдельно.
### 4.1. Версионирование тестов
Цель: сохранить корректность истории прохождений, когда автор правит тест после первых попыток.
Правила:
- Пока по тесту не было ни одной попытки — автор редактирует тест **на месте**, номер версии не меняется.
- Как только появилась хотя бы одна попытка — любое сохранение изменений создаёт **новую версию** теста (`version + 1`, связь со старой версией через `parent_id`). Старая версия становится неактивной, но сохраняется в базе.
- Все версии теста связаны в цепочку. Каждая попытка прохождения привязана к конкретной версии, по которой сотрудник проходил тест, — разбор ошибок по старым результатам остаётся корректным даже после того, как автор изменил тест.
- В списке тестов для сотрудников и авторов показывается только **одна активная версия** каждой цепочки.
- Автор может открыть историю версий теста и вручную переключить активную версию на любую другую из цепочки — остальные при этом автоматически становятся неактивными.
- Тест можно деактивировать целиком (скрыть всю цепочку из списка, данные не удаляются).
### 4.2. AI-помощник при создании и редактировании тестов
Цель: ускорить и повысить качество создания тестов силами LLM.
Интеграция:
- Используется DeepSeek API (совместим с форматом OpenAI — подключается через библиотеку `openai` с `base_url=https://api.deepseek.com`, модель `deepseek-chat`).
- Для структурированных ответов использовать `response_format={"type": "json_object"}`.
- API-ключ DeepSeek вводится на отдельной странице настроек (`/settings`) и хранится в БД. На фронтенд ключ **не передаётся**.
- На странице настроек — кнопка «Проверить подключение», которая выполняет тестовый запрос к API.
- Все AI-функции требуют настроенного ключа; при его отсутствии возвращается понятная ошибка со ссылкой на «Настройки».
Функции уровня всего теста:
| Функция | Описание |
|---------|----------|
| Сгенерировать тест | По названию теста AI генерирует набор вопросов с вариантами ответов. Кнопка доступна только когда название заполнено. Результат показывается в превью, автор применяет его целиком. |
| Проверить тест | AI анализирует весь тест и выдаёт рекомендации по улучшению (чёткость формулировок, качество дистракторов, охват темы). Показывается в модальном окне. |
| Предложить улучшение всего теста | AI предлагает улучшенные формулировки всех вопросов и ответов. Результат отображается как **постатейное сравнение** (было → стало) с чекбоксами — автор выбирает, какие изменения применить. |
Функции уровня одного вопроса:
| Функция | Описание |
|---------|----------|
| Улучшить вопрос | AI переформулирует выбранный вопрос и его варианты ответов. Результат показывается в модальном окне **с постатейным сравнением и чекбоксами** (вопрос + каждый вариант отдельно). Прямая замена без подтверждения не допускается. |
| Дистракторы | AI генерирует 3 новых правдоподобных неправильных варианта ответа. Они **добавляются** к существующим, а не заменяют их. |
| Сгенерировать подсказку | AI пишет подсказку к вопросу (см. раздел 4.4). Автор может отредактировать или переписать полученный текст. |
### 4.3. Медиа в вопросах
Цель: к вопросу можно прикрепить изображение или видео, которое сотрудник увидит при прохождении.
Требования:
- К одному вопросу может быть прикреплён **один** медиа-файл (изображение или видео).
- Поддерживаемые форматы:
- изображения: JPG, PNG, WebP;
- видео: MP4, WebM.
- Ограничения по размеру:
- изображение — до 5 МБ;
- видео — до 50 МБ.
- Файлы хранятся **локально** на сервере (например, в папке `uploads/`). Внешние хранилища (S3/MinIO) не используются.
- В редакторе вопроса — отдельное поле «Медиа» с загрузкой файла и превью, кнопкой удаления.
- При прохождении теста медиа отображается **над** текстом вопроса. Видео — с нативным плеером браузера.
- Имена файлов на диске должны быть непредсказуемыми (например, UUID), чтобы исключить угадывание ссылок.
### 4.4. Подсказки к вопросам
Цель: при прохождении теста в режиме с подсказками сотрудник может запросить подсказку к вопросу.
Требования:
- Каждый вопрос имеет **одно** необязательное текстовое поле «Подсказка».
- Заполнение подсказки — на усмотрение автора. Три способа:
1. Автор пишет подсказку вручную.
2. Автор нажимает «Сгенерировать подсказку» — AI генерирует текст подсказки; автор может сохранить как есть, отредактировать или удалить.
3. Оставить поле пустым — подсказки по этому вопросу не будет даже в режиме с подсказками.
- Подсказка показывается сотруднику **только** если тест запущен в режиме с подсказками (см. 4.5) и автор её заполнил.
### 4.5. Режимы прохождения теста
Цель: автор при создании теста выбирает, как именно сотрудник будет его проходить.
Три независимых настройки теста (устанавливаются при создании, сохраняются в версии теста):
| Настройка | Варианты | Поведение при прохождении |
|-----------|----------|---------------------------|
| Подсказки | Включены / выключены | Если включены и у вопроса заполнена подсказка — сотруднику доступна кнопка «Показать подсказку» под вопросом. Факт использования подсказки фиксируется в попытке (в будущем может влиять на балл). |
| Таймер | Выключен / N минут | Если задан — отображается обратный отсчёт. По истечении — тест автоматически завершается, попытка считается сданной с тем, что ответил сотрудник. |
| Мгновенная оценка | Включена / выключена | Если включена — после ответа на каждый вопрос сразу показывается правильный ответ и комментарий (разбор по этому вопросу), затем переход к следующему. Если выключена — разбор и итог только после завершения всего теста. |
Настройки отображаются на странице прохождения так, чтобы сотрудник заранее понимал условия (есть ли таймер, будет ли сразу виден правильный ответ и т. д.).
---
## 5. Этап 2 — Дашборды
Цель: предоставить каждой роли индивидуальный экран с релевантной для неё информацией. Этап реализуется отдельным desktop-приложением (или отдельным разделом того же приложения — на усмотрение разработчика).
### 5.1. Дашборд сотрудника
Что видит сотрудник на своей главной странице:
- **Назначенные тесты** — таблица или карточки со статусом (`Не начат`, `В процессе`, `Завершён`, `Просрочен`) и датой дедлайна.
- **График дедлайнов** — визуализация (таймлайн или календарь) по ближайшим срокам сдачи.
- **История попыток** — все попытки сотрудника: тест, версия, дата начала/завершения, результат, зачёт/незачёт.
- Из строки истории — переход на разбор ошибок конкретной попытки.
### 5.2. Дашборд руководителя подразделения
Что видит руководитель подразделения — только по своему подразделению:
- **Сводка по сотрудникам**: список сотрудников с колонками — назначено тестов / сдано / просрочено / средний балл.
- По клику на сотрудника — его история попыток и назначенных тестов.
- **Сводка по назначенным тестам**: по каждому тесту, назначенному подразделению — процент сдавших, список сдавших и несдавших.
- Фильтры: по диапазону дат, по конкретному тесту.
### 5.3. Дашборд директора и помощника директора
Что видят директор и его помощник — по всей клинике:
- **Общая сводка**: число активных тестов, число сотрудников, общий процент сдачи, средний балл.
- **Сравнение подразделений**: таблица подразделений с колонками — число сотрудников, процент сдачи, средний балл. Сортировка по любой колонке.
- По клику на подразделение открывается вид **как у руководителя этого подразделения** (см. 5.2).
- По клику на сотрудника (из любого уровня) — его история попыток.
---
## 6. Этап 3 — Интеграция с HR-системой
Цель: модуль тестирования становится частью большой HR-системы клиники.
Ключевые изменения:
- **Собственная авторизация модуля тестирования отключается.** Вход выполняется через HR (SSO, JWT или другой механизм, который будет определён командой HR).
- **Пользователи, подразделения и роли** приходят из HR — не хранятся в локальной БД модуля тестирования (или хранятся как кэш, синхронизируемый с HR).
- **Разграничение прав доступа** (кто что видит и что может делать) выполняется по ролям, приходящим из HR. Соответствие ролей HR-системы и возможностей модуля тестирования определяется отдельно в начале этапа.
- **Назначение тестов** остаётся **внутри модуля тестирования** (а не в HR). Это отдельный пользовательский сценарий, который удобнее оставить рядом с редактором и трекером.
- Дашборды используют ФИО, подразделения и иерархию из HR.
Детальные контракты (API HR, формат токена, справочники) будут описаны отдельным документом совместно с командой HR перед стартом этапа.
---
## 7. Этап 4 — Мини-приложение для бота MAX
Цель: сотрудник может проходить назначенные тесты прямо из бота MAX, без перехода в браузер.
Верхнеуровневые требования:
- Desktop-интерфейс сотрудника адаптируется под размер мини-приложения MAX (адаптивная вёрстка, упрощённая навигация, без многоуровневого меню).
- Внутри мини-приложения доступны: список назначенных тестов, прохождение теста, результат и разбор ошибок.
- Функции авторов тестов и руководителей в mini-app **не выносятся** — для них остаётся полноценный desktop-интерфейс.
- Авторизация в mini-app — через MAX → HR (конкретная схема определяется на старте этапа).
---
## 8. Этап 5 — Уведомления
Реализуются в рамках общей системы уведомлений большой HR-системы, а не как отдельный модуль системы тестирования.
События, которые должна знать система тестирования и передавать в общую систему уведомлений:
- Сотруднику назначен новый тест.
- Приближается дедлайн сдачи теста (за N дней, N — настраивается).
- Дедлайн теста просрочен без сдачи.
Канал (MAX / e-mail / другое) и формат сообщений определяются общей системой HR.
---
## 9. Вне scope
В рамках этой доработки **не** реализуются:
- Экспорт отчётов в Excel / PDF.
- Собственная система уведомлений внутри модуля тестирования — уведомления будут реализованы в общей HR-системе.
---
## 10. Порядок приёмки
Общий принцип: **каждый этап принимается отдельно**. Следующий этап не начинается, пока предыдущий не принят.
1. **Этап 1** — по мере готовности каждой из пяти функций (4.1–4.5) руководители подразделений вручную проходят по ней сценарии использования, заводят замечания, разработчик их исправляет. Этап принят, когда все пять функций прошли приёмку.
2. **Этап 2** — дашборды тестируются по ролям: сотрудник → руководитель подразделения → директор. Проверяется, что каждая роль видит только разрешённые данные и что переходы между уровнями (клиника → подразделение → сотрудник) работают корректно.
3. **Этапы 3–5** — приёмка проводится совместно с командой большой HR-системы, критерии и сценарии определяются в начале каждого этапа.
Подробные чек-листы тестирования для каждой функции готовятся перед стартом соответствующего этапа и ведутся в отдельных документах в папке `DOC/`.

101
DOC/ШАГИ/ШАГ_2026-03-21_011.md

@ -0,0 +1,101 @@
# ШАГ 011 — Спринт 4: AI-помощник (DeepSeek)
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> запускам спринт 4
---
## Реализовано
Интеграция с DeepSeek LLM при создании и редактировании тестов. Страница настроек для управления API ключом.
---
## Новые файлы
```
backend/app/models/setting.py ← модель Setting (key-value)
backend/app/schemas/setting.py ← SettingOut, SettingUpdate
backend/app/services/__init__.py ← пакет services
backend/app/services/llm.py ← DeepSeek клиент (все 4 функции)
backend/app/api/settings.py ← GET/PUT /api/settings/{key}
backend/app/api/llm.py ← POST /api/llm/check|generate|improve|distractors|review
backend/alembic/versions/004_settings.py ← миграция: таблица settings
frontend/src/api/settings.ts ← API клиент настроек
frontend/src/api/llm.ts ← API клиент LLM
frontend/src/pages/Settings/index.tsx ← страница /settings
```
## Изменённые файлы
```
backend/app/main.py ← зарегистрированы роутеры settings и llm
backend/alembic/env.py ← импорт модели setting
backend/requirements.txt ← добавлен openai==1.57.0
frontend/src/components/TestForm/index.tsx ← добавлены AI-кнопки
frontend/src/App.tsx ← Layout с шапкой, роут /settings
```
---
## API эндпоинты (новые)
| Метод | URL | Описание |
|-------|-----|----------|
| GET | `/api/settings/{key}` | Получить значение настройки |
| PUT | `/api/settings/{key}` | Сохранить значение настройки |
| POST | `/api/llm/check` | Проверить подключение к DeepSeek |
| POST | `/api/llm/generate` | Сгенерировать вопросы по теме |
| POST | `/api/llm/improve` | Улучшить формулировку вопроса |
| POST | `/api/llm/distractors` | Сгенерировать дистракторы |
| POST | `/api/llm/review` | Проверить качество всего теста |
---
## Схема БД (новое)
```
settings
key VARCHAR(100) PK
value TEXT nullable
updated_at TIMESTAMP auto-updated
```
---
## AI-функции в форме теста
| Кнопка | Расположение | Действие |
|--------|-------------|---------|
| «Сгенерировать с AI» | Над списком вопросов | Открывает модал → ввод темы → превью → «Применить все вопросы» |
| «Проверить тест» | Над списком вопросов | Открывает модал с рекомендациями AI по всему тесту |
| «Улучшить» | В шапке каждого вопроса | Заменяет текст вопроса улучшенной AI-формулировкой |
| «Дистракторы» | В шапке каждого вопроса | Добавляет 3 новых неправильных варианта к вопросу |
---
## Технические детали
- DeepSeek API совместим с OpenAI SDK: `AsyncOpenAI(base_url="https://api.deepseek.com")`
- Модель: `deepseek-chat`
- `response_format={"type": "json_object"}` для generate и distractors — гарантирует JSON-ответ
- API ключ хранится в таблице `settings` с ключом `deepseek_api_key`; фронт ключ не видит
- Шапка приложения: новый `Layout` с `AppHeader` — ссылка «Настройки» в правом углу
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [x] Спринт 3: Редактирование + версионность
- [x] Спринт 4: AI-помощник (DeepSeek)
- [ ] Спринт 5: Трекер результатов
- [ ] Спринт 6: Авторизация и роли
- [ ] Спринт 7: Уведомления в MAX

78
DOC/ШАГИ/ШАГ_2026-03-21_012.md

@ -0,0 +1,78 @@
# ШАГ 012 — Спринт 4: Доработки после тестирования
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос (серия правок после тестирования)
> 1. «Сгенерировать с AI» не должен спрашивать тему — использовать название теста
> 2. «Улучшить» должен показывать сравнение старого и нового с галочками, а не затирать текст
> 3. «Проверить тест» → добавить кнопку «Предложить вариант» с полным сравнением всего теста
---
## Доработки
### 1. Кнопка «Сгенерировать с AI»
**Было:** при нажатии открывался модал с полем ввода темы.
**Стало:**
- Кнопка задизаблена, пока не заполнено поле «Название теста» (`shouldUpdate` на поле `title`)
- При нажатии сразу берёт название теста как тему и запускает генерацию
- Открывается модал с анимацией загрузки → превью вопросов → «Применить» / «Сгенерировать заново»
### 2. Кнопка «Улучшить» в карточке вопроса
**Было:** заменяла текст вопроса новой формулировкой без предупреждения.
**Стало:**
- Открывается модал с двумя колонками: текущая формулировка и предложение AI
- Изменения разбиты на позиции: текст вопроса + каждый вариант ответа отдельно
- Чекбокс «Применить» у каждой позиции
- Кнопка «Применить выбранные» — применяет только отмеченные пункты
**Изменения в бэкенде:**
- `improve_question(db, question, answers)` — теперь принимает список ответов и возвращает JSON `{question, answers[]}`
- `POST /api/llm/improve``ImproveRequest` добавлено поле `answers`, `ImproveResponse` теперь `{improved_question, improved_answers[]}`
### 3. Кнопка «Предложить вариант» в модале «Проверить тест»
**Новая кнопка** появляется после получения рекомендаций AI.
**Поведение:**
- При нажатии вызывает новый `POST /api/llm/improve_all`
- Модал переключается в режим сравнения: весь тест постранично
- Для каждого вопроса: текущий vs AI-предложение + чекбокс
- Для каждого варианта ответа: текущий vs AI-предложение + чекбокс
- Правильные ответы помечены `(правильный ✓)`
- Кнопки: «Применить выбранные» / «← К рекомендациям» / «Закрыть»
**Новые файлы/функции бэкенда:**
- `improve_all(db, title, questions)` в `services/llm.py`
- `POST /api/llm/improve_all` в `api/llm.py`
---
## Изменённые файлы
```
backend/app/services/llm.py ← improve_question принимает answers; новая функция improve_all
backend/app/api/llm.py ← обновлён ImproveRequest/ImproveResponse; новый /improve_all
frontend/src/api/llm.ts ← обновлена сигнатура improve; новый метод improveAll
frontend/src/components/TestForm/ ← все три доработки UI
```
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [x] Спринт 3: Редактирование + версионность
- [x] Спринт 4: AI-помощник (DeepSeek)
- [ ] Спринт 5: Трекер результатов
- [ ] Спринт 6: Авторизация и роли
- [ ] Спринт 7: Уведомления в MAX

72
DOC/ШАГИ/ШАГ_2026-03-21_013.md

@ -0,0 +1,72 @@
# ШАГ 013 — Спринт 5: Трекер результатов
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> Оставляем текущий спринт. Введём user_id здесь вручную — пусть это будет гость.
---
## Реализовано
Страница трекера всех попыток с фильтрацией и пагинацией. Все попытки пока принадлежат одному пользователю — «Гость» (user_id = 1).
---
## Новые файлы
```
backend/alembic/versions/005_attempt_user.py ← добавляет user_id в test_attempts
frontend/src/pages/Tracker/index.tsx ← страница /tracker
```
## Изменённые файлы
```
backend/app/models/attempt.py ← поле user_id (default=1, server_default='1')
backend/app/schemas/attempt.py ← AttemptListItem, AttemptListResponse
backend/app/api/attempts.py ← GET /api/attempts + GUEST_USER_ID/NAME константы
frontend/src/api/attempts.ts ← AttemptListItem, AttemptListResponse, list()
frontend/src/App.tsx ← роут /tracker, кнопка «Трекер» в шапке
```
---
## API эндпоинты (новые)
| Метод | URL | Параметры | Описание |
|-------|-----|-----------|----------|
| GET | `/api/attempts` | test_id, date_from, date_to, page, page_size | Список завершённых попыток |
---
## Схема БД (изменено)
```
test_attempts
+ user_id INTEGER NOT NULL DEFAULT 1
```
---
## Технический долг (Sprint 6)
- `user_id = 1` → заменить на ID из JWT токена
- `GUEST_USER_NAME` → JOIN с таблицей users
- Все эндпоинты открыты → добавить JWT Middleware
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [x] Спринт 3: Редактирование + версионность
- [x] Спринт 4: AI-помощник (DeepSeek)
- [x] Спринт 5: Трекер результатов
- [ ] Спринт 6: Авторизация и роли
- [ ] Спринт 7: Уведомления в MAX

6
README.md

@ -46,8 +46,8 @@
| **1** | Инфраструктура (Docker, FastAPI, React, PostgreSQL) + создание тестов | ✅ | | **1** | Инфраструктура (Docker, FastAPI, React, PostgreSQL) + создание тестов | ✅ |
| **2** | Прохождение теста + результаты и разбор ошибок | ✅ | | **2** | Прохождение теста + результаты и разбор ошибок | ✅ |
| **3** | Редактирование тестов + версионность | ✅ | | **3** | Редактирование тестов + версионность | ✅ |
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | | | **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | |
| **5** | Трекер результатов | | | **5** | Трекер результатов | |
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ | | **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ |
| **7** | Уведомления в MAX | ⬜ | | **7** | Уведомления в MAX | ⬜ |
@ -57,7 +57,7 @@
| Файл | Содержание | | Файл | Содержание |
|------|-----------| |------|-----------|
| `DOC/ТЗ.md` | Техническое задание (v1.1) | | `DOC/ТЗ.md` | Техническое задание (v1.2) |
| `DOC/СТЕК.md` | Технологический стек с обоснованием | | `DOC/СТЕК.md` | Технологический стек с обоснованием |
| `DOC/СПРИНТЫ.md` | Детальный план спринтов с задачами | | `DOC/СПРИНТЫ.md` | Детальный план спринтов с задачами |
| `DOC/ШАГИ/` | История разработки шаг за шагом | | `DOC/ШАГИ/` | История разработки шаг за шагом |

2
backend/alembic/env.py

@ -15,7 +15,7 @@ if config.config_file_name is not None:
# Берём DATABASE_URL из настроек приложения # Берём DATABASE_URL из настроек приложения
from app.config import settings from app.config import settings
from app.database import Base from app.database import Base
from app.models import attempt, test # noqa: F401 — импортируем модели, чтобы Alembic их видел from app.models import attempt, setting, test # noqa: F401 — импортируем модели, чтобы Alembic их видел
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)

33
backend/alembic/versions/004_settings.py

@ -0,0 +1,33 @@
"""004_settings
Revision ID: 004
Revises: 003
Create Date: 2026-03-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"settings",
sa.Column("key", sa.String(100), primary_key=True),
sa.Column("value", sa.Text(), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("settings")

31
backend/alembic/versions/005_attempt_user.py

@ -0,0 +1,31 @@
"""005_attempt_user
Revision ID: 005
Revises: 004
Create Date: 2026-03-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"test_attempts",
sa.Column(
"user_id",
sa.Integer(),
nullable=False,
server_default="1",
),
)
def downgrade() -> None:
op.drop_column("test_attempts", "user_id")

69
backend/app/api/attempts.py

@ -1,8 +1,9 @@
import random import random
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -10,16 +11,22 @@ from app.database import get_db
from app.models.attempt import AttemptAnswer, TestAttempt from app.models.attempt import AttemptAnswer, TestAttempt
from app.models.test import Question, Test from app.models.test import Question, Test
from app.schemas.attempt import ( from app.schemas.attempt import (
AnswerForTest,
AnswerResult,
AttemptListItem,
AttemptListResponse,
AttemptResult, AttemptResult,
AttemptStart, AttemptStart,
AttemptStarted, AttemptStarted,
AttemptSubmitDto, AttemptSubmitDto,
AnswerForTest,
AnswerResult,
QuestionForTest, QuestionForTest,
QuestionResult, QuestionResult,
) )
# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю
GUEST_USER_ID = 1
GUEST_USER_NAME = "Гость"
router = APIRouter(prefix="/api/attempts", tags=["attempts"]) router = APIRouter(prefix="/api/attempts", tags=["attempts"])
@ -36,7 +43,7 @@ async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)):
if not test: if not test:
raise HTTPException(status_code=404, detail="Тест не найден") raise HTTPException(status_code=404, detail="Тест не найден")
attempt = TestAttempt(test_id=test.id, status="in_progress") attempt = TestAttempt(test_id=test.id, status="in_progress", user_id=GUEST_USER_ID)
db.add(attempt) db.add(attempt)
await db.commit() await db.commit()
await db.refresh(attempt) await db.refresh(attempt)
@ -162,6 +169,58 @@ async def submit_attempt(
) )
@router.get("", response_model=AttemptListResponse)
async def list_attempts(
test_id: Optional[int] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""Трекер попыток: все завершённые попытки с фильтрацией и пагинацией."""
base = (
select(TestAttempt)
.options(selectinload(TestAttempt.test))
.where(TestAttempt.status == "finished")
)
if test_id is not None:
base = base.where(TestAttempt.test_id == test_id)
if date_from is not None:
base = base.where(TestAttempt.started_at >= date_from)
if date_to is not None:
base = base.where(TestAttempt.started_at <= date_to)
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
rows_result = await db.execute(
base.order_by(TestAttempt.started_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
attempts_list = rows_result.scalars().all()
items = [
AttemptListItem(
id=a.id,
test_id=a.test_id,
test_title=a.test.title,
test_version=a.test.version,
user_id=a.user_id,
user_name=GUEST_USER_NAME, # TODO Sprint 6: заменить на JOIN с таблицей users
started_at=a.started_at,
finished_at=a.finished_at,
score=a.score,
correct_count=a.correct_count,
total_count=a.total_count,
passed=a.passed,
)
for a in attempts_list
]
return AttemptListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/{attempt_id}/result", response_model=AttemptResult) @router.get("/{attempt_id}/result", response_model=AttemptResult)
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)): async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)):
"""Получить результат завершённой попытки.""" """Получить результат завершённой попытки."""

132
backend/app/api/llm.py

@ -0,0 +1,132 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services import llm as llm_service
router = APIRouter(tags=["llm"])
class CheckResponse(BaseModel):
ok: bool
message: str
class GenerateRequest(BaseModel):
topic: str
count: int = 7
answers_count: int = 3
class GenerateResponse(BaseModel):
questions: list[dict]
class ImproveRequest(BaseModel):
question: str
answers: list[str]
class ImproveResponse(BaseModel):
improved_question: str
improved_answers: list[str]
class DistractorsRequest(BaseModel):
question: str
answers: list[str]
class DistractorsResponse(BaseModel):
distractors: list[str]
class ReviewRequest(BaseModel):
title: str
questions: list[dict]
class ReviewResponse(BaseModel):
review: str
class ImproveAllRequest(BaseModel):
title: str
questions: list[dict]
class ImproveAllResponse(BaseModel):
questions: list[dict]
@router.post("/api/llm/check", response_model=CheckResponse)
async def check_connection(db: AsyncSession = Depends(get_db)):
try:
result = await llm_service.check_connection(db)
return {"ok": True, "message": f"Подключение успешно: {result}"}
except ValueError as e:
return {"ok": False, "message": str(e)}
except Exception as e:
return {"ok": False, "message": f"Ошибка подключения: {str(e)}"}
@router.post("/api/llm/generate", response_model=GenerateResponse)
async def generate_questions(req: GenerateRequest, db: AsyncSession = Depends(get_db)):
try:
questions = await llm_service.generate_questions(
db, req.topic, req.count, req.answers_count
)
return {"questions": questions}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/improve", response_model=ImproveResponse)
async def improve_question(req: ImproveRequest, db: AsyncSession = Depends(get_db)):
try:
data = await llm_service.improve_question(db, req.question, req.answers)
return {"improved_question": data["question"], "improved_answers": data["answers"]}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/distractors", response_model=DistractorsResponse)
async def generate_distractors(
req: DistractorsRequest, db: AsyncSession = Depends(get_db)
):
try:
distractors = await llm_service.generate_distractors(
db, req.question, req.answers
)
return {"distractors": distractors}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/review", response_model=ReviewResponse)
async def review_test(req: ReviewRequest, db: AsyncSession = Depends(get_db)):
try:
review = await llm_service.review_test(db, req.title, req.questions)
return {"review": review}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/improve_all", response_model=ImproveAllResponse)
async def improve_all(req: ImproveAllRequest, db: AsyncSession = Depends(get_db)):
try:
questions = await llm_service.improve_all(db, req.title, req.questions)
return {"questions": questions}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")

34
backend/app/api/settings.py

@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.setting import Setting
from app.schemas.setting import SettingOut, SettingUpdate
router = APIRouter(tags=["settings"])
@router.get("/api/settings/{key}", response_model=SettingOut)
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if setting is None:
return SettingOut(key=key, value=None)
return setting
@router.put("/api/settings/{key}", response_model=SettingOut)
async def update_setting(
key: str, data: SettingUpdate, db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(key=key, value=data.value)
db.add(setting)
else:
setting.value = data.value
await db.commit()
await db.refresh(setting)
return setting

4
backend/app/main.py

@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import attempts, tests from app.api import attempts, llm, settings, tests
app = FastAPI( app = FastAPI(
title="QA Test App API", title="QA Test App API",
@ -20,6 +20,8 @@ app.add_middleware(
app.include_router(tests.router) app.include_router(tests.router)
app.include_router(attempts.router) app.include_router(attempts.router)
app.include_router(settings.router)
app.include_router(llm.router)
@app.get("/api/health") @app.get("/api/health")

1
backend/app/models/attempt.py

@ -12,6 +12,7 @@ class TestAttempt(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False) test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
started_at: Mapped[datetime] = mapped_column( started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )

17
backend/app/models/setting.py

@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

26
backend/app/schemas/attempt.py

@ -87,3 +87,29 @@ class AttemptResult(BaseModel):
questions: list[QuestionResult] questions: list[QuestionResult]
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ── Трекер результатов ────────────────────────────────────────
class AttemptListItem(BaseModel):
id: int
test_id: int
test_title: str
test_version: int
user_id: int
user_name: str
started_at: datetime
finished_at: Optional[datetime]
score: Optional[float]
correct_count: Optional[int]
total_count: Optional[int]
passed: Optional[bool]
model_config = {"from_attributes": True}
class AttemptListResponse(BaseModel):
items: list[AttemptListItem]
total: int
page: int
page_size: int

14
backend/app/schemas/setting.py

@ -0,0 +1,14 @@
from typing import Optional
from pydantic import BaseModel
class SettingOut(BaseModel):
key: str
value: Optional[str]
model_config = {"from_attributes": True}
class SettingUpdate(BaseModel):
value: Optional[str] = None

0
backend/app/services/__init__.py

208
backend/app/services/llm.py

@ -0,0 +1,208 @@
import json
from openai import AsyncOpenAI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.setting import Setting
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
DEEPSEEK_MODEL = "deepseek-chat"
async def _get_api_key(db: AsyncSession) -> str:
result = await db.execute(select(Setting).where(Setting.key == "deepseek_api_key"))
setting = result.scalar_one_or_none()
if not setting or not setting.value:
raise ValueError("API ключ DeepSeek не настроен. Перейдите в Настройки.")
return setting.value
def _client(api_key: str) -> AsyncOpenAI:
return AsyncOpenAI(api_key=api_key, base_url=DEEPSEEK_BASE_URL)
async def check_connection(db: AsyncSession) -> str:
api_key = await _get_api_key(db)
client = _client(api_key)
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": "Ответь одним словом: работает"}],
max_tokens=10,
)
return response.choices[0].message.content.strip()
async def generate_questions(
db: AsyncSession,
topic: str,
count: int = 7,
answers_count: int = 3,
) -> list[dict]:
api_key = await _get_api_key(db)
client = _client(api_key)
prompt = f"""Сгенерируй {count} вопросов для теста по теме: "{topic}".
Верни ТОЛЬКО JSON без пояснений в следующем формате:
{{
"questions": [
{{
"text": "Текст вопроса",
"answers": [
{{"text": "Вариант 1", "is_correct": true}},
{{"text": "Вариант 2", "is_correct": false}},
{{"text": "Вариант 3", "is_correct": false}}
]
}}
]
}}
Требования:
- Ровно {answers_count} вариантов ответа на каждый вопрос
- Ровно один правильный ответ на каждый вопрос
- Вопросы должны проверять практические знания по теме
- Варианты ответов должны быть правдоподобными"""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
max_tokens=3000,
)
data = json.loads(response.choices[0].message.content)
return data["questions"]
async def improve_question(
db: AsyncSession, question: str, answers: list[str]
) -> dict:
api_key = await _get_api_key(db)
client = _client(api_key)
answers_str = "\n".join(f"{i + 1}. {a}" for i, a in enumerate(answers))
prompt = f"""Улучши формулировки вопроса и вариантов ответов для теста. Сделай их более чёткими, однозначными и профессиональными.
Вопрос: {question}
Варианты ответов (верни в том же порядке и том же количестве):
{answers_str}
Верни ТОЛЬКО JSON без пояснений:
{{
"question": "улучшенный текст вопроса",
"answers": ["улучшенный вариант 1", "улучшенный вариант 2", ...]
}}"""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
max_tokens=600,
)
return json.loads(response.choices[0].message.content)
async def generate_distractors(
db: AsyncSession, question: str, existing_answers: list[str]
) -> list[str]:
api_key = await _get_api_key(db)
client = _client(api_key)
existing_str = "\n".join(f"- {a}" for a in existing_answers)
prompt = f"""Для вопроса теста сгенерируй 3 правдоподобных неправильных варианта ответа (дистракторы).
Вопрос: {question}
Уже существующие варианты ответов:
{existing_str}
Верни ТОЛЬКО JSON без пояснений:
{{"distractors": ["Вариант 1", "Вариант 2", "Вариант 3"]}}
Требования:
- Дистракторы должны быть правдоподобными, но неправильными
- Не повторяй уже существующие варианты
- Дистракторы должны быть сопоставимы по длине с существующими вариантами"""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
max_tokens=400,
)
data = json.loads(response.choices[0].message.content)
return data["distractors"]
async def review_test(db: AsyncSession, title: str, questions: list[dict]) -> str:
api_key = await _get_api_key(db)
client = _client(api_key)
questions_str = ""
for i, q in enumerate(questions, 1):
questions_str += f"\nВопрос {i}: {q.get('text', '')}\n"
for a in q.get("answers", []):
marker = "" if a.get("is_correct") else ""
questions_str += f" {marker} {a.get('text', '')}\n"
prompt = f"""Проанализируй тест и дай рекомендации по улучшению его качества.
Название теста: {title}
Вопросы:
{questions_str}
Оцени по следующим критериям:
1. Качество и чёткость формулировок вопросов
2. Качество вариантов ответов (правдоподобность дистракторов)
3. Охват темы и разнообразие вопросов
4. Конкретные рекомендации по улучшению
Отвечай на русском языке, структурированно."""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=1500,
)
return response.choices[0].message.content.strip()
async def improve_all(
db: AsyncSession, title: str, questions: list[dict]
) -> list[dict]:
api_key = await _get_api_key(db)
client = _client(api_key)
questions_str = ""
for i, q in enumerate(questions, 1):
questions_str += f"\nВопрос {i}: {q.get('text', '')}\n"
for j, a in enumerate(q.get("answers", []), 1):
questions_str += f" {j}. {a.get('text', '') if isinstance(a, dict) else a}\n"
prompt = f"""Улучши формулировки всех вопросов и вариантов ответов в тесте. Сделай их более чёткими, однозначными и профессиональными.
Название теста: {title}
Вопросы:
{questions_str}
Верни ТОЛЬКО JSON. Для каждого вопроса улучшенную формулировку и все варианты ответов в том же порядке и том же количестве:
{{
"questions": [
{{
"question": "улучшенный текст вопроса 1",
"answers": ["улучшенный вариант 1", "улучшенный вариант 2", "..."]
}}
]
}}"""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
max_tokens=4000,
)
data = json.loads(response.choices[0].message.content)
return data["questions"]

1
backend/requirements.txt

@ -5,3 +5,4 @@ asyncpg==0.29.0
alembic==1.13.3 alembic==1.13.3
pydantic==2.9.2 pydantic==2.9.2
pydantic-settings==2.5.2 pydantic-settings==2.5.2
openai==1.57.0

55
frontend/src/App.tsx

@ -1,22 +1,69 @@
import { BarChartOutlined, SettingOutlined } from '@ant-design/icons'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ConfigProvider } from 'antd' import { Button, ConfigProvider, Layout, Space } from 'antd'
import ruRU from 'antd/locale/ru_RU' import ruRU from 'antd/locale/ru_RU'
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
import AttemptResult from './pages/AttemptResult' import AttemptResult from './pages/AttemptResult'
import Settings from './pages/Settings'
import Tracker from './pages/Tracker'
import TestCreate from './pages/TestCreate' import TestCreate from './pages/TestCreate'
import TestDetail from './pages/TestDetail' import TestDetail from './pages/TestDetail'
import TestEdit from './pages/TestEdit' import TestEdit from './pages/TestEdit'
import TestList from './pages/TestList' import TestList from './pages/TestList'
import TestTake from './pages/TestTake' import TestTake from './pages/TestTake'
const { Header, Content } = Layout
const queryClient = new QueryClient() const queryClient = new QueryClient()
function AppHeader() {
const navigate = useNavigate()
return (
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#fff',
borderBottom: '1px solid #f0f0f0',
padding: '0 24px',
}}
>
<span
style={{ fontWeight: 700, fontSize: 16, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
QA Test App
</span>
<Space>
<Button
icon={<BarChartOutlined />}
type="text"
onClick={() => navigate('/tracker')}
>
Трекер
</Button>
<Button
icon={<SettingOutlined />}
type="text"
onClick={() => navigate('/settings')}
>
Настройки
</Button>
</Space>
</Header>
)
}
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfigProvider locale={ruRU}> <ConfigProvider locale={ruRU}>
<BrowserRouter> <BrowserRouter>
<Layout style={{ minHeight: '100vh', background: '#f5f5f5' }}>
<AppHeader />
<Content>
<Routes> <Routes>
<Route path="/" element={<TestList />} /> <Route path="/" element={<TestList />} />
<Route path="/tests/create" element={<TestCreate />} /> <Route path="/tests/create" element={<TestCreate />} />
@ -24,7 +71,11 @@ export default function App() {
<Route path="/tests/:id/edit" element={<TestEdit />} /> <Route path="/tests/:id/edit" element={<TestEdit />} />
<Route path="/tests/:testId/take" element={<TestTake />} /> <Route path="/tests/:testId/take" element={<TestTake />} />
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} /> <Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
<Route path="/tracker" element={<Tracker />} />
<Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</Content>
</Layout>
</BrowserRouter> </BrowserRouter>
</ConfigProvider> </ConfigProvider>
</QueryClientProvider> </QueryClientProvider>

33
frontend/src/api/attempts.ts

@ -56,6 +56,36 @@ export interface AttemptResult {
questions: QuestionResult[] questions: QuestionResult[]
} }
export interface AttemptListItem {
id: number
test_id: number
test_title: string
test_version: number
user_id: number
user_name: string
started_at: string
finished_at: string | null
score: number | null
correct_count: number | null
total_count: number | null
passed: boolean | null
}
export interface AttemptListResponse {
items: AttemptListItem[]
total: number
page: number
page_size: number
}
export interface AttemptListParams {
test_id?: number
date_from?: string
date_to?: string
page?: number
page_size?: number
}
export const attemptsApi = { export const attemptsApi = {
start: (test_id: number) => start: (test_id: number) =>
client.post<AttemptStarted>('/attempts', { test_id }), client.post<AttemptStarted>('/attempts', { test_id }),
@ -65,4 +95,7 @@ export const attemptsApi = {
getResult: (attempt_id: number) => getResult: (attempt_id: number) =>
client.get<AttemptResult>(`/attempts/${attempt_id}/result`), client.get<AttemptResult>(`/attempts/${attempt_id}/result`),
list: (params: AttemptListParams = {}) =>
client.get<AttemptListResponse>('/attempts', { params }),
} }

48
frontend/src/api/llm.ts

@ -0,0 +1,48 @@
import axios from 'axios'
export interface LLMQuestion {
text: string
answers: { text: string; is_correct: boolean }[]
}
const llmApi = {
check: () =>
axios.post<{ ok: boolean; message: string }>('/api/llm/check').then((r) => r.data),
generate: (topic: string, count = 7, answersCount = 3) =>
axios
.post<{ questions: LLMQuestion[] }>('/api/llm/generate', {
topic,
count,
answers_count: answersCount,
})
.then((r) => r.data),
improve: (question: string, answers: string[]) =>
axios
.post<{ improved_question: string; improved_answers: string[] }>('/api/llm/improve', {
question,
answers,
})
.then((r) => r.data),
distractors: (question: string, answers: string[]) =>
axios
.post<{ distractors: string[] }>('/api/llm/distractors', { question, answers })
.then((r) => r.data),
review: (title: string, questions: object[]) =>
axios
.post<{ review: string }>('/api/llm/review', { title, questions })
.then((r) => r.data),
improveAll: (title: string, questions: object[]) =>
axios
.post<{ questions: { question: string; answers: string[] }[] }>('/api/llm/improve_all', {
title,
questions,
})
.then((r) => r.data),
}
export default llmApi

14
frontend/src/api/settings.ts

@ -0,0 +1,14 @@
import axios from 'axios'
export interface Setting {
key: string
value: string | null
}
const settingsApi = {
get: (key: string) => axios.get<Setting>(`/api/settings/${key}`).then((r) => r.data),
update: (key: string, value: string | null) =>
axios.put<Setting>(`/api/settings/${key}`, { value }).then((r) => r.data),
}
export default settingsApi

770
frontend/src/components/TestForm/index.tsx

@ -1,4 +1,10 @@
import { ArrowLeftOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons' import {
ArrowLeftOutlined,
MinusCircleOutlined,
PlusOutlined,
RobotOutlined,
StarOutlined,
} from '@ant-design/icons'
import { import {
Button, Button,
Card, Card,
@ -6,13 +12,21 @@ import {
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal,
Space, Space,
Switch, Switch,
Table,
Tag,
Typography, Typography,
notification,
} from 'antd' } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useState } from 'react'
import llmApi, { LLMQuestion } from '../../api/llm'
import { TestListItem } from '../../api/tests'
const { Title } = Typography const { Title, Text, Paragraph } = Typography
const EMPTY_ANSWER = { text: '', is_correct: false } const EMPTY_ANSWER = { text: '', is_correct: false }
const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] } const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] }
@ -36,6 +50,11 @@ interface TestFormProps {
onCancel: () => void onCancel: () => void
onBack?: () => void onBack?: () => void
backLabel?: string backLabel?: string
versions?: TestListItem[]
currentVersionId?: number
onActivateVersion?: (id: number) => void
isActivating?: boolean
onOpenVersion?: (id: number) => void
} }
export default function TestForm({ export default function TestForm({
@ -47,33 +66,272 @@ export default function TestForm({
onCancel, onCancel,
onBack, onBack,
backLabel = 'Назад', backLabel = 'Назад',
versions,
currentVersionId,
onActivateVersion,
isActivating,
onOpenVersion,
}: TestFormProps) { }: TestFormProps) {
const [form] = Form.useForm<TestFormValues>() const [form] = Form.useForm<TestFormValues>()
const [notifApi, contextHolder] = notification.useNotification()
// Generate state
const [generateLoading, setGenerateLoading] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false)
const [previewQuestions, setPreviewQuestions] = useState<LLMQuestion[] | null>(null)
const [aiTopic, setAiTopic] = useState('')
const [aiQuestionsCount, setAiQuestionsCount] = useState(7)
const [aiAnswersCount, setAiAnswersCount] = useState(3)
// Improve modal state
const [improveState, setImproveState] = useState<{
open: boolean
qIndex: number | null
loading: boolean
original: { text: string; answers: { text: string; is_correct: boolean }[] } | null
improved: { question: string; answers: string[] } | null
applyQuestion: boolean
applyAnswers: boolean[]
}>({
open: false,
qIndex: null,
loading: false,
original: null,
improved: null,
applyQuestion: true,
applyAnswers: [],
})
// Distractors state: qIndex → loading
const [distractorsLoading, setDistractorsLoading] = useState<Record<number, boolean>>({})
// Review modal state
const [reviewOpen, setReviewOpen] = useState(false)
const [reviewLoading, setReviewLoading] = useState(false)
const [reviewText, setReviewText] = useState('')
// improve_all state (inside review modal)
const [improveAllLoading, setImproveAllLoading] = useState(false)
const [improveAllData, setImproveAllData] = useState<{
original: { text: string; answers: { text: string; is_correct: boolean }[] }[]
improved: { question: string; answers: string[] }[]
applyQuestions: boolean[]
applyAnswers: boolean[][]
} | null>(null)
const defaultValues: Partial<TestFormValues> = { const defaultValues: Partial<TestFormValues> = {
allow_navigation_back: true, allow_navigation_back: true,
has_timer: false, has_timer: false,
passing_score: 70, passing_score: 70,
questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })), questions: Array(7)
.fill(null)
.map(() => ({ ...EMPTY_QUESTION })),
...initialValues, ...initialValues,
} }
// ── AI: Генерация вопросов ──────────────────────────────────────────────
const handleGenerate = async () => {
const titleField = (form.getFieldValue('title') as string) || ''
const topic = (aiTopic.trim() || titleField.trim())
if (!topic) {
notifApi.warning({
message: 'Укажите тему',
description: 'Заполните поле «Тема» или название теста в метаинформации',
})
return
}
setGenerateLoading(true)
setPreviewQuestions(null)
setPreviewOpen(true)
try {
const data = await llmApi.generate(topic, aiQuestionsCount, aiAnswersCount)
setPreviewQuestions(data.questions)
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать вопросы' })
setPreviewOpen(false)
} finally {
setGenerateLoading(false)
}
}
const handleGenerateApply = () => {
if (!previewQuestions) return
form.setFieldValue('questions', previewQuestions)
setPreviewOpen(false)
setPreviewQuestions(null)
notifApi.success({ message: `Добавлено ${previewQuestions.length} вопросов` })
}
const handleGenerateClose = () => {
setPreviewOpen(false)
setPreviewQuestions(null)
}
// ── AI: Улучшить формулировку ──────────────────────────────────────────
const handleImprove = async (qIndex: number) => {
const questionText: string = form.getFieldValue(['questions', qIndex, 'text']) || ''
if (!questionText.trim()) {
notifApi.warning({ message: 'Введите текст вопроса перед улучшением' })
return
}
const answers: { text: string; is_correct: boolean }[] =
form.getFieldValue(['questions', qIndex, 'answers']) || []
setImproveState({
open: true,
qIndex,
loading: true,
original: { text: questionText, answers },
improved: null,
applyQuestion: true,
applyAnswers: answers.map(() => true),
})
try {
const answerTexts = answers.map((a) => a.text)
const data = await llmApi.improve(questionText, answerTexts)
setImproveState((prev) => ({
...prev,
loading: false,
improved: { question: data.improved_question, answers: data.improved_answers },
applyAnswers: data.improved_answers.map(() => true),
}))
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось улучшить вопрос' })
setImproveState((prev) => ({ ...prev, open: false, loading: false }))
}
}
const handleImproveApply = () => {
const { qIndex, original, improved, applyQuestion, applyAnswers } = improveState
if (qIndex === null || !original || !improved) return
if (applyQuestion) {
form.setFieldValue(['questions', qIndex, 'text'], improved.question)
}
const updatedAnswers = original.answers.map((answer, i) => ({
...answer,
text: applyAnswers[i] && improved.answers[i] ? improved.answers[i] : answer.text,
}))
form.setFieldValue(['questions', qIndex, 'answers'], updatedAnswers)
setImproveState((prev) => ({ ...prev, open: false }))
notifApi.success({ message: 'Изменения применены' })
}
// ── AI: Добавить дистракторы ────────────────────────────────────────────
const handleDistractors = async (qIndex: number) => {
const questionText: string = form.getFieldValue(['questions', qIndex, 'text']) || ''
if (!questionText.trim()) {
notifApi.warning({ message: 'Введите текст вопроса перед генерацией дистракторов' })
return
}
const currentAnswers: { text: string; is_correct: boolean }[] =
form.getFieldValue(['questions', qIndex, 'answers']) || []
const answerTexts = currentAnswers.map((a) => a.text).filter(Boolean)
setDistractorsLoading((prev) => ({ ...prev, [qIndex]: true }))
try {
const data = await llmApi.distractors(questionText, answerTexts)
const newAnswers = [
...currentAnswers,
...data.distractors.map((d) => ({ text: d, is_correct: false })),
]
form.setFieldValue(['questions', qIndex, 'answers'], newAnswers)
notifApi.success({ message: `Добавлено ${data.distractors.length} дистракторов` })
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать дистракторы' })
} finally {
setDistractorsLoading((prev) => ({ ...prev, [qIndex]: false }))
}
}
// ── AI: Проверить тест ─────────────────────────────────────────────────
const handleReview = async () => {
const values = form.getFieldsValue()
if (!values.title) {
notifApi.warning({ message: 'Введите название теста перед проверкой' })
return
}
setReviewLoading(true)
setReviewOpen(true)
setReviewText('')
setImproveAllData(null)
try {
const data = await llmApi.review(values.title, values.questions || [])
setReviewText(data.review)
} catch {
setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.')
} finally {
setReviewLoading(false)
}
}
const handleImproveAll = async () => {
const values = form.getFieldsValue()
const original: { text: string; answers: { text: string; is_correct: boolean }[] }[] =
(values.questions || []).map((q: { text: string; answers: { text: string; is_correct: boolean }[] }) => ({
text: q.text,
answers: q.answers || [],
}))
setImproveAllLoading(true)
try {
const data = await llmApi.improveAll(values.title, values.questions || [])
setImproveAllData({
original,
improved: data.questions,
applyQuestions: data.questions.map(() => true),
applyAnswers: data.questions.map((q) => q.answers.map(() => true)),
})
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать улучшения' })
} finally {
setImproveAllLoading(false)
}
}
const handleImproveAllApply = () => {
if (!improveAllData) return
const questions: { text: string; answers: { text: string; is_correct: boolean }[] }[] =
form.getFieldValue('questions') || []
const updated = questions.map((q, qi) => ({
...q,
text:
improveAllData.applyQuestions[qi] && improveAllData.improved[qi]
? improveAllData.improved[qi].question
: q.text,
answers: q.answers.map((a, ai) => ({
...a,
text:
improveAllData.applyAnswers[qi]?.[ai] && improveAllData.improved[qi]?.answers[ai]
? improveAllData.improved[qi].answers[ai]
: a.text,
})),
}))
form.setFieldValue('questions', updated)
setReviewOpen(false)
setImproveAllData(null)
notifApi.success({ message: 'Изменения применены' })
}
return ( return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> <div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
{contextHolder}
{onBack && ( {onBack && (
<Button <Button icon={<ArrowLeftOutlined />} onClick={onBack} style={{ marginBottom: 16 }}>
icon={<ArrowLeftOutlined />}
onClick={onBack}
style={{ marginBottom: 16 }}
>
{backLabel} {backLabel}
</Button> </Button>
)} )}
<Title level={2}>{heading}</Title> <Title level={2}>{heading}</Title>
<Form form={form} layout="vertical" onFinish={onSubmit} initialValues={defaultValues}> <Form form={form} layout="vertical" onFinish={onSubmit} initialValues={defaultValues}>
{/* ── Основные настройки ── */} {/* ── Метаинформация ── */}
<Card title="Основные настройки" style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
<Form.Item <Form.Item
name="title" name="title"
label="Название теста" label="Название теста"
@ -129,6 +387,148 @@ export default function TestForm({
</Form.Item> </Form.Item>
</Card> </Card>
{/* ── Версии теста ── */}
{versions && versions.length > 0 && (
<Card title="Версии теста" style={{ marginBottom: 16 }}>
<Table<TestListItem>
dataSource={versions}
rowKey="id"
size="small"
pagination={false}
rowClassName={(record) =>
record.id === currentVersionId ? 'ant-table-row-selected' : ''
}
columns={
[
{
title: 'Версия',
dataIndex: 'version',
width: 80,
render: (v: number) => <Tag color="default">v{v}</Tag>,
},
{
title: 'Статус',
dataIndex: 'is_active',
width: 130,
render: (active: boolean) =>
active ? (
<Tag color="green">Активная</Tag>
) : (
<Tag color="default">Неактивная</Tag>
),
},
{
title: 'Дата',
dataIndex: 'created_at',
width: 120,
render: (d: string) => new Date(d).toLocaleDateString('ru-RU'),
},
{
title: 'Вопросов',
dataIndex: 'questions_count',
width: 100,
align: 'center' as const,
},
{
title: 'Порог',
dataIndex: 'passing_score',
width: 90,
align: 'center' as const,
render: (s: number) => `${s}%`,
},
{
title: '',
key: 'action',
render: (_: unknown, record: TestListItem) => (
<Space>
{onOpenVersion && record.id !== currentVersionId && (
<Button size="small" onClick={() => onOpenVersion(record.id)}>
Открыть
</Button>
)}
{onActivateVersion &&
(record.id !== currentVersionId || !record.is_active) && (
<Button
size="small"
type={record.is_active ? 'default' : 'primary'}
loading={isActivating}
onClick={() => onActivateVersion(record.id)}
>
Сделать активной
</Button>
)}
</Space>
),
},
] as ColumnsType<TestListItem>
}
/>
</Card>
)}
{/* ── Содержание ── */}
<Card title="Содержание" style={{ marginBottom: 16 }}>
{/* AI мини-форма для генерации структуры теста */}
<div
style={{
padding: 12,
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 8,
marginBottom: 16,
}}
>
<Text strong>Сгенерировать вопросы с AI</Text>
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 4, marginBottom: 12 }}>
Заполните поля и получите готовую структуру вопросов и вариантов без ручного «+ вопрос» / «+ вариант».
</Paragraph>
<Space wrap align="end">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>
Тема (если пусто берётся из названия)
</Text>
<Input
placeholder="Например: Пожарная безопасность"
value={aiTopic}
onChange={(e) => setAiTopic(e.target.value)}
style={{ width: 320 }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>
Вопросов
</Text>
<InputNumber
min={1}
max={30}
value={aiQuestionsCount}
onChange={(v) => setAiQuestionsCount(Number(v) || 7)}
style={{ width: 100 }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>
Вариантов
</Text>
<InputNumber
min={2}
max={8}
value={aiAnswersCount}
onChange={(v) => setAiAnswersCount(Number(v) || 3)}
style={{ width: 100 }}
/>
</div>
<Button
type="primary"
icon={<RobotOutlined />}
onClick={handleGenerate}
loading={generateLoading}
>
Сгенерировать
</Button>
</Space>
</div>
{/* ── Вопросы ── */} {/* ── Вопросы ── */}
<Form.List <Form.List
name="questions" name="questions"
@ -149,12 +549,32 @@ export default function TestForm({
key={key} key={key}
title={`Вопрос ${index + 1}`} title={`Вопрос ${index + 1}`}
extra={ extra={
questionFields.length > 7 ? ( <Space>
<Button
size="small"
icon={<RobotOutlined />}
loading={improveState.loading && improveState.qIndex === index}
onClick={() => handleImprove(index)}
title="Улучшить формулировку"
>
Улучшить
</Button>
<Button
size="small"
icon={<PlusOutlined />}
loading={distractorsLoading[index]}
onClick={() => handleDistractors(index)}
title="Добавить дистракторы"
>
Дистракторы
</Button>
{questionFields.length > 7 && (
<MinusCircleOutlined <MinusCircleOutlined
style={{ color: '#ff4d4f', fontSize: 16 }} style={{ color: '#ff4d4f', fontSize: 16 }}
onClick={() => removeQuestion(qName)} onClick={() => removeQuestion(qName)}
/> />
) : null )}
</Space>
} }
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
@ -253,16 +673,342 @@ export default function TestForm({
</> </>
)} )}
</Form.List> </Form.List>
</Card>
{/* ── Команды ── */}
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit" loading={isPending}> <Button type="primary" htmlType="submit" loading={isPending}>
{submitLabel} {submitLabel}
</Button> </Button>
<Button icon={<StarOutlined />} onClick={handleReview}>
Проверить тест
</Button>
<Button onClick={onCancel}>Отмена</Button> <Button onClick={onCancel}>Отмена</Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
{/* ── Modal: Превью сгенерированных вопросов ── */}
<Modal
title={<><RobotOutlined /> Сгенерированные вопросы</>}
open={previewOpen}
onCancel={handleGenerateClose}
footer={null}
width={640}
>
{generateLoading && (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI генерирует вопросы...</div>
</div>
)}
{!generateLoading && previewQuestions && (
<>
<Text type="secondary">
Сгенерировано {previewQuestions.length} вопросов. Нажмите «Применить», чтобы
заменить вопросы в форме.
</Text>
<div
style={{
maxHeight: 340,
overflowY: 'auto',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: 12,
marginTop: 12,
marginBottom: 16,
background: '#fafafa',
}}
>
{previewQuestions.map((q, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<Text strong>
{i + 1}. {q.text}
</Text>
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
{q.answers.map((a, ai) => (
<li key={ai} style={{ color: a.is_correct ? '#52c41a' : undefined }}>
{a.text} {a.is_correct && '✓'}
</li>
))}
</ul>
</div>
))}
</div>
<Space>
<Button type="primary" onClick={handleGenerateApply}>
Применить все вопросы
</Button>
<Button onClick={handleGenerate}>Сгенерировать заново</Button>
<Button onClick={handleGenerateClose}>Отмена</Button>
</Space>
</>
)}
</Modal>
{/* ── Modal: Улучшение вопроса ── */}
<Modal
title={
<>
<RobotOutlined /> Улучшение вопроса{' '}
{improveState.qIndex !== null ? improveState.qIndex + 1 : ''}
</>
}
open={improveState.open}
onCancel={() => setImproveState((prev) => ({ ...prev, open: false }))}
width={680}
footer={
improveState.improved ? (
<Space>
<Button type="primary" onClick={handleImproveApply}>
Применить выбранные
</Button>
<Button onClick={() => setImproveState((prev) => ({ ...prev, open: false }))}>
Отмена
</Button>
</Space>
) : null
}
>
{improveState.loading && (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI улучшает формулировки...</div>
</div>
)}
{!improveState.loading && improveState.improved && improveState.original && (
<>
{/* Вопрос */}
<div style={{ marginBottom: 16 }}>
<Text strong>Текст вопроса</Text>
<div
style={{
display: 'flex',
gap: 12,
marginTop: 8,
padding: 12,
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Текущая
</Text>
<div>{improveState.original.text}</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>{improveState.improved.question}</div>
</div>
</div>
<Checkbox
checked={improveState.applyQuestion}
onChange={(e) =>
setImproveState((prev) => ({ ...prev, applyQuestion: e.target.checked }))
}
>
Применить
</Checkbox>
</div>
</div>
{/* Ответы */}
<Text strong>Варианты ответов</Text>
<div style={{ marginTop: 8 }}>
{improveState.original.answers.map((answer, i) => (
<div
key={i}
style={{
display: 'flex',
gap: 12,
marginBottom: 8,
padding: 12,
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вариант {i + 1}{answer.is_correct ? ' (правильный ✓)' : ''}
</Text>
<div>{answer.text}</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>
{improveState.improved.answers[i] ?? answer.text}
</div>
</div>
</div>
<Checkbox
checked={improveState.applyAnswers[i]}
onChange={(e) => {
const next = [...improveState.applyAnswers]
next[i] = e.target.checked
setImproveState((prev) => ({ ...prev, applyAnswers: next }))
}}
>
Применить
</Checkbox>
</div>
))}
</div>
</>
)}
</Modal>
{/* ── Modal: Проверка теста + улучшение всего теста ── */}
<Modal
title={
improveAllData
? <><RobotOutlined /> Предложения по улучшению теста</>
: <><StarOutlined /> Рекомендации AI</>
}
open={reviewOpen}
onCancel={() => { setReviewOpen(false); setImproveAllData(null) }}
width={700}
footer={
improveAllData ? (
<Space>
<Button type="primary" onClick={handleImproveAllApply}>
Применить выбранные
</Button>
<Button onClick={() => setImproveAllData(null)}>
К рекомендациям
</Button>
<Button onClick={() => { setReviewOpen(false); setImproveAllData(null) }}>
Закрыть
</Button>
</Space>
) : (
<Space>
{!reviewLoading && reviewText && (
<Button
type="primary"
icon={<RobotOutlined />}
loading={improveAllLoading}
onClick={handleImproveAll}
>
Предложить вариант
</Button>
)}
<Button onClick={() => setReviewOpen(false)}>Закрыть</Button>
</Space>
)
}
>
{/* Режим 1: рекомендации */}
{!improveAllData && (
reviewLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI анализирует тест...</div>
</div>
) : improveAllLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI готовит улучшенный вариант...</div>
</div>
) : (
<Paragraph style={{ whiteSpace: 'pre-wrap' }}>{reviewText}</Paragraph>
)
)}
{/* Режим 2: сравнение старого и нового */}
{improveAllData && (
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
{improveAllData.original.map((origQ, qi) => (
<div key={qi} style={{ marginBottom: 20 }}>
{/* Заголовок вопроса */}
<div
style={{
display: 'flex',
gap: 12,
padding: 12,
background: '#f0f5ff',
borderRadius: 8,
border: '1px solid #d6e4ff',
marginBottom: 6,
}}
>
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вопрос {qi + 1} текущий
</Text>
<div style={{ marginBottom: 4 }}>{origQ.text}</div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>
{improveAllData.improved[qi]?.question ?? origQ.text}
</div>
</div>
<Checkbox
checked={improveAllData.applyQuestions[qi]}
onChange={(e) => {
const next = [...improveAllData.applyQuestions]
next[qi] = e.target.checked
setImproveAllData((prev) => prev && { ...prev, applyQuestions: next })
}}
>
Применить
</Checkbox>
</div>
{/* Ответы */}
{origQ.answers.map((origA, ai) => (
<div
key={ai}
style={{
display: 'flex',
gap: 12,
padding: '8px 12px',
background: '#fafafa',
borderRadius: 6,
border: '1px solid #f0f0f0',
marginBottom: 4,
marginLeft: 16,
}}
>
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вариант {ai + 1}{origA.is_correct ? ' (правильный ✓)' : ''}
</Text>
<div style={{ marginBottom: 2 }}>{origA.text}</div>
<Text type="secondary" style={{ fontSize: 12 }}>AI предлагает</Text>
<div style={{ color: '#1677ff' }}>
{improveAllData.improved[qi]?.answers[ai] ?? origA.text}
</div>
</div>
<Checkbox
checked={improveAllData.applyAnswers[qi]?.[ai]}
onChange={(e) => {
const next = improveAllData.applyAnswers.map((row) => [...row])
next[qi][ai] = e.target.checked
setImproveAllData((prev) => prev && { ...prev, applyAnswers: next })
}}
>
Применить
</Checkbox>
</div>
))}
</div>
))}
</div>
)}
</Modal>
</div> </div>
) )
} }

124
frontend/src/pages/Settings/index.tsx

@ -0,0 +1,124 @@
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Form, Input, Space, Spin, Typography } from 'antd'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import llmApi from '../../api/llm'
import settingsApi from '../../api/settings'
const { Title, Text } = Typography
const API_KEY = 'deepseek_api_key'
export default function Settings() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [checkResult, setCheckResult] = useState<{ ok: boolean; message: string } | null>(null)
const { data: setting, isLoading } = useQuery({
queryKey: ['settings', API_KEY],
queryFn: () => settingsApi.get(API_KEY),
})
const saveMutation = useMutation({
mutationFn: (value: string) => settingsApi.update(API_KEY, value || null),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', API_KEY] })
setCheckResult(null)
},
})
const checkMutation = useMutation({
mutationFn: () => llmApi.check(),
onSuccess: (data) => setCheckResult(data),
})
const handleSave = (values: { api_key: string }) => {
saveMutation.mutate(values.api_key)
}
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
<Spin size="large" />
</div>
)
}
return (
<div style={{ maxWidth: 600, margin: '40px auto', padding: '0 24px' }}>
<Title level={2}>Настройки</Title>
<Card title="AI-помощник (DeepSeek)">
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
Введите API ключ DeepSeek для активации AI-функций при создании и редактировании
тестов. Ключ хранится только на сервере.
</Text>
<Form layout="vertical" onFinish={handleSave} initialValues={{ api_key: setting?.value ?? '' }}>
<Form.Item
name="api_key"
label="API ключ DeepSeek"
>
<Input.Password
placeholder="sk-..."
visibilityToggle
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space wrap>
<Button
type="primary"
htmlType="submit"
loading={saveMutation.isPending}
>
Сохранить
</Button>
<Button
onClick={() => checkMutation.mutate()}
loading={checkMutation.isPending}
disabled={!setting?.value}
>
Проверить подключение
</Button>
<Button onClick={() => navigate('/')}>
На главную
</Button>
</Space>
</Form.Item>
</Form>
{saveMutation.isSuccess && (
<Alert
type="success"
message="Ключ сохранён"
showIcon
style={{ marginTop: 16 }}
closable
/>
)}
{checkResult && (
<Alert
type={checkResult.ok ? 'success' : 'error'}
message={checkResult.message}
icon={
checkResult.ok ? (
<CheckCircleOutlined />
) : (
<CloseCircleOutlined />
)
}
showIcon
style={{ marginTop: 16 }}
closable
onClose={() => setCheckResult(null)}
/>
)}
</Card>
</div>
)
}

6
frontend/src/pages/TestEdit/index.tsx

@ -30,7 +30,6 @@ export default function TestEdit() {
const { data: versions = [] } = useQuery({ const { data: versions = [] } = useQuery({
queryKey: ['tests', id, 'versions'], queryKey: ['tests', id, 'versions'],
queryFn: () => testsApi.versions(Number(id)).then((r) => r.data), queryFn: () => testsApi.versions(Number(id)).then((r) => r.data),
enabled: !editMode,
}) })
const { mutate: activateVersion, isPending: isActivating } = useMutation({ const { mutate: activateVersion, isPending: isActivating } = useMutation({
@ -105,6 +104,11 @@ export default function TestEdit() {
onCancel={() => setEditMode(false)} onCancel={() => setEditMode(false)}
onBack={() => setEditMode(false)} onBack={() => setEditMode(false)}
backLabel="К просмотру теста" backLabel="К просмотру теста"
versions={versions}
currentVersionId={test.id}
onActivateVersion={activateVersion}
isActivating={isActivating}
onOpenVersion={(vid) => navigate(`/tests/${vid}/edit`)}
/> />
) )
} }

145
frontend/src/pages/Tracker/index.tsx

@ -0,0 +1,145 @@
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, DatePicker, Select, Space, Table, Tag, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import dayjs, { Dayjs } from 'dayjs'
import { useState } from 'react'
import { AttemptListItem, attemptsApi } from '../../api/attempts'
import { testsApi } from '../../api/tests'
const { Title } = Typography
const { RangePicker } = DatePicker
export default function Tracker() {
const [testId, setTestId] = useState<number | undefined>()
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null)
const [page, setPage] = useState(1)
const pageSize = 20
const params = {
test_id: testId,
date_from: dateRange?.[0].startOf('day').toISOString(),
date_to: dateRange?.[1].endOf('day').toISOString(),
page,
page_size: pageSize,
}
const { data, isLoading } = useQuery({
queryKey: ['attempts', params],
queryFn: () => attemptsApi.list(params).then((r) => r.data),
})
const { data: testsData } = useQuery({
queryKey: ['tests'],
queryFn: () => testsApi.list().then((r) => r.data),
})
const handleReset = () => {
setTestId(undefined)
setDateRange(null)
setPage(1)
}
const columns: ColumnsType<AttemptListItem> = [
{
title: 'Сотрудник',
dataIndex: 'user_name',
width: 120,
},
{
title: 'Тест',
key: 'test',
render: (_, r) => (
<span>
{r.test_title}{' '}
<Tag color="default" style={{ fontSize: 11 }}>
v{r.test_version}
</Tag>
</span>
),
},
{
title: 'Начало',
dataIndex: 'started_at',
width: 160,
render: (v: string) => dayjs(v).format('DD.MM.YYYY HH:mm'),
},
{
title: 'Завершение',
dataIndex: 'finished_at',
width: 160,
render: (v: string | null) => (v ? dayjs(v).format('DD.MM.YYYY HH:mm') : '—'),
},
{
title: 'Результат',
key: 'result',
width: 140,
render: (_, r) =>
r.correct_count != null && r.total_count != null
? `${r.correct_count} / ${r.total_count} (${r.score?.toFixed(1)}%)`
: '—',
},
{
title: 'Зачёт',
dataIndex: 'passed',
width: 90,
render: (passed: boolean | null) => {
if (passed == null) return '—'
return passed ? (
<Space size={4}>
<CheckCircleTwoTone twoToneColor="#52c41a" />
<Tag color="success">Сдал</Tag>
</Space>
) : (
<Space size={4}>
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
<Tag color="error">Не сдал</Tag>
</Space>
)
},
},
]
return (
<div style={{ maxWidth: 1000, margin: '32px auto', padding: '0 24px' }}>
<Title level={2}>Трекер результатов</Title>
{/* Фильтры */}
<Space wrap style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="Все тесты"
style={{ width: 260 }}
value={testId}
onChange={(v) => { setTestId(v); setPage(1) }}
options={(testsData ?? []).map((t) => ({
value: t.id,
label: `${t.title} (v${t.version})`,
}))}
/>
<RangePicker
value={dateRange}
onChange={(v) => { setDateRange(v as [Dayjs, Dayjs] | null); setPage(1) }}
format="DD.MM.YYYY"
placeholder={['Дата от', 'Дата до']}
/>
<Button onClick={handleReset}>Сбросить</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
onChange: setPage,
showTotal: (total) => `Всего: ${total}`,
}}
/>
</div>
)
}
Loading…
Cancel
Save