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