Compare commits
5 Commits
master
..
51df045220
| Author | SHA1 | Date | |
|---|---|---|---|
| 51df045220 | |||
| f6fc92298a | |||
| 6416a72f29 | |||
| fc684e7c7d | |||
| 9a0b3ba92c |
@@ -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`
|
||||||
@@ -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 вариантов» — устраивают или нужны другие?
|
||||||
+33
-21
@@ -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 — Трекер результатов ✅
|
||||||
|
|
||||||
**Результат:** Таблица всех попыток прохождения тестов.
|
**Результат:** Таблица всех попыток прохождения тестов.
|
||||||
|
**Статус:** Завершён и протестирован вручную в браузере.
|
||||||
|
|
||||||
- [ ] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате)
|
- [x] Миграция `005`: поле `user_id` в `test_attempts` (дефолт 1 = «Гость»)
|
||||||
- [ ] Фронт: страница трекера
|
- [x] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате, пагинацией)
|
||||||
- Таблица: тест, версия, дата начала, дата завершения, результат, зачёт
|
- [x] Фронт: страница `/tracker`
|
||||||
- Фильтрация по тесту и дате
|
- Таблица: сотрудник, тест + версия, дата начала, дата завершения, результат, зачёт
|
||||||
- Пагинация
|
- Фильтрация по тесту и диапазону дат
|
||||||
|
- Пагинация (20 записей на страницу)
|
||||||
|
- [x] Ссылка «Трекер» в шапке приложения
|
||||||
|
|
||||||
|
**Примечание:** `user_id = 1` («Гость») — временно до Спринта 6 (авторизация).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 анализирует весь тест и выдаёт рекомендации по улучшению |
|
|
||||||
|
**Функции уровня одного вопроса:**
|
||||||
|
|
||||||
|
| Функция | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| Улучшить вопрос | AI переформулирует выбранный вопрос и его варианты ответов более чётко и однозначно. Результат показывается в модале **с постатейным сравнением и чекбоксами** (вопрос + каждый вариант ответа отдельно), прямой замены без подтверждения не происходит. |
|
||||||
|
| Дистракторы | AI генерирует 3 новых правдоподобных неправильных варианта ответа к вопросу; они добавляются к существующим, а не заменяют их. |
|
||||||
|
|
||||||
**Настройки:**
|
**Настройки:**
|
||||||
- API-ключ DeepSeek вводится на странице `/settings` и хранится в базе данных
|
- API-ключ DeepSeek вводится на странице `/settings` и хранится в базе данных.
|
||||||
- Страница настроек содержит кнопку «Проверить подключение» — выполняет тестовый запрос к API
|
- Страница настроек содержит кнопку «Проверить подключение» — выполняет тестовый запрос к API.
|
||||||
- Ключ хранится только на бэкенде и не передаётся на фронтенд
|
- Ключ хранится только на бэкенде, на фронтенд не передаётся.
|
||||||
|
- Все AI-функции требуют настроенного ключа: при его отсутствии возвращается понятная ошибка с предложением перейти в «Настройки».
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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/`.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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/ШАГИ/` | История разработки шаг за шагом |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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)):
|
||||||
"""Получить результат завершённой попытки."""
|
"""Получить результат завершённой попытки."""
|
||||||
|
|||||||
@@ -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)}")
|
||||||
@@ -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
|
||||||
+3
-1
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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"]
|
||||||
@@ -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
|
||||||
|
|||||||
+61
-10
@@ -1,30 +1,81 @@
|
|||||||
|
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>
|
||||||
<Routes>
|
<Layout style={{ minHeight: '100vh', background: '#f5f5f5' }}>
|
||||||
<Route path="/" element={<TestList />} />
|
<AppHeader />
|
||||||
<Route path="/tests/create" element={<TestCreate />} />
|
<Content>
|
||||||
<Route path="/tests/:id" element={<TestDetail />} />
|
<Routes>
|
||||||
<Route path="/tests/:id/edit" element={<TestEdit />} />
|
<Route path="/" element={<TestList />} />
|
||||||
<Route path="/tests/:testId/take" element={<TestTake />} />
|
<Route path="/tests/create" element={<TestCreate />} />
|
||||||
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
|
<Route path="/tests/:id" element={<TestDetail />} />
|
||||||
</Routes>
|
<Route path="/tests/:id/edit" element={<TestEdit />} />
|
||||||
|
<Route path="/tests/:testId/take" element={<TestTake />} />
|
||||||
|
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
|
||||||
|
<Route path="/tracker" element={<Tracker />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
</Routes>
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,8 +387,150 @@ export default function TestForm({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── Вопросы ── */}
|
{/* ── Версии теста ── */}
|
||||||
<Form.List
|
{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
|
||||||
name="questions"
|
name="questions"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
@@ -149,12 +549,32 @@ export default function TestForm({
|
|||||||
key={key}
|
key={key}
|
||||||
title={`Вопрос ${index + 1}`}
|
title={`Вопрос ${index + 1}`}
|
||||||
extra={
|
extra={
|
||||||
questionFields.length > 7 ? (
|
<Space>
|
||||||
<MinusCircleOutlined
|
<Button
|
||||||
style={{ color: '#ff4d4f', fontSize: 16 }}
|
size="small"
|
||||||
onClick={() => removeQuestion(qName)}
|
icon={<RobotOutlined />}
|
||||||
/>
|
loading={improveState.loading && improveState.qIndex === index}
|
||||||
) : null
|
onClick={() => handleImprove(index)}
|
||||||
|
title="Улучшить формулировку"
|
||||||
|
>
|
||||||
|
Улучшить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
loading={distractorsLoading[index]}
|
||||||
|
onClick={() => handleDistractors(index)}
|
||||||
|
title="Добавить дистракторы"
|
||||||
|
>
|
||||||
|
Дистракторы
|
||||||
|
</Button>
|
||||||
|
{questionFields.length > 7 && (
|
||||||
|
<MinusCircleOutlined
|
||||||
|
style={{ color: '#ff4d4f', fontSize: 16 }}
|
||||||
|
onClick={() => removeQuestion(qName)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
>
|
>
|
||||||
@@ -252,17 +672,343 @@ export default function TestForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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`)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user