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