Compare commits
23 Commits
5cd94c05ad
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ea83aa6b4 | |||
| 0229bc250b | |||
| 1494b839f5 | |||
| 9511fcb555 | |||
| b72b485fce | |||
| df6e770f90 | |||
| 44366a2865 | |||
| 2b429f0b08 | |||
| e6b85f3944 | |||
| db9851eeda | |||
| ebb58d4b5a | |||
| c4a7d2ef08 | |||
| 09d996ead0 | |||
| fba11ff4cc | |||
| bba96f8f9f | |||
| eff3fda5b0 | |||
| 1c4dacbc85 | |||
| c3bdb406d6 | |||
| 47d673496b | |||
| 2d6d75fb3c | |||
| 547840d671 | |||
| 4b0d56ff0e | |||
| 31b51b7768 |
@@ -0,0 +1 @@
|
||||
тестирования
|
||||
@@ -1,200 +1,200 @@
|
||||
# Система тестирования сотрудников клиники
|
||||
|
||||
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. Руководители подразделений и HR-менеджеры создают тесты и назначают их сотрудникам. Система фиксирует все попытки и результаты.
|
||||
Веб-приложение для проведения внутреннего тестирования сотрудников клиники.
|
||||
Руководители подразделений и HR-менеджеры создают тесты и назначают их
|
||||
сотрудникам. Все попытки и результаты сохраняются.
|
||||
|
||||
**Версия ТЗ:** 1.2
|
||||
**Дата:** 2026-03-21
|
||||
**Статус:** Согласовано
|
||||
|
||||
**Актуальное состояние кода (не ТЗ, а «что уже есть»):** [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) · [инструкция для проверяющих на dev](docs/DEV_CONTOUR_USER_GUIDE.md).
|
||||
**Перенос на стек кабинета / мини-приложения:** [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md) · [простым языком](docs/migration-to-tgflaskform-plain.md). Отдельный Flask-контур: [flask_app/README.md](flask_app/README.md).
|
||||
- **Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)**
|
||||
- **Ветка разработки:** `dev`
|
||||
- **ТЗ:** v1.2 от 2026-03-21 (статус — согласовано)
|
||||
|
||||
---
|
||||
|
||||
## Стек технологий
|
||||
## Стек и состояние
|
||||
|
||||
### Этот репозиторий (TestingWebApp)
|
||||
**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 +
|
||||
Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в
|
||||
[`flask_app/`](flask_app/). На нём работает и прод, и dev (кабинетный UI,
|
||||
порт **:3107** в Docker, см. ниже).
|
||||
|
||||
| Слой | Технологии |
|
||||
|------|------------|
|
||||
| **Backend** | Node.js (ESM), **Express** 4, **pg**, миграции SQL; аутентификация — cookie + **JWT** (**jsonwebtoken**), пароли **bcryptjs**; опционально вход через HR (`HR_AUTH`, отдельное подключение к БД HR). |
|
||||
| **Frontend** | **React** 18, **React Router** 6, сборка **Vite** 5; статика в проде через Nginx (см. `docker-compose.dev.yml`). |
|
||||
| **Данные** | **PostgreSQL**, отдельная БД **`clinic_tests`**: UUID-ключи, таблицы `tests`, `test_versions`, `questions`, `answer_options`, назначения, попытки (см. `backend/src/db/migrations/`). |
|
||||
| **Прочее** | Извлечение текста из PDF/DOCX (**pdf-parse**, **mammoth**), опционально LLM для черновиков тестов; **dotenv**, **cors**, **multer**. |
|
||||
Старые каталоги `backend/` (Node.js / Express) и `frontend/`
|
||||
(React + Vite) — **архив**: не разворачиваются, в `docker-compose.dev.yml`
|
||||
поднимается сервис **`testing-flask`**, удаление папок запланировано
|
||||
в спринте **E1.6**. Использовать их не надо, миграции и SQL-схема
|
||||
сохранены в `backend/src/db/migrations/` исключительно как источник
|
||||
структуры БД.
|
||||
|
||||
### Целевой стек (Flask, как в кабинете / мини-приложении)
|
||||
БД — **`clinic_tests`** (PostgreSQL, UUID-ключи). В Этапе 1 схема
|
||||
не меняется.
|
||||
|
||||
Тот же класс технологий, что в **`HR_TG_Bot/tgFlaskForm`**: Python, Flask, шаблоны, Postgres. Сейчас допускается **отдельный деплой** нового контура из каталога [`flask_app/`](flask_app/README.md); позже — слияние с полным кабинетом при необходимости.
|
||||
|
||||
Эталон реализации модуля в монорепозитории HR — общий веб-кабинет **`HR_TG_Bot/tgFlaskForm`**:
|
||||
|
||||
| Слой | Технологии |
|
||||
|------|------------|
|
||||
| **Приложение** | **Python 3**, **Flask** 3, шаблоны **Jinja2** + **PyPug**, blueprint `/cabinet/testing`; прод-сервер типично **waitress**. |
|
||||
| **Данные** | **SQLAlchemy** 2, **psycopg2**, БД **`hr_bot_test`**: таблицы `testing_*`, связи с **`staff_members`**. |
|
||||
| **Клиент** | HTML-шаблоны кабинета, JS в `webApp/templates/static/js/cabinet/` (без отдельного SPA в этом репозитории). |
|
||||
| **Инфра** | Тот же кластер Postgres, что и у Postgres_TG_Bots / HR (см. раздел установки ниже). |
|
||||
|
||||
Подробности переноса и миграции данных: [docs/migration-to-tgflaskform.md](docs/migration-to-tgflaskform.md).
|
||||
Скрипт ETL в монорепозитории HR: [`../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (`--dry-run` / `--apply`, переменные `CLINIC_TESTS_URL` и `HR_BOT_URL`).
|
||||
**Этап 2** — слияние с общим HR-кабинетом `HR_TG_Bot/tgFlaskForm` —
|
||||
запланирован на будущее, сейчас не делается. План:
|
||||
[`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md)
|
||||
([простыми словами](docs/migration-to-tgflaskform-plain.md)).
|
||||
|
||||
---
|
||||
|
||||
## Содержание
|
||||
## Интерфейс (кабинет)
|
||||
|
||||
- [Стек технологий](#стек-технологий) · [flask_app/ — новый контур](flask_app/README.md)
|
||||
- [Состояние реализации (сводка)](#состояние-реализации-сводка)
|
||||
- [Функциональные возможности](#функциональные-возможности)
|
||||
- [Роли и права доступа](#роли-и-права-доступа)
|
||||
- [Установка и запуск](#установка-и-запуск)
|
||||
- [Данные, сотрудники, интеграция с HR](#данные-сотрудники-интеграция-с-hr)
|
||||
- [Нефункциональные требования](#нефункциональные-требования)
|
||||
- [Вне scope](#вне-scope-не-реализуется-в-данной-версии)
|
||||
Единственный вариант UI — **как у основного HR-веба**: в
|
||||
[`base.html`](flask_app/app/templates/base.html) корень
|
||||
`cabinet-app` → шапка `cabinet-header` → контент `cabinet-main`; на
|
||||
`<body>` всегда класс **`ui-legacy`**, стили в [`app.css`](flask_app/app/static/css/app.css)
|
||||
с префиксом **`body.ui-legacy`** (primary/teal, `.btn`, `.surface-card`,
|
||||
`legacy-list-shell`, `test-detail-page` и т.д.).
|
||||
|
||||
В [`docker-compose.dev.yml`](docker-compose.dev.yml) один сервис
|
||||
**`testing-flask`** (`container_name: testing_webapp_flask`), порт **3107**.
|
||||
|
||||
---
|
||||
|
||||
## Состояние реализации (сводка)
|
||||
## Что уже работает на новом (Flask) контуре
|
||||
|
||||
Коротко и по-человечески: [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) (черновики и версии, разбор попыток, список тестов, dev-стенд).
|
||||
Как пользоваться локальным **dev** без чтения кода: [docs/DEV_CONTOUR_USER_GUIDE.md](docs/DEV_CONTOUR_USER_GUIDE.md).
|
||||
E1.0–E1.3 и E1.8 закрыты. Чек-лист и журнал —
|
||||
[`docs/migration-final.md`](docs/migration-final.md).
|
||||
|
||||
---
|
||||
- **Авторизация** через куки-сессию Flask: bcrypt (локальные пользователи
|
||||
`clinic_tests.users`) или Werkzeug-хеши при включённом `HR_AUTH=1`
|
||||
(UPSERT в `clinic_tests.users` по `staff_id` из `hr_bot_test`).
|
||||
UI: `/login`, JSON: `/api/auth/{login,logout,me}`.
|
||||
- **Каталог тестов** `/tests` (видны активные + блок «Скрытые вами»),
|
||||
создание теста через модалку.
|
||||
- **Редактор** `/tests/<id>/edit`: правка названия/описания/проходного
|
||||
балла, добавление/удаление/перемещение вопросов и вариантов,
|
||||
переключатель «Цепочка активна», авто-форк новой версии при правке
|
||||
после первой попытки.
|
||||
- **AI-помощник** в редакторе:
|
||||
- «По названию» — генерация всего теста по теме (количество вопросов
|
||||
и вариантов задаёт автор);
|
||||
- «По текущей сетке» — генерация по уже расставленным карточкам;
|
||||
- «Проверить» — рецензия теста с вердиктом и разделами рекомендаций;
|
||||
- «Улучшить» — массовое «было → стало» с чекбоксами;
|
||||
- «AI: вопрос/переформулировать» — на отдельной карточке вопроса.
|
||||
- **Импорт документа** в редакторе: PDF / DOCX / TXT / MD до 16 МБ,
|
||||
через `pypdf` и `python-docx` → AI-черновик.
|
||||
- **Настройки** `/settings` — статус общего LLM-ключа из ENV (DeepSeek
|
||||
или OpenAI-совместимый), кнопка «Проверить подключение».
|
||||
|
||||
## Функциональные возможности
|
||||
Подробная инструкция для тестировщика (только UI, без консоли) —
|
||||
[`docs/QA-versioning-and-ai.md`](docs/QA-versioning-and-ai.md).
|
||||
|
||||
### Управление пользователями и подразделениями
|
||||
## Что ещё не реализовано
|
||||
|
||||
- Создание/редактирование/деактивация учётных записей сотрудников
|
||||
- Каждый сотрудник принадлежит одному подразделению
|
||||
- Создание/редактирование справочника подразделений
|
||||
- Назначение роли сотруднику: HR-менеджер / Руководитель подразделения / Сотрудник
|
||||
|
||||
### Создание и редактирование тестов
|
||||
|
||||
**Тест содержит:**
|
||||
- Название теста
|
||||
- Описание (опционально)
|
||||
- Список вопросов (минимум 7)
|
||||
- Порог зачёта — минимальный % правильных ответов
|
||||
- Таймер прохождения — лимит в минутах (опционально)
|
||||
|
||||
**Вопрос содержит:**
|
||||
- Текст вопроса
|
||||
- Минимум 3 варианта ответа
|
||||
- Один или несколько правильных ответов
|
||||
|
||||
**Настройки теста:**
|
||||
- Разрешить возврат к предыдущему вопросу: да / нет
|
||||
|
||||
**Версионирование:**
|
||||
- Автор может редактировать тест пока никто его не проходил
|
||||
- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется
|
||||
- Все версии теста хранятся; результаты привязаны к конкретной версии
|
||||
- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию
|
||||
- Тест можно деактивировать (скрыть из списка, не удалять)
|
||||
|
||||
### Назначение теста
|
||||
|
||||
- Список получателей (отдел или конкретные сотрудники)
|
||||
- Срок сдачи — дата дедлайна
|
||||
- Допустимое количество попыток (1 или более)
|
||||
|
||||
### Прохождение теста
|
||||
|
||||
- На главной странице сотрудник видит список назначенных тестов со статусами:
|
||||
- `Не начат` — ещё не открывал
|
||||
- `В процессе` — начал, не завершил
|
||||
- `Завершён` — сдал/не сдал
|
||||
- `Просрочен` — дедлайн прошёл, не сдан
|
||||
- Если задан таймер — отображается обратный отсчёт, по истечении тест завершается автоматически
|
||||
- Порядок вопросов **случайный** при каждом прохождении
|
||||
- Возможность вернуться к предыдущему вопросу — определяется настройкой теста
|
||||
|
||||
### Результаты после завершения теста
|
||||
|
||||
- Итоговый балл и процент правильных ответов
|
||||
- Факт зачёта: **сдал / не сдал**
|
||||
- Разбор ошибок: по каждому вопросу — его ответ и правильный ответ
|
||||
|
||||
### Трекер попыток
|
||||
|
||||
Единый интерфейс просмотра всех попыток прохождения тестов:
|
||||
- Фильтрация по подразделению, сотруднику, тесту, статусу, результату
|
||||
- Пагинация и сортировка
|
||||
|
||||
### AI-помощник
|
||||
|
||||
Интеграция с LLM для помощи при создании тестов:
|
||||
|
||||
| Функция | Описание |
|
||||
|---------|----------|
|
||||
| Генерация теста | AI генерирует готовый набор вопросов с вариантами ответов по теме |
|
||||
| Улучшение формулировки | AI переформулирует выбранный вопрос более чётко |
|
||||
| Добавление дистракторов | AI генерирует правдоподобные неправильные варианты ответов |
|
||||
| Проверка качества | AI анализирует весь тест и выдаёт рекомендации |
|
||||
|
||||
---
|
||||
|
||||
## Роли и права доступа
|
||||
|
||||
| Роль | Кто | Создаёт тесты | Назначает тесты | Видит результаты |
|
||||
|------|-----|:---:|:---:|:---:|
|
||||
| **HR-менеджер** | Руководитель службы HR, Директор клиники | ✅ | Всем сотрудникам клиники | Всех сотрудников |
|
||||
| **Руководитель подразделения** | Главный врач, рук. службы администраторов | ✅ | Только своему подразделению | Только своего подразделения |
|
||||
| **Сотрудник** | Все остальные работники | ❌ | ❌ | Только свои |
|
||||
| Спринт | Что включает |
|
||||
|---|---|
|
||||
| **E1.4** — Назначение и прохождение | Назначить тест сотруднику, экран прохождения, экран результата с разбором ошибок. |
|
||||
| **E1.5** — Трекер и настройки модуля | Единый список попыток с фильтрами, страница настроек цепочки. |
|
||||
| **E1.6** — Cutover внутри репозитория | Удаление `backend/` и `frontend/`, чистка `docker-compose.dev.yml` от архивных Node/React-сервисов. |
|
||||
| **E1.7** — UX-полировка редактора | 4 аккордеона (Шапка / AI / Вопросы / Действия) и drag-n-drop из [Спринта 3](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). |
|
||||
|
||||
---
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### База данных (как в HR_TG_Bot / Postgres_TG_Bots)
|
||||
### Предпосылка: общий Postgres
|
||||
|
||||
Используется **тот же** экземпляр PostgreSQL, что и в [Postgres_TG_Bots](../Postgres_TG_Bots) (`docker-compose.dev.yml`, контейнер `hr_postgres_dev`, учётка `hr_bot_user` / сеть `hr_postgres_dev_net` — см. [HR_TG_Bot docker-compose](../HR_TG_Bot/docker-compose.dev.yml)).
|
||||
Используется **тот же** PostgreSQL, что и в
|
||||
[Postgres_TG_Bots](../Postgres_TG_Bots) (контейнер `hr_postgres_dev`,
|
||||
сеть `hr_postgres_dev_net`, учётка `hr_bot_user`).
|
||||
|
||||
Схема приложения (таблицы `users`, `tests`, `departments`, …) **не** совмещается с БД `hr_bot_test` — для TestingWebApp заведена отдельная база **`clinic_tests`**.
|
||||
```bash
|
||||
# (один раз) создать базу
|
||||
psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" \
|
||||
-c "CREATE DATABASE clinic_tests;"
|
||||
|
||||
1. Поднять Postgres из `Postgres_TG_Bots` (и при необходимости внешнюю сеть: `docker network create hr_postgres_dev_net` — как в compose этих репозиториев).
|
||||
2. Один раз создать базу:
|
||||
`psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"`
|
||||
3. Скопировать `backend/.env.example` в `backend/.env`, при необходимости поправить `DATABASE_URL` (внутри Docker кластера — хост `hr_postgres_dev`, порт `5432`).
|
||||
4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/` — `npm run dev`).
|
||||
# (один раз) внешняя сеть, если ещё не создана соседом
|
||||
docker network create hr_postgres_dev_net || true
|
||||
```
|
||||
|
||||
**Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`:
|
||||
`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:3107** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3001** (см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). **Новый Flask-контур** (тот же стек, что кабинет HR): **http://localhost:3108** — сервис `testing-flask`, см. [flask_app/README.md](flask_app/README.md). Локальный `npm run dev` фронта (Vite) — тоже **:3107**, прокси `/api` на **:3001**. В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`). В `backend/.env` задайте `PORT=3001`, если поднимаете API отдельно от compose.
|
||||
### Dev-стенд
|
||||
|
||||
`docker compose -f docker-compose.dev.yml down` — остановка.
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
**Без общего кластера** (только отладка): `docker compose --profile standalone up -d` в TestingWebApp — Postgres на **5433**, тогда в `.env` укажите `DATABASE_URL=...localhost:5433/clinic_tests` или `DB_PORT=5433` с `DB_NAME`/`DB_USER` из compose.
|
||||
| Что | URL |
|
||||
|---|---|
|
||||
| Приложение (Flask) | <http://localhost:3107> |
|
||||
| Health-check | <http://localhost:3107/health> |
|
||||
|
||||
**Если `npm run migrate` пишет `ECONNREFUSED ...:5433`:** в `backend/.env` нет (или кривой) `DATABASE_URL` на **5432**, и сработал старый `DB_PORT=5433`. Задайте `DATABASE_URL` как в `backend/.env.example` для общего Postgres.
|
||||
`docker-compose.dev.yml` пробрасывает в контейнер **`testing-flask`**:
|
||||
- `DATABASE_URL` (по умолчанию на контейнерный Postgres `clinic_tests`);
|
||||
- `HR_AUTH=1` / `HR_DATABASE_URL` по умолчанию — вход через HR-кабинет;
|
||||
- `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` —
|
||||
для AI-функций. Достаточно положить ключ в корневой `.env` репозитория.
|
||||
|
||||
### Данные, сотрудники, интеграция с HR
|
||||
### Локально без Docker
|
||||
|
||||
- **Две роли кластера Postgres:** в **`clinic_tests`** — только сущности модуля тестирования (тесты, версии, назначения, попытки, локальные технические учётки при необходимости). В **`hr_bot_test`** (Postgres_TG_Bots / hr_web_viewer) — штат, справочники, существующий **RBAC** и веб-логины. Так мы не смешиваем схемы и не дублируем «источник правды» по людям.
|
||||
- **Сотрудник в процессах** (назначения, дашборды, доступ к результатам) — везде по **`staff_members.id`**. Ссылки в `clinic_tests` храним как **тот же идентификатор** (логическая связь с `staff_members` в `hr_bot_test`); **ФИО, отдел, роли** подтягиваем из HR при отображении или кэшируем по согласованной политике, а не ведём второй кадровый учёт.
|
||||
- **`telegram_id`** в данных сотрудника **не участвует** в бизнес-логике модуля: ни вход, ни проверка прав, ни выбор сотрудника в сценариях, ни фильтрация — только **справочная** информация при необходимости (отображение, история).
|
||||
- **RBAC в перспективе:** единая система разрешений — та, что уже в HR (роли, `staff_role_assignments`, permissions). Модуль тестирования **не** развивает отдельную полную копию матрицы; проверка действий в целевом виде — через **HR** (внутренний API / токен / согласованные запросы к БД). Пока договор и API не готовы — допустимы временные флаги в `clinic_tests`, явно помечаемые как MVP.
|
||||
См. [`flask_app/README.md`](flask_app/README.md) — `venv` +
|
||||
`pip install -r requirements.txt` + `python run.py`.
|
||||
|
||||
Детализация задач и варианты A.x: [docs/revision_task/card1.md](docs/revision_task/card1.md).
|
||||
---
|
||||
|
||||
## Данные и интеграция с HR
|
||||
|
||||
- **Две роли кластера Postgres.** В **`clinic_tests`** — только сущности
|
||||
модуля тестирования (тесты, версии, назначения, попытки, локальные
|
||||
технические учётки). В **`hr_bot_test`** (Postgres_TG_Bots /
|
||||
hr_web_viewer) — штат, справочники, RBAC и веб-логины. Схемы не
|
||||
смешиваем, второй кадровый учёт в `clinic_tests` не ведём.
|
||||
- **Сотрудник** во всех бизнес-процессах — по
|
||||
**`staff_members.id`** из `hr_bot_test`. В `clinic_tests` храним тот же
|
||||
идентификатор; ФИО / отдел / роли подтягиваем из HR при отображении.
|
||||
- **`telegram_id` сотрудника** в бизнес-логике модуля **не участвует**
|
||||
(ни вход, ни проверка прав, ни выбор сотрудника, ни фильтрация).
|
||||
- **Целевой RBAC** — единая система разрешений HR
|
||||
(`staff_role_assignments`, `permissions`). Модуль тестирования
|
||||
не дублирует матрицу; пока единый API не готов — в `clinic_tests`
|
||||
допустимы временные флаги, явно помеченные как MVP.
|
||||
- **`HR_AUTH=1`**: в Flask-контуре включает вход через `hr_bot_test.users`
|
||||
(Werkzeug-хеши) с UPSERT в `clinic_tests.users`. См.
|
||||
[`flask_app/.env.example`](flask_app/.env.example).
|
||||
|
||||
---
|
||||
|
||||
## Роли и права (по ТЗ)
|
||||
|
||||
| Роль | Кто | Создаёт тесты | Назначает | Видит результаты |
|
||||
|---|---|:---:|:---:|:---:|
|
||||
| **HR-менеджер** | Руководитель HR, директор | ✅ | Всем | Всех |
|
||||
| **Руководитель подразделения** | Главврач, рук. отделения | ✅ | Только своему подразделению | Только своего подразделения |
|
||||
| **Сотрудник** | Все остальные | ❌ | ❌ | Только свои |
|
||||
|
||||
> На текущем Flask-контуре (E1.0–E1.3, E1.8) проверяется только
|
||||
> `@login_required`; разделение по ролям задействуется на E1.4–E1.5.
|
||||
|
||||
---
|
||||
|
||||
## Нефункциональные требования
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
|---|---|
|
||||
| Количество пользователей | 50–200 человек |
|
||||
| Платформа | Веб-приложение, браузер (desktop-first) |
|
||||
| Платформа | Веб, браузер; mobile-friendly |
|
||||
| Доступность | Внутренняя сеть клиники |
|
||||
| Язык интерфейса | Русский |
|
||||
| Время отклика | < 2 секунды |
|
||||
|
||||
## Вне scope (в текущей версии не делаем)
|
||||
|
||||
- Интеграция с AD / LDAP.
|
||||
- Нативное мобильное приложение.
|
||||
- Вопросы с вложениями (картинки, видео).
|
||||
- Экспорт отчётов в Excel / PDF.
|
||||
- Уведомления в MAX (отдельный спринт).
|
||||
|
||||
---
|
||||
|
||||
## Вне scope (не реализуется в данной версии)
|
||||
## Документация
|
||||
|
||||
- Интеграция с AD/LDAP
|
||||
- Мобильное приложение
|
||||
- Вопросы с вложениями (изображения, видео)
|
||||
- Экспорт отчётов в Excel / PDF
|
||||
- Уведомления в MAX (отдельный спринт)
|
||||
| Файл | О чём |
|
||||
|---|---|
|
||||
| [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) | Что работает «прямо сейчас», что в работе, что в бэклоге. |
|
||||
| [`docs/migration-final.md`](docs/migration-final.md) | Главный трекер миграции: спринты Этапа 1, журнал, критерии готовности. |
|
||||
| [`docs/migration-final-inventory.md`](docs/migration-final-inventory.md) | Карта 22 эндпоинтов Express + gap-analysis с `tgFlaskForm`. |
|
||||
| [`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md) | План Этапа 2 (слияние с HR-кабинетом). |
|
||||
| [`docs/QA-versioning-and-ai.md`](docs/QA-versioning-and-ai.md) | Инструкция для тестировщика — только через сайт. |
|
||||
| [`docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md`](docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) | Целевой мобильный UX редактора (база для E1.7). |
|
||||
| [`docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md`](docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) | Кратко для врачей-кураторов. |
|
||||
| [`flask_app/README.md`](flask_app/README.md) | Конкретные команды для нового контура. |
|
||||
| [`docs/ТЗ.md`](docs/ТЗ.md) | Исходное ТЗ заказчика. |
|
||||
|
||||
@@ -4,66 +4,32 @@
|
||||
# База clinic_tests: один раз
|
||||
# psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;"
|
||||
#
|
||||
# Запуск: из каталога TestingWebApp
|
||||
# docker compose -f docker-compose.dev.yml up --build
|
||||
# UI (Node): http://localhost:3107 (Nginx: /api → backend:3001), API: http://localhost:3001
|
||||
# UI (Flask, новый контур): http://localhost:3108
|
||||
# Flask UI (кабинетный стиль): http://localhost:3107
|
||||
|
||||
services:
|
||||
testing-backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: testing_webapp_backend
|
||||
# LLM и прочие секреты из хоста (не копируются в образ — см. .dockerignore)
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
|
||||
JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev}
|
||||
# development: httpOnly-cookie без Secure (иначе на http://localhost:3107 логин не сработает)
|
||||
NODE_ENV: development
|
||||
FRONTEND_URL: http://localhost:3107
|
||||
PORT: "3001"
|
||||
# Вход теми же учётками, что в HR: проверка пароля в hr_bot_test + привязка сотрудника по web_login.
|
||||
# Без HR_AUTH / HR_DATABASE_URL логин ищется только в clinic_tests.users (локальные dev-учётки).
|
||||
HR_AUTH: ${HR_AUTH:-1}
|
||||
HR_DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test
|
||||
# Прямой доступ к API с хоста (Vite proxy в dev: см. frontend/vite.config.js)
|
||||
ports:
|
||||
- "3001:3001"
|
||||
networks:
|
||||
- app
|
||||
- postgres
|
||||
|
||||
testing-web:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: testing_webapp_nginx
|
||||
depends_on:
|
||||
- testing-backend
|
||||
ports:
|
||||
- "3107:80"
|
||||
networks:
|
||||
- app
|
||||
|
||||
# Новый контур: Flask (тот же стек, что кабинет HR), отдельный порт
|
||||
testing-flask:
|
||||
build:
|
||||
context: ./flask_app
|
||||
dockerfile: Dockerfile
|
||||
container_name: testing_webapp_flask
|
||||
environment:
|
||||
PORT: "3108"
|
||||
PORT: "3107"
|
||||
WEB_USE_WAITRESS: "1"
|
||||
FLASK_DEBUG: "0"
|
||||
SECRET_KEY: ${FLASK_SECRET_KEY:-testing_flask_dev_change_me}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests}
|
||||
HR_AUTH: ${HR_AUTH:-1}
|
||||
HR_DATABASE_URL: ${HR_DATABASE_URL:-postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test}
|
||||
DEV_FIO_PASSWORD: ${DEV_FIO_PASSWORD:-}
|
||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-}
|
||||
ports:
|
||||
- "3108:3108"
|
||||
- "3107:3107"
|
||||
networks:
|
||||
- app
|
||||
# когда понадобится БД из контейнера — добавьте сеть postgres (hr_postgres_dev_net)
|
||||
- postgres
|
||||
|
||||
networks:
|
||||
app:
|
||||
|
||||
@@ -31,11 +31,12 @@
|
||||
|
||||
## 3. Карточка теста (автор)
|
||||
|
||||
- **Содержание:** название, порог зачёта, вопросы и варианты, отметка верных ответов. **Сохранить черновик** — записывает правки. Если по тесту **уже были прогоны**, при изменении **содержимого** система заведёт **новую версию** (и предупредит, что так и задумано).
|
||||
- **История версий** — посмотреть все версии, **сделать активной** другую (с подтверждением). Новые «Пройти» пойдут с активной.
|
||||
- **Публикация** — скрыть цепочку из общего списка или вернуть обратно.
|
||||
- **Прогоны и разбор** (если есть завершённые попытки) — таблица; можно открыть **разбор** по вопросам.
|
||||
- **Импорт из файла** (если на стенде настроен ключ к LLM) — загрузить документ, получить **черновик**, вставить в редактор, затем снова **сохранить черновик** по правилам версий.
|
||||
Блоки **сворачиваются** (заголовок — тап). Внизу экрана на телефоне часто закреплены **«Сохранить черновик»** и **«К списку»**.
|
||||
|
||||
- **О тесте** — название, описание, **порог зачёта**. **Сохранить черновик** — записывает правки. Если по тесту **уже были прогоны**, при изменении **содержимого** система заведёт **новую версию** (и предупредит, что так и задумано).
|
||||
- **Вопросы** — тексты вопросов и варианты, отметка верных, **ИИ** (если есть), внизу секции — **«Документ в вопросы»** (загрузка файла и вставка в черновик, если на стенде настроен LLM).
|
||||
- **История** — **Версии** (все версии, **сделать активной** с подтверждением) и **Прохождения** (кто сдавал; **Разбор** по вопросам).
|
||||
- **Показ в каталоге** — **Видимость** (скрыть из общего списка или снова показать); при включённом назначении — **Кому выдать** (поиск, **«Назначить выбранных»**).
|
||||
|
||||
Автор **не** запускает экзамен **с карточки** в том же сценарии, что сотрудник: для самопрохождения — **«Пройти»** в **списке** «Тесты».
|
||||
|
||||
|
||||
@@ -1,72 +1,148 @@
|
||||
# Состояние проекта (человеческий обзор)
|
||||
# Состояние проекта
|
||||
|
||||
**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки: **`dev`**
|
||||
**Дата среза:** 2026-04-24
|
||||
**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки **`dev`**
|
||||
**Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)**
|
||||
**Дата среза:** 2026-04-28
|
||||
|
||||
Этот документ — не дублирование ТЗ, а **короткое объяснение**, что уже работает в коде и что логично делать дальше. Подробные задачи: [revision_task/card1.md](revision_task/card1.md), [revision_task/BACKLOG.md](revision_task/BACKLOG.md).
|
||||
Не дубль ТЗ, а карта «что реально работает в коде, на каком контуре,
|
||||
и что логично сделать дальше».
|
||||
|
||||
---
|
||||
|
||||
## Что уже сделано (как это устроено)
|
||||
## TL;DR
|
||||
|
||||
### Вход и роли
|
||||
- Прод и dev работают **только на Flask-контуре** (`flask_app/`,
|
||||
Python 3.11 + Flask 3 + Jinja2 + Tailwind CDN + SQLAlchemy).
|
||||
- Каталоги `backend/` (Express) и `frontend/` (React) — архив, не
|
||||
разворачиваются и не используются; удаление запланировано в
|
||||
спринте **E1.6**.
|
||||
- БД — **`clinic_tests`** (PostgreSQL). Схема в Этапе 1 не меняется.
|
||||
- Этап 2 (слияние с `HR_TG_Bot/tgFlaskForm`) пока не делаем —
|
||||
[`migration-to-tgflaskform.md`](migration-to-tgflaskform.md).
|
||||
|
||||
- Сотрудник входит по **логину и паролю** (сессия через cookie + JWT).
|
||||
- В шапке показываются **роль** и **Фамилия с инициалами** (например, *Иванов И. О.*), полное ФИО — во всплывающей подсказке.
|
||||
- В **режиме разработки** (`NODE_ENV=development`) у удобного тестирования могут быть дополнительные кнопки (например, создание теста сотрудником — `devUi` в ответе `/api/auth/me`).
|
||||
|
||||
### «Цепочка» теста и черновики
|
||||
|
||||
- У каждого теста есть **одна логическая цепочка** в базе: все правки вопросов относятся к ней, но **версия контента** (`v1`, `v2`, …) может расти.
|
||||
- **Пока никто не проходил** этот тест — автор правит **на месте**: сохраняет черновик, и меняется текущая активная версия **без** лишнего дублирования строк в истории.
|
||||
- **Как только по цепочке появилась хотя бы одна завершённая попытка** — каждое **содержательное** сохранение с изменениями создаёт **новую версию** (новый номер, старая остаётся в истории). Старые результаты остаются привязаны к **той** версии, с которой человек реально отвечал.
|
||||
- **Активная версия** — та, с которой сейчас стартуют новые попытки. Автор может **вручную** переключить активную версию в таблице истории (с подтверждением), если бизнесу так нужно.
|
||||
- **Публикация:** тест можно **скрыть из общего списка** (цепочка остаётся в базе; автор видит скрытые в отдельном блоке и может вернуть в список).
|
||||
|
||||
### Список тестов и доступ
|
||||
|
||||
- В каталоге **«Тесты»** видны цепочки, где вы **автор**, и тесты, **назначенные вам** (через назначение на пользователя; в dev назначения обычно **включены**).
|
||||
- Под названием показывается **«Автор: Вы»** для своих тестов и **«Автор: Фамилия И. О.»** для чужих (назначенных).
|
||||
- **Пройти** тест — кнопка **справа** в строке; **карточка** теста — клик по названию **слева** (попытка с карточки не стартует сама).
|
||||
|
||||
### Прохождение и результат
|
||||
|
||||
- Открывается экран вопросов (один или несколько верных вариантов); после **«Завершить тест»** — итог: сколько верно, процент, **зачёт** по порогу.
|
||||
- **Разбор:** после сдачи показывается, по **каждому вопросу**, что выбрал пользователь и какие варианты верны. Отдельная страница разбора доступна по ссылке; **автор** на карточке теста видит раздел **«Прогоны и разбор»** по завершённым попыткам.
|
||||
|
||||
### Импорт и ИИ (MVP)
|
||||
|
||||
- Можно загрузить **файл** (PDF, DOCX, текст): сервер **извлекает текст** и при настроенном ключе **LLM** (например, `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` в окружении) предлагает **черновик** вопросов. Дальше тот же поток, что и при ручном редактировании: правки → **сохранить черновик** (с учётом правил версий выше).
|
||||
- **Полный** набор сценариев из ТЗ (отдельная страница настроек ключа, «проверить тест целиком», модалки с чекбоксами и т.д.) — в [sprint-02](revision_task/sprint-02.md); часть уже заложена в сервисах, UI доводится.
|
||||
|
||||
### Назначения (MVP)
|
||||
|
||||
- **Автор** на карточке теста может **назначить** сотрудников из справочника (в dev — через поиск/каталог, если фича включена в `docker-compose` / `.env`). Назначение **не** перепривязывается автоматически к каждой новой версии контента: **старт попытки** всегда берёт **текущую активную** версию на момент нажатия **«Пройти»**.
|
||||
|
||||
### Интеграция с HR (в зачатке)
|
||||
|
||||
- Поддержан сценарий **входа через учётки HR** (`HR_AUTH` + `HR_DATABASE_URL`) для проверок на одном кластере Postgres с экосистемой `Postgres_TG_Bots` (см. [README — установка](../README.md)).
|
||||
- Целевой **RBAC** из HR-таблиц — [card1, часть A](revision_task/card1.md#часть-a--авторизация-по-паролю-бд-postgres_tg_bots); сейчас — упрощённое сопоставление ролей.
|
||||
Главный трекер по спринтам — [`migration-final.md`](migration-final.md).
|
||||
|
||||
---
|
||||
|
||||
## Что в планах (логичный следующий слой)
|
||||
## Что уже работает на новом контуре (E1.0–E1.3, E1.8)
|
||||
|
||||
### Вход
|
||||
- `/login` (форма) и `/api/auth/login` (JSON), `/api/auth/logout`,
|
||||
`/api/auth/me`.
|
||||
- По умолчанию — bcrypt-хеши из `clinic_tests.users`.
|
||||
- `HR_AUTH=1` + `HR_DATABASE_URL` — вход через `hr_bot_test.users`
|
||||
(Werkzeug); запись синхронизируется в `clinic_tests.users` UPSERT-ом по
|
||||
`staff_id`. Сценарий «пользователь без `staff_id`» — пропускается с
|
||||
предупреждением в логах.
|
||||
|
||||
### Каталог тестов (`/tests`)
|
||||
- Видны цепочки, где вы автор, и активные публичные.
|
||||
- Создание теста через модалку («Название» + «Описание»).
|
||||
- Кнопка «Скрыть» / «Вернуть» работает на цепочку целиком.
|
||||
|
||||
### Редактор теста (`/tests/<id>/edit`)
|
||||
- Поля шапки: название, описание, проходной балл, переключатель
|
||||
«Цепочка активна».
|
||||
- Вопросы и варианты: добавить / удалить / переместить, отметить верные.
|
||||
- **Версионирование.** Пока по цепочке нет завершённых попыток —
|
||||
правки идут «на месте». После первой попытки любое содержательное
|
||||
сохранение делает форк (`version + 1`, `parent_id` = прежняя),
|
||||
старая версия остаётся в БД и не видна в каталоге.
|
||||
- Подробная модель поведения и проверочные сценарии —
|
||||
[`QA-versioning-and-ai.md`](QA-versioning-and-ai.md).
|
||||
|
||||
### AI-помощник в редакторе
|
||||
| Кнопка | Что делает |
|
||||
|---|---|
|
||||
| По названию | Генерирует весь набор вопросов по теме. Параметры — кол-во вопросов и вариантов. |
|
||||
| По текущей сетке | Дописывает варианты для уже расставленных карточек. |
|
||||
| Проверить | Рецензирует тест: вердикт + блоки рекомендаций. |
|
||||
| Улучшить | «Было → стало» по каждому вопросу/варианту с чекбоксами. |
|
||||
| AI: вопрос | На карточке вопроса — переформулировка / генерация дистракторов. |
|
||||
|
||||
При отсутствии ключа — единая ошибка с ссылкой на `/settings`.
|
||||
|
||||
### Импорт документа
|
||||
- PDF / DOCX / TXT / MD до 16 МБ.
|
||||
- `pypdf` для PDF, `python-docx` для DOCX, плоский текст — как есть.
|
||||
- Извлечённый текст идёт в LLM, на выходе — черновик теста, который
|
||||
открывается в редакторе.
|
||||
|
||||
### Настройки (`/settings`)
|
||||
- Статус общего LLM-ключа (берётся из ENV: `DEEPSEEK_API_KEY` →
|
||||
`OPENAI_API_KEY`).
|
||||
- Провайдер, модель, base URL.
|
||||
- Кнопка «Проверить подключение» — пинг `/v1/chat/completions` через
|
||||
`ping_llm()`.
|
||||
- Ключ на клиента не уходит и в БД не пишется.
|
||||
|
||||
---
|
||||
|
||||
## Чего на Flask пока нет
|
||||
|
||||
Эти сценарии будут реализованы в E1.4–E1.5. До этого в приложении они
|
||||
просто отсутствуют (старый Express-контур не используется и не
|
||||
поднимается):
|
||||
|
||||
- **Назначение теста сотруднику** — поиск по справочнику, «Выбрать
|
||||
всех», фильтры по подразделениям.
|
||||
- **Прохождение** — экран вопросов, таймер, сохранение попытки.
|
||||
- **Результат и разбор ошибок** — отдельная страница с ответами
|
||||
пользователя и правильными вариантами.
|
||||
- **Трекер попыток** — единый список завершённых попыток с фильтрами
|
||||
(подразделение / сотрудник / тест / статус / результат).
|
||||
|
||||
---
|
||||
|
||||
## Что в работе и в планах
|
||||
|
||||
### Этап 1 — паритет внутри TestingWebApp
|
||||
|
||||
| Спринт | Содержание | Статус |
|
||||
|---|---|---|
|
||||
| E1.0 | База Flask-приложения (БД-пул, сессии, `base.html`). | ✅ |
|
||||
| E1.1 | Auth + `/api/me` (bcrypt + Werkzeug, опц. `HR_AUTH`). | ✅ |
|
||||
| E1.2 | Каталог тестов и редактор (функциональный минимум). | ✅ |
|
||||
| E1.3 | Импорт документов (PDF / DOCX / TXT / MD). | ✅ |
|
||||
| E1.4 | Назначения и прохождение тестов. | ⬜ Следующий. |
|
||||
| E1.5 | Трекер попыток + страница настроек цепочки. | ⬜ |
|
||||
| E1.6 | Cutover внутри репозитория (удаление `backend/` + `frontend/`). | ⬜ |
|
||||
| E1.7 | UX-полировка редактора: 4 аккордеона + drag-n-drop. | ⬜ |
|
||||
| E1.8 | AI-функции v2 (`/settings`, generate-by-title, check, improve). | ✅ |
|
||||
|
||||
Подробности — [`migration-final.md`](migration-final.md).
|
||||
|
||||
### Этап 2 — слияние с HR-кабинетом (на будущее)
|
||||
|
||||
- Перенос blueprint'ом в `HR_TG_Bot/tgFlaskForm` под путь
|
||||
`/cabinet/testing`.
|
||||
- ETL `clinic_tests → hr_bot_test`. Скрипт-заготовка:
|
||||
[`HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py)
|
||||
(`--dry-run` / `--apply`).
|
||||
- Авторизация — через сессию HR-кабинета.
|
||||
- Подробности и риски — [`migration-to-tgflaskform.md`](migration-to-tgflaskform.md)
|
||||
(и [простыми словами](migration-to-tgflaskform-plain.md)).
|
||||
|
||||
### Долгий бэклог
|
||||
|
||||
| Направление | Суть |
|
||||
|-------------|------|
|
||||
| **AI по ТЗ §4.2** | Ключ в настройках (не на клиенте), кнопки «сгенерировать/проверить/улучшить» с превью и подтверждением, регресс с версиями. |
|
||||
| **Дашборды (ТЗ этап 2)** | Единая картина по отделу / клинике, фильтры, история. |
|
||||
| **MAX / мини-приложение** | Встраивание в общий HR-контур клиники. |
|
||||
| **Таймер, подсказки, медиа в вопросах** | Режимы прохождения и вложения — отдельные этапы ТЗ. |
|
||||
| **E2E и интеграционные тесты** | Расширение `V.9`, стабильный CI. |
|
||||
| **Назначения** | Сроки, лимит попыток, назначения «по отделу» (частично в бэклоге [BACKLOG_IDEAS](revision_task/BACKLOG_IDEAS.md)). |
|
||||
|
||||
Журнал приёмок и чек-листы: [TESTING_JOURNAL.md](revision_task/TESTING_JOURNAL.md).
|
||||
|---|---|
|
||||
| Дашборды (ТЗ этап 2) | Единая картина по отделу / клинике, фильтры, история. |
|
||||
| MAX / мини-приложение | Встраивание в общий HR-контур клиники. |
|
||||
| Таймер, подсказки, медиа в вопросах | Режимы прохождения и вложения — отдельные этапы ТЗ. |
|
||||
| E2E и интеграционные тесты | Расширение `V.9`, стабильный CI. |
|
||||
| Назначения по отделу | Сроки, лимит попыток, групповые назначения. |
|
||||
|
||||
---
|
||||
|
||||
## Связанные файлы
|
||||
## Связанные документы
|
||||
|
||||
- [Руководство пользователя dev-контура](DEV_CONTOUR_USER_GUIDE.md)
|
||||
- [README с установкой](../README.md)
|
||||
- [Карта задач card1](revision_task/card1.md)
|
||||
- [README репозитория](../README.md)
|
||||
- [Главный трекер миграции — `migration-final.md`](migration-final.md)
|
||||
- [Карта Express + gap-analysis с `tgFlaskForm` — `migration-final-inventory.md`](migration-final-inventory.md)
|
||||
- [План Этапа 2 — `migration-to-tgflaskform.md`](migration-to-tgflaskform.md)
|
||||
- [Инструкция тестировщику — `QA-versioning-and-ai.md`](QA-versioning-and-ai.md)
|
||||
- [Спринты мобильного UX редактора](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md)
|
||||
- [Кратко для врачей-кураторов](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md)
|
||||
- [Руководство по dev-контуру](DEV_CONTOUR_USER_GUIDE.md)
|
||||
- [ТЗ заказчика](ТЗ.md)
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
# Инструкция для тестировщика: версионирование тестов и AI
|
||||
|
||||
Сайт: **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)**
|
||||
|
||||
Учётка: подойдёт **любая** учётная запись на сайте — никаких особых ролей
|
||||
не требуется. Любой залогиненный пользователь, который создаёт тест,
|
||||
автоматически становится его автором и может его редактировать. Если
|
||||
учётки нет — попросите её у разработчика.
|
||||
|
||||
> Всё, что описано ниже, проверяется **только через сайт**. Если в каком-то
|
||||
> сценарии написано «недоступно сейчас» — это **не баг**, это значит, что
|
||||
> функция UI ещё не сделана и появится позже. Просто пропускайте.
|
||||
|
||||
---
|
||||
|
||||
## Часть 1. Версии теста — что меняется при правках
|
||||
|
||||
### О чём вообще задача
|
||||
|
||||
Когда автор правит тест, в системе важно не сломать историю прохождений
|
||||
сотрудников. Поэтому правила такие:
|
||||
|
||||
- Пока **никто не прошёл** тест — автор правит на месте, версия одна.
|
||||
- Как только **хотя бы один сотрудник прошёл** тест — следующее сохранение
|
||||
изменений создаёт **новую версию** (v2, v3, …), старая сохраняется.
|
||||
- В каталоге всегда видна **только одна** активная версия.
|
||||
- Автор может **скрыть** тест целиком (чекбокс «Цепочка активна»).
|
||||
- Автор может **переключить** активную версию на другую из истории.
|
||||
|
||||
> Сейчас на сайте нельзя пройти тест сотруднику и нельзя из UI открыть
|
||||
> историю версий — это будет в следующих спринтах. Поэтому из шести
|
||||
> правил тестировщик пока проверяет четыре, остальные помечены ниже.
|
||||
|
||||
---
|
||||
|
||||
### 1.1. Создание нового теста
|
||||
|
||||
**Что проверяем:** автор может создать тест с нуля.
|
||||
|
||||
**Как проверять:**
|
||||
1. Откройте [https://edullm.pirogov.ai/](https://edullm.pirogov.ai/) и войдите.
|
||||
2. Нажмите в шапке иконку **«Тесты»** (список) → попадаете в каталог.
|
||||
3. Нажмите кнопку **«Создать тест»**.
|
||||
4. В появившемся окне заполните **Название** (например, «Тест A»),
|
||||
при желании — Описание.
|
||||
5. Нажмите **«Создать»**.
|
||||
|
||||
**Что должно произойти:**
|
||||
- Открывается экран редактора нового теста.
|
||||
- В поле «Название» — то, что вы ввели.
|
||||
- Список вопросов пуст, счётчик «Вопросы (0)».
|
||||
- Внизу — кнопка **«Сохранить»** и чекбокс **«Цепочка активна»** (по
|
||||
умолчанию включён).
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- Окно «Создать тест» не открывается.
|
||||
- После «Создать» никуда не перенаправило.
|
||||
- В поле «Название» в редакторе пусто, хотя ввели текст.
|
||||
- Список тестов в каталоге не обновился (не появилась новая карточка).
|
||||
|
||||
---
|
||||
|
||||
### 1.2. Правка теста до прохождений (версия не растёт)
|
||||
|
||||
**Что проверяем:** пока никто не проходил тест, автор может править его
|
||||
сколько угодно — это всё одна и та же версия.
|
||||
|
||||
**Как проверять:**
|
||||
1. Откройте только что созданный «Тест A» (если уже не открыт): шапка → **«Тесты»** → нажмите на карточку теста.
|
||||
2. Нажмите **«Добавить вопрос»** — появится карточка вопроса.
|
||||
3. Введите текст вопроса.
|
||||
4. Заполните 3–4 варианта ответа в поле «Вариант ответа», у одного из них поставьте чекбокс «Правильный» (квадратик слева от текста).
|
||||
5. Добавьте ещё один-два вопроса тем же способом.
|
||||
6. Нажмите **«Сохранить»** в нижней панели.
|
||||
|
||||
**Что должно произойти:**
|
||||
- Под шапкой появляется надпись **«Сохранено.»** (без слов про новую версию).
|
||||
- Если перезагрузить страницу — все вопросы и варианты на месте.
|
||||
|
||||
**Повторите правку:**
|
||||
1. На том же экране **измените** текст одного вопроса, **добавьте** ещё один вариант к другому, **удалите** третий вопрос (кнопка «корзина» справа в карточке вопроса).
|
||||
2. Нажмите **«Сохранить»**.
|
||||
|
||||
**Что должно произойти:**
|
||||
- Снова надпись **«Сохранено.»**.
|
||||
- Никаких слов «создана новая версия».
|
||||
- Перезагрузка страницы — изменения сохранились.
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- Появляется сообщение про «новую версию» (его быть не должно — попыток ещё нет).
|
||||
- Изменения не сохранились после перезагрузки страницы.
|
||||
- Сообщение «Сохранено.» не появилось вовсе.
|
||||
|
||||
---
|
||||
|
||||
### 1.3. Правка после прохождений (создаётся v2)
|
||||
|
||||
> ⏳ **Сейчас недоступно для проверки.**
|
||||
>
|
||||
> На сайте пока нет страницы для прохождения теста сотрудником, поэтому
|
||||
> тестировщик не может «накопить» попытки и проверить эту логику через UI.
|
||||
> Сценарий вернётся в инструкцию вместе со следующим спринтом, когда
|
||||
> появится страница прохождения.
|
||||
|
||||
---
|
||||
|
||||
### 1.4. Каталог показывает только активные тесты
|
||||
|
||||
**Что проверяем:** в каталоге нет неактивных/скрытых тестов.
|
||||
|
||||
**Как проверять:**
|
||||
1. На экране редактора любого теста снимите внизу чекбокс
|
||||
**«Цепочка активна»** и нажмите **«Сохранить»**.
|
||||
2. Перейдите в шапке на **«Тесты»** (каталог).
|
||||
|
||||
**Что должно произойти:**
|
||||
- Карточки этого теста в основном списке нет.
|
||||
- Внизу страницы каталога есть раскрывающийся блок **«Скрытые вами цепочки (N)»**. Раскройте его — там видно ваш тест.
|
||||
- Нажмите **«Открыть»** рядом с тестом — снова попадаете в редактор.
|
||||
- Поставьте обратно галку **«Цепочка активна»** → **«Сохранить»**.
|
||||
- Снова перейдите в **«Тесты»** — тест опять в основном списке каталога.
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- После снятия галки тест остаётся в обычном каталоге.
|
||||
- Тест полностью пропал и его не видно нигде, даже в «Скрытых».
|
||||
- После возврата галки тест не вернулся в основной каталог.
|
||||
|
||||
---
|
||||
|
||||
### 1.5. Ручное переключение активной версии
|
||||
|
||||
> ⏳ **Сейчас недоступно для проверки.**
|
||||
>
|
||||
> Страница с историей версий теста (где можно нажать «сделать активной»)
|
||||
> ещё не сделана. Появится в следующем спринте.
|
||||
|
||||
---
|
||||
|
||||
### 1.6. История прохождений после правок
|
||||
|
||||
> ⏳ **Сейчас недоступно для проверки** — см. 1.3 и 1.5.
|
||||
|
||||
---
|
||||
|
||||
## Часть 2. AI-функции в редакторе теста
|
||||
|
||||
### Как проверять, что AI вообще работает
|
||||
|
||||
**Что проверяем:** ключ к AI задан и сервис отвечает.
|
||||
|
||||
**Как проверять:**
|
||||
1. В шапке нажмите иконку **«Настройки»** (шестерёнка).
|
||||
2. Посмотрите блок «Подключение к LLM»:
|
||||
- **Статус ключа** — должно быть зелёное **«Задан»**.
|
||||
- **Провайдер** и **Модель** — заполнены.
|
||||
3. Нажмите кнопку **«Проверить подключение»**.
|
||||
|
||||
**Что должно произойти:**
|
||||
- В течение 1–10 секунд под кнопкой появляется **зелёный** блок
|
||||
с текстом вида **«OK · deepseek / deepseek-chat · 1234 мс»**.
|
||||
|
||||
**Если что-то не так:**
|
||||
- Если статус **«Не задан»** (красный) — сообщите разработчику, не задан ключ. Тестировать AI-функции в этом режиме не нужно, кроме одного сценария ниже (2.7).
|
||||
- Если кнопка отдала **красный** блок «Ошибка» при заданном ключе — это баг, прикладывайте текст ошибки к тикету.
|
||||
|
||||
---
|
||||
|
||||
### 2.1. Сгенерировать тест по названию
|
||||
|
||||
**Простыми словами:** автор пишет только тему, AI сам придумывает вопросы.
|
||||
|
||||
**Как проверять:**
|
||||
1. **«Тесты»** → **«Создать тест»** → название, например, **«Гигиена рук»**, описание можно оставить пустым → **«Создать»**.
|
||||
2. В редакторе нажмите кнопку **«По названию»** (фиолетовая, в блоке «AI-помощник» → «Создать вопросы»).
|
||||
3. На вопрос «Сколько вопросов сгенерировать?» введите, например, `8` → **OK**.
|
||||
4. На вопрос «Сколько вариантов в каждом вопросе?» введите, например, `4` → **OK**.
|
||||
5. Подождите 5–20 секунд.
|
||||
6. Появится подтверждение «Готово: «…», вопросов — N. Применить как черновик? Текущие вопросы будут заменены». Нажмите **OK**.
|
||||
|
||||
**Что должно произойти:**
|
||||
- В списке появилось примерно 8 вопросов на тему гигиены рук, в каждом примерно 4 варианта ответа на русском языке.
|
||||
- В каждом вопросе хотя бы один вариант помечен как «Правильный» (галка слева от текста варианта).
|
||||
- Внизу можно нажать **«Сохранить»** — тест сохраняется.
|
||||
|
||||
**Дополнительно (что блокировка названия работает):**
|
||||
1. Создайте ещё один тест, в редакторе **очистите поле «Название»**.
|
||||
2. Нажмите **«По названию»**.
|
||||
3. Должен появиться алерт **«Сначала заполните название теста.»**, курсор перейдёт в поле «Название». Никакой генерации не происходит.
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- Кнопка **«По названию»** работает при пустом названии (без алерта).
|
||||
- Сгенерированные вопросы — не на русском или не по теме названия.
|
||||
- В вопросе нет ни одного правильного варианта.
|
||||
- Подтверждение «Применить?» не появилось — вопросы заменились молча.
|
||||
- Отказ в подтверждении (Cancel) всё равно заменил вопросы.
|
||||
|
||||
---
|
||||
|
||||
### 2.2. Сгенерировать тест по сетке
|
||||
|
||||
**Простыми словами:** автор сам задаёт «скелет» — сколько вопросов и
|
||||
сколько вариантов; AI заполняет вопросы по этому скелету.
|
||||
|
||||
**Как проверять:**
|
||||
1. Создайте новый тест с названием **«Тест по сетке»**.
|
||||
2. Нажмите **«Добавить вопрос»** пять раз — получится 5 пустых карточек.
|
||||
3. В **третьей и пятой** карточках поставьте галку **«Несколько правильных ответов»**.
|
||||
4. В каждом вопросе по умолчанию 0 вариантов — нажмите **«Добавить вариант»** в каждом вопросе по 3 раза, чтобы стало по 3 варианта.
|
||||
5. Нажмите кнопку **«По текущей сетке»** в блоке «AI-помощник».
|
||||
|
||||
**Что должно произойти:**
|
||||
- В списке снова 5 вопросов (не больше, не меньше).
|
||||
- В каждом — по 3 варианта.
|
||||
- В третьем и пятом вопросах несколько вариантов помечены правильными;
|
||||
в остальных — ровно один.
|
||||
- Тексты — на русском, по теме названия.
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- Стало другое число вопросов или вариантов (не 5×3).
|
||||
- В третьем/пятом вопросе только один правильный ответ, а в остальных — несколько.
|
||||
- Пришла ошибка типа **«AI: ошибка»** без понятного объяснения.
|
||||
|
||||
---
|
||||
|
||||
### 2.3. Проверить тест
|
||||
|
||||
**Простыми словами:** AI читает весь тест и пишет, что в нём не так.
|
||||
|
||||
**Как проверять:**
|
||||
1. Откройте любой тест с 3+ вопросами (например, «Гигиена рук» из 2.1).
|
||||
2. Желательно специально испортить пару вопросов: переписать
|
||||
формулировку расплывчато («что-то про что-то»), сделать варианты
|
||||
ответа очень похожими друг на друга или явно дурацкими.
|
||||
3. Нажмите **«Сохранить»**.
|
||||
4. Нажмите кнопку **«Проверить»** в блоке «AI-помощник» → «Улучшить существующее».
|
||||
|
||||
**Что должно произойти:**
|
||||
- Открывается окно «Проверка теста».
|
||||
- Сверху — цветная плашка с одним из вердиктов: **«Годен»** (зелёный),
|
||||
**«Есть замечания»** (жёлтый) или **«Серьёзные проблемы»** (красный)
|
||||
+ одно-два предложения резюме.
|
||||
- Ниже — список разделов: «Чёткость формулировок», «Качество дистракторов»,
|
||||
«Охват темы», «Сбалансированность сложности». Под каждым — конкретные
|
||||
пункты, что улучшить.
|
||||
- Закрытие крестиком сверху или кнопкой «Закрыть» внизу — работает.
|
||||
- В тесте при этом **ничего не меняется**, AI только советует.
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- Окно пустое или текст не на русском.
|
||||
- Все вопросы AI признал хорошими, хотя вы их специально испортили.
|
||||
- После закрытия окна в тесте появились/исчезли вопросы.
|
||||
|
||||
---
|
||||
|
||||
### 2.4. Улучшить весь тест
|
||||
|
||||
**Простыми словами:** AI предлагает улучшенные формулировки и варианты;
|
||||
автор отмечает галками, что применить.
|
||||
|
||||
**Как проверять:**
|
||||
1. Откройте тест из 2.3 (с теми же намеренно слабыми вопросами).
|
||||
2. Нажмите **«Сохранить»** на всякий случай.
|
||||
3. Нажмите кнопку **«Улучшить»**.
|
||||
|
||||
**Что должно произойти:**
|
||||
- Открывается окно «Улучшение теста».
|
||||
- Сверху подпись «Отметьте вопросы… N из M» (M — всего вопросов, N — где AI предложил изменения).
|
||||
- Каждый изменённый вопрос — отдельный блок:
|
||||
- Чекбокс **«Вопрос #N»** (по умолчанию **отмечен**).
|
||||
- Слева — «Было» (старый текст и варианты, изменённые куски зачёркнуты).
|
||||
- Справа — «Стало» (новые формулировки, выделены).
|
||||
- Правильные варианты помечены галочкой **✓**.
|
||||
- Внизу — две кнопки: **«Отмена»** и **«Применить выбранное»**.
|
||||
|
||||
**Проверьте применение по выбору:**
|
||||
1. Снимите галки у двух вопросов из списка.
|
||||
2. Нажмите **«Применить выбранное»**.
|
||||
|
||||
**Что должно произойти:**
|
||||
- Окно закрывается.
|
||||
- В редакторе **только** отмеченные вопросы заменены на улучшенные;
|
||||
два вопроса, у которых вы сняли галки, остались в прежнем виде.
|
||||
- Появляется подсказка «Изменения применены. Не забудьте сохранить.»
|
||||
- После **«Сохранить»** — обычное «Сохранено.»
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- Поменялось **число** вопросов или вариантов в каких-то вопросах
|
||||
(должно остаться как было).
|
||||
- В вопросе изменилось значение «Несколько правильных ответов» (галка
|
||||
переключилась сама).
|
||||
- Изменились вопросы, у которых вы сняли галку.
|
||||
- Кнопка «Отмена» всё равно применила изменения.
|
||||
- В колонках «Было» и «Стало» одинаковый текст (нет смысла предлагать).
|
||||
|
||||
---
|
||||
|
||||
### 2.5. AI: вопрос/переформулировать
|
||||
|
||||
**Простыми словами:** работа с одним вопросом. Если поле пустое — AI его
|
||||
придумает; если уже заполнено — переформулирует красивее, варианты не трогает.
|
||||
|
||||
**Как проверять (новый вопрос):**
|
||||
1. В любом тесте нажмите **«Добавить вопрос»** — появилась пустая карточка.
|
||||
2. **Не трогайте** поле «Формулировка вопроса».
|
||||
3. Нажмите **«Добавить вариант»** 4 раза — должно стать 4 пустых варианта.
|
||||
4. Нажмите кнопку **«AI: вопрос/переформулировать»** в этой карточке.
|
||||
|
||||
**Что должно произойти:**
|
||||
- Поле «Формулировка вопроса» заполнено осмысленным текстом по теме теста.
|
||||
- Все 4 варианта заполнены.
|
||||
- Ровно один помечен как правильный.
|
||||
- Внизу появляется строка статуса **«AI: вопрос сгенерирован.»**
|
||||
|
||||
**Как проверять (переформулировать существующий):**
|
||||
1. Возьмите готовый вопрос с уже заполненной формулировкой.
|
||||
2. Нажмите **«AI: вопрос/переформулировать»** в его карточке.
|
||||
|
||||
**Что должно произойти:**
|
||||
- Меняется **только текст вопроса** — варианты ответа остаются прежними,
|
||||
правильные варианты те же.
|
||||
- Статус **«AI: формулировка обновлена.»**
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- На пустом вопросе AI ничего не сгенерировал.
|
||||
- На заполненном вопросе AI поменял варианты ответа или правильность —
|
||||
должен трогать только формулировку.
|
||||
- Получилось 0 или 1 правильных вариантов в новом вопросе (надо ровно 1
|
||||
для одиночного выбора).
|
||||
|
||||
---
|
||||
|
||||
### 2.6. Импорт документа
|
||||
|
||||
**Простыми словами:** автор загружает PDF/Word/текст со статьёй —
|
||||
AI читает файл и сам предлагает черновик теста.
|
||||
|
||||
**Подготовьте файл:** возьмите PDF или DOCX на 1–3 страницы со связным
|
||||
русским текстом (например, любую методичку или статью). Лимит — 16 МБ.
|
||||
|
||||
**Как проверять:**
|
||||
1. В редакторе любого нового теста (можно пустого) → блок «AI-помощник»
|
||||
→ **«Импортировать»** → нажмите большую кнопку **«Загрузить документ (PDF, DOCX, TXT, MD)»** → выберите файл.
|
||||
2. Подождите 5–30 секунд.
|
||||
|
||||
**Что должно произойти:**
|
||||
- Появляется подтверждение «Сгенерировано: «…», вопросов: N. Применить как новый черновик? Текущие вопросы будут заменены». Нажмите **OK**.
|
||||
- Тест заполнен вопросами по содержанию загруженного документа.
|
||||
- Можно сохранить кнопкой **«Сохранить»**.
|
||||
|
||||
**Дополнительно — отказ:**
|
||||
1. Повторите загрузку, но в подтверждении нажмите **«Отмена»**.
|
||||
2. Тест должен остаться **в прежнем виде**, ничего не подменилось.
|
||||
|
||||
**Дополнительно — большой файл:**
|
||||
1. Возьмите файл больше 16 МБ.
|
||||
2. Загрузите его.
|
||||
|
||||
**Что должно произойти:** ошибка о слишком большом файле; вопросы не
|
||||
подменились.
|
||||
|
||||
**Дополнительно — неподдерживаемый тип:**
|
||||
1. Возьмите файл `.xlsx` или картинку `.png`.
|
||||
2. Попробуйте загрузить.
|
||||
|
||||
**Что должно произойти:** алерт «Неподдерживаемый формат…»; вопросы не
|
||||
подменились.
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- Подтверждение не появилось — вопросы заменились молча.
|
||||
- Отказ в подтверждении всё равно подменил вопросы.
|
||||
- Большой файл не вызвал ошибку, а как-то «прошёл».
|
||||
- Файл не на русском дал тест с непонятной кашей вместо вопросов.
|
||||
|
||||
---
|
||||
|
||||
### 2.7. Поведение, если ключа AI нет
|
||||
|
||||
> Этот сценарий обычно проверять не нужно — он сработает автоматически, если разработчик уберёт ключ.
|
||||
> Но если в каталоге AI вообще не работает, то проверьте именно это, чтобы понять — это баг или просто ключ не настроен.
|
||||
|
||||
**Как проверять:**
|
||||
1. **«Настройки»** → если статус **«Не задан»** (красный) — это и есть тестируемая ситуация.
|
||||
2. Откройте любой тест и нажмите по очереди:
|
||||
- **«По названию»**
|
||||
- **«По текущей сетке»**
|
||||
- **«Проверить»**
|
||||
- **«Улучшить»**
|
||||
- **«AI: вопрос/переформулировать»** в любой карточке
|
||||
- **«Загрузить документ»**
|
||||
|
||||
**Что должно произойти:** в каждом случае появляется понятное сообщение,
|
||||
что AI не настроен, **и предложение открыть «Настройки»**. После согласия
|
||||
— открывается страница `/settings`. Никакого «AI: ошибка» без объяснений.
|
||||
|
||||
**Если что-то не так — баг:**
|
||||
- Сообщение об ошибке без слов «Настройки».
|
||||
- Ссылка/кнопка «Открыть Настройки» не ведёт на нужную страницу.
|
||||
- Сайт молча ничего не делает после нажатия AI-кнопки.
|
||||
|
||||
---
|
||||
|
||||
## Памятка: общий алгоритм отчёта о баге
|
||||
|
||||
Если что-то идёт не так, к тикету приложите:
|
||||
|
||||
1. **URL страницы**, на которой воспроизвели баг (полностью, из адресной
|
||||
строки).
|
||||
2. **Шаги** — что именно нажимали по порядку (1-2-3).
|
||||
3. **Что увидели** против **что ожидали увидеть** (по описанию выше).
|
||||
4. **Скриншот** экрана с проблемой (для модалок — со всем содержимым окна).
|
||||
5. Если ошибка — **точный текст** сообщения.
|
||||
6. **Учётная запись**, под которой воспроизвели (логин, без пароля).
|
||||
|
||||
Этого достаточно — лезть в консоль/код/базу не нужно и не надо.
|
||||
@@ -0,0 +1,340 @@
|
||||
# UX-аудит страницы теста и предложение новой информационной архитектуры
|
||||
|
||||
**Продукт:** HR system — модуль тестирования
|
||||
**Платформа:** Цифровые сервисы клиники им. Е. Н. Оленевой
|
||||
**Объект анализа:** страница `/tests/{id}` — создание/редактирование теста
|
||||
**Дата:** 29 апреля 2026
|
||||
|
||||
---
|
||||
|
||||
## Краткая сводка
|
||||
|
||||
Текущая страница `/tests/{id}` совмещает три разные пользовательские задачи в одном экране:
|
||||
|
||||
1. **Авторскую** — придумать и оформить тест (название, описание, вопросы, варианты).
|
||||
2. **Управленческую** — назначить тест 1–N сотрудникам.
|
||||
3. **Аналитическую** — посмотреть, кто из сотрудников и какие версии проходил.
|
||||
|
||||
Эти задачи различаются по ролям, частоте, объёму данных и контексту. Сейчас они смешаны в одном длинном аккордеоне, что приводит к ряду проблем — от потери изменений до невозможности масштабировать список аудитории за пределы 100–200 человек.
|
||||
|
||||
Документ состоит из двух частей:
|
||||
|
||||
- **Часть 1** — аудит текущей страницы с приоритизированными проблемами (critical / major / minor) и ссылками на скриншоты.
|
||||
- **Часть 2** — предложение новой IA с раздельными разделами «Тесты», «Назначения», «Отчёты», ролевой моделью и описанием жизненного цикла версии теста.
|
||||
|
||||
---
|
||||
|
||||
# Часть 1. Аудит текущей страницы
|
||||
|
||||
Все скриншоты сделаны 29.04.2026 на странице `https://edullm.pirogov.ai/tests/298a64af-...` под ролью `employee` (см. п. M-3).
|
||||
|
||||
## 1.1. Шапка, заголовок и баннер версионирования
|
||||
|
||||

|
||||
|
||||
Что мы видим: глобальная шапка «Тестирование», подпись пользователя `Разорвин А. М. · employee`, кнопка «Выйти». Ниже — хлебная крошка «← к списку», название теста, автор, дата обновления, **жёлтый баннер «При сохранении будет создана новая версия теста.»** и схлопнутый аккордеон «О тесте».
|
||||
|
||||
Замечания:
|
||||
|
||||
- **C-1 [critical] Баннер о новой версии показывается ВСЕГДА**, независимо от того, изменил ли пользователь хоть что-то. Это сбивает с толку: автор открывает существующий тест, ничего не трогает — и думает, что версия уже создана. Должен показываться только при наличии несохранённых изменений (dirty state).
|
||||
- **m-2 [minor] Роль `employee` написана по-английски** в шапке. В русском интерфейсе должно быть «сотрудник» / «автор» / другое (см. ролевую модель в Части 2).
|
||||
- **m-3 [minor] Опечатка** в `<title>` вкладки: «Система тестирования» (нет «и»). Влияет на закладки, историю, поиск.
|
||||
|
||||
---
|
||||
|
||||
## 1.2. Секция «О тесте»
|
||||
|
||||

|
||||
|
||||
Замечания:
|
||||
|
||||
- **M-1 [major] Аккордеон по умолчанию схлопнут.** Чтобы начать редактировать главный объект страницы (вопросы), нужно сделать лишний клик. На странице редактирования теста раздел «Вопросы» (а возможно, и «О тесте») должен быть открыт по умолчанию.
|
||||
- **M-2 [major] Поле «Порог зачёта, %» не имеет валидации min/max.** Что произойдёт при вводе 0, 100, 150, –5, 70.5, или вообще буквы? Минимум: подсказка «от 1 до 100», атрибуты `min/max/step` на input, инлайн-ошибка при некорректном вводе.
|
||||
|
||||
---
|
||||
|
||||
## 1.3. Раздел «Вопросы» — генерация и Вопрос 1
|
||||
|
||||

|
||||
|
||||
Что мы видим: блок **«Генерация сетки вопросов (ИИ)»** с полями «Тема», «Вопросов: 7», «Вариантов: 3» и кнопкой «Сгенерировать тест (ИИ)». Ниже — Вопрос 1 с собственной кнопкой «Сгенерировать вопрос (ИИ)» в правом верхнем углу, чекбоксом «Несколько верных ответов», тремя вариантами с радиокнопками и крестиками «удалить».
|
||||
|
||||
Замечания:
|
||||
|
||||
- **C-2 [critical] ИИ-генерация без подтверждения и без отображения хода работы.** Кнопка «Сгенерировать тест (ИИ)» одной нажатием перезаписывает существующие вопросы — а они уже могут быть наполовину написаны вручную. То же касается кнопки «Сгенерировать вопрос (ИИ)» рядом с уже заполненным вопросом.
|
||||
- Нужно: confirm-диалог «Заменить текущие N вопросов?», индикатор прогресса генерации, возможность откатить (undo) последний результат генерации.
|
||||
- **M-3 [major] Чекбокс «Несколько верных ответов» меняет семантику варианта без явного намёка.** Когда выкл — радиокнопки (один верный), когда вкл — должны стать чекбоксами (несколько). Лучше переписать подпись в зависимости от состояния: «один верный» / «несколько верных», и/или показать рядом подсказку, как изменится контрол.
|
||||
|
||||
---
|
||||
|
||||
## 1.4. Вопросы 3–5
|
||||
|
||||

|
||||
|
||||
Замечания:
|
||||
|
||||
- **M-4 [major] Нет нумерации/перетаскивания вопросов.** «Вопрос 1, 2, 3…» — порядок фиксирован тем, в каком порядке добавляли. Для длинных тестов нужен drag-handle или хотя бы стрелки «вверх / вниз».
|
||||
- **M-5 [major] «Удалить вопрос» без подтверждения.** Случайный клик уничтожит написанный вопрос. Минимум — confirm-диалог; лучше — undo-toast «Вопрос удалён · Отменить».
|
||||
- **m-4 [minor] Маленькая видимая «вода» между вопросами.** Карточки вопросов мало отделены друг от друга визуально, при пролистывании они сливаются в стену форм. Стоит увеличить вертикальный отступ между карточками или добавить разделитель.
|
||||
|
||||
---
|
||||
|
||||
## 1.5. Вопрос 7 — обрыв длинного варианта
|
||||
|
||||

|
||||
|
||||
Это один из самых наглядных багов:
|
||||
|
||||
- **M-6 [major] Длинный текст варианта обрезается.** В Q7 первый вариант отображается как «Максимальное количество токенов, которое модель может о…» — текст уходит за правый край однострочного `<input>`. Для содержательных тестов (особенно медицинских) ответы часто длинные. Нужно: либо `<textarea>` с автовысотой, либо горизонтальный скролл с tooltip всего текста на ховере.
|
||||
- **m-5 [minor] Загрузка файла «Документ в вопросы» — без drag-and-drop, без ограничений по размеру/формату на UI, без обратной связи.** Подсказка «PDF, Word или текст — вставьте в черновик вопросов» — хорошая по-человечески, но не объясняет, что произойдёт после загрузки: заменит ли существующие вопросы, добавит ли в конец, есть ли превью результата.
|
||||
|
||||
---
|
||||
|
||||
## 1.6. Кнопка «Сохранить черновик» в середине + История
|
||||
|
||||

|
||||
|
||||
Здесь главная архитектурная проблема страницы:
|
||||
|
||||
- **C-3 [critical] Кнопка «Сохранить черновик» расположена в середине страницы.** Сразу после неё ниже идут ещё две большие секции — «История» и «Показ в каталоге». Пользователь, открывший «Показ в каталоге» и поменявший там аудиторию, психологически ищет «Сохранить» внизу страницы — но там его нет. Очень высокий риск потерять изменения.
|
||||
- Решения, любое или все: (а) sticky-панель сохранения внизу страницы; (б) дубль кнопки после последней секции; (в) автосохранение черновика; (г) предупреждение перед уходом со страницы при наличии несохранённых изменений.
|
||||
- **M-7 [major] Раздел «Прохождения» показывает сырые ENUM-значения.** Видно `v1 · in_progress` — это техническое значение, а не пользовательский текст. Должно быть «в процессе» / «пройдено» / «не пройдено», лучше с цветной плашкой-индикатором.
|
||||
- **M-8 [major] Дубль кнопки «К списку».** Хлебная крошка «← к списку» наверху + кнопка «К списку» рядом с «Сохранить черновик» — две точки выхода с разным визуальным весом. Кнопка справа от primary-кнопки создаёт ложное ощущение симметричности с действием. Оставить либо крошку, либо превратить вторую кнопку в текстовую ссылку «Отмена».
|
||||
|
||||
---
|
||||
|
||||
## 1.7. «Показ в каталоге» — Видимость и фильтры
|
||||
|
||||

|
||||
|
||||
- **M-9 [major] Контрол «Видимость» неясен по текущему состоянию.** Кнопка «Скрыть из списка» — это сейчас действие или текущее состояние? Если тест уже скрыт — кнопка должна называться «Показать в списке». Лучше — переключатель (toggle/switch) с подписью «Тест виден в каталоге», чтобы текущее состояние читалось без действий.
|
||||
- **m-6 [minor] Поле поиска и два селекта** «Все отделы» / «Все» расположены без подписей — что делает второй селект, без раскрытия не понятно. Нужны явные label или persistent placeholder.
|
||||
|
||||
---
|
||||
|
||||
## 1.8. Список «Кому выдать» — 147 сотрудников
|
||||
|
||||

|
||||
|
||||
Этот блок — корень главной IA-проблемы (см. Часть 2):
|
||||
|
||||
- **C-4 [critical] Назначение тестов не должно жить на странице теста.** Это управленческая задача отдельной роли (HR-менеджер, руководитель отделения), а не авторская. Подробно — в Часть 2.
|
||||
- **M-10 [major] Список из 147 человек без виртуализации и счётчика выбранных.** Нужно как минимум: счётчик «выбрано N из 147», фильтр «только выбранные», сохранение выбранного при изменении фильтра, виртуальный скролл (на 1000+ сотрудников страница встанет колом).
|
||||
- **M-11 [major] «Назначить выбранных» внутри контейнера списка.** Кнопка стоит на нижней границе скролл-контейнера — её очень легко не заметить. И непонятно: «Назначить» — это отдельное действие или часть общего «Сохранить черновик» наверху?
|
||||
- **m-7 [minor] Подпись «нет учётки (создадим при назначении)»** — хорошая идея (ленивая выдача учёток), но требует пояснения: что значит «при назначении», что получит сотрудник после, как ему придёт первый пароль.
|
||||
|
||||
---
|
||||
|
||||
## 1.9. Сводная таблица замечаний
|
||||
|
||||
| ID | Приоритет | Что | Место |
|
||||
|---|---|---|---|
|
||||
| C-1 | critical | Баннер «новая версия» виден всегда, не только при изменениях | 1.1 |
|
||||
| C-2 | critical | ИИ-генерация без confirm и без прогресса | 1.3 |
|
||||
| C-3 | critical | Кнопка «Сохранить» в середине страницы | 1.6 |
|
||||
| C-4 | critical | Назначение сотрудников не должно жить на странице теста | 1.8 |
|
||||
| M-1 | major | Аккордеоны схлопнуты по умолчанию, включая «Вопросы» | 1.2 |
|
||||
| M-2 | major | «Порог зачёта» без валидации min/max | 1.2 |
|
||||
| M-3 | major | Чекбокс «Несколько верных» меняет семантику без подсказки | 1.3 |
|
||||
| M-4 | major | Нет переупорядочивания вопросов | 1.4 |
|
||||
| M-5 | major | «Удалить вопрос» без подтверждения и undo | 1.4 |
|
||||
| M-6 | major | Длинный текст варианта ответа обрезается | 1.5 |
|
||||
| M-7 | major | Сырые ENUM-значения в статусах прохождений | 1.6 |
|
||||
| M-8 | major | Дубль точек выхода («← к списку» + «К списку») | 1.6 |
|
||||
| M-9 | major | Контрол «Видимость» неясен по состоянию | 1.7 |
|
||||
| M-10 | major | Список 147 сотрудников без виртуализации/счётчиков | 1.8 |
|
||||
| M-11 | major | «Назначить выбранных» теряется внутри контейнера | 1.8 |
|
||||
| m-1 | minor | Логотип на странице логина обрезан | вне скрина |
|
||||
| m-2 | minor | Роль `employee` латиницей в шапке | 1.1 |
|
||||
| m-3 | minor | Опечатка «тестирования» в `<title>` | 1.1 |
|
||||
| m-4 | minor | Карточки вопросов слабо отделены друг от друга | 1.4 |
|
||||
| m-5 | minor | Загрузка файла без drag-and-drop и описания результата | 1.5 |
|
||||
| m-6 | minor | Селекты в фильтрах без явных label | 1.7 |
|
||||
| m-7 | minor | «Нет учётки (создадим при назначении)» — нужно пояснение | 1.8 |
|
||||
|
||||
Не проверено и стоит протестировать отдельно: валидация при сохранении пустого вопроса/вариантов, мобильная вёрстка, клавиатурная навигация и focus ring, контрастность по WCAG 2.2, поведение под другими ролями (руководитель, HR, директор).
|
||||
|
||||
---
|
||||
|
||||
# Часть 2. Предлагаемая новая IA
|
||||
|
||||
## 2.1. Что не так с текущей IA
|
||||
|
||||
Сейчас одна страница `/tests/{id}` решает три разные задачи разных ролей:
|
||||
|
||||
| Задача | Кто делает | Как часто | Какие данные |
|
||||
|---|---|---|---|
|
||||
| Сочинить тест | автор / методолог | один раз при создании, далее редко | вопросы, варианты, порог |
|
||||
| Назначить кому проходить | автор (иногда) или HR / руководитель | каждый раз для нового сотрудника или потока | список из 100–10 000 сотрудников, фильтры |
|
||||
| Посмотреть кто прошёл | руководитель / HR / директор | регулярно | результаты, динамика, агрегаты |
|
||||
|
||||
Это три разных пользовательских ритма, три разных набора фильтров, три разных уровня доступа. Складывать их в один аккордеон — экономия на маршрутизации и проигрыш во всём остальном (см. C-3, C-4, M-9, M-10, M-11).
|
||||
|
||||
## 2.2. Карта разделов после редизайна
|
||||
|
||||
```
|
||||
HR system (модуль «Тестирование»)
|
||||
│
|
||||
├── Главная / Дашборд
|
||||
│ сводка: «назначено N тестов, X% прошли, Y просрочены»
|
||||
│ (вид зависит от роли — см. 2.4)
|
||||
│
|
||||
├── Тесты
|
||||
│ ├── Каталог тестов ← список, поиск, фильтры
|
||||
│ ├── Создать тест ← минимальный wizard: название → пустой черновик
|
||||
│ └── /tests/{id} ← страница теста
|
||||
│ ├── Просмотр ← все, у кого есть доступ
|
||||
│ │ краткая сводка прохождений (89 / 147, средний 6.2/7)
|
||||
│ │ кнопка «Назначить» (открывает модалку из 2.3)
|
||||
│ │ кнопка «Редактировать» (если есть права)
|
||||
│ └── Редактирование ← только автор / методолог
|
||||
│ ├── О тесте
|
||||
│ ├── Вопросы
|
||||
│ └── Версии теста ← (вместо «История» — только версии)
|
||||
│
|
||||
├── Назначения ← новый раздел
|
||||
│ ├── Список назначений ← таблица «тест × сотрудник × срок × статус»
|
||||
│ ├── Создать назначение ← массовый wizard (см. 2.3)
|
||||
│ └── /assignments/{id} ← страница назначения, где можно отозвать,
|
||||
│ продлить срок, посмотреть прогресс
|
||||
│
|
||||
├── Отчёты ← новый раздел
|
||||
│ ├── По тесту ← кто прошёл, средний балл, кривые
|
||||
│ ├── По сотруднику ← все тесты сотрудника, история
|
||||
│ └── По отделу ← агрегаты для руководителей
|
||||
│
|
||||
├── Сотрудники ← справочник, синхронизация с кадрами
|
||||
│
|
||||
└── Настройки ← роли, подразделения, шаблоны уведомлений
|
||||
```
|
||||
|
||||
## 2.3. Сценарий «Назначить тест» через модалку
|
||||
|
||||
Поскольку автор иногда сам назначает тест, а иногда передаёт это HR/руководителю, кнопка «Назначить» нужна **в двух местах**:
|
||||
|
||||
- На странице теста (для автора, который сразу выдаёт тест).
|
||||
- В разделе «Назначения → Создать» (для HR/руководителя, который отбирает аудиторию массово).
|
||||
|
||||
Обе точки открывают **одну и ту же модалку / визард** с шагами:
|
||||
|
||||
1. **Кому.** Сначала фильтры по отделу/должности → одной кнопкой «Все из отделения хирургии (38)» или вручную чекбоксами. Сохранение выбранного при смене фильтра. Виртуализированный список.
|
||||
2. **Когда.** Дедлайн, опционально дата старта (например, новый сотрудник получает тест на 3-й рабочий день).
|
||||
3. **Параметры.** Сколько попыток допустимо, нужен ли пересдача после неуспеха, кому уведомления о результате.
|
||||
4. **Подтверждение.** «Назначить тест „Введение про LLM v1“ 38 сотрудникам отделения хирургии до 15 мая 2026 — назначить?»
|
||||
|
||||
После назначения автор/HR попадает на страницу созданного назначения, где видит прогресс: кто открыл, кто проходит, кто завершил.
|
||||
|
||||
## 2.4. Ролевая модель и матрица доступа
|
||||
|
||||
Четыре роли из ваших пояснений: **сотрудник**, **руководитель подразделения**, **HR-менеджер**, **директор**. Плюс отдельно — **методолог/автор**, которая может присваиваться поверх любой из роли (директор, HR или руководитель могут также быть авторами).
|
||||
|
||||
| Раздел / действие | Сотрудник | Рук. подр. | HR | Директор | Автор |
|
||||
|---|---|---|---|---|---|
|
||||
| Главная | свои назначения | свой отдел | вся клиника | вся клиника | свои тесты |
|
||||
| Каталог тестов — просмотр | да (только видимые) | да | да | да | да |
|
||||
| Создать тест | — | — | да | да | да |
|
||||
| Редактировать тест | — | — | (свои) | да | свои |
|
||||
| Опубликовать новую версию | — | — | (свои) | да | свои |
|
||||
| Удалить/архивировать тест | — | — | (свои) | да | свои |
|
||||
| Назначить тест | — | свой отдел | вся клиника | вся клиника | (если сам назначает) |
|
||||
| Отозвать назначение | — | свои | свои + HR-уровня | все | свои |
|
||||
| Отчёты по сотруднику | свои | подчинённые | все | все | свои тесты |
|
||||
| Отчёты по отделу | — | свой отдел | все | все | — |
|
||||
| Настройки ролей | — | — | да | да | — |
|
||||
|
||||
«—» — действие не доступно. Точные границы (например, может ли HR редактировать чужой тест) уточняются на этапе требований.
|
||||
|
||||
## 2.5. Жизненный цикл версии теста и поведение при активных прохождениях
|
||||
|
||||
Версионирование уже сделано правильно — оно фиксирует, какую именно версию проходил сотрудник, и не ломает прошлые результаты. Но в UI нужно явно показать состояния и поведение при апдейте.
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ Черновик │ ← автор может править свободно,
|
||||
└────┬─────┘ назначения нельзя выдать
|
||||
│
|
||||
«Опубликовать как v2»
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ Активная │ ← новые назначения идут на эту версию;
|
||||
└────┬─────┘ уже идущие прохождения остаются на старой
|
||||
│
|
||||
«Опубликовать как v3»
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ Архив │ ← новые назначения нельзя; старые
|
||||
└──────────┘ прохождения видны в отчётах
|
||||
```
|
||||
|
||||
Что должно быть видно в UI:
|
||||
|
||||
- **Бейдж версии** рядом с названием теста: `Введение про LLM · v2 (активна)`.
|
||||
- **На странице редактирования** — явно: «Редактируется черновик v3 на основе активной v2».
|
||||
- **При публикации новой версии** — диалог: «Сейчас тест проходят 12 сотрудников на v2. Они закончат на v2; новые назначения пойдут на v3. Опубликовать v3?»
|
||||
- **В отчётах** — фильтр по версии теста.
|
||||
- **В назначении** — версия зафиксирована: «Назначен на тесте Введение про LLM v2».
|
||||
|
||||
## 2.6. Состояние «черновик» страницы теста
|
||||
|
||||
Сейчас единственная кнопка — «Сохранить черновик». Лучше добавить два глагола:
|
||||
|
||||
- **«Сохранить черновик»** — сохранить промежуточно, не публиковать. Не создаёт новой версии.
|
||||
- **«Опубликовать как новую версию»** — фиксирует версию, делает её активной, открывает диалог из 2.5.
|
||||
|
||||
Тогда жёлтый баннер из C-1 превращается в осмысленную подсказку: он показывается **только при наличии несохранённых изменений** и говорит «Чтобы изменения попали в назначения — опубликуйте новую версию».
|
||||
|
||||
---
|
||||
|
||||
# Часть 3. Чеклист изменений
|
||||
|
||||
Разбит на три волны по приоритету и независимости работ.
|
||||
|
||||
## Волна 1 — быстрые правки на текущей странице (1–2 спринта)
|
||||
|
||||
Не требуют структурных изменений, можно делать параллельно с разработкой новой IA:
|
||||
|
||||
- [ ] **C-1** Скрыть баннер версионирования при отсутствии изменений (dirty state).
|
||||
- [ ] **C-2** Confirm-диалог + прогресс для ИИ-генерации, undo последнего результата.
|
||||
- [ ] **C-3** Sticky-панель «Сохранить» внизу + предупреждение `beforeunload` при unsaved changes.
|
||||
- [ ] **M-1** Аккордеон «Вопросы» открыт по умолчанию.
|
||||
- [ ] **M-2** Валидация порога зачёта (1–100, целое число).
|
||||
- [ ] **M-3** Поясняющий текст для «Несколько верных ответов».
|
||||
- [ ] **M-5** Confirm + undo для «Удалить вопрос».
|
||||
- [ ] **M-6** Длинные варианты — `textarea` с автовысотой.
|
||||
- [ ] **M-7** Перевод ENUM-значений статусов прохождения.
|
||||
- [ ] **M-9** Toggle-switch для «Видимость» вместо одной кнопки.
|
||||
- [ ] **m-1, m-2, m-3** Косметика: логотип логина, роль, опечатка title.
|
||||
|
||||
## Волна 2 — выделение разделов (новая IA)
|
||||
|
||||
- [ ] Выделить раздел «Назначения» с собственной таблицей и фильтрами.
|
||||
- [ ] Перенести «Кому выдать» со страницы теста в модалку «Назначить» из 2.3.
|
||||
- [ ] Выделить раздел «Отчёты» из секции «История», расширить фильтрами и агрегатами.
|
||||
- [ ] Реализовать ролевую модель из 2.4 (RBAC): меню, разделы и действия зависят от роли.
|
||||
- [ ] Реализовать жизненный цикл версии (2.5) и явную публикацию.
|
||||
|
||||
## Волна 3 — масштабирование и качество
|
||||
|
||||
- [ ] Виртуализация списков сотрудников (поддержка 5 000+).
|
||||
- [ ] Drag-and-drop для перестановки вопросов (M-4).
|
||||
- [ ] Drag-and-drop загрузка файла с превью результата (m-5).
|
||||
- [ ] Аудит доступности (WCAG 2.2 AA): клавиатурная навигация, focus-ring, контрастность.
|
||||
- [ ] Адаптивная вёрстка для мобильных и планшетов.
|
||||
- [ ] Уведомления (e-mail, в системе) для назначений, дедлайнов, результатов.
|
||||
- [ ] Связка с курсами/треками (когда появятся).
|
||||
|
||||
---
|
||||
|
||||
# Что дальше
|
||||
|
||||
После согласования этого документа имеет смысл:
|
||||
|
||||
1. Сделать кликабельный прототип в Figma на 2 ключевых сценария: «автор создаёт и сразу назначает тест», «HR назначает существующий тест 200 сотрудникам». Это покажет, как именно ложится новая IA на реальные действия и где остались дыры.
|
||||
2. Прогнать прототип на 2–3 пользователях каждой роли (автор, HR, руководитель) — модерируемое юзабилити-тестирование на 30–40 минут. По итогам — финальные правки до старта разработки.
|
||||
3. Параллельно запустить Волну 1 — она независима от IA и сразу снимает большую часть пользовательской боли.
|
||||
|
||||
---
|
||||
|
||||
*Если по какому-то пункту нужно больше деталей или хочется, чтобы я подготовил визард-сценарий «Назначить» в виде последовательных макетов — скажите.*
|
||||
@@ -0,0 +1,220 @@
|
||||
# Спринты по bugfix-задачам (Тесты / Доступ / Генерация)
|
||||
|
||||
Дата: 2026-04-30
|
||||
Контур: `flask_app` (UI + API + сервисы генерации)
|
||||
|
||||
## Цели пакета
|
||||
|
||||
1. Дать доступ всем авторизованным пользователям ко всем активным тестам (без назначений).
|
||||
2. Привести поведение шаблона генерации теста к ожидаемому (кол-во вопросов/вариантов/правильных ответов).
|
||||
3. Добавить массовые настройки «несколько вариантов ответа».
|
||||
4. Улучшить поток работы с подсказками и прозрачность прогресса генерации.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 1 — Доступ всем авторизованным (без назначений)
|
||||
|
||||
### Объём
|
||||
|
||||
- Убрать зависимость прохождения теста от назначений (`TestAssignment*`).
|
||||
- Разрешить доступ к активным тестам для любого авторизованного пользователя.
|
||||
- Проверить, что каталог и старт попытки работают консистентно с новой политикой.
|
||||
|
||||
### Задачи
|
||||
|
||||
1. **Политика доступа**
|
||||
- В `user_has_test_access` вернуть `ok=True` для любого активного теста, если тест существует и пользователь авторизован.
|
||||
- Оставить проверки авторства только там, где они нужны для редактора/версий/админских действий.
|
||||
|
||||
2. **Проверка API прохождения**
|
||||
- `start_attempt`, `play`, `submit`, `review` не должны требовать назначения на пользователя.
|
||||
- Ошибка «Доступ запрещён» не возникает для обычного авторизованного сотрудника при прохождении активного теста.
|
||||
|
||||
### Критерии приёмки
|
||||
|
||||
- Любой авторизованный пользователь может открыть и пройти любой активный тест, даже без назначения.
|
||||
- При этом операции автора (редактор, версии, массовые действия) остаются ограничены автором.
|
||||
|
||||
### Оценка
|
||||
|
||||
- 0.5 дня.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 2 — Шаблон генерации: контракт и предсказуемость
|
||||
|
||||
### Проблема
|
||||
|
||||
Сейчас пользователь создаёт шаблон (например, 12×4), затем генерирует из документа и получает иной результат (например, 10×3). Ожидания и фактический контракт не совпадают.
|
||||
|
||||
### Объём
|
||||
|
||||
- Зафиксировать единый контракт: какие параметры шаблона обязательны для генератора.
|
||||
- Принудительно соблюдать:
|
||||
- количество вопросов,
|
||||
- количество вариантов в вопросе,
|
||||
- границы количества правильных ответов.
|
||||
- Добавить валидацию и пост-проверку результата генерации.
|
||||
|
||||
### Задачи
|
||||
|
||||
1. **Формальный контракт шаблона**
|
||||
- Явно определить обязательные поля shape:
|
||||
- `questions_count`
|
||||
- `options_per_question`
|
||||
- `multiple_answers_default`
|
||||
- `correct_answers_min/max`
|
||||
- Хранить shape вместе с тестом/версией как источник истины.
|
||||
|
||||
2. **Генерация по документу с shape**
|
||||
- Передавать shape в генератор при `generate from document`.
|
||||
- После генерации валидировать фактическую структуру.
|
||||
- При расхождении:
|
||||
- либо автоматически нормализовать (добить/сжать до нужного формата),
|
||||
- либо показать понятную ошибку и не сохранять черновик.
|
||||
|
||||
3. **Пользовательская обратная связь**
|
||||
- На экране до запуска показывать «Будет сгенерировано: 12 вопросов, 4 варианта».
|
||||
- После завершения показывать фактический итог и предупреждение, если пришлось авто-нормализовать.
|
||||
|
||||
### Критерии приёмки
|
||||
|
||||
- Для шаблона 12×4 итоговый тест всегда 12 вопросов по 4 варианта.
|
||||
- Несоответствие не проходит «тихо»: либо авто-исправление с уведомлением, либо явная ошибка.
|
||||
|
||||
### Оценка
|
||||
|
||||
- 1.5–2.5 дня.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 3 — Массовый контроль «Несколько вариантов ответа»
|
||||
|
||||
### Объём
|
||||
|
||||
- Добавить глобальный чекбокс «Несколько вариантов ответа» (для всех вопросов).
|
||||
- В шаблоне добавить диапазон правильных ответов: `от _ до _`.
|
||||
- При отключении мультивыбора на конкретном вопросе нижняя граница «от» фиксируется в `1`.
|
||||
|
||||
### Задачи
|
||||
|
||||
1. **UI шаблона**
|
||||
- Глобальный switch/checkbox: «Несколько вариантов ответа для всех вопросов».
|
||||
- Поля диапазона:
|
||||
- `Мин. правильных` (от),
|
||||
- `Макс. правильных` (до),
|
||||
- валидация `1 <= min <= max <= options_per_question`.
|
||||
|
||||
2. **Применение к вопросам**
|
||||
- При включении глобального флага обновлять все вопросы:
|
||||
- `hasMultipleAnswers=true`.
|
||||
- При выключении:
|
||||
- `hasMultipleAnswers=false`,
|
||||
- `minCorrect=1`, `maxCorrect=1`.
|
||||
- На уровне отдельного вопроса разрешить override.
|
||||
|
||||
3. **Правило «заморозки min=1»**
|
||||
- Если на вопросе `hasMultipleAnswers=false`, то:
|
||||
- `minCorrect` автоматически = `1`,
|
||||
- поле `minCorrect` read-only/disabled.
|
||||
|
||||
4. **Серверная валидация**
|
||||
- API сохраняет/проверяет тот же инвариант.
|
||||
- Невалидные комбинации отклоняются с понятным сообщением.
|
||||
|
||||
### Критерии приёмки
|
||||
|
||||
- Глобальный флаг влияет на все вопросы.
|
||||
- Локальное отключение мультивыбора фиксирует `min=1`.
|
||||
- Генератор и редактор работают в одинаковой логике, без рассинхрона.
|
||||
|
||||
### Оценка
|
||||
|
||||
- 1.5–2 дня.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 4 — Подсказки и прогресс генерации
|
||||
|
||||
### Объём
|
||||
|
||||
- В параметрах теста при включённых подсказках показать действие «Сгенерировать подсказки».
|
||||
- Генерация подсказок работает только по заполненным вопросам.
|
||||
- Если тест генерируется с нуля и подсказки уже включены — подсказки генерируются в том же пайплайне.
|
||||
- Добавить прогресс по этапам генерации + локальные индикаторы загрузки в соответствующих блоках UI.
|
||||
|
||||
### Задачи
|
||||
|
||||
1. **Кнопка/ссылка «Сгенерировать подсказки»**
|
||||
- Показ только при `hintsEnabled=true`.
|
||||
- Показ количества: `N без подсказок`.
|
||||
- Запуск только по вопросам, где есть текст + варианты.
|
||||
|
||||
2. **Пайплайн генерации «с нуля»**
|
||||
- Если `hintsEnabled=true`, после генерации вопросов автоматически запускать генерацию подсказок.
|
||||
- Ошибки подсказок не должны ломать весь тест: частичный результат допустим с отчётом.
|
||||
|
||||
3. **Прогресс и статусы**
|
||||
- Этапы:
|
||||
- подготовка документа,
|
||||
- извлечение текста,
|
||||
- генерация структуры,
|
||||
- генерация вопросов,
|
||||
- генерация подсказок,
|
||||
- финализация.
|
||||
- В UI показывать текущий этап и процент/счётчик.
|
||||
- Спиннеры показывать только у активного блока (не глобально на всю форму).
|
||||
|
||||
4. **Наблюдаемость**
|
||||
- Логи этапов и длительности.
|
||||
- В ответ API возвращать breakdown по шагам/ошибкам.
|
||||
|
||||
### Критерии приёмки
|
||||
|
||||
- Пользователь видит, что именно сейчас генерируется.
|
||||
- Подсказки отдельно запускаются и генерируются только для валидных вопросов.
|
||||
- При генерации «с нуля» с включёнными подсказками подсказки появляются автоматически.
|
||||
|
||||
### Оценка
|
||||
|
||||
- 2–3 дня.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 5 — Регрессия, UX-полировка, выпуск
|
||||
|
||||
### Объём
|
||||
|
||||
- Сквозное тестирование сценариев.
|
||||
- Документация для пользователей и команды.
|
||||
- Подготовка релиз-нота.
|
||||
|
||||
### Тест-кейсы (минимум)
|
||||
|
||||
1. Неавторизованный пользователь открывает приватные URL → редирект на логин.
|
||||
2. Каталог тестов: есть переход «На главную».
|
||||
3. Шаблон 12×4 + генерация из PDF → на выходе 12×4.
|
||||
4. Глобальный мультивыбор + локальное выключение на 1 вопросе → `min=1` на этом вопросе.
|
||||
5. Включены подсказки:
|
||||
- кнопка «Сгенерировать подсказки» доступна,
|
||||
- генерируются только по заполненным вопросам.
|
||||
6. Генерация с нуля + подсказки включены → подсказки сгенерированы в том же запуске.
|
||||
7. Прогресс этапов отображается корректно, загрузка локальная.
|
||||
|
||||
### Оценка
|
||||
|
||||
- 1–1.5 дня.
|
||||
|
||||
---
|
||||
|
||||
## Приоритеты
|
||||
|
||||
- **P0:** Спринт 1 (доступ всем авторизованным), критичный функциональный bugfix.
|
||||
- **P1:** Спринт 2 (контракт шаблона), устранение основного функционального несоответствия.
|
||||
- **P1:** Спринт 3 (массовый мультивыбор), важная продуктовая логика.
|
||||
- **P2:** Спринт 4 (подсказки + прогресс), прозрачность и удобство.
|
||||
- **P2:** Спринт 5 (регрессия + выпуск).
|
||||
|
||||
## Суммарная оценка
|
||||
|
||||
Ориентир: **6.5–10 рабочих дней** (в зависимости от объёма автотестов и глубины рефакторинга генератора).
|
||||
@@ -1,10 +0,0 @@
|
||||
# Иллюстрации к разделу «Как пользоваться»
|
||||
|
||||
Сейчас здесь **SVG-схемы** — их видно в Git и в просмотрщике Markdown.
|
||||
Чтобы подставить **скриншоты** с экрана мини-приложения:
|
||||
|
||||
1. Сделайте 2–3 скриншота (список, низ с «Сохранить», блок «Показ / назначения»).
|
||||
2. Сохраните в эту папку, например: `spisok.png`, `chernovik.png`, `pokaz.png`.
|
||||
3. В файле `docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md` замените в картинках `placeholder-*.svg` на ваши имена `*.png` (или оставьте SVG).
|
||||
|
||||
Имена в Markdown сейчас: `placeholder-spisok.svg`, `placeholder-chernovik.svg`, `placeholder-pokaz.svg`.
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200" role="img" aria-label="!E5<0: A>E@0=5=85">
|
||||
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
|
||||
<text x="180" y="36" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">=87C «!>E@0=8BL G5@=>28:»</text>
|
||||
<text x="180" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#5c6b69">A>E@0=O5B 2AQ, GB> =0?8A0;8 2 2>?@>A0E</text>
|
||||
<rect x="32" y="100" width="296" height="40" rx="8" fill="#0d5c54"/>
|
||||
<text x="180" y="125" text-anchor="middle" font-family="system-ui,sans-serif" font-size="12" font-weight="600" fill="#fff">!>E@0=8BL G5@=>28:</text>
|
||||
<rect x="32" y="150" width="296" height="32" rx="8" fill="#fff" stroke="#0d5c54"/>
|
||||
<text x="180" y="170" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#0d5c54"> A?8A:C</text>
|
||||
</svg>
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="180" viewBox="0 0 360 180" role="img" aria-label="!E5<0: 2848<>ABL 8 =07=0G5=8O">
|
||||
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
|
||||
<text x="180" y="32" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">>:07 2 :0B0;>35</text>
|
||||
<text x="32" y="64" font-family="system-ui,sans-serif" font-size="11" fill="#1a1a1a">· 848<>ABL 2 >1I5< A?8A:5 8;8 A:@KB</text>
|
||||
<text x="32" y="88" font-family="system-ui,sans-serif" font-size="11" fill="#1a1a1a">· ><C 2K40BL A>B@C4=8:8 (5A;8 2:;NG5=>)</text>
|
||||
<text x="32" y="118" font-family="system-ui,sans-serif" font-size="10" fill="#5c6b69">07=0G5=85 = 2K40BL B5AB 2 @01>BC, =5 ?CB09B5 A «A>E@0=8BL»</text>
|
||||
<text x="32" y="148" font-family="system-ui,sans-serif" font-size="10" fill="#5c6b69">«K1@0BL 2A5E» = B>;L:> 2 B5:CI5< >BD8;LB@>20==>< A?8A:5</text>
|
||||
</svg>
|
||||
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200" role="img" aria-label="!E5<0: A?8A>: B5AB>2">
|
||||
<rect width="100%" height="100%" fill="#f8faf9" stroke="#cfe8e3"/>
|
||||
<text x="180" y="32" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="#0d5c54">!?8A>: B5AB>2</text>
|
||||
<text x="180" y="54" text-anchor="middle" font-family="system-ui,sans-serif" font-size="11" fill="#5c6b69">:0@B>G:0 ’ =06<8B5 =0 =0720=85 8;8 «@>9B8»</text>
|
||||
<rect x="24" y="72" width="312" height="44" rx="10" fill="#fff" stroke="#b8d4cf"/>
|
||||
<rect x="24" y="124" width="312" height="44" rx="10" fill="#fff" stroke="#b8d4cf"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,291 @@
|
||||
# Инвентаризация backend и справочный gap-analysis с `tgFlaskForm`
|
||||
|
||||
**Назначение.** §1–§9 — полная карта того, что **сейчас живёт** в `backend/` (Node.js / Express). Используется в [Этапе 1](migration-final.md#этап-1-текущий--единый-стек-express--flask-react--jinja-внутри-testingwebapp) как чек-лист «что переписать на Flask внутри `flask_app/`». §10 — **справочный gap-analysis** между этим и уже готовым модулем `cabinet/testing` в `HR_TG_Bot/tgFlaskForm` — пригодится в [Этапе 2](migration-to-tgflaskform.md) при слиянии.
|
||||
|
||||
**Связано:** [migration-final.md](migration-final.md) (главный трекер двух этапов), [migration-to-tgflaskform.md](migration-to-tgflaskform.md) (план Этапа 2 — слияние с tgFlaskForm), [PROJECT_STATUS.md](PROJECT_STATUS.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
|
||||
|
||||
> **Прочитайте сначала [migration-final.md](migration-final.md)** — там зафиксировано разделение на Этап 1 (унификация стека внутри TestingWebApp, БД остаётся `clinic_tests`) и Этап 2 (слияние с HR-кабинетом, БД мигрирует на `hr_bot_test`). Содержимое §10 этого документа — материал для Этапа 2.
|
||||
|
||||
---
|
||||
|
||||
## 1. Карта HTTP-эндпоинтов (всё, что зовёт фронтенд)
|
||||
|
||||
Источник: `backend/src/app.js`, `backend/src/routes/*.js`. Перепроверено по фактическим вызовам из `frontend/src/**` (`frontend/src/api.js` — общий `fetch` с `credentials: 'include'`).
|
||||
|
||||
| # | Метод | Путь | Зовёт из фронта | Auth | Сервис(ы) | Особенности |
|
||||
|---|------|------|----------------|------|----------|-------------|
|
||||
| 1 | GET | `/api/health` | — | нет | inline | smoke-проверка |
|
||||
| 2 | POST | `/api/auth/login` | `Login.jsx` | нет | `utils/auth`, `db/db`, `db/hrPool`, `utils/werkzeugPassword`, `utils/hrRoleMap`, `config/authConstants` | bcrypt (dev) **+** Werkzeug (HR) ветки; UPSERT `users` по `staff_id`; кладёт JWT в HTTP-only cookie |
|
||||
| 3 | POST | `/api/auth/logout` | `CabinetLayout.jsx` | нет | inline | очистить cookie |
|
||||
| 4 | GET | `/api/auth/me` | `CabinetLayout.jsx` | cookie JWT | `middleware/auth` + `featureFlags` | возвращает пользователя + флаги UI (`devUi`, `assignmentUi`) |
|
||||
| 5 | GET | `/api/auth/dev/assignment-directory` | `TestDetail.jsx` (assign-блок) | cookie JWT, feature-flag | `services/assignmentDirectoryService` | сливает HR (`staff_members`, `employees_departments`) + `clinic_tests.users`; query: `q`, `department`, `clinic` |
|
||||
| 6 | POST | `/api/tests/import/document` | `TestDetail.jsx` («Документ в вопросы») | cookie JWT | `services/documentExtractService` (PDF/DOCX/TXT), `services/documentGenService` → `services/llmClient` | `multer` (10 МБ, OS tmpdir); удаляет файл после извлечения |
|
||||
| 7 | GET | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testAccessService.queryTestsVisibleToUser` + inline (hiddenByYou) | каталог + список «скрытые мной» |
|
||||
| 8 | POST | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testDraftService.createTestWithVersion` | создаёт цепочку + версию 1 |
|
||||
| 9 | GET | `/api/tests/:id/summary` | `TestDetail.jsx` | cookie JWT | inline + `testAccessService.userHasTestAccess` | карточка цепочки (одна строка) |
|
||||
| 10 | GET | `/api/tests/:id/versions` | `TestDetail.jsx` | cookie JWT, **только автор** | inline + `testChainService.hasAnyAttemptForTest` | список версий + флаг `hasAttempts` |
|
||||
| 11 | GET | `/api/tests/:id/editor` | `TestDetail.jsx` | cookie JWT, **только автор** | `testAttemptService.getEditorContent` | вопросы активной версии **с правильными ответами** |
|
||||
| 12 | POST | `/api/tests/:id/ai/generate-test` | `TestDetail.jsx` (ИИ — целиком) | cookie JWT, **только автор** | `aiEditorService.generateFullTestByShape` → `llmClient` | строгая сетка `shape: [{optionsCount, hasMultipleAnswers}]`; до 40 вопросов |
|
||||
| 13 | POST | `/api/tests/:id/ai/generate-question` | `TestDetail.jsx` (ИИ — один вопрос) | cookie JWT, **только автор** | `aiEditorService.generateOrRephraseQuestion` → `llmClient` | пустой текст → новый вопрос; непустой → переформулировка |
|
||||
| 14 | POST | `/api/tests/:id/versions/:vid/activate` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | транзакция; снимает `is_active` со всех версий, потом ставит на `:vid` |
|
||||
| 15 | PATCH | `/api/tests/:id` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | `chainActive` (true/false) — публикация в каталоге |
|
||||
| 16 | POST | `/api/tests/:id/assign` | `TestDetail.jsx` (назначение) | cookie JWT, **только автор**, feature-flag | `assignmentUserService.ensureClinicUserIdForStaff` | принимает `userIds[]`/`staffIds[]`/legacy `userId`/`staffId`; одна строка `test_assignments` + N строк `test_assignment_targets` |
|
||||
| 17 | POST | `/api/tests/:id/draft` | `TestDetail.jsx` («Сохранить») | cookie JWT, **только автор** | `testDraftService.saveTestDraft` | если есть попытки и переданы `questions` — fork новой версии (V.3) |
|
||||
| 18 | POST | `/api/tests/:id/attempts/start` | `TestsList.jsx` | cookie JWT (доступ через `userHasTestAccess`) | `testAccessService.userHasTestAccess` + inline | новая попытка по активной версии |
|
||||
| 19 | GET | `/api/tests/:id/attempts` | `TestDetail.jsx` («Прохождения») | cookie JWT, **только автор** | `testAttemptService.listTestAttemptsForAuthor` | до 200 попыток по всем версиям |
|
||||
| 20 | GET | `/api/tests/:id/attempts/:aid/review` | `TestAttemptReview.jsx` | cookie JWT (владелец **или** автор) | `testAttemptService.getAttemptReviewForUser` → `buildReviewFromDb` | разбор попытки |
|
||||
| 21 | GET | `/api/tests/:id/attempts/:aid/play` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.getPlayContent` | вопросы **без** правильных ответов |
|
||||
| 22 | POST | `/api/tests/:id/attempts/:aid/submit` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.submitAttempt` | `FOR UPDATE`, проверка ответов, перезапись `user_answers`, статус `completed` |
|
||||
| 23 | GET | `/api/tests/:id/chain-info` | `TestDetail.jsx` | cookie JWT (через `userHasTestAccess`) | `testAccessService` + `testChainService` | флаг `hasAnyAttempt` |
|
||||
|
||||
> **Итого: 22 функциональных эндпоинта** (без `/api/health`). Все ответы — JSON. Все входы — JSON или `multipart/form-data` (только `/import/document`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Сервисный уровень (что должно появиться в Flask)
|
||||
|
||||
| Файл (Node) | Что делает | Что нужно в Python |
|
||||
|------------|-----------|---------------------|
|
||||
| `services/testAccessService.js` | Запросы каталога; `userHasTestAccess(testId, userId)` | SQLAlchemy/`psycopg2` версия двух запросов |
|
||||
| `services/testDraftService.js` | `createTestWithVersion`, `saveTestDraft`, `forkNewVersion`, `replaceVersionContent`, `copyQuestionTree` | Транзакции на `psycopg2`/SQLAlchemy; следить за частичным уникальным индексом `uq_test_versions_one_active_per_test` |
|
||||
| `services/testAttemptService.js` | `getEditorContent`, `getPlayContent`, `submitAttempt`, `buildReviewFromDb`, `getAttemptReviewForUser`, `listTestAttemptsForAuthor`; вычисление баллов | Самый объёмный модуль; внимательно с массивами UUID (`user_answers.selected_options uuid[]`) |
|
||||
| `services/testChainService.js` | `hasAnyAttemptForTest` | Один SQL `EXISTS` |
|
||||
| `services/aiEditorService.js` | `parseAndValidateShape`, `generateFullTestByShape`, `generateOrRephraseQuestion`, `assertDraftMatchesShape` | Чистый Python; зависит от `llmClient` и `documentGenService.validate*` |
|
||||
| `services/documentGenService.js` | `parseJsonFromLlmText`, `validateAndNormalizeDraft`, `generationForImportDocument` | Чистый Python (`json.loads` + валидация формы) |
|
||||
| `services/documentExtractService.js` | `resolveDocumentKind`, `extractTextFromFile`, `extractTextFromBuffer` | **Замены пакетов:** `mammoth` → `python-docx` или `mammoth.py`; `pdf-parse` → `pypdf` (или `pdfminer.six`) |
|
||||
| `services/llmClient.js` | OpenAI-совместимый Chat Completions, JSON-mode, таймаут 120 с | `httpx` / `requests` + явный `timeout` |
|
||||
| `services/assignmentDirectoryService.js` | Слияние `clinic_tests.users` ↔ HR (`staff_members`, `employees_departments`); фильтры `q/department/clinic` | Два пула; `psycopg2` достаточно |
|
||||
| `services/assignmentUserService.js` | `ensureClinicUserIdForStaff` (UPSERT по `staff_id`) | UPSERT с `ON CONFLICT (staff_id)` |
|
||||
| `utils/auth.js` | bcrypt + JWT + (Werkzeug fallback) | `passlib[bcrypt]` или `bcrypt`; **`flask-jwt-extended`** или ручной `pyjwt` |
|
||||
| `utils/werkzeugPassword.js` | `scrypt:$N:$r:$p$salt$hex`, `pbkdf2:sha256:iter$salt$hex` | Уже **родной** Python: `werkzeug.security.check_password_hash` |
|
||||
| `utils/hrRoleMap.js` | строка HR-роли → `'hr'/'manager'/'employee'` | Один `def map_hr_role()` |
|
||||
| `middleware/auth.js` | `authenticate`, `requireRole`, `requireDepartment`, `optionalAuth` | Flask-декоратор `@login_required`/`@roles_required` (или `before_request`) |
|
||||
| `config/featureFlags.js` | `isAssignmentFeatureEnabled` | Простая функция от env |
|
||||
| `config/devAuthor.js` | `isTestAuthor(createdBy, userId)` | Один сравнительный helper |
|
||||
| `config/authConstants.js` | `HR_MANAGED_PASSWORD_PLACEHOLDER`, `isHrAuthEnabled` | Без изменений |
|
||||
| `messages/ru.js` | Текстовые сообщения API | `app/messages/ru.py` (dict) |
|
||||
|
||||
> **Особое внимание:** `testAttemptService.submitAttempt` использует `FOR UPDATE` и выгружает все `answer_options` версии разом. Простую построчную проверку «правильно/нет» делает Python-функция `same_selection(set, set)`. На больших тестах помогает индекс `idx_answer_options_question_id` — он уже есть.
|
||||
|
||||
---
|
||||
|
||||
## 3. Базы данных и схемы
|
||||
|
||||
### 3.1 Основная — `clinic_tests` (`DATABASE_URL`)
|
||||
|
||||
Из `backend/src/db/migrations/001_initial.sql`:
|
||||
|
||||
- `departments` (UUID, name)
|
||||
- `users` (UUID, login UNIQUE, password_hash, full_name, role `user_role`, department_id FK, is_active, **`staff_id`** — миграция 003, **UNIQUE**)
|
||||
- `tests` (UUID, title, description, passing_threshold, time_limit, allow_back, is_active, is_versioned, created_by FK)
|
||||
- `test_versions` (UUID, test_id FK, version, is_active; миграция 002 добавляет `parent_id` + частичный уникальный индекс «одна активная версия на цепочку»)
|
||||
- `questions` (UUID, test_version_id FK, text, question_order, has_multiple_answers)
|
||||
- `answer_options` (UUID, question_id FK, text, is_correct, option_order)
|
||||
- `test_assignments` (UUID, test_version_id FK, assigned_by FK, deadline DATE, max_attempts)
|
||||
- `test_assignment_targets` (UUID, assignment_id FK, target_type `'department'|'user'`, target_id UUID)
|
||||
- `test_attempts` (UUID, test_version_id FK, user_id FK, attempt_number, status `attempt_status`, started_at, completed_at, correct_count, total_questions, passed; UNIQUE(test_version_id, user_id, attempt_number))
|
||||
- `user_answers` (UUID, attempt_id FK, question_id FK, selected_options **UUID[]**)
|
||||
- `migrations` (служебная, имена применённых SQL)
|
||||
|
||||
**Расширения:** `uuid-ossp`. **Кастомные типы:** `user_role`, `target_type`, `attempt_status`.
|
||||
|
||||
### 3.2 Дополнительная — `hr_bot_test` (`HR_DATABASE_URL`, опционально)
|
||||
|
||||
Используется только на чтение через `db/hrPool.js`:
|
||||
|
||||
- `users` — для входа (HR_AUTH=1): `id`, `username`, `password_hash` (Werkzeug), `role`
|
||||
- `staff_members` — `id`, `fio`, `web_login`
|
||||
- `employees_departments` — `staff_id`, `department`
|
||||
|
||||
### 3.3 Миграции
|
||||
|
||||
3 SQL-файла в `backend/src/db/migrations/`. Простой `npm run migrate` пишет в служебную таблицу `migrations`. В Flask эквивалент: **Alembic** или ручной runner на `psycopg2`. Файлы `.sql` можно переиспользовать **как есть** — никакого Node-специфичного синтаксиса в них нет.
|
||||
|
||||
---
|
||||
|
||||
## 4. Зависимости Node → Python (план замены)
|
||||
|
||||
| Node-пакет | Python-замена | Комментарий |
|
||||
|-----------|----------------|-------------|
|
||||
| `express`, `cors`, `cookie-parser` | **Flask** + `flask-cors` | сессии встроены |
|
||||
| `multer` | `Flask` (`request.files`) + `werkzeug.utils.secure_filename` + `tempfile` | лимит 10 МБ — `MAX_CONTENT_LENGTH` |
|
||||
| `dotenv` | `python-dotenv` (уже есть в `flask_app/run.py`) | — |
|
||||
| `pg` | `psycopg2-binary` (или `psycopg[binary]` v3) | — |
|
||||
| `bcryptjs` | `bcrypt` (`pip install bcrypt`) | формат `$2b$…` совместим |
|
||||
| `jsonwebtoken` | `pyjwt` или `flask-jwt-extended` | важен **тот же** алгоритм/секрет, чтобы старые cookie работали в переходный период |
|
||||
| `mammoth` | `mammoth` (PyPI: `pip install mammoth`) или `python-docx` | API почти идентичен |
|
||||
| `pdf-parse` | `pypdf` (`pip install pypdf`) или `pdfminer.six` | `pypdf.PdfReader().pages[].extract_text()` |
|
||||
| `fetch` (LLM) | `httpx` (рекомендую — есть `timeout`, async) или `requests` | сохранить `Authorization: Bearer …`, `response_format: json_object` |
|
||||
|
||||
**Уже в `tgFlaskForm` есть готовое** — `werkzeug.security.check_password_hash` (бесплатно), Sentry, Jinja2-шаблоны, обмен с `staff_members`. Переиспользовать, если выберем сценарий «общий кабинет».
|
||||
|
||||
---
|
||||
|
||||
## 5. Переменные окружения (полный список)
|
||||
|
||||
| Переменная | Где читается | Назначение | Обязательная |
|
||||
|-----------|--------------|-----------|--------------|
|
||||
| `NODE_ENV` | `app.js`, `auth.js`, `featureFlags.js`, `db.js` | dev vs production | да (нет → dev) |
|
||||
| `PORT` | `server.js` | Express, по умолчанию 3001 | нет |
|
||||
| `FRONTEND_URL` | `app.js` (CORS) | разрешённый origin в prod | в prod — да |
|
||||
| `DATABASE_URL` **или** `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASSWORD` | `db/poolConfig.js` | основная БД `clinic_tests` | да |
|
||||
| `DB_POOL_MAX`, `DB_IDLE_TIMEOUT`, `DB_CONNECTION_TIMEOUT` | `db/poolConfig.js` | пул | нет |
|
||||
| `HR_DATABASE_URL` | `db/poolConfig.js` (`getHrPoolConfig`) | hr_bot_test (read-only) | при HR_AUTH=1 — да |
|
||||
| `HR_DB_POOL_MAX` | то же | пул HR | нет |
|
||||
| `HR_AUTH` (1/true) | `config/authConstants.js` | вход по HR-логину | нет |
|
||||
| `JWT_SECRET` | `utils/auth.js` | подпись JWT | **да** |
|
||||
| `JWT_EXPIRES_IN` | `utils/auth.js` | дефолт `7d` | нет |
|
||||
| `DEEPSEEK_API_KEY` | `services/llmClient.js` | LLM, приоритет №1 | для AI/импорта — да |
|
||||
| `OPENAI_API_KEY` | то же | LLM, приоритет №2 | альтернатива |
|
||||
| `LLM_BASE_URL` | то же | сменить хост (proxy / vLLM) | нет |
|
||||
| `LLM_MODEL` | то же | по умолчанию `deepseek-chat` или `gpt-4o-mini` | нет |
|
||||
| `LLM_NO_JSON` (1) | то же | отключить `response_format: json_object` (для моделей без поддержки) | нет |
|
||||
| `CLINIC_ASSIGNMENT_ENABLED` (1) | `config/featureFlags.js` | прод: включить assign | в prod — да |
|
||||
|
||||
В `flask_app/.env` нужно перенести **те же ключи** (имена можно сохранить, чтобы не плодить вариации).
|
||||
|
||||
---
|
||||
|
||||
## 6. Что вызывает фронтенд (карта зависимостей React → API)
|
||||
|
||||
Используется один тонкий клиент `frontend/src/api.js`: `fetch` с `credentials: 'include'`, базовый путь — пустой (т.е. **`/api/...` относительно текущего origin**, что в dev резолвит Vite-proxy, а в prod — Nginx). Это значит:
|
||||
|
||||
- **Менять фронтенд при смене бэкенда не нужно**, если новый сервис отвечает по тем же путям.
|
||||
- В dev сейчас `vite.config.js` проксирует `/api` на Express (`localhost:3001`). После переноса — заменить адрес/порт на Flask (см. `flask_app/run.py`, по умолчанию `3107`).
|
||||
|
||||
Список путей (отсортирован по убыванию использований):
|
||||
|
||||
```
|
||||
/api/auth/login
|
||||
/api/auth/logout
|
||||
/api/auth/me
|
||||
/api/auth/dev/assignment-directory?q&department&clinic
|
||||
/api/tests
|
||||
/api/tests/:id/summary
|
||||
/api/tests/:id/versions
|
||||
/api/tests/:id/editor
|
||||
/api/tests/:id/chain-info
|
||||
/api/tests/:id/draft
|
||||
/api/tests/:id/assign
|
||||
/api/tests/:id/ai/generate-test
|
||||
/api/tests/:id/ai/generate-question
|
||||
/api/tests/:id (PATCH)
|
||||
/api/tests/:id/versions/:vid/activate
|
||||
/api/tests/:id/attempts
|
||||
/api/tests/:id/attempts/start
|
||||
/api/tests/:id/attempts/:aid/play
|
||||
/api/tests/:id/attempts/:aid/review
|
||||
/api/tests/:id/attempts/:aid/submit
|
||||
/api/tests/import/document
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Тесты, которые тянутся за собой
|
||||
|
||||
В `backend/src/**/*.test.js` (Node test runner):
|
||||
|
||||
- `apiSmoke.test.js` — smoke-проверки HTTP.
|
||||
- `services/testChainService.test.js`
|
||||
- `services/aiEditorService.test.js`
|
||||
- `services/documentGenService.test.js`
|
||||
- `services/documentExtractService.test.js`
|
||||
- `utils/werkzeugPassword.test.js`
|
||||
- `integration/v9card1.test.js` (требует `CLINIC_TESTS_INTEGRATION=1`)
|
||||
|
||||
После переноса нужны их Python-аналоги (`pytest`). Часть (валидация LLM-формы, разбор Werkzeug) тривиально превращается в юниты.
|
||||
|
||||
---
|
||||
|
||||
## 8. Чего сейчас в `flask_app/` НЕТ (чтобы не повторяться в Спринте 2)
|
||||
|
||||
`flask_app/` содержит только: `run.py`, `app/__init__.py` (с `/health` и `/`), `app/templates/`, `app/static/`. Не реализовано **ничего** из перечня §1–§4. Это значит, что в Спринте 2 нужно с нуля поднять:
|
||||
|
||||
1. Подключение к БД (минимум — основной пул `clinic_tests`).
|
||||
2. Декоратор аутентификации (cookie JWT) и эндпоинты `/api/auth/*`.
|
||||
3. Роуты `/api/tests/*` поэтапно — начать с **read-only** (list, summary, versions, editor, attempts, review), потом write (draft, activate, patch, assign, attempts/start/submit), потом AI/import.
|
||||
4. CORS (в dev совпадает с тем, что у Express).
|
||||
5. Запуск под Vite/Nginx — обновить proxy/upstream.
|
||||
|
||||
---
|
||||
|
||||
## 9. Критерии «можно удалять `backend/`» (Спринт 4)
|
||||
|
||||
- ETL-скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` прогнан **на копии прод**, агрегаты совпадают (тесты, версии, вопросы, попытки) — затем прогон на проде в окне.
|
||||
- Все сценарии §1 (TestingWebApp) либо имеют **рабочий аналог** в `cabinet/testing/`, либо явно **признаны не-MVP** (см. §10).
|
||||
- Внутренние ссылки и закладки сотрудников переключены на URL общего кабинета (`/cabinet/testing/...`).
|
||||
- Документация (`README.md`, `DEV_CONTOUR_USER_GUIDE.md`, `шаги/*`) перестала ссылаться на `backend/` и `frontend/` TestingWebApp.
|
||||
- Резерв: ветка `legacy/clinic-tests-node` (или `legacy/express-backend`) зафиксирована перед удалением.
|
||||
|
||||
---
|
||||
|
||||
## 10. Gap-analysis: `tgFlaskForm/cabinet/testing` vs Express
|
||||
|
||||
Сверка проведена по фактическому коду `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/*` и `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` на 2026-04-27. Колонка «Что нужно сделать» — это и есть остаток Спринта 2 из [migration-to-tgflaskform.md](migration-to-tgflaskform.md).
|
||||
|
||||
### 10.1 Маппинг сущностей и ID
|
||||
|
||||
| TestingWebApp (`clinic_tests`) | tgFlaskForm (`hr_bot_test`, схема `testing_*`) | Замечание |
|
||||
|---|---|---|
|
||||
| `tests` (UUID) | `testing_tests` (Integer PK) | UUID → integer; маппинг хранится в `_clinic_tests_migration_map` |
|
||||
| `test_versions` (UUID, `version`, `is_active`, `parent_id`) | `testing_test_versions` (Integer, `version_number`, `is_active_version`) — **+** `passing_score_percent`, `time_limit_minutes`, `allow_back_navigation` (нормализовано на версию) | В Express часть полей лежала на `tests` (`passing_threshold`, `time_limit`, `allow_back`); ETL поднимает их на версию |
|
||||
| `questions` (`text`, `has_multiple_answers`, `question_order`) | `testing_questions` (`question_text`, `question_type` `'single'\|'multiple'`, `sort_order`) | `has_multiple_answers: true` ↔ `question_type='multiple'` |
|
||||
| `answer_options` (`text`, `is_correct`, `option_order`) | `testing_answers` (`answer_text`, `is_correct`, `sort_order`) | прямой маппинг |
|
||||
| `users` + `users.staff_id` | `staff_members.id` напрямую | Express держит «свою» строку `users` со ссылкой на `staff_id`; в HR — только `staff_members` |
|
||||
| `test_assignments` + `test_assignment_targets` (target_type='user', target_id=UUID) | `testing_assignments` (одна строка = одна пара тест×сотрудник, `assigned_to=staff_id`) | **Существенное расхождение модели** — см. §10.3 |
|
||||
| `test_attempts` (привязан к `test_version_id`, `user_id`) | `testing_attempts` (привязан к `assignment_id` + `test_version_id`, попытка от **сотрудника**) | Если в clinic попытка не имеет связанного assignment — ETL создаёт синтетическое назначение (`max_attempts=99`) |
|
||||
| `user_answers` (`selected_options uuid[]`) | `testing_attempt_answers` (одна строка на каждый выбранный `answer_id`) | Развёртка массива в N строк |
|
||||
| (нет аналога) | `testing_settings` (key-value), `testing_head_positions` (право назначения по должности) | в Express не было — RBAC проще |
|
||||
|
||||
### 10.2 Эндпоинты Express → роуты `tgFlaskForm`
|
||||
|
||||
| # | Express | Соответствие в `tgFlaskForm` | Статус | Что сделать |
|
||||
|---|---------|------------------------------|--------|-------------|
|
||||
| 1 | `POST /api/auth/login` | `webApp/auth/*` (общая сессия HR) | ✅ есть аналог | Express-сессии не переносим; пользователи входят как обычно в HR-кабинет |
|
||||
| 2 | `POST /api/auth/logout` | `webApp/auth/*` | ✅ — | — |
|
||||
| 3 | `GET /api/auth/me` | session + `helpers._get_staff_id` | ✅ — | — |
|
||||
| 4 | `GET /api/auth/dev/assignment-directory` | `routes_assignments.assign_form` (страница) + `testing_get_employees_for_assignment` | ✅ — | На UI: добавить «Выбрать всех» (см. Спринт 3 React-кабинета), мульти-фильтры |
|
||||
| 5 | `GET /api/health` | `webApp/__init__.py` | ✅ — | — |
|
||||
| 6 | `GET /api/tests` (каталог + `hiddenByYou`) | `routes_tests.test_list` (по автору) + `routes_passing.my_tests` (по назначениям) | ⚠️ нет «скрытые мной» отдельным разделом | Добавить вкладку/фильтр «скрытые автором» в `cabinet/testing/test_list.html` |
|
||||
| 7 | `POST /api/tests` (создать пустой) | `routes_tests.test_create` (требует full payload + ≥7 вопросов) | ⚠️ другой контракт | Решить продуктово: оставить ограничение `≥7` либо разрешить «пустой тест» как в Express. **Рекомендую** разрешить пустой и валидировать только при публикации |
|
||||
| 8 | `GET /api/tests/:id/summary` | вшито в `test_edit` | ⚠️ нет отдельной summary | Добавить функцию `testing_get_test_summary(test_id)` если нужен лёгкий вариант для списка |
|
||||
| 9 | `GET /api/tests/:id/versions` | `testing_get_test_for_edit` отдаёт активную; история — внутри `test_edit` | ⚠️ — | Вынести список версий в API/шаблон («История» в UX) |
|
||||
| 10 | `GET /api/tests/:id/editor` | `routes_tests.test_edit` + `testing_get_test_for_edit` | ✅ — | сверить набор полей, отдаваемых шаблону, с тем что показывает React-редактор |
|
||||
| 11 | `POST /api/tests/:id/ai/generate-test` (строгая `shape: [{optionsCount, hasMultipleAnswers}]`, до 40 вопросов) | `routes_ai.ai_generate` принимает `topic, question_count` (фикс. форма) | ❌ **gap** | Добавить вариант с явной формой (передавать массив `optionsCount/hasMultipleAnswers`); либо принять, что HR-кабинет генерирует «обобщённо» |
|
||||
| 12 | `POST /api/tests/:id/ai/generate-question` (один вопрос: пусто=новый, текст=переформулировать) | `routes_ai.ai_improve` (улучшить) + `ai_distractors` (варианты) | ❌ **gap** | Добавить ручку «один вопрос со строгой сеткой» (объединить `improve` + `distractors` под единый сценарий) |
|
||||
| 13 | `POST /api/tests/:id/versions/:vid/activate` | `routes_tests.test_version_activate` | ✅ — | — |
|
||||
| 14 | `PATCH /api/tests/:id` (`chainActive`) | `routes_tests.test_toggle` | ✅ — | сверить семантику (toggle vs явный флаг) |
|
||||
| 15 | `POST /api/tests/:id/assign` (массивы `userIds`/`staffIds`) | `routes_assignments.assign_submit` (`employee_ids = staff_id[]`) | ✅ совместимо | — |
|
||||
| 16 | `POST /api/tests/:id/draft` (с авто-fork версии) | `routes_tests.test_update` (тоже фаркает при `has_attempts`) | ⚠️ контракт другой | Сверить, что в HR-кабинете тоже работает «правка без попыток = на месте, после попыток = новая версия». В коде есть, но нужна проверка |
|
||||
| 17 | `POST /api/tests/:id/attempts/start` | `routes_passing.start_attempt(assignment_id)` | ⚠️ другой ключ | Привести к виду «start by test_id» **или** оставить как есть и в UI стартовать по `assignment_id` (логичнее) |
|
||||
| 18 | `GET /api/tests/:id/attempts` | `routes_results.tracker` (общая лента) — без фильтра по тесту | ⚠️ нет per-test ленты | Добавить фильтр `test_id` или отдельный URL `/cabinet/testing/tests/:id/attempts` |
|
||||
| 19 | `GET /api/tests/:id/attempts/:aid/review` | `routes_results.result(attempt_id)` | ✅ — | сверить, что разбор показывает выбранные/верные варианты по каждому вопросу |
|
||||
| 20 | `GET /api/tests/:id/attempts/:aid/play` | `routes_passing.take_test(attempt_id)` | ✅ — | — |
|
||||
| 21 | `POST /api/tests/:id/attempts/:aid/submit` | `routes_passing.finish_attempt(attempt_id)` (+ `save_answer`) | ⚠️ контракт другой | Express отправляет все ответы пакетом; HR — пошагово (`save_answer` × N + `finish`). Это **OK для UX** (продолжить позже), но фронт работает иначе |
|
||||
| 22 | `POST /api/tests/import/document` | `routes_import.import_document` | ✅ — | сверить mime-типы и сообщения об ошибках |
|
||||
| 23 | `GET /api/tests/:id/chain-info` (`hasAnyAttempt`) | вшито в `testing_get_test_for_edit['has_attempts']` | ✅ — | — |
|
||||
|
||||
> **Итог по gap-analysis:** 13 из 22 эндпоинтов закрыты «как есть» или близко; 4 требуют добавления (AI «один вопрос», AI «строгая сетка», per-test attempts, summary); 5 — расхождение контракта, надо принять решение или подровнять.
|
||||
|
||||
### 10.3 Расхождения, которые ETL уже обходит
|
||||
|
||||
- **Назначения на отдел:** в `clinic_tests.test_assignment_targets` могут быть строки `target_type='department'`. ETL разворачивает их в N строк `testing_assignments` по списку сотрудников отдела на момент миграции (см. шапку `tools/migrate_clinic_tests_to_hr.py`).
|
||||
- **Попытки без явного назначения:** в Express `test_attempts.user_id` напрямую идёт от пользователя; в HR попытка обязательно имеет `assignment_id`. ETL для каждой пары (тест, сотрудник) создаёт **синтетическое** назначение `max_attempts=99` и привязывает попытку к нему.
|
||||
- **UUID → Integer:** идемпотентный маппинг лежит в `public._clinic_tests_migration_map` (entity, old_uuid → new_id) — повторный прогон ETL безопасен.
|
||||
|
||||
### 10.4 Чего точно нет в `tgFlaskForm` и потребует доработки
|
||||
|
||||
1. **AI «один вопрос со строгой сеткой»** (`generate_or_rephrase_question` из `aiEditorService.js`).
|
||||
2. **AI «целый тест по shape»** с проверкой `optionsCount` и `hasMultipleAnswers` для каждой строки.
|
||||
3. **«Скрытые автором» цепочки** как отдельный список на UI.
|
||||
4. **`/tests/:id/summary`** — лёгкая ручка для карточки в списке (если потребуется в новом UI).
|
||||
5. **Per-test лента попыток** (UI «Прохождения» в аккордеоне «История»).
|
||||
6. **Мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md):** аккордеоны «О тесте / Вопросы / История / Показ в каталоге», фикс-футер, икон-кнопка удаления варианта, «Выбрать всех» в назначении и т.д. В Этапе 1 это переносится в Jinja-шаблоны `flask_app/app/templates/`. В Этапе 2 уже готовые шаблоны переезжают в `tgFlaskForm/webApp/templates/cabinet/testing/`.
|
||||
|
||||
### 10.5 Что точно НЕ переносим
|
||||
|
||||
- **Свой JWT/cookie/bcrypt** из `backend/src/utils/auth.js` и `middleware/auth.js`. Авторизация — через сессии общего кабинета.
|
||||
- **Werkzeug-полифилл в Node** (`utils/werkzeugPassword.js`). В Python это родная функция `werkzeug.security.check_password_hash`.
|
||||
- **Свои миграции SQL** из `backend/src/db/migrations/`. Целевая схема живёт в `tgFlaskForm/db/models.py` и сопутствующих DDL/Alembic.
|
||||
- **`flask_app/`** в этом репозитории — каркас уже **не нужен**, в Спринте 4 решим: удалить или оставить как историческую заготовку.
|
||||
- **`backend/src/**/*.test.js`** (Node test runner). Их предметная нагрузка покрывается **либо** уже существующими тестами `tgFlaskForm`, **либо** новыми pytest-юнитами.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Унификация стека TestingWebApp с `tgFlaskForm` — план и журнал
|
||||
|
||||
> **Корректировка курса от 2026-04-27.**
|
||||
> Ранее в этом документе фигурировал «полный переезд в HR-кабинет (`tgFlaskForm`) с cutover'ом и удалением React/Express». Это было забеганием вперёд. Текущая фаза — **только унификация стека**, без слияния репозиториев и без миграции данных.
|
||||
|
||||
---
|
||||
|
||||
## Назначение документа
|
||||
|
||||
Зафиксировать разделение работы на **два этапа** и текущий статус каждого. Этот файл — **трекер решения и журнал**, основной план Этапа 1 — здесь же; план Этапа 2 (на будущее) — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md).
|
||||
|
||||
**Связано:** [migration-final-inventory.md](migration-final-inventory.md) (карта 22 эндпоинтов Express, БД, env, зависимости, плюс справочный gap-analysis с уже существующим модулем в `tgFlaskForm` — пригодится в Этапе 2), [PROJECT_STATUS.md](PROJECT_STATUS.md), [README](../README.md), [`flask_app/README.md`](../flask_app/README.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
|
||||
|
||||
---
|
||||
|
||||
## Этап 1 (текущий) — единый стек: Express → Flask, React → Jinja, **внутри TestingWebApp**
|
||||
|
||||
### Цель
|
||||
|
||||
Привести TestingWebApp к **тому же стеку**, что у `HR_TG_Bot/tgFlaskForm`:
|
||||
|
||||
- **Бэкенд:** Python 3 + Flask, точечный SQL/SQLAlchemy в стиле `tgFlaskForm`. Развивается в каталоге [`flask_app/`](../flask_app/) этого репозитория (сейчас — минимальный каркас).
|
||||
- **Фронтенд:** Jinja-шаблоны в `flask_app/app/templates/`, мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) переносится один в один. React (`frontend/`) уходит **после** того, как Jinja-версия закроет все экраны.
|
||||
- **БД:** **остаётся `clinic_tests`** (со своими UUID-ключами). Никаких изменений схемы.
|
||||
- **Авторизация:** JWT/bcrypt + опциональный `HR_AUTH=1` (как в Express) — переносим как есть.
|
||||
|
||||
### Что **не** делаем в Этапе 1
|
||||
|
||||
- **Не** трогаем `HR_TG_Bot/tgFlaskForm/`. Его модуль `cabinet/testing` живёт своей жизнью.
|
||||
- **Не** мигрируем данные `clinic_tests → hr_bot_test`. ETL-скрипт `migrate_clinic_tests_to_hr.py` есть, но он — для Этапа 2.
|
||||
- **Не** удаляем `backend/` и `frontend/` сразу. Они работают параллельно с `flask_app/` до полного паритета. Удаление — последним PR этапа.
|
||||
|
||||
### Стартовая точка `flask_app/`
|
||||
|
||||
| Что есть | Файл / артефакт |
|
||||
|---|---|
|
||||
| Flask-приложение (`create_app`) | `flask_app/app/__init__.py` |
|
||||
| Точка входа (dev / waitress) | `flask_app/run.py` |
|
||||
| `/health` | `flask_app/app/__init__.py` |
|
||||
| Пустой `index.html` | `flask_app/app/templates/index.html` |
|
||||
| Зависимости | `flask_app/requirements.txt` (Flask, python-dotenv, waitress) |
|
||||
| Docker Flask (порт 3107) | `flask_app/Dockerfile`, `docker-compose.dev.yml` (сервис `testing-flask`) |
|
||||
|
||||
Всё остальное — **писать**.
|
||||
|
||||
### План Этапа 1 (по спринтам)
|
||||
|
||||
| Спринт | Цель | Артефакты |
|
||||
|---|---|---|
|
||||
| **E1.0 — База Flask-приложения** ✅ | БД-пул (SQLAlchemy + psycopg2), Flask sessions через `SECRET_KEY`, конфиг через `.env`, структура blueprint'ов, шаблон `base.html` в стиле кабинета, обработчики 404/500, `/health` с проверкой БД. **Без бизнес-логики.** | `flask_app/app/db.py`, `flask_app/app/__init__.py`, `flask_app/app/blueprints/main.py`, `flask_app/app/templates/{base,index,404,500}.html`, `flask_app/app/static/css/app.css`, обновлённые `requirements.txt` и `.env.example` |
|
||||
| **E1.1 — Auth и `/api/me`** ✅ | Flask sessions (signed cookie), bcrypt + Werkzeug (`werkzeug.security.check_password_hash`), опц. `HR_AUTH=1` с UPSERT в `clinic_tests.users` по `staff_id`. UI-страница `/login`, JSON-API `/api/auth/{login,logout,me}`, декораторы `login_required`/`require_role`, `current_user` доступен в шаблонах. | `flask_app/app/auth/{routes,services,decorators,hr_role}.py`, `flask_app/app/{config,messages}.py`, `flask_app/app/templates/auth/login.html`, обновлены `base.html`, `__init__.py`, `requirements.txt` (+`bcrypt`) |
|
||||
| **E1.2 — Тесты: список и редактор** ✅ | Перенесены 10 эндпоинтов из Express: `GET/POST /api/tests`, `GET /api/tests/:id/{summary,versions,editor}`, `POST /api/tests/:id/draft`, `POST /api/tests/:id/versions/:vid/activate`, `PATCH /api/tests/:id`, `POST /api/tests/:id/ai/{generate-test,generate-question}`. UI: `/tests` (каталог + создание), `/tests/:id/edit` (рабочий редактор с AI). Полная мобильная отполировка UX (4 аккордеона + fixed footer + drag-n-drop) — в **E1.7**. | `flask_app/app/services/{llm_client,draft_validator,ai_editor,test_access,test_chain,test_draft,editor_content}.py`, `flask_app/app/tests/{__init__,routes}.py`, `flask_app/app/templates/tests/{list,editor}.html`, `flask_app/app/static/js/editor.js`, обновлены `base.html`, `index.html`, `__init__.py` |
|
||||
| **E1.3 — Импорт документов** ✅ | `POST /api/tests/import/document` (PDF/DOCX/TXT/MD извлечение текста через `pypdf` и `python-docx`), интеграция с AI-генерацией черновика (`generation_for_import_document`), кнопка «Импорт документа» в AI-панели редактора, лимит 16 МБ. | `flask_app/app/services/{document_extract,document_gen}.py`, эндпоинт в `flask_app/app/tests/routes.py`, кнопка в `editor.html` + `editor.js`, `requirements.txt` (+`pypdf`, `python-docx`) |
|
||||
| **E1.4 — Назначение и прохождение** | Эндпоинты `assign`, `attempts/start`, `attempts/:id/play`, `attempts/:id/submit`, `attempts/:id/review`. Шаблоны: `assign.html`, `take_test.html`, `test_result.html`. | `flask_app/app/assignments/`, `flask_app/app/attempts/`, шаблоны |
|
||||
| **E1.5 — Трекер и настройки** | Трекер прохождений, настройки модуля (ключи AI и т.д.), цепочки тестов. Шаблоны `tracker.html`, `settings.html`. | `flask_app/app/tracker/`, `flask_app/app/settings/` |
|
||||
| **E1.6 — Cutover внутри репозитория** | `docker-compose.dev.yml` указывает на `flask_app/` как основной сервис; Nginx маршрутизирует `/api` и UI на новый Flask. Удаление `backend/` и `frontend/` отдельным PR. README → актуальные команды. | `docker-compose.dev.yml`, корневой `README.md`, `frontend/` и `backend/` удаляются |
|
||||
| **E1.7 — UX-полировка редактора** | Перевод базового редактора (E1.2) на мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md): 4 аккордеона (Шапка / AI-помощник / Вопросы / Действия), sticky footer, drag-n-drop вопросов, импорт документа в подразделе AI-блока (после E1.3). | `flask_app/app/templates/tests/editor.html`, `flask_app/app/static/js/editor.js`, новый `static/css/testing.css` |
|
||||
| **E1.8 — AI-функции v2** ✅ | `/settings` (статус ключа из ENV + ping), `POST /api/llm/ping`, на тесте — `ai/generate-by-title` (без сетки), `ai/check` (рецензия), `ai/improve` (массовое «было → стало» с чекбоксами). На уровне вопроса — уже есть `ai/generate-question` из E1.2 (создаёт вопрос или переформулирует). Все AI-эндпоинты унифицированы: при отсутствии ключа — `{ error, code, settingsUrl: '/settings' }`. | `flask_app/app/services/{ai_editor,llm_client}.py`, `flask_app/app/blueprints/settings.py`, `flask_app/app/templates/settings.html`, ссылка «Настройки» в `base.html`, обновлены `tests/routes.py`, `editor.html`, `editor.js` |
|
||||
|
||||
### Критерии готовности Этапа 1
|
||||
|
||||
- Все 22 эндпоинта Express (см. [migration-final-inventory.md](migration-final-inventory.md)) реализованы в `flask_app/` и проходят smoke-тесты.
|
||||
- Все экраны мобильного UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) воспроизведены в Jinja.
|
||||
- В `docker-compose.dev.yml` остался **один** сервис приложения (Flask). `backend/` и `frontend/` удалены или перенесены в ветку `legacy/clinic-tests-node`.
|
||||
- БД — по-прежнему `clinic_tests`, схема не менялась.
|
||||
|
||||
---
|
||||
|
||||
## Этап 2 (на будущее, без сроков) — слияние с `tgFlaskForm`
|
||||
|
||||
Когда заказчик решит «вот теперь объединяем» — **вся** разработанная Flask-логика и шаблоны легко переносятся в `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` и `templates/cabinet/testing/`, потому что **стек уже совпадает**. Это и есть смысл Этапа 1.
|
||||
|
||||
Что нужно сделать в Этапе 2 (план — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md)):
|
||||
|
||||
1. Перенести код Flask-приложения как blueprint в `tgFlaskForm`.
|
||||
2. Адаптировать модели под существующие `Testing*` таблицы (`hr_bot_test.testing_*`).
|
||||
3. Перевести авторизацию на сессии общего HR-кабинета.
|
||||
4. Прогнать ETL `clinic_tests → hr_bot_test` (скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` уже готов: 437 строк, режимы `--dry-run`/`--apply`, идемпотентность через `_clinic_tests_migration_map`).
|
||||
5. Cutover (если к тому моменту появятся реальные пользователи; сейчас TestingWebApp — песочница для тестировщиков).
|
||||
|
||||
**Решения, которые относятся к Этапу 2** (зафиксированы заранее, чтобы потом не переоткрывать):
|
||||
|
||||
- **`test_assignments`:** переносим 1:1, дописывая отдельный блок в ETL (сейчас скрипт переносит только пары через попытки).
|
||||
- **Пользователи без `staff_id`:** игнорируем с WARN; по договорённости настоящие пользователи всегда привязаны к `staff_members`.
|
||||
- **Cutover / окно простоя:** не нужны, пока TestingWebApp остаётся песочницей.
|
||||
|
||||
---
|
||||
|
||||
## Журнал
|
||||
|
||||
| Дата | Что сделано |
|
||||
|------|-------------|
|
||||
| 2026-04-27 | Спринт 0 («инвентаризация» в старой нумерации) закрыт: артефакт [migration-final-inventory.md](migration-final-inventory.md) — карта 22 эндпоинтов Express, БД, env, зависимости. |
|
||||
| 2026-04-27 | Принято решение: **сценарий B + b1** (полный переезд в HR-кабинет). |
|
||||
| 2026-04-27 | **Курс скорректирован:** Этап 1 = унификация стека внутри TestingWebApp (Express → Flask + React → Jinja, БД остаётся `clinic_tests`). Этап 2 = слияние с `tgFlaskForm` — на будущее. ETL и удаление React переходят в Этап 2. Документы переписаны под двух-этапную картину. Эксперимент с правкой `tgFlaskForm/cabinet/testing/test_editor.html` (ветка `feat/testing-editor-jinja-redesign`) откачен и не оставил следов в HR-репо. |
|
||||
| 2026-04-27 | **E1.8 закрыт.** AI v2: страница `/settings` (статус ключа из ENV, `Проверить подключение` → `POST /api/llm/ping`). Три новых эндпоинта на тесте: `POST /api/tests/<id>/ai/generate-by-title` (генерация только по названию + опции «сколько вопросов / сколько вариантов»), `POST /api/tests/<id>/ai/check` (рецензия: вердикт + разделы рекомендаций), `POST /api/tests/<id>/ai/improve` (массовое «было → стало» с проверкой неизменности сетки). UI редактора: кнопки «Сгенерировать по названию», «Проверить тест», «Улучшить тест»; общий `<dialog>` для модалок check/improve; чекбоксы в improve позволяют применять изменения по выбранным вопросам. Все AI-эндпоинты унифицированы: при отсутствии ключа возвращают `{ error, code, settingsUrl: '/settings' }` 502 — фронт предлагает открыть Настройки. |
|
||||
| 2026-04-27 | **E1.3 закрыт.** Импорт документов: `app/services/document_extract.py` (PDF через `pypdf`, DOCX через `python-docx`, TXT/MD), `app/services/document_gen.py` (`generation_for_import_document` — извлекает текст, при наличии LLM-ключа просит модель собрать draft через `validate_and_normalize_draft`), эндпоинт `POST /api/tests/import/document` под `@login_required` с лимитом 16 МБ. UI редактора: кнопка «Импорт документа» в AI-панели, после загрузки — confirm с предложением применить черновик; если ключа нет — алерт с превью текста. В `requirements.txt` добавлены `pypdf>=4` и `python-docx>=1.1`. |
|
||||
| 2026-04-27 | **E1.2 закрыт.** Перенесены `backend/src/routes/tests.js` (только E1.2-эндпоинты — без `import/document`/`assign`/`attempts`/`chain-info`, они уйдут в E1.3-E1.5) + сервисы `testDraftService.js`, `testAccessService.js`, `testChainService.js`, `aiEditorService.js`, `documentGenService.js` (только парсер JSON и валидатор draft), `llmClient.js`, `getEditorContent` из `testAttemptService.js`. Эндпоинты регистрируются в blueprint `tests`. AI-генерация: `parseAndValidateShape` 1:1, ошибки LLM (`llm_*`-коды) пробрасываются как 502 с кодом в JSON. UI: каталог тестов с кнопкой создания (модалка `<dialog>`) и рабочий редактор (inline-поля, AI-кнопки «весь тест» / «один вопрос», добавление/удаление/перемещение вопросов и вариантов, сохранение черновика, переключатель «Цепочка активна»). Полный мобильный UX редактора (аккордеоны+fixed footer+drag-n-drop из Спринта 3) вынесен в новый спринт **E1.7** — этот PR закрывает функциональность, не дизайн. |
|
||||
| 2026-04-27 | **E1.1 закрыт.** Перенесены `backend/src/routes/auth.js` + `middleware/auth.js` + `utils/{auth,werkzeugPassword,hrRoleMap}.js` в `flask_app/app/auth/`. Решение: Flask sessions (signed cookie) вместо JWT, как договорились (вариант A). Поддерживаются bcrypt-хеши (`$2*`) и Werkzeug-хеши (`scrypt:`/`pbkdf2:`). Эндпоинты — те же пути, что в Express: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` (отдаёт `user`, `devUi`, `assignmentUi`). Дополнительно — HTML-страница `/login` (форма) и `POST /logout`. Декораторы: `@login_required`, `@require_role(...)`. В шаблонах доступны `current_user`, `hr_auth_enabled`, `dev_ui`, `assignment_ui`. Защита от open-redirect в параметре `?next=`. Главная (`/`) теперь требует логин. |
|
||||
| 2026-04-27 | **E1.0 закрыт.** В `flask_app/`: SQLAlchemy/psycopg2-пул в стиле `tgFlaskForm/db/session.py` (`app/db.py`, основная БД `clinic_tests` + опциональная HR-БД при `HR_AUTH=1`), фабрика `create_app` с регистрацией blueprint'ов, обработчиками 404/500 и Flask sessions, главный blueprint `main` с `/` и `/health` (smoke-проверка БД), `base.html` в стиле кабинета HR (Tailwind CDN + Manrope + Material Symbols, без зависимостей от HR-репо), шаблоны `index/404/500`, минимальный `static/css/app.css`. Бизнес-логика **не** добавлялась. |
|
||||
@@ -1,175 +1,102 @@
|
||||
# Перенос TestingWebApp на стек HR_TG_Bot / tgFlaskForm
|
||||
# Этап 2 (на будущее) — слияние TestingWebApp с `HR_TG_Bot/tgFlaskForm`
|
||||
|
||||
**Тот же план простым языком (две базы, люди, этапы):** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md).
|
||||
> **Не текущая фаза.** Текущая работа — **Этап 1**: унификация стека внутри TestingWebApp (Express → Flask + React → Jinja). См. [migration-final.md](migration-final.md). Этот документ — план **последующего** слияния, когда заказчик решит объединять.
|
||||
|
||||
**Назначение документа:** зафиксировать целевую архитектуру, **спринтовый план** доведения функциональности до паритета и **порядок миграции данных** из отдельного приложения (`Express` + `React` + БД `clinic_tests`) в кабинет **`tgFlaskForm`** (Flask, шаблоны, общая БД `hr_bot_test`, таблицы `testing_*`).
|
||||
**Простое объяснение тех же шагов:** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md).
|
||||
|
||||
**Связанные материалы:** [PROJECT_STATUS.md](PROJECT_STATUS.md), [README.md](../README.md), [TEST_TABLES_ANALYSIS.md](TEST_TABLES_ANALYSIS.md), код модуля в репозитории HR: `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`, модели: `HR_TG_Bot/tgFlaskForm/db/models.py`.
|
||||
**Каркас нового контура в этом репозитории:** [../flask_app/README.md](../flask_app/README.md).
|
||||
**Связано:** [migration-final.md](migration-final.md) (главный трекер двух этапов), [migration-final-inventory.md](migration-final-inventory.md) (карта Express + gap-analysis с уже существующим модулем `tgFlaskForm`), [PROJECT_STATUS.md](PROJECT_STATUS.md), [README](../README.md), код HR-кабинета: `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`, модели: `HR_TG_Bot/tgFlaskForm/db/models.py`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Стратегия переходного периода (отдельное приложение, тот же стек)
|
||||
## 0. Предусловие — Этап 1 закрыт
|
||||
|
||||
**Решение:** переписывание с Node/React на **тот же стек, что у мини-приложения и кабинета HR** — Python 3, **Flask**, шаблоны (Jinja2), статический JS, работа с PostgreSQL в духе `tgFlaskForm`. При этом сервис **пока живёт отдельно**: свой процесс, свой URL/порт, **не** обязан совпадать с деплоем полного `HR_TG_Bot/tgFlaskForm`.
|
||||
К моменту, когда этот документ берётся в работу, в TestingWebApp **уже** должно быть:
|
||||
|
||||
**Зачем так:** быстрее выйти на паритет по UX и данным, **без** риска «большого взрыва» в едином кабинете; позже либо встраиваете модуль в кабинет (общий `webApp`), либо оставляете отдельный вход — стек уже совпадает.
|
||||
- Бэкенд переписан с Express на Flask внутри [`flask_app/`](../flask_app/), все 22 эндпоинта работают.
|
||||
- Фронтенд переписан с React на Jinja-шаблоны в `flask_app/app/templates/`.
|
||||
- БД — по-прежнему `clinic_tests`, схема не менялась.
|
||||
- В репозитории остался один сервис приложения.
|
||||
|
||||
**Обязательно зафиксировать продуктово:**
|
||||
|
||||
| Вопрос | Рекомендация |
|
||||
|--------|----------------|
|
||||
| Где **пишут** тесты и попытки, пока два контура? | Один «канонический» контур на запись; второй read-only или только пилот — иначе разъедутся данные. |
|
||||
| База | Либо по-прежнему **`clinic_tests`** в новом Flask до ETL, либо сразу **`hr_bot_test`** + `testing_*` (как в кабинете) — одно из двух, не смешивать без миграции. |
|
||||
| ETL | Скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`: бэкап → `--dry-run` → проверка на копии → короткое окно → `--apply`. |
|
||||
|
||||
**Технически:** в репозитории TestingWebApp заведён каталог **`flask_app/`** — минимальное приложение-заготовка; развитие переноса идёт там (или копированием готовых модулей из `HR_TG_Bot/tgFlaskForm`).
|
||||
Если что-то из этого ещё не готово — Этап 2 не начинается.
|
||||
|
||||
---
|
||||
|
||||
## 1. Зачем переносить
|
||||
## 1. Что меняется при слиянии
|
||||
|
||||
| Аспект | Сейчас (TestingWebApp) | Цель (tgFlaskForm) |
|
||||
|--------|------------------------|---------------------|
|
||||
| Стек | Node.js (Express), React (Vite), отдельный деплой | Python 3, Flask, Jinja/PyPug, статический JS в шаблонах — **единый кабинет** с остальным HR |
|
||||
| База | PostgreSQL, схема `clinic_tests`, UUID-ключи, локальные `users` | Та же инфраструктура Postgres, БД **`hr_bot_test`**, целочисленные `id`, связь с **`staff_members`** |
|
||||
| Авторизация | Собственные логин/JWT + опция `HR_AUTH` | Сессии кабинета, RBAC через HR (`testing_head_positions`, флаги HR и т.д.) |
|
||||
| Модуль тестирования | Полный цикл в одном репозитории | В **`tgFlaskForm` уже есть** blueprint `/cabinet/testing`, запросы в `db/queries/testing_queries.py` — задача переноса = **паритет фич + данные + вывод из эксплуатации** старого UI/API |
|
||||
|
||||
Итог после **полной** консолидации: один вход для сотрудника, одна БД «истины» по людям, меньше дублирования интеграций с HR. На переходном этапе допустим **отдельный** Flask-инстанс с тем же стеком (см. §0).
|
||||
| Аспект | После Этапа 1 (отдельный сервис) | После Этапа 2 (часть HR-кабинета) |
|
||||
|---|---|---|
|
||||
| Репозиторий | `TestingWebApp/flask_app/` | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` |
|
||||
| Деплой | Свой Docker-сервис, свой URL/порт | Часть основного `tgFlaskForm`, общий URL `/cabinet/testing/...` |
|
||||
| БД | `clinic_tests`, UUID | `hr_bot_test`, integer ID, схема `testing_*` |
|
||||
| Авторизация | JWT/bcrypt + опциональный `HR_AUTH` | Сессии общего HR-кабинета, привязка к `staff_members` |
|
||||
| Модели | Свои (как в Express, но на Python) | Существующие `TestingTest`, `TestingTestVersion`, `TestingQuestion`, `TestingAnswer`, `TestingAssignment`, `TestingAttempt`, `TestingAttemptAnswer`, `TestingSetting`, `TestingHeadPosition` в `db/models.py` |
|
||||
| UI | Jinja-шаблоны в `flask_app/app/templates/` | Jinja-шаблоны в `tgFlaskForm/webApp/templates/cabinet/testing/` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Исходный и целевой стек (кратко)
|
||||
## 2. План Этапа 2 (по спринтам)
|
||||
|
||||
**Исходный (TestingWebApp):**
|
||||
### E2.0 — Сверка кода и моделей
|
||||
|
||||
- Backend: `express`, `pg`, миграции SQL в `backend/src/db/migrations/`.
|
||||
- Frontend: `react`, `react-router-dom`, `vite`.
|
||||
- Данные: цепочки `tests` → `test_versions` → `questions` → `answer_options`; назначения с `test_assignment_targets` (отдел/пользователь); попытки `test_attempts`, ответы `user_answers` (массив UUID вариантов).
|
||||
- Сравнить структуру `flask_app/` (после Этапа 1) с уже существующим модулем `tgFlaskForm/webApp/interfaces/testing/`. Где функции называются иначе — выбрать одно имя.
|
||||
- Сверить модели: `clinic_tests` UUID-схема vs `hr_bot_test` `testing_*` integer-схема. Зафиксировать поле-в-поле соответствия (большая часть уже сделана в [migration-final-inventory.md §10](migration-final-inventory.md#10-gap-analysis-tgflaskformcabinettesting-vs-express)).
|
||||
- **Критерий выхода:** документ соответствий + решение по спорным точкам (например, кто прав — `is_active` на цепочке или на версии).
|
||||
|
||||
**Целевой (`HR_TG_Bot/tgFlaskForm`) и отдельный контур в этом репозитории (`flask_app/`):**
|
||||
### E2.1 — Перенос кода как blueprint
|
||||
|
||||
- Приложение: `Flask`, точка входа `web_run.py`, фабрика/приложение `webApp/__init__.py`.
|
||||
- Шаблоны: `webApp/templates/cabinet/testing/*.html`, клиентский JS в `templates/static/js/cabinet/testing_*`.
|
||||
- ORM/запросы: SQLAlchemy-модели `TestingTest`, `TestingTestVersion`, `TestingQuestion`, `TestingAnswer`, `TestingAssignment`, `TestingAttempt`, `TestingAttemptAnswer`, `TestingSetting`, `TestingHeadPosition` в `db/models.py`; бизнес-запросы — `db/queries/testing_queries.py`.
|
||||
- Сервер: dev `flask run`, prod типично `waitress` (см. `web_run.py`).
|
||||
- **Отдельный деплой в TestingWebApp:** каталог `flask_app/` — `run.py`, шаблоны в `flask_app/app/templates/` (см. §0).
|
||||
- Скопировать роуты, сервисы, шаблоны из `flask_app/` в `tgFlaskForm`. **Адаптировать**:
|
||||
- Импорты — на `db/models.py` и `db/queries/testing_queries.py` HR-кабинета.
|
||||
- Авторизация — сменить с JWT/bcrypt на сессии и `werkzeug.security.check_password_hash`.
|
||||
- URL-prefix с корневого на `/cabinet/testing/`.
|
||||
- Шаблоны — наследование от `cabinet/base.html` (хедер, нижний нав-бар).
|
||||
- **Критерий выхода:** все экраны открываются через HR-кабинет, локальный smoke-тест зелёный.
|
||||
|
||||
### E2.2 — Миграция данных (ETL)
|
||||
|
||||
Скрипт уже готов: [`HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (437 строк, режимы `--dry-run`/`--apply`, идемпотентность через `public._clinic_tests_migration_map`).
|
||||
|
||||
Перед прогоном **на актуальных данных** дописать:
|
||||
|
||||
- **Перенос `test_assignments` 1:1** — сейчас скрипт переносит только пары «тест-сотрудник» через попытки; нужны и «висящие» назначения без попыток. (Решение Этапа 2.)
|
||||
- **Логирование пользователей без `staff_id`:** автор → WARN, попытка → WARN; никаких хардовых ошибок. (Решение Этапа 2.)
|
||||
|
||||
**Порядок:**
|
||||
|
||||
1. Бэкап `clinic_tests` и `hr_bot_test`.
|
||||
2. `--dry-run` на копии прод-БД, разбор лога.
|
||||
3. `--apply` на той же копии, ручная сверка через UI HR-кабинета.
|
||||
4. После приёмки — `--dry-run` + `--apply` на боевой БД.
|
||||
|
||||
### E2.3 — Cutover
|
||||
|
||||
Если к этому моменту у TestingWebApp всё ещё «песочница для тестировщиков» (как сейчас) — простое переключение, без окна простоя и баннеров. Если появятся реальные пользователи — добавить пункт E2.3.1: коммуникация и redirect.
|
||||
|
||||
- Заморозка записи в `flask_app/` старой инсталляции (read-only).
|
||||
- Прогон ETL на боевом.
|
||||
- Маршрутизация: внешние ссылки `clinic-tests.example.com/*` → `hr-cabinet.example.com/cabinet/testing/*`.
|
||||
- В корневом репозитории TestingWebApp — ветка `legacy/clinic-tests-flask`, в README — ссылка на этот документ и дату EOL.
|
||||
|
||||
**Критерий выхода:** мониторинг ошибок (например Sentry, уже подключён в `webApp/__init__.py`), отсутствие P1 в первую неделю.
|
||||
|
||||
---
|
||||
|
||||
## 3. Спринтовый план (переписывание = паритет + миграция + снятие стенда)
|
||||
## 3. Что трогаем в HR-кабинете до Этапа 2
|
||||
|
||||
Длительность спринта ориентировочно **2 календарные недели**; границы можно сжимать/растягивать под состав команды. Нумерация условная: **Спринт 0** — подготовка, далее функциональные слои.
|
||||
|
||||
### Спринт 0 — Инвентаризация и критерии готовности
|
||||
|
||||
**Цель:** зафиксировать разрыв «TestingWebApp ↔ tgFlaskForm» и правила миграции.
|
||||
|
||||
- Составить **матрицу сценариев** по [ТЗ.md](ТЗ.md) и [PROJECT_STATUS.md](PROJECT_STATUS.md): редактор теста, версии, назначения, прохождение, разбор, трекер, настройки модуля, AI.
|
||||
- Зафиксировать отличия схемы: UUID vs integer, модель назначений (цель: каждая строка `TestingAssignment` = один `staff_id`).
|
||||
- Решение по **импорту из PDF/DOCX** (в Node-версии есть извлечение текста для черновика): либо перенос в Python (`tgFlaskForm`), либо явный scope «после миграции».
|
||||
- **Критерий выхода:** подписанный чек-лист паритета + утверждённый порядок миграции (раздел 4 этого документа).
|
||||
|
||||
### Спринт 1 — Данные и идентификаторы
|
||||
|
||||
**Цель:** подготовить перенос без потери смысла связей.
|
||||
|
||||
- Убедиться, что у всех значимых пользователей `clinic_tests.users` есть сопоставление с **`staff_members.id`** (колонка `staff_id` и/или правила сопоставления по логину из HR).
|
||||
- Спроектировать **таблицы соответствия** для одноразового ETL (например временные таблицы или JSON-маппинги: `old_test_uuid → testing_tests.id`, `old_version_uuid → testing_test_versions.id`, и т.д.).
|
||||
- Реализовать **скрипт миграции** — в репозитории HR: [`HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (Python, `psycopg2`, два URL). Режимы: `--dry-run` (только отчёт) и `--apply` (одна транзакция `COMMIT` в `hr_bot_test`). Переменные или флаги: `CLINIC_TESTS_URL`, `HR_BOT_URL`; опция `--skip-missing-staff` пропускает цепочки, у автора нет `users.staff_id`.
|
||||
- **Критерий выхода:** dry-run на копии прод-дампа `clinic_tests` + smoke-проверки количества строк (тесты, версии, вопросы, попытки).
|
||||
|
||||
### Спринт 2 — Паритет бизнес-логики в Flask
|
||||
|
||||
**Цель:** закрыть расхождения поведения, а не только UI.
|
||||
|
||||
- Версионирование: правила «первая правка без попыток / новая версия после попыток», активная версия — согласовать с уже реализованным в `testing_queries.py` и довести до полного соответствия ТЗ при необходимости.
|
||||
- Назначения: если в `clinic_tests` остались назначения **на отдел**, описать стратегию **разворачивания** в N строк `TestingAssignment` (по списку `staff_id` отдела на дату миграции) или доработать модель в HR (отдельное решение продукт-оунера).
|
||||
- Прохождение: таймер, лимит попыток, дедлайн, случайный порядок вопросов (`question_seed`) — сверка с ТЗ и доработка в Python при расхождении.
|
||||
- **Критерий выхода:** автоматические тесты на критичные запросы (где их ещё нет) + ручной прогон чек-листа из спринта 0.
|
||||
|
||||
### Спринт 3 — UI/UX кабинета и интеграция в меню
|
||||
|
||||
**Цель:** пользователь не возвращается к старому хосту.
|
||||
|
||||
- Пункты меню кабинета, бейджи «назначенные тесты», единый стиль с `cabinet/base.html`.
|
||||
- Довести страницы: список «мои тесты», редактор, назначение, прохождение, результат/разбор, трекер, настройки — по чек-листу.
|
||||
- Импорт документов (если включён в scope спринта 0): эндпоинт + UI в шаблоне, ключи API только на сервере (`TestingSetting` / env).
|
||||
- **Критерий выхода:** UX-приёмка на стенде, совпадающий с ТЗ сценарий для HR / руководителя / сотрудника.
|
||||
|
||||
### Спринт 4 — Миграция prod, cutover, архив TestingWebApp
|
||||
|
||||
**Цель:** переключить реальных пользователей и зафиксировать артефакты.
|
||||
|
||||
- Заморозка записи в TestingWebApp (режим только чтение или техническое окно).
|
||||
- Прогон ETL на прод-копии → валидация → прогон на боевой БД в согласованное окно.
|
||||
- Обновление ссылок (внутренние порталы, документация, docker-compose): вместо `:3107` / отдельного сервиса — URL кабинета HR с `/cabinet/testing/...`.
|
||||
- Репозиторий TestingWebApp: ветка **`legacy/clinic-tests-node`**, в README — ссылка на этот документ и дата end-of-life API/UI.
|
||||
- **Критерий выхода:** мониторинг ошибок (например Sentry уже в `webApp/__init__.py`), отсутствие P1 по тестам в первую неделю после cutover.
|
||||
**Ничего.** Существующий модуль `tgFlaskForm/webApp/interfaces/testing/` развивается своим темпом командой HR-кабинета и **не** должен подстраиваться под TestingWebApp до момента слияния. Если в нём появляются полезные правки — переносим в `flask_app/` обратным потоком.
|
||||
|
||||
---
|
||||
|
||||
## 4. Как происходит миграция данных (пошагово)
|
||||
|
||||
### 4.1 Предпосылки
|
||||
|
||||
1. Доступ к **двум** базам с одной машины (или логическое копирование дампа): `clinic_tests` и `hr_bot_test`.
|
||||
2. Маппинг **пользователь → сотрудник:** для каждой строки `users` в `clinic_tests` должен быть известен **`staff_members.id`**. Если `staff_id` пустой — заранее ручной/полуавтоматический справочник соответствий (логин, email, ФИО).
|
||||
3. Зафиксированная **версия кода** `tgFlaskForm`, в которой пройдены регрессионные тесты модуля тестирования.
|
||||
|
||||
### 4.2 Порядок загрузки сущностей (чтобы не нарушить FK)
|
||||
|
||||
Рекомендуемый порядок транзакций/батчей:
|
||||
|
||||
1. **`testing_tests`** — из цепочек `tests`: `title`, `description`, `created_by` ← `users.staff_id`, `is_active`, `created_at` (по политике: локальное время vs UTC).
|
||||
2. **`testing_test_versions`** — из `test_versions`: связь `test_id` через маппинг; `version_number` ← `version`; `passing_score_percent` ← порог из версии/цепочки (в старой схеме часть полей была на `tests` — нормализовать в версию как в SQLAlchemy-модели); `time_limit_minutes`, `allow_back_navigation`, `is_active_version`, флаг единственной активной версии на цепочку.
|
||||
3. **`testing_questions`** — из `questions`: текст, тип (`single`/`multiple` из `has_multiple_answers`), `sort_order` ← `question_order`.
|
||||
4. **`testing_answers`** — из `answer_options`: текст, `is_correct`, порядок.
|
||||
5. **`testing_assignments`** — из `test_assignments` + `test_assignment_targets`:
|
||||
- для целей типа **пользователь:** одна строка на пару (тест, `staff_id`);
|
||||
- для целей **отдел:** развернуть в множество строк по сотрудникам отдела на момент миграции (с явным логом «создано из department_id=…»);
|
||||
- `assigned_by` ← `staff_id` постановщика; `deadline`, `max_attempts`, `assigned_at`.
|
||||
6. **`testing_attempts`** — из `test_attempts`: связь с новым `assignment_id` (если в старой модели попытка шла от `user_id` без отдельного assignment — потребуется **восстановление** или создание синтетических назначений; зафиксировать правило в спринте 0).
|
||||
7. **`testing_attempt_answers`** — из `user_answers`: каждый выбранный UUID варианта → строка с новым `answer_id` (через маппинг `answer_options.id` → `testing_answers.id`).
|
||||
|
||||
Везде, где в старой БД использовались **UUID**, скрипт хранит таблицу **`public._clinic_tests_migration_map`** (`entity`, `old_uuid` → `new_id`) в `hr_bot_test` для идемпотентного повторного прогона.
|
||||
|
||||
**Замечание по назначениям:** в текущей версии скрипта строки `clinic_tests.test_assignments` / `test_assignment_targets` **не** переносятся пакетно; для каждой пары (тест HR, сотрудник) при переносе **попыток** создаётся или находится строка `testing_assignments` (синтетическое назначение, `max_attempts = 99`). Полный импорт истории назначений из clinic — отдельная доработка при необходимости.
|
||||
|
||||
### 4.3 Валидация после ETL
|
||||
|
||||
- Сравнение **агрегатов:** число тестов, версий, вопросов, назначений, завершённых попыток, строк ответов.
|
||||
- Выборочная сверка: 5–10 последних попыток — ручной разбор «вопрос / выбранные варианты / балл» в старом и новом UI.
|
||||
- Проверка уникальности «одна активная версия на тест» и отсутствия «висячих» FK.
|
||||
|
||||
### 4.4 Cutover (переключение)
|
||||
|
||||
1. Объявить **окно**: остановка записи в TestingWebApp.
|
||||
2. Инкрементальный дамп изменений с последней реплики (если делали пробный перенос ранее) или финальный полный перенос.
|
||||
3. Прогон ETL в транзакции (или по крупным батчам с чекпоинтами) → `VACUUM ANALYZE` при необходимости.
|
||||
4. Включить пользователям ссылку на **кабинет**; проверить права `can_create_tests` / HR.
|
||||
5. Сохранить **бэкап** `clinic_tests` и лог миграции минимум на срок, определённый политикой клиники (типично 30–90 дней).
|
||||
|
||||
### 4.5 Откат
|
||||
|
||||
- Если после cutover обнаружен блокирующий дефект: вернуть пользователей на временный старый стенд **только для чтения** при наличии бэкапа; новые данные в `hr_bot_test` после cutover при откате не синхронизируются автоматически — риск фиксируется заранее (короткое окно, «freeze» повторных действий).
|
||||
|
||||
---
|
||||
|
||||
## 5. Риски и как их снимать
|
||||
## 4. Риски Этапа 2 и как их снимать
|
||||
|
||||
| Риск | Мера |
|
||||
|------|------|
|
||||
| Неполное сопоставление `users` ↔ `staff_members` | Закрыть в спринте 1; не начинать ETL без процента покрытия, согласованного с заказчиком |
|
||||
| Разная семантика назначений (отдел, версия) | Явные правила в спринте 0 + лог развёртки отделов |
|
||||
| Потеря истории попыток из-за смены модели assignment | Моделирование на копии БД в спринте 1–2 |
|
||||
| Дублирование разработки UI | Опираться на уже существующий модуль в `tgFlaskForm`, не переписывать с нуля параллельный SPA |
|
||||
| Несовпадение `users.staff_id` ↔ `staff_members.id` | Проверка перед `--apply`; пользователей без `staff_id` пропускаем по решению. |
|
||||
| Расхождение моделей (UUID vs integer, поля «на цепочке» vs «на версии») | Закрыть в E2.0; подкрепить unit-тестами на конвертацию. |
|
||||
| Назначения «отдел → N сотрудников» | Логировать развёртку с пометкой `created_from_department=...`. |
|
||||
| Двойное развитие модуля HR-кабинета | До Этапа 2 — не править `tgFlaskForm/cabinet/testing` под нужды TestingWebApp. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Итог
|
||||
## 5. Производительность кабинета (общее)
|
||||
|
||||
Переписывание в данном контексте — это не «ещё один greenfield на Flask», а **консолидация** уже начатого модуля в `tgFlaskForm` с **одноразовой миграцией** из `clinic_tests` и выводом из эксплуатации связки React + Express. Спринты 0–4 дают сквозной маршрут от анализа до cutover; детали ETL должны быть закреплены в коде скрипта и журнале прогона к концу **спринта 1**.
|
||||
|
||||
**См. также:** если пользователи жалуются на медленную загрузку страниц кабинета/Flask — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md).
|
||||
Если после переноса страницы кабинета или мини-приложения работают медленно — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). Это вспомогательный документ, не часть этапов миграции.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| Контур | Путь в репозитории | Назначение |
|
||||
|--------|-------------------|------------|
|
||||
| **Основной веб-кабинет HR** | `HR_TG_Bot/tgFlaskForm/` | Flask-приложение: авторизация, кабинет, разделы в т.ч. **тестирование сотрудников**. Именно сюда чаще всего относят жалобы «мини-приложение / кабинет на Flask». |
|
||||
| **Отдельный Flask-скелет под тестирование** | `TestingWebApp/flask_app/` | Упрощённое приложение того же стека (переходный контур, Docker-сервис `testing-flask`, порт **3108** в `TestingWebApp/docker-compose.dev.yml`). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. |
|
||||
| **Отдельный Flask-скелет под тестирование** | `TestingWebApp/flask_app/` | Упрощённое приложение того же стека (переходный контур, Docker `testing-flask`, порт **3107**). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. |
|
||||
|
||||
Связанные по смыслу документы (миграция данных, две БД):
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
1. **Веб-кабинет в браузере** (`tgFlaskForm`, типичный порт локально **3104** в `web_run.py`).
|
||||
2. **Встроенный WebView в мини-приложении** (Telegram MAX и т.п.) — тот же HTML с того же хоста, но **другая сеть, кэш, DNS, TLS**; воспроизведение обязательно на целевом клиенте.
|
||||
3. **Переходный контур** `TestingWebApp` на **3108** — проверять отдельно, если пользователи реально ходят туда.
|
||||
3. **Переходный контур** `TestingWebApp` на **3107** — проверять отдельно, если пользователи реально ходят туда.
|
||||
|
||||
Перед оптимизацией **уточнить URL/контур** у тех, кто жалуется.
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
### Фаза 0 — уточнение (полдня максимум)
|
||||
|
||||
- [ ] Точный **URL/продукт** (кабинет HR vs TestingWebApp:3108 vs мини-app WebView).
|
||||
- [ ] Точный **URL/продукт** (кабинет HR vs TestingWebApp:3107 vs мини-app WebView).
|
||||
- [ ] **Роль пользователя** и сценарий (первый заход, каждый клик, только раздел тестирования).
|
||||
- [ ] Есть ли **nginx / CDN** перед приложением.
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
| Авторизация и gate | `HR_TG_Bot/tgFlaskForm/webApp/auth.py` |
|
||||
| Шаблоны | `HR_TG_Bot/tgFlaskForm/webApp/templates/` |
|
||||
| Переходный Flask + waitress | `TestingWebApp/flask_app/run.py`, `TestingWebApp/flask_app/app/` |
|
||||
| Docker dev (пример порта 3108) | `TestingWebApp/docker-compose.dev.yml` |
|
||||
| Docker dev (по умолчанию 3107 legacy) | `TestingWebApp/docker-compose.dev.yml` |
|
||||
| Docker dev кабинета | `HR_TG_Bot/docker-compose.dev.yml` |
|
||||
|
||||
---
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -1,7 +1,25 @@
|
||||
# Предложение по редизайну страницы «Создание теста»
|
||||
|
||||
**Ветка:** `dev-new-design-page-createtest`
|
||||
**Затронутые файлы:** `frontend/src/components/TestForm/index.tsx`, `frontend/src/pages/TestCreate/index.tsx` (через общий компонент), частично `frontend/src/api/llm.ts` (расширение сигнатуры `generate`).
|
||||
## Актуализация (кабинет `TestingWebApp`, 2026)
|
||||
|
||||
**Фактическая реализация** кабинета (React) — **не** отдельный `TestForm` из текста ниже, а страница **`frontend/src/pages/TestDetail.jsx`** (редактирование существующего теста) и глобальные стили **`frontend/src/styles/cabinet-theme.css`**.
|
||||
|
||||
| Блок в UI (как в интерфейсе) | Содержимое |
|
||||
|------------------------------|------------|
|
||||
| **О тесте** | Название, описание, порог зачёта |
|
||||
| **Вопросы** | Панель ИИ «сетка», вопросы/варианты, **«Документ в вопросы»** (импорт) внизу секции |
|
||||
| Панель в потоке + **фикс-футер** `≤640px` | «Сохранить черновик», «К списку» |
|
||||
| **История** | Подзаголовки **Версии** / **Прохождения** |
|
||||
| **Показ в каталоге** | **Видимость**; при включённом назначении — **Кому выдать** (поиск, «Выбрать всех», список) |
|
||||
|
||||
Сводка мобильных доработок и чек-листы: [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). Памятка для пользователей без кода: [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md).
|
||||
|
||||
**Ниже** — **исторический** вариант документа (Ant Design, `TestForm` / `TestCreate` из другой ветки/референса). Имеет смысл читать как **идеи** по группировке, пока **не** как текущий путь к файлам.
|
||||
|
||||
---
|
||||
|
||||
**Ветка (ист.):** `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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Кабинет тестов: коротко, как пользоваться
|
||||
|
||||
*Для врачей, заведующих, кураторов — без IT-терминов.
|
||||
Иллюстрации: [images/cabinet-ui/](images/cabinet-ui/) (схемы-заглушки, можно заменить на скриншоты, см. `README` в той папке).*
|
||||
|
||||
---
|
||||
|
||||
### 1. Список тестов
|
||||
|
||||
Все тесты, к которым у вас есть доступ. **Название** (слева) ведёт в **редактирование** или просмотр, **«Пройти»** (справа) — **сдать** тест, если вам тест **назначили** или открыт самопроход. Редактирование — не у всех ролей.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 2. О тесте
|
||||
|
||||
Название, **описание** для коллег, **порог зачёта** (%). «Паспорт» теста: **что проверяете** и **с какой планкой** зачёт/незачёт.
|
||||
|
||||
---
|
||||
|
||||
### 3. Вопросы
|
||||
|
||||
**Вопросы и варианты** пишите здесь. Слева от варианта — **верные** отметки: один вариант как контрольный; несколько верных — чекбокс **«Несколько верных ответов»**.
|
||||
|
||||
- **+ вопрос** / **+ вариант** — добавить. **Крестик** у варианта — убрать лишний ответ.
|
||||
- **Документ в вопросы** — при необходимости загрузить файл (PDF, Word, текст) и вставить в черновик; не обязательно, если ввели всё вручную.
|
||||
- **ИИ** (если включён) — подсказка, не готовый клинический документ: **проверьте и исправьте** перед публикацией.
|
||||
|
||||
---
|
||||
|
||||
### 4. Сохранить
|
||||
|
||||
**«Сохранить черновик»** (часто **внизу** на телефоне) — чтобы не потерять правки. **«К списку»** — выход; если **уже сохранялись** — данные в черновике записаны.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 5. История
|
||||
|
||||
- **Версии** — когда и как менялся тест. Актуальная отмечена. **«Сделать активной»** — редко, обычно согласуя с IT/методистом.
|
||||
- **Прохождения** — кто уже **сдавал**; **«Разбор»** — ответы по вопросам (если вам **открыт** доступ).
|
||||
|
||||
---
|
||||
|
||||
### 6. Показ в каталоге
|
||||
|
||||
- **Видимость** — показать в **общем** списке тестов или **скрыть** (тест **не** удаляется, просто **не** светится в ленте). «Старые» **ссылки** у кого-то **могут** ещё открываться, если **переадресовали** вручную.
|
||||
- **Кому выдать** (если раздел есть) — **назначение** сотрудникам: **поиск, фильтры, галочки**; **«Выбрать всех»** — только в **текущем** отфильтрованном списке; затем **«Назначить выбранных»**. Это **про людей**, не про редактуру вопросов.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
**В одном движении:** написали **вопросы** → **«Сохранить»** → при **необходимости** **показали** в списке и/или **кому-то** **выдали** тест. Остальное — **по ситуации**.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Рекомендации UX по экранам редактирования теста
|
||||
|
||||
*Основание: скриншоты в `docs/screens`, словарь `docs/Словарь UX-UI-IA терминов.md`. Дата фиксации: 29.04.2026.*
|
||||
|
||||
---
|
||||
|
||||
## Навигация и IA
|
||||
|
||||
- **Хлебные крошки.** Сейчас только «← к списку». Имеет смысл добавить полную цепочку вроде «Тесты → Введение про LLM → Редактирование», чтобы снизить когнитивную нагрузку и отразить иерархию сущностей (Тест → Версия).
|
||||
- **Якоря по длинной странице.** Блоки «О тесте», «Вопросы», «История», «Показ в каталоге» образуют длинную вертикаль. Полезны боковое оглавление или «прыжки» по разделам / закреплённая поднавигация внутри страницы теста, чтобы не терять контекст при работе с нижними вопросами и назначением.
|
||||
|
||||
---
|
||||
|
||||
## Состояния интерфейса и обратная связь
|
||||
|
||||
- **ИИ-кнопки.** Для «Сгенерировать тест (ИИ)» и «Сгенерировать вопрос (ИИ)» нужны явные состояния: загрузка (спиннер, disabled), успех/ошибка, при необходимости — отмена длительной операции (видимость статуса системы).
|
||||
- **Черновик и риск потери данных.** Уже есть заметный «Сохранить черновик» и жёлтый баннер про новую версию — хорошо. Дополнительно: предупреждение при уходе со страницы с несохранёнными изменениями; для длинной формы — **закреплённая панель** с сохранением (или дублирование primary-действия после блока вопросов), чтобы не скроллить вниз каждый раз.
|
||||
|
||||
---
|
||||
|
||||
## Редактор вопросов (UI и логика)
|
||||
|
||||
- **Один vs несколько верных ответов.** При включённом «Несколько верных ответов» визуально должны быть **чекбоксы**, а не радиокнопки — соответствие метафоре, ожиданиям пользователя и доступности (скринридер, множественный выбор).
|
||||
- **Разделение действий.** «+ вариант» и «Удалить вопрос» сейчас визуально близки по весу — риск ошибочного клика. Деструктивное действие: вторичный стиль, отступ, по желанию подтверждение или «Удалить» в меню «⋯».
|
||||
- **Иерархия ИИ vs ручное редактирование.** Блок «Генерация сетки (ИИ)» логично оформить как сворачиваемый «продвинутый» блок или визуально отделить (заголовок, граница), чтобы отличать массовую генерацию от точечной «Сгенерировать вопрос» у карточки.
|
||||
- **Длинные варианты ответа.** Обрезка текста в однострочном поле мешает автору. Варианты: многострочное поле с авто-ростом по высоте или предпросмотр полной строки при фокусе/hover.
|
||||
|
||||
---
|
||||
|
||||
## Локализация и терминология
|
||||
|
||||
- В истории статус **`in_progress` на английском** при русском интерфейсе — заменить на «В процессе» или единый глоссарий статусов прохождения.
|
||||
- В шапке роль **`employee`** — унифицировать с русскими названиями ролей из словаря проекта (сотрудник, HR и т.д.).
|
||||
|
||||
---
|
||||
|
||||
## «Показ в каталоге» и список сотрудников
|
||||
|
||||
- **Кнопка «Назначить выбранных».** Сейчас выглядит как вторичная; это главное действие сценария выдачи теста. Имеет смысл сделать её **заполненной primary** при наличии выбора и **disabled с подсказкой**, если никто не выбран.
|
||||
- **Повтор строки «нет учётки (создадим при назначении)».** На каждой строке создаётся шум. Лучше: один информационный блок над списком; в строке — компактный бейдж/иконка только где уместно.
|
||||
- **Крайний случай: много сотрудников.** При сотнях/тысячах записей — виртуализация, пагинация или «выбрать всех по фильтру» с явным числом «будет назначено N человек».
|
||||
|
||||
---
|
||||
|
||||
## История и версии
|
||||
|
||||
- При росте списка карточки версий и прохождений превращаются в длинную простыню — предусмотреть **свёрнутый список**, пагинацию или табы «Версии» / «Прохождения» с фильтром по версии и статусу.
|
||||
|
||||
---
|
||||
|
||||
## Доступность и плотность
|
||||
|
||||
- Мелкий серый текст в списке сотрудников — проверить контраст (WCAG).
|
||||
- Чекбоксы и переключатели: достаточная зона клика, связь подписи с полем, логичный порядок табуляции.
|
||||
|
||||
---
|
||||
|
||||
## Пустые состояния
|
||||
|
||||
- Пустая история, нет вопросов, поиск «никого не нашёл» — короткий текст **почему пусто** и **следующий шаг** («Добавьте вопрос», «Измените фильтр»).
|
||||
|
||||
---
|
||||
|
||||
## Приоритизация внедрения
|
||||
|
||||
1. **Высокий эффект / низкий риск:** локализация статусов и ролей; визуальное различие «Удалить вопрос» vs «+ вариант»; primary для «Назначить выбранных»; убрать повтор длинного текста про учётку в каждой строке.
|
||||
2. **Средний:** чекбоксы при нескольких верных ответах; многострочные варианты; состояния загрузки для ИИ; закреплённое сохранение.
|
||||
3. **Стратегический:** хлебные крошки и внутренняя навигация по разделу; масштабирование списка назначений; пустые состояния; продуктовая аналитика (например, доходят ли авторы до «Показ в каталоге»).
|
||||
|
||||
---
|
||||
|
||||
*Документ можно дополнять по мере внедрения и новых скринов.*
|
||||
@@ -64,73 +64,4 @@
|
||||
|
||||
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`, `frontend/src/pages/TestsList.jsx` (только косвенно через стили, разметка списка ранее).
|
||||
|
||||
---
|
||||
|
||||
## Спринт 4 — памятка для врачей/заведующих (без IT-сленга)
|
||||
|
||||
**Цель:** в этом же файле в конце — краткая инструкция, как пользоваться кабинетом тестов; иллюстрации к разделу; никакой отдельной «саги» про дизайн-токены.
|
||||
|
||||
- [x] **4.1** Блок «Как пользоваться» простым языком, по смыслу блоков интерфейса
|
||||
- [x] **4.2** Папка `docs/images/cabinet-ui/`: схемы-заглушки (SVG); при необходимости **замените** их своими **скриншотами** с тем же именем файла или поправьте пути внизу документа
|
||||
- [x] **4.3** Отдельный спринт про design tokens **убран** — при желании вынесите в issue/другой документ
|
||||
|
||||
**Файлы:** `docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md` (блок внизу), `docs/images/cabinet-ui/*`.
|
||||
|
||||
---
|
||||
|
||||
## Как пользоваться: коротко и по делу
|
||||
|
||||
*Если картинки не открываются — проверьте, что рядом с этим файлом лежат файлы в `images/cabinet-ui/`. Схемы можно заменить на настоящие скриншоты из мини-приложения (PNG) — с теми же названиями.*
|
||||
|
||||
### 1. Список тестов
|
||||
|
||||
Тут — все доступные вам тесты. **Название** (или область слева) ведёт в **редактирование**, кнопка **«Пройти»** — **сдать** тест, если вы в роли сотрудника, а не автора. Не путайте: редактировать могут не все.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 2. О тесте
|
||||
|
||||
Название, **описание** для коллег, **порог зачёта** (процент). Это «паспорт» теста: заполните так, чтобы было понятно, **что проверяете** и с какой планкой «зачёт/незачёт».
|
||||
|
||||
---
|
||||
|
||||
### 3. Вопросы
|
||||
|
||||
Здесь вы **пишете сами** вопросы и **варианты ответов**. Кружок/квадратик слева от варианта — **отметьте верные**. Один верный = как контрольный; несколько = можно включить **«Несколько верных ответов»**.
|
||||
|
||||
- **+ вопрос** / **+ вариант** — добавить.
|
||||
- **Крестик** у варианта — убрать лишний ответ.
|
||||
- **Документ в вопросы** — подгрузить **файл** (PDF, Word, текст) и по подсказкам вставить в черновик; **не** обязателен, если вы всё ввели руками.
|
||||
- **ИИ** (если есть) — **помощь**, а не готовый клинический протокол: всё равно **прочитайте и исправьте** перед публикацией.
|
||||
|
||||
---
|
||||
|
||||
### 4. Сохранить
|
||||
|
||||
Кнопка **«Сохранить черновик»** (часто закреплена **внизу экрана** на телефоне) — **обязательно**, если не хотите потерять правки. **«К списку»** — выйти без паники: если **уже нажимали «Сохранить»** до этого, черновик сохранён.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 5. История
|
||||
|
||||
- **Версии** — когда менялся тест. Актуальная помечена. Редко нужно **«Сделать активной»** другую версию — обычно по согласованию с IT/методистом.
|
||||
- **Прохождения** — **кто уже сдавал**; «Разбор» — посмотреть **ответы** (если вам открыт доступ). Для **обычного заведения теста** смотрите **редко**; для **разбора ситуации** — полезно.
|
||||
|
||||
---
|
||||
|
||||
### 6. Показ в каталоге
|
||||
|
||||
- **Видимость** — **показать** тест **всех** в списке или **скрыть** (тест **не** удаляется, просто **не мешается** в ленте). Скрытие **не** отменяет уже выданные ссылки, если кто-то ими **поделился**.
|
||||
- **Кому выдать** (если раздел есть) — **кому** из сотрудников **назначить** тест. Сначала **поиск/фильтр**, **галочки**, при необходимости **«Выбрать всех»** по **текущему** списку, затем **«Назначить выбранных»**. Это **про людей**, не про текст вопросов.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### В одном предложении
|
||||
|
||||
**Написали вопросы → нажали «Сохранить» → при необходимости показали в списке / кому-то выдали.** Всё остальное — по ситуации.
|
||||
**Памятка для пользователей (не-разработчиков), иллюстрации:** [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) · [images/cabinet-ui/](images/cabinet-ui/README.md)
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Словарь терминов проектирования
|
||||
|
||||
**UX · UI · IA и смежные понятия**
|
||||
|
||||
*Контекст: HR system / Платформа Цифровых Сервисов клиники им. Е. Н. Оленевой*
|
||||
|
||||
---
|
||||
|
||||
Короткий справочник, чтобы команда говорила на одном языке. Для каждого термина: русское название, английский эквивалент, короткое определение и пример из вашего продукта, чтобы было понятно, как термин применяется в реальной работе.
|
||||
|
||||
Файл живой: добавляйте сюда термины, которые регулярно всплывают в обсуждениях.
|
||||
|
||||
---
|
||||
|
||||
## 1. Три основных слоя проектирования
|
||||
|
||||
Три понятия, которые часто путают друг с другом. Это не синонимы и не одно и то же — это три разных профессиональных взгляда на один и тот же продукт.
|
||||
|
||||
### UX (User Experience) — *опыт взаимодействия / пользовательский опыт*
|
||||
|
||||
Совокупность ощущений пользователя от взаимодействия с продуктом: насколько просто понять, как достичь цели, насколько быстро это получается, насколько мало раздражения по дороге. UX — это про задачу пользователя, а не про конкретный экран.
|
||||
|
||||
> *Пример из HR system:* HR-менеджер хочет назначить тест 50 сотрудникам отделения. Хороший UX — он делает это в три клика через фильтр по отделу. Плохой UX — он скроллит список из 147 человек и отмечает чекбоксами вручную.
|
||||
|
||||
### UI (User Interface) — *пользовательский интерфейс*
|
||||
|
||||
Видимая и кликабельная часть продукта: кнопки, поля, цвета, иконки, типографика, состояния (наведение, фокус, ошибка). UI — это про то, как продукт выглядит и как откликается на действия.
|
||||
|
||||
> *Пример из HR system:* Кнопка «Сохранить черновик» на странице теста — её цвет, размер, скруглённые углы, текст внутри, реакция на наведение курсора — это всё UI.
|
||||
|
||||
### IA (Information Architecture) — *информационная архитектура*
|
||||
|
||||
Структура продукта на уровне «что где лежит и как связано»: какие есть разделы, какие сущности живут внутри, по какой логике пользователь переходит с одной страницы на другую. IA — это скелет, на который потом натягиваются UX и UI.
|
||||
|
||||
> *Пример из HR system:* Решение «авторская работа над тестом, назначение и отчётность — это три разных раздела меню, а не один длинный аккордеон» — это IA-решение.
|
||||
|
||||
---
|
||||
|
||||
## 2. Исследования и работа с пользователем
|
||||
|
||||
### Целевая аудитория — *target audience*
|
||||
|
||||
Группы людей, для которых проектируется продукт. У каждой группы своя задача и контекст использования.
|
||||
|
||||
> *Пример из HR system:* В вашей системе четыре аудитории: сотрудник, руководитель подразделения, HR-менеджер, директор. У них разные потребности и разные роли в системе.
|
||||
|
||||
### Персона — *persona*
|
||||
|
||||
Собирательный образ типичного представителя аудитории: имя, должность, цели, ограничения, частые сценарии. Помогает команде договориться, для кого мы решаем задачу.
|
||||
|
||||
> *Пример из HR system:* «Ольга, HR-менеджер, 35 лет. Раз в квартал назначает массовое обучение 200 сотрудникам. Не любит интерфейсы, где надо кликать каждого по отдельности. Открывает систему с рабочего ноутбука и иногда с телефона на ходу.»
|
||||
|
||||
### Сценарий использования — *user scenario / use case*
|
||||
|
||||
История: пользователь приходит с какой-то задачей и проходит шаги, чтобы её решить. Сценарий описывает, что он делает и какие ожидания у него есть.
|
||||
|
||||
> *Пример из HR system:* «Руководитель отделения хочет, чтобы все его подчинённые прошли тест по пожарной безопасности до конца квартала. Он входит в систему, выбирает свой отдел, выбирает тест, ставит дедлайн, отправляет.»
|
||||
|
||||
### Пользовательский путь / CJM — *Customer Journey Map*
|
||||
|
||||
Развёрнутая визуализация пути пользователя: шаги, точки контакта, эмоции на каждом этапе, где возникают проблемы (pain points) и где можно улучшить.
|
||||
|
||||
> *Пример из HR system:* CJM сотрудника: получил уведомление → открыл письмо → перешёл по ссылке → ввёл логин → увидел список назначенных тестов → выбрал → прошёл → получил результат. На каждом шаге — что ему легко, а что мешает.
|
||||
|
||||
### JTBD (Jobs To Be Done) — *работы, которые нужно выполнить*
|
||||
|
||||
Подход: люди не «пользуются продуктом», они «нанимают» его, чтобы сделать конкретную работу. Помогает увидеть истинную мотивацию, а не поверхностный запрос.
|
||||
|
||||
> *Пример из HR system:* HR не «нанимает» вашу систему, чтобы кликать по чекбоксам. Он нанимает её, чтобы доказать аудиту, что 100% персонала прошли инструктаж в срок.
|
||||
|
||||
### Pain point — *болевая точка*
|
||||
|
||||
Конкретное место, где пользователю плохо: непонятно, медленно, страшно, обидно. Pain points — главные кандидаты на улучшение.
|
||||
|
||||
> *Пример из HR system:* На странице теста пользователь не понимает, где кнопка «Сохранить», и боится потерять изменения — это pain point.
|
||||
|
||||
---
|
||||
|
||||
## 3. Информационная архитектура и навигация
|
||||
|
||||
### Карта сайта / структура продукта — *sitemap*
|
||||
|
||||
Иерархическое описание всех экранов и разделов продукта. Показывает, что есть в продукте и в каких отношениях разделы стоят друг к другу.
|
||||
|
||||
> *Пример из HR system:* Главная → Тесты → [страница теста] → Назначения → Отчёты → Сотрудники → Настройки.
|
||||
|
||||
### Навигация — *navigation*
|
||||
|
||||
Способ перемещаться по продукту: главное меню, хлебные крошки, ссылки, табы, кнопки «назад». Навигация бывает первичной (основной), вторичной и контекстной.
|
||||
|
||||
> *Пример из HR system:* Шапка с логотипом «Тестирование», меню справа («Тесты», «Назначения», «Отчёты»), ссылка «← к списку» наверху страницы — всё это элементы навигации.
|
||||
|
||||
### Хлебные крошки — *breadcrumbs*
|
||||
|
||||
Цепочка ссылок, показывающая, где пользователь находится в иерархии и куда можно вернуться: «Тесты / Введение про LLM / Редактирование».
|
||||
|
||||
> *Пример из HR system:* Сейчас на странице теста есть только «← к списку». Полные крошки помогли бы быстрее ориентироваться.
|
||||
|
||||
### Таксономия — *taxonomy*
|
||||
|
||||
Набор категорий и тегов, по которым классифицируются объекты. Хорошая таксономия позволяет быстро находить нужное и не плодит дубликаты.
|
||||
|
||||
> *Пример из HR system:* Тест может иметь категории: «обязательные», «рекомендованные», «по специальности», «обучающие». Это таксономия.
|
||||
|
||||
### Сущность / объект предметной области — *entity / domain object*
|
||||
|
||||
Главные «существительные» вашей системы: Тест, Версия теста, Вопрос, Вариант, Сотрудник, Назначение, Прохождение, Отчёт. Дизайн начинается с понимания, какие сущности есть и как они связаны.
|
||||
|
||||
> *Пример из HR system:* Связь «Тест → Версия → Прохождение» позволяет фиксировать результаты конкретной версии, даже если автор потом изменил вопросы.
|
||||
|
||||
---
|
||||
|
||||
## 4. Проектирование интерфейса
|
||||
|
||||
### Вайрфрейм — *wireframe*
|
||||
|
||||
Скелетный набросок экрана без цвета и стилей: просто блоки, поля, кнопки, чтобы показать структуру и иерархию. Используется на ранних этапах для быстрого обсуждения.
|
||||
|
||||
> *Пример из HR system:* Перед прорисовкой страницы создания теста — простой набросок: «слева 70% — форма, справа 30% — превью теста».
|
||||
|
||||
### Макет — *mockup*
|
||||
|
||||
Визуально проработанный вариант экрана: с реальными цветами, шрифтами, иконками, но обычно статичный (не кликается).
|
||||
|
||||
> *Пример из HR system:* Готовый Figma-макет страницы теста, согласованный с вашим зелёным брендом и шрифтом.
|
||||
|
||||
### Прототип — *prototype*
|
||||
|
||||
Кликабельная модель продукта: можно жать на кнопки, переходить между экранами, увидеть переходы. Прототип бывает разной степени проработанности — от карандашных набросков до почти-настоящего продукта.
|
||||
|
||||
> *Пример из HR system:* Кликабельный прототип в Figma, на котором можно «пройти» сценарий «создал тест → назначил отделению → получил уведомление о результате».
|
||||
|
||||
### Состояния интерфейса — *states*
|
||||
|
||||
Один и тот же элемент или экран в разных ситуациях: пустой, загрузка, ошибка, успех, наведение, фокус, отключённый. Хорошие проекты прорисовывают все состояния, а не только «всё хорошо».
|
||||
|
||||
> *Пример из HR system:* Кнопка «Назначить выбранных» имеет состояния: disabled (никто не выбран), normal, hover, loading (отправка идёт), success (готово).
|
||||
|
||||
### Empty state — *пустое состояние*
|
||||
|
||||
Что пользователь видит, когда данных нет: список пуст, поиск ничего не нашёл, ещё ничего не назначено. Хороший empty state объясняет, почему пусто, и предлагает следующий шаг.
|
||||
|
||||
> *Пример из HR system:* Сотрудник заходит и видит пустой список «Мои тесты». Empty state: «Сейчас вам ничего не назначено. Когда руководитель добавит тест — он появится здесь.»
|
||||
|
||||
### Edge case — *крайний случай*
|
||||
|
||||
Редкая, но возможная ситуация: ноль элементов, тысяча элементов, очень длинный текст, обрыв сети. Игнорирование edge cases ломает интерфейс именно тогда, когда пользователь меньше всего этого ожидает.
|
||||
|
||||
> *Пример из HR system:* Что если в клинике 5000 сотрудников, а не 147? Список «Кому выдать» сегодня этого не выдержит — это edge case, который нужно учесть.
|
||||
|
||||
### Happy path — *счастливый сценарий*
|
||||
|
||||
Идеальное прохождение сценария без ошибок и непредвиденных ситуаций. Полезно как стартовая точка, но проектирование только под happy path — частая ошибка.
|
||||
|
||||
> *Пример из HR system:* «Автор создаёт тест, заполняет 7 вопросов, сохраняет, назначает отделу, все проходят» — это happy path. А что если у автора оборвался интернет на полпути?
|
||||
|
||||
---
|
||||
|
||||
## 5. Дизайн-система и компоненты
|
||||
|
||||
### Дизайн-система — *design system*
|
||||
|
||||
Набор готовых правил, компонентов и токенов (цветов, отступов, шрифтов), которыми пользуется вся команда. Цель — единообразие и скорость: не изобретать каждый раз кнопку с нуля.
|
||||
|
||||
> *Пример из HR system:* Внутри Платформы Цифровых Сервисов клиники должна быть единая дизайн-система: HR system, регистратура, эндовидеоплатформа выглядят как продукты одной семьи.
|
||||
|
||||
### UI-кит — *UI kit*
|
||||
|
||||
Библиотека готовых интерфейсных элементов (кнопки, поля, модалки, таблицы) в Figma или коде, которой пользуются дизайнеры и разработчики.
|
||||
|
||||
> *Пример из HR system:* Если у вас есть UI-кит, новая страница «Назначения» собирается из готовых компонентов за день, а не за неделю.
|
||||
|
||||
### Компонент — *component*
|
||||
|
||||
Самостоятельный кусочек интерфейса с понятным API: входные параметры, состояния, поведение. Кнопка, поле ввода, аккордеон, модалка — всё это компоненты.
|
||||
|
||||
> *Пример из HR system:* Аккордеон «О тесте» / «Вопросы» / «История» / «Показ в каталоге» — четыре экземпляра одного и того же компонента «аккордеон».
|
||||
|
||||
### Токен дизайна — *design token*
|
||||
|
||||
Атомарная переменная стиля: цвет, отступ, размер шрифта, радиус скругления. Токены позволяют менять оформление всего продукта централизованно.
|
||||
|
||||
> *Пример из HR system:* Цвет `primary-green = #2E7D5B` — токен. Если решите перейти на другой оттенок зелёного, меняете в одном месте, и все кнопки обновляются.
|
||||
|
||||
### Паттерн — *pattern*
|
||||
|
||||
Типовое решение типовой задачи: «как реализовать поиск с фильтрами», «как показать длинный список». Паттерны — это коллективная мудрость комьюнити.
|
||||
|
||||
> *Пример из HR system:* Паттерн «master-detail»: слева список тестов, справа детали выбранного. Хорошо ложится на ваш будущий раздел «Назначения».
|
||||
|
||||
---
|
||||
|
||||
## 6. Качество и проверка дизайна
|
||||
|
||||
### Юзабилити — *usability*
|
||||
|
||||
Свойство интерфейса быть простым и эффективным в использовании. Измеряется через эффективность (получилось ли), скорость и количество ошибок.
|
||||
|
||||
> *Пример из HR system:* Если сотрудник не может с первого раза найти, как пройти тест — у интерфейса проблема с юзабилити.
|
||||
|
||||
### Доступность — *accessibility / a11y*
|
||||
|
||||
Возможность использовать продукт людям с особенностями: слабовидящим, незрячим (через скринридеры), людям с моторными ограничениями (только клавиатура), дальтоникам. Стандарт — WCAG.
|
||||
|
||||
> *Пример из HR system:* Радиокнопки выбора правильного варианта должны быть доступны с клавиатуры (Tab + Space) и понятны скринридеру («Вариант 1 из 3, выбран»).
|
||||
|
||||
### Юзабилити-тестирование — *usability testing*
|
||||
|
||||
Метод исследования: реальный пользователь выполняет задание, исследователь наблюдает, где он спотыкается. Дешёвый способ найти большую часть проблем.
|
||||
|
||||
> *Пример из HR system:* Дать HR-менеджеру задание «назначь этот тест всему отделению хирургии до 1 мая» и записать, где он зависнет.
|
||||
|
||||
### Эвристическая оценка — *heuristic evaluation*
|
||||
|
||||
Эксперт сверяет интерфейс с набором эвристик (правил хорошего дизайна, например, эвристиками Нильсена) и фиксирует нарушения. Быстрее теста с пользователями, но менее точно.
|
||||
|
||||
> *Пример из HR system:* Анализ страницы теста, который мы делаем сейчас — это, по сути, эвристическая оценка.
|
||||
|
||||
### A/B-тест — *A/B testing*
|
||||
|
||||
Сравнение двух вариантов интерфейса на реальной аудитории: половина видит вариант A, половина — B; измеряем, какой работает лучше.
|
||||
|
||||
> *Пример из HR system:* Сравнить две формулировки кнопки: «Сохранить черновик» vs «Сохранить и назначить» — что чаще ведёт к завершению задачи.
|
||||
|
||||
### Аналитика продукта — *product analytics*
|
||||
|
||||
Сбор и анализ данных о том, как пользователи реально пользуются продуктом: где кликают, где бросают, сколько времени проводят. Подсказывает, где искать проблемы.
|
||||
|
||||
> *Пример из HR system:* Если в аналитике видно, что 40% авторов не доходят до раздела «Показ в каталоге» — это сигнал, что его упускают.
|
||||
|
||||
---
|
||||
|
||||
## 7. Технические понятия, нужные дизайнеру
|
||||
|
||||
### Респонсив / адаптивность — *responsive design*
|
||||
|
||||
Способность интерфейса корректно работать на разных размерах экрана: от телефона до большого монитора. Не путать с «мобильной версией».
|
||||
|
||||
> *Пример из HR system:* Список «Кому выдать» должен оставаться удобным на 13-дюймовом ноутбуке руководителя и на телефоне HR-менеджера в дороге.
|
||||
|
||||
### Брейкпойнт — *breakpoint*
|
||||
|
||||
Ширина экрана, на которой меняется раскладка интерфейса. Типовые: 360, 768, 1024, 1440 px.
|
||||
|
||||
> *Пример из HR system:* На брейкпойнте 768 px (планшет) две колонки на странице теста схлопываются в одну.
|
||||
|
||||
### RBAC — *Role-Based Access Control / ролевая модель доступа*
|
||||
|
||||
Правила, что какая роль видит и может делать в системе. Дизайн интерфейса должен учитывать роль: один и тот же экран показывается по-разному сотруднику, руководителю, HR и директору.
|
||||
|
||||
> *Пример из HR system:* Сотрудник видит только «Мои тесты». Руководитель — ещё «Мой отдел». HR — все назначения. Директор — сводный отчёт.
|
||||
|
||||
### Версионирование — *versioning*
|
||||
|
||||
Подход, при котором у объекта (теста, документа) есть несколько версий, и история фиксируется. Полезно для аудита и неизменности результатов.
|
||||
|
||||
> *Пример из HR system:* В вашей системе тест имеет версии v1, v2 и т.д. Прохождение всегда привязано к конкретной версии — изменения автора не «переписывают» прошлые результаты.
|
||||
|
||||
### Состояние черновика — *draft state*
|
||||
|
||||
Промежуточное состояние объекта: ещё не опубликован/не активирован, можно безопасно править.
|
||||
|
||||
> *Пример из HR system:* Кнопка «Сохранить черновик» означает: тест сохранён, но пока не выдан сотрудникам. Можно ещё дорабатывать.
|
||||
|
||||
### Уведомление — *notification*
|
||||
|
||||
Сообщение системы пользователю: всплывающее (toast), баннер на странице, push, e-mail. Каждый канал имеет свои правила использования.
|
||||
|
||||
> *Пример из HR system:* Тост «Тест сохранён» после нажатия кнопки. E-mail сотруднику с дедлайном по назначенному тесту.
|
||||
|
||||
---
|
||||
|
||||
## 8. Терминология этого проекта
|
||||
|
||||
Чтобы команда не путалась, фиксируем основные сущности HR system явно.
|
||||
|
||||
- **Тест** — учебный материал, состоящий из вопросов с вариантами ответов. Один тест может иметь несколько версий.
|
||||
- **Версия теста** — снимок содержимого теста на момент сохранения. Прохождение всегда привязано к конкретной версии.
|
||||
- **Вопрос** — отдельный пункт теста с формулировкой и набором вариантов. Может допускать один или несколько верных ответов.
|
||||
- **Вариант ответа** — один из предложенных ответов на вопрос. Помечается как верный или нет.
|
||||
- **Назначение** — связь «тест × сотрудник × срок». Формирует у сотрудника обязательство пройти этот тест.
|
||||
- **Прохождение** — попытка сотрудника пройти конкретную версию теста. Имеет статус (в процессе, пройдено, не пройдено) и результат (X из Y).
|
||||
- **Порог зачёта** — процент правильных ответов, начиная с которого прохождение засчитывается.
|
||||
- **Каталог** — общий список тестов, видимый сотрудникам с правами.
|
||||
- **Роль** — профиль доступа: сотрудник, руководитель подразделения, HR-менеджер, директор.
|
||||
|
||||
---
|
||||
|
||||
## Полезные ссылки и стандарты
|
||||
|
||||
- **Эвристики Якоба Нильсена** — 10 базовых правил юзабилити: [nngroup.com](https://www.nngroup.com/articles/ten-usability-heuristics/)
|
||||
- **WCAG 2.2** — стандарт доступности: [w3.org/TR/WCAG22](https://www.w3.org/TR/WCAG22/)
|
||||
- **Material Design** — [m3.material.io](https://m3.material.io/) и **Apple HIG** — [developer.apple.com/design](https://developer.apple.com/design/human-interface-guidelines/) — два больших источника готовых паттернов и принципов.
|
||||
- **Refactoring UI** (Adam Wathan, Steve Schoger) — настольная книга по практическому UI-дизайну.
|
||||
|
||||
---
|
||||
|
||||
*— Справочник можно дополнять по мере появления новых терминов —*
|
||||
@@ -49,6 +49,11 @@
|
||||
|
||||
*Фактические маршруты API в `TestingWebApp`: префикс `/api/tests`, версии и черновик — см. [../PROJECT_STATUS.md](../PROJECT_STATUS.md).*
|
||||
|
||||
### 5.6. UI кабинета (React, `TestDetail`)
|
||||
|
||||
- Редактор теста: **`frontend/src/pages/TestDetail.jsx`**, стили **`frontend/src/styles/cabinet-theme.css`**: аккордеоны **«О тесте»**, **«Вопросы»** (в т.ч. **«Документ в вопросы»**), **«История»** (внутри: **Версии** + **Прохождения**), **«Показ в каталоге»** (**Видимость**; при `assignmentUi` — **Кому выдать**), фикс-футер **«Сохранить / К списку»** на моб. ширинах.
|
||||
- Сводка мобильного UI и чек-лист: [../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
|
||||
|
||||
## Результат
|
||||
|
||||
- Полноценный конструктор тестов
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Файлы **01**–**11** — **проектные шаги** (целевое поведение и API), а не автоматическая копия кода. Фактическое состояние фич, сценарии «как у пользователя» и ветка **`dev`** описаны в:
|
||||
|
||||
- [../PROJECT_STATUS.md](../PROJECT_STATUS.md) — что сделано и что в планах;
|
||||
- [../DEV_CONTOUR_USER_GUIDE.md](../DEV_CONTOUR_USER_GUIDE.md) — инструкция для проверки на dev-стенде.
|
||||
- [../DEV_CONTOUR_USER_GUIDE.md](../DEV_CONTOUR_USER_GUIDE.md) — инструкция для проверки на dev-стенде;
|
||||
- [../РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md](../РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md) — тезисы для врачей/кураторов; [../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) — чек-лист моб. UI.
|
||||
|
||||
Журнал приёмки: [../revision_task/TESTING_JOURNAL.md](../revision_task/TESTING_JOURNAL.md).
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
# Порт HTTP (не пересекать с :3107 текущего compose)
|
||||
PORT=3108
|
||||
# ─── HTTP сервер ─────────────────────────────────────────────────
|
||||
# Порт как в docker-compose (:3107).
|
||||
PORT=3107
|
||||
FLASK_DEBUG=1
|
||||
SECRET_KEY=change-me-in-dev-only
|
||||
|
||||
# В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml)
|
||||
# WEB_USE_WAITRESS=1
|
||||
|
||||
# Секрет для подписи cookies / Flask sessions.
|
||||
# В dev можно оставить заглушку — будет сгенерирован случайный, но при рестарте
|
||||
# процесса сессии "обнулятся" (это критично для логина/CSRF).
|
||||
SECRET_KEY=change-me-in-dev-only
|
||||
|
||||
# ─── База данных (clinic_tests, та же, что у Express-бэкенда) ────
|
||||
# Этап 1: продолжаем работать с clinic_tests, схему не меняем.
|
||||
# Локально:
|
||||
# DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
|
||||
# В Docker рядом с HR (общая сеть Postgres_TG_Bots):
|
||||
# DATABASE_URL=postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
|
||||
DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
|
||||
|
||||
# ─── Опциональная HR-аутентификация (как в Express-бэкенде) ──────
|
||||
# Если HR_AUTH=1, используем БД hr_bot_test для проверки логина/пароля
|
||||
# (Werkzeug-хеш в public.users.password по web_login = username).
|
||||
# HR_AUTH=1
|
||||
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test
|
||||
|
||||
# ─── Редактирование тестов (до внедрения RBAC) ───────────────────
|
||||
# По умолчанию любой залогиненный пользователь может править любой тест.
|
||||
# Чтобы снова ограничить сохранение/ИИ/версии только автором цепочки:
|
||||
# CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR=1
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM python:3.11-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PORT=3108 \
|
||||
PORT=3107 \
|
||||
WEB_USE_WAITRESS=1 \
|
||||
FLASK_DEBUG=0
|
||||
|
||||
@@ -15,7 +15,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY run.py .
|
||||
COPY app ./app
|
||||
COPY alembic.ini .
|
||||
COPY alembic ./alembic
|
||||
|
||||
EXPOSE 3108
|
||||
EXPOSE 3107
|
||||
|
||||
CMD ["python", "run.py"]
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
# Flask-контур тестирования (тот же стек, отдельный деплой)
|
||||
# Flask-контур тестирования (целевой стек)
|
||||
|
||||
Здесь — **новое** приложение на **Python / Flask** в духе `HR_TG_Bot/tgFlaskForm` (шаблоны + серверный рендер, без React). Старый стек (`backend/` + `frontend/`) пока не удаляется: оба контура могут существовать параллельно, пока не зафиксирована политика «один источник записи» и cutover.
|
||||
Приложение на **Python 3.11 / Flask 3 / Jinja2** в духе
|
||||
`HR_TG_Bot/tgFlaskForm` (серверный рендер, без React). На этом контуре
|
||||
работает прод **[edullm.pirogov.ai](https://edullm.pirogov.ai/)**.
|
||||
|
||||
Старый стек (`backend/` + `frontend/`) пока не удаляется: оба контура
|
||||
существуют параллельно до закрытия паритета (спринты **E1.4–E1.6** в
|
||||
[`docs/migration-final.md`](../docs/migration-final.md)).
|
||||
|
||||
## Запуск в Docker (рекомендуется)
|
||||
|
||||
Из **корня** репозитория TestingWebApp. Сервис **не** зависит от `testing-backend` и **не** требует внешней сети Postgres для старта (только внутренняя сеть compose).
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build testing-flask
|
||||
```
|
||||
|
||||
Когда подключите БД из контейнера к `hr_postgres_dev` / `clinic_tests`, в `docker-compose.dev.yml` у сервиса `testing-flask` добавьте сеть `postgres` (как у `testing-backend`).
|
||||
|
||||
- **URL:** http://localhost:3108
|
||||
- **Проверка:** http://localhost:3108/health
|
||||
|
||||
Вместе со старым UI и API:
|
||||
Из **корня** репозитория TestingWebApp. `docker-compose.dev.yml` уже
|
||||
подключён к сети `postgres` (`hr_postgres_dev_net`) и пробрасывает
|
||||
`DATABASE_URL`, `HR_AUTH`, `HR_DATABASE_URL`, `DEEPSEEK_API_KEY` /
|
||||
`OPENAI_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` из корневого `.env`.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
Тогда Node-интерфейс остаётся на **http://localhost:3107**, Flask — на **3108**.
|
||||
- **URL:** http://localhost:3107
|
||||
- **Проверка:** http://localhost:3107/health
|
||||
|
||||
## Запуск локально (без Docker)
|
||||
|
||||
@@ -34,7 +33,7 @@ cp .env.example .env # при необходимости поправьте
|
||||
python run.py
|
||||
```
|
||||
|
||||
По умолчанию: **http://127.0.0.1:3108** , проверка: **http://127.0.0.1:3108/health**
|
||||
По умолчанию (без `PORT` в `.env`): **http://127.0.0.1:3107** , проверка: **http://127.0.0.1:3107/health**
|
||||
Для режима как в Docker (waitress): в `.env` задайте `WEB_USE_WAITRESS=1`.
|
||||
|
||||
## Зачем отдельная папка
|
||||
@@ -42,13 +41,32 @@ python run.py
|
||||
- Один **стек** с кабинетом / мини-приложением — проще переносить экраны и запросы из `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`.
|
||||
- **Отдельный** процесс и порт — без риска сломать текущий `docker-compose.dev.yml` с Node до готовности.
|
||||
|
||||
## Дальнейшие шаги (код)
|
||||
## Состояние спринтов
|
||||
|
||||
1. Подключить БД (`clinic_tests` **или** `hr_bot_test` + `testing_*` — одно из двух, см. [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) §0).
|
||||
2. Переносить маршруты и шаблоны по образцу `tgFlaskForm` (blueprint `testing`, `db/queries/testing_queries.py`, шаблоны `cabinet/testing/`).
|
||||
3. ETL при переходе на HR-БД: `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`.
|
||||
Этот каталог — место разработки **Этапа 1**
|
||||
([migration-final.md](../docs/migration-final.md)).
|
||||
|
||||
| Спринт | Что входит | Статус |
|
||||
|---|---|---|
|
||||
| E1.0 | База Flask-приложения, БД-пул, сессии, `base.html`. | ✅ |
|
||||
| E1.1 | Auth + `/api/me` (bcrypt + Werkzeug, опц. `HR_AUTH`). | ✅ |
|
||||
| E1.2 | Каталог тестов и редактор (функциональный минимум). | ✅ |
|
||||
| E1.3 | Импорт документов (PDF / DOCX / TXT / MD). | ✅ |
|
||||
| E1.4 | Назначения и прохождение тестов. | ⬜ |
|
||||
| E1.5 | Трекер попыток + страница настроек цепочки. | ⬜ |
|
||||
| E1.6 | Cutover: удаление `backend/` и `frontend/`. | ⬜ |
|
||||
| E1.7 | UX-полировка: 4 аккордеона + drag-n-drop. | ⬜ |
|
||||
| E1.8 | AI-функции v2 (`/settings`, generate / check / improve). | ✅ |
|
||||
|
||||
Чек-лист эндпоинтов и gap-analysis с `tgFlaskForm` —
|
||||
[`migration-final-inventory.md`](../docs/migration-final-inventory.md).
|
||||
ETL `clinic_tests → hr_bot_test` и слияние с `tgFlaskForm` — это
|
||||
**Этап 2**, на будущее
|
||||
([migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)).
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md)
|
||||
- [docs/migration-final.md](../docs/migration-final.md) — главный трекер двух этапов.
|
||||
- [docs/migration-final-inventory.md](../docs/migration-final-inventory.md) — карта Express-функционала, чек-лист Этапа 1.
|
||||
- [docs/migration-to-tgflaskform.md](../docs/migration-to-tgflaskform.md) — план Этапа 2.
|
||||
- [README корня репозитория](../README.md)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
# URL задаётся динамически в env.py через get_database_url()
|
||||
sqlalchemy.url =
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[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
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Alembic environment — подключает модели и настраивает миграции."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# Добавляем flask_app в sys.path, чтобы `from app...` работало.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from app.db import get_database_url
|
||||
from app.models import Base # noqa: E402 — импорт после sys.path
|
||||
|
||||
config = context.config
|
||||
config.set_main_option('sqlalchemy.url', get_database_url())
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
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"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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"}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Baseline — existing schema recognised by ORM.
|
||||
|
||||
Revision ID: 0001_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Эта ревизия не создаёт таблиц: схема существует. Она фиксирует текущее
|
||||
состояние как отправную точку для будущих автогенерированных миграций.
|
||||
|
||||
Чтобы применить при первом запуске:
|
||||
alembic stamp 0001_baseline # если схема уже есть
|
||||
# или:
|
||||
alembic upgrade head # если схема пустая
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "0001_baseline"
|
||||
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:
|
||||
pass # схема уже создана SQL-скриптами / Docker entrypoint
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Добавление hints_enabled и result_mode в tests.
|
||||
|
||||
Revision ID: 0002_tests_hints_result_mode
|
||||
Revises: 0001_baseline
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Совместимо с БД, где колонки уже есть: используется ADD COLUMN IF NOT EXISTS (PostgreSQL).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_tests_hints_result_mode"
|
||||
down_revision: Union[str, None] = "0001_baseline"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE tests
|
||||
ADD COLUMN IF NOT EXISTS hints_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE tests
|
||||
ADD COLUMN IF NOT EXISTS result_mode VARCHAR(16) NOT NULL DEFAULT 'end';
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE tests DROP COLUMN IF EXISTS hints_enabled;
|
||||
ALTER TABLE tests DROP COLUMN IF EXISTS result_mode;
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Добавление ai_hint в questions.
|
||||
|
||||
Revision ID: 0003_question_ai_hint
|
||||
Revises: 0002_tests_hints_result_mode
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Колонка опциональная (TEXT NULL), как в ORM Question.ai_hint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003_question_ai_hint"
|
||||
down_revision: Union[str, None] = "0002_tests_hints_result_mode"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS ai_hint TEXT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE questions DROP COLUMN IF EXISTS ai_hint;
|
||||
"""
|
||||
)
|
||||
@@ -1,8 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Фабрика Flask-приложения.
|
||||
|
||||
Этап 1:
|
||||
E1.0 — фундамент (БД-пул, sessions, base.html).
|
||||
E1.1 — авторизация (cookie-сессии Flask, bcrypt + Werkzeug, опц. HR_AUTH).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
|
||||
_ROLE_LABELS = {
|
||||
'employee': 'Сотрудник',
|
||||
'manager': 'Руководитель',
|
||||
'hr': 'HR',
|
||||
}
|
||||
|
||||
|
||||
def _format_role(role: str | None) -> str:
|
||||
return _ROLE_LABELS.get((role or '').strip().lower(), '')
|
||||
|
||||
|
||||
def _format_surname_with_initials(full_name: str | None, fallback: str | None = None) -> str:
|
||||
name = (full_name or '').strip()
|
||||
if not name:
|
||||
return (fallback or '—').strip() or '—'
|
||||
parts = [p for p in name.replace('\xa0', ' ').split(' ') if p]
|
||||
if len(parts) < 2:
|
||||
return name
|
||||
surname = parts[0]
|
||||
initials = []
|
||||
for p in parts[1:3]:
|
||||
initials.append(f'{p[0].upper()}.')
|
||||
return f"{surname} {' '.join(initials)}".strip()
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
@@ -13,17 +47,69 @@ def create_app() -> Flask:
|
||||
static_folder='static',
|
||||
static_url_path='/static',
|
||||
)
|
||||
|
||||
sk = (os.environ.get('SECRET_KEY') or '').strip()
|
||||
app.config['SECRET_KEY'] = sk or secrets.token_hex(32)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB upload limit
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
return jsonify(status='ok', service='testing-flask-app')
|
||||
app.config.update(
|
||||
SESSION_COOKIE_NAME='testing_session',
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE='Lax',
|
||||
SESSION_COOKIE_SECURE=(os.environ.get('FLASK_ENV') == 'production'),
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=7),
|
||||
)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
from flask import render_template
|
||||
from .blueprints.main import main_bp
|
||||
from .blueprints.settings import settings_bp
|
||||
from .auth import auth_bp
|
||||
from .tests import tests_bp
|
||||
|
||||
return render_template('index.html')
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(tests_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
|
||||
from . import db as _db
|
||||
|
||||
@app.teardown_appcontext
|
||||
def _shutdown_session(exc): # noqa: ARG001
|
||||
_db.remove_session()
|
||||
|
||||
from .config import is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled
|
||||
from .auth.decorators import current_user as _current_user
|
||||
|
||||
@app.context_processor
|
||||
def _inject_globals():
|
||||
return {
|
||||
'current_user': _current_user(),
|
||||
'hr_auth_enabled': is_hr_auth_enabled(),
|
||||
'dev_ui': is_dev_ui(),
|
||||
'assignment_ui': is_assignment_feature_enabled(),
|
||||
'format_name_short': _format_surname_with_initials,
|
||||
'format_role': _format_role,
|
||||
}
|
||||
|
||||
@app.errorhandler(RequestEntityTooLarge)
|
||||
def _payload_too_large(_e):
|
||||
if _is_api_path():
|
||||
return jsonify(error='Файл слишком большой (лимит загрузки на сервере).'), 413
|
||||
return ('Файл слишком большой.', 413, {'Content-Type': 'text/plain; charset=utf-8'})
|
||||
|
||||
@app.errorhandler(404)
|
||||
def _not_found(_e):
|
||||
if _is_api_path():
|
||||
return jsonify(error='not_found'), 404
|
||||
return render_template('404.html'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def _internal_error(_e):
|
||||
if _is_api_path():
|
||||
return jsonify(error='internal_error'), 500
|
||||
return render_template('500.html'), 500
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _is_api_path() -> bool:
|
||||
return request.path.startswith('/api/')
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Auth: логин/логаут/me — пара UI-страниц и JSON-API.
|
||||
|
||||
См. routes.py, services.py, decorators.py.
|
||||
"""
|
||||
from .routes import auth_bp # noqa: F401
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Декораторы доступа: подгружают пользователя из сессии.
|
||||
|
||||
`g.current_user` доступен в шаблонах как `current_user` (см. context_processor в `app/__init__.py`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from typing import Iterable
|
||||
|
||||
from flask import g, jsonify, redirect, request, session, url_for
|
||||
|
||||
from ..messages import RU
|
||||
from .services import AuthUser, load_user_by_id
|
||||
|
||||
|
||||
def _wants_json() -> bool:
|
||||
if request.path.startswith('/api/'):
|
||||
return True
|
||||
accept = request.headers.get('Accept', '')
|
||||
return 'application/json' in accept and 'text/html' not in accept
|
||||
|
||||
|
||||
def _load_current_user() -> AuthUser | None:
|
||||
if hasattr(g, 'current_user'):
|
||||
return g.current_user
|
||||
user_id = session.get('user_id')
|
||||
user = load_user_by_id(user_id) if user_id else None
|
||||
g.current_user = user
|
||||
return user
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = _load_current_user()
|
||||
if user is None:
|
||||
if _wants_json():
|
||||
return jsonify(error=RU['authRequired']), 401
|
||||
return redirect(url_for('auth.login_page', next=request.full_path or '/'))
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_role(roles: str | Iterable[str]):
|
||||
allowed = {roles} if isinstance(roles, str) else set(roles)
|
||||
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = _load_current_user()
|
||||
if user is None:
|
||||
if _wants_json():
|
||||
return jsonify(error=RU['authRequired']), 401
|
||||
return redirect(url_for('auth.login_page', next=request.full_path or '/'))
|
||||
if user.role not in allowed:
|
||||
if _wants_json():
|
||||
return jsonify(error=RU['insufficientPermissions']), 403
|
||||
return ('Доступ запрещён.', 403)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def current_user() -> AuthUser | None:
|
||||
"""Хелпер для шаблонов и view-функций."""
|
||||
return _load_current_user()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Маппинг роли HR → роль модуля тестов (порт `backend/src/utils/hrRoleMap.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def map_hr_role_to_app(hr_role: str | None) -> str:
|
||||
r = (hr_role or '').strip().lower()
|
||||
if not r:
|
||||
return 'employee'
|
||||
if r == 'admin' or 'hr' in r or 'дире' in r:
|
||||
return 'hr'
|
||||
if 'manager' in r or 'рук' in r or 'завед' in r:
|
||||
return 'manager'
|
||||
return 'employee'
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Маршруты auth: HTML (`/login`, `/logout`) и JSON (`/api/auth/*`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from ..config import get_dev_fio_password, is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled
|
||||
from ..messages import RU
|
||||
from .decorators import login_required, current_user
|
||||
from .services import AuthError, authenticate_credentials
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
def _safe_next(default: str = '/') -> str:
|
||||
"""Защита от open-redirect: разрешаем только относительные пути."""
|
||||
nxt = request.values.get('next') or default
|
||||
if not nxt.startswith('/') or nxt.startswith('//'):
|
||||
return default
|
||||
return nxt
|
||||
|
||||
|
||||
def _do_login(login: str, password: str):
|
||||
user = authenticate_credentials(login, password)
|
||||
session.clear()
|
||||
session['user_id'] = user.id
|
||||
session.permanent = True
|
||||
return user
|
||||
|
||||
|
||||
# ─── HTML ────────────────────────────────────────────────────────────
|
||||
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
if current_user() is not None:
|
||||
return redirect(_safe_next('/'))
|
||||
return render_template(
|
||||
'auth/login.html',
|
||||
next=_safe_next('/'),
|
||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||
hr_auth_enabled=is_hr_auth_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login_submit():
|
||||
login = (request.form.get('login') or '').strip()
|
||||
password = request.form.get('password') or ''
|
||||
try:
|
||||
_do_login(login, password)
|
||||
except AuthError as e:
|
||||
flash(e.message, 'error')
|
||||
return render_template(
|
||||
'auth/login.html',
|
||||
next=_safe_next('/'),
|
||||
login=login,
|
||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||
hr_auth_enabled=is_hr_auth_enabled(),
|
||||
), e.status
|
||||
except Exception:
|
||||
log.exception('login_submit failed')
|
||||
flash(RU['loginFailed'], 'error')
|
||||
return render_template(
|
||||
'auth/login.html',
|
||||
next=_safe_next('/'),
|
||||
login=login,
|
||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||
hr_auth_enabled=is_hr_auth_enabled(),
|
||||
), 500
|
||||
return redirect(_safe_next('/'))
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST', 'GET'])
|
||||
def logout():
|
||||
session.clear()
|
||||
if request.method == 'GET':
|
||||
return redirect(url_for('auth.login_page'))
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
|
||||
# ─── JSON API ────────────────────────────────────────────────────────
|
||||
|
||||
@auth_bp.route('/api/auth/login', methods=['POST'])
|
||||
def api_login():
|
||||
data = request.get_json(silent=True) or {}
|
||||
login = (data.get('login') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
try:
|
||||
user = _do_login(login, password)
|
||||
except AuthError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
except Exception:
|
||||
log.exception('api_login failed')
|
||||
return jsonify(error=RU['loginFailed']), 500
|
||||
return jsonify(user=user.to_public_dict())
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/logout', methods=['POST'])
|
||||
def api_logout():
|
||||
session.clear()
|
||||
return jsonify(message=RU['loggedOut'])
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/dev/assignment-directory', methods=['GET'])
|
||||
@login_required
|
||||
def api_assignment_directory():
|
||||
"""Список сотрудников для назначения теста с поиском по имени/логину и фильтром отдела."""
|
||||
from ..db import get_session
|
||||
from ..models import Department, User as UserModel
|
||||
|
||||
q_str = (request.args.get('q') or '').strip()
|
||||
dept_filter = (request.args.get('department') or '').strip()
|
||||
clinic_filter = (request.args.get('clinic') or 'all').strip()
|
||||
|
||||
session = get_session()
|
||||
|
||||
dept_rows = session.query(Department).order_by(Department.name).all()
|
||||
dept_by_name = {d.name: d.id for d in dept_rows}
|
||||
departments = [d.name for d in dept_rows]
|
||||
|
||||
query = (
|
||||
session.query(UserModel)
|
||||
.filter(UserModel.is_active.is_(True))
|
||||
)
|
||||
if q_str:
|
||||
like = f'%{q_str}%'
|
||||
query = query.filter(
|
||||
UserModel.full_name.ilike(like) | UserModel.login.ilike(like)
|
||||
)
|
||||
if dept_filter and dept_filter in dept_by_name:
|
||||
query = query.filter(UserModel.department_id == dept_by_name[dept_filter])
|
||||
if clinic_filter == 'with':
|
||||
query = query.filter(UserModel.staff_id.isnot(None))
|
||||
elif clinic_filter == 'without':
|
||||
query = query.filter(UserModel.staff_id.is_(None))
|
||||
|
||||
users = query.order_by(UserModel.full_name).limit(200).all()
|
||||
|
||||
dept_name_by_id = {d.id: d.name for d in dept_rows}
|
||||
people = [
|
||||
{
|
||||
'staffId': str(u.id),
|
||||
'fio': u.full_name,
|
||||
'webLogin': u.login,
|
||||
'role': u.role,
|
||||
'department': dept_name_by_id.get(u.department_id) if u.department_id else None,
|
||||
'clinicUserId': u.staff_id,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
return jsonify(people=people, departments=departments)
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/me', methods=['GET'])
|
||||
@login_required
|
||||
def api_me():
|
||||
user = current_user()
|
||||
return jsonify(
|
||||
user=user.to_public_dict() if user else None,
|
||||
devUi=is_dev_ui(),
|
||||
assignmentUi=is_assignment_feature_enabled(),
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Бизнес-логика логина (ORM-версия).
|
||||
|
||||
Поддерживает оба режима:
|
||||
- Локальный (по умолчанию): bcrypt / Werkzeug в `clinic_tests.users.password_hash`.
|
||||
- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (raw SQL, внешняя схема),
|
||||
затем UPSERT в `clinic_tests.users` по `staff_id`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import text
|
||||
from werkzeug.security import check_password_hash as _werkzeug_check
|
||||
|
||||
from ..config import (
|
||||
HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
get_dev_fio_password,
|
||||
is_hr_auth_enabled,
|
||||
)
|
||||
from ..db import get_engine, get_session
|
||||
from ..messages import RU
|
||||
from ..models import User
|
||||
from .hr_role import map_hr_role_to_app
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthUser:
|
||||
id: str
|
||||
login: str
|
||||
full_name: str | None
|
||||
role: str
|
||||
department_id: Optional[str]
|
||||
staff_id: Optional[int]
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'login': self.login,
|
||||
'fullName': self.full_name,
|
||||
'role': self.role,
|
||||
'departmentId': str(self.department_id) if self.department_id else None,
|
||||
'staffId': self.staff_id,
|
||||
}
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def _verify_password(plain: str, hashed: str | None) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER:
|
||||
return False
|
||||
if hashed.startswith('scrypt:') or hashed.startswith('pbkdf2:'):
|
||||
try:
|
||||
return _werkzeug_check(hashed, plain)
|
||||
except Exception:
|
||||
return False
|
||||
if hashed.startswith('$2'):
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode('utf-8'), hashed.encode('utf-8'))
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
return _werkzeug_check(hashed, plain)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _user_to_auth(user: User) -> AuthUser:
|
||||
return AuthUser(
|
||||
id=str(user.id),
|
||||
login=user.login,
|
||||
full_name=user.full_name,
|
||||
role=user.role,
|
||||
department_id=str(user.department_id) if user.department_id else None,
|
||||
staff_id=user.staff_id,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_fio(s: str) -> str:
|
||||
"""Приводим ФИО к одному виду: trim, пробелы, неразрывный пробел, много переводов строк."""
|
||||
t = str(s or '')
|
||||
t = t.replace('\u00a0', ' ')
|
||||
return re.sub(r'\s+', ' ', t).strip()
|
||||
|
||||
|
||||
_FIO_STAFF_SQL = text(
|
||||
"""
|
||||
SELECT id, fio, web_login
|
||||
FROM staff_members
|
||||
WHERE fio IS NOT NULL
|
||||
AND lower(
|
||||
regexp_replace(
|
||||
trim(replace(replace(coalesce(fio, ''), chr(160), ' '), E'\\t', ' ')),
|
||||
'[[:space:]]+', ' ', 'g'
|
||||
)
|
||||
) = :needle
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _staff_rows_by_normalized_fio(hr_conn, fio_raw: str) -> list:
|
||||
"""Строки staff_members с тем же ФИО, что и ввод, после нормализации (регистр не важен)."""
|
||||
fio_needle = _normalize_fio(fio_raw)
|
||||
if not fio_needle:
|
||||
return []
|
||||
needle = fio_needle.lower()
|
||||
return list(hr_conn.execute(_FIO_STAFF_SQL, {'needle': needle}).mappings().all())
|
||||
|
||||
|
||||
def _single_web_login_from_fio(hr_conn, fio_raw: str) -> str | None:
|
||||
"""Если по ФИО найден ровно один сотрудник — его web_login для входа в HR; иначе None."""
|
||||
rows = _staff_rows_by_normalized_fio(hr_conn, fio_raw)
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
wl = (rows[0]['web_login'] or '').strip()
|
||||
return wl or None
|
||||
|
||||
|
||||
def authenticate_credentials(login: str, password: str) -> AuthUser:
|
||||
login = (login or '').strip()
|
||||
password = password or ''
|
||||
if not login or not password:
|
||||
raise AuthError(400, RU['loginAndPasswordRequired'])
|
||||
|
||||
# Dev-режим: вход по ФИО из HR с общим паролем
|
||||
dev_pw = get_dev_fio_password()
|
||||
if dev_pw and password.strip() == dev_pw.strip():
|
||||
try:
|
||||
return _authenticate_dev_fio(login)
|
||||
except AuthError:
|
||||
pass # если ФИО не найдено — продолжаем обычную проверку
|
||||
|
||||
if is_hr_auth_enabled():
|
||||
return _authenticate_via_hr(login, password)
|
||||
return _authenticate_local(login, password)
|
||||
|
||||
|
||||
def _authenticate_dev_fio(fio_raw: str) -> AuthUser:
|
||||
"""Dev-режим: найти сотрудника по ФИО в HR-системе и создать/обновить локального пользователя."""
|
||||
from ..db import get_hr_engine
|
||||
hr_eng = get_hr_engine()
|
||||
if hr_eng is None:
|
||||
raise AuthError(500, RU['hrDatabaseUrlMissing'])
|
||||
|
||||
fio_needle = _normalize_fio(fio_raw)
|
||||
if not fio_needle:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
with hr_eng.connect() as hr_conn:
|
||||
rows = _staff_rows_by_normalized_fio(hr_conn, fio_raw)
|
||||
if len(rows) != 1:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
s = rows[0]
|
||||
|
||||
web_login = (s['web_login'] or '').strip() or f'fio_{s["id"]}'
|
||||
|
||||
staff_id = int(s['id'])
|
||||
full_name = s['fio'] or fio_raw
|
||||
|
||||
session = get_session()
|
||||
|
||||
user = session.query(User).filter(User.staff_id == staff_id).first()
|
||||
if not user:
|
||||
# Не переиспользовать строку другого человека только по совпадению логина HR
|
||||
occupied = session.query(User).filter(User.login == web_login).first()
|
||||
login_val = web_login
|
||||
if occupied and occupied.staff_id != staff_id:
|
||||
login_val = f'hr_staff_{staff_id}'
|
||||
user = User(
|
||||
login=login_val,
|
||||
password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
full_name=full_name,
|
||||
role='employee',
|
||||
is_active=True,
|
||||
staff_id=staff_id,
|
||||
)
|
||||
session.add(user)
|
||||
else:
|
||||
user.full_name = full_name
|
||||
user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER
|
||||
user.is_active = True
|
||||
# Обновить логин с HR только если конфликта нет
|
||||
if web_login != user.login:
|
||||
taken = session.query(User).filter(
|
||||
User.login == web_login,
|
||||
User.id != user.id,
|
||||
).first()
|
||||
if not taken:
|
||||
user.login = web_login
|
||||
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return _user_to_auth(user)
|
||||
|
||||
|
||||
def _authenticate_local(login: str, password: str) -> AuthUser:
|
||||
session = get_session()
|
||||
user = (
|
||||
session.query(User)
|
||||
.filter(User.login == login, User.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
if user.password_hash == HR_MANAGED_PASSWORD_PLACEHOLDER:
|
||||
raise AuthError(401, RU['useHrLogin'])
|
||||
if not _verify_password(password, user.password_hash):
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
return _user_to_auth(user)
|
||||
|
||||
|
||||
def _authenticate_via_hr(login: str, password: str) -> AuthUser:
|
||||
from ..db import get_hr_engine
|
||||
hr_eng = get_hr_engine()
|
||||
if hr_eng is None:
|
||||
raise AuthError(500, RU['hrDatabaseUrlMissing'])
|
||||
|
||||
user_row_sql = text(
|
||||
'SELECT id, username, password_hash, role FROM users '
|
||||
'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))'
|
||||
)
|
||||
|
||||
with hr_eng.connect() as hr_conn:
|
||||
u = hr_conn.execute(user_row_sql, {'login': login}).mappings().first()
|
||||
if not u:
|
||||
wl = _single_web_login_from_fio(hr_conn, login)
|
||||
if wl:
|
||||
u = hr_conn.execute(user_row_sql, {'login': wl}).mappings().first()
|
||||
|
||||
if not u or not u['password_hash']:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
if not _verify_password(password, u['password_hash']):
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
hr_username = (u['username'] or '').strip()
|
||||
if not hr_username:
|
||||
raise AuthError(401, RU['invalidCredentials'])
|
||||
|
||||
s = hr_conn.execute(
|
||||
text(
|
||||
'SELECT id, fio FROM staff_members '
|
||||
"WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))"
|
||||
),
|
||||
{'login': hr_username},
|
||||
).mappings().first()
|
||||
if not s:
|
||||
raise AuthError(403, RU['noStaffForLogin'])
|
||||
|
||||
staff_id = int(s['id'])
|
||||
fio = s['fio'] or hr_username
|
||||
app_role = map_hr_role_to_app(u['role'])
|
||||
canonical_login = hr_username
|
||||
|
||||
# UPSERT через ORM: ищем по staff_id, затем по логину HR, затем по вводу (ФИО/старый логин)
|
||||
session = get_session()
|
||||
user = session.query(User).filter(User.staff_id == staff_id).first()
|
||||
if not user:
|
||||
user = session.query(User).filter(User.login == canonical_login).first()
|
||||
if not user:
|
||||
user = session.query(User).filter(User.login == login).first()
|
||||
if user:
|
||||
user.login = canonical_login
|
||||
user.full_name = fio
|
||||
user.role = app_role
|
||||
user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER
|
||||
user.staff_id = staff_id
|
||||
user.is_active = True
|
||||
else:
|
||||
user = User(
|
||||
login=canonical_login,
|
||||
password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
full_name=fio,
|
||||
role=app_role,
|
||||
is_active=True,
|
||||
staff_id=staff_id,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return _user_to_auth(user)
|
||||
|
||||
|
||||
def load_user_by_id(user_id: str) -> Optional[AuthUser]:
|
||||
if not user_id:
|
||||
return None
|
||||
session = get_session()
|
||||
try:
|
||||
import uuid as _uuid
|
||||
uid = _uuid.UUID(user_id)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
user = (
|
||||
session.query(User)
|
||||
.filter(User.id == uid, User.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
return None
|
||||
return _user_to_auth(user)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Главный blueprint — посадочная страница, статистика и health-чек."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
|
||||
from .. import db as app_db
|
||||
from ..auth.decorators import login_required
|
||||
from ..auth.decorators import current_user as get_current_user
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
def _get_stats() -> dict:
|
||||
"""Собирает статистику прохождений для дашборда."""
|
||||
from sqlalchemy import func, case
|
||||
from ..db import get_session
|
||||
from ..models import Department, User, Test, TestAttempt
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
total_tests = session.query(func.count(Test.id)).scalar() or 0
|
||||
active_tests = session.query(func.count(Test.id)).filter(Test.is_active.is_(True)).scalar() or 0
|
||||
|
||||
total_completed = (
|
||||
session.query(func.count(TestAttempt.id))
|
||||
.filter(TestAttempt.status == 'completed')
|
||||
.scalar() or 0
|
||||
)
|
||||
total_passed = (
|
||||
session.query(func.count(TestAttempt.id))
|
||||
.filter(TestAttempt.status == 'completed', TestAttempt.passed.is_(True))
|
||||
.scalar() or 0
|
||||
)
|
||||
total_users = session.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
|
||||
|
||||
# Статистика по отделам
|
||||
dept_rows = (
|
||||
session.query(
|
||||
Department.name,
|
||||
func.count(TestAttempt.id).label('total'),
|
||||
func.sum(
|
||||
case((TestAttempt.passed.is_(True), 1), else_=0)
|
||||
).label('passed_count'),
|
||||
)
|
||||
.join(User, User.department_id == Department.id)
|
||||
.join(TestAttempt, TestAttempt.user_id == User.id)
|
||||
.filter(TestAttempt.status == 'completed')
|
||||
.group_by(Department.id, Department.name)
|
||||
.order_by(Department.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
from ..models import TestVersion
|
||||
recent_rows = (
|
||||
session.query(
|
||||
TestAttempt.started_at,
|
||||
TestAttempt.completed_at,
|
||||
TestAttempt.passed,
|
||||
TestAttempt.correct_count,
|
||||
TestAttempt.total_questions,
|
||||
User.full_name,
|
||||
User.login,
|
||||
Test.title.label('test_title'),
|
||||
)
|
||||
.join(User, User.id == TestAttempt.user_id)
|
||||
.join(TestVersion, TestVersion.id == TestAttempt.test_version_id)
|
||||
.join(Test, Test.id == TestVersion.test_id)
|
||||
.filter(TestAttempt.status == 'completed')
|
||||
.order_by(TestAttempt.completed_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
dept_stats = [
|
||||
{
|
||||
'name': r.name,
|
||||
'total': r.total,
|
||||
'passed': int(r.passed_count or 0),
|
||||
'rate': round(100 * int(r.passed_count or 0) / r.total) if r.total else 0,
|
||||
}
|
||||
for r in dept_rows
|
||||
]
|
||||
|
||||
recent_list = [
|
||||
{
|
||||
'user': r.full_name or r.login,
|
||||
'test': r.test_title,
|
||||
'passed': r.passed,
|
||||
'score': f'{r.correct_count}/{r.total_questions}' if r.correct_count is not None else '—',
|
||||
'at': r.completed_at.strftime('%d.%m %H:%M') if r.completed_at else '—',
|
||||
}
|
||||
for r in recent_rows
|
||||
]
|
||||
|
||||
pass_rate = round(100 * total_passed / total_completed) if total_completed else 0
|
||||
|
||||
return {
|
||||
'total_tests': total_tests,
|
||||
'active_tests': active_tests,
|
||||
'total_completed': total_completed,
|
||||
'total_passed': total_passed,
|
||||
'total_users': total_users,
|
||||
'pass_rate': pass_rate,
|
||||
'dept_stats': dept_stats,
|
||||
'recent': recent_list,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
'total_tests': 0, 'active_tests': 0, 'total_completed': 0,
|
||||
'total_passed': 0, 'total_users': 0, 'pass_rate': 0,
|
||||
'dept_stats': [], 'recent': [],
|
||||
}
|
||||
|
||||
|
||||
@main_bp.route('/health')
|
||||
def health():
|
||||
"""Smoke-проверка приложения и подключений к БД (без авторизации)."""
|
||||
db_status = app_db.ping()
|
||||
overall = 'ok' if db_status.get('main') == 'ok' else 'degraded'
|
||||
return jsonify(
|
||||
status=overall,
|
||||
service='testing-flask-app',
|
||||
db=db_status,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@main_bp.route('/stats')
|
||||
@login_required
|
||||
def stats_page():
|
||||
return render_template('stats.html', stats=_get_stats())
|
||||
|
||||
|
||||
@main_bp.route('/assignments')
|
||||
@login_required
|
||||
def assignments_page():
|
||||
from ..db import get_session
|
||||
from ..models import Test, TestVersion
|
||||
from sqlalchemy.orm import selectinload
|
||||
session = get_session()
|
||||
tests = (
|
||||
session.query(Test)
|
||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||
.filter(Test.is_active.is_(True))
|
||||
.order_by(Test.title)
|
||||
.all()
|
||||
)
|
||||
return render_template('assignments.html', tests=[
|
||||
{'id': str(t.id), 'title': t.title or '(без названия)'}
|
||||
for t in tests
|
||||
])
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Настройки: LLM-статус, пинг и редактор промптов."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from ..auth.decorators import login_required
|
||||
from ..services.llm_client import get_llm_config, ping_llm
|
||||
from ..services import prompt_store
|
||||
|
||||
settings_bp = Blueprint('settings', __name__)
|
||||
|
||||
|
||||
@settings_bp.route('/settings', methods=['GET'])
|
||||
@login_required
|
||||
def settings_page():
|
||||
cfg = get_llm_config()
|
||||
return render_template(
|
||||
'settings.html',
|
||||
configured=cfg is not None,
|
||||
provider=cfg.provider if cfg else None,
|
||||
model=cfg.model if cfg else None,
|
||||
base_url=cfg.base_url if cfg else None,
|
||||
)
|
||||
|
||||
|
||||
@settings_bp.route('/settings/prompts', methods=['GET'])
|
||||
@login_required
|
||||
def prompts_page():
|
||||
return render_template('settings_prompts.html', prompts=prompt_store.get_all())
|
||||
|
||||
|
||||
@settings_bp.route('/api/llm/ping', methods=['POST', 'GET'])
|
||||
@login_required
|
||||
def api_llm_ping():
|
||||
return jsonify(ping_llm())
|
||||
|
||||
|
||||
@settings_bp.route('/api/ai/prompts', methods=['GET'])
|
||||
@login_required
|
||||
def api_get_prompts():
|
||||
return jsonify(prompts=prompt_store.get_all())
|
||||
|
||||
|
||||
@settings_bp.route('/api/ai/prompts/<prompt_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def api_save_prompt(prompt_id: str):
|
||||
body = request.get_json(silent=True) or {}
|
||||
system = str(body.get('system') or '').strip()
|
||||
user = str(body.get('user') or '').strip()
|
||||
try:
|
||||
prompt_store.save_prompt(prompt_id, system, user)
|
||||
except KeyError as e:
|
||||
return jsonify(ok=False, error=str(e)), 404
|
||||
except Exception as e:
|
||||
return jsonify(ok=False, error=str(e)), 500
|
||||
return jsonify(ok=True)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Точечные настройки и feature-флаги (1:1 с Express-бэкендом)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$'
|
||||
"""Заглушка пароля для пользователей, попавших в clinic_tests через HR-апсёрт.
|
||||
При локальном входе compare всегда даёт False (см. authenticate_local)."""
|
||||
|
||||
|
||||
def _truthy(val: str | None) -> bool:
|
||||
return (val or '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def is_hr_auth_enabled() -> bool:
|
||||
"""`HR_AUTH=1` → логин через `hr_bot_test.users` (Werkzeug)."""
|
||||
return _truthy(os.environ.get('HR_AUTH'))
|
||||
|
||||
|
||||
def is_assignment_feature_enabled() -> bool:
|
||||
"""API/UI назначения тестов сотрудникам (см. backend/src/config/featureFlags.js)."""
|
||||
if (os.environ.get('FLASK_ENV') or '').lower() == 'development':
|
||||
return True
|
||||
if (os.environ.get('FLASK_DEBUG') or '').strip() == '1':
|
||||
return True
|
||||
raw = (os.environ.get('CLINIC_ASSIGNMENT_ENABLED') or '').strip().lower()
|
||||
if raw in ('1', 'true', 'yes'):
|
||||
return True
|
||||
if raw in ('0', 'false', 'no'):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def is_dev_ui() -> bool:
|
||||
"""В Express это `NODE_ENV=development`. У нас — FLASK_ENV/FLASK_DEBUG."""
|
||||
if (os.environ.get('FLASK_ENV') or '').lower() == 'development':
|
||||
return True
|
||||
return (os.environ.get('FLASK_DEBUG') or '').strip() == '1'
|
||||
|
||||
|
||||
def get_dev_fio_password() -> str | None:
|
||||
"""Общий пароль для dev-входа по ФИО. Задаётся в DEV_FIO_PASSWORD."""
|
||||
return os.environ.get('DEV_FIO_PASSWORD') or None
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"generate_question_full": {
|
||||
"label": "Генерация вопроса (новый)",
|
||||
"description": "AI создаёт вопрос с вариантами ответа по теме теста.",
|
||||
"system": "Ты составитель тестов. Отвечай ТОЛЬКО JSON: {\"text\", \"hasMultipleAnswers\", \"options\": [{ \"text\", \"isCorrect\" }]}. Все на русском.",
|
||||
"user": "Тема теста: {topic}\n\nСформулируй ОДИН вопрос по этой теме с ровно {optionsCount} вариантами ответа. hasMultipleAnswers = {multiClause}",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: сделай акцент на противопожарных нормах",
|
||||
"vars": {
|
||||
"topic": "Название + описание теста",
|
||||
"optionsCount": "Количество вариантов ответа",
|
||||
"multiClause": "Пояснение о типе ответа"
|
||||
}
|
||||
},
|
||||
"rephrase_question": {
|
||||
"label": "Улучшение вопроса",
|
||||
"description": "AI переформулирует черновик вопроса, сохраняя смысл.",
|
||||
"system": "Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {\"text\": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.",
|
||||
"user": "Тема теста: {topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{questionText}",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: сделай формулировку короче и чётче",
|
||||
"vars": {
|
||||
"topic": "Название + описание теста",
|
||||
"questionText": "Исходный текст вопроса"
|
||||
}
|
||||
},
|
||||
"generate_distractors": {
|
||||
"label": "Генерация дистракторов",
|
||||
"description": "AI заполняет пустые варианты ответа правдоподобными, но неверными.",
|
||||
"system": "Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: {\"options\": [{\"text\": string, \"isCorrect\": false}, ...]} — ровно {emptyCount} объекта в массиве. Все тексты на русском, без нумерации, без кавычек.",
|
||||
"user": "Тема теста: {topic}\n\nВопрос: {questionText}\n\nУже заполненные варианты:\n{filledOptions}\n\nПридумай ровно {emptyCount} правдоподобных, но НЕВЕРНЫХ дистракторов (isCorrect: false), которые не повторяют уже существующие варианты и выглядят похоже на реальные ответы.",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: дистракторы должны быть из той же категории",
|
||||
"vars": {
|
||||
"topic": "Название + описание теста",
|
||||
"questionText": "Текст вопроса",
|
||||
"filledOptions": "Список уже заполненных вариантов",
|
||||
"emptyCount": "Количество пустых слотов"
|
||||
}
|
||||
},
|
||||
"generate_test_by_title": {
|
||||
"label": "Генерация теста по теме",
|
||||
"description": "AI генерирует весь тест (структуру + вопросы) по названию.",
|
||||
"system": "Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {\"title\", \"description\", \"questions\": [{\"text\", \"hasMultipleAnswers\": boolean, \"options\": [{\"text\", \"isCorrect\"}]}]}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.",
|
||||
"user": "Составь учебный тест по этой теме.\n\nНазвание теста: {title}\nОписание/контекст: {desc}\n\nПодсказка по сетке: примерно {nQ} вопросов, в каждом по {nOpt} вариантов ответа. Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно неверными. Текст — короткий, понятный.",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: сделай акцент на правилах безопасности, избегай теоретических вопросов",
|
||||
"vars": {
|
||||
"title": "Название теста",
|
||||
"desc": "Описание/контекст темы",
|
||||
"nQ": "Желаемое количество вопросов",
|
||||
"nOpt": "Желаемое количество вариантов"
|
||||
}
|
||||
},
|
||||
"generate_test_from_doc": {
|
||||
"label": "Генерация теста из документа",
|
||||
"description": "AI создаёт тест на основе загруженного текста документа.",
|
||||
"system": "Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {\"title\": string, \"description\"?: string, \"questions\": array}. Каждый вопрос: {\"text\", \"hasMultipleAnswers\": boolean, \"options\": [{\"text\", \"isCorrect\": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.",
|
||||
"user": "Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n{documentText}",
|
||||
"user_hint_label": "На что сделать акцент",
|
||||
"user_hint_placeholder": "Например: сделай акцент на разделе 3, избегай вопросов про даты",
|
||||
"vars": {
|
||||
"documentText": "Извлечённый текст документа"
|
||||
}
|
||||
},
|
||||
"check_test_quality": {
|
||||
"label": "Проверка качества теста",
|
||||
"description": "AI анализирует тест и выдаёт рекомендации по улучшению.",
|
||||
"system": "Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {\"verdict\": \"ok\"|\"warn\"|\"bad\", \"summary\": string, \"sections\": [{\"title\": string, \"items\": [string, ...]}]}. Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», «Охват темы», «Сбалансированность сложности». Пропусти раздел, если претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные проблемы. Все тексты — на русском, короткие и предметные.",
|
||||
"user": "Проверь качество теста и дай рекомендации:\n\n{testDump}",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: обрати особое внимание на дистракторы",
|
||||
"vars": {
|
||||
"testDump": "JSON-дамп теста (заголовок + вопросы)"
|
||||
}
|
||||
},
|
||||
"improve_test_full": {
|
||||
"label": "Улучшение всего теста",
|
||||
"description": "AI предлагает улучшенные формулировки для всех вопросов и ответов.",
|
||||
"system": "Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. Отвечай ТОЛЬКО JSON: {\"questions\": [{\"text\", \"hasMultipleAnswers\", \"options\": [{\"text\", \"isCorrect\"}]}, ...]}. Тексты — на русском, короткие.",
|
||||
"user": "Улучши тест без изменения сетки:\n\n{testDump}",
|
||||
"user_hint_label": "Акцент / дополнение",
|
||||
"user_hint_placeholder": "Например: сделай дистракторы правдоподобнее",
|
||||
"vars": {
|
||||
"testDump": "JSON-дамп теста (заголовок + вопросы)"
|
||||
}
|
||||
},
|
||||
"question_hint": {
|
||||
"label": "Подсказка к вопросу",
|
||||
"description": "AI объясняет правильный ответ (показывается при прохождении теста).",
|
||||
"system": "Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения), без markdown и без вступлений. Объясни почему правильный вариант — правильный.",
|
||||
"user": "Вопрос: {questionText}\nВарианты: {allOptions}\nПравильный ответ: {correctOptions}\n\nДай краткое объяснение для подсказки во всплывающем окне.",
|
||||
"vars": {
|
||||
"questionText": "Текст вопроса",
|
||||
"allOptions": "Все варианты ответа",
|
||||
"correctOptions": "Правильные варианты ответа"
|
||||
}
|
||||
},
|
||||
"explain_answer": {
|
||||
"label": "Объяснение ответа",
|
||||
"description": "AI объясняет результат ответа пользователя (после прохождения).",
|
||||
"system": "Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения). Объясни почему правильный ответ именно такой, без лишней воды и без markdown.",
|
||||
"user": "Вопрос: {questionText}\nПравильный ответ: {correctOptions}\nОтвет ученика ({verdict}): {selectedOptions}\n\nДай краткое объяснение для подсказки во всплывающем окне.",
|
||||
"vars": {
|
||||
"questionText": "Текст вопроса",
|
||||
"correctOptions": "Правильные варианты",
|
||||
"verdict": "Результат (верно/неверно)",
|
||||
"selectedOptions": "Выбранные пользователем варианты"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Подключение к PostgreSQL и ORM-сессии.
|
||||
|
||||
Основная БД — `clinic_tests`.
|
||||
Опциональная вторая БД — `hr_bot_test` (когда HR_AUTH=1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
_engine_lock = threading.Lock()
|
||||
_session_lock = threading.Lock()
|
||||
_hr_engine_lock = threading.Lock()
|
||||
|
||||
_engine: Optional[Engine] = None
|
||||
_session_factory: Optional[scoped_session] = None
|
||||
_hr_engine: Optional[Engine] = None
|
||||
|
||||
|
||||
# ─── URL helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_database_url() -> str:
|
||||
if db_url := os.environ.get('DATABASE_URL'):
|
||||
return db_url.strip()
|
||||
host = os.environ.get('DB_HOST', 'localhost')
|
||||
port = os.environ.get('DB_PORT', '5432')
|
||||
name = os.environ.get('DB_NAME', 'clinic_tests')
|
||||
user = os.environ.get('DB_USER', 'hr_bot_user')
|
||||
password = os.environ.get('DB_PASSWORD', 'hrbot123')
|
||||
return f'postgresql+psycopg2://{user}:{password}@{host}:{port}/{name}'
|
||||
|
||||
|
||||
def _hr_auth_enabled() -> bool:
|
||||
return (os.environ.get('HR_AUTH') or '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def get_hr_database_url() -> Optional[str]:
|
||||
if not _hr_auth_enabled():
|
||||
return None
|
||||
url = (os.environ.get('HR_DATABASE_URL') or '').strip()
|
||||
return url or None
|
||||
|
||||
|
||||
# ─── Main engine ─────────────────────────────────────────────────────────────
|
||||
|
||||
def get_engine() -> Engine:
|
||||
global _engine
|
||||
if _engine is not None:
|
||||
return _engine
|
||||
with _engine_lock:
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
get_database_url(),
|
||||
poolclass=QueuePool,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
# ─── Scoped session ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_session() -> Session:
|
||||
"""Возвращает ORM-сессию для текущего потока (scoped_session)."""
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
with _session_lock:
|
||||
if _session_factory is None:
|
||||
# Инициализируем engine до захвата session_lock, чтобы не было вложенных блокировок
|
||||
engine = get_engine()
|
||||
_session_factory = scoped_session(
|
||||
sessionmaker(bind=engine, autoflush=True, autocommit=False)
|
||||
)
|
||||
return _session_factory # type: ignore[return-value]
|
||||
|
||||
|
||||
def remove_session() -> None:
|
||||
"""Освобождает сессию для текущего потока. Вызывается в teardown_appcontext."""
|
||||
if _session_factory is not None:
|
||||
_session_factory.remove()
|
||||
|
||||
|
||||
# ─── HR engine (raw SQL only) ────────────────────────────────────────────────
|
||||
|
||||
def get_hr_engine() -> Optional[Engine]:
|
||||
if not _hr_auth_enabled():
|
||||
return None
|
||||
global _hr_engine
|
||||
if _hr_engine is not None:
|
||||
return _hr_engine
|
||||
url = get_hr_database_url()
|
||||
if not url:
|
||||
return None
|
||||
with _hr_engine_lock:
|
||||
if _hr_engine is None:
|
||||
_hr_engine = create_engine(
|
||||
url,
|
||||
poolclass=QueuePool,
|
||||
pool_size=3,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
return _hr_engine
|
||||
|
||||
|
||||
# ─── Smoke check ─────────────────────────────────────────────────────────────
|
||||
|
||||
def ping() -> dict:
|
||||
out: dict = {'main': 'unknown'}
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
conn.exec_driver_sql('SELECT 1')
|
||||
out['main'] = 'ok'
|
||||
except Exception as e:
|
||||
out['main'] = f'error: {type(e).__name__}: {e}'
|
||||
|
||||
if _hr_auth_enabled():
|
||||
out['hr'] = 'unknown'
|
||||
try:
|
||||
eng = get_hr_engine()
|
||||
if eng is None:
|
||||
out['hr'] = 'disabled (HR_DATABASE_URL not set)'
|
||||
else:
|
||||
with eng.connect() as conn:
|
||||
conn.exec_driver_sql('SELECT 1')
|
||||
out['hr'] = 'ok'
|
||||
except Exception as e:
|
||||
out['hr'] = f'error: {type(e).__name__}: {e}'
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Русские сообщения API (порт `backend/src/messages/ru.js`)."""
|
||||
|
||||
RU = {
|
||||
'loginAndPasswordRequired': 'Укажите логин и пароль.',
|
||||
'invalidCredentials': 'Неверный логин или пароль.',
|
||||
'useHrLogin': 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).',
|
||||
'hrDatabaseUrlMissing': 'База кадровой системы не настроена: задайте HR_DATABASE_URL.',
|
||||
'hrDatabaseNotConfigured': 'База кадровой системы не настроена.',
|
||||
'noStaffForLogin': (
|
||||
'К учётной записи не привязан сотрудник: в HR в карточке сотрудника '
|
||||
'должно совпадать поле веб-логина (web_login) с логином входа.'
|
||||
),
|
||||
'loggedOut': 'Вы вышли из системы.',
|
||||
'logoutFailed': 'Не удалось выйти. Повторите попытку.',
|
||||
'userDataFailed': 'Не удалось загрузить данные пользователя.',
|
||||
'loginFailed': 'Ошибка входа. Повторите попытку.',
|
||||
'authRequired': 'Требуется вход в систему.',
|
||||
'tokenInvalid': 'Сессия истекла или недействительна. Войдите снова.',
|
||||
'userNotFound': 'Пользователь не найден.',
|
||||
'authError': 'Ошибка проверки доступа.',
|
||||
'insufficientPermissions': 'Недостаточно прав.',
|
||||
'departmentAccessDenied': 'Нет доступа к этому подразделению.',
|
||||
'notFound': 'Не найдено.',
|
||||
'internal': 'Внутренняя ошибка сервера.',
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"""SQLAlchemy ORM-модели для БД clinic_tests.
|
||||
|
||||
Таблицы: departments, users, tests, test_versions, questions, answer_options,
|
||||
test_assignments, test_assignment_targets, test_attempts, user_answers.
|
||||
|
||||
Enum-типы (`user_role`, `target_type`, `attempt_status`) соответствуют PostgreSQL-перечислениям
|
||||
из 001_initial.sql — создаются через `create_constraint=False` (тип уже есть в БД).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
# ─── Base ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# ─── Enum types ───────────────────────────────────────────────────────────────
|
||||
|
||||
user_role_enum = Enum(
|
||||
"hr", "manager", "employee",
|
||||
name="user_role",
|
||||
create_constraint=False, # тип уже существует в БД
|
||||
)
|
||||
|
||||
target_type_enum = Enum(
|
||||
"department", "user",
|
||||
name="target_type",
|
||||
create_constraint=False,
|
||||
)
|
||||
|
||||
attempt_status_enum = Enum(
|
||||
"in_progress", "completed", "expired",
|
||||
name="attempt_status",
|
||||
create_constraint=False,
|
||||
)
|
||||
|
||||
|
||||
# ─── Models ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class Department(Base):
|
||||
__tablename__ = "departments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
users: Mapped[List["User"]] = relationship(back_populates="department")
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
Index("idx_users_login", "login"),
|
||||
Index("idx_users_department", "department_id"),
|
||||
Index(
|
||||
"idx_users_staff_id", "staff_id",
|
||||
postgresql_where="staff_id IS NOT NULL",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
login: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(user_role_enum, nullable=False, server_default="employee")
|
||||
department_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("departments.id"), nullable=True
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default="true")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
staff_id: Mapped[Optional[int]] = mapped_column(Integer, unique=True, nullable=True)
|
||||
|
||||
department: Mapped[Optional["Department"]] = relationship(back_populates="users")
|
||||
tests: Mapped[List["Test"]] = relationship(back_populates="author", foreign_keys="Test.created_by")
|
||||
attempts: Mapped[List["TestAttempt"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class Test(Base):
|
||||
__tablename__ = "tests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
passing_threshold: Mapped[Optional[int]] = mapped_column(Integer, server_default="70")
|
||||
time_limit: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
allow_back: Mapped[bool] = mapped_column(Boolean, server_default="true")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default="true")
|
||||
is_versioned: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
hints_enabled: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
result_mode: Mapped[str] = mapped_column(String(16), server_default="end")
|
||||
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
author: Mapped[Optional["User"]] = relationship(
|
||||
back_populates="tests", foreign_keys=[created_by]
|
||||
)
|
||||
versions: Mapped[List["TestVersion"]] = relationship(
|
||||
back_populates="test", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class TestVersion(Base):
|
||||
__tablename__ = "test_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("test_id", "version", name="test_versions_test_id_version_key"),
|
||||
Index("idx_test_versions_parent_id", "parent_id"),
|
||||
Index(
|
||||
"uq_test_versions_one_active_per_test", "test_id",
|
||||
unique=True,
|
||||
postgresql_where="is_active = true",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
test_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tests.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("test_versions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
|
||||
test: Mapped["Test"] = relationship(back_populates="versions")
|
||||
questions: Mapped[List["Question"]] = relationship(
|
||||
back_populates="version", cascade="all, delete-orphan", order_by="Question.question_order"
|
||||
)
|
||||
attempts: Mapped[List["TestAttempt"]] = relationship(back_populates="test_version")
|
||||
assignments: Mapped[List["TestAssignment"]] = relationship(back_populates="test_version")
|
||||
parent: Mapped[Optional["TestVersion"]] = relationship(
|
||||
remote_side="TestVersion.id", foreign_keys=[parent_id]
|
||||
)
|
||||
|
||||
|
||||
class Question(Base):
|
||||
__tablename__ = "questions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
test_version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
question_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
has_multiple_answers: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
ai_hint: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
version: Mapped["TestVersion"] = relationship(back_populates="questions")
|
||||
options: Mapped[List["AnswerOption"]] = relationship(
|
||||
back_populates="question", cascade="all, delete-orphan", order_by="AnswerOption.option_order"
|
||||
)
|
||||
user_answers: Mapped[List["UserAnswer"]] = relationship(back_populates="question")
|
||||
|
||||
|
||||
class AnswerOption(Base):
|
||||
__tablename__ = "answer_options"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
question_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_correct: Mapped[bool] = mapped_column(Boolean, server_default="false")
|
||||
option_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
question: Mapped["Question"] = relationship(back_populates="options")
|
||||
|
||||
|
||||
class TestAssignment(Base):
|
||||
__tablename__ = "test_assignments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
test_version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
assigned_by: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
deadline: Mapped[Optional[date]] = mapped_column(Date)
|
||||
max_attempts: Mapped[Optional[int]] = mapped_column(Integer, server_default="1")
|
||||
created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
|
||||
test_version: Mapped["TestVersion"] = relationship(back_populates="assignments")
|
||||
targets: Mapped[List["TestAssignmentTarget"]] = relationship(
|
||||
back_populates="assignment", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class TestAssignmentTarget(Base):
|
||||
__tablename__ = "test_assignment_targets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
assignment_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_assignments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
target_type: Mapped[str] = mapped_column(target_type_enum, nullable=False)
|
||||
target_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
assignment: Mapped["TestAssignment"] = relationship(back_populates="targets")
|
||||
|
||||
|
||||
class TestAttempt(Base):
|
||||
__tablename__ = "test_attempts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"test_version_id", "user_id", "attempt_number",
|
||||
name="test_attempts_test_version_id_user_id_attempt_number_key",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
test_version_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_versions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
attempt_number: Mapped[int] = mapped_column(Integer, server_default="1")
|
||||
status: Mapped[str] = mapped_column(attempt_status_enum, server_default="in_progress")
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
correct_count: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
total_questions: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
passed: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
|
||||
test_version: Mapped["TestVersion"] = relationship(back_populates="attempts")
|
||||
user: Mapped["User"] = relationship(back_populates="attempts")
|
||||
user_answers: Mapped[List["UserAnswer"]] = relationship(
|
||||
back_populates="attempt", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class UserAnswer(Base):
|
||||
__tablename__ = "user_answers"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
attempt_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("test_attempts.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
question_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
selected_options: Mapped[Optional[List[uuid.UUID]]] = mapped_column(
|
||||
ARRAY(UUID(as_uuid=True)), server_default="{}"
|
||||
)
|
||||
|
||||
attempt: Mapped["TestAttempt"] = relationship(back_populates="user_answers")
|
||||
question: Mapped["Question"] = relationship(back_populates="user_answers")
|
||||
@@ -0,0 +1,659 @@
|
||||
"""AI-генерация теста/вопроса в редакторе (порт `services/aiEditorService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .draft_validator import (
|
||||
assert_draft_matches_shape,
|
||||
parse_json_from_llm_text,
|
||||
shuffle_options_in_questions,
|
||||
validate_and_normalize_draft,
|
||||
)
|
||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def parse_and_validate_shape(s: Any) -> list[dict]:
|
||||
if not isinstance(s, list) or not s:
|
||||
raise HttpError(400, 'Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].')
|
||||
if len(s) > 40:
|
||||
raise HttpError(400, 'Не более 40 вопросов за раз.')
|
||||
out = []
|
||||
for i, row in enumerate(s):
|
||||
if not isinstance(row, dict):
|
||||
raise HttpError(400, f'shape[{i}]: ожидается объект.')
|
||||
try:
|
||||
n = int(float(row.get('optionsCount')))
|
||||
except (TypeError, ValueError):
|
||||
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
||||
if n < 2 or n > 12:
|
||||
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
||||
has_multi = bool(row.get('hasMultipleAnswers'))
|
||||
raw_min = row.get('minCorrect', 1)
|
||||
raw_max = row.get('maxCorrect', n if has_multi else 1)
|
||||
try:
|
||||
min_c = int(float(raw_min))
|
||||
max_c = int(float(raw_max))
|
||||
except (TypeError, ValueError):
|
||||
raise HttpError(400, f'shape[{i}]: minCorrect/maxCorrect должны быть числами.')
|
||||
if not has_multi:
|
||||
min_c = 1
|
||||
max_c = 1
|
||||
if min_c < 1 or max_c < 1 or min_c > max_c or max_c > n:
|
||||
raise HttpError(
|
||||
400,
|
||||
f'shape[{i}]: корректный диапазон правильных ответов — от 1 до {n}, min<=max.',
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
'optionsCount': n,
|
||||
'hasMultipleAnswers': has_multi,
|
||||
'minCorrect': min_c,
|
||||
'maxCorrect': max_c,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _require_cfg():
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
raise HttpError(503, 'Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.')
|
||||
return cfg
|
||||
|
||||
|
||||
def generate_full_test_by_shape(test_title: str, test_description: str, shape: list[dict]) -> dict:
|
||||
cfg = _require_cfg()
|
||||
title = (test_title or '').strip() or 'Тест'
|
||||
desc = (test_description or '').strip()
|
||||
lines = []
|
||||
for i, sh in enumerate(shape):
|
||||
if sh['hasMultipleAnswers']:
|
||||
tail = (
|
||||
'несколько вариантов помечены как верные (hasMultipleAnswers: true), '
|
||||
f'число правильных от {sh["minCorrect"]} до {sh["maxCorrect"]}.'
|
||||
)
|
||||
else:
|
||||
tail = 'ровно один верный вариант (hasMultipleAnswers: false).'
|
||||
lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}')
|
||||
|
||||
system = (
|
||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. '
|
||||
'Схема: {"title": string, "description": string (может быть пустой строкой), '
|
||||
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", '
|
||||
'"options": [{ "text", "isCorrect" }]}.'
|
||||
)
|
||||
user = (
|
||||
'Составь тест по теме.\n\n'
|
||||
f'Название (можно уточнить, но смысл сохранить): {title}\n'
|
||||
f'Краткое описание / контекст темы: '
|
||||
f'{desc or "не указано; придумай согласованную тему с названием."}\n\n'
|
||||
f'Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):\n'
|
||||
+ '\n'.join(lines)
|
||||
+ '\n\nПравила: варианты — осмысленные, по теме; отметь isCorrect согласно '
|
||||
'hasMultipleAnswers; для одного правильного — ровна одна true.'
|
||||
)
|
||||
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.35)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
assert_draft_matches_shape({'questions': draft['questions']}, shape)
|
||||
shuffle_options_in_questions(draft['questions'])
|
||||
return {
|
||||
'title': draft['title'],
|
||||
'description': draft['description'],
|
||||
'questions': draft['questions'],
|
||||
}
|
||||
|
||||
|
||||
# ─── E1.8: AI v2 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_test_by_title(
|
||||
test_title: str,
|
||||
test_description: str = '',
|
||||
questions_count: int = 10,
|
||||
options_count: int = 4,
|
||||
has_multiple_answers: bool = False,
|
||||
) -> dict:
|
||||
"""Генерация теста ТОЛЬКО по названию: AI сам предлагает вопросы.
|
||||
|
||||
Сетка не задаётся жёстко: пользователю даётся подсказка о желаемом числе
|
||||
вопросов и вариантов, но мы валидируем мягко (не assert_draft_matches_shape).
|
||||
"""
|
||||
cfg = _require_cfg()
|
||||
title = (test_title or '').strip()
|
||||
if not title:
|
||||
raise HttpError(400, 'Укажите название теста.')
|
||||
desc = (test_description or '').strip()
|
||||
n_q = max(3, min(40, int(questions_count or 10)))
|
||||
n_opt = max(2, min(12, int(options_count or 4)))
|
||||
|
||||
system = (
|
||||
'Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним '
|
||||
'JSON-объектом на русском. Схема: {"title", "description", "questions": ['
|
||||
'{"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect"}]}'
|
||||
']}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.'
|
||||
)
|
||||
user = (
|
||||
'Составь учебный тест по этой теме.\n\n'
|
||||
f'Название теста: {title}\n'
|
||||
f'Описание/контекст: {desc or "не указано — определи по названию."}\n\n'
|
||||
f'Подсказка по сетке: примерно {n_q} вопросов, в каждом по {n_opt} вариантов '
|
||||
f'ответа; '
|
||||
f'тип ответа — {"несколько правильных" if has_multiple_answers else "один правильный"} '
|
||||
f'(но если по смыслу нужно отступить — отступи). '
|
||||
'Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно '
|
||||
'неверными. Текст — короткий, понятный.'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
shuffle_options_in_questions(draft['questions'])
|
||||
return {
|
||||
'title': draft['title'],
|
||||
'description': draft['description'],
|
||||
'questions': draft['questions'],
|
||||
}
|
||||
|
||||
|
||||
def check_test_quality(test_title: str, test_description: str, questions: list[dict]) -> dict:
|
||||
"""AI-рецензия теста: общий вердикт + список рекомендаций по разделам."""
|
||||
cfg = _require_cfg()
|
||||
title = (test_title or '').strip() or 'Тест'
|
||||
desc = (test_description or '').strip()
|
||||
qs = questions or []
|
||||
if not qs:
|
||||
raise HttpError(400, 'В тесте нет вопросов — нечего проверять.')
|
||||
|
||||
system = (
|
||||
'Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {"verdict": "ok"|"warn"|"bad", '
|
||||
'"summary": string (1-2 предложения), '
|
||||
'"sections": [{"title": string, "items": [string, ...]}]}. '
|
||||
'Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», '
|
||||
'"Охват темы», «Сбалансированность сложности». Пропусти раздел, если '
|
||||
'претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные '
|
||||
'проблемы. Все тексты — на русском, короткие и предметные.'
|
||||
)
|
||||
test_dump = {
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'questions': [
|
||||
{
|
||||
'text': q.get('text', ''),
|
||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
||||
'options': [
|
||||
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in (q.get('options') or [])
|
||||
],
|
||||
}
|
||||
for q in qs
|
||||
],
|
||||
}
|
||||
import json as _json
|
||||
|
||||
user = 'Проверь качество теста и дай рекомендации:\n\n' + _json.dumps(
|
||||
test_dump, ensure_ascii=False
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise LlmError('Неверный формат ответа модели.', code='llm_shape')
|
||||
verdict = str(parsed.get('verdict') or '').strip().lower()
|
||||
if verdict not in ('ok', 'warn', 'bad'):
|
||||
verdict = 'warn'
|
||||
summary = str(parsed.get('summary') or '').strip()
|
||||
raw_sections = parsed.get('sections') or []
|
||||
sections: list[dict] = []
|
||||
if isinstance(raw_sections, list):
|
||||
for s in raw_sections:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
t = str(s.get('title') or '').strip()
|
||||
items = s.get('items') or []
|
||||
if not t or not isinstance(items, list) or not items:
|
||||
continue
|
||||
clean_items = [str(x).strip() for x in items if str(x).strip()]
|
||||
if clean_items:
|
||||
sections.append({'title': t, 'items': clean_items})
|
||||
return {'verdict': verdict, 'summary': summary, 'sections': sections}
|
||||
|
||||
|
||||
def _merge_suggested_by_focus(
|
||||
focus: str,
|
||||
orig: dict,
|
||||
sug: dict,
|
||||
) -> dict:
|
||||
"""Сужает предложение модели под выбранную область правки."""
|
||||
orig_opts = [
|
||||
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in (orig.get('options') or [])
|
||||
]
|
||||
sug_opts = sug.get('options') or []
|
||||
hm = bool(orig.get('hasMultipleAnswers'))
|
||||
ot = str(orig.get('text', '')).strip()
|
||||
|
||||
if focus == 'questions':
|
||||
return {
|
||||
'text': str(sug.get('text', '')).strip(),
|
||||
'hasMultipleAnswers': hm,
|
||||
'options': [dict(o) for o in orig_opts],
|
||||
}
|
||||
if focus == 'distractors':
|
||||
merged = []
|
||||
for j, oo in enumerate(orig_opts):
|
||||
so = sug_opts[j] if j < len(sug_opts) else oo
|
||||
if oo['isCorrect']:
|
||||
merged.append(dict(oo))
|
||||
else:
|
||||
merged.append(
|
||||
{'text': str(so.get('text', '')).strip(), 'isCorrect': False}
|
||||
)
|
||||
return {'text': ot, 'hasMultipleAnswers': hm, 'options': merged}
|
||||
if focus == 'options':
|
||||
merged = []
|
||||
for j, oo in enumerate(orig_opts):
|
||||
so = sug_opts[j] if j < len(sug_opts) else oo
|
||||
merged.append(
|
||||
{
|
||||
'text': str(so.get('text', '')).strip(),
|
||||
'isCorrect': oo['isCorrect'],
|
||||
}
|
||||
)
|
||||
return {'text': ot, 'hasMultipleAnswers': hm, 'options': merged}
|
||||
return {
|
||||
'text': str(sug.get('text', '')).strip(),
|
||||
'hasMultipleAnswers': bool(sug.get('hasMultipleAnswers')),
|
||||
'options': [
|
||||
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in sug_opts
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def improve_test_full(
|
||||
test_title: str, test_description: str, questions: list[dict], focus: str = 'all'
|
||||
) -> dict:
|
||||
"""AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса.
|
||||
|
||||
focus: all | questions | distractors | options
|
||||
"""
|
||||
cfg = _require_cfg()
|
||||
title = (test_title or '').strip() or 'Тест'
|
||||
desc = (test_description or '').strip()
|
||||
qs = questions or []
|
||||
if not qs:
|
||||
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.')
|
||||
|
||||
focus = (focus or 'all').strip().lower()
|
||||
if focus not in ('all', 'questions', 'distractors', 'options'):
|
||||
focus = 'all'
|
||||
|
||||
system_by_focus = {
|
||||
'all': (
|
||||
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь '
|
||||
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку '
|
||||
'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и '
|
||||
'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. '
|
||||
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||
'"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.'
|
||||
),
|
||||
'questions': (
|
||||
'Ты редактор учебных тестов. Улучши ТОЛЬКО формулировки вопросов (поле text). '
|
||||
'В ответе для каждого вопроса верни options ДОСЛОВНО как во входе '
|
||||
'(те же тексты и те же isCorrect), без правок. '
|
||||
'Сетку не меняй. Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||
'"options": [{"text", "isCorrect"}]}, ...]}.'
|
||||
),
|
||||
'distractors': (
|
||||
'Ты редактор учебных тестов. Улучши ТОЛЬКО неверные варианты (дистракторы), '
|
||||
'где isCorrect: false. Верные варианты (isCorrect: true) верни ДОСЛОВНО как во входе. '
|
||||
'Текст вопроса (text) не меняй — верни как во входе. hasMultipleAnswers не меняй. '
|
||||
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||
'"options": [{"text", "isCorrect"}]}, ...]}.'
|
||||
),
|
||||
'options': (
|
||||
'Ты редактор учебных тестов. Улучши формулировки всех вариантов ответа; '
|
||||
'какой вариант верный, не меняй — isCorrect копируй из входа для каждого индекса. '
|
||||
'Текст вопроса не меняй — верни как во входе. hasMultipleAnswers не меняй. '
|
||||
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||
'"options": [{"text", "isCorrect"}]}, ...]}.'
|
||||
),
|
||||
}
|
||||
system = system_by_focus[focus]
|
||||
|
||||
test_dump = {
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'questions': [
|
||||
{
|
||||
'text': q.get('text', ''),
|
||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
||||
'options': [
|
||||
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in (q.get('options') or [])
|
||||
],
|
||||
}
|
||||
for q in qs
|
||||
],
|
||||
}
|
||||
import json as _json
|
||||
|
||||
user = 'Улучши тест без изменения сетки:\n\n' + _json.dumps(
|
||||
test_dump, ensure_ascii=False
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
|
||||
shape = [
|
||||
{
|
||||
'optionsCount': len(q.get('options') or []),
|
||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
||||
}
|
||||
for q in qs
|
||||
]
|
||||
assert_draft_matches_shape(parsed, shape)
|
||||
draft = validate_and_normalize_draft(
|
||||
{'title': title, 'questions': parsed.get('questions') or []}
|
||||
)
|
||||
raw_suggested = draft['questions']
|
||||
|
||||
suggested_qs = [_merge_suggested_by_focus(focus, o, s) for o, s in zip(qs, raw_suggested)]
|
||||
|
||||
items = []
|
||||
for i, (orig, sug) in enumerate(zip(qs, suggested_qs)):
|
||||
orig_opts = [
|
||||
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in (orig.get('options') or [])
|
||||
]
|
||||
sug_opts = sug['options']
|
||||
text_changed = (str(orig.get('text', '')).strip() != sug['text'])
|
||||
options_changed = (
|
||||
len(orig_opts) != len(sug_opts)
|
||||
or any(
|
||||
a['text'] != b['text'] or a['isCorrect'] != b['isCorrect']
|
||||
for a, b in zip(orig_opts, sug_opts)
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
'index': i,
|
||||
'original': {
|
||||
'text': str(orig.get('text', '')).strip(),
|
||||
'hasMultipleAnswers': bool(orig.get('hasMultipleAnswers')),
|
||||
'options': orig_opts,
|
||||
},
|
||||
'suggested': {
|
||||
'text': sug['text'],
|
||||
'hasMultipleAnswers': sug['hasMultipleAnswers'],
|
||||
'options': sug_opts,
|
||||
},
|
||||
'textChanged': text_changed,
|
||||
'optionsChanged': options_changed,
|
||||
'changed': text_changed or options_changed,
|
||||
}
|
||||
)
|
||||
|
||||
return {'items': items, 'focus': focus}
|
||||
|
||||
|
||||
def improve_single_option_text(
|
||||
test_title: str,
|
||||
test_description: str,
|
||||
question_text: str,
|
||||
options: list[dict],
|
||||
option_index: int,
|
||||
) -> dict:
|
||||
"""Улучшает формулировку одного варианта ответа, не меняя роль (верный/неверный)."""
|
||||
cfg = _require_cfg()
|
||||
opts = options or []
|
||||
try:
|
||||
idx = int(option_index)
|
||||
except (TypeError, ValueError):
|
||||
raise HttpError(400, 'Укажите целочисленный optionIndex.')
|
||||
if idx < 0 or idx >= len(opts):
|
||||
raise HttpError(400, 'Некорректный индекс варианта.')
|
||||
|
||||
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
|
||||
qt = (question_text or '').strip()
|
||||
lines = []
|
||||
for j, o in enumerate(opts):
|
||||
mark = ' ← улучшить этот вариант' if j == idx else ''
|
||||
oc = 'верный' if o.get('isCorrect') else 'неверный'
|
||||
lines.append(f'{j + 1}. ({oc}) {o.get("text", "")}{mark}')
|
||||
|
||||
system = (
|
||||
'Ты редактор учебных тестов. Отвечай ТОЛЬКО JSON: {"text": string} — '
|
||||
'улучшенная формулировка ОДНОГО варианта ответа. Не меняй статус верности: '
|
||||
'если вариант был верным, формулировка остаётся верным ответом по смыслу; '
|
||||
'если неверным — остаётся неверным дистрактором. Без лишних слов.'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Вопрос: {qt or "—"}\n\n'
|
||||
'Варианты:\n'
|
||||
+ '\n'.join(lines)
|
||||
+ f'\n\nПерепиши только вариант №{idx + 1}, короче и яснее, на русском.'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
text = str((parsed or {}).get('text') or '').strip()
|
||||
if not text:
|
||||
raise LlmError('Пустой text в ответе модели.', code='llm_shape')
|
||||
return {'optionIndex': idx, 'text': text}
|
||||
|
||||
|
||||
def generate_question_hint(
|
||||
*,
|
||||
question_text: str,
|
||||
options: list[dict],
|
||||
) -> str:
|
||||
"""Универсальная подсказка к вопросу: 2–4 предложения, объясняет правильный ответ."""
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
return ''
|
||||
correct_list = '; '.join(o['text'] for o in options if o.get('isCorrect'))
|
||||
all_list = '; '.join(o['text'] for o in options)
|
||||
system = (
|
||||
'Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения), '
|
||||
'без markdown и без вступлений. Объясни почему правильный вариант — правильный.'
|
||||
)
|
||||
user = (
|
||||
f'Вопрос: {question_text}\n'
|
||||
f'Варианты: {all_list}\n'
|
||||
f'Правильный ответ: {correct_list or "—"}\n\n'
|
||||
'Дай краткое объяснение для подсказки во всплывающем окне.'
|
||||
)
|
||||
try:
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3, as_json=False)
|
||||
return (raw or '').strip()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning('generate_question_hint failed: %s', e)
|
||||
return ''
|
||||
|
||||
|
||||
def explain_answer(
|
||||
*,
|
||||
question_text: str,
|
||||
options: list[dict],
|
||||
selected_texts: list[str],
|
||||
is_correct: bool,
|
||||
) -> str:
|
||||
"""Генерирует короткое объяснение результата ответа на вопрос (для попапа подсказки)."""
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
return ''
|
||||
correct_list = '; '.join(o['text'] for o in options if o.get('isCorrect'))
|
||||
sel_list = '; '.join(selected_texts) if selected_texts else '(ничего не выбрано)'
|
||||
verdict = 'верно' if is_correct else 'неверно'
|
||||
system = (
|
||||
'Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения). '
|
||||
'Объясни почему правильный ответ именно такой, без лишней воды и без markdown.'
|
||||
)
|
||||
user = (
|
||||
f'Вопрос: {question_text}\n'
|
||||
f'Правильный ответ: {correct_list or "—"}\n'
|
||||
f'Ответ ученика ({verdict}): {sel_list}\n\n'
|
||||
'Дай краткое объяснение для подсказки во всплывающем окне.'
|
||||
)
|
||||
try:
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3, as_json=False)
|
||||
return (raw or '').strip()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning('explain_answer failed: %s', e)
|
||||
return ''
|
||||
|
||||
|
||||
def generate_or_rephrase_question(
|
||||
test_title: str,
|
||||
test_description: str,
|
||||
question_text: str,
|
||||
options_count: Any,
|
||||
has_multiple_answers: bool,
|
||||
mode: str | None = None,
|
||||
existing_options: list[dict] | None = None,
|
||||
) -> dict:
|
||||
import json as _json
|
||||
|
||||
cfg = _require_cfg()
|
||||
try:
|
||||
n = int(float(options_count))
|
||||
except (TypeError, ValueError):
|
||||
raise HttpError(400, 'optionsCount: от 2 до 12.')
|
||||
if n < 2 or n > 12:
|
||||
raise HttpError(400, 'optionsCount: от 2 до 12.')
|
||||
|
||||
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
|
||||
qt = (question_text or '').strip()
|
||||
mode = (mode or '').strip() if mode else ''
|
||||
|
||||
# ── Режим дистракторов: есть вопрос + часть вариантов пуста ─────────────
|
||||
if qt and mode == 'distractors' and existing_options:
|
||||
filled = [o for o in existing_options if (o.get('text') or '').strip()]
|
||||
empty_count = len([o for o in existing_options if not (o.get('text') or '').strip()])
|
||||
if empty_count <= 0:
|
||||
raise HttpError(400, 'Нет пустых полей для дистракторов — добавьте пустые варианты или выберите другой режим.')
|
||||
filled_lines = '\n'.join(
|
||||
f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}'
|
||||
for o in filled
|
||||
) or '(нет)'
|
||||
system = (
|
||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: '
|
||||
f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — '
|
||||
f'ровно {empty_count} объекта в массиве. '
|
||||
'Все тексты на русском, без нумерации, без кавычек.'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Вопрос: {qt}\n\n'
|
||||
f'Уже заполненные варианты:\n{filled_lines}\n\n'
|
||||
f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов '
|
||||
f'(isCorrect: false), которые не повторяют уже существующие варианты '
|
||||
f'и выглядят похоже на реальные ответы.'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
opts = []
|
||||
if isinstance(parsed, dict):
|
||||
opts = parsed.get('options') or []
|
||||
elif isinstance(parsed, list):
|
||||
opts = parsed
|
||||
opts = [
|
||||
{'text': str(o.get('text') or '').strip(), 'isCorrect': False}
|
||||
for o in opts if (o.get('text') or '').strip()
|
||||
][:empty_count]
|
||||
return {'mode': 'distractors', 'text': qt, 'options': opts}
|
||||
|
||||
# ── Улучшить только формулировки вариантов (без смены верности) ──────────
|
||||
if qt and mode == 'improve_options' and existing_options:
|
||||
n_opts = len(existing_options)
|
||||
if n_opts < 2:
|
||||
raise HttpError(400, 'Нужно минимум два варианта ответа.')
|
||||
dump_opts = [
|
||||
{'text': str(o.get('text', '')), 'isCorrect': bool(o.get('isCorrect'))}
|
||||
for o in existing_options
|
||||
]
|
||||
system = (
|
||||
'Ты редактор учебных тестов. Отвечай ТОЛЬКО JSON: '
|
||||
'{"options": [{"text": string, "isCorrect": boolean}, ...]}. '
|
||||
'Число элементов и значение isCorrect на каждом индексе должны совпадать с входом; '
|
||||
'улучши только формулировки text — короче и яснее, на русском.'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Вопрос:\n{qt}\n\n'
|
||||
f'Варианты (улучши только тексты, isCorrect не меняй):\n{_json.dumps(dump_opts, ensure_ascii=False)}'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
raw_list = parsed.get('options') if isinstance(parsed, dict) else None
|
||||
if not isinstance(raw_list, list) or len(raw_list) != n_opts:
|
||||
raise LlmError('Неверный формат: ожидается options той же длины.', code='llm_shape')
|
||||
out_opts = []
|
||||
for i in range(n_opts):
|
||||
ro = raw_list[i] if i < len(raw_list) else {}
|
||||
orig_t = dump_opts[i]['text'].strip()
|
||||
t = str((ro or {}).get('text') or '').strip() or orig_t
|
||||
out_opts.append({'text': t, 'isCorrect': dump_opts[i]['isCorrect']})
|
||||
return {'mode': 'improve_options', 'text': qt, 'options': out_opts}
|
||||
|
||||
# ── Только переформулировать текст вопроса ────────────────────────────────
|
||||
if qt and mode == 'rephrase':
|
||||
system = (
|
||||
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — '
|
||||
'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости '
|
||||
'от сложности исходного черновика, без вариантов ответа.'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Исходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{qt}'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.3)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
text = str((parsed or {}).get('text') or '').strip()
|
||||
if not text:
|
||||
raise LlmError('Пустой text в ответе модели.', code='llm_shape')
|
||||
return {'mode': 'rephrase', 'text': text}
|
||||
|
||||
if qt:
|
||||
raise HttpError(
|
||||
400,
|
||||
'Укажите режим: distractors, improve_options или rephrase — или очистите текст вопроса для полной генерации.',
|
||||
)
|
||||
|
||||
# ── Полная генерация: вопрос пуст ────────────────────────────────────────
|
||||
system = (
|
||||
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
|
||||
'"options": [{ "text", "isCorrect" }]}. Все на русском.'
|
||||
)
|
||||
multi_clause = (
|
||||
'true (несколько верных, минимум 2 isCorrect: true, остальные false).'
|
||||
if has_multiple_answers
|
||||
else 'false (ровно один isCorrect: true).'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Сформулируй ОДИН вопрос по этой теме с ровно {n} вариантами ответа. '
|
||||
f'hasMultipleAnswers = {multi_clause}'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.35)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}]
|
||||
assert_draft_matches_shape({'questions': [parsed]}, shape)
|
||||
draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]})
|
||||
q0 = draft['questions'][0]
|
||||
shuffle_options_in_questions([q0])
|
||||
return {
|
||||
'mode': 'full',
|
||||
'text': q0['text'],
|
||||
'hasMultipleAnswers': q0['hasMultipleAnswers'],
|
||||
'options': q0['options'],
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Извлечение текста из PDF/DOCX/TXT/MD (порт `services/documentExtractService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
SUPPORTED_MIME = {
|
||||
'application/pdf': 'pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'text/plain': 'text',
|
||||
'text/markdown': 'text',
|
||||
}
|
||||
SUPPORTED_EXT = {
|
||||
'.pdf': 'pdf',
|
||||
'.docx': 'docx',
|
||||
'.txt': 'text',
|
||||
'.md': 'text',
|
||||
}
|
||||
|
||||
|
||||
def resolve_document_kind(mimetype: str | None, original_name: str | None = '') -> Optional[str]:
|
||||
# Браузеры часто шлют «text/plain; charset=utf-8» — без отсечения параметров ключ не совпадает.
|
||||
raw = (mimetype or '').strip()
|
||||
m = raw.split(';', 1)[0].strip().lower()
|
||||
n = (original_name or '').lower()
|
||||
if m in SUPPORTED_MIME:
|
||||
return SUPPORTED_MIME[m]
|
||||
for ext, kind in SUPPORTED_EXT.items():
|
||||
if n.endswith(ext):
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
def extract_text_from_buffer(kind: str, buf: bytes) -> str:
|
||||
if kind == 'text':
|
||||
try:
|
||||
return buf.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return buf.decode('utf-8', errors='replace')
|
||||
|
||||
if kind == 'docx':
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
raise HttpError(500, 'python-docx не установлен (см. requirements.txt).')
|
||||
doc = Document(BytesIO(buf))
|
||||
parts = []
|
||||
for p in doc.paragraphs:
|
||||
if p.text:
|
||||
parts.append(p.text)
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
if cell.text:
|
||||
parts.append(cell.text)
|
||||
return '\n'.join(parts).replace('\r\n', '\n').strip()
|
||||
|
||||
if kind == 'pdf':
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
raise HttpError(500, 'pypdf не установлен (см. requirements.txt).')
|
||||
reader = PdfReader(BytesIO(buf))
|
||||
parts = []
|
||||
for page in reader.pages:
|
||||
try:
|
||||
t = page.extract_text() or ''
|
||||
except Exception:
|
||||
t = ''
|
||||
if t:
|
||||
parts.append(t)
|
||||
return '\n'.join(parts).replace('\r\n', '\n').strip()
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def extract_text_from_file(mimetype: str | None, file_storage, original_name: str | None) -> str:
|
||||
"""`file_storage` — werkzeug FileStorage. Читает целиком в память (≤16 МБ)."""
|
||||
kind = resolve_document_kind(mimetype, original_name)
|
||||
if not kind:
|
||||
raise HttpError(400, 'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.')
|
||||
buf = file_storage.read()
|
||||
return extract_text_from_buffer(kind, buf)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Генерация черновика теста из извлечённого текста (порт части `documentGenService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .draft_validator import (
|
||||
normalize_draft_to_shape,
|
||||
parse_json_from_llm_text,
|
||||
shuffle_options_in_questions,
|
||||
validate_and_normalize_draft,
|
||||
)
|
||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
||||
|
||||
|
||||
MAX_EXTRACT_CHARS = 14000
|
||||
|
||||
|
||||
def generation_for_import_document(extracted_text: str, user_hint: str = '', shape: list[dict] | None = None) -> dict:
|
||||
text = (extracted_text or '').strip()
|
||||
if not text:
|
||||
return {
|
||||
'available': False,
|
||||
'message': 'Нет извлечённого текста — нечего передавать в модель.',
|
||||
}
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
return {
|
||||
'available': False,
|
||||
'message': (
|
||||
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY '
|
||||
'в .env. Превью текста ниже — можно вставить вручную.'
|
||||
),
|
||||
'textPreview': text[:4000],
|
||||
}
|
||||
if len(text) > MAX_EXTRACT_CHARS:
|
||||
slice_ = text[:MAX_EXTRACT_CHARS] + '\n\n[…фрагмент обрезан для API]'
|
||||
else:
|
||||
slice_ = text
|
||||
try:
|
||||
system = (
|
||||
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом '
|
||||
'без пояснений. Схема: {"title": string, "description"?: string, '
|
||||
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, '
|
||||
'"options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. '
|
||||
'Для одиночного выбора ровно один isCorrect: true. '
|
||||
'Текст и формулировки — на русском, по содержанию входного материала.'
|
||||
)
|
||||
hint_block = f'\n\nДополнительные инструкции от автора теста:\n{user_hint.strip()}' if user_hint and user_hint.strip() else ''
|
||||
shape_block = ''
|
||||
if shape:
|
||||
rows = []
|
||||
for i, sh in enumerate(shape):
|
||||
if sh.get('hasMultipleAnswers'):
|
||||
rows.append(
|
||||
f'- Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов, '
|
||||
f'правильных от {sh.get("minCorrect", 1)} до {sh.get("maxCorrect", sh["optionsCount"])}.'
|
||||
)
|
||||
else:
|
||||
rows.append(f'- Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов, ровно 1 правильный.')
|
||||
shape_block = '\n\nСтрого соблюди шаблон:\n' + '\n'.join(rows)
|
||||
user = (
|
||||
'Составь тест с вопросами с одним или несколькими правильными ответами '
|
||||
'на основе текста:\n\n' + slice_ + hint_block + shape_block
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
draft = validate_and_normalize_draft(parsed)
|
||||
if shape:
|
||||
draft = normalize_draft_to_shape(draft, shape)
|
||||
shuffle_options_in_questions(draft['questions'])
|
||||
return {
|
||||
'available': True,
|
||||
'message': (
|
||||
f'Сгенерировано: «{draft["title"]}», вопросов: '
|
||||
f'{len(draft["questions"])}. Нажмите «Применить сгенерированный черновик».'
|
||||
),
|
||||
'draft': draft,
|
||||
}
|
||||
except LlmError as e:
|
||||
return {
|
||||
'available': False,
|
||||
'message': f'Генерация не удалась: {e}',
|
||||
'errorCode': e.code,
|
||||
'textPreview': text[:4000],
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'available': False,
|
||||
'message': f'Генерация не удалась: {e}',
|
||||
'errorCode': 'unknown',
|
||||
'textPreview': text[:4000],
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Парсер JSON от LLM и валидатор draft (порт частей `documentGenService.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import random
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .llm_client import LlmError
|
||||
|
||||
|
||||
_FENCE_RE = re.compile(r'^```(?:json)?\s*([\s\S]*?)```$', re.MULTILINE)
|
||||
|
||||
|
||||
def parse_json_from_llm_text(text: str) -> Any:
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise LlmError('Пустой ответ модели.', code='llm_empty')
|
||||
t = text.strip()
|
||||
if m := _FENCE_RE.match(t):
|
||||
t = m.group(1).strip()
|
||||
try:
|
||||
return _json.loads(t)
|
||||
except _json.JSONDecodeError:
|
||||
raise LlmError('Ответ модели не является корректным JSON.', code='llm_json_parse')
|
||||
|
||||
|
||||
def validate_and_normalize_draft(o: Any) -> dict:
|
||||
if not isinstance(o, dict):
|
||||
raise LlmError('JSON не содержит объекта с данными.', code='llm_shape')
|
||||
title = str(o.get('title') or '').strip()
|
||||
if not title:
|
||||
raise LlmError('В ответе нет поля title.', code='llm_shape')
|
||||
desc = o.get('description')
|
||||
description = str(desc).strip() if desc and str(desc).strip() else None
|
||||
|
||||
raw_qs = o.get('questions')
|
||||
if not isinstance(raw_qs, list) or not raw_qs:
|
||||
raise LlmError('В ответе нет вопросов (questions).', code='llm_shape')
|
||||
if len(raw_qs) > 40:
|
||||
raise LlmError('Слишком много вопросов в ответе (макс. 40).', code='llm_shape')
|
||||
|
||||
questions = []
|
||||
for i, q in enumerate(raw_qs):
|
||||
if not isinstance(q, dict):
|
||||
raise LlmError(f'Вопрос {i + 1}: неверный формат.', code='llm_shape')
|
||||
text = str(q.get('text') or '').strip()
|
||||
if not text:
|
||||
raise LlmError(f'Вопрос {i + 1}: пустой текст.', code='llm_shape')
|
||||
has_multi = bool(q.get('hasMultipleAnswers'))
|
||||
raw_opts = q.get('options')
|
||||
if not isinstance(raw_opts, list) or len(raw_opts) < 2:
|
||||
raise LlmError(f'Вопрос {i + 1}: нужны минимум 2 варианта ответа.', code='llm_shape')
|
||||
if len(raw_opts) > 12:
|
||||
raise LlmError(f'Вопрос {i + 1}: слишком много вариантов (макс. 12).', code='llm_shape')
|
||||
|
||||
options = []
|
||||
for j, op in enumerate(raw_opts):
|
||||
if not isinstance(op, dict):
|
||||
raise LlmError(f'Вопрос {i + 1}, вариант {j + 1}: неверный формат.', code='llm_shape')
|
||||
options.append(
|
||||
{
|
||||
'text': (str(op.get('text') or '').strip() or f'Вариант {j + 1}'),
|
||||
'isCorrect': bool(op.get('isCorrect')),
|
||||
}
|
||||
)
|
||||
correct_n = sum(1 for x in options if x['isCorrect'])
|
||||
if correct_n == 0:
|
||||
raise LlmError(
|
||||
f'Вопрос {i + 1}: отметьте минимум один правильный вариант.',
|
||||
code='llm_shape',
|
||||
)
|
||||
if not has_multi and correct_n > 1:
|
||||
raise LlmError(
|
||||
f'Вопрос {i + 1}: с одним правильным ответом должен быть один вариант '
|
||||
f'isCorrect, либо укажите hasMultipleAnswers: true.',
|
||||
code='llm_shape',
|
||||
)
|
||||
questions.append({'text': text, 'hasMultipleAnswers': has_multi, 'options': options})
|
||||
|
||||
return {'title': title, 'description': description, 'questions': questions}
|
||||
|
||||
|
||||
def shuffle_options_in_questions(questions: list[dict] | None) -> None:
|
||||
"""Случайный порядок вариантов в каждом вопросе; isCorrect остаётся у соответствующего текста."""
|
||||
if not questions:
|
||||
return
|
||||
for q in questions:
|
||||
if not isinstance(q, dict):
|
||||
continue
|
||||
opts = q.get('options')
|
||||
if isinstance(opts, list) and len(opts) >= 2:
|
||||
random.shuffle(opts)
|
||||
|
||||
|
||||
def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None:
|
||||
"""Проверяет, что число вопросов и вариантов = ровно как в shape."""
|
||||
qs = o.get('questions') if isinstance(o, dict) else None
|
||||
if not isinstance(qs, list):
|
||||
raise LlmError('В ответе нет questions.', code='llm_shape')
|
||||
if len(qs) != len(shape):
|
||||
raise LlmError(
|
||||
f'Ожидалось вопросов: {len(shape)}, в ответе: {len(qs)}.',
|
||||
code='llm_shape',
|
||||
)
|
||||
for i, (q, sh) in enumerate(zip(qs, shape)):
|
||||
opts = q.get('options') if isinstance(q, dict) else None
|
||||
if not isinstance(opts, list):
|
||||
raise LlmError(f'Вопрос {i + 1}: нет options.', code='llm_shape')
|
||||
if len(opts) != sh['optionsCount']:
|
||||
raise LlmError(
|
||||
f'Вопрос {i + 1}: ожидалось вариантов {sh["optionsCount"]}, в ответе: {len(opts)}.',
|
||||
code='llm_shape',
|
||||
)
|
||||
if bool(q.get('hasMultipleAnswers')) != sh['hasMultipleAnswers']:
|
||||
raise LlmError(
|
||||
f'Вопрос {i + 1}: hasMultipleAnswers должен быть {sh["hasMultipleAnswers"]}.',
|
||||
code='llm_shape',
|
||||
)
|
||||
min_c = int(sh.get('minCorrect', 1))
|
||||
max_c = int(sh.get('maxCorrect', sh['optionsCount']))
|
||||
correct_n = sum(1 for op in opts if bool(op.get('isCorrect')))
|
||||
if correct_n < min_c or correct_n > max_c:
|
||||
raise LlmError(
|
||||
f'Вопрос {i + 1}: правильных ответов должно быть от {min_c} до {max_c}, в ответе: {correct_n}.',
|
||||
code='llm_shape',
|
||||
)
|
||||
|
||||
|
||||
def normalize_draft_to_shape(draft: dict, shape: list[dict]) -> dict:
|
||||
"""Приводит draft к shape: число вопросов/вариантов/мульти и диапазон correct."""
|
||||
qs = list((draft or {}).get('questions') or [])
|
||||
out_qs = []
|
||||
|
||||
def _mk_option(i: int) -> dict:
|
||||
return {'text': f'Вариант {i + 1}', 'isCorrect': False}
|
||||
|
||||
for i, sh in enumerate(shape):
|
||||
src = qs[i] if i < len(qs) and isinstance(qs[i], dict) else {}
|
||||
text = str(src.get('text') or '').strip() or f'Вопрос {i + 1}'
|
||||
has_multi = bool(sh.get('hasMultipleAnswers'))
|
||||
min_c = int(sh.get('minCorrect', 1))
|
||||
max_c = int(sh.get('maxCorrect', sh['optionsCount']))
|
||||
if not has_multi:
|
||||
min_c = 1
|
||||
max_c = 1
|
||||
|
||||
raw_opts = src.get('options') if isinstance(src.get('options'), list) else []
|
||||
opts = []
|
||||
for j in range(sh['optionsCount']):
|
||||
if j < len(raw_opts) and isinstance(raw_opts[j], dict):
|
||||
t = str(raw_opts[j].get('text') or '').strip() or f'Вариант {j + 1}'
|
||||
opts.append({'text': t, 'isCorrect': bool(raw_opts[j].get('isCorrect'))})
|
||||
else:
|
||||
opts.append(_mk_option(j))
|
||||
|
||||
true_idx = [idx for idx, op in enumerate(opts) if op['isCorrect']]
|
||||
if not has_multi:
|
||||
keep = true_idx[0] if true_idx else 0
|
||||
for idx, op in enumerate(opts):
|
||||
op['isCorrect'] = (idx == keep)
|
||||
else:
|
||||
if len(true_idx) < min_c:
|
||||
for idx in range(len(opts)):
|
||||
if idx not in true_idx:
|
||||
opts[idx]['isCorrect'] = True
|
||||
true_idx.append(idx)
|
||||
if len(true_idx) >= min_c:
|
||||
break
|
||||
if len(true_idx) > max_c:
|
||||
keep = set(true_idx[:max_c])
|
||||
for idx, op in enumerate(opts):
|
||||
op['isCorrect'] = idx in keep
|
||||
|
||||
out_qs.append({'text': text, 'hasMultipleAnswers': has_multi, 'options': opts})
|
||||
|
||||
return {
|
||||
'title': str((draft or {}).get('title') or '').strip() or 'Тест',
|
||||
'description': (draft or {}).get('description'),
|
||||
'questions': out_qs,
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..db import get_session
|
||||
from ..messages import RU
|
||||
from ..models import AnswerOption, Question, Test, TestVersion
|
||||
from .test_access import is_test_author, is_test_edit_open
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def load_questions_for_version(session, test_version_id, *, include_correct: bool) -> list[dict]:
|
||||
if not isinstance(test_version_id, _uuid.UUID):
|
||||
try:
|
||||
test_version_id = _uuid.UUID(str(test_version_id))
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
questions = (
|
||||
session.query(Question)
|
||||
.options(selectinload(Question.options))
|
||||
.filter(Question.test_version_id == test_version_id)
|
||||
.order_by(Question.question_order)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for q in questions:
|
||||
options = []
|
||||
for o in sorted(q.options, key=lambda x: x.option_order):
|
||||
base = {
|
||||
'id': str(o.id),
|
||||
'text': o.text,
|
||||
'optionOrder': o.option_order,
|
||||
}
|
||||
if include_correct:
|
||||
base['isCorrect'] = bool(o.is_correct)
|
||||
options.append(base)
|
||||
out.append({
|
||||
'id': str(q.id),
|
||||
'text': q.text,
|
||||
'questionOrder': q.question_order,
|
||||
'hasMultipleAnswers': bool(q.has_multiple_answers),
|
||||
'aiHint': (q.ai_hint or '').strip() or None,
|
||||
'options': options,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def get_editor_content(user_id: str, test_id: str) -> dict:
|
||||
session = get_session()
|
||||
try:
|
||||
tid = _uuid.UUID(test_id)
|
||||
except (ValueError, AttributeError):
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, user_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
active_version = (
|
||||
session.query(TestVersion)
|
||||
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not active_version:
|
||||
raise HttpError(400, 'Нет активной версии теста.')
|
||||
|
||||
version_count = (
|
||||
session.query(func.count(TestVersion.id))
|
||||
.filter(TestVersion.test_id == tid)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
questions = load_questions_for_version(session, active_version.id, include_correct=True)
|
||||
has_attempts = has_any_attempt_for_test(session, tid)
|
||||
|
||||
return {
|
||||
'test': {
|
||||
'id': str(test.id),
|
||||
'title': test.title,
|
||||
'description': test.description,
|
||||
'passingThreshold': test.passing_threshold,
|
||||
'timeLimit': test.time_limit,
|
||||
'hintsEnabled': bool(test.hints_enabled),
|
||||
'resultMode': test.result_mode or 'end',
|
||||
'hasAttempts': bool(has_attempts),
|
||||
'versionCount': version_count,
|
||||
'hasForkRisk': bool(has_attempts) or version_count > 1,
|
||||
},
|
||||
'activeVersionId': str(active_version.id),
|
||||
'questions': questions,
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"""OpenAI-совместимый клиент Chat Completions (порт `services/llmClient.js`)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import json as _json
|
||||
|
||||
|
||||
class LlmError(Exception):
|
||||
"""Ошибка работы с LLM API."""
|
||||
|
||||
def __init__(self, message: str, code: str = 'llm_error', status: int | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmConfig:
|
||||
provider: str
|
||||
api_key: str
|
||||
base_url: str
|
||||
model: str
|
||||
|
||||
|
||||
def get_llm_config() -> Optional[LlmConfig]:
|
||||
if k := os.environ.get('DEEPSEEK_API_KEY'):
|
||||
return LlmConfig(
|
||||
provider='deepseek',
|
||||
api_key=k,
|
||||
base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.deepseek.com/v1').rstrip('/'),
|
||||
model=os.environ.get('LLM_MODEL') or 'deepseek-chat',
|
||||
)
|
||||
if k := os.environ.get('OPENAI_API_KEY'):
|
||||
return LlmConfig(
|
||||
provider='openai',
|
||||
api_key=k,
|
||||
base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.openai.com/v1').rstrip('/'),
|
||||
model=os.environ.get('LLM_MODEL') or 'gpt-4o-mini',
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def chat_completion_text_content(
|
||||
cfg: LlmConfig,
|
||||
system: str,
|
||||
user: str,
|
||||
temperature: float = 0.25,
|
||||
timeout: int = 120,
|
||||
as_json: bool = True,
|
||||
) -> str:
|
||||
"""Возвращает `assistant.message.content` (строку).
|
||||
|
||||
`as_json=True` (по умолчанию) включает `response_format: json_object`. Для свободного
|
||||
текста (например, объяснения к вопросу) передайте `as_json=False`.
|
||||
"""
|
||||
body: dict = {
|
||||
'model': cfg.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system},
|
||||
{'role': 'user', 'content': user},
|
||||
],
|
||||
'temperature': temperature,
|
||||
}
|
||||
if as_json and (os.environ.get('LLM_NO_JSON') or '').strip() != '1':
|
||||
body['response_format'] = {'type': 'json_object'}
|
||||
|
||||
req = urllib.request.Request(
|
||||
f'{cfg.base_url}/chat/completions',
|
||||
data=_json.dumps(body).encode('utf-8'),
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {cfg.api_key}',
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = _json.loads(resp.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
text = ''
|
||||
try:
|
||||
text = e.read().decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
raise LlmError(
|
||||
f'LLM {e.code}: {(text or "").replace(chr(10), " ")[:280]}',
|
||||
code='llm_http',
|
||||
status=e.code,
|
||||
)
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
msg = str(getattr(e, 'reason', '') or e)
|
||||
if 'timed out' in msg.lower():
|
||||
raise LlmError('Превышен таймаут ожидания ответа LLM (120 с).', code='llm_timeout')
|
||||
raise LlmError(f'Сбой сети при обращении к LLM: {msg}', code='llm_network')
|
||||
|
||||
try:
|
||||
content = data['choices'][0]['message']['content']
|
||||
except (KeyError, IndexError, TypeError):
|
||||
content = None
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise LlmError('Пустой content в ответе API.', code='llm_empty')
|
||||
return content
|
||||
|
||||
|
||||
def ping_llm(timeout: int = 30) -> dict:
|
||||
"""Smoke-проверка подключения к LLM. Не бросает исключений — всё в результате.
|
||||
|
||||
Возвращает: {'ok': bool, 'provider', 'model', 'error'?, 'latencyMs'?, 'sample'?}
|
||||
"""
|
||||
import time
|
||||
|
||||
cfg = get_llm_config()
|
||||
if cfg is None:
|
||||
return {
|
||||
'ok': False,
|
||||
'configured': False,
|
||||
'error': 'Ключ не задан. Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY в .env.',
|
||||
}
|
||||
started = time.monotonic()
|
||||
try:
|
||||
raw = chat_completion_text_content(
|
||||
cfg,
|
||||
'Отвечай ТОЛЬКО JSON: {"ok": true}.',
|
||||
'ping',
|
||||
temperature=0.0,
|
||||
timeout=timeout,
|
||||
)
|
||||
ms = int((time.monotonic() - started) * 1000)
|
||||
return {
|
||||
'ok': True,
|
||||
'configured': True,
|
||||
'provider': cfg.provider,
|
||||
'model': cfg.model,
|
||||
'latencyMs': ms,
|
||||
'sample': raw[:120],
|
||||
}
|
||||
except LlmError as e:
|
||||
ms = int((time.monotonic() - started) * 1000)
|
||||
return {
|
||||
'ok': False,
|
||||
'configured': True,
|
||||
'provider': cfg.provider,
|
||||
'model': cfg.model,
|
||||
'latencyMs': ms,
|
||||
'error': str(e),
|
||||
'code': e.code,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'ok': False,
|
||||
'configured': True,
|
||||
'provider': cfg.provider,
|
||||
'model': cfg.model,
|
||||
'error': f'{type(e).__name__}: {e}',
|
||||
'code': 'unknown',
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Хранилище редактируемых промптов для AI-функций.
|
||||
|
||||
Промпты хранятся в app/data/prompts.json и загружаются при старте.
|
||||
Если файл не существует или повреждён — используются встроенные defaults.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
_lock = threading.Lock()
|
||||
|
||||
_DATA_FILE = Path(__file__).parent.parent / 'data' / 'prompts.json'
|
||||
|
||||
_store: dict[str, dict] | None = None
|
||||
|
||||
|
||||
def _load() -> dict[str, dict]:
|
||||
if _DATA_FILE.exists():
|
||||
try:
|
||||
with _DATA_FILE.open('r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
_log.warning('prompt_store: не удалось загрузить %s: %s', _DATA_FILE, e)
|
||||
return {}
|
||||
|
||||
|
||||
def _get_store() -> dict[str, dict]:
|
||||
global _store
|
||||
if _store is None:
|
||||
with _lock:
|
||||
if _store is None:
|
||||
_store = _load()
|
||||
return _store
|
||||
|
||||
|
||||
def get_all() -> dict[str, dict]:
|
||||
"""Вернуть все промпты (id → {label, description, system, user, vars})."""
|
||||
return dict(_get_store())
|
||||
|
||||
|
||||
def get_prompt(prompt_id: str) -> dict[str, Any] | None:
|
||||
return _get_store().get(prompt_id)
|
||||
|
||||
|
||||
def get_system(prompt_id: str, default: str = '') -> str:
|
||||
p = get_prompt(prompt_id)
|
||||
return p.get('system', default) if p else default
|
||||
|
||||
|
||||
def get_user(prompt_id: str, default: str = '') -> str:
|
||||
p = get_prompt(prompt_id)
|
||||
return p.get('user', default) if p else default
|
||||
|
||||
|
||||
def save_all(data: dict[str, dict]) -> None:
|
||||
"""Сохранить все промпты в JSON-файл и обновить кеш."""
|
||||
global _store
|
||||
_DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _lock:
|
||||
with _DATA_FILE.open('w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
_store = data
|
||||
|
||||
|
||||
def save_prompt(prompt_id: str, system: str, user: str) -> None:
|
||||
"""Обновить system/user для одного промпта."""
|
||||
store = dict(_get_store())
|
||||
if prompt_id not in store:
|
||||
raise KeyError(f'Промпт {prompt_id!r} не найден.')
|
||||
store[prompt_id] = {**store[prompt_id], 'system': system, 'user': user}
|
||||
save_all(store)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Кто видит тест: автор + назначенные пользователи."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import exists, func
|
||||
|
||||
from ..db import get_session
|
||||
from ..models import Question, Test, TestAssignment, TestAssignmentTarget, TestAttempt, TestVersion, User
|
||||
|
||||
|
||||
def _truthy_env(val: str | None) -> bool:
|
||||
return (val or '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def is_test_edit_open() -> bool:
|
||||
"""Пока без RBAC: любой залогиненный пользователь может править любой тест.
|
||||
|
||||
Задайте ``CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR=1``, чтобы снова требовать роль автора
|
||||
для редактирования, подсказок, списка попыток и разбора чужих попыток.
|
||||
"""
|
||||
return not _truthy_env(os.environ.get('CLINIC_TESTS_RESTRICT_EDIT_TO_AUTHOR'))
|
||||
|
||||
|
||||
def is_test_author(created_by, user_id) -> bool:
|
||||
if created_by is None or user_id is None:
|
||||
return False
|
||||
return str(created_by) == str(user_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessResult:
|
||||
ok: bool
|
||||
is_author: bool
|
||||
not_found: bool
|
||||
|
||||
|
||||
def user_has_test_access(user_id: str, test_id: str) -> AccessResult:
|
||||
import uuid as _uuid
|
||||
session = get_session()
|
||||
try:
|
||||
tid = _uuid.UUID(test_id)
|
||||
uid = _uuid.UUID(user_id)
|
||||
except (ValueError, AttributeError):
|
||||
return AccessResult(ok=False, is_author=False, not_found=True)
|
||||
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
return AccessResult(ok=False, is_author=False, not_found=True)
|
||||
|
||||
if is_test_author(test.created_by, uid):
|
||||
return AccessResult(ok=True, is_author=True, not_found=False)
|
||||
|
||||
# Глобальная политика доступа: любой активный тест доступен всем сотрудникам.
|
||||
if bool(test.is_active):
|
||||
return AccessResult(ok=True, is_author=False, not_found=False)
|
||||
|
||||
assigned = session.query(
|
||||
exists().where(
|
||||
TestAssignmentTarget.target_type == 'user',
|
||||
TestAssignmentTarget.target_id == uid,
|
||||
TestAssignmentTarget.assignment_id == TestAssignment.id,
|
||||
TestAssignment.test_version_id == TestVersion.id,
|
||||
TestVersion.test_id == tid,
|
||||
)
|
||||
).scalar()
|
||||
|
||||
return AccessResult(ok=bool(assigned), is_author=False, not_found=False)
|
||||
|
||||
|
||||
def list_visible_tests(user_id: str) -> list[dict]:
|
||||
"""В dev-режиме возвращает все активные тесты независимо от назначения."""
|
||||
import uuid as _uuid
|
||||
session = get_session()
|
||||
try:
|
||||
uid = _uuid.UUID(user_id)
|
||||
except (ValueError, AttributeError):
|
||||
uid = None
|
||||
|
||||
qcount_sq = (
|
||||
session.query(
|
||||
Question.test_version_id.label('tv_id'),
|
||||
func.count(Question.id).label('qc'),
|
||||
)
|
||||
.group_by(Question.test_version_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
session.query(Test, TestVersion, User, qcount_sq.c.qc)
|
||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||
.outerjoin(User, User.id == Test.created_by)
|
||||
.outerjoin(qcount_sq, qcount_sq.c.tv_id == TestVersion.id)
|
||||
.filter(Test.is_active.is_(True))
|
||||
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'id': str(t.id),
|
||||
'title': t.title,
|
||||
'description': t.description,
|
||||
'chain_active': t.is_active,
|
||||
'created_at': t.created_at,
|
||||
'updated_at': t.updated_at,
|
||||
'active_version_id': str(tv.id),
|
||||
'version': tv.version,
|
||||
'created_by': str(t.created_by) if t.created_by else None,
|
||||
'author_full_name': u.full_name if u else '—',
|
||||
'passing_threshold': int(t.passing_threshold or 0),
|
||||
'time_limit': t.time_limit,
|
||||
'result_mode': (t.result_mode or 'end'),
|
||||
'hints_enabled': bool(t.hints_enabled),
|
||||
'questions_count': int(qc or 0),
|
||||
'has_in_progress_attempt': bool(
|
||||
uid and session.query(
|
||||
exists().where(
|
||||
TestAttempt.user_id == uid,
|
||||
TestAttempt.status == 'in_progress',
|
||||
TestAttempt.test_version_id == TestVersion.id,
|
||||
TestVersion.test_id == t.id,
|
||||
)
|
||||
).scalar()
|
||||
),
|
||||
}
|
||||
for t, tv, u, qc in rows
|
||||
]
|
||||
|
||||
|
||||
def list_hidden_by_author(user_id: str) -> list[dict]:
|
||||
import uuid as _uuid
|
||||
session = get_session()
|
||||
try:
|
||||
uid = _uuid.UUID(user_id)
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
qcount_sq = (
|
||||
session.query(
|
||||
Question.test_version_id.label('tv_id'),
|
||||
func.count(Question.id).label('qc'),
|
||||
)
|
||||
.group_by(Question.test_version_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
session.query(Test, TestVersion, User, qcount_sq.c.qc)
|
||||
.join(TestVersion, (TestVersion.test_id == Test.id) & TestVersion.is_active.is_(True))
|
||||
.join(User, User.id == Test.created_by)
|
||||
.outerjoin(qcount_sq, qcount_sq.c.tv_id == TestVersion.id)
|
||||
.filter(Test.is_active.is_(False), Test.created_by == uid)
|
||||
.order_by(Test.updated_at.desc().nullslast(), Test.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'id': str(t.id),
|
||||
'title': t.title,
|
||||
'description': t.description,
|
||||
'chain_active': t.is_active,
|
||||
'created_at': t.created_at,
|
||||
'updated_at': t.updated_at,
|
||||
'active_version_id': str(tv.id),
|
||||
'version': tv.version,
|
||||
'created_by': str(t.created_by),
|
||||
'author_full_name': u.full_name,
|
||||
'passing_threshold': int(t.passing_threshold or 0),
|
||||
'time_limit': t.time_limit,
|
||||
'result_mode': (t.result_mode or 'end'),
|
||||
'hints_enabled': bool(t.hints_enabled),
|
||||
'questions_count': int(qc or 0),
|
||||
'has_in_progress_attempt': bool(
|
||||
session.query(
|
||||
exists().where(
|
||||
TestAttempt.user_id == uid,
|
||||
TestAttempt.status == 'in_progress',
|
||||
TestAttempt.test_version_id == TestVersion.id,
|
||||
TestVersion.test_id == t.id,
|
||||
)
|
||||
).scalar()
|
||||
),
|
||||
}
|
||||
for t, tv, u, qc in rows
|
||||
]
|
||||
@@ -0,0 +1,619 @@
|
||||
"""Сервис прохождения теста."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from ..db import get_session
|
||||
from ..models import (
|
||||
AnswerOption,
|
||||
Question,
|
||||
Test,
|
||||
TestAttempt,
|
||||
TestVersion,
|
||||
User,
|
||||
UserAnswer,
|
||||
)
|
||||
from .test_access import is_test_author, is_test_edit_open, user_has_test_access
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def _sort_uuid_strings(items) -> list[str]:
|
||||
return sorted({str(x) for x in (items or []) if x is not None})
|
||||
|
||||
|
||||
def _same_selection(selected, correct_ids) -> bool:
|
||||
return _sort_uuid_strings(selected) == _sort_uuid_strings(correct_ids)
|
||||
|
||||
|
||||
def _to_uuid(val) -> _uuid.UUID | None:
|
||||
if isinstance(val, _uuid.UUID):
|
||||
return val
|
||||
try:
|
||||
return _uuid.UUID(str(val))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
# ─── load questions (shared) ─────────────────────────────────────────────────
|
||||
|
||||
def load_questions_for_version(
|
||||
session: Session,
|
||||
test_version_id,
|
||||
*,
|
||||
include_correct: bool,
|
||||
include_hints: bool = False,
|
||||
) -> list[dict]:
|
||||
vid = _to_uuid(test_version_id)
|
||||
if vid is None:
|
||||
return []
|
||||
questions = (
|
||||
session.query(Question)
|
||||
.options(selectinload(Question.options))
|
||||
.filter(Question.test_version_id == vid)
|
||||
.order_by(Question.question_order)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for q in questions:
|
||||
options = []
|
||||
for o in sorted(q.options, key=lambda x: x.option_order):
|
||||
base = {'id': str(o.id), 'text': o.text, 'optionOrder': o.option_order}
|
||||
if include_correct:
|
||||
base['isCorrect'] = bool(o.is_correct)
|
||||
options.append(base)
|
||||
item = {
|
||||
'id': str(q.id),
|
||||
'text': q.text,
|
||||
'questionOrder': q.question_order,
|
||||
'hasMultipleAnswers': bool(q.has_multiple_answers),
|
||||
'options': options,
|
||||
}
|
||||
if include_hints:
|
||||
hint = (q.ai_hint or '').strip()
|
||||
item['aiHint'] = hint or None
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
# ─── start ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def start_attempt(session_or_eng, user_id: str, test_id: str) -> dict:
|
||||
"""Принимает engine (legacy) или session — для обратной совместимости."""
|
||||
acc = user_has_test_access(user_id, test_id)
|
||||
if not acc.ok:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
uid = _to_uuid(user_id)
|
||||
|
||||
active_version = (
|
||||
session.query(TestVersion)
|
||||
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not active_version:
|
||||
raise HttpError(404, 'Нет активной версии теста.')
|
||||
|
||||
max_n = (
|
||||
session.query(func.coalesce(func.max(TestAttempt.attempt_number), 0))
|
||||
.filter(
|
||||
TestAttempt.test_version_id == active_version.id,
|
||||
TestAttempt.user_id == uid,
|
||||
)
|
||||
.scalar() or 0
|
||||
)
|
||||
attempt = TestAttempt(
|
||||
test_version_id=active_version.id,
|
||||
user_id=uid,
|
||||
attempt_number=int(max_n) + 1,
|
||||
status='in_progress',
|
||||
)
|
||||
session.add(attempt)
|
||||
session.commit()
|
||||
session.refresh(attempt)
|
||||
return {
|
||||
'attempt': {
|
||||
'id': str(attempt.id),
|
||||
'test_version_id': str(attempt.test_version_id),
|
||||
'user_id': str(attempt.user_id),
|
||||
'attempt_number': attempt.attempt_number,
|
||||
'status': attempt.status,
|
||||
'started_at': attempt.started_at.isoformat() if attempt.started_at else None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ─── play ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_play_content(session_or_eng, user_id: str, test_id: str, attempt_id: str) -> dict:
|
||||
session = get_session()
|
||||
aid = _to_uuid(attempt_id)
|
||||
uid = _to_uuid(user_id)
|
||||
tid = _to_uuid(test_id)
|
||||
|
||||
attempt = (
|
||||
session.query(TestAttempt)
|
||||
.options(
|
||||
selectinload(TestAttempt.test_version).selectinload(TestVersion.test)
|
||||
)
|
||||
.filter(TestAttempt.id == aid)
|
||||
.first()
|
||||
)
|
||||
if not attempt:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if attempt.test_version.test_id != tid:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if attempt.user_id != uid:
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
if attempt.status != 'in_progress':
|
||||
raise HttpError(400, 'Попытка уже завершена.')
|
||||
|
||||
test = attempt.test_version.test
|
||||
qs = load_questions_for_version(session, attempt.test_version_id, include_correct=False)
|
||||
return {
|
||||
'testTitle': test.title,
|
||||
'passingThreshold': test.passing_threshold,
|
||||
'timeLimit': test.time_limit,
|
||||
'hintsEnabled': bool(test.hints_enabled),
|
||||
'resultMode': test.result_mode or 'end',
|
||||
'attemptId': str(attempt.id),
|
||||
'questions': qs,
|
||||
}
|
||||
|
||||
|
||||
# ─── submit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def submit_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str,
|
||||
raw_answers: dict | None) -> dict:
|
||||
answers = raw_answers if isinstance(raw_answers, dict) else {}
|
||||
session = get_session()
|
||||
aid = _to_uuid(attempt_id)
|
||||
uid = _to_uuid(user_id)
|
||||
tid = _to_uuid(test_id)
|
||||
|
||||
attempt = (
|
||||
session.query(TestAttempt)
|
||||
.options(selectinload(TestAttempt.test_version).selectinload(TestVersion.test))
|
||||
.filter(TestAttempt.id == aid)
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not attempt:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if attempt.test_version.test_id != tid:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if attempt.user_id != uid:
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
if attempt.status != 'in_progress':
|
||||
raise HttpError(400, 'Попытка уже завершена.')
|
||||
|
||||
test = attempt.test_version.test
|
||||
questions = (
|
||||
session.query(Question)
|
||||
.options(selectinload(Question.options))
|
||||
.filter(Question.test_version_id == attempt.test_version_id)
|
||||
.all()
|
||||
)
|
||||
if not questions:
|
||||
raise HttpError(400, 'В тесте нет вопросов.')
|
||||
|
||||
by_q: dict[str, dict] = {}
|
||||
for q in questions:
|
||||
qid = str(q.id)
|
||||
by_q[qid] = {'all': {str(o.id) for o in q.options}, 'correct': [str(o.id) for o in q.options if o.is_correct]}
|
||||
|
||||
correct_count = 0
|
||||
for q in questions:
|
||||
qid = str(q.id)
|
||||
selected = answers.get(qid, [])
|
||||
if not isinstance(selected, list):
|
||||
selected = [str(selected)]
|
||||
selected = [str(x) for x in selected]
|
||||
g = by_q[qid]
|
||||
for sid in selected:
|
||||
if sid not in g['all']:
|
||||
raise HttpError(400, 'Некорректный вариант ответа.')
|
||||
if _same_selection(selected, g['correct']):
|
||||
correct_count += 1
|
||||
|
||||
total = len(questions)
|
||||
percent = (correct_count / total) * 100 if total else 0
|
||||
threshold = int(test.passing_threshold or 0)
|
||||
passed = percent + 1e-9 >= threshold
|
||||
|
||||
# удаляем старые ответы и записываем новые
|
||||
session.query(UserAnswer).filter(UserAnswer.attempt_id == aid).delete(synchronize_session='fetch')
|
||||
for q in questions:
|
||||
qid = str(q.id)
|
||||
selected = answers.get(qid, [])
|
||||
if not isinstance(selected, list):
|
||||
selected = [str(selected)]
|
||||
selected_uuids = [_to_uuid(x) for x in selected if _to_uuid(x) is not None]
|
||||
session.add(UserAnswer(
|
||||
attempt_id=aid,
|
||||
question_id=q.id,
|
||||
selected_options=selected_uuids,
|
||||
))
|
||||
|
||||
attempt.status = 'completed'
|
||||
attempt.completed_at = datetime.now(timezone.utc)
|
||||
attempt.correct_count = correct_count
|
||||
attempt.total_questions = total
|
||||
attempt.passed = passed
|
||||
session.commit()
|
||||
|
||||
review = build_review_from_db(session, attempt_id)
|
||||
return {
|
||||
'attemptId': attempt_id,
|
||||
'correctCount': correct_count,
|
||||
'totalQuestions': total,
|
||||
'percent': round(percent, 1),
|
||||
'passed': passed,
|
||||
'passingThreshold': threshold,
|
||||
'review': review,
|
||||
}
|
||||
|
||||
|
||||
# ─── review ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_review_from_db(session: Session, attempt_id: str) -> dict:
|
||||
aid = _to_uuid(attempt_id)
|
||||
attempt = (
|
||||
session.query(TestAttempt)
|
||||
.options(
|
||||
selectinload(TestAttempt.test_version).selectinload(TestVersion.test),
|
||||
selectinload(TestAttempt.user),
|
||||
selectinload(TestAttempt.user_answers),
|
||||
)
|
||||
.filter(TestAttempt.id == aid)
|
||||
.first()
|
||||
)
|
||||
if not attempt:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if attempt.status != 'completed':
|
||||
raise HttpError(400, 'Попытка не завершена.')
|
||||
|
||||
test = attempt.test_version.test
|
||||
questions = load_questions_for_version(
|
||||
session, attempt.test_version_id, include_correct=True, include_hints=True
|
||||
)
|
||||
|
||||
sel_by_q: dict[str, list[str]] = {
|
||||
str(ua.question_id): [str(x) for x in (ua.selected_options or [])]
|
||||
for ua in attempt.user_answers
|
||||
}
|
||||
|
||||
total = int(attempt.total_questions or len(questions))
|
||||
percent = round(((attempt.correct_count or 0) / total) * 100, 1) if total else 0
|
||||
|
||||
q_out = []
|
||||
for q in questions:
|
||||
selected = _sort_uuid_strings(sel_by_q.get(q['id'], []))
|
||||
correct = _sort_uuid_strings([o['id'] for o in q['options'] if o.get('isCorrect')])
|
||||
selected_set = set(selected)
|
||||
q_out.append({
|
||||
'id': q['id'],
|
||||
'text': q['text'],
|
||||
'hasMultipleAnswers': q['hasMultipleAnswers'],
|
||||
'aiHint': q.get('aiHint'),
|
||||
'isUserCorrect': _same_selection(selected, correct),
|
||||
'options': [
|
||||
{
|
||||
'id': o['id'],
|
||||
'text': o['text'],
|
||||
'isCorrect': o.get('isCorrect', False),
|
||||
'selected': o['id'] in selected_set,
|
||||
}
|
||||
for o in q['options']
|
||||
],
|
||||
})
|
||||
|
||||
return {
|
||||
'attemptId': str(attempt.id),
|
||||
'testId': str(test.id),
|
||||
'testTitle': test.title,
|
||||
'passingThreshold': int(test.passing_threshold or 0),
|
||||
'timeLimit': test.time_limit,
|
||||
'resultMode': test.result_mode or 'end',
|
||||
'hintsEnabled': bool(test.hints_enabled),
|
||||
'correctCount': int(attempt.correct_count or 0),
|
||||
'totalQuestions': total,
|
||||
'percent': percent,
|
||||
'passed': bool(attempt.passed),
|
||||
'startedAt': attempt.started_at.isoformat() if attempt.started_at else None,
|
||||
'completedAt': attempt.completed_at.isoformat() if attempt.completed_at else None,
|
||||
'attempterUserId': str(attempt.user_id),
|
||||
'attempterName': attempt.user.full_name,
|
||||
'attempterLogin': attempt.user.login,
|
||||
'questions': q_out,
|
||||
}
|
||||
|
||||
|
||||
def get_attempt_review_for_user(session_or_eng, current_user_id: str, test_id: str,
|
||||
attempt_id: str) -> dict:
|
||||
session = get_session()
|
||||
aid = _to_uuid(attempt_id)
|
||||
tid = _to_uuid(test_id)
|
||||
|
||||
attempt = (
|
||||
session.query(TestAttempt)
|
||||
.options(selectinload(TestAttempt.test_version).selectinload(TestVersion.test))
|
||||
.filter(TestAttempt.id == aid)
|
||||
.first()
|
||||
)
|
||||
if not attempt:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if attempt.test_version.test_id != tid:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
|
||||
is_owner = str(attempt.user_id) == str(current_user_id)
|
||||
is_author = is_test_author(attempt.test_version.test.created_by, current_user_id)
|
||||
if not is_owner and not is_author and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
return build_review_from_db(session, attempt_id)
|
||||
|
||||
|
||||
# ─── hints ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def count_missing_hints(session_or_eng, test_id: str) -> dict:
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
if tid is None:
|
||||
return {'total': 0, 'missing': 0}
|
||||
|
||||
active_version = (
|
||||
session.query(TestVersion)
|
||||
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not active_version:
|
||||
return {'total': 0, 'missing': 0}
|
||||
|
||||
all_qs = session.query(Question).options(selectinload(Question.options)).filter(
|
||||
Question.test_version_id == active_version.id
|
||||
).all()
|
||||
filled_qs = []
|
||||
for q in all_qs:
|
||||
if not (q.text or '').strip():
|
||||
continue
|
||||
if len([o for o in q.options if (o.text or '').strip()]) < 2:
|
||||
continue
|
||||
filled_qs.append(q)
|
||||
total = len(filled_qs)
|
||||
missing = sum(1 for q in filled_qs if not q.ai_hint)
|
||||
return {'total': total, 'missing': missing}
|
||||
|
||||
|
||||
def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str) -> dict:
|
||||
from .ai_editor import generate_question_hint
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
active_version = (
|
||||
session.query(TestVersion)
|
||||
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not active_version:
|
||||
return {'generated': 0, 'failed': 0, 'total': 0}
|
||||
|
||||
missing_qs = (
|
||||
session.query(Question)
|
||||
.options(selectinload(Question.options))
|
||||
.filter(
|
||||
Question.test_version_id == active_version.id,
|
||||
(Question.ai_hint == None) | (Question.ai_hint == ''), # noqa: E711
|
||||
)
|
||||
.order_by(Question.question_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
generated = failed = skipped = 0
|
||||
for q in missing_qs:
|
||||
# Подсказку строим только по заполненному вопросу (есть текст и >=2 непустых варианта).
|
||||
if not (q.text or '').strip():
|
||||
skipped += 1
|
||||
continue
|
||||
valid_opts = [o for o in q.options if (o.text or '').strip()]
|
||||
if len(valid_opts) < 2:
|
||||
skipped += 1
|
||||
continue
|
||||
opt_payload = [{'text': o.text, 'isCorrect': bool(o.is_correct)} for o in q.options]
|
||||
hint = generate_question_hint(question_text=q.text, options=opt_payload)
|
||||
if hint:
|
||||
q.ai_hint = hint
|
||||
generated += 1
|
||||
else:
|
||||
failed += 1
|
||||
session.commit()
|
||||
return {'generated': generated, 'failed': failed, 'skipped': skipped, 'total': len(missing_qs)}
|
||||
|
||||
|
||||
def generate_next_missing_hint_for_test(session_or_eng, author_id: str, test_id: str) -> dict:
|
||||
"""Генерирует одну недостающую подсказку (для отображения прогресса в UI)."""
|
||||
from .ai_editor import generate_question_hint
|
||||
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
active_version = (
|
||||
session.query(TestVersion)
|
||||
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
if not active_version:
|
||||
return {'generated': 0, 'remaining': 0, 'done': True, 'totalMissing': 0}
|
||||
|
||||
before = count_missing_hints(None, test_id).get('missing') or 0
|
||||
|
||||
missing_qs = (
|
||||
session.query(Question)
|
||||
.options(selectinload(Question.options))
|
||||
.filter(
|
||||
Question.test_version_id == active_version.id,
|
||||
(Question.ai_hint == None) | (Question.ai_hint == ''), # noqa: E711
|
||||
)
|
||||
.order_by(Question.question_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
for q in missing_qs:
|
||||
if not (q.text or '').strip():
|
||||
continue
|
||||
valid_opts = [o for o in q.options if (o.text or '').strip()]
|
||||
if len(valid_opts) < 2:
|
||||
continue
|
||||
opt_payload = [{'text': o.text, 'isCorrect': bool(o.is_correct)} for o in q.options]
|
||||
hint = generate_question_hint(question_text=q.text, options=opt_payload)
|
||||
if hint:
|
||||
q.ai_hint = hint
|
||||
session.commit()
|
||||
after = count_missing_hints(None, test_id).get('missing') or 0
|
||||
return {
|
||||
'generated': 1,
|
||||
'remaining': after,
|
||||
'done': after == 0,
|
||||
'totalMissing': before,
|
||||
'failed': 0,
|
||||
}
|
||||
|
||||
after = count_missing_hints(None, test_id).get('missing') or 0
|
||||
return {
|
||||
'generated': 0,
|
||||
'remaining': after,
|
||||
'done': after == 0,
|
||||
'totalMissing': before,
|
||||
'failed': 0,
|
||||
}
|
||||
|
||||
|
||||
def check_question_for_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str,
|
||||
question_id: str, selected_option_ids: list[str]) -> dict:
|
||||
session = get_session()
|
||||
aid = _to_uuid(attempt_id)
|
||||
uid = _to_uuid(user_id)
|
||||
tid = _to_uuid(test_id)
|
||||
qid = _to_uuid(question_id)
|
||||
|
||||
attempt = (
|
||||
session.query(TestAttempt)
|
||||
.options(
|
||||
selectinload(TestAttempt.test_version).selectinload(TestVersion.test)
|
||||
)
|
||||
.filter(TestAttempt.id == aid)
|
||||
.first()
|
||||
)
|
||||
if not attempt:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if attempt.test_version.test_id != tid:
|
||||
raise HttpError(404, 'Попытка не найдена.')
|
||||
if attempt.user_id != uid:
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
if attempt.status != 'in_progress':
|
||||
raise HttpError(400, 'Попытка уже завершена.')
|
||||
|
||||
question = (
|
||||
session.query(Question)
|
||||
.options(selectinload(Question.options))
|
||||
.filter(
|
||||
Question.id == qid,
|
||||
Question.test_version_id == attempt.test_version_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not question:
|
||||
raise HttpError(404, 'Вопрос не найден.')
|
||||
|
||||
correct_ids = [str(o.id) for o in question.options if o.is_correct]
|
||||
is_correct = _same_selection(selected_option_ids, correct_ids)
|
||||
|
||||
selected_set = {str(x) for x in (selected_option_ids or [])}
|
||||
selected_texts = [o.text for o in question.options if str(o.id) in selected_set]
|
||||
correct_texts = [o.text for o in question.options if o.is_correct]
|
||||
|
||||
test = attempt.test_version.test
|
||||
explanation = ''
|
||||
if test.hints_enabled:
|
||||
if question.ai_hint:
|
||||
explanation = question.ai_hint
|
||||
else:
|
||||
try:
|
||||
from .ai_editor import explain_answer
|
||||
explanation = explain_answer(
|
||||
question_text=question.text,
|
||||
options=[{'text': o.text, 'isCorrect': bool(o.is_correct)} for o in question.options],
|
||||
selected_texts=selected_texts,
|
||||
is_correct=is_correct,
|
||||
)
|
||||
except Exception:
|
||||
explanation = ''
|
||||
|
||||
return {
|
||||
'questionId': str(question.id),
|
||||
'isCorrect': is_correct,
|
||||
'correctOptionIds': correct_ids,
|
||||
'correctOptionTexts': correct_texts,
|
||||
'explanation': explanation,
|
||||
}
|
||||
|
||||
|
||||
def list_test_attempts_for_author(session_or_eng, author_id: str, test_id: str) -> list[dict]:
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
rows = (
|
||||
session.query(TestAttempt, TestVersion, User)
|
||||
.join(TestVersion, TestAttempt.test_version_id == TestVersion.id)
|
||||
.join(User, TestAttempt.user_id == User.id)
|
||||
.filter(TestVersion.test_id == tid)
|
||||
.order_by(TestAttempt.started_at.desc().nullslast())
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'id': str(a.id),
|
||||
'user_id': str(a.user_id),
|
||||
'status': a.status,
|
||||
'attempt_number': a.attempt_number,
|
||||
'started_at': a.started_at,
|
||||
'completed_at': a.completed_at,
|
||||
'correct_count': a.correct_count,
|
||||
'total_questions': a.total_questions,
|
||||
'passed': a.passed,
|
||||
'test_version': tv.version,
|
||||
'attempter_name': u.full_name,
|
||||
'attempter_login': u.login,
|
||||
}
|
||||
for a, tv, u in rows
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Утилиты по цепочке теста (попытки/версии)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import TestAttempt, TestVersion
|
||||
|
||||
|
||||
def has_any_attempt_for_test(session: Session, test_id) -> bool:
|
||||
"""Возвращает True, если для теста есть хотя бы одна попытка."""
|
||||
import uuid as _uuid
|
||||
if not isinstance(test_id, _uuid.UUID):
|
||||
try:
|
||||
test_id = _uuid.UUID(str(test_id))
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
return session.query(
|
||||
session.query(TestAttempt)
|
||||
.join(TestVersion, TestAttempt.test_version_id == TestVersion.id)
|
||||
.filter(TestVersion.test_id == test_id)
|
||||
.exists()
|
||||
).scalar()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Создание/правка теста, fork версии при наличии попыток."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_session
|
||||
from ..messages import RU
|
||||
from ..models import AnswerOption, Question, Test, TestVersion
|
||||
from .test_access import is_test_author, is_test_edit_open
|
||||
from .test_chain import has_any_attempt_for_test
|
||||
|
||||
|
||||
class HttpError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def create_test_with_version(author_id: str, *, title: str, description: str | None) -> dict:
|
||||
session = get_session()
|
||||
try:
|
||||
uid = _uuid.UUID(author_id)
|
||||
except (ValueError, AttributeError):
|
||||
raise HttpError(400, 'Некорректный user_id.')
|
||||
|
||||
test = Test(
|
||||
title=title,
|
||||
description=description or None,
|
||||
created_by=uid,
|
||||
is_active=True,
|
||||
is_versioned=True,
|
||||
)
|
||||
session.add(test)
|
||||
session.flush() # получаем test.id
|
||||
|
||||
version = TestVersion(test_id=test.id, version=1, is_active=True, parent_id=None)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return {'testId': str(test.id), 'versionId': str(version.id)}
|
||||
|
||||
|
||||
def _get_active_version(session: Session, test_id: _uuid.UUID) -> TestVersion | None:
|
||||
return (
|
||||
session.query(TestVersion)
|
||||
.filter(TestVersion.test_id == test_id, TestVersion.is_active.is_(True))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _copy_question_tree(session: Session, from_version_id, to_version_id) -> None:
|
||||
questions = (
|
||||
session.query(Question)
|
||||
.filter(Question.test_version_id == from_version_id)
|
||||
.order_by(Question.question_order)
|
||||
.all()
|
||||
)
|
||||
for q in questions:
|
||||
new_q = Question(
|
||||
test_version_id=to_version_id,
|
||||
text=q.text,
|
||||
question_order=q.question_order,
|
||||
has_multiple_answers=q.has_multiple_answers,
|
||||
ai_hint=q.ai_hint,
|
||||
)
|
||||
session.add(new_q)
|
||||
session.flush()
|
||||
for o in sorted(q.options, key=lambda x: x.option_order):
|
||||
session.add(AnswerOption(
|
||||
question_id=new_q.id,
|
||||
text=o.text,
|
||||
is_correct=o.is_correct,
|
||||
option_order=o.option_order,
|
||||
))
|
||||
|
||||
|
||||
def _fork_new_version(session: Session, test_id: _uuid.UUID) -> TestVersion:
|
||||
av = _get_active_version(session, test_id)
|
||||
if not av:
|
||||
raise HttpError(500, RU['internal'] if 'internal' in RU else 'Внутренняя ошибка.')
|
||||
|
||||
max_ver = (
|
||||
session.query(func.coalesce(func.max(TestVersion.version), 0))
|
||||
.filter(TestVersion.test_id == test_id)
|
||||
.scalar() or 0
|
||||
)
|
||||
next_v = int(max_ver) + 1
|
||||
|
||||
# деактивируем все версии
|
||||
session.query(TestVersion).filter(TestVersion.test_id == test_id).update(
|
||||
{TestVersion.is_active: False}, synchronize_session='fetch'
|
||||
)
|
||||
|
||||
new_version = TestVersion(
|
||||
test_id=test_id,
|
||||
version=next_v,
|
||||
is_active=True,
|
||||
parent_id=av.id,
|
||||
)
|
||||
session.add(new_version)
|
||||
session.flush()
|
||||
_copy_question_tree(session, av.id, new_version.id)
|
||||
return new_version
|
||||
|
||||
|
||||
def _replace_version_content(session: Session, version: TestVersion, payload: dict) -> None:
|
||||
# Снимок ai_hint по тексту вопроса перед удалением
|
||||
old_hints: dict[str, str] = {}
|
||||
for q in version.questions:
|
||||
if q.ai_hint and q.text not in old_hints:
|
||||
old_hints[q.text] = q.ai_hint
|
||||
|
||||
# удаляем через cascade (answer_options удалятся каскадно через ORM)
|
||||
for q in list(version.questions):
|
||||
session.delete(q)
|
||||
session.flush()
|
||||
|
||||
questions_payload = payload.get('questions') or []
|
||||
for i, qp in enumerate(questions_payload):
|
||||
q_text = (qp.get('text') or '').strip()
|
||||
hint_in = qp.get('aiHint')
|
||||
if hint_in is not None:
|
||||
hint_val = (str(hint_in).strip() or None)
|
||||
else:
|
||||
hint_val = old_hints.get(q_text)
|
||||
new_q = Question(
|
||||
test_version_id=version.id,
|
||||
text=q_text,
|
||||
question_order=qp.get('question_order') or (i + 1),
|
||||
has_multiple_answers=bool(qp.get('hasMultipleAnswers')),
|
||||
ai_hint=hint_val,
|
||||
)
|
||||
session.add(new_q)
|
||||
session.flush()
|
||||
for j, op in enumerate(qp.get('options') or []):
|
||||
session.add(AnswerOption(
|
||||
question_id=new_q.id,
|
||||
text=(op.get('text') or '').strip(),
|
||||
is_correct=bool(op.get('isCorrect')),
|
||||
option_order=op.get('option_order') or (j + 1),
|
||||
))
|
||||
|
||||
|
||||
def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
session = get_session()
|
||||
try:
|
||||
tid = _uuid.UUID(test_id)
|
||||
except (ValueError, AttributeError):
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
|
||||
test = session.get(Test, tid)
|
||||
if not test:
|
||||
raise HttpError(404, 'Тест не найден.')
|
||||
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||
raise HttpError(403, 'Доступ запрещён.')
|
||||
|
||||
if payload.get('title') is not None:
|
||||
test.title = payload['title']
|
||||
if payload.get('description') is not None:
|
||||
test.description = payload['description'] or None
|
||||
|
||||
if payload.get('passingThreshold') is not None:
|
||||
try:
|
||||
test.passing_threshold = max(0, min(100, round(float(payload['passingThreshold']))))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if 'timeLimit' in payload:
|
||||
tl = payload.get('timeLimit')
|
||||
try:
|
||||
test.time_limit = None if tl in (None, '', 0) else max(0, int(tl))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if 'hintsEnabled' in payload:
|
||||
test.hints_enabled = bool(payload['hintsEnabled'])
|
||||
|
||||
if 'resultMode' in payload:
|
||||
rm = (payload.get('resultMode') or '').strip().lower()
|
||||
if rm in ('immediate', 'end'):
|
||||
test.result_mode = rm
|
||||
|
||||
has_attempts = has_any_attempt_for_test(session, tid)
|
||||
active_version = _get_active_version(session, tid)
|
||||
if not active_version:
|
||||
raise HttpError(500, 'Нет активной версии теста.')
|
||||
|
||||
forked = False
|
||||
if has_attempts and 'questions' in payload and payload.get('questions') is not None:
|
||||
active_version = _fork_new_version(session, tid)
|
||||
forked = True
|
||||
|
||||
if payload.get('questions') is not None:
|
||||
_replace_version_content(session, active_version, payload)
|
||||
|
||||
session.commit()
|
||||
return {'testId': test_id, 'versionId': str(active_version.id), 'forked': forked}
|
||||
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Прохождение теста: один вопрос на экран, прогресс сверху.
|
||||
*/
|
||||
(() => {
|
||||
const root = document.getElementById('attempt-root');
|
||||
if (!root) return;
|
||||
|
||||
const testId = root.dataset.testId;
|
||||
const attemptId = root.dataset.attemptId;
|
||||
|
||||
const flowEl = document.getElementById('attempt-flow');
|
||||
const titleEl = document.getElementById('attempt-title');
|
||||
const subEl = document.getElementById('attempt-subtitle');
|
||||
const timerEl = document.getElementById('attempt-timer');
|
||||
const errEl = document.getElementById('attempt-error');
|
||||
const stageEl = document.getElementById('attempt-stage');
|
||||
const progressLabel = document.getElementById('attempt-progress-label');
|
||||
const progressFill = document.getElementById('attempt-progress-fill');
|
||||
const progressTrack = document.querySelector('.attempt-progress-track');
|
||||
const btnPrev = document.getElementById('attempt-prev');
|
||||
const btnNext = document.getElementById('attempt-next');
|
||||
const btnFinish = document.getElementById('attempt-finish');
|
||||
const resultEl = document.getElementById('attempt-result');
|
||||
|
||||
let playData = null;
|
||||
const selections = {};
|
||||
const checked = {};
|
||||
let timerHandle = null;
|
||||
let deadlineMs = null;
|
||||
let currentIdx = 0;
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (m) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[m]));
|
||||
}
|
||||
|
||||
/** Доля круга 0..1 от верха по часовой; путь для stroke с round caps (viewBox 120). */
|
||||
function attemptResultArcD(cx, cy, r, t0, t1) {
|
||||
if (t1 <= t0 + 1e-9) return '';
|
||||
if (t1 - t0 > 0.5 + 1e-6) {
|
||||
const a = attemptResultArcD(cx, cy, r, t0, t0 + 0.5);
|
||||
const b = attemptResultArcD(cx, cy, r, t0 + 0.5, t1);
|
||||
return `${a} ${b.replace(/^M [\d.]+\s+[\d.]+\s+/, '')}`;
|
||||
}
|
||||
const angle = (t) => (-Math.PI / 2) + 2 * Math.PI * t;
|
||||
const x0 = cx + r * Math.cos(angle(t0));
|
||||
const y0 = cy + r * Math.sin(angle(t0));
|
||||
const x1 = cx + r * Math.cos(angle(t1));
|
||||
const y1 = cy + r * Math.sin(angle(t1));
|
||||
const spanDeg = (t1 - t0) * 360;
|
||||
const largeArc = spanDeg > 180 ? 1 : 0;
|
||||
return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 ${largeArc} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function setErr(msg) {
|
||||
errEl.textContent = msg || 'Ошибка.';
|
||||
errEl.style.display = msg ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function isSelected(qid, oid) {
|
||||
return (selections[String(qid)] || []).includes(String(oid));
|
||||
}
|
||||
|
||||
function toggle(qid, oid, multi) {
|
||||
const k = String(qid);
|
||||
const cur = selections[k] || [];
|
||||
const id = String(oid);
|
||||
if (multi) {
|
||||
selections[k] = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||
return;
|
||||
}
|
||||
selections[k] = [id];
|
||||
}
|
||||
|
||||
function isImmediate() {
|
||||
return playData && playData.resultMode === 'immediate';
|
||||
}
|
||||
|
||||
function formatAttemptMeta(d) {
|
||||
const qs = d.questions || [];
|
||||
const n = qs.length;
|
||||
const tl = d.timeLimit;
|
||||
const timestr = !tl || Number(tl) <= 0 ? 'без ограничения' : `${tl} мин`;
|
||||
const res = d.resultMode === 'immediate' ? 'сразу' : 'в конце';
|
||||
const hint = d.resultMode !== 'immediate'
|
||||
? 'недоступны'
|
||||
: (d.hintsEnabled ? 'вкл' : 'выкл');
|
||||
const th = d.passingThreshold ?? 0;
|
||||
return `Порог: ${th}% · Вопросов: ${n} · Время: ${timestr} · Результат: ${res} · Подсказки: ${hint}`;
|
||||
}
|
||||
|
||||
function renderFeedbackPanel(data) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'attempt-feedback-panel';
|
||||
const ok = data.isCorrect;
|
||||
const verdict = document.createElement('p');
|
||||
verdict.className = `attempt-feedback-verdict ${ok ? 'attempt-feedback-verdict--ok' : 'attempt-feedback-verdict--bad'}`;
|
||||
verdict.textContent = ok ? 'Верно!' : 'Неверно.';
|
||||
wrap.appendChild(verdict);
|
||||
const correct = (data.correctOptionTexts || []).join('; ');
|
||||
if (correct) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'attempt-feedback-correct';
|
||||
p.textContent = `Правильный ответ: ${correct}`;
|
||||
wrap.appendChild(p);
|
||||
}
|
||||
const exp = document.createElement('p');
|
||||
exp.className = 'attempt-feedback-explanation';
|
||||
exp.textContent = data.explanation || 'Объяснение недоступно.';
|
||||
wrap.appendChild(exp);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function stepAnswered(q) {
|
||||
const k = String(q.id);
|
||||
if (isImmediate()) return !!checked[k];
|
||||
return (selections[k] || []).length > 0;
|
||||
}
|
||||
|
||||
function allAnswered() {
|
||||
return (playData.questions || []).every((q) => stepAnswered(q));
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const qs = playData.questions || [];
|
||||
const total = qs.length;
|
||||
const n = Math.min(currentIdx + 1, Math.max(total, 1));
|
||||
if (progressLabel) progressLabel.textContent = `${n} из ${total}`;
|
||||
const pct = total <= 1 ? 100 : ((currentIdx + 1) / total) * 100;
|
||||
if (progressFill) progressFill.style.width = `${pct}%`;
|
||||
if (progressTrack) {
|
||||
progressTrack.setAttribute('aria-valuenow', String(n));
|
||||
progressTrack.setAttribute('aria-valuemax', String(total));
|
||||
}
|
||||
}
|
||||
|
||||
function renderStep() {
|
||||
const qs = playData.questions || [];
|
||||
const total = qs.length;
|
||||
const q = qs[currentIdx];
|
||||
stageEl.innerHTML = '';
|
||||
if (!q) return;
|
||||
|
||||
updateProgress();
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = 'attempt-q-card surface-card';
|
||||
card.dataset.qid = String(q.id);
|
||||
|
||||
const qid = String(q.id);
|
||||
const isChk = !!checked[qid];
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'attempt-q-card__meta';
|
||||
meta.innerHTML = `<span class="attempt-q-num">Вопрос ${currentIdx + 1}</span>`;
|
||||
card.appendChild(meta);
|
||||
|
||||
const textP = document.createElement('div');
|
||||
textP.className = 'attempt-q-text';
|
||||
let badge = '';
|
||||
if (isChk) {
|
||||
const ok = checked[qid].isCorrect;
|
||||
badge = `<span class="attempt-q-badge ${ok ? 'attempt-q-badge--ok' : 'attempt-q-badge--bad'}">${ok ? '✓ верно' : '✗ неверно'}</span>`;
|
||||
}
|
||||
textP.innerHTML = esc(q.text) + badge;
|
||||
card.appendChild(textP);
|
||||
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'attempt-q-options';
|
||||
const correctSet = new Set((checked[qid] && checked[qid].correctOptionIds) || []);
|
||||
|
||||
for (const o of (q.options || [])) {
|
||||
const oid = String(o.id);
|
||||
const row = document.createElement('li');
|
||||
row.className = 'attempt-opt-row';
|
||||
const type = q.hasMultipleAnswers ? 'checkbox' : 'radio';
|
||||
const name = `q-${q.id}`;
|
||||
let mark = '';
|
||||
if (isChk) {
|
||||
if (correctSet.has(oid)) mark = ' <span class="attempt-mark attempt-mark--ok">верно</span>';
|
||||
else if (isSelected(q.id, o.id)) mark = ' <span class="attempt-mark attempt-mark--bad">ваш ответ</span>';
|
||||
}
|
||||
row.innerHTML =
|
||||
`<label class="attempt-opt-label ${isChk ? 'attempt-opt-label--locked' : ''}">`
|
||||
+ `<input type="${type}" ${q.hasMultipleAnswers ? '' : `name="${esc(name)}"`} class="attempt-opt-input" />`
|
||||
+ `<span class="attempt-opt-text">${esc(o.text)}${mark}</span>`
|
||||
+ '</label>';
|
||||
const input = row.querySelector('input');
|
||||
if (isSelected(q.id, o.id)) input.checked = true;
|
||||
if (isChk) input.disabled = true;
|
||||
input.addEventListener('change', () => {
|
||||
if (checked[qid]) return;
|
||||
toggle(q.id, o.id, q.hasMultipleAnswers);
|
||||
renderStep();
|
||||
});
|
||||
ul.appendChild(row);
|
||||
}
|
||||
card.appendChild(ul);
|
||||
|
||||
if (isChk && isImmediate() && playData.hintsEnabled) {
|
||||
card.appendChild(renderFeedbackPanel(checked[qid]));
|
||||
}
|
||||
|
||||
if (isImmediate() && !isChk) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'attempt-answer-actions';
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-primary attempt-reply-btn';
|
||||
btn.textContent = 'Ответить';
|
||||
const sel = selections[qid] || [];
|
||||
btn.disabled = sel.length === 0;
|
||||
btn.addEventListener('click', () => checkOne(q.id));
|
||||
wrap.appendChild(btn);
|
||||
card.appendChild(wrap);
|
||||
}
|
||||
|
||||
stageEl.appendChild(card);
|
||||
updateNav();
|
||||
}
|
||||
|
||||
function updateNav() {
|
||||
const qs = playData.questions || [];
|
||||
const total = qs.length;
|
||||
const q = qs[currentIdx];
|
||||
if (!q) return;
|
||||
const ok = stepAnswered(q);
|
||||
|
||||
btnPrev.disabled = currentIdx <= 0;
|
||||
btnPrev.toggleAttribute('hidden', currentIdx <= 0);
|
||||
|
||||
const last = currentIdx >= total - 1;
|
||||
btnNext.style.display = last ? 'none' : '';
|
||||
btnFinish.style.display = last ? '' : 'none';
|
||||
|
||||
if (last) {
|
||||
btnFinish.disabled = !allAnswered();
|
||||
} else {
|
||||
btnNext.disabled = !ok;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOne(qid) {
|
||||
const k = String(qid);
|
||||
const sel = selections[k] || [];
|
||||
if (!sel.length) return;
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ questionId: qid, selectedOptionIds: sel }),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось проверить ответ.');
|
||||
checked[k] = data;
|
||||
renderStep();
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
btnPrev.addEventListener('click', () => {
|
||||
if (currentIdx <= 0) return;
|
||||
currentIdx -= 1;
|
||||
renderStep();
|
||||
});
|
||||
|
||||
btnNext.addEventListener('click', () => {
|
||||
const total = (playData.questions || []).length;
|
||||
if (currentIdx >= total - 1) return;
|
||||
currentIdx += 1;
|
||||
renderStep();
|
||||
});
|
||||
|
||||
btnFinish.addEventListener('click', () => submit(false));
|
||||
|
||||
function startTimer(minutes) {
|
||||
if (!timerEl) return;
|
||||
timerEl.style.display = '';
|
||||
if (!minutes || minutes <= 0) {
|
||||
deadlineMs = null;
|
||||
timerEl.textContent = 'без ограничения';
|
||||
return;
|
||||
}
|
||||
deadlineMs = Date.now() + minutes * 60 * 1000;
|
||||
const tick = () => {
|
||||
const left = Math.max(0, deadlineMs - Date.now());
|
||||
const m = Math.floor(left / 60000);
|
||||
const s = Math.floor((left % 60000) / 1000);
|
||||
timerEl.textContent = `Осталось ${m}:${String(s).padStart(2, '0')}`;
|
||||
if (left <= 0) {
|
||||
clearInterval(timerHandle);
|
||||
submit(true);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
timerHandle = setInterval(tick, 500);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/play`);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось открыть попытку.');
|
||||
playData = data;
|
||||
titleEl.textContent = data.testTitle || 'Прохождение теста';
|
||||
subEl.textContent = formatAttemptMeta(data);
|
||||
if (!Array.isArray(data.questions) || !data.questions.length) {
|
||||
setErr('В активной версии нет вопросов.');
|
||||
btnNext.disabled = true;
|
||||
btnFinish.disabled = true;
|
||||
return;
|
||||
}
|
||||
currentIdx = 0;
|
||||
renderStep();
|
||||
startTimer(Number(data.timeLimit));
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
btnFinish.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(auto) {
|
||||
btnFinish.disabled = true;
|
||||
btnNext.disabled = true;
|
||||
btnPrev.disabled = true;
|
||||
const label = auto ? 'Время вышло…' : 'Отправка…';
|
||||
btnFinish.textContent = label;
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${testId}/attempts/${attemptId}/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers: selections }),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось завершить попытку.');
|
||||
if (timerHandle) clearInterval(timerHandle);
|
||||
timerEl.style.display = 'none';
|
||||
if (flowEl) flowEl.style.display = 'none';
|
||||
resultEl.style.display = '';
|
||||
const pct = Math.min(100, Math.max(0, Number(data.percent) || 0));
|
||||
const thr = Math.min(100, Math.max(0, Number(data.passingThreshold) || 0));
|
||||
const passed = !!data.passed;
|
||||
const thrArc = passed ? pct : Math.min(100, Math.max(pct, thr));
|
||||
const centerIcon = passed ? 'check' : 'close';
|
||||
const verdictLabel = passed ? 'Зачёт' : 'Незачёт';
|
||||
const ariaRing = passed
|
||||
? `Зачёт: ${pct} процентов верных ответов`
|
||||
: `Незачёт: ${pct} процентов, порог ${thr} процентов`;
|
||||
const CX = 60;
|
||||
const CY = 60;
|
||||
const RAD = 48;
|
||||
const tScore = pct / 100;
|
||||
const tThr = thrArc / 100;
|
||||
const trackCircle = `<circle class="attempt-result-svg__track" cx="${CX}" cy="${CY}" r="${RAD}" fill="none" />`;
|
||||
let arcPaths = '';
|
||||
if (passed) {
|
||||
const dPass = attemptResultArcD(CX, CY, RAD, 0, tScore);
|
||||
if (dPass) {
|
||||
arcPaths += `<path class="attempt-result-svg__pass" d="${esc(dPass)}" fill="none" />`;
|
||||
}
|
||||
} else {
|
||||
const dGap = tThr > tScore + 1e-9 ? attemptResultArcD(CX, CY, RAD, tScore, tThr) : '';
|
||||
const dScore = tScore > 1e-9 ? attemptResultArcD(CX, CY, RAD, 0, tScore) : '';
|
||||
if (dGap) arcPaths += `<path class="attempt-result-svg__fail-gap" d="${esc(dGap)}" fill="none" />`;
|
||||
if (dScore) arcPaths += `<path class="attempt-result-svg__fail-score" d="${esc(dScore)}" fill="none" />`;
|
||||
}
|
||||
const donutSvg = `<svg class="attempt-result-donut__svg" viewBox="0 0 120 120" aria-hidden="true">${trackCircle}${arcPaths}</svg>`;
|
||||
resultEl.innerHTML = `
|
||||
<div class="attempt-result-card__inner">
|
||||
<h3 class="attempt-result-title font-headline">Результат</h3>
|
||||
<div class="attempt-result-visual" aria-label="${ariaRing.replace(/"/g, '"')}">
|
||||
<div class="attempt-result-donut">
|
||||
${donutSvg}
|
||||
<div class="attempt-result-donut__disk" aria-hidden="true"></div>
|
||||
<div class="attempt-result-donut__center" data-passed="${passed ? '1' : '0'}">
|
||||
<span class="material-symbols-outlined attempt-result-donut__icon" aria-hidden="true">${centerIcon}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="attempt-result-verdict" data-passed="${passed ? '1' : '0'}">
|
||||
<span class="attempt-result-verdict__label">${verdictLabel}</span>
|
||||
</p>
|
||||
</div>
|
||||
<ul class="attempt-result-stats">
|
||||
<li><span class="attempt-result-stats__k">Верно</span> <strong>${data.correctCount}</strong> из ${data.totalQuestions} (${pct}%)</li>
|
||||
<li><span class="attempt-result-stats__k">Порог зачёта</span> <strong>${thr}%</strong></li>
|
||||
</ul>
|
||||
<p class="attempt-result-actions">
|
||||
<a class="btn btn-primary attempt-result-review-link"
|
||||
href="/tests/${testId}/attempts/${data.attemptId}/review">Разбор попытки</a>
|
||||
</p>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
btnFinish.disabled = false;
|
||||
btnFinish.textContent = 'Завершить тест';
|
||||
btnPrev.disabled = false;
|
||||
btnNext.disabled = false;
|
||||
if (playData) updateNav();
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}404 — страница не найдена{% endblock %}
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
|
||||
<span class="material-symbols-outlined text-5xl text-brand-600">search_off</span>
|
||||
<h1 class="mt-2 text-xl font-semibold">Страница не найдена</h1>
|
||||
<p class="mt-1 text-ink-500">Проверьте адрес или вернитесь на <a class="text-brand-600 hover:underline" href="{{ url_for('main.index') }}">главную</a>.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}500 — внутренняя ошибка{% endblock %}
|
||||
{% block content %}
|
||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center">
|
||||
<span class="material-symbols-outlined text-5xl text-red-600">error</span>
|
||||
<h1 class="mt-2 text-xl font-semibold">Что-то пошло не так</h1>
|
||||
<p class="mt-1 text-ink-500">Попробуйте обновить страницу. Если ошибка повторяется — посмотрите логи сервера.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,169 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Назначения — Тестирование персонала{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-4 sm:space-y-5 pb-10">
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-semibold text-ink-900">Назначения</h1>
|
||||
<a href="{{ url_for('main.index') }}" class="link-back text-sm">← Главная</a>
|
||||
</div>
|
||||
|
||||
{# Выбор теста #}
|
||||
<div class="cabinet-brick">
|
||||
<h2 class="font-semibold text-sm text-ink-700 mb-3">Тест</h2>
|
||||
<select id="assign-test-select" class="form-input">
|
||||
<option value="">— Выберите тест —</option>
|
||||
{% for t in tests %}
|
||||
<option value="{{ t.id }}">{{ t.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Панель назначения (скрыта пока не выбран тест) #}
|
||||
<div id="assign-panel" class="cabinet-brick hidden">
|
||||
<h2 class="font-semibold text-sm text-ink-700 mb-3">Кому выдать</h2>
|
||||
<p class="test-detail-hint">Список с учётом поиска и фильтров; можно отметить всех на экране.</p>
|
||||
<div class="assign-toolbar mt-3">
|
||||
<input id="assign-search" class="form-input assign-toolbar__search" type="text" placeholder="Поиск: ФИО, логин" />
|
||||
<select id="assign-dept" class="form-input"><option value="__all__">Все отделы</option></select>
|
||||
<select id="assign-clinic" class="form-input">
|
||||
<option value="all">Все сотрудники</option>
|
||||
<option value="with">Уже есть в системе</option>
|
||||
<option value="without">Ещё не в системе</option>
|
||||
</select>
|
||||
<button id="assign-select-all" class="btn btn-ghost btn--sm" type="button">Выбрать всех</button>
|
||||
</div>
|
||||
<div id="assign-list" class="assign-list mt-3"></div>
|
||||
<div class="inline-actions mt-3">
|
||||
<button id="assign-submit" class="btn btn-primary" type="button">Назначить выбранным</button>
|
||||
<span id="assign-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const testSelect = $('#assign-test-select');
|
||||
const assignPanel = $('#assign-panel');
|
||||
const assignSearchEl = $('#assign-search');
|
||||
const assignDeptEl = $('#assign-dept');
|
||||
const assignClinicEl = $('#assign-clinic');
|
||||
const assignListEl = $('#assign-list');
|
||||
const assignSelectAllBtn = $('#assign-select-all');
|
||||
const assignSubmitBtn = $('#assign-submit');
|
||||
const assignStatusEl = $('#assign-status');
|
||||
|
||||
let currentTestId = null;
|
||||
let assignPeople = [];
|
||||
let assignSelected = new Set();
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function renderAssignList() {
|
||||
assignListEl.innerHTML = '';
|
||||
assignPeople.forEach((p) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`;
|
||||
row.innerHTML = `
|
||||
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} />
|
||||
<span class="assign-row__text">
|
||||
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
|
||||
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
|
||||
<span class="assign-row__meta">${escHtml(p.department || '—')}</span>
|
||||
</span>`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.addEventListener('change', () => {
|
||||
const k = String(p.staffId);
|
||||
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k);
|
||||
row.classList.toggle('assign-row--selected', cb.checked);
|
||||
});
|
||||
assignListEl.appendChild(row);
|
||||
});
|
||||
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>';
|
||||
}
|
||||
|
||||
async function loadDirectory() {
|
||||
if (!currentTestId) return;
|
||||
assignStatusEl.textContent = 'Загружаем…';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim());
|
||||
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value);
|
||||
params.set('clinic', assignClinicEl.value || 'all');
|
||||
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников');
|
||||
assignPeople = data.people || [];
|
||||
const depts = data.departments || [];
|
||||
if (assignDeptEl.options.length <= 1) {
|
||||
depts.forEach((d) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = d; o.textContent = d;
|
||||
assignDeptEl.appendChild(o);
|
||||
});
|
||||
}
|
||||
assignSelected = new Set();
|
||||
renderAssignList();
|
||||
assignStatusEl.textContent = '';
|
||||
} catch (e) {
|
||||
assignStatusEl.textContent = e.message || 'Ошибка загрузки';
|
||||
}
|
||||
}
|
||||
|
||||
testSelect.addEventListener('change', () => {
|
||||
currentTestId = testSelect.value || null;
|
||||
if (currentTestId) {
|
||||
assignPanel.classList.remove('hidden');
|
||||
// Сброс фильтров
|
||||
assignDeptEl.innerHTML = '<option value="__all__">Все отделы</option>';
|
||||
assignSearchEl.value = '';
|
||||
loadDirectory();
|
||||
} else {
|
||||
assignPanel.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
let t = null;
|
||||
assignSearchEl.addEventListener('input', () => { clearTimeout(t); t = setTimeout(loadDirectory, 350); });
|
||||
assignDeptEl.addEventListener('change', loadDirectory);
|
||||
assignClinicEl.addEventListener('change', loadDirectory);
|
||||
|
||||
assignSelectAllBtn.addEventListener('click', () => {
|
||||
assignPeople.forEach((p) => assignSelected.add(String(p.staffId)));
|
||||
renderAssignList();
|
||||
});
|
||||
|
||||
assignSubmitBtn.addEventListener('click', async () => {
|
||||
if (!currentTestId) return;
|
||||
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId)));
|
||||
if (!selectedRows.length) { assignStatusEl.textContent = 'Никто не выбран'; return; }
|
||||
assignStatusEl.textContent = 'Назначаем…';
|
||||
try {
|
||||
const payload = selectedRows.map((p) => ({
|
||||
staffId: p.staffId,
|
||||
webLogin: p.webLogin || null,
|
||||
fio: p.fio || null,
|
||||
department: p.department || null,
|
||||
}));
|
||||
const r = await fetch(`/api/tests/${currentTestId}/assign`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ targets: payload }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Ошибка назначения');
|
||||
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`;
|
||||
} catch (e) {
|
||||
assignStatusEl.textContent = e.message || 'Ошибка назначения';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Вход — Тестирование{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set fio_as_login = dev_fio_enabled or hr_auth_enabled %}
|
||||
<div class="login-page">
|
||||
<div class="login-shell">
|
||||
<div class="login-logo">
|
||||
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
||||
alt="Логотип клиники" class="login-logo__img" />
|
||||
<h1 class="font-headline">Тестирование</h1>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="callout callout--error" style="margin-bottom: 1rem;">
|
||||
{% for category, msg in messages %}
|
||||
{% if category == 'error' %}{{ msg }}{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="login-card">
|
||||
{% if dev_fio_enabled %}
|
||||
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
||||
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
||||
</p>
|
||||
{% elif hr_auth_enabled %}
|
||||
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
||||
Можно ввести <b>логин</b> из HR или <b>ФИО</b> (как в кадровой системе), если совпадение одно, и пароль учётной записи HR.
|
||||
</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
|
||||
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="login-username">
|
||||
{% if fio_as_login %}ФИО или логин{% else %}Логин{% endif %}
|
||||
</label>
|
||||
<input id="login-username" class="form-input" type="text" name="login"
|
||||
value="{{ login or '' }}" required autofocus autocomplete="username"
|
||||
placeholder="{% if fio_as_login %}Иванов Иван Иванович{% endif %}" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="login-password">Пароль</label>
|
||||
<input id="login-password" class="form-input" type="password" name="password"
|
||||
required autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{% block title %}Тестирование персонала{% endblock %}</title>
|
||||
|
||||
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #}
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
// Палитра/типографика в стиле webapp-nginx (cabinet-theme).
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Manrope', 'Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#ecf7f6',
|
||||
100: '#d9efec',
|
||||
300: '#9bd7d0',
|
||||
500: '#007168',
|
||||
600: '#00645b',
|
||||
700: '#00574f',
|
||||
},
|
||||
ink: {
|
||||
900: '#0d1b1d',
|
||||
700: '#3d5357',
|
||||
500: '#506965',
|
||||
300: '#b9bc94',
|
||||
100: '#f3f8f9',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased ui-legacy">
|
||||
<div class="cabinet-app">
|
||||
<header class="cabinet-header">
|
||||
<div class="cabinet-header__inner">
|
||||
<a href="{{ url_for('tests.tests_list_page') }}" class="cabinet-brand">
|
||||
<img src="{{ url_for('static', filename='img/clinic-logo.png') }}"
|
||||
alt="Логотип клиники" class="cabinet-brand__logo" />
|
||||
<div>
|
||||
<div class="cabinet-brand__title">Тестирование</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="cabinet-header__actions">
|
||||
{% if current_user %}
|
||||
<span class="cabinet-user" title="{{ (current_user.full_name or current_user.login) ~ (' · ' ~ format_role(current_user.role) if format_role(current_user.role) else '') }}">
|
||||
{{ format_name_short(current_user.full_name, current_user.login) }}
|
||||
{% if format_role(current_user.role) %}<span class="cabinet-user__role"> · {{ format_role(current_user.role) }}</span>{% endif %}
|
||||
</span>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
|
||||
<button type="submit" class="btn btn-ghost">Выйти</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login_page') }}" class="btn btn-ghost">Войти</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="cabinet-main">
|
||||
{% block content scoped %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Тестирование</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Главная — Тестирование персонала{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-semibold text-ink-900 mb-5">Тестирование персонала</h1>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
{# Статистика #}
|
||||
<a href="{{ url_for('main.stats_page') }}"
|
||||
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
|
||||
hover:shadow-md hover:border-brand-300 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">bar_chart</span>
|
||||
<h2 class="font-semibold text-ink-900">Статистика</h2>
|
||||
</div>
|
||||
<p class="text-sm text-ink-500 flex-1">Прохождения по отделам, общая динамика и последняя активность.</p>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
|
||||
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{# Тесты #}
|
||||
<a href="{{ url_for('tests.tests_list_page') }}"
|
||||
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
|
||||
hover:shadow-md hover:border-brand-300 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">list_alt</span>
|
||||
<h2 class="font-semibold text-ink-900">Тесты</h2>
|
||||
</div>
|
||||
<p class="text-sm text-ink-500 flex-1">Каталог тестов, создание, редактирование и прохождение.</p>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
|
||||
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{# Назначения #}
|
||||
<a href="{{ url_for('main.assignments_page') }}"
|
||||
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
|
||||
hover:shadow-md hover:border-brand-300 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">assignment_ind</span>
|
||||
<h2 class="font-semibold text-ink-900">Назначения</h2>
|
||||
</div>
|
||||
<p class="text-sm text-ink-500 flex-1">Выдача тестов сотрудникам и отделам.</p>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
|
||||
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{# Настройки ИИ #}
|
||||
<a href="{{ url_for('settings.prompts_page') }}"
|
||||
class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-5 flex flex-col gap-3
|
||||
hover:shadow-md hover:border-brand-300 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span>
|
||||
<h2 class="font-semibold text-ink-900">Настройки ИИ</h2>
|
||||
</div>
|
||||
<p class="text-sm text-ink-500 flex-1">Редактор промптов — генерация вопросов, дистракторы, улучшение теста.</p>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 group-hover:underline">
|
||||
Открыть <span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Настройки — LLM{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="surface-card p-6 max-w-2xl">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-600">settings</span>
|
||||
<h1 class="text-2xl font-semibold">Настройки</h1>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-5 font-semibold font-headline">Подключение к LLM</h2>
|
||||
<p class="mt-1 text-sm text-ink-500">
|
||||
Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера
|
||||
(общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API.
|
||||
После изменения <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> нужен рестарт процесса.
|
||||
</p>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<dt class="text-ink-500">Статус ключа</dt>
|
||||
<dd>
|
||||
{% if configured %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-50 text-green-700 border border-green-200">
|
||||
<span class="material-symbols-outlined text-base">check_circle</span> Задан
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-50 text-red-700 border border-red-200">
|
||||
<span class="material-symbols-outlined text-base">error</span> Не задан
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="text-ink-500">Провайдер</dt>
|
||||
<dd>{{ provider or '—' }}</dd>
|
||||
<dt class="text-ink-500">Модель</dt>
|
||||
<dd>{{ model or '—' }}</dd>
|
||||
<dt class="text-ink-500">Base URL</dt>
|
||||
<dd class="break-all">{{ base_url or '—' }}</dd>
|
||||
</dl>
|
||||
|
||||
{% if not configured %}
|
||||
<div class="mt-5 rounded-lg bg-ink-100/60 border border-ink-300/60 p-4 text-sm">
|
||||
<p class="font-medium">Как задать ключ</p>
|
||||
<pre class="mt-2 text-xs whitespace-pre-wrap font-mono">DEEPSEEK_API_KEY=sk-...
|
||||
# либо
|
||||
OPENAI_API_KEY=sk-...
|
||||
# опционально:
|
||||
# LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
# LLM_MODEL=deepseek-chat</pre>
|
||||
<p class="mt-2 text-ink-500">
|
||||
Файл: <code>flask_app/.env</code>. После сохранения — рестарт процесса.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<button id="btn-ping"
|
||||
class="btn btn-primary">
|
||||
<span class="material-symbols-outlined text-base">cable</span>
|
||||
Проверить подключение
|
||||
</button>
|
||||
<span id="ping-status" class="text-sm text-ink-500"></span>
|
||||
</div>
|
||||
|
||||
<div id="ping-result" class="mt-4 hidden text-sm rounded-lg p-3 border"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const btn = document.getElementById('btn-ping');
|
||||
const status = document.getElementById('ping-status');
|
||||
const result = document.getElementById('ping-result');
|
||||
btn.addEventListener('click', async () => {
|
||||
status.textContent = 'Запрос…';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/llm/ping', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
result.classList.remove('hidden', 'bg-green-50', 'border-green-200', 'text-green-800',
|
||||
'bg-red-50', 'border-red-200', 'text-red-800');
|
||||
if (d.ok) {
|
||||
result.classList.add('bg-green-50', 'border-green-200', 'text-green-800');
|
||||
result.innerHTML = `<b>OK</b> · ${d.provider} / ${d.model} · ${d.latencyMs} мс`
|
||||
+ (d.sample ? `<br><span class="text-xs opacity-80">Ответ: ${d.sample.replace(/</g,'<')}</span>` : '');
|
||||
} else {
|
||||
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
|
||||
result.innerHTML = `<b>Ошибка</b> · ${d.code || ''}<br>${(d.error || '').replace(/</g,'<')}`;
|
||||
}
|
||||
} catch (e) {
|
||||
result.classList.remove('hidden');
|
||||
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800');
|
||||
result.textContent = e.message || 'Сбой запроса.';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
status.textContent = '';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,433 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Настройки ИИ — промпты{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.pe-wrap {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.625rem;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pe-wrap:focus-within {
|
||||
border-color: #00645b;
|
||||
box-shadow: 0 0 0 3px rgba(0,100,91,0.12);
|
||||
}
|
||||
.pe-field {
|
||||
min-height: 72px;
|
||||
padding: 10px 12px;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
}
|
||||
.pe-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 9px 1px 7px;
|
||||
height: 20px;
|
||||
border-radius: 99px;
|
||||
background: #d9efec;
|
||||
color: #00574f;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #9bd7d0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, opacity 0.12s;
|
||||
}
|
||||
.pe-chip:hover { background: #bfe8e3; }
|
||||
.pe-chip.is-dragging { opacity: 0.3; cursor: grabbing; }
|
||||
.pe-caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.1em;
|
||||
background: #00645b;
|
||||
border-radius: 1px;
|
||||
vertical-align: middle;
|
||||
pointer-events: none;
|
||||
animation: pe-blink 0.7s steps(1) infinite;
|
||||
}
|
||||
@keyframes pe-blink { 50% { opacity: 0; } }
|
||||
.pc {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pc-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 13px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.pc.open .pc-head { border-bottom-color: #e5e7eb; }
|
||||
.pc-chevron { font-size: 18px; color: #6b7280; transition: transform 0.2s; }
|
||||
.pc.open .pc-chevron { transform: rotate(90deg); }
|
||||
.pc-body { display: none; padding: 16px; gap: 14px; flex-direction: column; }
|
||||
.pc.open .pc-body { display: flex; }
|
||||
.pe-label {
|
||||
display: block;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .06em;
|
||||
text-transform: uppercase; color: #9ca3af;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.pe-palette {
|
||||
display: flex; flex-wrap: wrap; gap: 5px;
|
||||
padding: 7px 10px;
|
||||
background: #f3f8f9;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-radius: 0 0 0.625rem 0.625rem;
|
||||
}
|
||||
.pe-palette-chip {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 99px;
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
border: 1px dashed #6ee7b7;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.pe-palette-chip:hover { background: #d1fae5; }
|
||||
.pc-badge { font-size: 11px; padding: 2px 8px; border-radius: 99px; }
|
||||
.pc-badge--ok { background: #dcfce7; color: #166534; }
|
||||
.pc-badge--err { background: #fee2e2; color: #991b1b; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Inject prompt data safely as JSON into JS scope #}
|
||||
<script>
|
||||
const PROMPTS_DATA = {{ prompts | tojson }};
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between mb-5 gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span>
|
||||
<h1 class="text-2xl font-semibold text-ink-900">Настройки ИИ</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('main.index') }}"
|
||||
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition">
|
||||
<span class="material-symbols-outlined text-base">arrow_back</span>
|
||||
Главная
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-ink-500 mb-5">
|
||||
Переменные отображаются как
|
||||
<span class="pe-chip" style="cursor:default; pointer-events:none;">Название теста</span>
|
||||
— перетащите их в нужное место или нажмите «+ переменная» внизу редактора для вставки.
|
||||
</p>
|
||||
|
||||
<div id="pc-list" class="flex flex-col gap-3">
|
||||
{% for pid, p in prompts.items() %}
|
||||
<div class="pc" data-pid="{{ pid }}">
|
||||
<div class="pc-head" onclick="this.closest('.pc').classList.toggle('open')">
|
||||
<span class="material-symbols-outlined pc-chevron">chevron_right</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-ink-900">{{ p.label }}</div>
|
||||
{% if p.description %}
|
||||
<div class="text-xs text-ink-400 mt-0.5">{{ p.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="pc-save-status pc-badge"></span>
|
||||
</div>
|
||||
|
||||
<div class="pc-body">
|
||||
{# System #}
|
||||
<div>
|
||||
<span class="pe-label">System</span>
|
||||
<div class="pe-wrap">
|
||||
<div class="pe-field" contenteditable="true" spellcheck="false"
|
||||
data-pid="{{ pid }}" data-field="system"></div>
|
||||
<div class="pe-palette" data-pid="{{ pid }}" data-field="system"></div>
|
||||
</div>
|
||||
</div>
|
||||
{# User #}
|
||||
<div>
|
||||
<span class="pe-label">User</span>
|
||||
<div class="pe-wrap">
|
||||
<div class="pe-field" contenteditable="true" spellcheck="false"
|
||||
data-pid="{{ pid }}" data-field="user"></div>
|
||||
<div class="pe-palette" data-pid="{{ pid }}" data-field="user"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition"
|
||||
onclick="savePrompt(this.closest('.pc'))">
|
||||
<span class="material-symbols-outlined text-base">save</span>Сохранить
|
||||
</button>
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-100 text-ink-700 text-sm transition"
|
||||
onclick="resetPrompt(this.closest('.pc'))">
|
||||
<span class="material-symbols-outlined text-base">restart_alt</span>Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
// ── DnD state ─────────────────────────────────────────────────────────
|
||||
let _drag = null;
|
||||
let _caret = null;
|
||||
|
||||
// ── Chip ──────────────────────────────────────────────────────────────
|
||||
function makeChip(varName, label) {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'pe-chip';
|
||||
s.setAttribute('contenteditable', 'false');
|
||||
s.setAttribute('draggable', 'true');
|
||||
s.dataset.var = varName;
|
||||
s.textContent = label;
|
||||
bindChipDnD(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function bindChipDnD(chip) {
|
||||
chip.addEventListener('dragstart', e => {
|
||||
_drag = chip;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
requestAnimationFrame(() => chip.classList.add('is-dragging'));
|
||||
});
|
||||
chip.addEventListener('dragend', () => {
|
||||
chip.classList.remove('is-dragging');
|
||||
removeCaret();
|
||||
_drag = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render / serialize ────────────────────────────────────────────────
|
||||
function renderText(el, text, vars) {
|
||||
el.innerHTML = '';
|
||||
const parts = text.split(/(\{[a-zA-Z_]+\})/g);
|
||||
parts.forEach(part => {
|
||||
const m = part.match(/^\{([a-zA-Z_]+)\}$/);
|
||||
if (m && vars[m[1]] !== undefined) {
|
||||
el.appendChild(makeChip(m[1], vars[m[1]]));
|
||||
} else {
|
||||
el.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function serialize(el) {
|
||||
let out = '';
|
||||
el.childNodes.forEach(n => {
|
||||
if (n.nodeType === Node.TEXT_NODE) out += n.textContent;
|
||||
else if (n.dataset && n.dataset.var) out += '{' + n.dataset.var + '}';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Editor DnD ────────────────────────────────────────────────────────
|
||||
function removeCaret() {
|
||||
if (_caret && _caret.parentNode) _caret.parentNode.removeChild(_caret);
|
||||
_caret = null;
|
||||
}
|
||||
|
||||
function caretAt(x, y) {
|
||||
if (document.caretRangeFromPoint) {
|
||||
const r = document.caretRangeFromPoint(x, y);
|
||||
if (r) return [r.startContainer, r.startOffset];
|
||||
}
|
||||
if (document.caretPositionFromPoint) {
|
||||
const p = document.caretPositionFromPoint(x, y);
|
||||
if (p) return [p.offsetNode, p.offset];
|
||||
}
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
function bindEditorDrop(el) {
|
||||
el.addEventListener('dragover', e => {
|
||||
if (!_drag) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
removeCaret();
|
||||
const [node, off] = caretAt(e.clientX, e.clientY);
|
||||
if (!node) return;
|
||||
const c = document.createElement('span');
|
||||
c.className = 'pe-caret';
|
||||
_caret = c;
|
||||
try {
|
||||
const r = document.createRange();
|
||||
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length));
|
||||
r.collapse(true);
|
||||
r.insertNode(c);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
el.addEventListener('dragleave', e => {
|
||||
if (!el.contains(e.relatedTarget)) removeCaret();
|
||||
});
|
||||
|
||||
el.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
removeCaret();
|
||||
if (!_drag) return;
|
||||
const [node, off] = caretAt(e.clientX, e.clientY);
|
||||
if (_drag.parentNode) _drag.parentNode.removeChild(_drag);
|
||||
if (node && el.contains(node)) {
|
||||
try {
|
||||
const r = document.createRange();
|
||||
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length));
|
||||
r.collapse(true);
|
||||
r.insertNode(_drag);
|
||||
} catch { el.appendChild(_drag); }
|
||||
} else {
|
||||
el.appendChild(_drag);
|
||||
}
|
||||
bindChipDnD(_drag);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Block deletion of chips ───────────────────────────────────────────
|
||||
function bindEditorKeys(el) {
|
||||
el.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!range.collapsed && range.cloneContents().querySelector('.pe-chip')) {
|
||||
e.preventDefault(); return;
|
||||
}
|
||||
if (range.collapsed) {
|
||||
const siblings = (container, offset, dir) => {
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
if (dir === 'prev' && offset > 0) return null;
|
||||
if (dir === 'next' && offset < container.length) return null;
|
||||
const arr = Array.from(container.parentNode.childNodes);
|
||||
const i = arr.indexOf(container);
|
||||
return dir === 'prev' ? arr[i - 1] : arr[i + 1];
|
||||
}
|
||||
return dir === 'prev' ? container.childNodes[offset - 1] : container.childNodes[offset];
|
||||
};
|
||||
const check = e.key === 'Backspace'
|
||||
? siblings(range.startContainer, range.startOffset, 'prev')
|
||||
: siblings(range.startContainer, range.startOffset, 'next');
|
||||
if (check && check.classList && check.classList.contains('pe-chip')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init from PROMPTS_DATA ────────────────────────────────────────────
|
||||
function initEditor(el) {
|
||||
const pid = el.dataset.pid;
|
||||
const field = el.dataset.field;
|
||||
const prompt = PROMPTS_DATA[pid];
|
||||
if (!prompt) return;
|
||||
const text = prompt[field] || '';
|
||||
const vars = prompt.vars || {};
|
||||
renderText(el, text, vars);
|
||||
bindEditorDrop(el);
|
||||
bindEditorKeys(el);
|
||||
}
|
||||
|
||||
function initPalette(palette) {
|
||||
const pid = palette.dataset.pid;
|
||||
const field = palette.dataset.field;
|
||||
const vars = (PROMPTS_DATA[pid] || {}).vars || {};
|
||||
palette.innerHTML = '';
|
||||
Object.entries(vars).forEach(([varName, label]) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'pe-palette-chip';
|
||||
chip.textContent = '+ ' + label;
|
||||
chip.title = 'вставить переменную «' + label + '»';
|
||||
chip.addEventListener('click', () => insertVar(pid, field, varName, label));
|
||||
palette.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function insertVar(pid, field, varName, label) {
|
||||
const el = document.querySelector(`.pe-field[data-pid="${pid}"][data-field="${field}"]`);
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const sel = window.getSelection();
|
||||
let range;
|
||||
if (sel && sel.rangeCount && el.contains(sel.getRangeAt(0).commonAncestorContainer)) {
|
||||
range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
} else {
|
||||
range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
}
|
||||
const chip = makeChip(varName, label);
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(chip);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.pe-field').forEach(initEditor);
|
||||
document.querySelectorAll('.pe-palette').forEach(initPalette);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────
|
||||
window.savePrompt = async function(card) {
|
||||
const pid = card.dataset.pid;
|
||||
const sysEl = card.querySelector('.pe-field[data-field="system"]');
|
||||
const usrEl = card.querySelector('.pe-field[data-field="user"]');
|
||||
const badge = card.querySelector('.pc-save-status');
|
||||
badge.textContent = '';
|
||||
badge.className = 'pc-save-status pc-badge';
|
||||
try {
|
||||
const r = await fetch('/api/ai/prompts/' + pid, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system: serialize(sysEl), user: serialize(usrEl) }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || 'Ошибка');
|
||||
// Update in-memory data
|
||||
PROMPTS_DATA[pid].system = serialize(sysEl);
|
||||
PROMPTS_DATA[pid].user = serialize(usrEl);
|
||||
badge.textContent = '✓ Сохранено';
|
||||
badge.className = 'pc-save-status pc-badge pc-badge--ok';
|
||||
setTimeout(() => { badge.textContent = ''; badge.className = 'pc-save-status pc-badge'; }, 3000);
|
||||
} catch (e) {
|
||||
badge.textContent = e.message;
|
||||
badge.className = 'pc-save-status pc-badge pc-badge--err';
|
||||
}
|
||||
};
|
||||
|
||||
// ── Reset ─────────────────────────────────────────────────────────────
|
||||
window.resetPrompt = async function(card) {
|
||||
if (!confirm('Сбросить к последней сохранённой версии?')) return;
|
||||
const pid = card.dataset.pid;
|
||||
try {
|
||||
const r = await fetch('/api/ai/prompts');
|
||||
const d = await r.json();
|
||||
const p = d.prompts?.[pid];
|
||||
if (!p) return;
|
||||
PROMPTS_DATA[pid] = p;
|
||||
const sysEl = card.querySelector('.pe-field[data-field="system"]');
|
||||
const usrEl = card.querySelector('.pe-field[data-field="user"]');
|
||||
renderText(sysEl, p.system || '', p.vars || {});
|
||||
renderText(usrEl, p.user || '', p.vars || {});
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Статистика прохождений{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6 gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-500 text-2xl">bar_chart</span>
|
||||
<h1 class="text-2xl font-semibold text-ink-900">Статистика</h1>
|
||||
</div>
|
||||
<a href="{{ url_for('main.index') }}"
|
||||
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition">
|
||||
<span class="material-symbols-outlined text-base">arrow_back</span>
|
||||
Главная
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Сводные метрики #}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
|
||||
{% for icon, label, value in [
|
||||
('quiz', 'Тестов всего', stats.total_tests),
|
||||
('people', 'Пользователей', stats.total_users),
|
||||
('fact_check', 'Прохождений', stats.total_completed),
|
||||
('check_circle', 'Сдали', stats.total_passed),
|
||||
('percent', 'Успешность', stats.pass_rate | string + '\u2009%'),
|
||||
] %}
|
||||
<div class="rounded-2xl bg-white border border-ink-300/50 shadow-sm p-4 flex flex-col gap-1">
|
||||
<span class="material-symbols-outlined text-brand-500 text-xl">{{ icon }}</span>
|
||||
<div class="text-2xl font-bold text-ink-900 leading-tight">{{ value }}</div>
|
||||
<div class="text-xs text-ink-500">{{ label }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# По отделам + последние прохождения #}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
<section class="rounded-2xl bg-white border border-ink-300/50 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-ink-100 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-500 text-base">corporate_fare</span>
|
||||
<h2 class="font-semibold text-sm text-ink-900">По отделам</h2>
|
||||
</div>
|
||||
{% if stats.dept_stats %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-ink-100 text-xs text-ink-500 uppercase tracking-wide">
|
||||
<th class="px-4 py-2 text-left font-medium">Отдел</th>
|
||||
<th class="px-4 py-2 text-right font-medium">Прошли</th>
|
||||
<th class="px-4 py-2 text-right font-medium">Сдали</th>
|
||||
<th class="px-4 py-2 text-right font-medium">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-ink-100/60">
|
||||
{% for d in stats.dept_stats %}
|
||||
<tr class="hover:bg-ink-100/40 transition-colors">
|
||||
<td class="px-4 py-2.5 text-ink-800 max-w-[180px] truncate">{{ d.name }}</td>
|
||||
<td class="px-4 py-2.5 text-right text-ink-700">{{ d.total }}</td>
|
||||
<td class="px-4 py-2.5 text-right text-ink-700">{{ d.passed }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span class="inline-block min-w-[38px] text-center rounded-full px-2 py-0.5 text-xs font-semibold
|
||||
{% if d.rate >= 80 %}bg-green-50 text-green-700
|
||||
{% elif d.rate >= 50 %}bg-yellow-50 text-yellow-700
|
||||
{% else %}bg-red-50 text-red-700{% endif %}">
|
||||
{{ d.rate }}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="px-4 py-8 text-sm text-ink-500 text-center">Прохождений ещё нет.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white border border-ink-300/50 shadow-sm overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-ink-100 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-brand-500 text-base">history</span>
|
||||
<h2 class="font-semibold text-sm text-ink-900">Последние прохождения</h2>
|
||||
</div>
|
||||
{% if stats.recent %}
|
||||
<ul class="divide-y divide-ink-100/60">
|
||||
{% for r in stats.recent %}
|
||||
<li class="px-4 py-2.5 flex items-start gap-3 hover:bg-ink-100/30 transition-colors">
|
||||
<span class="material-symbols-outlined text-base mt-0.5
|
||||
{% if r.passed == true %}text-green-500{% elif r.passed is none %}text-ink-300{% else %}text-red-400{% endif %}">
|
||||
{% if r.passed == true %}check_circle{% elif r.passed is none %}radio_button_unchecked{% else %}cancel{% endif %}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm text-ink-800 truncate font-medium">{{ r.test }}</div>
|
||||
<div class="text-xs text-ink-500 truncate">{{ r.user }} · {{ r.score }} · {{ r.at }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="px-4 py-8 text-sm text-ink-500 text-center">Прохождений ещё нет.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Прохождение теста{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="attempt-root test-detail-page" id="attempt-root" data-test-id="{{ test_id }}" data-attempt-id="{{ attempt_id }}">
|
||||
|
||||
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back attempt-back-link">← К тестам</a>
|
||||
|
||||
<div id="attempt-flow" class="attempt-flow">
|
||||
<header class="attempt-progress-head">
|
||||
<div class="attempt-progress-row">
|
||||
<span id="attempt-progress-label" class="attempt-progress-label" aria-live="polite">—</span>
|
||||
<span id="attempt-timer" class="attempt-timer" style="display:none;" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="attempt-progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin="1"
|
||||
aria-valuenow="1"
|
||||
aria-valuemax="1"
|
||||
aria-label="Прогресс прохождения">
|
||||
<div id="attempt-progress-fill" class="attempt-progress-fill"></div>
|
||||
</div>
|
||||
<h1 class="attempt-title font-headline" id="attempt-title">Загрузка…</h1>
|
||||
<p class="attempt-subtitle muted text-sm" id="attempt-subtitle"></p>
|
||||
</header>
|
||||
|
||||
<div id="attempt-stage" class="attempt-stage"></div>
|
||||
|
||||
<footer class="attempt-footer-bar" aria-label="Навигация по вопросам">
|
||||
<div class="attempt-footer-inner">
|
||||
<button type="button" class="btn btn-ghost attempt-footer-btn" id="attempt-prev" hidden>Назад</button>
|
||||
<div class="attempt-footer-spacer"></div>
|
||||
<button type="button" class="btn btn-primary attempt-footer-btn" id="attempt-next">Далее</button>
|
||||
<button type="button" class="btn btn-primary attempt-footer-btn" id="attempt-finish" style="display:none;">Завершить тест</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<p id="attempt-error" class="callout callout--error attempt-error-box" style="display:none;"></p>
|
||||
|
||||
<div id="attempt-result" class="surface-card attempt-result-card" style="display:none;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/attempt.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Разбор попытки{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="test-detail-page attempt-review-page">
|
||||
<header class="attempt-review-page__header">
|
||||
<p class="link-back attempt-review-page__back"><a href="/tests">← к списку тестов</a></p>
|
||||
<h1 class="attempt-review-page__title font-headline">Разбор: {{ review.testTitle }}</h1>
|
||||
{% set tl = review.timeLimit %}
|
||||
{% set timestr = 'без ограничения' if tl is none or tl == 0 else (tl|string ~ ' мин') %}
|
||||
{% set rm = review.resultMode or 'end' %}
|
||||
{% set res = 'сразу' if rm == 'immediate' else 'в конце' %}
|
||||
{% set hint = 'недоступны' if rm != 'immediate' else ('вкл' if review.hintsEnabled else 'выкл') %}
|
||||
<p class="attempt-review-page__params text-muted text-sm">
|
||||
Порог: {{ review.passingThreshold }}% · Вопросов: {{ review.totalQuestions }} · Время: {{ timestr }} · Результат: {{ res }} · Подсказки: {{ hint }}
|
||||
</p>
|
||||
<div class="attempt-review-score {% if review.passed %}attempt-review-score--pass{% else %}attempt-review-score--fail{% endif %}">
|
||||
<div class="attempt-review-score__main">
|
||||
<span class="attempt-review-score__label">Итог</span>
|
||||
<p class="attempt-review-score__value">
|
||||
Правильно <strong>{{ review.correctCount }}</strong> из {{ review.totalQuestions }}
|
||||
<span class="attempt-review-score__percent">({{ review.percent }}%)</span>
|
||||
</p>
|
||||
<p class="attempt-review-score__threshold">Порог зачёта: {{ review.passingThreshold }}%</p>
|
||||
</div>
|
||||
<span class="attempt-review-score__verdict">{% if review.passed %}Зачёт{% else %}Незачёт{% endif %}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="attempt-review-page__list">
|
||||
{% for q in review.questions %}
|
||||
<article class="attempt-card attempt-review-card">
|
||||
<div class="attempt-review-card__head">
|
||||
<span class="attempt-review-card__num">{{ loop.index }}</span>
|
||||
<span class="attempt-review-card__badge {% if q.isUserCorrect %}attempt-review-card__badge--ok{% else %}attempt-review-card__badge--bad{% endif %}">
|
||||
{{ 'Верно' if q.isUserCorrect else 'Ошибка' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="attempt-review-card__question">{{ q.text }}</p>
|
||||
{% if q.aiHint %}
|
||||
<div class="attempt-review-hint">
|
||||
<span class="attempt-review-hint__label">Подсказка</span>
|
||||
<p class="attempt-review-hint__text">{{ q.aiHint }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="attempt-review-options" role="list">
|
||||
{% for o in q.options %}
|
||||
<li class="attempt-review-option
|
||||
{% if o.isCorrect %}attempt-review-option--correct{% endif %}
|
||||
{% if o.selected and not o.isCorrect %}attempt-review-option--wrong{% endif %}">
|
||||
<span class="attempt-review-option__text">
|
||||
<span class="attempt-review-option__mark" aria-hidden="true">{% if o.selected %}☑{% else %}☐{% endif %}</span>
|
||||
<span class="attempt-review-option__body">{{ o.text }}{% if o.isCorrect %}<span class="attempt-review-option__tag">верный ответ</span>{% endif %}</span>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,533 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ content.test.title }} — редактор{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="editor-root"
|
||||
class="space-y-4 sm:space-y-5 pb-24 test-detail-page test-detail-page--with-fixed-actions"
|
||||
data-test-id="{{ test_id }}"
|
||||
data-initial='{{ content | tojson | safe }}'>
|
||||
|
||||
<div id="editor-gen-toast" class="editor-gen-toast" role="status" aria-live="polite" hidden></div>
|
||||
|
||||
<section class="cabinet-brick cabinet-brick--hero hero-brick">
|
||||
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← К тестам</a>
|
||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||
class="hero-brick__title font-headline"></textarea>
|
||||
|
||||
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)"
|
||||
class="hero-brick__desc"></textarea>
|
||||
|
||||
<div class="hero-brick__meta-row">
|
||||
<span>Автор: <b id="intro-author">Вы</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Обновлён: <span id="intro-updated">—</span></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Версия <span id="intro-version">—</span></span>
|
||||
</div>
|
||||
|
||||
<div class="hero-brick__divider"></div>
|
||||
|
||||
<div class="hero-brick__meta-row">
|
||||
<span>Порог зачёта: <b id="threshold-mirror">—</b>%</span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Вопросов: <b id="q-count">0</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Время: <b id="editor-hero-time-val">—</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Результат: <b id="editor-hero-result-val">—</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span>Подсказки: <b id="editor-hero-hints-val">—</b></span>
|
||||
<span class="hero-brick__sep">·</span>
|
||||
<span id="chain-active-display">Активна в каталоге</span>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{# ── Версии ───────────────────────────────────────────────────── #}
|
||||
<details class="cabinet-disclosure cabinet-brick">
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Версии</span>
|
||||
<span class="cabinet-disclosure__summary-sub">История изменений; можно переключить активную версию</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<ul id="versions-list" class="version-card-list"></ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick">
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Параметры теста</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Порог, таймер, режим результата, подсказки и шаблон структуры вопросов</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<div class="settings-grid">
|
||||
<label class="settings-row">
|
||||
<span class="settings-row__label">Проходной балл, %</span>
|
||||
<input id="test-threshold" type="number" min="0" max="100" step="1" inputmode="numeric"
|
||||
class="settings-row__input" />
|
||||
</label>
|
||||
|
||||
<label class="settings-row">
|
||||
<span class="settings-row__label">
|
||||
Таймер, минут
|
||||
<span class="settings-row__hint">0 или пусто — без ограничения</span>
|
||||
</span>
|
||||
<input id="test-time-limit" type="number" min="0" max="600" step="1" inputmode="numeric"
|
||||
class="settings-row__input" placeholder="—" />
|
||||
</label>
|
||||
|
||||
<fieldset class="settings-row settings-row--block">
|
||||
<legend class="settings-row__label">Когда показывать результат</legend>
|
||||
<label class="settings-radio">
|
||||
<input type="radio" name="result-mode" value="end" />
|
||||
<span>В конце теста</span>
|
||||
</label>
|
||||
<label class="settings-radio">
|
||||
<input type="radio" name="result-mode" value="immediate" />
|
||||
<span>Сразу после ответа</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="settings-row settings-row--toggle" id="test-hints-row" style="display:none;">
|
||||
<span class="settings-row__label">
|
||||
Показывать подсказку после ответа
|
||||
<span class="settings-row__hint">Краткое объяснение под вариантами ответа</span>
|
||||
</span>
|
||||
<input id="test-hints-enabled" type="checkbox" />
|
||||
</label>
|
||||
|
||||
<div class="settings-row settings-row--block">
|
||||
<span class="settings-row__label">Шаблон структуры вопросов</span>
|
||||
<p class="settings-row__hint" style="margin-bottom:0.5rem;">
|
||||
Задаёт сетку для автосборки пустых вопросов и для генерации через ИИ. При смене чисел список вопросов пересобирается (с подтверждением, если уже есть текст).
|
||||
</p>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="block">
|
||||
<span class="form-label">Вопросов</span>
|
||||
<input id="ai-q-count" type="number" min="1" max="30" step="1" value="7"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">Вариантов</span>
|
||||
<input id="ai-o-count" type="number" min="2" max="8" step="1" value="3"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="template-multi-row mt-3">
|
||||
<label class="inline-flex items-start gap-2 min-h-9 w-full">
|
||||
<input id="template-global-multi" type="checkbox"
|
||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500 mt-0.5 shrink-0" />
|
||||
<span class="text-sm leading-snug">Несколько правильных ответов (все вопросы)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="template-correct-range-row mt-2 flex flex-wrap items-end gap-3">
|
||||
<label class="block">
|
||||
<span class="form-label">Правильных: от</span>
|
||||
<input id="template-min-correct" type="number" min="1" max="8" step="1" value="1"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">до</span>
|
||||
<input id="template-max-correct" type="number" min="1" max="8" step="1" value="1"
|
||||
class="form-input" style="width:90px;" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
|
||||
<span class="settings-row__label">Видимость в каталоге</span>
|
||||
<p class="settings-row__hint" style="margin-bottom:0.5rem;">Скрытые тесты не показываются в общем списке; ссылку по-прежнему можно открыть.</p>
|
||||
<button id="btn-toggle-visibility" class="btn btn-ghost btn--sm" type="button">Скрыть из списка</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick">
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Инструменты генерации</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Генерация через ИИ, проверка, улучшение и импорт документа</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 test-detail-ai-panel editor-generation-panel">
|
||||
|
||||
{# ── Заполнить через ИИ по теме ──────────────────────────── #}
|
||||
<div class="question-editor-block question-editor-block--first">
|
||||
<h3 class="test-detail-subsection__title">Заполнить через ИИ</h3>
|
||||
<label class="block">
|
||||
<span class="form-label">Тема / промпт</span>
|
||||
<textarea id="ai-topic" rows="1" class="form-input"
|
||||
placeholder="Например: охрана труда на производстве"
|
||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||
</label>
|
||||
<label class="mt-2 inline-flex items-start gap-2 text-sm text-ink-700 cursor-pointer select-none">
|
||||
<input type="checkbox" id="ai-keep-title" class="mt-1 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span>Не менять название теста в редакторе после генерации (оставить текущее)</span>
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<button id="ai-generate-test"
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
Сгенерировать вопросы (ИИ)
|
||||
</button>
|
||||
</div>
|
||||
<ul class="editor-generation-panel__notes mt-2 text-xs text-ink-500 space-y-1 list-disc pl-4 leading-snug">
|
||||
<li>Если в параметрах включены подсказки — после генерации будут созданы и тексты подсказок (черновик сохранится автоматически).</li>
|
||||
<li>Если уже есть версия с заполненным тестом — после «Сгенерировать» и сохранения весь прежний текст останется в старой версии в блоке «Версии».</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# ── Проверить и улучшить ─────────────────────────────────── #}
|
||||
<div class="question-editor-block">
|
||||
<h3 class="test-detail-subsection__title">Проверить и улучшить</h3>
|
||||
<p class="text-xs text-ink-500 leading-snug mb-2">
|
||||
<span class="font-medium text-ink-600">Проверить тест</span> — ИИ прочитает вопросы и варианты и вернёт краткий вердикт и список замечаний; черновик не меняется.
|
||||
</p>
|
||||
<p class="text-xs text-ink-500 leading-snug mb-3">
|
||||
<span class="font-medium text-ink-600">Предложить улучшение</span> — ИИ предложит правки по каждому вопросу (было → стало); вы отметите, что применить к черновику.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row sm:flex-wrap sm:items-end gap-2">
|
||||
<label class="flex flex-col gap-1 min-w-[12rem] flex-1">
|
||||
<span class="text-xs font-medium text-ink-600">Область улучшения</span>
|
||||
<select id="ai-improve-focus"
|
||||
class="form-input text-sm py-2 rounded-lg border border-ink-300 bg-white">
|
||||
<option value="all">Всё: вопросы и варианты</option>
|
||||
<option value="questions">Только формулировки вопросов</option>
|
||||
<option value="distractors">Только неверные варианты (дистракторы)</option>
|
||||
<option value="options">Все варианты ответа (без смены верности)</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button id="ai-check"
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">fact_check</span>
|
||||
Проверить тест
|
||||
</button>
|
||||
<button id="ai-improve"
|
||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_fix_high</span>
|
||||
Предложить улучшение
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Документ в вопросы ──────────────────────────────────── #}
|
||||
<div class="question-editor-block test-detail-subsection test-detail-subsection--import">
|
||||
<h3 class="test-detail-subsection__title">Документ в вопросы</h3>
|
||||
<p class="text-xs text-ink-500 leading-snug mb-2 text-center">
|
||||
<span class="font-medium text-ink-600">Сгенерировать из документа</span> — из файла извлекается текст; ИИ составляет вопросы по содержанию и шаблону из «Параметров» (число вопросов, вариантов, несколько верных и т.д.), с учётом поля «Пожелания», если оно заполнено. Перед заменой откроется предпросмотр: «Применить» подставит черновик вместо текущих вопросов; дальше сохраните тест на сервер — подсказки и версии ведут себя так же, как при генерации по теме.
|
||||
</p>
|
||||
<label id="ai-import-dropzone"
|
||||
class="import-dropzone mt-2 flex flex-col w-full items-center justify-center gap-1
|
||||
px-4 py-5 rounded-xl bg-white border-2 border-dashed border-ink-300/70
|
||||
hover:border-brand-400 hover:bg-brand-50/40 cursor-pointer transition-colors">
|
||||
<span class="material-symbols-outlined text-2xl text-brand-400">upload_file</span>
|
||||
<span id="ai-import-dropzone-label" class="text-sm font-medium text-ink-700 text-center block w-full">Перетащите файлы сюда или нажмите</span>
|
||||
<span class="text-xs text-ink-400 text-center block w-full">PDF, DOCX, TXT, MD · до 5 за раз · до 16 МБ · повторный выбор добавляет к уже загруженным; полный сброс — «Сбросить загрузку».</span>
|
||||
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" multiple class="hidden" />
|
||||
</label>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button type="button" id="ai-import-clear" class="btn btn-ghost btn--sm text-sm" style="min-height:36px;">
|
||||
Сбросить загрузку
|
||||
</button>
|
||||
</div>
|
||||
<label class="block mt-3">
|
||||
<span class="form-label">Пожелания по содержанию <span class="text-ink-400 font-normal">(необязательно)</span></span>
|
||||
<textarea id="doc-user-hint" rows="1"
|
||||
class="form-input mt-1"
|
||||
placeholder="Например: акцент на разделе 3, не делать вопросы про даты"
|
||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||
</label>
|
||||
<button id="doc-generate-btn"
|
||||
class="btn btn-ghost mt-2" type="button" style="min-height:43px;">
|
||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span>
|
||||
Сгенерировать из документа
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# ── Модалка результата импорта документа ─────────────────── #}
|
||||
<dialog id="import-modal" class="save-modal import-modal">
|
||||
<div class="save-modal__inner import-modal__inner">
|
||||
<h3 id="import-modal-title" class="import-modal__title font-headline"></h3>
|
||||
<div id="import-modal-body" class="import-modal__body"></div>
|
||||
<div id="import-modal-actions" class="import-modal__actions flex gap-2 justify-end flex-wrap"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="cabinet-disclosure cabinet-brick" open>
|
||||
<summary class="cabinet-disclosure__summary">
|
||||
<span class="cabinet-disclosure__summary-text">
|
||||
<span class="cabinet-disclosure__summary-title font-headline">Вопросы</span>
|
||||
<span class="cabinet-disclosure__summary-sub">Тексты, варианты ответов и подсказки по каждому вопросу</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="cabinet-disclosure__body">
|
||||
<section class="mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
||||
</div>
|
||||
<ol id="questions" class="mt-3 space-y-4"></ol>
|
||||
<div class="mt-3 flex justify-center flex-wrap gap-2">
|
||||
<button id="add-question"
|
||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
||||
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10
|
||||
btn btn-ghost question-editor__add-question">
|
||||
<span class="material-symbols-outlined text-base">add</span>
|
||||
<span>Добавить вопрос</span>
|
||||
</button>
|
||||
<button id="add-question-ai" type="button"
|
||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
||||
bg-brand-50 border border-brand-200 hover:border-brand-400 text-sm min-h-10
|
||||
btn btn-ghost question-editor__add-question-ai"
|
||||
title="Добавляет пустой блок и вызывает ИИ для одного нового вопроса с вариантами">
|
||||
<span class="material-symbols-outlined text-base">auto_awesome</span>
|
||||
<span>Новый вопрос (ИИ)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{# Прохождения перенесены на /stats. Назначения перенесены на /assignments #}
|
||||
</div>
|
||||
|
||||
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #}
|
||||
<div class="fixed bottom-0 inset-x-0 z-30 bg-white/95 backdrop-blur border-t border-ink-300/60
|
||||
pb-[env(safe-area-inset-bottom)]">
|
||||
<div class="mx-auto max-w-2xl px-4 py-3
|
||||
flex items-center justify-between gap-3">
|
||||
<div id="intro-fork-banner" class="callout callout--warning text-xs sm:text-sm"
|
||||
data-fork-risk="{{ '1' if content.test.hasForkRisk else '0' }}"
|
||||
style="display:none; margin:0; padding:0.4rem 0.6rem; flex:1 1 0; min-width:0; white-space:normal; word-break:break-word; line-height:1.25;">
|
||||
При сохранении будет создана новая версия теста.
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-auto shrink-0">
|
||||
<button id="btn-cancel"
|
||||
class="inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm btn btn-ghost">
|
||||
Отмена
|
||||
</button>
|
||||
<button id="save-draft"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg
|
||||
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11 btn btn-primary">
|
||||
<span class="material-symbols-outlined text-base">save</span>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="save-status" class="mx-auto max-w-2xl px-4 pb-2 text-xs text-ink-500"></p>
|
||||
</div>
|
||||
|
||||
{# ── Шаблон вопроса ─────────────────────────────────────────────── #}
|
||||
<template id="tpl-question">
|
||||
<li class="relative rounded-xl bg-white border border-ink-300/60 p-4 sm:p-5 q-item" draggable="true">
|
||||
|
||||
{# Оверлей загрузки AI #}
|
||||
<div class="q-ai-overlay hidden absolute inset-0 rounded-xl z-10
|
||||
bg-white/80 backdrop-blur-[2px] flex flex-col items-center justify-center gap-2">
|
||||
<span class="q-ai-spinner inline-block w-7 h-7 rounded-full
|
||||
border-[3px] border-brand-200 border-t-brand-600 animate-spin"></span>
|
||||
<span class="text-xs text-ink-500 font-medium">Генерирую…</span>
|
||||
</div>
|
||||
|
||||
{# Шапка карточки вопроса #}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<button class="q-drag p-2 rounded hover:bg-ink-100 min-w-10 min-h-10 cursor-grab"
|
||||
title="Перетащить" aria-label="Перетащить" type="button">
|
||||
<span class="material-symbols-outlined text-base">drag_indicator</span>
|
||||
</button>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-md
|
||||
bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button class="q-clear p-2 rounded hover:bg-ink-100 min-w-10 min-h-10 text-ink-400"
|
||||
title="Очистить вопрос" aria-label="Очистить вопрос" type="button">
|
||||
<span class="material-symbols-outlined text-base">backspace</span>
|
||||
</button>
|
||||
<button class="q-up p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
|
||||
title="Выше" aria-label="Поднять выше">
|
||||
<span class="material-symbols-outlined text-base">arrow_upward</span>
|
||||
</button>
|
||||
<button class="q-down p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
|
||||
title="Ниже" aria-label="Опустить ниже">
|
||||
<span class="material-symbols-outlined text-base">arrow_downward</span>
|
||||
</button>
|
||||
<button class="q-delete p-2 rounded hover:bg-red-50 text-red-600 min-w-10 min-h-10"
|
||||
title="Удалить" aria-label="Удалить вопрос">
|
||||
<span class="material-symbols-outlined text-base">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 relative">
|
||||
<textarea class="q-text w-full rounded-lg border border-ink-300 px-3 py-2
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
rows="1" placeholder="Формулировка вопроса" maxlength="500"
|
||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||
<span class="q-text-counter absolute bottom-1.5 right-2 text-xs text-ink-400 pointer-events-none select-none"></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm">
|
||||
<label class="inline-flex items-center gap-2 min-h-9">
|
||||
<input type="checkbox"
|
||||
class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
<span>Несколько правильных ответов</span>
|
||||
</label>
|
||||
<button class="q-ai btn btn-ghost btn--sm q-ai-btn" style="font-size:0.75rem; padding:0.3rem 0.7rem;">
|
||||
<span class="material-symbols-outlined q-ai-icon" style="font-size:0.9rem; vertical-align:-2px;">auto_fix_high</span>
|
||||
<span class="q-ai-label">Сгенерировать</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 mb-2 text-xs text-ink-400 font-medium">Отметьте правильные варианты</p>
|
||||
<ul class="q-options space-y-2"></ul>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<button class="q-add-option inline-flex items-center gap-1 px-2 py-2 rounded
|
||||
text-sm text-brand-700 hover:bg-brand-50 min-h-10 btn btn-ghost btn--sm">
|
||||
<span class="material-symbols-outlined text-base">add</span>
|
||||
<span class="q-add-option-label">Добавить вариант</span>
|
||||
</button>
|
||||
<span class="q-options-count text-xs text-ink-400"></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-3 border-t border-ink-200/80 q-hint-block">
|
||||
<label class="block">
|
||||
<span class="text-xs text-ink-600 font-medium mb-1 block">Подсказка при прохождении</span>
|
||||
<textarea class="q-hint form-input w-full rounded-lg border border-ink-300 px-3 py-2 text-sm
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
rows="2" maxlength="8000"
|
||||
placeholder="Краткий текст подсказки (если в параметрах теста включены подсказки и режим «Сразу после ответа»)"
|
||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||
</label>
|
||||
<p class="mt-1.5 text-xs text-ink-400 leading-snug">Необязательно. Показывается участнику во всплывающем окне после ответа на вопрос.</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
{# ── Шаблон варианта ────────────────────────────────────────────── #}
|
||||
<template id="tpl-option">
|
||||
<li class="flex items-start gap-2 opt-item">
|
||||
{# Чекбокс «Правильный» — выровнен по первой строке textarea #}
|
||||
<label class="shrink-0 w-10 inline-flex items-center justify-center cursor-pointer
|
||||
rounded hover:bg-ink-100 pt-1.5" style="min-height:2.5rem;" title="Правильный ответ">
|
||||
<input type="checkbox"
|
||||
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||
</label>
|
||||
<div class="opt-text-wrap relative flex-1 min-w-0 self-start">
|
||||
<textarea rows="1"
|
||||
class="opt-text w-full rounded-lg border border-ink-300 px-3 py-2
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
placeholder="Вариант ответа"
|
||||
style="resize:none; overflow:hidden; font-family:inherit; line-height:1.55;"></textarea>
|
||||
<div class="opt-ai-overlay hidden absolute inset-0 rounded-lg z-[5]
|
||||
bg-white/85 backdrop-blur-[2px] flex flex-col items-center justify-center gap-1
|
||||
border border-ink-200/80 shadow-sm">
|
||||
<span class="inline-block w-6 h-6 rounded-full
|
||||
border-[3px] border-brand-200 border-t-brand-600 animate-spin"></span>
|
||||
<span class="text-xs text-ink-600 font-medium px-2 text-center leading-snug">Улучшаю…</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="opt-ai shrink-0 inline-flex items-center justify-center
|
||||
rounded hover:bg-brand-50 text-brand-700 px-1.5 pt-1.5"
|
||||
style="min-height:2.5rem; min-width:2.25rem;"
|
||||
title="Улучшить только этот вариант (ИИ)" aria-label="Улучшить вариант ИИ">
|
||||
<span class="material-symbols-outlined text-base">auto_fix_high</span>
|
||||
</button>
|
||||
<button class="opt-delete shrink-0 w-10 inline-flex items-center justify-center
|
||||
rounded hover:bg-red-50 text-red-600 pt-1.5"
|
||||
style="min-height:2.5rem;"
|
||||
title="Удалить" aria-label="Удалить вариант">
|
||||
<span class="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
{# ── Модалка успешного сохранения (компактная, сверху) ──────────── #}
|
||||
<dialog id="save-modal" class="save-modal">
|
||||
<div class="save-modal__inner">
|
||||
<h3 class="text-base font-semibold mb-1" id="save-modal-title">Сохранено</h3>
|
||||
<p id="save-modal-msg" class="text-sm text-ink-700">Изменения сохранены.</p>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<button id="save-modal-stay" type="button"
|
||||
class="px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm btn btn-ghost">
|
||||
К редактору
|
||||
</button>
|
||||
<button id="save-modal-go" type="button"
|
||||
class="px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm btn btn-primary">
|
||||
К каталогу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{# ── Выбор режима ИИ для непустого вопроса (улучшить / дистракторы) ── #}
|
||||
<dialog id="dlg-q-ai-mode" class="save-modal" style="max-width: 24rem; width: calc(100% - 2rem);">
|
||||
<div class="save-modal__inner">
|
||||
<h3 class="text-base font-semibold text-ink-900 mb-1">Что сделать с вопросом?</h3>
|
||||
<p class="text-xs text-ink-500 mb-3">Выберите, что должен сделать ИИ.</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button type="button" id="q-ai-mode-distractors"
|
||||
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
|
||||
hover:bg-brand-50 hover:border-brand-200 transition-colors">
|
||||
<span class="font-medium text-ink-800">Добавить дистракторы</span>
|
||||
<span class="block text-ink-500 text-xs mt-0.5">Заполнить пустые поля вариантов</span>
|
||||
</button>
|
||||
<button type="button" id="q-ai-mode-question"
|
||||
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
|
||||
hover:bg-brand-50 hover:border-brand-200 transition-colors">
|
||||
<span class="font-medium text-ink-800">Улучшить только вопрос</span>
|
||||
<span class="block text-ink-500 text-xs mt-0.5">Переформулировать текст вопроса</span>
|
||||
</button>
|
||||
<button type="button" id="q-ai-mode-options"
|
||||
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
|
||||
hover:bg-brand-50 hover:border-brand-200 transition-colors">
|
||||
<span class="font-medium text-ink-800">Улучшить только варианты</span>
|
||||
<span class="block text-ink-500 text-xs mt-0.5">Тексты ответов, без смены верности</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button type="button" id="q-ai-mode-cancel"
|
||||
class="px-3 py-2 rounded-lg text-ink-600 hover:bg-ink-100 text-sm">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #}
|
||||
<dialog id="ai-modal"
|
||||
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-3xl sm:w-full sm:max-h-[90vh]
|
||||
sm:rounded-2xl sm:m-auto bg-white backdrop:bg-black/50">
|
||||
<div class="flex flex-col h-full sm:max-h-[90vh]">
|
||||
<div class="flex items-center justify-between gap-3 px-4 sm:px-5 py-3 border-b border-ink-300/60">
|
||||
<h3 id="ai-modal-title" class="text-lg font-semibold truncate">AI</h3>
|
||||
<button id="ai-modal-close"
|
||||
class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
|
||||
aria-label="Закрыть">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ai-modal-body" class="flex-1 overflow-y-auto px-4 sm:px-5 py-4"></div>
|
||||
<div id="ai-modal-actions"
|
||||
class="px-4 sm:px-5 py-3 border-t border-ink-300/60
|
||||
flex items-center justify-end gap-2 flex-wrap
|
||||
pb-[max(env(safe-area-inset-bottom),0.75rem)]"></div>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/editor.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,263 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Тесты — каталог{% endblock %}
|
||||
|
||||
{% macro catalog_test_params_line(t) -%}
|
||||
{%- set tl = t.time_limit -%}
|
||||
{%- set timestr = 'без ограничения' if tl is none or tl == 0 else (tl|string ~ ' мин') -%}
|
||||
{%- set rm = t.result_mode or 'end' -%}
|
||||
{%- set res = 'сразу' if rm == 'immediate' else 'в конце' -%}
|
||||
{%- if rm != 'immediate' -%}
|
||||
{%- set hint = 'недоступны' -%}
|
||||
{%- elif t.hints_enabled -%}
|
||||
{%- set hint = 'вкл' -%}
|
||||
{%- else -%}
|
||||
{%- set hint = 'выкл' -%}
|
||||
{%- endif -%}
|
||||
Порог: {{ t.passing_threshold }}% · Вопросов: {{ t.questions_count }} · Время: {{ timestr }} · Результат: {{ res }} · Подсказки: {{ hint }}
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<section class="legacy-list-shell">
|
||||
<h1 class="font-headline legacy-list-title">Тесты</h1>
|
||||
<div class="legacy-list-toolbar legacy-list-toolbar--wrap flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-col sm:flex-row flex-1 min-w-0 gap-2 sm:gap-3">
|
||||
<label class="flex flex-col gap-1 flex-1 min-w-[12rem] max-w-md">
|
||||
<span class="text-xs font-medium text-ink-600">Поиск по названию</span>
|
||||
<input id="catalog-search" type="search" autocomplete="off" placeholder="Начните вводить…"
|
||||
class="rounded-lg border border-ink-300 px-3 py-2 text-sm w-full
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 w-full sm:w-52 shrink-0">
|
||||
<span class="text-xs font-medium text-ink-600">Автор</span>
|
||||
<select id="catalog-author"
|
||||
class="rounded-lg border border-ink-300 px-3 py-2 text-sm w-full bg-white
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20">
|
||||
<option value="">Все авторы</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button id="btn-create-test" class="btn btn-ghost shrink-0" type="button">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
<p id="catalog-filter-empty" class="text-sm text-ink-500 mt-2 hidden" role="status"></p>
|
||||
|
||||
{% if visible %}
|
||||
<ul class="list-stack" aria-label="Тесты в общем списке">
|
||||
{% for t in visible %}
|
||||
<li class="list-row list-row--split list-row--catalog"
|
||||
data-catalog-row
|
||||
data-title-lower="{{ (t.title or '')|lower }}"
|
||||
data-author-id="{{ t.created_by or '' }}"
|
||||
data-author-name="{{ (t.author_full_name or '—')|e }}">
|
||||
<div class="list-row__main">
|
||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
|
||||
<span class="list-row__title">{{ t.title }}</span>
|
||||
<span class="list-row__meta">
|
||||
{{ t.author_full_name or '—' }}
|
||||
<span class="list-row__meta-tail"> · Версия {{ t.version }}</span>
|
||||
</span>
|
||||
<span class="list-row__params muted" style="display:block;font-size:0.78rem;margin-top:0.2rem;line-height:1.35;">{{ catalog_test_params_line(t) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="list-row__side">
|
||||
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">
|
||||
{{ 'Продолжить' if t.has_in_progress_attempt else 'Пройти' }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">Нет тестов</p>
|
||||
{% endif %}
|
||||
|
||||
{% if hidden %}
|
||||
<h2 class="font-headline legacy-list-subtitle">Скрытые вами из списка</h2>
|
||||
<ul class="list-stack" aria-label="Скрытые тесты автора">
|
||||
{% for t in hidden %}
|
||||
<li class="list-row list-row--split list-row--hidden list-row--catalog"
|
||||
data-catalog-row
|
||||
data-title-lower="{{ (t.title or '')|lower }}"
|
||||
data-author-id="{{ t.created_by or '' }}"
|
||||
data-author-name="{{ (t.author_full_name or '—')|e }}">
|
||||
<div class="list-row__main">
|
||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
|
||||
<span class="list-row__title">{{ t.title }}</span>
|
||||
<span class="list-row__meta">
|
||||
{{ t.author_full_name or '—' }}
|
||||
<span class="list-row__meta-tail"> · Версия {{ t.version }} · скрыт</span>
|
||||
</span>
|
||||
<span class="list-row__params muted" style="display:block;font-size:0.78rem;margin-top:0.2rem;line-height:1.35;">{{ catalog_test_params_line(t) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="list-row__side">
|
||||
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}">
|
||||
{{ 'Продолжить' if t.has_in_progress_attempt else 'Пройти' }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<dialog id="dlg-create"
|
||||
class="m-0 p-0 w-full sm:w-full sm:max-w-md
|
||||
h-fit max-h-[calc(100vh-2rem)]
|
||||
rounded-2xl bg-white backdrop:bg-ink-900/50 m-auto">
|
||||
<form method="dialog" class="flex flex-col sm:h-auto bg-white sm:rounded-2xl">
|
||||
<div class="px-4 sm:px-5 py-3 border-b border-ink-300/60 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Новый тест</h2>
|
||||
<button type="button" id="dlg-cancel-x"
|
||||
class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10"
|
||||
aria-label="Закрыть">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-4 sm:px-5 py-4 space-y-3">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-ink-700">Название</span>
|
||||
<input id="new-test-title" type="text" required maxlength="200"
|
||||
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-3
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-ink-700">Описание (опц.)</span>
|
||||
<textarea id="new-test-desc" rows="3"
|
||||
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2
|
||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="px-4 sm:px-5 py-3 border-t border-ink-300/60 flex justify-end gap-2
|
||||
bg-ink-100/40 sm:rounded-b-2xl
|
||||
pb-[max(env(safe-area-inset-bottom),0.75rem)]">
|
||||
<button type="button" id="dlg-cancel"
|
||||
class="px-4 py-2.5 rounded-lg text-ink-700 hover:bg-ink-100 min-h-11">Отмена</button>
|
||||
<button type="button" id="dlg-submit"
|
||||
class="px-4 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white min-h-11">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(() => {
|
||||
const dlg = document.getElementById('dlg-create');
|
||||
const titleEl = document.getElementById('new-test-title');
|
||||
const descEl = document.getElementById('new-test-desc');
|
||||
const catalogSearch = document.getElementById('catalog-search');
|
||||
const catalogAuthor = document.getElementById('catalog-author');
|
||||
const catalogEmpty = document.getElementById('catalog-filter-empty');
|
||||
|
||||
(function initCatalogFilter() {
|
||||
if (!catalogSearch || !catalogAuthor) return;
|
||||
const rows = Array.from(document.querySelectorAll('[data-catalog-row]'));
|
||||
const byAuthor = new Map();
|
||||
rows.forEach((row) => {
|
||||
const id = (row.dataset.authorId || '').trim();
|
||||
const name = (row.dataset.authorName || '').trim() || '—';
|
||||
if (id && !byAuthor.has(id)) byAuthor.set(id, name);
|
||||
});
|
||||
const sorted = Array.from(byAuthor.entries()).sort((a, b) => a[1].localeCompare(b[1], 'ru'));
|
||||
sorted.forEach(([id, name]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = name;
|
||||
catalogAuthor.appendChild(opt);
|
||||
});
|
||||
|
||||
function applyFilter() {
|
||||
const q = (catalogSearch.value || '').trim().toLowerCase();
|
||||
const author = (catalogAuthor.value || '').trim();
|
||||
let visibleCount = 0;
|
||||
rows.forEach((row) => {
|
||||
const title = row.dataset.titleLower || '';
|
||||
const aid = (row.dataset.authorId || '').trim();
|
||||
const matchQ = !q || title.includes(q);
|
||||
const matchA = !author || aid === author;
|
||||
const show = matchQ && matchA;
|
||||
row.style.display = show ? '' : 'none';
|
||||
if (show) visibleCount += 1;
|
||||
});
|
||||
if (catalogEmpty) {
|
||||
if (rows.length && visibleCount === 0) {
|
||||
catalogEmpty.textContent = 'Ничего не найдено — измените запрос или фильтр.';
|
||||
catalogEmpty.classList.remove('hidden');
|
||||
} else {
|
||||
catalogEmpty.textContent = '';
|
||||
catalogEmpty.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let t = null;
|
||||
catalogSearch.addEventListener('input', () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(applyFilter, 120);
|
||||
});
|
||||
catalogAuthor.addEventListener('change', applyFilter);
|
||||
})();
|
||||
|
||||
document.getElementById('btn-create-test').addEventListener('click', () => {
|
||||
titleEl.value = '';
|
||||
descEl.value = '';
|
||||
if (typeof dlg.showModal === 'function') dlg.showModal();
|
||||
else dlg.setAttribute('open', 'open');
|
||||
setTimeout(() => titleEl.focus(), 50);
|
||||
});
|
||||
document.getElementById('dlg-cancel').addEventListener('click', () => dlg.close());
|
||||
document.getElementById('dlg-cancel-x').addEventListener('click', () => dlg.close());
|
||||
|
||||
document.getElementById('dlg-submit').addEventListener('click', async () => {
|
||||
const title = titleEl.value.trim();
|
||||
if (!title) { titleEl.focus(); return; }
|
||||
try {
|
||||
const r = await fetch('/api/tests', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description: descEl.value.trim() || null }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Не удалось создать тест.');
|
||||
window.location.href = `/tests/${data.testId}/edit`;
|
||||
} catch (e) {
|
||||
alert(e.message || 'Не удалось создать тест.');
|
||||
}
|
||||
});
|
||||
|
||||
const passButtons = Array.from(document.querySelectorAll('.btn-start-pass'));
|
||||
passButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const testId = btn.dataset.testId;
|
||||
if (!testId) return;
|
||||
btn.disabled = true;
|
||||
const oldText = (btn.textContent || '').trim() || 'Пройти';
|
||||
btn.textContent = `${oldText}…`;
|
||||
try {
|
||||
const r = await fetch(`/api/tests/${testId}/attempts/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
let data = {};
|
||||
try { data = await r.json(); } catch (_) {}
|
||||
if (!r.ok || !data.attempt || !data.attempt.id) {
|
||||
// Если нет страницы попытки, уводим в редактор.
|
||||
// Тогда ведём в карточку теста, чтобы пользователь не попадал на not_found.
|
||||
window.location.href = `/tests/${testId}/edit`;
|
||||
return;
|
||||
}
|
||||
window.location.href = `/tests/${testId}/attempt/${data.attempt.id}`;
|
||||
} catch (e) {
|
||||
window.location.href = `/tests/${testId}/edit`;
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Blueprint `tests`: JSON API (`/api/tests/*`) и UI (`/tests`, `/tests/<id>/edit`)."""
|
||||
from .routes import tests_bp # noqa: F401
|
||||
@@ -0,0 +1,665 @@
|
||||
"""Маршруты тестов (E1.2).
|
||||
|
||||
Покрытие Express → Flask:
|
||||
- GET /api/tests/ — каталог + hidden by you
|
||||
- POST /api/tests/ — создать тест (цепочку с версией 1)
|
||||
- GET /api/tests/<id>/summary — краткая карточка
|
||||
- GET /api/tests/<id>/versions — список версий + hasAttempts
|
||||
- GET /api/tests/<id>/editor — контент редактора
|
||||
- POST /api/tests/<id>/draft — saveTestDraft (fork если нужно)
|
||||
- POST /api/tests/<id>/versions/<vid>/activate
|
||||
- PATCH /api/tests/<id> — chainActive
|
||||
- POST /api/tests/<id>/ai/generate-test
|
||||
- POST /api/tests/<id>/ai/generate-question
|
||||
|
||||
UI-страницы:
|
||||
- GET /tests — каталог
|
||||
- GET /tests/<id>/edit — редактор (вызывает /api/tests/...)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..auth.decorators import current_user, login_required
|
||||
from ..db import get_session
|
||||
from ..messages import RU
|
||||
from ..models import Test, TestVersion, User
|
||||
from ..services.ai_editor import (
|
||||
HttpError as AiHttpError,
|
||||
check_test_quality,
|
||||
generate_full_test_by_shape,
|
||||
generate_or_rephrase_question,
|
||||
generate_test_by_title,
|
||||
improve_single_option_text,
|
||||
improve_test_full,
|
||||
parse_and_validate_shape,
|
||||
)
|
||||
from ..services.document_extract import (
|
||||
HttpError as DocExtractHttpError,
|
||||
extract_text_from_file,
|
||||
)
|
||||
from ..services.document_gen import generation_for_import_document
|
||||
from ..services.draft_validator import LlmError
|
||||
from ..services.editor_content import HttpError as EditorHttpError, get_editor_content
|
||||
from ..services.test_attempt import (
|
||||
HttpError as AttemptHttpError,
|
||||
check_question_for_attempt,
|
||||
count_missing_hints,
|
||||
generate_missing_hints_for_test,
|
||||
generate_next_missing_hint_for_test,
|
||||
get_attempt_review_for_user,
|
||||
get_play_content,
|
||||
list_test_attempts_for_author,
|
||||
start_attempt,
|
||||
submit_attempt,
|
||||
)
|
||||
from ..services.test_access import (
|
||||
is_test_author,
|
||||
is_test_edit_open,
|
||||
list_hidden_by_author,
|
||||
list_visible_tests,
|
||||
user_has_test_access,
|
||||
)
|
||||
from ..services.test_chain import has_any_attempt_for_test
|
||||
from ..services.test_draft import (
|
||||
HttpError as DraftHttpError,
|
||||
create_test_with_version,
|
||||
save_test_draft,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
tests_bp = Blueprint('tests', __name__)
|
||||
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _stringify_uuids(d: dict) -> dict:
|
||||
out = {}
|
||||
for k, v in d.items():
|
||||
if hasattr(v, 'hex') and not isinstance(v, (str, bytes)):
|
||||
out[k] = str(v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _to_uuid(val) -> _uuid.UUID | None:
|
||||
if isinstance(val, _uuid.UUID):
|
||||
return val
|
||||
try:
|
||||
return _uuid.UUID(str(val))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _check_test_author_or_404(test_id: str, user_id: str) -> Test:
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
test = session.get(Test, tid) if tid else None
|
||||
if not test:
|
||||
from werkzeug.exceptions import NotFound
|
||||
raise NotFound(RU['notFound'])
|
||||
if not is_test_author(test.created_by, user_id) and not is_test_edit_open():
|
||||
from werkzeug.exceptions import Forbidden
|
||||
raise Forbidden('Доступ запрещён.')
|
||||
return test
|
||||
|
||||
|
||||
# ─── JSON API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/', methods=['GET'])
|
||||
@tests_bp.route('/api/tests', methods=['GET'])
|
||||
@login_required
|
||||
def api_list_tests():
|
||||
user = current_user()
|
||||
visible = list_visible_tests(user.id)
|
||||
hidden = list_hidden_by_author(user.id)
|
||||
return jsonify(
|
||||
tests=[_stringify_uuids(r) for r in visible],
|
||||
hiddenByYou=[_stringify_uuids(r) for r in hidden],
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/', methods=['POST'])
|
||||
@tests_bp.route('/api/tests', methods=['POST'])
|
||||
@login_required
|
||||
def api_create_test():
|
||||
user = current_user()
|
||||
body = request.get_json(silent=True) or {}
|
||||
title = body.get('title')
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
return jsonify(error='Укажите название.'), 400
|
||||
out = create_test_with_version(user.id, title=title.strip(), description=body.get('description'))
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/summary', methods=['GET'])
|
||||
@login_required
|
||||
def api_test_summary(test_id):
|
||||
user = current_user()
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
if not tid:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
test = (
|
||||
session.query(Test)
|
||||
.options(selectinload(Test.author), selectinload(Test.versions))
|
||||
.filter(Test.id == tid)
|
||||
.first()
|
||||
)
|
||||
if not test:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
is_author = is_test_author(test.created_by, user.id)
|
||||
open_edit = is_test_edit_open()
|
||||
if not test.is_active and not is_author and not open_edit:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
if not is_author and not open_edit:
|
||||
acc = user_has_test_access(user.id, test_id)
|
||||
if not acc.ok:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
active_version = next((v for v in test.versions if v.is_active), None)
|
||||
has_attempts = has_any_attempt_for_test(session, tid)
|
||||
|
||||
return jsonify(
|
||||
test={
|
||||
'id': str(test.id),
|
||||
'title': test.title,
|
||||
'description': test.description,
|
||||
'passingThreshold': test.passing_threshold,
|
||||
'chainActive': test.is_active,
|
||||
'activeVersionId': str(active_version.id) if active_version else None,
|
||||
'version': active_version.version if active_version else None,
|
||||
'createdAt': test.created_at.isoformat() if test.created_at else None,
|
||||
'updatedAt': test.updated_at.isoformat() if test.updated_at else None,
|
||||
'createdBy': str(test.created_by) if test.created_by else None,
|
||||
'authorFullName': test.author.full_name if test.author else None,
|
||||
'hasAttempts': bool(has_attempts),
|
||||
},
|
||||
isAuthor=is_author,
|
||||
hasActiveVersion=active_version is not None,
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/versions', methods=['GET'])
|
||||
@login_required
|
||||
def api_test_versions(test_id):
|
||||
user = current_user()
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
if not tid:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
|
||||
test = (
|
||||
session.query(Test)
|
||||
.options(selectinload(Test.author), selectinload(Test.versions))
|
||||
.filter(Test.id == tid)
|
||||
.first()
|
||||
)
|
||||
if not test:
|
||||
return jsonify(error=RU['notFound']), 404
|
||||
if not is_test_author(test.created_by, user.id) and not is_test_edit_open():
|
||||
return jsonify(error='Доступ запрещён.'), 403
|
||||
|
||||
has_attempts = has_any_attempt_for_test(session, tid)
|
||||
sorted_versions = sorted(test.versions, key=lambda v: v.version)
|
||||
|
||||
return jsonify(
|
||||
test={
|
||||
'id': str(test.id),
|
||||
'title': test.title,
|
||||
'description': test.description,
|
||||
'chainActive': test.is_active,
|
||||
'createdAt': test.created_at.isoformat() if test.created_at else None,
|
||||
'updatedAt': test.updated_at.isoformat() if test.updated_at else None,
|
||||
'createdBy': str(test.created_by) if test.created_by else None,
|
||||
'authorFullName': test.author.full_name if test.author else None,
|
||||
},
|
||||
versions=[
|
||||
{
|
||||
'id': str(v.id),
|
||||
'version': v.version,
|
||||
'is_active': v.is_active,
|
||||
'parent_id': str(v.parent_id) if v.parent_id else None,
|
||||
'created_at': v.created_at.isoformat() if v.created_at else None,
|
||||
}
|
||||
for v in sorted_versions
|
||||
],
|
||||
hasAttempts=has_attempts,
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/editor', methods=['GET'])
|
||||
@login_required
|
||||
def api_test_editor(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
out = get_editor_content(user.id, test_id)
|
||||
except EditorHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/draft', methods=['POST'])
|
||||
@login_required
|
||||
def api_save_draft(test_id):
|
||||
user = current_user()
|
||||
payload = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = save_test_draft(user.id, test_id, payload)
|
||||
except DraftHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/versions/<version_id>/activate', methods=['POST'])
|
||||
@login_required
|
||||
def api_activate_version(test_id, version_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
|
||||
session = get_session()
|
||||
tid = _to_uuid(test_id)
|
||||
vid = _to_uuid(version_id)
|
||||
|
||||
version = session.query(TestVersion).filter(
|
||||
TestVersion.test_id == tid, TestVersion.id == vid
|
||||
).first()
|
||||
if not version:
|
||||
return jsonify(error='Версия не найдена.'), 404
|
||||
|
||||
session.query(TestVersion).filter(TestVersion.test_id == tid).update(
|
||||
{TestVersion.is_active: False}, synchronize_session='fetch'
|
||||
)
|
||||
version.is_active = True
|
||||
session.commit()
|
||||
return jsonify(ok=True, activeVersionId=str(version_id))
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>', methods=['PATCH'])
|
||||
@login_required
|
||||
def api_patch_test(test_id):
|
||||
user = current_user()
|
||||
body = request.get_json(silent=True) or {}
|
||||
chain = body.get('chainActive', body.get('isActive'))
|
||||
if not isinstance(chain, bool):
|
||||
return jsonify(error='Передайте chainActive: true/false в теле запроса.'), 400
|
||||
test = _check_test_author_or_404(test_id, user.id)
|
||||
session = get_session()
|
||||
test.is_active = chain
|
||||
session.commit()
|
||||
return jsonify(id=test_id, chainActive=chain)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/start', methods=['POST'])
|
||||
@login_required
|
||||
def api_start_attempt(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
out = start_attempt(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out), 201
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/play', methods=['GET'])
|
||||
@login_required
|
||||
def api_attempt_play(test_id, attempt_id):
|
||||
user = current_user()
|
||||
try:
|
||||
out = get_play_content(None, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/submit', methods=['POST'])
|
||||
@login_required
|
||||
def api_attempt_submit(test_id, attempt_id):
|
||||
user = current_user()
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = submit_attempt(None, user.id, test_id, attempt_id, body.get('answers'))
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/hints/status', methods=['GET'])
|
||||
@login_required
|
||||
def api_test_hints_status(test_id):
|
||||
out = count_missing_hints(None, test_id)
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/hints/generate', methods=['POST'])
|
||||
@login_required
|
||||
def api_test_hints_generate(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
out = generate_missing_hints_for_test(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/hints/generate-next', methods=['POST'])
|
||||
@login_required
|
||||
def api_test_hints_generate_next(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
out = generate_next_missing_hint_for_test(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/check', methods=['POST'])
|
||||
@login_required
|
||||
def api_attempt_check_question(test_id, attempt_id):
|
||||
user = current_user()
|
||||
body = request.get_json(silent=True) or {}
|
||||
qid = body.get('questionId')
|
||||
sel = body.get('selectedOptionIds') or []
|
||||
if not qid:
|
||||
return jsonify(error='questionId обязателен.'), 400
|
||||
if not isinstance(sel, list):
|
||||
return jsonify(error='selectedOptionIds должен быть массивом.'), 400
|
||||
try:
|
||||
out = check_question_for_attempt(None, user.id, test_id, attempt_id, qid, sel)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET'])
|
||||
@login_required
|
||||
def api_attempt_review(test_id, attempt_id):
|
||||
user = current_user()
|
||||
try:
|
||||
out = get_attempt_review_for_user(None, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/attempts', methods=['GET'])
|
||||
@login_required
|
||||
def api_attempts_list(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
rows = list_test_attempts_for_author(None, user.id, test_id)
|
||||
except AttemptHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
return jsonify(
|
||||
attempts=[
|
||||
{
|
||||
'id': str(r['id']),
|
||||
'userId': str(r['user_id']),
|
||||
'status': r['status'],
|
||||
'attemptNumber': r['attempt_number'],
|
||||
'startedAt': r['started_at'].isoformat() if r['started_at'] else None,
|
||||
'completedAt': r['completed_at'].isoformat() if r['completed_at'] else None,
|
||||
'correctCount': r['correct_count'],
|
||||
'totalQuestions': r['total_questions'],
|
||||
'passed': r['passed'],
|
||||
'testVersion': r['test_version'],
|
||||
'attempterName': r['attempter_name'],
|
||||
'attempterLogin': r['attempter_login'],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ─── AI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_generate_test(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
shape = parse_and_validate_shape(body.get('shape'))
|
||||
draft = generate_full_test_by_shape(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
shape,
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, draft=draft)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-question', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_generate_question(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = generate_or_rephrase_question(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
body.get('questionText') or '',
|
||||
body.get('optionsCount'),
|
||||
bool(body.get('hasMultipleAnswers')),
|
||||
mode=body.get('mode'),
|
||||
existing_options=body.get('existingOptions'),
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, **out)
|
||||
|
||||
|
||||
def _ai_error_response(e):
|
||||
if isinstance(e, AiHttpError):
|
||||
return jsonify(error=e.message), e.status
|
||||
if isinstance(e, LlmError):
|
||||
log.warning('LLM error: %s (%s)', e, e.code)
|
||||
return (
|
||||
jsonify(error=str(e), code=e.code, settingsUrl='/settings'),
|
||||
e.status or 502,
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-by-title', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_generate_by_title(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
title = (body.get('testTitle') or '').strip()
|
||||
if not title:
|
||||
return jsonify(error='Заполните название теста.'), 400
|
||||
try:
|
||||
draft = generate_test_by_title(
|
||||
title,
|
||||
body.get('testDescription') or '',
|
||||
int(body.get('questionsCount') or 10),
|
||||
int(body.get('optionsCount') or 4),
|
||||
bool(body.get('hasMultipleAnswers')),
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, draft=draft)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/check', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_check_test(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
review = check_test_quality(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
body.get('questions') or [],
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, review=review)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/improve', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_improve_test(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = improve_test_full(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
body.get('questions') or [],
|
||||
focus=body.get('focus') or 'all',
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, **out)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/<test_id>/ai/improve-option', methods=['POST'])
|
||||
@login_required
|
||||
def api_ai_improve_option(test_id):
|
||||
user = current_user()
|
||||
_check_test_author_or_404(test_id, user.id)
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
out = improve_single_option_text(
|
||||
body.get('testTitle') or '',
|
||||
body.get('testDescription') or '',
|
||||
body.get('questionText') or '',
|
||||
body.get('options') or [],
|
||||
body.get('optionIndex'),
|
||||
)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
return jsonify(ok=True, **out)
|
||||
|
||||
|
||||
# ─── Импорт документа (E1.3) ─────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/api/tests/import/document', methods=['POST'])
|
||||
@login_required
|
||||
def api_import_document():
|
||||
"""Шаг 1: загрузить один или несколько файлов и извлечь текст. Генерация — отдельным запросом."""
|
||||
files = request.files.getlist('files')
|
||||
if not files:
|
||||
f = request.files.get('file')
|
||||
files = [f] if f is not None and getattr(f, 'filename', None) else []
|
||||
files = [x for x in files if x is not None and getattr(x, 'filename', None)]
|
||||
if not files:
|
||||
return jsonify(error='Прикрепите файл(ы) к полю file или files.'), 400
|
||||
if len(files) > 5:
|
||||
return jsonify(error='Не более 5 файлов за один раз.'), 400
|
||||
|
||||
parts: list[str] = []
|
||||
names: list[str] = []
|
||||
mimes: list[str] = []
|
||||
for f in files:
|
||||
try:
|
||||
extracted = extract_text_from_file(f.mimetype, f, f.filename)
|
||||
except DocExtractHttpError as e:
|
||||
return jsonify(error=e.message), e.status
|
||||
except Exception:
|
||||
log.exception('extract_text_from_file failed')
|
||||
return jsonify(error='Не удалось разобрать файл.'), 500
|
||||
parts.append(extracted)
|
||||
names.append(f.filename or '')
|
||||
mimes.append(f.mimetype or '')
|
||||
|
||||
combined = '\n\n---\n\n'.join(parts)
|
||||
primary = files[0]
|
||||
return jsonify(
|
||||
received=True,
|
||||
originalName=primary.filename,
|
||||
originalNames=names,
|
||||
mime=primary.mimetype,
|
||||
mimes=mimes,
|
||||
size=len(combined.encode('utf-8')),
|
||||
extractedText=combined,
|
||||
textLength=len(combined),
|
||||
fileCount=len(files),
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/api/tests/generate-from-extracted', methods=['POST'])
|
||||
@login_required
|
||||
def api_generate_from_extracted():
|
||||
"""Шаг 2: сгенерировать черновик теста из ранее извлечённого текста + подсказки автора."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
extracted = (body.get('extractedText') or '').strip()
|
||||
user_hint = (body.get('userHint') or '').strip()
|
||||
shape_raw = body.get('shape')
|
||||
if not extracted:
|
||||
return jsonify(error='Нет текста для генерации.'), 400
|
||||
shape = None
|
||||
if shape_raw:
|
||||
try:
|
||||
shape = parse_and_validate_shape(shape_raw)
|
||||
except (AiHttpError, LlmError) as e:
|
||||
return _ai_error_response(e)
|
||||
generation = generation_for_import_document(extracted, user_hint=user_hint, shape=shape)
|
||||
return jsonify(generation=generation)
|
||||
|
||||
|
||||
# ─── UI (Jinja) ───────────────────────────────────────────────────────────────
|
||||
|
||||
@tests_bp.route('/tests', methods=['GET'])
|
||||
@login_required
|
||||
def tests_list_page():
|
||||
user = current_user()
|
||||
visible = list_visible_tests(user.id)
|
||||
hidden = list_hidden_by_author(user.id)
|
||||
return render_template(
|
||||
'tests/list.html',
|
||||
visible=[_stringify_uuids(r) for r in visible],
|
||||
hidden=[_stringify_uuids(r) for r in hidden],
|
||||
)
|
||||
|
||||
|
||||
@tests_bp.route('/tests/<test_id>/edit', methods=['GET'])
|
||||
@login_required
|
||||
def tests_editor_page(test_id):
|
||||
user = current_user()
|
||||
try:
|
||||
content = get_editor_content(user.id, test_id)
|
||||
except EditorHttpError as e:
|
||||
if e.status == 404:
|
||||
return render_template('404.html'), 404
|
||||
if e.status == 403:
|
||||
return ('Доступ запрещён.', 403)
|
||||
return render_template('500.html'), 500
|
||||
return render_template('tests/editor.html', content=content, test_id=test_id)
|
||||
|
||||
|
||||
@tests_bp.route('/tests/<test_id>/attempt/<attempt_id>', methods=['GET'])
|
||||
@login_required
|
||||
def tests_attempt_page(test_id, attempt_id):
|
||||
return render_template('tests/attempt.html', test_id=test_id, attempt_id=attempt_id)
|
||||
|
||||
|
||||
@tests_bp.route('/tests/<test_id>/attempts/<attempt_id>/review', methods=['GET'])
|
||||
@login_required
|
||||
def tests_attempt_review_page(test_id, attempt_id):
|
||||
user = current_user()
|
||||
try:
|
||||
review = get_attempt_review_for_user(None, user.id, test_id, attempt_id)
|
||||
except AttemptHttpError as e:
|
||||
if e.status == 404:
|
||||
return render_template('404.html'), 404
|
||||
return (e.message, e.status)
|
||||
return render_template('tests/attempt_review.html', test_id=test_id, review=review)
|
||||
@@ -1,3 +1,18 @@
|
||||
Flask>=3.0.0,<4
|
||||
python-dotenv>=1.0.0
|
||||
waitress>=3.0.0
|
||||
|
||||
# Этап 1 (E1.0): тот же стек, что в HR_TG_Bot/tgFlaskForm.
|
||||
# SQLAlchemy + psycopg2 драйвер для PostgreSQL.
|
||||
SQLAlchemy>=2.0.0,<3
|
||||
psycopg2-binary>=2.9.0,<3
|
||||
alembic>=1.13.0,<2
|
||||
|
||||
# Этап 1 (E1.1): авторизация. bcrypt — для локальных хешей в clinic_tests.users.
|
||||
# Werkzeug-хеши (scrypt/pbkdf2) проверяет встроенный werkzeug.security.
|
||||
bcrypt>=4.0.0,<5
|
||||
|
||||
# Этап 1 (E1.3): импорт документов (PDF/DOCX) → AI-черновик.
|
||||
pypdf>=4.0.0,<6
|
||||
python-docx>=1.1.0,<2
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ def _use_waitress() -> bool:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', '3108'))
|
||||
port = int(os.environ.get('PORT', '3107'))
|
||||
if _use_waitress():
|
||||
from waitress import serve
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Система тестрования</title>
|
||||
<title>Система тестирования</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
|
After Width: | Height: | Size: 52 KiB |
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams, Link, useOutletContext } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { formatTestAuthorLabel } from '../utils/formatUserName';
|
||||
@@ -70,6 +70,41 @@ function mapEditorToDraftQuestions(ed) {
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDraftSnapshot({ title, description, passing, questions }) {
|
||||
return JSON.stringify({
|
||||
title: title ?? '',
|
||||
description: description ?? '',
|
||||
passing: String(passing ?? ''),
|
||||
questions: (questions || []).map((q) => ({
|
||||
text: q.text ?? '',
|
||||
hasMultipleAnswers: !!q.hasMultipleAnswers,
|
||||
options: (q.options || []).map((o) => ({
|
||||
text: o.text ?? '',
|
||||
isCorrect: !!o.isCorrect,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function postDebugLog({ runId, hypothesisId, location, message, data }) {
|
||||
fetch('http://127.0.0.1:7419/ingest/a86fc408-7178-4abe-8dd9-f3e6bfb05d76', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Debug-Session-Id': '034e19',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: '034e19',
|
||||
runId,
|
||||
hypothesisId,
|
||||
location,
|
||||
message,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export default function TestDetail() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
@@ -83,6 +118,7 @@ export default function TestDetail() {
|
||||
const [draftDescription, setDraftDescription] = useState('');
|
||||
const [draftPassing, setDraftPassing] = useState('70');
|
||||
const [draftQuestions, setDraftQuestions] = useState(() => [createEmptyQuestion()]);
|
||||
const [draftSnapshotOnLoad, setDraftSnapshotOnLoad] = useState(null);
|
||||
const [draftStatus, setDraftStatus] = useState('');
|
||||
const [deactivateBusy, setDeactivateBusy] = useState(false);
|
||||
const [importPreview, setImportPreview] = useState(null);
|
||||
@@ -106,6 +142,16 @@ export default function TestDetail() {
|
||||
const [assignLoadBusy, setAssignLoadBusy] = useState(false);
|
||||
const [attemptsList, setAttemptsList] = useState(undefined);
|
||||
const [attemptsErr, setAttemptsErr] = useState(null);
|
||||
const debugRunId = 'pre-fix';
|
||||
// #region agent log
|
||||
postDebugLog({
|
||||
runId: debugRunId,
|
||||
hypothesisId: 'H1',
|
||||
location: 'frontend/src/pages/TestDetail.jsx:component-start',
|
||||
message: 'TestDetail render start',
|
||||
data: { hasData: !!data, hasTaker: !!taker, hasErr: !!err, testId: id != null },
|
||||
});
|
||||
// #endregion
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
@@ -127,14 +173,26 @@ export default function TestDetail() {
|
||||
setData(v);
|
||||
setChain(c);
|
||||
if (ed?.test) {
|
||||
setDraftTitle(ed.test.title || '');
|
||||
setAiGenTopic((ed.test.title || '').trim());
|
||||
setDraftDescription(ed.test.description || '');
|
||||
const loadedTitle = ed.test.title || '';
|
||||
const loadedDescription = ed.test.description || '';
|
||||
const th = ed.test.passingThreshold;
|
||||
setDraftPassing(
|
||||
th !== undefined && th !== null && String(th) !== '' ? String(th) : '70'
|
||||
const loadedPassing =
|
||||
th !== undefined && th !== null && String(th) !== '' ? String(th) : '70';
|
||||
const loadedQuestions = mapEditorToDraftQuestions(ed);
|
||||
|
||||
setDraftSnapshotOnLoad(
|
||||
buildDraftSnapshot({
|
||||
title: loadedTitle,
|
||||
description: loadedDescription,
|
||||
passing: loadedPassing,
|
||||
questions: loadedQuestions,
|
||||
})
|
||||
);
|
||||
setDraftQuestions(mapEditorToDraftQuestions(ed));
|
||||
setDraftTitle(loadedTitle);
|
||||
setAiGenTopic(loadedTitle.trim());
|
||||
setDraftDescription(loadedDescription);
|
||||
setDraftPassing(loadedPassing);
|
||||
setDraftQuestions(loadedQuestions);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
@@ -590,13 +648,40 @@ export default function TestDetail() {
|
||||
}
|
||||
|
||||
if (err) {
|
||||
// #region agent log
|
||||
postDebugLog({
|
||||
runId: debugRunId,
|
||||
hypothesisId: 'H3',
|
||||
location: 'frontend/src/pages/TestDetail.jsx:return-err',
|
||||
message: 'Returning error branch before memo hook',
|
||||
data: { hasErr: true },
|
||||
});
|
||||
// #endregion
|
||||
return <p className="error-text">{err}</p>;
|
||||
}
|
||||
if (!data && !taker) {
|
||||
// #region agent log
|
||||
postDebugLog({
|
||||
runId: debugRunId,
|
||||
hypothesisId: 'H1',
|
||||
location: 'frontend/src/pages/TestDetail.jsx:return-loading',
|
||||
message: 'Returning loading branch before memo hook',
|
||||
data: { hasData: false, hasTaker: false },
|
||||
});
|
||||
// #endregion
|
||||
return <p className="text-muted">Загрузка…</p>;
|
||||
}
|
||||
|
||||
if (taker) {
|
||||
// #region agent log
|
||||
postDebugLog({
|
||||
runId: debugRunId,
|
||||
hypothesisId: 'H1',
|
||||
location: 'frontend/src/pages/TestDetail.jsx:return-taker',
|
||||
message: 'Returning taker branch before memo hook',
|
||||
data: { hasTaker: true },
|
||||
});
|
||||
// #endregion
|
||||
const { test: t, hasActiveVersion } = taker.summary;
|
||||
const title = t?.title || 'Тест';
|
||||
return (
|
||||
@@ -635,6 +720,42 @@ export default function TestDetail() {
|
||||
const assignSelectedInList = assignPeople.filter((p) =>
|
||||
assignSelected.has(assignPersonKey(p))
|
||||
);
|
||||
// #region agent log
|
||||
postDebugLog({
|
||||
runId: debugRunId,
|
||||
hypothesisId: 'H1',
|
||||
location: 'frontend/src/pages/TestDetail.jsx:before-useMemo',
|
||||
message: 'Reached line right before hasDraftChanges useMemo',
|
||||
data: { hasData: !!data, hasTaker: !!taker },
|
||||
});
|
||||
// #endregion
|
||||
const hasDraftChanges = useMemo(() => {
|
||||
// #region agent log
|
||||
postDebugLog({
|
||||
runId: debugRunId,
|
||||
hypothesisId: 'H2',
|
||||
location: 'frontend/src/pages/TestDetail.jsx:inside-useMemo',
|
||||
message: 'Computing hasDraftChanges',
|
||||
data: {
|
||||
hasDraftSnapshotOnLoad: !!draftSnapshotOnLoad,
|
||||
titleLen: (draftTitle || '').length,
|
||||
descriptionLen: (draftDescription || '').length,
|
||||
passing: String(draftPassing || ''),
|
||||
questionsCount: Array.isArray(draftQuestions) ? draftQuestions.length : -1,
|
||||
},
|
||||
});
|
||||
// #endregion
|
||||
if (!draftSnapshotOnLoad) {
|
||||
return false;
|
||||
}
|
||||
const currentSnapshot = buildDraftSnapshot({
|
||||
title: draftTitle,
|
||||
description: draftDescription,
|
||||
passing: draftPassing,
|
||||
questions: draftQuestions,
|
||||
});
|
||||
return currentSnapshot !== draftSnapshotOnLoad;
|
||||
}, [draftDescription, draftPassing, draftQuestions, draftSnapshotOnLoad, draftTitle]);
|
||||
|
||||
return (
|
||||
<div className="test-detail-page test-detail-page--with-fixed-actions">
|
||||
@@ -667,7 +788,7 @@ export default function TestDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chain?.hasAnyAttempt && (
|
||||
{chain?.hasAnyAttempt && hasDraftChanges && (
|
||||
<div className="callout callout--warning" role="status" style={{ marginTop: '0.75rem' }}>
|
||||
При сохранении будет создана новая версия теста.
|
||||
</div>
|
||||
|
||||