36 Commits

Author SHA1 Message Date
Aleksey Razorvin 51df045220 feat: редизайн страницы создания/редактирования теста
- TestForm: смысловые блоки «Метаинформация» / «Версии теста» / «Содержание» / команды
- AI-генерация: мини-форма из 3 полей (тема, число вопросов, число вариантов)
- Кнопка «Проверить тест» переехала в нижнюю панель команд
- Backend: GenerateRequest принимает answers_count, передаётся в промпт
- Убрано упоминание API-ключа в fallback-сообщении формы

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:44:34 +05:00
Aleksey Razorvin f6fc92298a docs: добавить ТЗ на доработку v1.0 для джуниор-разработчика
Новый документ DOC/ТЗ_доработка_v1.md описывает расширение параллельной реализации Кости: контекст и польза для ролей, раздел с ключевыми возможностями (AI-помощники, версионирование, медиа, подсказки и режимы, дашборды), 5 этапов (редактор → дашборды → интеграция с HR → mini-app MAX → уведомления) и порядок приёмки.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:43:25 +05:00
Aleksey Razorvin 6416a72f29 docs: update ТЗ to v1.3 — детализировать версионность и AI-помощник
- §4.2: развернуто описание поведения версионности (правка in-place до
  первой попытки, создание новой версии после, переключение активной,
  привязка попыток к версии)
- §4.7: AI-функции разделены на два уровня — над всем тестом
  (сгенерировать / проверить / предложить вариант) и над отдельным
  вопросом (улучшить / дистракторы); описана механика постатейного
  сравнения с чекбоксами

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:02:12 +05:00
Aleksey Razorvin fc684e7c7d Спринт 5: Трекер результатов
- Миграция 005: user_id в test_attempts (дефолт 1 = Гость)
- GET /api/attempts с фильтрами по тесту, дате и пагинацией
- Страница /tracker: таблица попыток, фильтры, пагинация
- Ссылка «Трекер» в шапке приложения

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:26:40 +05:00
Aleksey Razorvin 9a0b3ba92c Спринт 4: AI-помощник на базе DeepSeek
- Страница /settings: ввод и проверка API ключа DeepSeek
- POST /api/llm/generate — генерация вопросов по названию теста
- POST /api/llm/improve — улучшение формулировки вопроса + ответов (модал с галочками)
- POST /api/llm/distractors — генерация дистракторов
- POST /api/llm/review — рецензия теста + кнопка «Предложить вариант»
- POST /api/llm/improve_all — улучшение всего теста с постатейным сравнением
- Миграция 004: таблица settings (key-value)
- Шапка приложения с навигацией на /settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:11:49 +05:00
Aleksey Razorvin c1a38bfef8 docs: update ТЗ to v1.2 and README with new sprint plan
ТЗ v1.2:
- Versioning logic: 'after first attempt' instead of 'after assignment'
- Added section 4.7 AI assistant (DeepSeek)
- Removed 'auto question generation' from out-of-scope

README:
- Sprint table updated: 7 sprints, statuses, Sprint 4 = AI/DeepSeek
- Added AI and MAX to stack table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:08:50 +05:00
Aleksey Razorvin 6cb2d527e1 docs: insert Sprint 4 LLM/DeepSeek, shift tracker to Sprint 5, auth to Sprint 6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:05:56 +05:00
Aleksey Razorvin d7409c5fa4 docs: mark Sprint 3 complete, add step 010
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 14:00:55 +05:00
Aleksey Razorvin 0977eb0c38 fix: delete answers before questions in update_test to avoid FK violation
Bulk DELETE bypasses ORM cascade — must manually delete child rows first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:56:55 +05:00
Aleksey Razorvin 8097672917 fix: show activate button in table row for current inactive version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:54:00 +05:00
Aleksey Razorvin c64b34edb7 fix: always show activate button for non-current versions and in page header
- Header: 'Сделать активной' appears when current version is not the sole active one
- Versions table: button shown for all non-current versions regardless of is_active status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:52:16 +05:00
Aleksey Razorvin 1103201ee3 feat: add version activation — choose which version is active
Backend:
- POST /api/tests/{id}/activate — deactivates all versions in chain, activates selected
- GET /api/tests — simplified to is_active=True only (no parent_id subquery)
- GET/PUT /api/tests/{id} — removed is_active filter, any version accessible by id
- PUT /api/tests/{id} — new version auto-activates, parent deactivates

Frontend:
- Version history table: status column (Активная/Неактивная), 'Сделать активной' button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:46:42 +05:00
Aleksey Razorvin 8df6077798 feat: add back button to test edit form
TestForm now accepts optional onBack/backLabel props.
TestEdit passes 'К просмотру теста' back button when in edit mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:42:07 +05:00
Aleksey Razorvin 60b9121a05 fix: move version history section below metadata card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:39:07 +05:00
Aleksey Razorvin 6178e6cb5f fix: move version history section below header on test edit page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:37:14 +05:00
Aleksey Razorvin 6b52bca55f feat: add version history section to test edit page
- GET /api/tests/{id}/versions — returns full version chain from oldest to newest
- TestEdit: shows 'История версий' table when multiple versions exist,
  current version highlighted, links to navigate between versions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:35:53 +05:00
Aleksey Razorvin b2a3bda01b feat: Sprint 3 — test editing with versioning
Backend:
- migration 003: add parent_id to tests table
- PUT /api/tests/{id}: edit in place if no attempts, create new version otherwise
- GET /api/tests: show only latest versions (no successor)

Frontend:
- TestForm: extracted reusable form component
- TestCreate: refactored to use TestForm
- TestEdit: full edit mode with pre-populated form, version redirect on new version

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:28:06 +05:00
Aleksey Razorvin 2b5dc379e1 docs: reorder sprints — editing+versioning moves to Sprint 3, tracker to Sprint 4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:22:24 +05:00
Aleksey Razorvin 98049244ae docs: document Sprint 2 UX improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:18:18 +05:00
Aleksey Razorvin f414f62f31 feat: replace action buttons with dropdown menu in test list
Колонка с названием теста теперь занимает всю свободную ширину.
Три действия (Открыть, Изменить, Пройти тест) убраны в выпадающее меню по кнопке «…».

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:16:28 +05:00
Aleksey Razorvin 4f788a9cf6 feat: split test detail into employee view and author view
- /tests/:id — employee view, answers shown without correct/incorrect markers
- /tests/:id/edit — author view, correct answers highlighted + edit button (disabled until Sprint 4)
- TestList: add 'Изменить' button linking to author view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:13:00 +05:00
Aleksey Razorvin 4762f86187 docs: mark Sprint 2 complete, document migration restart bug
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:03:33 +05:00
Aleksey Razorvin a7937c937b feat: add 'Пройти тест' button to test list table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:58:38 +05:00
Aleksey Razorvin d5f6abb5ad feat: Sprint 2 — test taking + results
Backend:
- Models: TestAttempt, AttemptAnswer (migration 002)
- POST /api/attempts: start attempt, shuffle questions/answers,
  hide is_correct, expose is_multiple for UI hints
- POST /api/attempts/{id}/submit: save answers, calculate score,
  strict matching (selected == correct), return full result
- GET /api/attempts/{id}/result: fetch saved result
- Register attempts router in main.py

Frontend:
- api/attempts.ts: types + API functions
- TestTake page: one question at a time, progress bar, timer
  with auto-submit, back navigation controlled by test setting,
  radio/checkbox based on is_multiple
- AttemptResult page: score, pass/fail, per-question breakdown
  with correct/selected/missed answer highlighting
- App.tsx: add /tests/:testId/take and /attempts/:id/result routes
- TestDetail: add "Пройти тест" button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:53:11 +05:00
Aleksey Razorvin 5551202d6f docs: update sprint 1 docs after manual testing
- СПРИНТЫ.md: mark Sprint 1 as completed (), check all tasks,
  add 4 bugs found and fixed during testing
- ШАГ_006.md: add bugs table with symptoms, causes and fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:46:30 +05:00
Aleksey Razorvin 3d21110dd9 fix: serve FastAPI docs under /api prefix
Set docs_url=/api/docs, redoc_url=/api/redoc,
openapi_url=/api/openapi.json so Swagger UI is
accessible through nginx at http://localhost/api/docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:44:15 +05:00
Aleksey Razorvin cc23783e2a fix: resolve container startup issues
backend/Dockerfile:
- Add ENV PYTHONPATH=/app so alembic can import app.config
- Change CMD to bash entrypoint.sh (volume mount breaks chmod +x)

nginx/nginx.conf:
- Add resolver 127.0.0.11 (Docker internal DNS)
- Use set $backend/$frontend variables so nginx resolves
  hostnames per-request instead of at startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:15:37 +05:00
Aleksey Razorvin 8b17c5d3c4 feat: Sprint 1 — infrastructure + test creation
Backend:
- FastAPI + SQLAlchemy 2.0 async + Alembic
- Models: Test, Question, Answer
- API: GET /api/tests, GET /api/tests/{id}, POST /api/tests
- Pydantic validation: min 7 questions, min 3 answers, ≥1 correct

Frontend:
- React 18 + TypeScript + Vite + Ant Design + TanStack Query
- Pages: TestList, TestCreate (nested Form.List), TestDetail

Infrastructure:
- Docker Compose: db (postgres:16), backend, frontend, nginx
- Nginx: /api/ → FastAPI, / → Vite dev server with HMR
- Alembic migration 001_init: tests, questions, answers tables
- entrypoint.sh: wait for db, migrate, start uvicorn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:05:04 +05:00
Aleksey Razorvin 054376bca7 docs: rewrite README with full project overview
- Add project description, roles, test format
- Add tech stack table
- Add sprint plan summary
- Add documentation index
- Add quick start command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:41:30 +05:00
Aleksey Razorvin d013a16e98 docs: add sprint plan and step 005 log
- Create DOC/СПРИНТЫ.md: 5 sprints, value-first approach
- Sprint 1: infra + test creation (no auth)
- Sprint 2: taking tests + results
- Sprint 3: results tracker
- Sprint 4: auth + roles + user management
- Sprint 5: MAX notifications
- Add DOC/ШАГИ/ШАГ_2026-03-21_005.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:39:43 +05:00
Aleksey Razorvin 15de1e9369 docs: add tech stack document and step 004 log
- Create DOC/СТЕК.md: FastAPI + React + PostgreSQL + Docker
- Add DOC/ШАГИ/ШАГ_2026-03-21_004.md with decisions log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:31:57 +05:00
Aleksey Razorvin 52c8aff459 docs: close all open questions, update ТЗ to v1.1
- Director role = HR-manager (same permissions)
- Editing assigned test creates new version
- Question order is random per attempt
- Navigation back configurable by test author
- Notifications via MAX messenger (last sprint)
- Export out of scope
- Add step 003 log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:27:26 +05:00
Aleksey Razorvin 7ff7d9b1d0 docs: add technical specification and step 002 log
- Create DOC/ТЗ.md with full system requirements
- Add DOC/ШАГИ/ШАГ_2026-03-21_002.md with step log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:20:22 +05:00
Aleksey Razorvin c1406c4ed1 merge: combine remote initial commit with local history 2026-03-21 11:07:35 +05:00
Aleksey Razorvin 20cc403b85 docs: update step 001 with remote repo setup
- Add git remote add origin command
- Add git push -u origin main result
- Mark remote repo task as completed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 11:06:04 +05:00
Aleksey Razorvin 3c97d2ba04 chore: init project structure
- Initialize git repository
- Add .gitignore (OS, editor, env, deps)
- Create DOC/ШАГИ/ directory for design documentation
- Add first step log: project kickoff and repo setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:58:14 +05:00
77 changed files with 6651 additions and 2 deletions
+2
View File
@@ -0,0 +1,2 @@
# База данных
DATABASE_URL=postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test
+19
View File
@@ -0,0 +1,19 @@
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
*.swo
# Env
.env
.env.local
.env.*.local
# Dependencies (добавим по мере определения стека)
node_modules/
__pycache__/
*.pyc
+118
View File
@@ -0,0 +1,118 @@
# Итоги разработки — 21 марта 2026
## Общая информация
| Параметр | Значение |
|----------|---------|
| Дата | 21 марта 2026 |
| Начало работы | 10:56 |
| Текущее состояние | Спринты 1–5 завершены |
| Затрачено времени | ~5 часов |
| Коммитов | 26 |
---
## Хронология по коммитам
| Время | Этап |
|-------|------|
| 10:5611:41 | Подготовка: структура проекта, ТЗ v1.1, стек, план спринтов, README |
| 12:0512:46 | **Спринт 1** — инфраструктура + создание тестов |
| 12:5313:18 | **Спринт 2** — прохождение теста + результаты + UX |
| 13:2214:08 | **Спринт 3** — редактирование + версионирование |
| 14:0815:11 | **Спринт 4** — AI-помощник (DeepSeek) |
| 15:1116: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 вариантов» — устраивают или нужны другие?
+235
View File
@@ -0,0 +1,235 @@
# План спринтов
**Дата:** 2026-03-21
**Статус:** Согласовано
---
## Принцип
Каждый спринт — это готовое работающее приложение (frontend + backend), которое можно запустить локально командой `docker compose up` и протестировать вручную в браузере.
---
## Спринт 1 — Инфраструктура + Создание тестов ✅
**Результат:** Поднят весь стек, можно зайти на страницу и создать тест.
**Статус:** Завершён и протестирован вручную в браузере.
### Инфраструктура
- [x] Структура репозитория: `backend/`, `frontend/`, `nginx/`, `docker-compose.yml`
- [x] `docker-compose.yml`: сервисы `db`, `backend`, `frontend`, `nginx`
- [x] PostgreSQL: контейнер, volume для данных
- [x] FastAPI: контейнер, `GET /api/health``{"status": "ok"}`
- [x] Alembic: инициализирован, первая миграция (`001_init`)
- [x] React + Vite: контейнер, базовая страница открывается в браузере
- [x] Nginx: `/` → React SPA, `/api/` → FastAPI
### Создание тестов (без авторизации)
- [x] Модели БД: `Test`, `Question`, `Answer`
- [x] API: `POST /api/tests` — создать тест с вопросами и ответами
- [x] API: `GET /api/tests` — список тестов
- [x] API: `GET /api/tests/{id}` — детали теста
- [x] Фронт: страница создания теста (название, вопросы, варианты, настройки)
- [x] Фронт: список тестов
- [x] Фронт: страница просмотра теста
**Настройки теста:** порог зачёта (%), таймер (опционально), разрешить возврат к предыдущему вопросу
### Баги, найденные и исправленные при тестировании
- [x] `permission denied` на `entrypoint.sh` — volume mount перекрывал `chmod +x` из Dockerfile → исправлено: `CMD ["bash", "entrypoint.sh"]`
- [x] `No module named 'app'` в Alembic — Python не видел `/app` → исправлено: `ENV PYTHONPATH=/app` в Dockerfile
- [x] `host not found in upstream "backend"` в nginx — nginx резолвил хост при старте, до поднятия backend → исправлено: Docker DNS resolver + `set $backend`
- [x] `http://localhost/api/docs` → 404 — FastAPI отдавал docs по `/docs`, а не `/api/docs` → исправлено: явные `docs_url`, `redoc_url`, `openapi_url` в FastAPI
---
## Спринт 2 — Прохождение теста ✅
**Результат:** Можно выбрать тест из списка и пройти его, увидеть результат и разбор ошибок.
**Статус:** Завершён и протестирован вручную в браузере.
- [x] Модели БД: `TestAttempt`, `AttemptAnswer`
- [x] API: `POST /api/attempts` — начать попытку (фиксируем время начала)
- [x] API: `POST /api/attempts/{id}/submit` — завершить попытку, подсчитать результат
- [x] API: `GET /api/attempts/{id}/result` — результат с разбором ошибок
- [x] Фронт: страница прохождения теста
- Случайный порядок вопросов
- Таймер с обратным отсчётом (если задан) — автосабмит по истечении
- Навигация назад (если разрешена настройкой теста)
- [x] Фронт: страница результата
- Балл и процент
- Сдал / Не сдал (относительно порога)
- Разбор ошибок: вопрос, ответ сотрудника, правильный ответ
- [x] Фронт: кнопка «Пройти тест» прямо в строке таблицы списка тестов
### Доработки после тестирования
- [x] Страница теста разделена на два вида:
- `/tests/:id` — вид сотрудника: вопросы и варианты ответов без отметок правильных
- `/tests/:id/edit` — вид автора: правильные ответы отмечены, жёлтый баннер, кнопка «Редактировать» (задизаблена до Спринта 4)
- [x] Список тестов: три кнопки действий заменены на выпадающее меню «⋯» — колонка с названием стала полноширинной
### Баги, найденные и исправленные при тестировании
- [x] «Не удалось загрузить тест» × 2 при нажатии «Пройти тест» — миграция `002_attempts` не применилась, т.к. `--reload` перезапускает только код приложения, но не `entrypoint.sh` → исправлено: `docker compose restart backend`
---
## Спринт 3 — Редактирование теста + версионность ✅
**Результат:** Тест можно редактировать. Если тест уже проходили — создаётся новая версия, старая сохраняется для истории.
**Статус:** Завершён и протестирован вручную в браузере.
### Backend
- [x] Миграция `003`: добавить поле `parent_id` в таблицу `tests`
- [x] `PUT /api/tests/{id}` — редактировать тест:
- Нет попыток → обновить на месте
- Есть попытки → создать новый тест (`version + 1`, `parent_id = id`), вернуть `{test, is_new_version: true}`
- [x] `GET /api/tests` — показывать только активные версии (`is_active = True`)
- [x] `GET /api/tests/{id}/versions` — цепочка всех версий теста
- [x] `POST /api/tests/{id}/activate` — сделать версию активной (деактивирует остальные в цепочке)
### Frontend
- [x] Страница `/tests/:id/edit` разделена на режим просмотра и режим редактирования
- [x] Форма редактирования с предзаполненными данными (общий компонент `TestForm`)
- [x] При сохранении с новой версией — редирект + уведомление «Создана новая версия v2»
- [x] Кнопка «← К просмотру теста» в форме редактирования
- [x] Секция «История версий»: таблица с версиями, статусом, датой, кнопкой «Сделать активной»
- [x] Активная версия — единственная видимая в списке тестов
### Баги, найденные и исправленные при тестировании
- [x] `ForeignKeyViolationError` при сохранении — bulk `DELETE questions` не каскадирует на `answers` → исправлено: сначала удаляем `answers`, потом `questions`
- [x] Обе версии показывались «Активными» при создании до введения логики деактивации → исправлено: кнопка «Сделать активной» в шапке и в строке таблицы
---
## Спринт 4 — AI-помощник (DeepSeek) ✅
**Результат:** При создании и редактировании теста доступен AI-ассистент на базе DeepSeek. Ключ API настраивается через страницу настроек.
**Статус:** Завершён и протестирован вручную в браузере.
### Страница настроек (`/settings`)
- [x] Модель БД: `Setting` (key-value, ключ `deepseek_api_key`)
- [x] Миграция `004`
- [x] API: `GET /api/settings/{key}`, `PUT /api/settings/{key}`
- [x] API: `POST /api/llm/check` — проверить подключение (тестовый запрос к DeepSeek)
- [x] Фронт: страница `/settings` — поле для ввода ключа + кнопка «Проверить подключение»
### AI-функции в форме создания/редактирования теста
- [x] API: `POST /api/llm/generate` — сгенерировать вопросы и ответы по теме
- [x] API: `POST /api/llm/improve` — улучшить формулировку вопроса
- [x] API: `POST /api/llm/distractors` — добавить варианты-дистракторы к вопросу
- [x] API: `POST /api/llm/review` — проверить качество всего теста
### Интеграция в UI
- [x] Кнопка «Сгенерировать с AI» над списком вопросов — ввод темы → превью → «Применить все вопросы»
- [x] Кнопка «Улучшить» в шапке каждого вопроса — заменяет формулировку AI-версией
- [x] Кнопка «Дистракторы» в шапке каждого вопроса — добавляет 3 новых неправильных варианта
- [x] Кнопка «Проверить тест» — AI анализирует весь тест и выдаёт рекомендации в модале
- [x] Ссылка «Настройки» в шапке приложения (новый Layout с AppHeader)
### Технические детали
- DeepSeek API совместим с форматом OpenAI — используем библиотеку `openai` с `base_url=https://api.deepseek.com`
- Модель: `deepseek-chat`
- Ключ хранится в таблице `settings`, передаётся из бэкенда — фронт ключ не видит
- `response_format={"type": "json_object"}` для generate, distractors, improve, improve_all — гарантирует структурированный ответ
### Доработки после тестирования
- [x] «Сгенерировать с AI»: убран вопрос про тему — используется название теста; кнопка задизаблена пока название не заполнено
- [x] «Улучшить»: открывает модал с постатейным сравнением (вопрос + каждый ответ) и галочками вместо прямой замены
- [x] «Проверить тест»: добавлена кнопка «Предложить вариант» — вызывает `POST /api/llm/improve_all`, показывает сравнение всего теста с галочками
---
## Спринт 5 — Трекер результатов ✅
**Результат:** Таблица всех попыток прохождения тестов.
**Статус:** Завершён и протестирован вручную в браузере.
- [x] Миграция `005`: поле `user_id` в `test_attempts` (дефолт 1 = «Гость»)
- [x] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате, пагинацией)
- [x] Фронт: страница `/tracker`
- Таблица: сотрудник, тест + версия, дата начала, дата завершения, результат, зачёт
- Фильтрация по тесту и диапазону дат
- Пагинация (20 записей на страницу)
- [x] Ссылка «Трекер» в шапке приложения
**Примечание:** `user_id = 1` («Гость») — временно до Спринта 6 (авторизация).
---
## Спринт 6 — Авторизация и управление пользователями
**Результат:** Вход по логину/паролю, роли ограничивают доступ. Можно создавать сотрудников и подразделения.
### Авторизация
- [ ] Модели БД: `User`, `Department`
- [ ] API: `POST /api/auth/login` → JWT access token
- [ ] API: `POST /api/auth/logout`
- [ ] API: `GET /api/auth/me`
- [ ] Middleware: проверка JWT на защищённых эндпоинтах
- [ ] Фронт: страница входа
- [ ] Фронт: защищённые роуты (редирект на логин если нет токена)
### Роли и права
| Роль | Тесты | Трекер |
|------|-------|--------|
| HR-менеджер / Директор | Создаёт и редактирует все тесты | Вся клиника |
| Руководитель подразделения | Создаёт и редактирует свои тесты | Только свой отдел |
| Сотрудник | Проходит назначенные тесты | Только свои результаты |
### Управление пользователями
- [ ] API: CRUD подразделений
- [ ] API: CRUD пользователей (создание, редактирование, деактивация)
- [ ] Фронт: страница управления подразделениями (HR)
- [ ] Фронт: страница управления сотрудниками (HR / руководитель)
### Назначение тестов
- [ ] Модели БД: `TestAssignment`
- [ ] API: `POST /api/assignments` — назначить тест (получатели, дедлайн, кол-во попыток)
- [ ] Фронт: форма назначения теста
- [ ] Фронт: дашборд сотрудника — список назначенных тестов со статусами (`Не начат`, `В процессе`, `Завершён`, `Просрочен`)
---
## Спринт 7 — Уведомления в MAX
**Результат:** Сотрудники получают уведомления в мессенджер MAX.
- [ ] Изучить документацию MAX API
- [ ] Реализовать сервис уведомлений в backend
- [ ] Уведомление при назначении теста сотруднику
- [ ] Уведомление за N дней до дедлайна (настраивается)
- [ ] Поле `max_user_id` в профиле пользователя
- [ ] Фронт: в профиле пользователя — поле для MAX ID
---
## Структура репозитория (целевая после Спринта 1)
```
qa_test_app/
├── backend/
│ ├── app/
│ │ ├── api/ ← роутеры FastAPI
│ │ ├── models/ ← SQLAlchemy модели
│ │ ├── schemas/ ← Pydantic схемы
│ │ ├── services/ ← бизнес-логика
│ │ └── main.py
│ ├── alembic/
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── api/ ← Axios + TanStack Query
│ │ ├── components/ ← переиспользуемые компоненты
│ │ ├── pages/ ← страницы
│ │ └── main.tsx
│ ├── Dockerfile
│ └── package.json
├── nginx/
│ └── nginx.conf
├── docker-compose.yml
└── DOC/
```
+121
View File
@@ -0,0 +1,121 @@
# Технологический стек
**Дата:** 2026-03-21
**Статус:** Согласовано
---
## Архитектура
```
┌─────────────────────────────────────────────┐
│ Nginx │
│ / → React SPA (статика) │
│ /api/ → FastAPI backend │
└────────────────────┬────────────────────────┘
│ Docker network
┌──────────┴──────────┐
│ │
┌─────▼──────┐ ┌──────▼─────┐
│ FastAPI │ │ React SPA │
│ (backend) │ │ (frontend)│
└─────┬──────┘ └────────────┘
┌─────▼──────┐
│ PostgreSQL │
└────────────┘
```
---
## Backend
| Компонент | Технология | Версия |
|-----------|-----------|--------|
| Язык | Python | 3.12+ |
| Фреймворк | FastAPI | 0.115+ |
| ORM | SQLAlchemy (async) | 2.0+ |
| Миграции | Alembic | latest |
| База данных | PostgreSQL | 16+ |
| Валидация | Pydantic v2 | 2.x |
| Аутентификация | JWT (python-jose) | latest |
| Хэширование паролей | passlib + bcrypt | latest |
| ASGI-сервер | Uvicorn | latest |
### Почему FastAPI
- Автоматическая генерация OpenAPI/Swagger документации — джуниор сразу видит API
- Async из коробки — правильные привычки с первого проекта
- Pydantic-схемы = валидация + сериализация в одном месте
- Активное сообщество, отличная документация на русском
---
## Frontend
| Компонент | Технология | Версия |
|-----------|-----------|--------|
| Фреймворк | React | 18+ |
| Язык | TypeScript | 5.x |
| Сборщик | Vite | 5.x |
| UI-библиотека | Ant Design (antd) | 5.x |
| Роутинг | React Router | v6 |
| Серверный стейт | TanStack Query (React Query) | v5 |
| HTTP-клиент | Axios | latest |
### Почему Ant Design
- Готовые компоненты: таблицы, формы, прогрессбары, таймеры — сэкономит спринты
- Хорошо подходит для административных интерфейсов
- Поддержка русской локализации
### Почему TanStack Query
- Учит джуниора правильной работе с серверным состоянием
- Кэширование, loading/error состояния, инвалидация — всё из коробки
- Убирает необходимость в Redux для большинства задач
---
## Инфраструктура
| Компонент | Технология |
|-----------|-----------|
| Контейнеризация | Docker + Docker Compose |
| Реверс-прокси | Nginx |
| ОС сервера | Linux (Ubuntu/Debian) |
### Структура Docker Compose
```yaml
services:
db: # PostgreSQL
backend: # FastAPI + Uvicorn
frontend: # React (build) / Vite dev server
nginx: # Reverse proxy
```
---
## Инструменты разработки
| Назначение | Инструмент |
|-----------|-----------|
| Линтер Python | Ruff |
| Форматтер Python | Black |
| Линтер/форматтер JS | ESLint + Prettier |
| API-тестирование | Swagger UI (встроен в FastAPI) |
---
## Уведомления (последний спринт)
- Канал: мессенджер **MAX**
- Реализация: отдельный сервис/модуль в backend
- Интеграция: MAX API (изучить документацию перед спринтом)
---
## Вне scope (не используем)
- Redis / очереди задач (Celery) — не нужны для данного масштаба
- GraphQL — REST достаточно
- Kubernetes — Docker Compose достаточно для 50–200 пользователей
- SSR (Next.js) — не нужен, SPA достаточно
+172
View File
@@ -0,0 +1,172 @@
# Техническое задание
## Система тестирования сотрудников клиники
**Версия:** 1.3
**Дата:** 2026-04-23
**Статус:** Согласовано
---
## 1. Назначение системы
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. Руководители подразделений и HR-менеджер создают тесты и назначают их сотрудникам. Сотрудники проходят тесты в браузере. Система фиксирует все попытки и результаты.
---
## 2. Роли и права доступа
| Роль | Кто | Создаёт тесты | Назначает тесты | Видит результаты |
|------|-----|:---:|:---:|:---:|
| **HR-менеджер** | Руководитель службы HR, Директор клиники | ✅ | Всем сотрудникам клиники | Всех сотрудников |
| **Руководитель подразделения** | Главный врач, рук. службы администраторов и др. | ✅ | Только своему подразделению | Только своего подразделения |
| **Сотрудник** | Все остальные работники | ❌ | ❌ | Только свои |
---
## 3. Авторизация
- Вход по логину и паролю
- Учётные записи создаются администратором системы вручную
- Сессия хранится на сервере (cookie-based или JWT — определить при выборе стека)
- Пароль хранится в зашифрованном виде (bcrypt или аналог)
---
## 4. Функциональные требования
### 4.1. Управление пользователями и подразделениями
- Создание/редактирование/деактивация учётных записей сотрудников
- Каждый сотрудник принадлежит одному подразделению
- Создание/редактирование справочника подразделений
- Назначение роли сотруднику: HR-менеджер / Руководитель подразделения / Сотрудник
### 4.2. Создание и редактирование тестов
**Тест содержит:**
- Название теста
- Описание (опционально)
- Список вопросов (минимум 7)
- Порог зачёта — минимальный % правильных ответов (задаётся автором)
- Таймер прохождения — лимит в минутах (опционально)
**Вопрос содержит:**
- Текст вопроса
- Минимум 3 варианта ответа
- Один или несколько правильных ответов (чекбокс или радио-кнопка в зависимости от типа)
**Настройки теста (задаются автором при создании):**
- Разрешить возврат к предыдущему вопросу: да / нет
**Правила работы с тестом (версионность):**
- Пока по тесту не было ни одной попытки, автор редактирует тест **на месте** — номер версии не меняется.
- Как только появилась хотя бы одна попытка, любое сохранение изменений создаёт **новую версию** теста (`version + 1`, связь со старой версией через `parent_id`). Старая версия становится неактивной, но сохраняется в истории.
- Все версии теста хранятся в виде связанной цепочки. Каждая попытка прохождения привязана к конкретной версии, по которой сотрудник проходил тест, — разбор ошибок по старым результатам остаётся корректным.
- В списке тестов сотрудникам и авторам показывается только **одна активная версия** каждой цепочки.
- Автор может открыть страницу истории версий теста и вручную переключить активную версию на любую из цепочки — остальные версии при этом автоматически становятся неактивными.
- Тест можно деактивировать целиком (скрыть цепочку из списка, данные не удаляются).
### 4.3. Назначение теста
При назначении задаются:
- Список получателей (отдел или конкретные сотрудники)
- Срок сдачи — дата дедлайна (задаётся в днях от даты назначения или конкретной датой)
- Допустимое количество попыток (1 или более — задаётся при назначении)
HR-менеджер может назначить тест сотрудникам любых подразделений.
Руководитель подразделения — только сотрудникам своего подразделения.
### 4.4. Прохождение теста (интерфейс сотрудника)
- На главной странице сотрудник видит список назначенных ему тестов со статусами:
- `Не начат` — ещё не открывал
- `В процессе` — начал, не завершил (если таймер — отсчёт продолжается)
- `Завершён` — сдал/не сдал
- `Просрочен` — дедлайн прошёл, не сдан
- Если задан таймер — отображается обратный отсчёт, по истечении тест завершается автоматически
- Порядок вопросов **случайный** при каждом прохождении
- Возможность вернуться к предыдущему вопросу — определяется настройкой теста
### 4.5. Результаты после завершения теста
Сотрудник сразу после сдачи видит:
- Итоговый балл и процент правильных ответов
- Факт зачёта: **сдал / не сдал** (относительно порога)
- Разбор ошибок: по каждому вопросу — его ответ и правильный ответ
### 4.6. Трекер попыток
Система фиксирует каждую попытку прохождения теста:
| Поле | Описание |
|------|----------|
| Сотрудник | ФИО, подразделение |
| Тест | Название |
| Попытка № | Порядковый номер попытки |
| Начало | Дата и время начала |
| Завершение | Дата и время окончания |
| Результат | Количество правильных ответов / всего, % |
| Зачёт | Да / Нет (преодолён ли порог) |
Руководитель видит трекер по своему подразделению.
HR-менеджер видит трекер по всей клинике.
Сотрудник видит только свои попытки.
### 4.7. AI-помощник при создании и редактировании тестов
Интеграция с LLM (DeepSeek) доступна авторам тестов в форме создания и редактирования. AI работает на двух уровнях: **над всем тестом целиком** и **над отдельным вопросом с блоком его вариантов ответов**.
**Функции уровня всего теста:**
| Функция | Описание |
|---------|----------|
| Сгенерировать тест | На основе названия теста AI генерирует готовый набор вопросов с вариантами ответов. Кнопка доступна только когда название теста заполнено; отдельный ввод темы не требуется. Результат показывается превью, автор применяет его целиком кнопкой «Применить все вопросы». |
| Проверить тест | AI анализирует весь тест и выдаёт структурированные рекомендации по улучшению (чёткость формулировок, качество дистракторов, охват темы). Показывается в модальном окне. |
| Предложить вариант (улучшить весь тест) | AI предлагает улучшенные формулировки всех вопросов и всех вариантов ответов. Результат отображается как **постатейное сравнение** (старая версия → новая) с чекбоксами — автор выбирает, какие изменения применить. |
**Функции уровня одного вопроса:**
| Функция | Описание |
|---------|----------|
| Улучшить вопрос | AI переформулирует выбранный вопрос и его варианты ответов более чётко и однозначно. Результат показывается в модале **с постатейным сравнением и чекбоксами** (вопрос + каждый вариант ответа отдельно), прямой замены без подтверждения не происходит. |
| Дистракторы | AI генерирует 3 новых правдоподобных неправильных варианта ответа к вопросу; они добавляются к существующим, а не заменяют их. |
**Настройки:**
- API-ключ DeepSeek вводится на странице `/settings` и хранится в базе данных.
- Страница настроек содержит кнопку «Проверить подключение» — выполняет тестовый запрос к API.
- Ключ хранится только на бэкенде, на фронтенд не передаётся.
- Все AI-функции требуют настроенного ключа: при его отсутствии возвращается понятная ошибка с предложением перейти в «Настройки».
---
## 5. Нефункциональные требования
| Параметр | Требование |
|----------|-----------|
| Количество пользователей | 50–200 человек |
| Платформа | Веб-приложение, браузер (desktop-first) |
| Доступность | Внутренняя сеть клиники |
| Язык интерфейса | Русский |
| Время отклика | < 2 секунды для основных операций |
---
## 6. Уведомления
Уведомления реализуются в одном из последних спринтов.
- Канал: мессенджер **MAX**
- События для уведомления:
- Сотруднику назначен новый тест
- Приближается дедлайн сдачи теста
- Интеграция с MAX API — отдельная задача спринта
---
## 7. Вне scope (не реализуем в данной версии)
- Интеграция с AD/LDAP
- Мобильное приложение
- Вопросы с вложениями (изображения, видео)
- Экспорт отчётов в Excel / PDF
+272
View File
@@ -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. **Этапы 35** — приёмка проводится совместно с командой большой HR-системы, критерии и сценарии определяются в начале каждого этапа.
Подробные чек-листы тестирования для каждой функции готовятся перед стартом соответствующего этапа и ведутся в отдельных документах в папке `DOC/`.
+67
View File
@@ -0,0 +1,67 @@
# ШАГ 001 — Инициализация проекта и настройка репозитория
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники для джуниор-разработчиков.
---
## Запрос
> в этой директории мы будем вести разработку приложения с помощью которого сотрудники нашей клиники смогут проходить тестирование. формат тестирования: вопрос, пять вариантов ответа и один из них правильный. чуть позже мы напишем ТЗ на систему, обсудим технологический стек и составим план спринтов. сейчас выполним такую задачу: настроим локальный гит, подключим удаленный, создаим папку DOC в которую будем складывать документы по проектированию. разработка системы тестирования сотрудников выполняется как мастер класс для джуниор разработчиков нашей компании. поэтому в папке DOC нужно будет создать подпапку и сохранять туда в отельные маркдаун файлы с названием ШАГ_timestamp мои запросы к тебе и ответы, чтобы джуниор смог прочитать как идет разработка.
---
## Ответ
### Что сделано
1. **Инициализирован локальный git-репозиторий**
```bash
git init
# Initialized empty Git repository in .../QA_test_app/.git/
```
2. **Создана структура папок для проектной документации**
```
QA_test_app/
└── DOC/
└── ШАГИ/ ← сюда сохраняются шаги разработки
```
3. **Создан первый файл шага** — этот файл (`ШАГ_2026-03-21_001.md`)
4. **Подключён удалённый репозиторий**
```bash
git remote add origin https://git.pirogov.ai/arazor72/qa_test_app
git push -u origin main
# branch 'main' set up to track 'origin/main'
```
---
### Структура проекта (начальная)
```
QA_test_app/
├── .git/ ← локальный git
└── DOC/
└── ШАГИ/ ← история разработки (мастер-класс)
```
---
### Договорённости по ведению документации
- Каждый значимый запрос и ответ сохраняется отдельным файлом в `DOC/ШАГИ/`
- Формат имени файла: `ШАГ_YYYY-MM-DD_NNN.md`
- Содержимое: исходный запрос → полный ответ с кодом и пояснениями
- Цель: джуниор-разработчик может читать историю разработки последовательно
---
### Следующие шаги (запланированы)
- [x] Подключить удалённый репозиторий
- [ ] Написать ТЗ на систему тестирования
- [ ] Обсудить технологический стек
- [ ] Составить план спринтов
+80
View File
@@ -0,0 +1,80 @@
# ШАГ 002 — Создание технического задания
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> переходим к следующему шагу: создание тз
В ходе диалога уточнены требования:
**О ролях:**
> разделение по ролям у нас устроено так: есть руководители подразделений, к примеру руководитель службы администраторов, главный врач, руководитель службы администраторов. это те сотрудники, которые создают тесты. каждый тест это не менее 7 вопросов и не менее трёх вариантов ответов на вопрос, один или несколько из которых правильные. руководители подразделений назначают свои тесты сотрудникам своих подразделений, при этом у меня, как у директора клиники есть руководитель службы HR, который может создать и назначить тест сотрудникам сразу же всех подразделений.
**О времени:**
> обычно мы даём время в днях на прохождение теста. но я подумал, и решил, что некоторые из тестов надо настроить на время.
**О результатах:**
> (выбрано: свой балл/%, свои ошибки)
> мы должны вести трекер всех попыток сдачи теста: какой сотрудник, какой тест, когда начал выполнение теста, когда закончил, с каким результатом. преодолел ли сотрудник заданный порог правильных ответов.
---
## Ответ
### Подход
Перед написанием ТЗ проведён диалог с заказчиком (директором клиники) для уточнения требований по ролям, формату теста, ограничениям по времени и логике результатов.
ТЗ сохранено в отдельный файл: `DOC/ТЗ.md`
### Ключевые решения, принятые в ТЗ
**Роли:**
- `HR-менеджер` — создаёт тесты и назначает их всем сотрудникам клиники
- `Руководитель подразделения` — создаёт тесты и назначает только своему подразделению
- `Сотрудник` — проходит назначенные тесты
**Формат теста:**
- Минимум 7 вопросов
- Минимум 3 варианта ответа на вопрос
- Один или несколько правильных ответов (смешанный тип)
- Порог зачёта — задаётся в % при создании теста
**Временны́е ограничения:**
- Срок сдачи — дедлайн в днях (обязательно при назначении)
- Таймер прохождения — лимит в минутах (опционально, настраивается в тесте)
**После завершения теста сотрудник видит:**
- Свой балл и процент
- Зачёт / незачёт
- Разбор ошибок (где ответил неверно и правильный ответ)
**Трекер попыток фиксирует:**
- Сотрудник, тест, № попытки
- Время начала и завершения
- Результат и факт зачёта
### Что создано
- `DOC/ТЗ.md` — полное техническое задание
### Открытые вопросы (зафиксированы в ТЗ)
Ряд деталей требует уточнения на следующих шагах:
- Роль директора клиники
- Редактирование теста после назначения
- Случайный или фиксированный порядок вопросов
- Возможность вернуться к предыдущему вопросу
- Уведомления о назначении и дедлайне
- Экспорт отчётов
### Следующие шаги
- [x] Написать ТЗ
- [ ] Обсудить и закрыть открытые вопросы из ТЗ
- [ ] Выбрать технологический стек
- [ ] Составить план спринтов
+85
View File
@@ -0,0 +1,85 @@
# ШАГ 003 — Закрытие открытых вопросов ТЗ
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> давай обсудим открытые вопросы сейчас
---
## Ответ
Проведён диалог с заказчиком по всем 6 открытым вопросам из ТЗ. Все решения приняты и внесены в `DOC/ТЗ.md` (версия 1.1, статус: Согласовано).
---
## Принятые решения
### 1. Роль Директора клиники
**Решение:** Директор получает права HR-менеджера — те же возможности, отдельная роль не нужна.
В таблице ролей ТЗ:
```
HR-менеджер | Руководитель службы HR, Директор клиники
```
---
### 2. Редактирование теста после назначения
**Решение:** Создаётся новая версия теста.
- Назначенный тест заморожен — изменить его нельзя
- Для исправления создаётся новая версия
- Все версии хранятся в системе
- Результаты прохождений привязаны к конкретной версии теста
Это важно для целостности данных: нельзя изменить вопросы уже после того, как кто-то прошёл тест.
---
### 3. Порядок вопросов
**Решение:** Случайный при каждом прохождении.
Это усложняет списывание — два сотрудника, проходящих тест одновременно, видят вопросы в разном порядке.
---
### 4. Возврат к предыдущему вопросу
**Решение:** Настраивается автором теста при создании.
Параметр добавлен в настройки теста: `Разрешить возврат к предыдущему вопросу: да / нет`.
- Для строгих тестов (экзаменационный формат) — выключить
- Для обучающих тестов — можно включить
---
### 5. Уведомления
**Решение:** Нужны, канал — мессенджер **MAX**.
- Уведомление о назначении нового теста
- Уведомление о приближении дедлайна
- **Реализуем в одном из последних спринтов** — интеграция с MAX API отдельная задача
> **Для джуниора:** MAX — корпоративный мессенджер. Интеграция потребует изучения API MAX и реализации отдельного сервиса уведомлений.
---
### 6. Экспорт отчётов
**Решение:** Не нужен в данной версии, вынесен за scope.
---
## Итог
ТЗ обновлено до версии 1.1, статус изменён на **Согласовано**. Открытых вопросов не осталось.
### Следующие шаги
- [x] Написать ТЗ
- [x] Закрыть открытые вопросы ТЗ
- [ ] Выбрать технологический стек
- [ ] Составить план спринтов
+96
View File
@@ -0,0 +1,96 @@
# ШАГ 004 — Выбор технологического стека
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> да (переходим к обсуждению стека)
Контекст:
- Команда: джуниор-разработчики, знают Python
- Архитектура: отдельный API + SPA
- Деплой: Docker на своём сервере
- Frontend фреймворк: React
---
## Ответ
Стек зафиксирован в `DOC/СТЕК.md`.
---
## Принятые решения и обоснование
### Backend: FastAPI
Почему не Django:
- Django — монолитный фреймворк, заточен под SSR, его DRF (Django REST Framework) добавляет сложность
- FastAPI чище для чистого API-сервиса, меньше магии, проще читать код
Почему не Flask:
- Flask слишком минималистичен, джуниор потратит время на сборку инфраструктуры вместо бизнес-логики
- FastAPI даёт валидацию, документацию и типизацию из коробки
### База данных: PostgreSQL
- SQLite не подходит для многопользовательского доступа (блокировки при записи)
- PostgreSQL — проверенный выбор для продакшена, отличная поддержка в SQLAlchemy
### ORM: SQLAlchemy 2.0 (async)
- Async-режим важен для FastAPI — приложение не блокируется на запросах к БД
- Alembic для миграций — джуниор учится правильно управлять схемой БД
### Frontend: React 18 + TypeScript
- React — самый востребованный фреймворк на рынке труда
- TypeScript добавляет типизацию, ловит ошибки на этапе компиляции
- Vite — современный и быстрый сборщик (замена устаревшего CRA)
### UI: Ant Design
- Готовые компоненты таблиц, форм, модалок, таймеров — сэкономим 1-2 спринта
- Хорошо подходит для admin-панелей и внутренних инструментов
- Встроенная русская локализация
### Серверный стейт: TanStack Query
Обучающий момент для джуниора:
- Учит разделять серверное состояние (данные с API) и клиентское состояние (UI)
- Без него джуниоры обычно пишут useEffect + useState везде — это антипаттерн
- Автоматическое кэширование и перезапросы — меньше кода, больше надёжности
### Инфраструктура: Docker Compose + Nginx
```
Nginx (80/443)
├── / → React SPA (статика из build)
└── /api/ → FastAPI backend (uvicorn)
Docker Compose сервисы:
├── db (PostgreSQL)
├── backend (FastAPI)
├── frontend (React build / Vite в dev)
└── nginx
```
- Один `docker compose up` запускает весь стек
- Nginx в одном контейнере раздаёт фронтенд и проксирует запросы к API
- Для 50–200 пользователей этой архитектуры полностью достаточно
---
## Итог
Документ `DOC/СТЕК.md` содержит полное описание стека с версиями и обоснованиями.
### Следующие шаги
- [x] Написать ТЗ
- [x] Закрыть открытые вопросы ТЗ
- [x] Выбрать технологический стек
- [ ] Составить план спринтов
+45
View File
@@ -0,0 +1,45 @@
# ШАГ 005 — План спринтов
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> мое предложение такое: давай на первом спринте мы сможем создавать тесты. здесь не нужно никакой авторизации и ролей. главное зашел на страницу создания теста и смог его создать. следующий спринт — можем выполнять тест. следующий спринт — трекер результатов. затем аутентификация и управление пользователями и подразделениями. и потом уведомления
> спринт 0 и спринт 1 соединяем
---
## Ответ
### Ключевое решение по порядку спринтов
Заказчик выбрал подход **"ценность сначала"**: бизнес-логика (создание и прохождение тестов) реализуется в первых спринтах без авторизации. Это даёт несколько преимуществ:
1. **Быстрая обратная связь** — уже после спринта 1 можно потрогать живой продукт
2. **Мотивация для джуниоров** — видят результат, а не бойлерплейт
3. **Проще отлаживать** — нет JWT, нет проверки прав, чистая бизнес-логика
4. **Авторизация добавляется поверх** — в спринте 4 она "накладывается" на уже работающее приложение
Это классический подход Lean/MVP: сначала рабочий продукт, потом безопасность и управление.
### Итоговый план
| Спринт | Что делаем | Результат в браузере |
|--------|-----------|---------------------|
| **1** | Инфраструктура + Создание тестов | Создаём тест, видим список тестов |
| **2** | Прохождение теста | Проходим тест, видим результат и ошибки |
| **3** | Трекер результатов | Таблица всех попыток |
| **4** | Авторизация + роли + подразделения + назначение | Вход по логину, разграничение доступа |
| **5** | Уведомления в MAX | Получаем уведомления в мессенджер |
### Следующие шаги
- [x] Написать ТЗ
- [x] Закрыть открытые вопросы ТЗ
- [x] Выбрать технологический стек
- [x] Составить план спринтов
- [ ] Начать Спринт 1
+160
View File
@@ -0,0 +1,160 @@
# ШАГ 006 — Спринт 1: Инфраструктура + Создание тестов
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> готов. поехали (начинаем Спринт 1)
---
## Ответ
Реализован Спринт 1 в полном объёме: инфраструктура + создание тестов без авторизации.
Спринт протестирован вручную в браузере, найдены и исправлены 4 бага.
---
## Структура проекта после спринта
```
qa_test_app/
├── docker-compose.yml ← поднимает весь стек одной командой
├── .env.example
├── nginx/
│ └── nginx.conf ← / → frontend, /api/ → backend
├── backend/
│ ├── Dockerfile
│ ├── entrypoint.sh ← ждёт БД, запускает миграции, стартует сервер
│ ├── requirements.txt
│ ├── alembic.ini
│ ├── alembic/
│ │ ├── env.py ← async alembic, берёт URL из settings
│ │ ├── script.py.mako
│ │ └── versions/
│ │ └── 001_init.py ← создаёт таблицы tests, questions, answers
│ └── app/
│ ├── config.py ← настройки через pydantic-settings
│ ├── database.py ← async SQLAlchemy engine + session
│ ├── main.py ← FastAPI app, /api/health
│ ├── models/test.py ← ORM модели: Test, Question, Answer
│ ├── schemas/test.py ← Pydantic схемы с валидацией
│ └── api/tests.py ← REST эндпоинты
└── frontend/
├── Dockerfile
├── package.json
├── vite.config.ts
├── index.html
└── src/
├── App.tsx ← роутер + провайдеры
├── api/
│ ├── client.ts ← axios с baseURL=/api
│ └── tests.ts ← типы + функции запросов
└── pages/
├── TestList/ ← список тестов + кнопка создать
├── TestCreate/ ← форма создания теста
└── TestDetail/ ← просмотр теста с вопросами
```
---
## API эндпоинты
| Метод | URL | Описание |
|-------|-----|----------|
| GET | `/api/health` | Проверка работы сервера |
| GET | `/api/tests` | Список тестов |
| GET | `/api/tests/{id}` | Детали теста с вопросами и ответами |
| POST | `/api/tests` | Создать тест |
---
## Схема БД
```
tests
id, title, description, passing_score, time_limit,
allow_navigation_back, is_active, version, created_at
questions
id, test_id → tests.id, text, order
answers
id, question_id → questions.id, text, is_correct
```
---
## Валидация
**Backend (Pydantic):**
- Тест: минимум 7 вопросов, passing_score 0100
- Вопрос: минимум 3 варианта ответа, хотя бы 1 правильный
**Frontend (Ant Design Form):**
- Те же правила воспроизведены на клиенте
- Nested Form.List для динамических вопросов и ответов
- Таймер показывается только при включённом переключателе (shouldUpdate)
---
## Как запустить
```bash
docker compose up --build
```
Открыть браузер: `http://localhost`
- Список тестов → кнопка «Создать тест»
- Заполнить форму → нажать «Создать тест»
- Перейти к созданному тесту и увидеть все вопросы и ответы
- `http://localhost/api/health``{"status": "ok"}`
- `http://localhost/api/docs` → Swagger UI FastAPI
- `http://localhost/api/redoc` → ReDoc документация
---
## Баги, найденные и исправленные при ручном тестировании
| # | Симптом | Причина | Исправление |
|---|---------|---------|-------------|
| 1 | `permission denied` на `entrypoint.sh` | `docker-compose.yml` монтирует `./backend:/app` — volume перекрывает файлы образа, включая результат `chmod +x` из Dockerfile | `CMD ["bash", "entrypoint.sh"]` вместо `CMD ["./entrypoint.sh"]` |
| 2 | `No module named 'app'` в Alembic | Python не добавляет WORKDIR в `sys.path` автоматически | `ENV PYTHONPATH=/app` в Dockerfile |
| 3 | nginx: `host not found in upstream "backend"` | nginx резолвит upstream-хосты **при старте**, а backend ещё не поднялся | Docker DNS resolver `127.0.0.11` + `set $backend` — резолвинг откладывается до момента запроса |
| 4 | `http://localhost/api/docs` → 404 | FastAPI по умолчанию отдаёт Swagger по `/docs`, а через nginx путь становится `/api/docs``backend:8000/api/docs` (не существует) | Явно указать `docs_url="/api/docs"`, `redoc_url="/api/redoc"`, `openapi_url="/api/openapi.json"` в FastAPI |
> **Для джуниора:** все четыре бага — типичные для первого запуска Docker + FastAPI + nginx. Запомни их, встретишь снова.
---
## Ключевые решения для джуниора
**Async SQLAlchemy 2.0:**
- `async_sessionmaker` + `AsyncSession` — не блокируем event loop при запросах к БД
- `selectinload` для жадной загрузки связей (вместо N+1 запросов)
**Alembic async:**
- `async_engine_from_config` + `connection.run_sync(do_run_migrations)`
- URL берётся из `app.config.settings` — одно место для конфига
**Docker Compose healthcheck:**
- `db` объявляет `healthcheck`, `backend` ждёт `condition: service_healthy`
- Дополнительно `entrypoint.sh` вызывает `pg_isready` для надёжности
**TanStack Query:**
- `useQuery` для чтения данных — кэш, loading state, error state из коробки
- `useMutation` для создания — `invalidateQueries` обновляет список после успеха
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [ ] Спринт 2: Прохождение теста + результаты
- [ ] Спринт 3: Трекер результатов
- [ ] Спринт 4: Авторизация и роли
- [ ] Спринт 5: Уведомления в MAX
+97
View File
@@ -0,0 +1,97 @@
# ШАГ 007 — Спринт 2: Прохождение теста + результат
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> запускай реализацию спринта 2
---
## Ответ
Реализован Спринт 2: прохождение теста с таймером и навигацией, результат с разбором ошибок.
---
## Новые файлы
```
backend/app/models/attempt.py ← TestAttempt, AttemptAnswer
backend/app/schemas/attempt.py ← схемы для старта, сдачи и результата
backend/app/api/attempts.py ← 3 эндпоинта
backend/alembic/versions/002_attempts.py ← миграция
frontend/src/api/attempts.ts ← типы и запросы
frontend/src/pages/TestTake/ ← страница прохождения теста
frontend/src/pages/AttemptResult/ ← страница результата
```
---
## API эндпоинты (новые)
| Метод | URL | Описание |
|-------|-----|----------|
| POST | `/api/attempts` | Начать попытку → возвращает вопросы перемешанные, без правильных ответов |
| POST | `/api/attempts/{id}/submit` | Сдать тест → подсчитать и вернуть результат |
| GET | `/api/attempts/{id}/result` | Получить результат сохранённой попытки |
---
## Схема БД (добавлено)
```
test_attempts
id, test_id → tests.id, started_at, finished_at,
score, passed, correct_count, total_count, status
attempt_answers
id, attempt_id → test_attempts.id,
question_id → questions.id, answer_id → answers.id
```
Одна строка `attempt_answers` = один выбранный вариант ответа.
Для вопросов с несколькими правильными ответами — несколько строк.
---
## Логика прохождения теста
**Старт попытки:**
- Создаётся запись `TestAttempt` со статусом `in_progress`
- Вопросы и ответы внутри каждого вопроса перемешиваются случайно
- Поле `is_correct` **не передаётся** на фронт — нельзя смошенничать через DevTools
- Поле `is_multiple: bool` говорит фронту: показывать радио-кнопки или чекбоксы
**Сдача теста:**
- Фронт отправляет `[{ question_id, answer_ids[] }]` для каждого вопроса
- Вопрос засчитывается правильным только если `selected_ids == correct_ids` (точное совпадение)
- Балл = (правильных / всего) × 100
- Зачёт: балл ≥ порогу из теста
**Разбор ошибок:**
- Для каждого вопроса: `is_answered_correctly`
- Для каждого варианта: `is_correct` + `is_selected` → фронт показывает что выбрал и что было правильно
---
## UX прохождения теста
- Вопросы по одному, прогресс-бар сверху
- Таймер: если задан — обратный отсчёт, при `< 60 сек` — предупреждение, при `0` — автосабмит
- Кнопка «Назад» заблокирована если `allow_navigation_back = false`
- Чекбоксы для `is_multiple`, радио-кнопки для одиночного ответа
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [ ] Спринт 3: Трекер результатов
- [ ] Спринт 4: Авторизация и роли
- [ ] Спринт 5: Уведомления в MAX
+73
View File
@@ -0,0 +1,73 @@
# ШАГ 008 — Спринт 2: Баг «Не удалось загрузить тест»
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> вот такой баг при нажатии кнопки «Пройти тест» — «Не удалось загрузить тест» × 2
---
## Симптом
При нажатии кнопки «Пройти тест» фронт выполняет `POST /api/attempts` и получает ошибку.
В консоли backend:
```
sqlalchemy.exc.ProgrammingError: (asyncpg.exceptions.UndefinedTableError)
relation "test_attempts" does not exist
```
Ошибка появляется дважды — из-за React StrictMode, который в dev-режиме намеренно монтирует компоненты дважды.
---
## Причина
`uvicorn --reload` следит только за изменениями Python-файлов и перезапускает **процесс приложения**, но **не перезапускает контейнер целиком** и не выполняет `entrypoint.sh` повторно.
Миграция `002_attempts` (создаёт таблицы `test_attempts` и `attempt_answers`) была добавлена уже после первого запуска стека — поэтому она ни разу не применялась.
---
## Исправление
```bash
docker compose restart backend
```
При перезапуске контейнер выполняет `entrypoint.sh` заново:
```
Running migrations...
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade 001 -> 002, attempts
Starting server...
INFO: Application startup complete.
```
Таблицы созданы — баг исчезает.
---
## Правило для джуниора
> `uvicorn --reload` ≠ перезапуск контейнера.
>
> Если ты добавил новую миграцию Alembic и стек уже работает — выполни `docker compose restart backend`, чтобы миграция применилась.
>
> `docker compose up` запускает контейнер только если он не запущен. `restart` — принудительно пересоздаёт его.
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [ ] Спринт 3: Трекер результатов
- [ ] Спринт 4: Авторизация и роли
- [ ] Спринт 5: Уведомления в MAX
+73
View File
@@ -0,0 +1,73 @@
# ШАГ 009 — Спринт 2: Доработки UX после тестирования
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запросы
> 1. На странице теста видны правильные ответы — зачем тогда проходить тест?
> 2. На главной колонка с названием теста стала слишком узкой из-за трёх кнопок действий.
---
## Изменения
### 1. Разделение страницы теста на два вида
**Проблема:** `/tests/:id` показывала зелёные галочки у правильных ответов. Пользователь мог подсмотреть ответы до прохождения теста.
**Решение:** два отдельных маршрута с разным содержимым.
| Маршрут | Для кого | Что показывает |
|---------|----------|----------------|
| `/tests/:id` | Сотрудник | Вопросы и варианты ответов без отметок |
| `/tests/:id/edit` | Автор | Все ответы с отметками ✓/✗ + жёлтый баннер «Вид автора» |
Кнопка «Редактировать» на странице автора пока задизаблена — активируется в Спринте 4 вместе с авторизацией.
**Затронутые файлы:**
```
frontend/src/pages/TestDetail/index.tsx ← убраны CheckCircleTwoTone / CloseCircleTwoTone
frontend/src/pages/TestEdit/index.tsx ← новый файл, бывший TestDetail + баннер
frontend/src/App.tsx ← добавлен маршрут /tests/:id/edit
```
---
### 2. Выпадающее меню «⋯» в списке тестов
**Проблема:** Три кнопки («Открыть», «Изменить», «Пройти тест») в одной ячейке сжимали колонку с названием теста.
**Решение:** Все три действия убраны в `Dropdown` по кнопке `MoreOutlined` (⋯). Колонка действий сужена до 60px, колонка с названием занимает всю оставшуюся ширину.
```
frontend/src/pages/TestList/index.tsx ← Space + 3 Button → Dropdown с menu.items
```
**Структура меню:**
```
Открыть
Изменить
─────────
Пройти тест
```
---
## Архитектурное решение для джуниора
> Разделение «вид сотрудника» / «вид автора» сделано через **два отдельных React-компонента** на разных маршрутах, а не через условный рендеринг внутри одного компонента.
>
> Почему: в Спринте 4 эти маршруты будут защищены разными ролями (`ProtectedRoute`). Если бы логика была в одном компоненте, пришлось бы прятать данные на фронте — это ненадёжно. Два маршрута = два разных запроса = чистое разграничение доступа.
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [ ] Спринт 3: Трекер результатов
- [ ] Спринт 4: Авторизация и роли
- [ ] Спринт 5: Уведомления в MAX
+98
View File
@@ -0,0 +1,98 @@
# ШАГ 010 — Спринт 3: Редактирование теста + версионность
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> запускаем спринт 3
---
## Реализовано
Полноценное редактирование тестов с автоматическим версионированием.
---
## Новые файлы
```
backend/alembic/versions/003_test_versioning.py ← добавляет parent_id в tests
frontend/src/components/TestForm/index.tsx ← переиспользуемая форма теста
```
## Изменённые файлы
```
backend/app/models/test.py ← добавлено поле parent_id
backend/app/schemas/test.py ← TestOut.parent_id, TestUpdateResponse
backend/app/api/tests.py ← PUT + /versions + /activate
frontend/src/api/tests.ts ← update, versions, activate
frontend/src/pages/TestCreate/ ← упрощён через TestForm
frontend/src/pages/TestEdit/ ← полноценный просмотр + редактирование
```
---
## API эндпоинты (новые)
| Метод | URL | Описание |
|-------|-----|----------|
| PUT | `/api/tests/{id}` | Редактировать тест |
| GET | `/api/tests/{id}/versions` | Цепочка всех версий |
| POST | `/api/tests/{id}/activate` | Сделать версию активной |
---
## Схема БД (изменено)
```
tests
+ parent_id → tests.id ← ссылка на предыдущую версию (NULL у первой)
```
---
## Логика версионирования
| Условие при сохранении | Результат |
|------------------------|-----------|
| Попыток нет | Редактируем на месте, версия не меняется |
| Есть хотя бы одна попытка | Новый тест: `version+1`, `parent_id=old.id`, `is_active=True`; старый: `is_active=False` |
**Список тестов** показывает только `is_active=True` версии.
**История версий** отображается на странице `/tests/:id/edit` — таблица всех версий цепочки.
---
## UX редактирования
- Страница `/tests/:id/edit` работает в двух режимах: просмотр автора → нажать «Редактировать» → форма редактирования
- Кнопка «← К просмотру теста» в шапке формы
- При создании новой версии: редирект на `/tests/:newId/edit` + уведомление
- В таблице «История версий»: статус (Активная/Неактивная), кнопка «Сделать активной» для любой версии
---
## Баги, найденные и исправленные при тестировании
| # | Симптом | Причина | Исправление |
|---|---------|---------|-------------|
| 1 | `ForeignKeyViolationError` при сохранении теста | `DELETE FROM questions` bulk-запросом не каскадирует на `answers` — в схеме нет `ON DELETE CASCADE` | Сначала удаляем `answers` по `question_id IN (...)`, затем `questions` |
| 2 | Обе версии показывались «Активными» | Тест был создан до введения логики деактивации родителя | Добавлена кнопка «Сделать активной» в шапке и в строке таблицы для любой версии |
> **Для джуниора:** `cascade="all, delete-orphan"` в SQLAlchemy работает только при удалении через ORM-объекты (`session.delete(obj)`). При bulk-delete через `db.execute(delete(Model)...)` ORM-каскад не срабатывает — нужно вручную удалять дочерние записи в правильном порядке.
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [x] Спринт 3: Редактирование + версионность
- [ ] Спринт 4: Трекер результатов
- [ ] Спринт 5: Авторизация и роли
- [ ] Спринт 6: Уведомления в MAX
+101
View File
@@ -0,0 +1,101 @@
# ШАГ 011 — Спринт 4: AI-помощник (DeepSeek)
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> запускам спринт 4
---
## Реализовано
Интеграция с DeepSeek LLM при создании и редактировании тестов. Страница настроек для управления API ключом.
---
## Новые файлы
```
backend/app/models/setting.py ← модель Setting (key-value)
backend/app/schemas/setting.py ← SettingOut, SettingUpdate
backend/app/services/__init__.py ← пакет services
backend/app/services/llm.py ← DeepSeek клиент (все 4 функции)
backend/app/api/settings.py ← GET/PUT /api/settings/{key}
backend/app/api/llm.py ← POST /api/llm/check|generate|improve|distractors|review
backend/alembic/versions/004_settings.py ← миграция: таблица settings
frontend/src/api/settings.ts ← API клиент настроек
frontend/src/api/llm.ts ← API клиент LLM
frontend/src/pages/Settings/index.tsx ← страница /settings
```
## Изменённые файлы
```
backend/app/main.py ← зарегистрированы роутеры settings и llm
backend/alembic/env.py ← импорт модели setting
backend/requirements.txt ← добавлен openai==1.57.0
frontend/src/components/TestForm/index.tsx ← добавлены AI-кнопки
frontend/src/App.tsx ← Layout с шапкой, роут /settings
```
---
## API эндпоинты (новые)
| Метод | URL | Описание |
|-------|-----|----------|
| GET | `/api/settings/{key}` | Получить значение настройки |
| PUT | `/api/settings/{key}` | Сохранить значение настройки |
| POST | `/api/llm/check` | Проверить подключение к DeepSeek |
| POST | `/api/llm/generate` | Сгенерировать вопросы по теме |
| POST | `/api/llm/improve` | Улучшить формулировку вопроса |
| POST | `/api/llm/distractors` | Сгенерировать дистракторы |
| POST | `/api/llm/review` | Проверить качество всего теста |
---
## Схема БД (новое)
```
settings
key VARCHAR(100) PK
value TEXT nullable
updated_at TIMESTAMP auto-updated
```
---
## AI-функции в форме теста
| Кнопка | Расположение | Действие |
|--------|-------------|---------|
| «Сгенерировать с AI» | Над списком вопросов | Открывает модал → ввод темы → превью → «Применить все вопросы» |
| «Проверить тест» | Над списком вопросов | Открывает модал с рекомендациями AI по всему тесту |
| «Улучшить» | В шапке каждого вопроса | Заменяет текст вопроса улучшенной AI-формулировкой |
| «Дистракторы» | В шапке каждого вопроса | Добавляет 3 новых неправильных варианта к вопросу |
---
## Технические детали
- DeepSeek API совместим с OpenAI SDK: `AsyncOpenAI(base_url="https://api.deepseek.com")`
- Модель: `deepseek-chat`
- `response_format={"type": "json_object"}` для generate и distractors — гарантирует JSON-ответ
- API ключ хранится в таблице `settings` с ключом `deepseek_api_key`; фронт ключ не видит
- Шапка приложения: новый `Layout` с `AppHeader` — ссылка «Настройки» в правом углу
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [x] Спринт 3: Редактирование + версионность
- [x] Спринт 4: AI-помощник (DeepSeek)
- [ ] Спринт 5: Трекер результатов
- [ ] Спринт 6: Авторизация и роли
- [ ] Спринт 7: Уведомления в MAX
+78
View File
@@ -0,0 +1,78 @@
# ШАГ 012 — Спринт 4: Доработки после тестирования
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос (серия правок после тестирования)
> 1. «Сгенерировать с AI» не должен спрашивать тему — использовать название теста
> 2. «Улучшить» должен показывать сравнение старого и нового с галочками, а не затирать текст
> 3. «Проверить тест» → добавить кнопку «Предложить вариант» с полным сравнением всего теста
---
## Доработки
### 1. Кнопка «Сгенерировать с AI»
**Было:** при нажатии открывался модал с полем ввода темы.
**Стало:**
- Кнопка задизаблена, пока не заполнено поле «Название теста» (`shouldUpdate` на поле `title`)
- При нажатии сразу берёт название теста как тему и запускает генерацию
- Открывается модал с анимацией загрузки → превью вопросов → «Применить» / «Сгенерировать заново»
### 2. Кнопка «Улучшить» в карточке вопроса
**Было:** заменяла текст вопроса новой формулировкой без предупреждения.
**Стало:**
- Открывается модал с двумя колонками: текущая формулировка и предложение AI
- Изменения разбиты на позиции: текст вопроса + каждый вариант ответа отдельно
- Чекбокс «Применить» у каждой позиции
- Кнопка «Применить выбранные» — применяет только отмеченные пункты
**Изменения в бэкенде:**
- `improve_question(db, question, answers)` — теперь принимает список ответов и возвращает JSON `{question, answers[]}`
- `POST /api/llm/improve``ImproveRequest` добавлено поле `answers`, `ImproveResponse` теперь `{improved_question, improved_answers[]}`
### 3. Кнопка «Предложить вариант» в модале «Проверить тест»
**Новая кнопка** появляется после получения рекомендаций AI.
**Поведение:**
- При нажатии вызывает новый `POST /api/llm/improve_all`
- Модал переключается в режим сравнения: весь тест постранично
- Для каждого вопроса: текущий vs AI-предложение + чекбокс
- Для каждого варианта ответа: текущий vs AI-предложение + чекбокс
- Правильные ответы помечены `(правильный ✓)`
- Кнопки: «Применить выбранные» / «← К рекомендациям» / «Закрыть»
**Новые файлы/функции бэкенда:**
- `improve_all(db, title, questions)` в `services/llm.py`
- `POST /api/llm/improve_all` в `api/llm.py`
---
## Изменённые файлы
```
backend/app/services/llm.py ← improve_question принимает answers; новая функция improve_all
backend/app/api/llm.py ← обновлён ImproveRequest/ImproveResponse; новый /improve_all
frontend/src/api/llm.ts ← обновлена сигнатура improve; новый метод improveAll
frontend/src/components/TestForm/ ← все три доработки UI
```
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [x] Спринт 3: Редактирование + версионность
- [x] Спринт 4: AI-помощник (DeepSeek)
- [ ] Спринт 5: Трекер результатов
- [ ] Спринт 6: Авторизация и роли
- [ ] Спринт 7: Уведомления в MAX
+72
View File
@@ -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
+72 -2
View File
@@ -1,3 +1,73 @@
# qa_test_app # QA Test App — Система тестирования сотрудников клиники
Приложение для тестирования сотрудников клиники методом один вопрос - до пяти ответов один из которых правильный. Сотрудник должен выбрать правильный вариант ответа Веб-приложение для проведения внутреннего тестирования сотрудников. Руководители подразделений создают тесты, назначают их сотрудникам, система фиксирует результаты.
> Проект разрабатывается как **мастер-класс для джуниор-разработчиков**. История разработки — пошаговые запросы и решения — сохраняется в `DOC/ШАГИ/`.
---
## Формат тестирования
- Вопрос + минимум 3 варианта ответа (один или несколько правильных)
- Минимум 7 вопросов в тесте
- Случайный порядок вопросов при каждом прохождении
- Опциональный таймер на прохождение
- Порог зачёта задаётся автором теста (%)
---
## Роли
| Роль | Возможности |
|------|------------|
| **HR-менеджер / Директор** | Создаёт тесты, назначает всем сотрудникам клиники, видит все результаты |
| **Руководитель подразделения** | Создаёт тесты, назначает только своему отделу, видит результаты своего отдела |
| **Сотрудник** | Проходит назначенные тесты, видит свои результаты и ошибки |
---
## Стек
| Слой | Технология |
|------|-----------|
| Backend | Python 3.12 + FastAPI + SQLAlchemy 2.0 + Alembic |
| База данных | PostgreSQL 16 |
| Frontend | React 18 + TypeScript + Vite + Ant Design + TanStack Query |
| Инфраструктура | Docker Compose + Nginx |
| AI-помощник | DeepSeek API (openai-совместимый) — Спринт 4 |
| Уведомления | Мессенджер MAX — Спринт 7 |
---
## План спринтов
| Спринт | Содержание | Статус |
|--------|-----------|--------|
| **1** | Инфраструктура (Docker, FastAPI, React, PostgreSQL) + создание тестов | ✅ |
| **2** | Прохождение теста + результаты и разбор ошибок | ✅ |
| **3** | Редактирование тестов + версионность | ✅ |
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ |
| **5** | Трекер результатов | ✅ |
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ |
| **7** | Уведомления в MAX | ⬜ |
---
## Документация
| Файл | Содержание |
|------|-----------|
| `DOC/ТЗ.md` | Техническое задание (v1.2) |
| `DOC/СТЕК.md` | Технологический стек с обоснованием |
| `DOC/СПРИНТЫ.md` | Детальный план спринтов с задачами |
| `DOC/ШАГИ/` | История разработки шаг за шагом |
---
## Запуск (после Спринта 1)
```bash
docker compose up --build
```
Приложение будет доступно на `http://localhost`.
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.12-slim
# pg_isready нужен для проверки готовности БД в entrypoint
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV PYTHONPATH=/app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x entrypoint.sh
CMD ["bash", "entrypoint.sh"]
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
# URL переопределяется в alembic/env.py из переменной окружения DATABASE_URL
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+57
View File
@@ -0,0 +1,57 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Alembic config
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Берём DATABASE_URL из настроек приложения
from app.config import settings
from app.database import Base
from app.models import attempt, setting, test # noqa: F401 — импортируем модели, чтобы Alembic их видел
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+62
View File
@@ -0,0 +1,62 @@
"""init
Revision ID: 001
Revises:
Create Date: 2026-03-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tests",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("passing_score", sa.Integer(), nullable=False),
sa.Column("time_limit", sa.Integer(), nullable=True),
sa.Column("allow_navigation_back", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"questions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("test_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("order", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"answers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"question_id", sa.Integer(), sa.ForeignKey("questions.id"), nullable=False
),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("is_correct", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("answers")
op.drop_table("questions")
op.drop_table("tests")
+53
View File
@@ -0,0 +1,53 @@
"""attempts
Revision ID: 002
Revises: 001
Create Date: 2026-03-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"test_attempts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("test_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=False),
sa.Column(
"started_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("score", sa.Float(), nullable=True),
sa.Column("passed", sa.Boolean(), nullable=True),
sa.Column("correct_count", sa.Integer(), nullable=True),
sa.Column("total_count", sa.Integer(), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="in_progress"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"attempt_answers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"attempt_id", sa.Integer(), sa.ForeignKey("test_attempts.id"), nullable=False
),
sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id"), nullable=False),
sa.Column("answer_id", sa.Integer(), sa.ForeignKey("answers.id"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
op.drop_table("attempt_answers")
op.drop_table("test_attempts")
@@ -0,0 +1,29 @@
"""test versioning
Revision ID: 003
Revises: 002
Create Date: 2026-03-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"tests",
sa.Column("parent_id", sa.Integer(), sa.ForeignKey("tests.id"), nullable=True),
)
op.create_index("ix_tests_parent_id", "tests", ["parent_id"])
def downgrade() -> None:
op.drop_index("ix_tests_parent_id", table_name="tests")
op.drop_column("tests", "parent_id")
+33
View File
@@ -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")
View File
View File
+282
View File
@@ -0,0 +1,282 @@
import random
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.attempt import AttemptAnswer, TestAttempt
from app.models.test import Question, Test
from app.schemas.attempt import (
AnswerForTest,
AnswerResult,
AttemptListItem,
AttemptListResponse,
AttemptResult,
AttemptStart,
AttemptStarted,
AttemptSubmitDto,
QuestionForTest,
QuestionResult,
)
# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю
GUEST_USER_ID = 1
GUEST_USER_NAME = "Гость"
router = APIRouter(prefix="/api/attempts", tags=["attempts"])
@router.post("", response_model=AttemptStarted, status_code=201)
async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)):
"""Начать попытку прохождения теста. Возвращает вопросы в случайном порядке без правильных ответов."""
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == data.test_id, Test.is_active == True)
)
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
attempt = TestAttempt(test_id=test.id, status="in_progress", user_id=GUEST_USER_ID)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
# Перемешиваем вопросы и ответы случайно
questions_shuffled = list(test.questions)
random.shuffle(questions_shuffled)
questions_for_test = []
for q in questions_shuffled:
correct_count = sum(1 for a in q.answers if a.is_correct)
answers_shuffled = list(q.answers)
random.shuffle(answers_shuffled)
questions_for_test.append(
QuestionForTest(
id=q.id,
text=q.text,
is_multiple=correct_count > 1,
answers=[AnswerForTest(id=a.id, text=a.text) for a in answers_shuffled],
)
)
return AttemptStarted(
id=attempt.id,
test_id=test.id,
test_title=test.title,
test_description=test.description,
started_at=attempt.started_at,
time_limit=test.time_limit,
allow_navigation_back=test.allow_navigation_back,
questions=questions_for_test,
)
@router.post("/{attempt_id}/submit", response_model=AttemptResult)
async def submit_attempt(
attempt_id: int, data: AttemptSubmitDto, db: AsyncSession = Depends(get_db)
):
"""Завершить попытку: сохранить ответы, подсчитать результат."""
result = await db.execute(
select(TestAttempt)
.where(TestAttempt.id == attempt_id, TestAttempt.status == "in_progress")
)
attempt = result.scalar_one_or_none()
if not attempt:
raise HTTPException(status_code=404, detail="Попытка не найдена или уже завершена")
# Загружаем тест с вопросами и ответами
test_result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == attempt.test_id)
)
test = test_result.scalar_one()
# Сохраняем выбранные ответы
submitted = {qa.question_id: set(qa.answer_ids) for qa in data.answers}
for qa in data.answers:
for answer_id in qa.answer_ids:
db.add(AttemptAnswer(
attempt_id=attempt.id,
question_id=qa.question_id,
answer_id=answer_id,
))
# Подсчёт результата
correct_count = 0
question_results = []
for question in test.questions:
correct_ids = {a.id for a in question.answers if a.is_correct}
selected_ids = submitted.get(question.id, set())
is_correct = selected_ids == correct_ids
if is_correct:
correct_count += 1
question_results.append(
QuestionResult(
id=question.id,
text=question.text,
is_answered_correctly=is_correct,
answers=[
AnswerResult(
id=a.id,
text=a.text,
is_correct=a.is_correct,
is_selected=a.id in selected_ids,
)
for a in question.answers
],
)
)
total = len(test.questions)
score = round((correct_count / total) * 100, 1) if total > 0 else 0.0
passed = score >= test.passing_score
finished_at = datetime.now(timezone.utc)
attempt.finished_at = finished_at
attempt.score = score
attempt.passed = passed
attempt.correct_count = correct_count
attempt.total_count = total
attempt.status = "finished"
await db.commit()
return AttemptResult(
id=attempt.id,
test_id=test.id,
test_title=test.title,
started_at=attempt.started_at,
finished_at=finished_at,
score=score,
passed=passed,
passing_score=test.passing_score,
correct_count=correct_count,
total_count=total,
questions=question_results,
)
@router.get("", response_model=AttemptListResponse)
async def list_attempts(
test_id: Optional[int] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""Трекер попыток: все завершённые попытки с фильтрацией и пагинацией."""
base = (
select(TestAttempt)
.options(selectinload(TestAttempt.test))
.where(TestAttempt.status == "finished")
)
if test_id is not None:
base = base.where(TestAttempt.test_id == test_id)
if date_from is not None:
base = base.where(TestAttempt.started_at >= date_from)
if date_to is not None:
base = base.where(TestAttempt.started_at <= date_to)
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
rows_result = await db.execute(
base.order_by(TestAttempt.started_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
attempts_list = rows_result.scalars().all()
items = [
AttemptListItem(
id=a.id,
test_id=a.test_id,
test_title=a.test.title,
test_version=a.test.version,
user_id=a.user_id,
user_name=GUEST_USER_NAME, # TODO Sprint 6: заменить на JOIN с таблицей users
started_at=a.started_at,
finished_at=a.finished_at,
score=a.score,
correct_count=a.correct_count,
total_count=a.total_count,
passed=a.passed,
)
for a in attempts_list
]
return AttemptListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/{attempt_id}/result", response_model=AttemptResult)
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)):
"""Получить результат завершённой попытки."""
result = await db.execute(
select(TestAttempt)
.options(selectinload(TestAttempt.answers).selectinload(AttemptAnswer.answer))
.where(TestAttempt.id == attempt_id, TestAttempt.status == "finished")
)
attempt = result.scalar_one_or_none()
if not attempt:
raise HTTPException(status_code=404, detail="Результат не найден")
test_result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == attempt.test_id)
)
test = test_result.scalar_one()
# Восстанавливаем выбранные ответы из БД
selected_by_question: dict[int, set[int]] = {}
for aa in attempt.answers:
selected_by_question.setdefault(aa.question_id, set()).add(aa.answer_id)
question_results = []
for question in test.questions:
correct_ids = {a.id for a in question.answers if a.is_correct}
selected_ids = selected_by_question.get(question.id, set())
question_results.append(
QuestionResult(
id=question.id,
text=question.text,
is_answered_correctly=selected_ids == correct_ids,
answers=[
AnswerResult(
id=a.id,
text=a.text,
is_correct=a.is_correct,
is_selected=a.id in selected_ids,
)
for a in question.answers
],
)
)
return AttemptResult(
id=attempt.id,
test_id=test.id,
test_title=test.title,
started_at=attempt.started_at,
finished_at=attempt.finished_at,
score=attempt.score,
passed=attempt.passed,
passing_score=test.passing_score,
correct_count=attempt.correct_count,
total_count=attempt.total_count,
questions=question_results,
)
+132
View File
@@ -0,0 +1,132 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services import llm as llm_service
router = APIRouter(tags=["llm"])
class CheckResponse(BaseModel):
ok: bool
message: str
class GenerateRequest(BaseModel):
topic: str
count: int = 7
answers_count: int = 3
class GenerateResponse(BaseModel):
questions: list[dict]
class ImproveRequest(BaseModel):
question: str
answers: list[str]
class ImproveResponse(BaseModel):
improved_question: str
improved_answers: list[str]
class DistractorsRequest(BaseModel):
question: str
answers: list[str]
class DistractorsResponse(BaseModel):
distractors: list[str]
class ReviewRequest(BaseModel):
title: str
questions: list[dict]
class ReviewResponse(BaseModel):
review: str
class ImproveAllRequest(BaseModel):
title: str
questions: list[dict]
class ImproveAllResponse(BaseModel):
questions: list[dict]
@router.post("/api/llm/check", response_model=CheckResponse)
async def check_connection(db: AsyncSession = Depends(get_db)):
try:
result = await llm_service.check_connection(db)
return {"ok": True, "message": f"Подключение успешно: {result}"}
except ValueError as e:
return {"ok": False, "message": str(e)}
except Exception as e:
return {"ok": False, "message": f"Ошибка подключения: {str(e)}"}
@router.post("/api/llm/generate", response_model=GenerateResponse)
async def generate_questions(req: GenerateRequest, db: AsyncSession = Depends(get_db)):
try:
questions = await llm_service.generate_questions(
db, req.topic, req.count, req.answers_count
)
return {"questions": questions}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/improve", response_model=ImproveResponse)
async def improve_question(req: ImproveRequest, db: AsyncSession = Depends(get_db)):
try:
data = await llm_service.improve_question(db, req.question, req.answers)
return {"improved_question": data["question"], "improved_answers": data["answers"]}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/distractors", response_model=DistractorsResponse)
async def generate_distractors(
req: DistractorsRequest, db: AsyncSession = Depends(get_db)
):
try:
distractors = await llm_service.generate_distractors(
db, req.question, req.answers
)
return {"distractors": distractors}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/review", response_model=ReviewResponse)
async def review_test(req: ReviewRequest, db: AsyncSession = Depends(get_db)):
try:
review = await llm_service.review_test(db, req.title, req.questions)
return {"review": review}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/improve_all", response_model=ImproveAllResponse)
async def improve_all(req: ImproveAllRequest, db: AsyncSession = Depends(get_db)):
try:
questions = await llm_service.improve_all(db, req.title, req.questions)
return {"questions": questions}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
+34
View File
@@ -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
+236
View File
@@ -0,0 +1,236 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.attempt import TestAttempt
from app.models.test import Answer, Question, Test
from app.schemas.test import TestCreate, TestListItem, TestOut, TestUpdateResponse
router = APIRouter(prefix="/api/tests", tags=["tests"])
@router.get("", response_model=list[TestListItem])
async def list_tests(db: AsyncSession = Depends(get_db)):
# Показываем только активные версии (is_active = True)
result = await db.execute(
select(Test)
.options(selectinload(Test.questions))
.where(Test.is_active == True)
.order_by(Test.created_at.desc())
)
tests = result.scalars().all()
items = []
for test in tests:
item = TestListItem.model_validate(test)
item.questions_count = len(test.questions)
items.append(item)
return items
@router.get("/{test_id}", response_model=TestOut)
async def get_test(test_id: int, db: AsyncSession = Depends(get_db)):
# Загружаем любую версию по id (без фильтра is_active — нужно для просмотра истории)
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == test_id)
)
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
return test
@router.post("", response_model=TestOut, status_code=201)
async def create_test(data: TestCreate, db: AsyncSession = Depends(get_db)):
test = Test(
title=data.title,
description=data.description,
passing_score=data.passing_score,
time_limit=data.time_limit,
allow_navigation_back=data.allow_navigation_back,
)
db.add(test)
await db.flush()
for order, q_data in enumerate(data.questions):
question = Question(test_id=test.id, text=q_data.text, order=order)
db.add(question)
await db.flush()
for a_data in q_data.answers:
db.add(Answer(
question_id=question.id,
text=a_data.text,
is_correct=a_data.is_correct,
))
await db.commit()
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == test.id)
)
return result.scalar_one()
@router.get("/{test_id}/versions", response_model=list[TestListItem])
async def get_test_versions(test_id: int, db: AsyncSession = Depends(get_db)):
"""Возвращает все версии теста (от первой к последней)."""
result = await db.execute(select(Test).where(Test.id == test_id))
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
# Идём вверх до корневой версии
root = test
while root.parent_id is not None:
result = await db.execute(select(Test).where(Test.id == root.parent_id))
root = result.scalar_one()
# Идём вниз от корня, собирая цепочку
versions: list[Test] = []
current = root
while current is not None:
result = await db.execute(
select(Test)
.options(selectinload(Test.questions))
.where(Test.id == current.id)
)
current_with_qs = result.scalar_one()
versions.append(current_with_qs)
result = await db.execute(select(Test).where(Test.parent_id == current.id))
current = result.scalar_one_or_none()
items = []
for v in versions:
item = TestListItem.model_validate(v)
item.questions_count = len(v.questions)
items.append(item)
return items
@router.post("/{test_id}/activate", response_model=TestOut)
async def activate_test_version(test_id: int, db: AsyncSession = Depends(get_db)):
"""Делает указанную версию активной, деактивирует все остальные в цепочке."""
result = await db.execute(select(Test).where(Test.id == test_id))
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
# Идём вверх до корневой версии
root = test
while root.parent_id is not None:
result = await db.execute(select(Test).where(Test.id == root.parent_id))
root = result.scalar_one()
# Собираем все id версий в цепочке
all_ids: list[int] = []
current = root
while current is not None:
all_ids.append(current.id)
result = await db.execute(select(Test).where(Test.parent_id == current.id))
current = result.scalar_one_or_none()
# Деактивируем все, активируем нужную
await db.execute(update(Test).where(Test.id.in_(all_ids)).values(is_active=False))
await db.execute(update(Test).where(Test.id == test_id).values(is_active=True))
await db.commit()
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == test_id)
)
return result.scalar_one()
@router.put("/{test_id}", response_model=TestUpdateResponse)
async def update_test(test_id: int, data: TestCreate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Test).where(Test.id == test_id))
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="Тест не найден")
attempts_count = await db.scalar(
select(func.count()).select_from(TestAttempt).where(TestAttempt.test_id == test_id)
)
if attempts_count == 0:
# Редактируем на месте
test.title = data.title
test.description = data.description
test.passing_score = data.passing_score
test.time_limit = data.time_limit
test.allow_navigation_back = data.allow_navigation_back
# Сначала удаляем ответы (FK: answers.question_id → questions.id)
q_ids_result = await db.execute(select(Question.id).where(Question.test_id == test_id))
q_ids = [row[0] for row in q_ids_result.fetchall()]
if q_ids:
await db.execute(delete(Answer).where(Answer.question_id.in_(q_ids)))
await db.execute(delete(Question).where(Question.test_id == test_id))
await db.flush()
for order, q_data in enumerate(data.questions):
question = Question(test_id=test.id, text=q_data.text, order=order)
db.add(question)
await db.flush()
for a_data in q_data.answers:
db.add(Answer(
question_id=question.id,
text=a_data.text,
is_correct=a_data.is_correct,
))
await db.commit()
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == test.id)
)
return {"test": result.scalar_one(), "is_new_version": False}
else:
# Есть попытки — создаём новую версию, деактивируем текущую
test.is_active = False
new_test = Test(
title=data.title,
description=data.description,
passing_score=data.passing_score,
time_limit=data.time_limit,
allow_navigation_back=data.allow_navigation_back,
version=test.version + 1,
parent_id=test.id,
is_active=True,
)
db.add(new_test)
await db.flush()
for order, q_data in enumerate(data.questions):
question = Question(test_id=new_test.id, text=q_data.text, order=order)
db.add(question)
await db.flush()
for a_data in q_data.answers:
db.add(Answer(
question_id=question.id,
text=a_data.text,
is_correct=a_data.is_correct,
))
await db.commit()
result = await db.execute(
select(Test)
.options(selectinload(Test.questions).selectinload(Question.answers))
.where(Test.id == new_test.id)
)
return {"test": result.scalar_one(), "is_new_version": True}
+11
View File
@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test"
class Config:
env_file = ".env"
settings = Settings()
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session
+29
View File
@@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import attempts, llm, settings, tests
app = FastAPI(
title="QA Test App API",
version="0.1.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(tests.router)
app.include_router(attempts.router)
app.include_router(settings.router)
app.include_router(llm.router)
@app.get("/api/health")
async def health():
return {"status": "ok"}
View File
+50
View File
@@ -0,0 +1,50 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class TestAttempt(Base):
__tablename__ = "test_attempts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
score: Mapped[float | None] = mapped_column(Float, nullable=True) # процент 0100
passed: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
correct_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# in_progress | finished
status: Mapped[str] = mapped_column(String(20), default="in_progress", nullable=False)
test: Mapped["Test"] = relationship("Test") # type: ignore[name-defined]
answers: Mapped[list["AttemptAnswer"]] = relationship(
"AttemptAnswer", back_populates="attempt", cascade="all, delete-orphan"
)
class AttemptAnswer(Base):
"""Одна запись = один выбранный вариант ответа сотрудника."""
__tablename__ = "attempt_answers"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
attempt_id: Mapped[int] = mapped_column(
Integer, ForeignKey("test_attempts.id"), nullable=False
)
question_id: Mapped[int] = mapped_column(
Integer, ForeignKey("questions.id"), nullable=False
)
answer_id: Mapped[int] = mapped_column(
Integer, ForeignKey("answers.id"), nullable=False
)
attempt: Mapped["TestAttempt"] = relationship("TestAttempt", back_populates="answers")
answer: Mapped["Answer"] = relationship("Answer") # type: ignore[name-defined]
+17
View File
@@ -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()
)
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class Test(Base):
__tablename__ = "tests"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
passing_score: Mapped[int] = mapped_column(Integer, nullable=False)
time_limit: Mapped[int | None] = mapped_column(Integer, nullable=True) # минуты
allow_navigation_back: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("tests.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
questions: Mapped[list["Question"]] = relationship(
"Question", back_populates="test", cascade="all, delete-orphan"
)
class Question(Base):
__tablename__ = "questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
order: Mapped[int] = mapped_column(Integer, nullable=False)
test: Mapped["Test"] = relationship("Test", back_populates="questions")
answers: Mapped[list["Answer"]] = relationship(
"Answer", back_populates="question", cascade="all, delete-orphan"
)
class Answer(Base):
__tablename__ = "answers"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_id: Mapped[int] = mapped_column(
Integer, ForeignKey("questions.id"), nullable=False
)
text: Mapped[str] = mapped_column(Text, nullable=False)
is_correct: Mapped[bool] = mapped_column(Boolean, nullable=False)
question: Mapped["Question"] = relationship("Question", back_populates="answers")
View File
+115
View File
@@ -0,0 +1,115 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# ── Начало попытки ──────────────────────────────────────────
class AttemptStart(BaseModel):
test_id: int
class AnswerForTest(BaseModel):
"""Вариант ответа без поля is_correct — не раскрываем правильные ответы."""
id: int
text: str
model_config = {"from_attributes": True}
class QuestionForTest(BaseModel):
id: int
text: str
is_multiple: bool # True = несколько правильных ответов → показываем чекбоксы
answers: list[AnswerForTest] # перемешаны случайно
model_config = {"from_attributes": True}
class AttemptStarted(BaseModel):
"""Возвращается после старта попытки."""
id: int
test_id: int
test_title: str
test_description: Optional[str]
started_at: datetime
time_limit: Optional[int] # минуты, из теста
allow_navigation_back: bool # из теста
questions: list[QuestionForTest] # перемешаны случайно
model_config = {"from_attributes": True}
# ── Сдача попытки ────────────────────────────────────────────
class QuestionAnswer(BaseModel):
"""Ответы сотрудника на один вопрос."""
question_id: int
answer_ids: list[int] # выбранные варианты (может быть пустым)
class AttemptSubmitDto(BaseModel):
answers: list[QuestionAnswer]
# ── Результат ────────────────────────────────────────────────
class AnswerResult(BaseModel):
id: int
text: str
is_correct: bool # правильный ли ответ вообще
is_selected: bool # выбрал ли его сотрудник
model_config = {"from_attributes": True}
class QuestionResult(BaseModel):
id: int
text: str
is_answered_correctly: bool # вся комбинация ответов верна
answers: list[AnswerResult]
model_config = {"from_attributes": True}
class AttemptResult(BaseModel):
id: int
test_id: int
test_title: str
started_at: datetime
finished_at: datetime
score: float # процент правильных ответов
passed: bool # преодолён ли порог зачёта
passing_score: int # порог из теста
correct_count: int
total_count: int
questions: list[QuestionResult]
model_config = {"from_attributes": True}
# ── Трекер результатов ────────────────────────────────────────
class AttemptListItem(BaseModel):
id: int
test_id: int
test_title: str
test_version: int
user_id: int
user_name: str
started_at: datetime
finished_at: Optional[datetime]
score: Optional[float]
correct_count: Optional[int]
total_count: Optional[int]
passed: Optional[bool]
model_config = {"from_attributes": True}
class AttemptListResponse(BaseModel):
items: list[AttemptListItem]
total: int
page: int
page_size: int
+14
View File
@@ -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
+91
View File
@@ -0,0 +1,91 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class AnswerCreate(BaseModel):
text: str = Field(min_length=1)
is_correct: bool
class AnswerOut(BaseModel):
id: int
text: str
is_correct: bool
model_config = {"from_attributes": True}
class QuestionCreate(BaseModel):
text: str = Field(min_length=1)
answers: list[AnswerCreate]
@field_validator("answers")
@classmethod
def validate_answers(cls, v: list[AnswerCreate]) -> list[AnswerCreate]:
if len(v) < 3:
raise ValueError("Минимум 3 варианта ответа на вопрос")
if not any(a.is_correct for a in v):
raise ValueError("Хотя бы один ответ должен быть правильным")
return v
class QuestionOut(BaseModel):
id: int
text: str
order: int
answers: list[AnswerOut]
model_config = {"from_attributes": True}
class TestCreate(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = None
passing_score: int = Field(ge=0, le=100)
time_limit: Optional[int] = Field(None, ge=1)
allow_navigation_back: bool = True
questions: list[QuestionCreate]
@field_validator("questions")
@classmethod
def validate_questions(cls, v: list[QuestionCreate]) -> list[QuestionCreate]:
if len(v) < 7:
raise ValueError("Минимум 7 вопросов в тесте")
return v
class TestOut(BaseModel):
id: int
title: str
description: Optional[str]
passing_score: int
time_limit: Optional[int]
allow_navigation_back: bool
is_active: bool
version: int
parent_id: Optional[int]
created_at: datetime
questions: list[QuestionOut] = []
model_config = {"from_attributes": True}
class TestUpdateResponse(BaseModel):
test: TestOut
is_new_version: bool
class TestListItem(BaseModel):
id: int
title: str
description: Optional[str]
passing_score: int
time_limit: Optional[int]
is_active: bool
version: int
created_at: datetime
questions_count: int = 0
model_config = {"from_attributes": True}
View File
+208
View File
@@ -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"]
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -e
echo "Waiting for PostgreSQL..."
until pg_isready -h db -p 5432 -U qa_user; do
sleep 1
done
echo "Running migrations..."
alembic upgrade head
echo "Starting server..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+8
View File
@@ -0,0 +1,8 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
asyncpg==0.29.0
alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
openai==1.57.0
+46
View File
@@ -0,0 +1,46 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: qa_test
POSTGRES_USER: qa_user
POSTGRES_PASSWORD: qa_password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U qa_user -d qa_test"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+asyncpg://qa_user:qa_password@db:5432/qa_test
depends_on:
db:
condition: service_healthy
volumes:
- ./backend:/app
frontend:
build: ./frontend
volumes:
- ./frontend/src:/app/src
- ./frontend/index.html:/app/index.html
depends_on:
- backend
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- backend
- frontend
volumes:
postgres_data:
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QA Test App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "qa-test-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.4.0",
"@tanstack/react-query": "^5.59.0",
"antd": "^5.21.0",
"axios": "^1.7.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.3",
"vite": "^5.4.8"
}
}
+83
View File
@@ -0,0 +1,83 @@
import { BarChartOutlined, SettingOutlined } from '@ant-design/icons'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Button, ConfigProvider, Layout, Space } from 'antd'
import ruRU from 'antd/locale/ru_RU'
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
import AttemptResult from './pages/AttemptResult'
import Settings from './pages/Settings'
import Tracker from './pages/Tracker'
import TestCreate from './pages/TestCreate'
import TestDetail from './pages/TestDetail'
import TestEdit from './pages/TestEdit'
import TestList from './pages/TestList'
import TestTake from './pages/TestTake'
const { Header, Content } = Layout
const queryClient = new QueryClient()
function AppHeader() {
const navigate = useNavigate()
return (
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#fff',
borderBottom: '1px solid #f0f0f0',
padding: '0 24px',
}}
>
<span
style={{ fontWeight: 700, fontSize: 16, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
QA Test App
</span>
<Space>
<Button
icon={<BarChartOutlined />}
type="text"
onClick={() => navigate('/tracker')}
>
Трекер
</Button>
<Button
icon={<SettingOutlined />}
type="text"
onClick={() => navigate('/settings')}
>
Настройки
</Button>
</Space>
</Header>
)
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={ruRU}>
<BrowserRouter>
<Layout style={{ minHeight: '100vh', background: '#f5f5f5' }}>
<AppHeader />
<Content>
<Routes>
<Route path="/" element={<TestList />} />
<Route path="/tests/create" element={<TestCreate />} />
<Route path="/tests/:id" element={<TestDetail />} />
<Route path="/tests/:id/edit" element={<TestEdit />} />
<Route path="/tests/:testId/take" element={<TestTake />} />
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
<Route path="/tracker" element={<Tracker />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Content>
</Layout>
</BrowserRouter>
</ConfigProvider>
</QueryClientProvider>
)
}
+101
View File
@@ -0,0 +1,101 @@
import client from './client'
export interface AnswerForTest {
id: number
text: string
}
export interface QuestionForTest {
id: number
text: string
is_multiple: boolean // true → показываем чекбоксы, false → радио-кнопки
answers: AnswerForTest[]
}
export interface AttemptStarted {
id: number
test_id: number
test_title: string
test_description: string | null
started_at: string
time_limit: number | null // минуты
allow_navigation_back: boolean
questions: QuestionForTest[]
}
export interface QuestionAnswer {
question_id: number
answer_ids: number[]
}
export interface AnswerResult {
id: number
text: string
is_correct: boolean
is_selected: boolean
}
export interface QuestionResult {
id: number
text: string
is_answered_correctly: boolean
answers: AnswerResult[]
}
export interface AttemptResult {
id: number
test_id: number
test_title: string
started_at: string
finished_at: string
score: number
passed: boolean
passing_score: number
correct_count: number
total_count: number
questions: QuestionResult[]
}
export interface AttemptListItem {
id: number
test_id: number
test_title: string
test_version: number
user_id: number
user_name: string
started_at: string
finished_at: string | null
score: number | null
correct_count: number | null
total_count: number | null
passed: boolean | null
}
export interface AttemptListResponse {
items: AttemptListItem[]
total: number
page: number
page_size: number
}
export interface AttemptListParams {
test_id?: number
date_from?: string
date_to?: string
page?: number
page_size?: number
}
export const attemptsApi = {
start: (test_id: number) =>
client.post<AttemptStarted>('/attempts', { test_id }),
submit: (attempt_id: number, answers: QuestionAnswer[]) =>
client.post<AttemptResult>(`/attempts/${attempt_id}/submit`, { answers }),
getResult: (attempt_id: number) =>
client.get<AttemptResult>(`/attempts/${attempt_id}/result`),
list: (params: AttemptListParams = {}) =>
client.get<AttemptListResponse>('/attempts', { params }),
}
+7
View File
@@ -0,0 +1,7 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api',
})
export default client
+48
View File
@@ -0,0 +1,48 @@
import axios from 'axios'
export interface LLMQuestion {
text: string
answers: { text: string; is_correct: boolean }[]
}
const llmApi = {
check: () =>
axios.post<{ ok: boolean; message: string }>('/api/llm/check').then((r) => r.data),
generate: (topic: string, count = 7, answersCount = 3) =>
axios
.post<{ questions: LLMQuestion[] }>('/api/llm/generate', {
topic,
count,
answers_count: answersCount,
})
.then((r) => r.data),
improve: (question: string, answers: string[]) =>
axios
.post<{ improved_question: string; improved_answers: string[] }>('/api/llm/improve', {
question,
answers,
})
.then((r) => r.data),
distractors: (question: string, answers: string[]) =>
axios
.post<{ distractors: string[] }>('/api/llm/distractors', { question, answers })
.then((r) => r.data),
review: (title: string, questions: object[]) =>
axios
.post<{ review: string }>('/api/llm/review', { title, questions })
.then((r) => r.data),
improveAll: (title: string, questions: object[]) =>
axios
.post<{ questions: { question: string; answers: string[] }[] }>('/api/llm/improve_all', {
title,
questions,
})
.then((r) => r.data),
}
export default llmApi
+14
View File
@@ -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
+74
View File
@@ -0,0 +1,74 @@
import client from './client'
export interface Answer {
id: number
text: string
is_correct: boolean
}
export interface Question {
id: number
text: string
order: number
answers: Answer[]
}
export interface Test {
id: number
title: string
description: string | null
passing_score: number
time_limit: number | null
allow_navigation_back: boolean
is_active: boolean
version: number
parent_id: number | null
created_at: string
questions: Question[]
}
export interface UpdateTestResponse {
test: Test
is_new_version: boolean
}
export interface TestListItem {
id: number
title: string
description: string | null
passing_score: number
time_limit: number | null
is_active: boolean
version: number
created_at: string
questions_count: number
}
export interface CreateAnswerDto {
text: string
is_correct: boolean
}
export interface CreateQuestionDto {
text: string
answers: CreateAnswerDto[]
}
export interface CreateTestDto {
title: string
description?: string
passing_score: number
time_limit?: number
allow_navigation_back: boolean
questions: CreateQuestionDto[]
}
export const testsApi = {
list: () => client.get<TestListItem[]>('/tests'),
get: (id: number) => client.get<Test>(`/tests/${id}`),
create: (data: CreateTestDto) => client.post<Test>('/tests', data),
update: (id: number, data: CreateTestDto) =>
client.put<UpdateTestResponse>(`/tests/${id}`, data),
versions: (id: number) => client.get<TestListItem[]>(`/tests/${id}/versions`),
activate: (id: number) => client.post<Test>(`/tests/${id}/activate`),
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f5f5f5;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+153
View File
@@ -0,0 +1,153 @@
import {
CheckCircleTwoTone,
CloseCircleTwoTone,
MinusCircleOutlined,
TrophyOutlined,
} from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, Col, Divider, List, Result, Row, Space, Spin, Tag, Typography } from 'antd'
import { useNavigate, useParams } from 'react-router-dom'
import { AnswerResult, attemptsApi } from '../../api/attempts'
const { Title, Text } = Typography
export default function AttemptResult() {
const { attemptId } = useParams<{ attemptId: string }>()
const navigate = useNavigate()
const { data: result, isLoading } = useQuery({
queryKey: ['attempts', attemptId, 'result'],
queryFn: () => attemptsApi.getResult(Number(attemptId)).then((r) => r.data),
})
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
if (!result) return null
const duration = Math.round(
(new Date(result.finished_at).getTime() - new Date(result.started_at).getTime()) / 1000,
)
const minutes = Math.floor(duration / 60)
const seconds = duration % 60
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
{/* Итог */}
<Result
icon={
result.passed ? (
<TrophyOutlined style={{ color: '#52c41a' }} />
) : (
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
)
}
status={result.passed ? 'success' : 'error'}
title={result.passed ? 'Тест сдан!' : 'Тест не сдан'}
subTitle={result.test_title}
/>
{/* Статистика */}
<Card style={{ marginBottom: 24 }}>
<Row gutter={32} justify="center" style={{ textAlign: 'center' }}>
<Col>
<Title level={1} style={{ margin: 0, color: result.passed ? '#52c41a' : '#ff4d4f' }}>
{result.score}%
</Title>
<Text type="secondary">Результат</Text>
</Col>
<Col>
<Title level={1} style={{ margin: 0 }}>
{result.correct_count}/{result.total_count}
</Title>
<Text type="secondary">Правильных ответов</Text>
</Col>
<Col>
<Title level={1} style={{ margin: 0 }}>
{result.passing_score}%
</Title>
<Text type="secondary">Порог зачёта</Text>
</Col>
<Col>
<Title level={1} style={{ margin: 0 }}>
{minutes > 0 ? `${minutes}м ` : ''}{seconds}с
</Title>
<Text type="secondary">Время</Text>
</Col>
</Row>
</Card>
{/* Разбор ошибок */}
<Title level={3}>Разбор ответов</Title>
{result.questions.map((question, index) => (
<Card
key={question.id}
style={{
marginBottom: 12,
borderColor: question.is_answered_correctly ? '#b7eb8f' : '#ffccc7',
}}
title={
<Space>
{question.is_answered_correctly ? (
<CheckCircleTwoTone twoToneColor="#52c41a" />
) : (
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
)}
<Text strong>
{index + 1}. {question.text}
</Text>
</Space>
}
>
<List
dataSource={question.answers}
renderItem={(answer: AnswerResult) => {
const icon = answer.is_correct ? (
<CheckCircleTwoTone twoToneColor="#52c41a" />
) : answer.is_selected ? (
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
) : (
<MinusCircleOutlined style={{ color: '#d9d9d9' }} />
)
return (
<List.Item style={{ padding: '4px 0' }}>
<Space>
{icon}
<Text
style={{
fontWeight: answer.is_correct ? 600 : 400,
color: answer.is_selected && !answer.is_correct ? '#ff4d4f' : undefined,
}}
>
{answer.text}
</Text>
{answer.is_selected && answer.is_correct && (
<Tag color="green">ваш ответ </Tag>
)}
{answer.is_selected && !answer.is_correct && (
<Tag color="red">ваш ответ </Tag>
)}
{!answer.is_selected && answer.is_correct && (
<Tag color="green">правильный ответ</Tag>
)}
</Space>
</List.Item>
)
}}
/>
</Card>
))}
<Divider />
<Space>
<Button onClick={() => navigate('/')}>К списку тестов</Button>
<Button type="primary" onClick={() => navigate(`/tests/${result.test_id}`)}>
Страница теста
</Button>
</Space>
</div>
)
}
+124
View File
@@ -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>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { message } from 'antd'
import { useNavigate } from 'react-router-dom'
import { CreateTestDto, testsApi } from '../../api/tests'
import TestForm, { TestFormValues } from '../../components/TestForm'
export default function TestCreate() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { mutate: createTest, isPending } = useMutation({
mutationFn: (data: CreateTestDto) => testsApi.create(data).then((r) => r.data),
onSuccess: (test) => {
queryClient.invalidateQueries({ queryKey: ['tests'] })
message.success('Тест успешно создан')
navigate(`/tests/${test.id}`)
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || 'Ошибка при создании теста')
},
})
const onSubmit = (values: TestFormValues) => {
createTest({
title: values.title,
description: values.description,
passing_score: values.passing_score,
time_limit: values.has_timer ? values.time_limit : undefined,
allow_navigation_back: values.allow_navigation_back ?? true,
questions: values.questions,
})
}
return (
<TestForm
heading="Создание теста"
onSubmit={onSubmit}
isPending={isPending}
submitLabel="Создать тест"
onCancel={() => navigate('/')}
/>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { ArrowLeftOutlined, PlayCircleOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, Descriptions, List, Space, Spin, Tag, Typography } from 'antd'
import { useNavigate, useParams } from 'react-router-dom'
import { Answer, testsApi } from '../../api/tests'
const { Title, Text } = Typography
export default function TestDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: test, isLoading } = useQuery({
queryKey: ['tests', id],
queryFn: () => testsApi.get(Number(id)).then((r) => r.data),
})
if (isLoading) {
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
}
if (!test) return null
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}>
К списку тестов
</Button>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => navigate(`/tests/${test.id}/take`)}
>
Пройти тест
</Button>
</Space>
<Title level={2}>{test.title}</Title>
{test.description && (
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
{test.description}
</Text>
)}
<Card style={{ marginBottom: 24 }}>
<Descriptions column={3}>
<Descriptions.Item label="Вопросов">{test.questions.length}</Descriptions.Item>
<Descriptions.Item label="Порог зачёта">{test.passing_score}%</Descriptions.Item>
<Descriptions.Item label="Таймер">
{test.time_limit ? `${test.time_limit} мин` : 'Без ограничений'}
</Descriptions.Item>
<Descriptions.Item label="Возврат к вопросу">
{test.allow_navigation_back ? (
<Tag color="green">Разрешён</Tag>
) : (
<Tag color="red">Запрещён</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="Версия">{test.version}</Descriptions.Item>
<Descriptions.Item label="Создан">
{new Date(test.created_at).toLocaleDateString('ru-RU')}
</Descriptions.Item>
</Descriptions>
</Card>
<Title level={3}>Вопросы ({test.questions.length})</Title>
{test.questions.map((question, index) => (
<Card key={question.id} style={{ marginBottom: 12 }}>
<Text strong>
{index + 1}. {question.text}
</Text>
<List
style={{ marginTop: 10 }}
dataSource={question.answers}
renderItem={(answer: Answer) => (
<List.Item style={{ padding: '4px 0' }}>
<Text>{answer.text}</Text>
</List.Item>
)}
/>
</Card>
))}
</div>
)
}
+289
View File
@@ -0,0 +1,289 @@
import {
ArrowLeftOutlined,
CheckCircleTwoTone,
CloseCircleTwoTone,
EditOutlined,
PlayCircleOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Descriptions, List, Space, Spin, Table, Tag, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Answer, CreateTestDto, TestListItem, testsApi } from '../../api/tests'
import TestForm, { TestFormValues } from '../../components/TestForm'
const { Title, Text } = Typography
export default function TestEdit() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [editMode, setEditMode] = useState(false)
const { data: test, isLoading } = useQuery({
queryKey: ['tests', id],
queryFn: () => testsApi.get(Number(id)).then((r) => r.data),
})
const { data: versions = [] } = useQuery({
queryKey: ['tests', id, 'versions'],
queryFn: () => testsApi.versions(Number(id)).then((r) => r.data),
})
const { mutate: activateVersion, isPending: isActivating } = useMutation({
mutationFn: (versionId: number) => testsApi.activate(versionId).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tests'] })
queryClient.invalidateQueries({ queryKey: ['tests', id, 'versions'] })
queryClient.invalidateQueries({ queryKey: ['tests', id] })
message.success('Активная версия изменена')
},
onError: () => message.error('Не удалось изменить активную версию'),
})
const { mutate: updateTest, isPending } = useMutation({
mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['tests'] })
if (result.is_new_version) {
message.success(`Создана новая версия теста (v${result.test.version})`)
navigate(`/tests/${result.test.id}/edit`)
} else {
message.success('Тест обновлён')
queryClient.invalidateQueries({ queryKey: ['tests', id] })
setEditMode(false)
}
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || 'Ошибка при сохранении теста')
},
})
if (isLoading) {
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
}
if (!test) return null
// Режим редактирования — показываем форму с предзаполненными данными
if (editMode) {
const initialValues: TestFormValues = {
title: test.title,
description: test.description ?? undefined,
passing_score: test.passing_score,
has_timer: test.time_limit !== null,
time_limit: test.time_limit ?? undefined,
allow_navigation_back: test.allow_navigation_back,
questions: test.questions.map((q) => ({
text: q.text,
answers: q.answers.map((a) => ({ text: a.text, is_correct: a.is_correct })),
})),
}
const onSubmit = (values: TestFormValues) => {
updateTest({
title: values.title,
description: values.description,
passing_score: values.passing_score,
time_limit: values.has_timer ? values.time_limit : undefined,
allow_navigation_back: values.allow_navigation_back ?? true,
questions: values.questions,
})
}
return (
<TestForm
heading={`Редактирование теста — v${test.version}`}
initialValues={initialValues}
onSubmit={onSubmit}
isPending={isPending}
submitLabel="Сохранить"
onCancel={() => setEditMode(false)}
onBack={() => setEditMode(false)}
backLabel="К просмотру теста"
versions={versions}
currentVersionId={test.id}
onActivateVersion={activateVersion}
isActivating={isActivating}
onOpenVersion={(vid) => navigate(`/tests/${vid}/edit`)}
/>
)
}
// Режим просмотра (вид автора)
return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}>
К списку тестов
</Button>
<Space>
{versions.length > 1 && (!test.is_active || versions.some(v => v.id !== test.id && v.is_active)) && (
<Button
loading={isActivating}
onClick={() => activateVersion(test.id)}
>
Сделать активной
</Button>
)}
<Button
icon={<EditOutlined />}
onClick={() => setEditMode(true)}
>
Редактировать
</Button>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => navigate(`/tests/${test.id}/take`)}
>
Пройти тест
</Button>
</Space>
</Space>
<Alert
type="warning"
showIcon
message="Вид автора — правильные ответы отмечены"
style={{ marginBottom: 16 }}
/>
<Title level={2}>{test.title}</Title>
{test.description && (
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
{test.description}
</Text>
)}
<Card style={{ marginBottom: 24 }}>
<Descriptions column={3}>
<Descriptions.Item label="Вопросов">{test.questions.length}</Descriptions.Item>
<Descriptions.Item label="Порог зачёта">{test.passing_score}%</Descriptions.Item>
<Descriptions.Item label="Таймер">
{test.time_limit ? `${test.time_limit} мин` : 'Без ограничений'}
</Descriptions.Item>
<Descriptions.Item label="Возврат к вопросу">
{test.allow_navigation_back ? (
<Tag color="green">Разрешён</Tag>
) : (
<Tag color="red">Запрещён</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="Версия">{test.version}</Descriptions.Item>
<Descriptions.Item label="Создан">
{new Date(test.created_at).toLocaleDateString('ru-RU')}
</Descriptions.Item>
</Descriptions>
</Card>
{versions.length > 1 && (
<>
<Title level={3}>История версий</Title>
<Table<TestListItem>
dataSource={versions}
rowKey="id"
size="small"
pagination={false}
style={{ marginBottom: 24 }}
rowClassName={(record) => (record.id === test.id ? 'ant-table-row-selected' : '')}
columns={
[
{
title: 'Версия',
dataIndex: 'version',
width: 70,
render: (v: number) => <Tag color="default">v{v}</Tag>,
},
{
title: 'Статус',
dataIndex: 'is_active',
width: 130,
render: (active: boolean) =>
active ? (
<Tag color="green">Активная</Tag>
) : (
<Tag color="default">Неактивная</Tag>
),
},
{
title: 'Дата',
dataIndex: 'created_at',
width: 120,
render: (d: string) => new Date(d).toLocaleDateString('ru-RU'),
},
{
title: 'Вопросов',
dataIndex: 'questions_count',
width: 100,
align: 'center' as const,
},
{
title: 'Порог зачёта',
dataIndex: 'passing_score',
width: 120,
align: 'center' as const,
render: (s: number) => `${s}%`,
},
{
title: '',
key: 'action',
render: (_: unknown, record: TestListItem) => (
<Space>
{record.id !== test.id && (
<Button size="small" onClick={() => navigate(`/tests/${record.id}/edit`)}>
Открыть
</Button>
)}
{(record.id !== test.id || !record.is_active) && (
<Button
size="small"
type={record.is_active ? 'default' : 'primary'}
loading={isActivating}
onClick={() => activateVersion(record.id)}
>
Сделать активной
</Button>
)}
</Space>
),
},
] as ColumnsType<TestListItem>
}
/>
</>
)}
<Title level={3}>Вопросы ({test.questions.length})</Title>
{test.questions.map((question, index) => (
<Card key={question.id} style={{ marginBottom: 12 }}>
<Text strong>
{index + 1}. {question.text}
</Text>
<List
style={{ marginTop: 10 }}
dataSource={question.answers}
renderItem={(answer: Answer) => (
<List.Item style={{ padding: '4px 0' }}>
<Space>
{answer.is_correct ? (
<CheckCircleTwoTone twoToneColor="#52c41a" />
) : (
<CloseCircleTwoTone twoToneColor="#d9d9d9" />
)}
<Text>{answer.text}</Text>
</Space>
</List.Item>
)}
/>
</Card>
))}
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { EditOutlined, MoreOutlined, PlayCircleOutlined, PlusOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Dropdown, Spin, Table, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useNavigate } from 'react-router-dom'
import { TestListItem, testsApi } from '../../api/tests'
const { Title } = Typography
export default function TestList() {
const navigate = useNavigate()
const { data: tests = [], isLoading } = useQuery({
queryKey: ['tests'],
queryFn: () => testsApi.list().then((r) => r.data),
})
const columns: ColumnsType<TestListItem> = [
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (text: string, record: TestListItem) => (
<a onClick={() => navigate(`/tests/${record.id}`)}>{text}</a>
),
},
{
title: 'Вопросов',
dataIndex: 'questions_count',
key: 'questions_count',
width: 100,
align: 'center',
},
{
title: 'Порог зачёта',
dataIndex: 'passing_score',
key: 'passing_score',
width: 130,
align: 'center',
render: (score: number) => `${score}%`,
},
{
title: 'Таймер',
dataIndex: 'time_limit',
key: 'time_limit',
width: 110,
align: 'center',
render: (limit: number | null) => (limit ? `${limit} мин` : '—'),
},
{
title: 'Создан',
dataIndex: 'created_at',
key: 'created_at',
width: 130,
render: (date: string) => new Date(date).toLocaleDateString('ru-RU'),
},
{
title: '',
key: 'actions',
width: 60,
align: 'center',
render: (_: unknown, record: TestListItem) => (
<Dropdown
menu={{
items: [
{
key: 'open',
label: 'Открыть',
onClick: () => navigate(`/tests/${record.id}`),
},
{
key: 'edit',
icon: <EditOutlined />,
label: 'Изменить',
onClick: () => navigate(`/tests/${record.id}/edit`),
},
{ type: 'divider' },
{
key: 'take',
icon: <PlayCircleOutlined />,
label: 'Пройти тест',
onClick: () => navigate(`/tests/${record.id}/take`),
},
],
}}
trigger={['click']}
>
<Button size="small" icon={<MoreOutlined />} />
</Dropdown>
),
},
]
if (isLoading) {
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
}
return (
<div style={{ maxWidth: 1000, margin: '0 auto', padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={2} style={{ margin: 0 }}>
Тесты
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/tests/create')}
>
Создать тест
</Button>
</div>
<Table
dataSource={tests}
columns={columns}
rowKey="id"
locale={{ emptyText: 'Тестов пока нет. Создайте первый!' }}
pagination={{ pageSize: 20 }}
/>
</div>
)
}
+225
View File
@@ -0,0 +1,225 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Alert,
Button,
Card,
Checkbox,
Progress,
Radio,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { ArrowLeftOutlined, ArrowRightOutlined, SendOutlined } from '@ant-design/icons'
import { AttemptStarted, QuestionAnswer, attemptsApi } from '../../api/attempts'
const { Title, Text } = Typography
export default function TestTake() {
const { testId } = useParams<{ testId: string }>()
const navigate = useNavigate()
const [attempt, setAttempt] = useState<AttemptStarted | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
// answers: questionId → выбранные answerId[]
const [answers, setAnswers] = useState<Map<number, number[]>>(new Map())
const [timeLeft, setTimeLeft] = useState<number | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Стартуем попытку при монтировании
useEffect(() => {
attemptsApi
.start(Number(testId))
.then((r) => {
setAttempt(r.data)
if (r.data.time_limit) {
setTimeLeft(r.data.time_limit * 60)
}
})
.catch(() => message.error('Не удалось загрузить тест'))
.finally(() => setLoading(false))
}, [testId])
// Таймер
useEffect(() => {
if (timeLeft === null) return
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev === null || prev <= 1) {
clearInterval(timerRef.current!)
handleSubmit()
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timerRef.current!)
}, [timeLeft !== null]) // запускаем один раз когда timeLeft появился
const handleSubmit = async () => {
if (!attempt || submitting) return
clearInterval(timerRef.current!)
setSubmitting(true)
const payload: QuestionAnswer[] = attempt.questions.map((q) => ({
question_id: q.id,
answer_ids: answers.get(q.id) ?? [],
}))
try {
await attemptsApi.submit(attempt.id, payload)
navigate(`/attempts/${attempt.id}/result`)
} catch {
message.error('Ошибка при отправке теста')
setSubmitting(false)
}
}
const handleAnswer = (questionId: number, answerId: number, isMultiple: boolean) => {
setAnswers((prev) => {
const next = new Map(prev)
if (isMultiple) {
const current = next.get(questionId) ?? []
next.set(
questionId,
current.includes(answerId)
? current.filter((id) => id !== answerId)
: [...current, answerId],
)
} else {
next.set(questionId, [answerId])
}
return next
})
}
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
if (loading) return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} />
if (!attempt) return null
const question = attempt.questions[currentIndex]
const selectedIds = answers.get(question.id) ?? []
const total = attempt.questions.length
const isLast = currentIndex === total - 1
const isTimeCritical = timeLeft !== null && timeLeft < 60
return (
<div style={{ maxWidth: 720, margin: '0 auto', padding: 24 }}>
{/* Шапка */}
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>
{attempt.test_title}
</Title>
{timeLeft !== null && (
<Tag color={isTimeCritical ? 'red' : 'blue'} style={{ fontSize: 16, padding: '4px 12px' }}>
{formatTime(timeLeft)}
</Tag>
)}
</Space>
{/* Прогресс */}
<Progress
percent={Math.round(((currentIndex + 1) / total) * 100)}
format={() => `${currentIndex + 1} / ${total}`}
style={{ marginBottom: 16 }}
/>
{isTimeCritical && (
<Alert
message="Осталось меньше минуты!"
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* Вопрос */}
<Card
title={
<Text strong style={{ fontSize: 16 }}>
Вопрос {currentIndex + 1}
</Text>
}
style={{ marginBottom: 24 }}
>
<Text style={{ fontSize: 15, display: 'block', marginBottom: 20 }}>
{question.text}
</Text>
{question.is_multiple && (
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Выберите все правильные варианты
</Text>
)}
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{question.answers.map((answer) =>
question.is_multiple ? (
<Checkbox
key={answer.id}
checked={selectedIds.includes(answer.id)}
onChange={() => handleAnswer(question.id, answer.id, true)}
style={{ fontSize: 14 }}
>
{answer.text}
</Checkbox>
) : (
<Radio
key={answer.id}
checked={selectedIds.includes(answer.id)}
onChange={() => handleAnswer(question.id, answer.id, false)}
style={{ fontSize: 14 }}
>
{answer.text}
</Radio>
),
)}
</Space>
</Card>
{/* Навигация */}
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => setCurrentIndex((i) => i - 1)}
disabled={currentIndex === 0 || !attempt.allow_navigation_back}
>
Назад
</Button>
{isLast ? (
<Button
type="primary"
icon={<SendOutlined />}
loading={submitting}
onClick={handleSubmit}
>
Завершить тест
</Button>
) : (
<Button
type="primary"
icon={<ArrowRightOutlined />}
onClick={() => setCurrentIndex((i) => i + 1)}
>
Далее
</Button>
)}
</Space>
</div>
)
}
+145
View File
@@ -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>
)
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
},
})
+25
View File
@@ -0,0 +1,25 @@
server {
listen 80;
# Docker внутренний DNS — резолвим хосты в момент запроса, а не при старте nginx
resolver 127.0.0.11 valid=30s;
# API запросы → FastAPI backend
location /api/ {
set $backend http://backend:8000;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Всё остальное → Vite dev server (с поддержкой WebSocket для HMR)
location / {
set $frontend http://frontend:5173;
proxy_pass $frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}