Compare commits
51 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
1ea83aa6b4 | 2 weeks ago |
|
|
0229bc250b | 2 weeks ago |
|
|
1494b839f5 | 2 weeks ago |
|
|
9511fcb555 | 2 weeks ago |
|
|
b72b485fce | 2 weeks ago |
|
|
df6e770f90 | 2 weeks ago |
|
|
44366a2865 | 2 weeks ago |
|
|
2b429f0b08 | 2 weeks ago |
|
|
e6b85f3944 | 2 weeks ago |
|
|
db9851eeda | 2 weeks ago |
|
|
ebb58d4b5a | 2 weeks ago |
|
|
c4a7d2ef08 | 2 weeks ago |
|
|
09d996ead0 | 2 weeks ago |
|
|
fba11ff4cc | 2 weeks ago |
|
|
bba96f8f9f | 2 weeks ago |
|
|
eff3fda5b0 | 2 weeks ago |
|
|
1c4dacbc85 | 3 weeks ago |
|
|
c3bdb406d6 | 3 weeks ago |
|
|
47d673496b | 3 weeks ago |
|
|
2d6d75fb3c | 3 weeks ago |
|
|
547840d671 | 3 weeks ago |
|
|
4b0d56ff0e | 3 weeks ago |
|
|
31b51b7768 | 3 weeks ago |
|
|
5cd94c05ad | 3 weeks ago |
|
|
f1f5223076 | 3 weeks ago |
|
|
72a5863871 | 3 weeks ago |
|
|
f7d9cbb1c4 | 3 weeks ago |
|
|
9a85a13e08 | 3 weeks ago |
|
|
3e70f4322d | 3 weeks ago |
|
|
1db3653e66 | 3 weeks ago |
|
|
5db12c2348 | 3 weeks ago |
|
|
2a05f41b65 | 3 weeks ago |
|
|
b3e3757a92 | 3 weeks ago |
|
|
47279c72e3 | 3 weeks ago |
|
|
42b5e9ad44 | 3 weeks ago |
|
|
0fe04d4d99 | 3 weeks ago |
|
|
a68331c86b | 3 weeks ago |
|
|
4801ea9f19 | 3 weeks ago |
|
|
89da5b60b7 | 3 weeks ago |
|
|
8ffd104f64 | 3 weeks ago |
|
|
699277be07 | 3 weeks ago |
|
|
5631d85238 | 3 weeks ago |
|
|
7fa6f98ee1 | 3 weeks ago |
|
|
675555531f | 3 weeks ago |
|
|
c381283ee4 | 3 weeks ago |
|
|
26b5eddefa | 3 weeks ago |
|
|
fcc6fae463 | 3 weeks ago |
|
|
997a71b974 | 3 weeks ago |
|
|
e87168d3a0 | 3 weeks ago |
|
|
4eeb3fbc62 | 3 weeks ago |
|
|
93dcfcf4ff | 3 weeks ago |
@ -0,0 +1,20 @@
|
||||
# Шаг 2026-04-27 — редизайн формы редактора теста (ветка `dev-redisign`) |
||||
|
||||
## Сделано |
||||
|
||||
- Создана ветка `dev-redisign` от `dev` в репозитории `TestingWebApp`. |
||||
- Страница автора `frontend/src/pages/TestDetail.jsx` приведена к структуре из `docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md` (адаптация под существующий React/JSX, без Ant Design): |
||||
- блок **«Метаинформация»** — название, описание, порог зачёта; |
||||
- блок **«Содержание»** — мини-панель ИИ (тема, число вопросов 1…30, число вариантов 2…8, кнопка генерации) и список вопросов с локальными кнопками ИИ; |
||||
- панель **«Команды»** — «Сохранить черновик» (основная), «К списку»; строка статуса черновика под панелью. |
||||
- Кнопка **«Сгенерировать тест (ИИ)»** убрана из шапки; генерация строит `shape` из введённых чисел, тема — из поля «Тема» с запасным вариантом на «Название»; после ответа API варианты в каждом вопросе нормализуются к выбранному числу (добор/обрезка, минимум один верный). |
||||
- Копирование темы при загрузке редактора и при применении импорта/черновика LLM (`setAiGenTopic` при `applyGeneratedDraft`). |
||||
|
||||
## Бэкенд |
||||
|
||||
- Менять не требовалось: `POST .../ai/generate-test` уже принимает `shape` с `optionsCount` (см. `backend/src/services/aiEditorService.js`). |
||||
|
||||
## Проверка |
||||
|
||||
- `npm run lint` и `npm run build` в `TestingWebApp/frontend` — без ошибок. |
||||
- Ручной прогон `docker compose` по чек-листу из предложения — остаётся на стороне исполнителя. |
||||
@ -0,0 +1,5 @@
|
||||
# Шаг 2026-04-27 — спринты мобильного UI и правки |
||||
|
||||
- Документ спринтов: [`docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md`](../../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) (спринт 1 выполнен в коде). |
||||
- Стили: `actions-bar`, `version-card-list`, `list-row__meta-tail`, `inline-actions--block-mobile`, safe-area у `.cabinet-main`, `.btn--sm` / `.btn-ghost`, `assign-list` без пустой «коробки`. |
||||
- Страницы: `TestDetail.jsx` (карточки версий, панель команд, назначение), `TestsList.jsx` (мета-строка). |
||||
@ -0,0 +1,4 @@
|
||||
# Шаг 2026-04-27 — спринт 2 (мобильный UI) |
||||
|
||||
- См. [`docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md`](../../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md): пункты 2.1–2.5 отмечены выполненными. |
||||
- Реализация: `TestDetail.jsx` (прогоны карточками, импорт через label+input, заголовок вопроса, radio/checkbox, фикс-футер), `cabinet-theme.css` (классы спринта 2). |
||||
@ -1,130 +1,200 @@
|
||||
# Система тестирования сотрудников клиники |
||||
|
||||
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. Руководители подразделений и HR-менеджеры создают тесты и назначают их сотрудникам. Система фиксирует все попытки и результаты. |
||||
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. |
||||
Руководители подразделений и HR-менеджеры создают тесты и назначают их |
||||
сотрудникам. Все попытки и результаты сохраняются. |
||||
|
||||
**Версия ТЗ:** 1.2 |
||||
**Дата:** 2026-03-21 |
||||
**Статус:** Согласовано |
||||
- **Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** |
||||
- **Ветка разработки:** `dev` |
||||
- **ТЗ:** v1.2 от 2026-03-21 (статус — согласовано) |
||||
|
||||
--- |
||||
|
||||
## Содержание |
||||
## Стек и состояние |
||||
|
||||
- [Функциональные возможности](#функциональные-возможности) |
||||
- [Роли и права доступа](#роли-и-права-доступа) |
||||
- [Установка и запуск](#установка-и-запуск) |
||||
- [Нефункциональные требования](#нефункциональные-требования) |
||||
- [Вне scope](#вне-scope-не-реализуется-в-данной-версии) |
||||
**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 + |
||||
Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в |
||||
[`flask_app/`](flask_app/). На нём работает и прод, и dev (кабинетный UI, |
||||
порт **:3107** в Docker, см. ниже). |
||||
|
||||
--- |
||||
Старые каталоги `backend/` (Node.js / Express) и `frontend/` |
||||
(React + Vite) — **архив**: не разворачиваются, в `docker-compose.dev.yml` |
||||
поднимается сервис **`testing-flask`**, удаление папок запланировано |
||||
в спринте **E1.6**. Использовать их не надо, миграции и SQL-схема |
||||
сохранены в `backend/src/db/migrations/` исключительно как источник |
||||
структуры БД. |
||||
|
||||
## Функциональные возможности |
||||
БД — **`clinic_tests`** (PostgreSQL, UUID-ключи). В Этапе 1 схема |
||||
не меняется. |
||||
|
||||
### Управление пользователями и подразделениями |
||||
**Этап 2** — слияние с общим HR-кабинетом `HR_TG_Bot/tgFlaskForm` — |
||||
запланирован на будущее, сейчас не делается. План: |
||||
[`docs/migration-to-tgflaskform.md`](docs/migration-to-tgflaskform.md) |
||||
([простыми словами](docs/migration-to-tgflaskform-plain.md)). |
||||
|
||||
- Создание/редактирование/деактивация учётных записей сотрудников |
||||
- Каждый сотрудник принадлежит одному подразделению |
||||
- Создание/редактирование справочника подразделений |
||||
- Назначение роли сотруднику: HR-менеджер / Руководитель подразделения / Сотрудник |
||||
--- |
||||
|
||||
### Создание и редактирование тестов |
||||
## Интерфейс (кабинет) |
||||
|
||||
**Тест содержит:** |
||||
- Название теста |
||||
- Описание (опционально) |
||||
- Список вопросов (минимум 7) |
||||
- Порог зачёта — минимальный % правильных ответов |
||||
- Таймер прохождения — лимит в минутах (опционально) |
||||
Единственный вариант 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` и т.д.). |
||||
|
||||
**Вопрос содержит:** |
||||
- Текст вопроса |
||||
- Минимум 3 варианта ответа |
||||
- Один или несколько правильных ответов |
||||
В [`docker-compose.dev.yml`](docker-compose.dev.yml) один сервис |
||||
**`testing-flask`** (`container_name: testing_webapp_flask`), порт **3107**. |
||||
|
||||
**Настройки теста:** |
||||
- Разрешить возврат к предыдущему вопросу: да / нет |
||||
--- |
||||
|
||||
**Версионирование:** |
||||
- Автор может редактировать тест пока никто его не проходил |
||||
- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется |
||||
- Все версии теста хранятся; результаты привязаны к конкретной версии |
||||
- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию |
||||
- Тест можно деактивировать (скрыть из списка, не удалять) |
||||
## Что уже работает на новом (Flask) контуре |
||||
|
||||
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). |
||||
|
||||
## Что ещё не реализовано |
||||
|
||||
| Спринт | Что включает | |
||||
|---|---| |
||||
| **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). | |
||||
|
||||
### Назначение теста |
||||
--- |
||||
|
||||
- Список получателей (отдел или конкретные сотрудники) |
||||
- Срок сдачи — дата дедлайна |
||||
- Допустимое количество попыток (1 или более) |
||||
## Установка и запуск |
||||
|
||||
### Прохождение теста |
||||
### Предпосылка: общий Postgres |
||||
|
||||
- На главной странице сотрудник видит список назначенных тестов со статусами: |
||||
- `Не начат` — ещё не открывал |
||||
- `В процессе` — начал, не завершил |
||||
- `Завершён` — сдал/не сдал |
||||
- `Просрочен` — дедлайн прошёл, не сдан |
||||
- Если задан таймер — отображается обратный отсчёт, по истечении тест завершается автоматически |
||||
- Порядок вопросов **случайный** при каждом прохождении |
||||
- Возможность вернуться к предыдущему вопросу — определяется настройкой теста |
||||
Используется **тот же** PostgreSQL, что и в |
||||
[Postgres_TG_Bots](../Postgres_TG_Bots) (контейнер `hr_postgres_dev`, |
||||
сеть `hr_postgres_dev_net`, учётка `hr_bot_user`). |
||||
|
||||
### Результаты после завершения теста |
||||
```bash |
||||
# (один раз) создать базу |
||||
psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" \ |
||||
-c "CREATE DATABASE clinic_tests;" |
||||
|
||||
- Итоговый балл и процент правильных ответов |
||||
- Факт зачёта: **сдал / не сдал** |
||||
- Разбор ошибок: по каждому вопросу — его ответ и правильный ответ |
||||
# (один раз) внешняя сеть, если ещё не создана соседом |
||||
docker network create hr_postgres_dev_net || true |
||||
``` |
||||
|
||||
### Трекер попыток |
||||
### Dev-стенд |
||||
|
||||
Единый интерфейс просмотра всех попыток прохождения тестов: |
||||
- Фильтрация по подразделению, сотруднику, тесту, статусу, результату |
||||
- Пагинация и сортировка |
||||
```bash |
||||
docker compose -f docker-compose.dev.yml up -d --build |
||||
``` |
||||
|
||||
### AI-помощник |
||||
| Что | URL | |
||||
|---|---| |
||||
| Приложение (Flask) | <http://localhost:3107> | |
||||
| Health-check | <http://localhost:3107/health> | |
||||
|
||||
Интеграция с LLM для помощи при создании тестов: |
||||
`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` репозитория. |
||||
|
||||
| Функция | Описание | |
||||
|---------|----------| |
||||
| Генерация теста | AI генерирует готовый набор вопросов с вариантами ответов по теме | |
||||
| Улучшение формулировки | AI переформулирует выбранный вопрос более чётко | |
||||
| Добавление дистракторов | AI генерирует правдоподобные неправильные варианты ответов | |
||||
| Проверка качества | AI анализирует весь тест и выдаёт рекомендации | |
||||
### Локально без Docker |
||||
|
||||
--- |
||||
См. [`flask_app/README.md`](flask_app/README.md) — `venv` + |
||||
`pip install -r requirements.txt` + `python run.py`. |
||||
|
||||
## Роли и права доступа |
||||
--- |
||||
|
||||
| Роль | Кто | Создаёт тесты | Назначает тесты | Видит результаты | |
||||
|------|-----|:---:|:---:|:---:| |
||||
| **HR-менеджер** | Руководитель службы HR, Директор клиники | ✅ | Всем сотрудникам клиники | Всех сотрудников | |
||||
| **Руководитель подразделения** | Главный врач, рук. службы администраторов | ✅ | Только своему подразделению | Только своего подразделения | |
||||
| **Сотрудник** | Все остальные работники | ❌ | ❌ | Только свои | |
||||
## Данные и интеграция с 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) | Исходное ТЗ заказчика. | |
||||
|
||||
@ -0,0 +1,46 @@
|
||||
# --- Рекомендуемый вариант: ОБЩИЙ кластер (Postgres_TG_Bots) --- |
||||
# Скопируйте в backend/.env и задайте минимум DATABASE_URL + JWT_SECRET. |
||||
# Не оставляйте в .env устаревший DB_PORT=5433, если пользуетесь 5432 — иначе, |
||||
# при отсутствии/ошибке в DATABASE_URL пул уйдёт на DB_* и снова «не туда». |
||||
# |
||||
# Как в HR_TG_Bot: тот же Postgres (Postgres_TG_Bots/docker-compose.dev.yml), |
||||
# отдельная база clinic_tests (не путать с hr_bot_test). |
||||
# Локально (порт 5432, как в Postgres_TG_Bots на хосте): |
||||
# |
||||
# Backend в Docker рядом с HR: хост — container_name Postgres, порт 5432 внутри сети: |
||||
# DATABASE_URL=postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests |
||||
# |
||||
# Базу clinic_tests создают один раз (от суперпользователя контейнера): |
||||
# psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;" |
||||
# |
||||
# Если DATABASE_URL НЕ задан, берутся DB_* (fallback). Для общего кластера задавайте DATABASE_URL. |
||||
# DB_HOST=localhost |
||||
# DB_PORT=5432 |
||||
# DB_NAME=clinic_tests |
||||
# DB_USER=developer |
||||
# DB_PASSWORD=dev_password |
||||
|
||||
DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests |
||||
|
||||
JWT_SECRET=change_me_in_production |
||||
|
||||
# Порт HTTP API (как в docker-compose: 3001) |
||||
# PORT=3001 |
||||
|
||||
# A.1: HR login (Werkzeug password, staff by web_login = username в public.users) |
||||
# В Docker (docker-compose.dev.yml) по умолчанию HR_AUTH=1 и HR_DATABASE_URL на hr_bot_test. |
||||
# HR_AUTH=1 |
||||
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test |
||||
|
||||
# V.8: API/UI назначения (POST /api/tests/:id/assign, каталог в карточке). В NODE_ENV=development |
||||
# включено без этого флага. В production: CLINIC_ASSIGNMENT_ENABLED=1 |
||||
# CLINIC_ASSIGNMENT_ENABLED=1 |
||||
|
||||
# D.3 — генерация черновика из импорта (POST /api/tests/import/document), OpenAI-совместимый API |
||||
# DEEPSEEK_API_KEY= → по умолчанию https://api.deepseek.com/v1, модель deepseek-chat |
||||
# OPENAI_API_KEY= → https://api.openai.com/v1, модель gpt-4o-mini (если нет ключа DeepSeek) |
||||
# LLM_BASE_URL= → переопределить (без /chat/completions) |
||||
# LLM_MODEL= |
||||
# LLM_NO_JSON=1 → убрать response_format, если API не принимает json_object |
||||
# DEEPSEEK_API_KEY= |
||||
# OPENAI_API_KEY= |
||||
@ -0,0 +1,15 @@
|
||||
{ |
||||
"env": { |
||||
"es2022": true, |
||||
"node": true |
||||
}, |
||||
"parserOptions": { |
||||
"ecmaVersion": "latest", |
||||
"sourceType": "module" |
||||
}, |
||||
"extends": ["eslint:recommended"], |
||||
"rules": { |
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], |
||||
"no-console": "warn" |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
|
||||
{ |
||||
"semi": true, |
||||
"singleQuote": true, |
||||
"tabWidth": 2, |
||||
"trailingComma": "es5", |
||||
"printWidth": 100 |
||||
} |
||||
@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine |
||||
WORKDIR /app |
||||
COPY package.json package-lock.json* ./ |
||||
RUN npm ci |
||||
COPY . . |
||||
EXPOSE 3001 |
||||
RUN chmod +x docker-entrypoint.sh |
||||
ENTRYPOINT ["./docker-entrypoint.sh"] |
||||
@ -0,0 +1,54 @@
|
||||
# Progress — миграция `001_initial` (историческая заметка) |
||||
|
||||
*Актуальное описание продукта и сценариев: [../docs/PROJECT_STATUS.md](../docs/PROJECT_STATUS.md).* |
||||
|
||||
# Progress - Шаг 2: Проектирование базы данных |
||||
|
||||
## Статус: ✅ ЗАВЕРШЕНО |
||||
|
||||
### Выполненные задачи: |
||||
|
||||
1. ✅ **Создание SQL-миграции** (`backend/src/db/migrations/001_initial.sql`) |
||||
- Созданы все таблицы: |
||||
- `departments` (Подразделения) |
||||
- `users` (Пользователи) |
||||
- `tests` (Тесты) |
||||
- `test_versions` (Версии тестов) |
||||
- `questions` (Вопросы) |
||||
- `answer_options` (Варианты ответов) |
||||
- `test_assignments` (Назначения тестов) |
||||
- `test_assignment_targets` (Получатели назначений) |
||||
- `test_attempts` (Попытки прохождения) |
||||
- `user_answers` (Ответы пользователя) |
||||
- `settings` (Настройки) |
||||
- Созданы ENUM типы: `user_role`, `target_type`, `attempt_status` |
||||
- Созданы индексы для оптимизации запросов |
||||
- Добавлены начальные данные в таблицу `settings` |
||||
|
||||
2. ✅ **Создание скрипта миграции** (`backend/src/db/migrate.js`) |
||||
- Поддержка выполнения SQL-миграций |
||||
- Отслеживание выполненных миграций в таблице `migrations` |
||||
- Транзакционное выполнение миграций |
||||
- Логирование процесса выполнения |
||||
|
||||
3. ✅ **Создание db.js** (`backend/src/db/db.js`) |
||||
- Подключение к PostgreSQL с использованием пула соединений |
||||
- Функции: `query()`, `transaction()`, `getClient()` |
||||
- Обработка ошибок пула |
||||
- Логирование запросов в режиме разработки |
||||
|
||||
4. ✅ **Применение миграций к БД** |
||||
- Миграция `001_initial.sql` успешно выполнена |
||||
- Все таблицы созданы в базе данных `clinic_tests` |
||||
|
||||
### Созданные файлы: |
||||
|
||||
``` |
||||
backend/src/db/ |
||||
├── migrations/ |
||||
│ └── 001_initial.sql # SQL-миграция с созданием всех таблиц |
||||
├── migrate.js # Скрипт для выполнения миграций |
||||
└── db.js # Модуль подключения к PostgreSQL |
||||
``` |
||||
|
||||
### Дата выполнения: 2026-03-21 |
||||
@ -0,0 +1,6 @@
|
||||
#!/bin/sh |
||||
set -e |
||||
echo "Running database migrations…" |
||||
node src/db/migrate.js |
||||
echo "Starting API…" |
||||
exec node src/server.js |
||||
@ -0,0 +1,37 @@
|
||||
{ |
||||
"name": "clinic-tests-backend", |
||||
"version": "1.0.0", |
||||
"description": "Backend for Clinic Tests application", |
||||
"main": "src/server.js", |
||||
"type": "module", |
||||
"scripts": { |
||||
"start": "node src/server.js", |
||||
"dev": "NODE_ENV=development node --watch src/server.js", |
||||
"test": "node --test 'src/**/*.test.js'", |
||||
"test:integration": "CLINIC_TESTS_INTEGRATION=1 node --test 'src/**/*.test.js'", |
||||
"migrate": "node src/db/migrate.js", |
||||
"lint": "eslint src/", |
||||
"lint:fix": "eslint src/ --fix", |
||||
"format": "prettier --write src/" |
||||
}, |
||||
"keywords": [], |
||||
"author": "", |
||||
"license": "ISC", |
||||
"dependencies": { |
||||
"bcryptjs": "^3.0.3", |
||||
"cookie-parser": "^1.4.7", |
||||
"cors": "^2.8.5", |
||||
"dotenv": "^16.4.5", |
||||
"express": "^4.21.0", |
||||
"jsonwebtoken": "^9.0.2", |
||||
"mammoth": "^1.12.0", |
||||
"multer": "^1.4.5-lts.1", |
||||
"pdf-parse": "^2.4.5", |
||||
"pg": "^8.12.0" |
||||
}, |
||||
"devDependencies": { |
||||
"eslint": "^8.57.0", |
||||
"prettier": "^3.3.3", |
||||
"supertest": "^7.2.2" |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@
|
||||
/** |
||||
* V.9 — минимальные проверки HTTP без БД: health и 401 на защищённых маршрутах. |
||||
* Интеграции с Postgres — см. отдельные сценарии / ручной журнал. |
||||
*/ |
||||
import { test } from 'node:test'; |
||||
import assert from 'node:assert/strict'; |
||||
import request from 'supertest'; |
||||
import { createApp } from './app.js'; |
||||
import { RU } from './messages/ru.js'; |
||||
|
||||
const app = createApp(); |
||||
|
||||
test('GET /api/health — 200 и status ok', async () => { |
||||
const res = await request(app).get('/api/health').expect(200); |
||||
assert.equal(res.body.status, 'ok'); |
||||
}); |
||||
|
||||
test('GET /api/tests без cookie — 401', async () => { |
||||
const res = await request(app).get('/api/tests').expect(401); |
||||
assert.equal(res.body.error, RU.authRequired); |
||||
}); |
||||
|
||||
test('GET /api/__no_route__ — 404 на русском', async () => { |
||||
const res = await request(app).get('/api/__no_route__').expect(404); |
||||
assert.equal(res.body.error, RU.notFound); |
||||
}); |
||||
@ -0,0 +1,49 @@
|
||||
import express from 'express'; |
||||
import cors from 'cors'; |
||||
import cookieParser from 'cookie-parser'; |
||||
import dotenv from 'dotenv'; |
||||
import authRoutes from './routes/auth.js'; |
||||
import testsRoutes from './routes/tests.js'; |
||||
import { RU } from './messages/ru.js'; |
||||
|
||||
dotenv.config(); |
||||
|
||||
export function createApp() { |
||||
const app = express(); |
||||
const corsOrigins = |
||||
process.env.NODE_ENV === 'production' |
||||
? process.env.FRONTEND_URL |
||||
? [process.env.FRONTEND_URL] |
||||
: [] |
||||
: [ |
||||
'http://localhost:3107', |
||||
'http://localhost:3000', |
||||
]; |
||||
app.use( |
||||
cors({ |
||||
origin: corsOrigins.length ? corsOrigins : true, |
||||
credentials: true, |
||||
}) |
||||
); |
||||
app.use(express.json()); |
||||
app.use(cookieParser()); |
||||
app.use('/api/auth', authRoutes); |
||||
app.use('/api/tests', testsRoutes); |
||||
app.get('/api/health', (req, res) => { |
||||
res.json({ |
||||
status: 'ok', |
||||
timestamp: new Date().toISOString(), |
||||
message: 'Server is running', |
||||
}); |
||||
}); |
||||
app.use((err, req, res, _next) => { |
||||
console.error('Error:', err); |
||||
res.status(err.status || 500).json({ |
||||
error: err.message || RU.internal, |
||||
}); |
||||
}); |
||||
app.use((req, res) => { |
||||
res.status(404).json({ error: RU.notFound }); |
||||
}); |
||||
return app; |
||||
} |
||||
@ -0,0 +1,7 @@
|
||||
/** Пароль-заглушка: вход только через HR, локальный compare не пройдёт. */ |
||||
export const HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$'; |
||||
|
||||
/** HR login enabled (1 = Werkzeug + upsert user по staff_id) */ |
||||
export function isHrAuthEnabled() { |
||||
return process.env.HR_AUTH === '1' || process.env.HR_AUTH === 'true'; |
||||
} |
||||
@ -0,0 +1,6 @@
|
||||
/** |
||||
* Правка цепочки теста (черновик, версии, публикация, редактор) — только создатель (`tests.created_by`). |
||||
*/ |
||||
export function isTestAuthor(createdBy, userId) { |
||||
return createdBy === userId; |
||||
} |
||||
@ -0,0 +1,18 @@
|
||||
/** |
||||
* Флаги продуктовых фич (env). В development ряд вещей включён по умолчанию. |
||||
*/ |
||||
|
||||
/** API и UI: назначение тестов сотрудникам (каталог HR + POST /tests/:id/assign). */ |
||||
export function isAssignmentFeatureEnabled() { |
||||
if (process.env.NODE_ENV === 'development') { |
||||
return true; |
||||
} |
||||
const v = (process.env.CLINIC_ASSIGNMENT_ENABLED || '').toLowerCase(); |
||||
if (v === '1' || v === 'true' || v === 'yes') { |
||||
return true; |
||||
} |
||||
if (v === '0' || v === 'false' || v === 'no') { |
||||
return false; |
||||
} |
||||
return false; |
||||
} |
||||
@ -0,0 +1,100 @@
|
||||
/** |
||||
* Database Connection Module |
||||
* PostgreSQL connection pool and utility functions |
||||
*/ |
||||
|
||||
import pg from 'pg'; |
||||
import { getPoolConfig } from './poolConfig.js'; |
||||
|
||||
const { Pool } = pg; |
||||
|
||||
const pool = new Pool(getPoolConfig()); |
||||
|
||||
// Handle pool errors
|
||||
pool.on('error', (err) => { |
||||
console.error('Unexpected pool error:', err.message); |
||||
}); |
||||
|
||||
/** |
||||
* Execute a query with the connection pool |
||||
* @param {string} text - SQL query text |
||||
* @param {Array} params - Query parameters |
||||
* @returns {Promise<pg.QueryResult>} Query result |
||||
*/ |
||||
export async function query(text, params) { |
||||
const start = Date.now(); |
||||
const result = await pool.query(text, params); |
||||
const duration = Date.now() - start; |
||||
|
||||
if (process.env.NODE_ENV === 'development') { |
||||
console.log('Executed query:', { text: text.substring(0, 50), duration, rows: result.rowCount }); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
/** |
||||
* Execute a query with automatic client release |
||||
* @param {string} text - SQL query text |
||||
* @param {Array} params - Query parameters |
||||
* @returns {Promise<pg.QueryResult>} Query result |
||||
*/ |
||||
export async function queryWithClient(text, params) { |
||||
const client = await pool.connect(); |
||||
try { |
||||
return await client.query(text, params); |
||||
} finally { |
||||
client.release(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Execute a transaction |
||||
* @param {Function} callback - Async function receiving client as parameter |
||||
* @returns {Promise<any>} Transaction result |
||||
*/ |
||||
export async function transaction(callback) { |
||||
const client = await pool.connect(); |
||||
try { |
||||
await client.query('BEGIN'); |
||||
const result = await callback(client); |
||||
await client.query('COMMIT'); |
||||
return result; |
||||
} catch (error) { |
||||
await client.query('ROLLBACK'); |
||||
throw error; |
||||
} finally { |
||||
client.release(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get a client from the pool |
||||
* @returns {Promise<pg.PoolClient>} Pool client |
||||
*/ |
||||
export async function getClient() { |
||||
return pool.connect(); |
||||
} |
||||
|
||||
/** |
||||
* Get pool status information |
||||
* @returns {Object} Pool statistics |
||||
*/ |
||||
export function getPoolStatus() { |
||||
return { |
||||
totalCount: pool.totalCount, |
||||
idleCount: pool.idleCount, |
||||
waitingCount: pool.waitingCount, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Close the connection pool |
||||
* @returns {Promise<void>} |
||||
*/ |
||||
export async function closePool() { |
||||
await pool.end(); |
||||
} |
||||
|
||||
// Default export the pool for direct access if needed
|
||||
export default pool; |
||||
@ -0,0 +1,27 @@
|
||||
/** |
||||
* Read-only (по соглашению) пул к hr_bot_test для логина и справки по сотруднику. |
||||
*/ |
||||
import pg from 'pg'; |
||||
import { getHrPoolConfig } from './poolConfig.js'; |
||||
|
||||
const { Pool } = pg; |
||||
|
||||
const cfg = getHrPoolConfig(); |
||||
const pool = cfg ? new Pool(cfg) : null; |
||||
|
||||
export function getHrPool() { |
||||
return pool; |
||||
} |
||||
|
||||
/** |
||||
* @param {string} text |
||||
* @param {unknown[]} [params] |
||||
*/ |
||||
export async function queryHr(text, params) { |
||||
if (!pool) { |
||||
throw new Error('HR database not configured (set HR_DATABASE_URL)'); |
||||
} |
||||
return pool.query(text, params); |
||||
} |
||||
|
||||
export default { getHrPool, queryHr }; |
||||
@ -0,0 +1,147 @@
|
||||
/** |
||||
* Database Migration Script |
||||
* Executes SQL migration files in order |
||||
*/ |
||||
|
||||
import { readFileSync, readdirSync } from 'fs'; |
||||
import { join } from 'path'; |
||||
import pg from 'pg'; |
||||
import { getPoolConfig } from './poolConfig.js'; |
||||
|
||||
const { Pool } = pg; |
||||
|
||||
const MIGRATIONS_DIR = join(process.cwd(), 'src', 'db', 'migrations'); |
||||
|
||||
/** |
||||
* Get list of migration files sorted by name |
||||
*/ |
||||
function getMigrationFiles() { |
||||
const files = readdirSync(MIGRATIONS_DIR) |
||||
.filter((file) => file.endsWith('.sql')) |
||||
.sort(); |
||||
|
||||
return files; |
||||
} |
||||
|
||||
/** |
||||
* Create migrations tracking table if not exists |
||||
*/ |
||||
async function ensureMigrationsTable(pool) { |
||||
await pool.query(` |
||||
CREATE TABLE IF NOT EXISTS migrations ( |
||||
id SERIAL PRIMARY KEY, |
||||
name VARCHAR(255) NOT NULL UNIQUE, |
||||
executed_at TIMESTAMP DEFAULT NOW() |
||||
) |
||||
`);
|
||||
} |
||||
|
||||
/** |
||||
* Get list of already executed migrations |
||||
*/ |
||||
async function getExecutedMigrations(pool) { |
||||
const result = await pool.query('SELECT name FROM migrations ORDER BY name'); |
||||
return result.rows.map((row) => row.name); |
||||
} |
||||
|
||||
/** |
||||
* Execute a single migration file |
||||
*/ |
||||
async function executeMigration(pool, filename) { |
||||
const filePath = join(MIGRATIONS_DIR, filename); |
||||
const sql = readFileSync(filePath, 'utf-8'); |
||||
|
||||
console.log(`Executing migration: ${filename}`); |
||||
|
||||
await pool.query('BEGIN'); |
||||
try { |
||||
await pool.query(sql); |
||||
await pool.query( |
||||
'INSERT INTO migrations (name) VALUES ($1)', |
||||
[filename] |
||||
); |
||||
await pool.query('COMMIT'); |
||||
console.log(`✓ Migration ${filename} completed successfully`); |
||||
} catch (error) { |
||||
await pool.query('ROLLBACK'); |
||||
console.error(`✗ Migration ${filename} failed:`, error.message); |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Main migration function |
||||
*/ |
||||
function logMigrationError(error) { |
||||
const msg = error?.message || String(error); |
||||
console.error('\n✗ Migration failed:', msg || '(no message)'); |
||||
if (error?.code) { |
||||
console.error(' PG code:', error.code); |
||||
} |
||||
if (error?.address && error?.port) { |
||||
console.error(' connect:', `${error.address}:${error.port}`); |
||||
} |
||||
if (error?.name === 'AggregateError' && Array.isArray(error.errors)) { |
||||
for (const e of error.errors) { |
||||
console.error(' —', e?.message || e); |
||||
} |
||||
} |
||||
if (error?.code === 'ECONNREFUSED') { |
||||
console.error( |
||||
' hint: проверьте, что Postgres запущен и DATABASE_URL / DB_* в backend/.env совпадают с портом (часто 5432 для Postgres_TG_Bots или 5433 для локального compose).' |
||||
); |
||||
} |
||||
if (process.env.DEBUG_MIGRATE === '1' || !msg) { |
||||
console.error(error); |
||||
} |
||||
} |
||||
|
||||
async function migrate() { |
||||
const pool = new Pool(getPoolConfig()); |
||||
|
||||
try { |
||||
console.log('Connecting to database...'); |
||||
const client = await pool.connect(); |
||||
try { |
||||
await client.query('SELECT 1'); |
||||
} finally { |
||||
client.release(); |
||||
} |
||||
console.log('Connected to database\n'); |
||||
|
||||
// Ensure migrations table exists
|
||||
await ensureMigrationsTable(pool); |
||||
|
||||
// Get migration files and already executed migrations
|
||||
const migrationFiles = getMigrationFiles(); |
||||
const executedMigrations = await getExecutedMigrations(pool); |
||||
|
||||
console.log(`Found ${migrationFiles.length} migration file(s)`); |
||||
console.log(`Already executed: ${executedMigrations.length} migration(s)\n`); |
||||
|
||||
// Execute pending migrations
|
||||
const pendingMigrations = migrationFiles.filter( |
||||
(file) => !executedMigrations.includes(file) |
||||
); |
||||
|
||||
if (pendingMigrations.length === 0) { |
||||
console.log('All migrations already executed.'); |
||||
} else { |
||||
console.log(`Pending migrations: ${pendingMigrations.length}\n`); |
||||
|
||||
for (const filename of pendingMigrations) { |
||||
await executeMigration(pool, filename); |
||||
} |
||||
|
||||
console.log(`\n✓ Successfully executed ${pendingMigrations.length} migration(s)`); |
||||
} |
||||
} catch (error) { |
||||
logMigrationError(error); |
||||
process.exit(1); |
||||
} finally { |
||||
await pool.end(); |
||||
} |
||||
} |
||||
|
||||
// Run migrations if this script is executed directly
|
||||
migrate(); |
||||
@ -0,0 +1,130 @@
|
||||
-- Initial database schema for clinic tests application |
||||
-- Version: 1.0 |
||||
|
||||
-- Enable UUID extension |
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; |
||||
|
||||
-- Departments table |
||||
CREATE TABLE IF NOT EXISTS departments ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
name VARCHAR(255) NOT NULL, |
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
||||
); |
||||
|
||||
-- User roles enum |
||||
CREATE TYPE user_role AS ENUM ('hr', 'manager', 'employee'); |
||||
|
||||
-- Users table |
||||
CREATE TABLE IF NOT EXISTS users ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
login VARCHAR(100) UNIQUE NOT NULL, |
||||
password_hash VARCHAR(255) NOT NULL, |
||||
full_name VARCHAR(255) NOT NULL, |
||||
role user_role NOT NULL DEFAULT 'employee', |
||||
department_id UUID REFERENCES departments(id), |
||||
is_active BOOLEAN DEFAULT true, |
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
||||
); |
||||
|
||||
-- Create index for login lookup |
||||
CREATE INDEX IF NOT EXISTS idx_users_login ON users(login); |
||||
CREATE INDEX IF NOT EXISTS idx_users_department ON users(department_id); |
||||
|
||||
-- Tests table |
||||
CREATE TABLE IF NOT EXISTS tests ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
title VARCHAR(255) NOT NULL, |
||||
description TEXT, |
||||
passing_threshold INTEGER DEFAULT 70, |
||||
time_limit INTEGER, |
||||
allow_back BOOLEAN DEFAULT true, |
||||
is_active BOOLEAN DEFAULT true, |
||||
is_versioned BOOLEAN DEFAULT false, |
||||
created_by UUID REFERENCES users(id), |
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
||||
); |
||||
|
||||
-- Test versions table |
||||
CREATE TABLE IF NOT EXISTS test_versions ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
test_id UUID REFERENCES tests(id) ON DELETE CASCADE, |
||||
version INTEGER NOT NULL DEFAULT 1, |
||||
is_active BOOLEAN DEFAULT false, |
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
UNIQUE(test_id, version) |
||||
); |
||||
|
||||
-- Questions table |
||||
CREATE TABLE IF NOT EXISTS questions ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE, |
||||
text TEXT NOT NULL, |
||||
question_order INTEGER NOT NULL, |
||||
has_multiple_answers BOOLEAN DEFAULT false |
||||
); |
||||
|
||||
-- Answer options table |
||||
CREATE TABLE IF NOT EXISTS answer_options ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
question_id UUID REFERENCES questions(id) ON DELETE CASCADE, |
||||
text TEXT NOT NULL, |
||||
is_correct BOOLEAN DEFAULT false, |
||||
option_order INTEGER NOT NULL |
||||
); |
||||
|
||||
-- Test assignments table |
||||
CREATE TABLE IF NOT EXISTS test_assignments ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE, |
||||
assigned_by UUID REFERENCES users(id), |
||||
deadline DATE, |
||||
max_attempts INTEGER DEFAULT 1, |
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
||||
); |
||||
|
||||
-- Assignment targets table |
||||
CREATE TYPE target_type AS ENUM ('department', 'user'); |
||||
|
||||
CREATE TABLE IF NOT EXISTS test_assignment_targets ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
assignment_id UUID REFERENCES test_assignments(id) ON DELETE CASCADE, |
||||
target_type target_type NOT NULL, |
||||
target_id UUID NOT NULL |
||||
); |
||||
|
||||
-- Test attempts table |
||||
CREATE TYPE attempt_status AS ENUM ('in_progress', 'completed', 'expired'); |
||||
|
||||
CREATE TABLE IF NOT EXISTS test_attempts ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE, |
||||
user_id UUID REFERENCES users(id), |
||||
attempt_number INTEGER NOT NULL DEFAULT 1, |
||||
status attempt_status DEFAULT 'in_progress', |
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
completed_at TIMESTAMP, |
||||
correct_count INTEGER DEFAULT 0, |
||||
total_questions INTEGER DEFAULT 0, |
||||
passed BOOLEAN DEFAULT false, |
||||
UNIQUE(test_version_id, user_id, attempt_number) |
||||
); |
||||
|
||||
-- User answers table |
||||
CREATE TABLE IF NOT EXISTS user_answers ( |
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), |
||||
attempt_id UUID REFERENCES test_attempts(id) ON DELETE CASCADE, |
||||
question_id UUID REFERENCES questions(id) ON DELETE CASCADE, |
||||
selected_options UUID[] DEFAULT '{}' |
||||
); |
||||
|
||||
-- Settings table |
||||
CREATE TABLE IF NOT EXISTS settings ( |
||||
key VARCHAR(100) PRIMARY KEY, |
||||
value TEXT |
||||
); |
||||
|
||||
-- Insert default admin user (password: admin123) |
||||
-- This will be done via application code to properly hash the password |
||||
@ -0,0 +1,14 @@
|
||||
-- Version chain: parent link + at most one active version per test chain |
||||
-- Aligns with docs/revision_task/card1.md (V.1) |
||||
|
||||
ALTER TABLE test_versions |
||||
ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES test_versions(id) ON DELETE RESTRICT; |
||||
|
||||
COMMENT ON COLUMN test_versions.parent_id IS 'Previous version in chain; NULL for first version'; |
||||
|
||||
-- Only one active version per tests.id (chain) |
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_test_versions_one_active_per_test |
||||
ON test_versions (test_id) |
||||
WHERE is_active = true; |
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_test_versions_parent_id ON test_versions (parent_id); |
||||
@ -0,0 +1,7 @@
|
||||
-- Связь пользователя клиник-теста с сотрудником HR (staff_members.id) — card1 A.x |
||||
ALTER TABLE users |
||||
ADD COLUMN IF NOT EXISTS staff_id INTEGER UNIQUE; |
||||
|
||||
COMMENT ON COLUMN users.staff_id IS 'id из hr_bot_test.staff_members; без дублирования кадров в clinic_tests'; |
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_staff_id ON users(staff_id) WHERE staff_id IS NOT NULL; |
||||
@ -0,0 +1,65 @@
|
||||
/** |
||||
* Параметры пула node-postgres, единообразно с HR_TG_Bot / Postgres_TG_Bots: |
||||
* приоритет у `DATABASE_URL` (postgresql://…), иначе DB_HOST, DB_PORT, DB_NAME, …
|
||||
*/ |
||||
|
||||
import dotenv from 'dotenv'; |
||||
|
||||
dotenv.config(); |
||||
|
||||
/** |
||||
* @param {import('pg').PoolConfig} [overrides] |
||||
* @returns {import('pg').PoolConfig} |
||||
*/ |
||||
export function getPoolConfig(overrides = {}) { |
||||
const url = process.env.DATABASE_URL?.trim(); |
||||
if (url) { |
||||
return { |
||||
connectionString: url, |
||||
max: parseInt(process.env.DB_POOL_MAX || '20', 10), |
||||
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10), |
||||
connectionTimeoutMillis: parseInt( |
||||
process.env.DB_CONNECTION_TIMEOUT || '2000', |
||||
10 |
||||
), |
||||
...overrides, |
||||
}; |
||||
} |
||||
return { |
||||
host: process.env.DB_HOST || 'localhost', |
||||
port: parseInt(process.env.DB_PORT || '5432', 10), |
||||
database: process.env.DB_NAME || 'clinic_tests', |
||||
user: process.env.DB_USER || 'developer', |
||||
password: process.env.DB_PASSWORD || 'dev_password', |
||||
max: parseInt(process.env.DB_POOL_MAX || '20', 10), |
||||
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10), |
||||
connectionTimeoutMillis: parseInt( |
||||
process.env.DB_CONNECTION_TIMEOUT || '2000', |
||||
10 |
||||
), |
||||
...overrides, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Пул к БД `hr_bot_test` (сотрудники, users, RBAC) — отдельно от `clinic_tests`. |
||||
* Только `HR_DATABASE_URL` (без каскадного fallback на `DATABASE_URL` — путаница опасна). |
||||
* @param {import('pg').PoolConfig} [overrides] |
||||
* @returns {import('pg').PoolConfig | null} |
||||
*/ |
||||
export function getHrPoolConfig(overrides = {}) { |
||||
const url = process.env.HR_DATABASE_URL?.trim(); |
||||
if (!url) { |
||||
return null; |
||||
} |
||||
return { |
||||
connectionString: url, |
||||
max: parseInt(process.env.HR_DB_POOL_MAX || '5', 10), |
||||
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10), |
||||
connectionTimeoutMillis: parseInt( |
||||
process.env.DB_CONNECTION_TIMEOUT || '2000', |
||||
10 |
||||
), |
||||
...overrides, |
||||
}; |
||||
} |
||||
@ -0,0 +1,234 @@
|
||||
/** |
||||
* Card1 V.9: интеграция с реальной `clinic_tests` — старая попытка остаётся |
||||
* на снимке версии и старых `question_id` после форка (новая версия). |
||||
* |
||||
* Запуск: `CLINIC_TESTS_INTEGRATION=1` и применённые миграции (`npm run migrate`), |
||||
* `DATABASE_URL` (или DB_*) к той же базе. Без флага тесты помечаются skip. |
||||
*/ |
||||
import { test } from 'node:test'; |
||||
import assert from 'node:assert/strict'; |
||||
import pg from 'pg'; |
||||
import bcrypt from 'bcryptjs'; |
||||
import { getPoolConfig } from '../db/poolConfig.js'; |
||||
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js'; |
||||
|
||||
const { Pool } = pg; |
||||
|
||||
/** `CLINIC_TESTS_INTEGRATION=1` и успешный `SELECT 1` (без БД — skip, не fail). */ |
||||
let runDb = false; |
||||
if (process.env.CLINIC_TESTS_INTEGRATION === '1') { |
||||
const probe = new Pool({ |
||||
...getPoolConfig(), |
||||
connectionTimeoutMillis: 2000, |
||||
}); |
||||
try { |
||||
await probe.query('SELECT 1'); |
||||
runDb = true; |
||||
} catch { |
||||
runDb = false; |
||||
} finally { |
||||
await probe.end(); |
||||
} |
||||
} |
||||
|
||||
const qPayload = (label) => ({ |
||||
title: 'V9 ' + label, |
||||
questions: [ |
||||
{ |
||||
text: `Q ${label}`, |
||||
question_order: 1, |
||||
hasMultipleAnswers: false, |
||||
options: [ |
||||
{ text: 'yes', isCorrect: true, option_order: 1 }, |
||||
{ text: 'no', isCorrect: false, option_order: 2 }, |
||||
], |
||||
}, |
||||
], |
||||
}); |
||||
|
||||
/** |
||||
* @param {import('pg').Pool} pool |
||||
* @param {string} testId |
||||
* @param {string} [exceptUserId] |
||||
*/ |
||||
async function purgeTestChain(pool, testId, exceptUserId) { |
||||
await pool.query( |
||||
`DELETE FROM user_answers WHERE attempt_id IN (
|
||||
SELECT id FROM test_attempts WHERE test_version_id IN ( |
||||
SELECT id FROM test_versions WHERE test_id = $1 |
||||
) |
||||
)`,
|
||||
[testId] |
||||
); |
||||
await pool.query( |
||||
`DELETE FROM test_attempts WHERE test_version_id IN (
|
||||
SELECT id FROM test_versions WHERE test_id = $1 |
||||
)`,
|
||||
[testId] |
||||
); |
||||
await pool.query( |
||||
`DELETE FROM answer_options WHERE question_id IN (
|
||||
SELECT id FROM questions WHERE test_version_id IN ( |
||||
SELECT id FROM test_versions WHERE test_id = $1 |
||||
) |
||||
)`,
|
||||
[testId] |
||||
); |
||||
await pool.query( |
||||
`DELETE FROM questions WHERE test_version_id IN (
|
||||
SELECT id FROM test_versions WHERE test_id = $1 |
||||
)`,
|
||||
[testId] |
||||
); |
||||
await pool.query(`DELETE FROM test_versions WHERE test_id = $1`, [testId]); |
||||
await pool.query(`DELETE FROM tests WHERE id = $1`, [testId]); |
||||
if (exceptUserId) { |
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [exceptUserId]); |
||||
} |
||||
} |
||||
|
||||
test( |
||||
'V.9: без попыток два saveTestDraft — одна строка test_versions (редактирование на месте)', |
||||
{ skip: !runDb }, |
||||
async () => { |
||||
const pool = new Pool(getPoolConfig()); |
||||
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; |
||||
let userId; |
||||
let testId; |
||||
try { |
||||
const { rows: u } = await pool.query( |
||||
`INSERT INTO users (login, password_hash, full_name, role, is_active)
|
||||
VALUES ($1, $2, 'V9 in-place', 'hr', true) RETURNING id`,
|
||||
[`v9p-${suffix}`, bcrypt.hashSync('x', 4)] |
||||
); |
||||
userId = u[0].id; |
||||
const c = await createTestWithVersion(pool, userId, { title: 'V9P' }); |
||||
testId = c.testId; |
||||
const { rows: v0 } = await pool.query( |
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||
[testId] |
||||
); |
||||
const vid0 = v0[0].id; |
||||
await saveTestDraft(pool, userId, testId, qPayload('A')); |
||||
const { rows: c1 } = await pool.query( |
||||
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`, |
||||
[testId] |
||||
); |
||||
assert.equal(c1[0].n, 1, 'должна остаться одна версия'); |
||||
const { rows: v1 } = await pool.query( |
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||
[testId] |
||||
); |
||||
assert.equal( |
||||
v1[0].id, |
||||
vid0, |
||||
'id активной версии не меняется при нуле попыток' |
||||
); |
||||
await saveTestDraft(pool, userId, testId, qPayload('B')); |
||||
const { rows: c2 } = await pool.query( |
||||
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`, |
||||
[testId] |
||||
); |
||||
assert.equal(c2[0].n, 1); |
||||
const { rows: v2 } = await pool.query( |
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||
[testId] |
||||
); |
||||
assert.equal(v2[0].id, vid0); |
||||
} finally { |
||||
if (userId && testId) { |
||||
await purgeTestChain(pool, testId, userId); |
||||
} |
||||
await pool.end(); |
||||
} |
||||
} |
||||
); |
||||
|
||||
test( |
||||
'V.9: после попытки форк — попытка и user_answers остаются на старых version_id / question_id', |
||||
{ skip: !runDb }, |
||||
async () => { |
||||
const pool = new Pool(getPoolConfig()); |
||||
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; |
||||
let userId; |
||||
let testId; |
||||
let v1Id; |
||||
let q1Id; |
||||
let opt1Id; |
||||
let attemptId; |
||||
try { |
||||
const { rows: u } = await pool.query( |
||||
`INSERT INTO users (login, password_hash, full_name, role, is_active)
|
||||
VALUES ($1, $2, 'V9 fork', 'hr', true) RETURNING id`,
|
||||
[`v9f-${suffix}`, bcrypt.hashSync('x', 4)] |
||||
); |
||||
userId = u[0].id; |
||||
const c = await createTestWithVersion(pool, userId, { title: 'V9F' }); |
||||
testId = c.testId; |
||||
await saveTestDraft(pool, userId, testId, qPayload('pre')); |
||||
|
||||
const { rows: tv0 } = await pool.query( |
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||
[testId] |
||||
); |
||||
v1Id = tv0[0].id; |
||||
const { rows: qu } = await pool.query( |
||||
`SELECT id FROM questions WHERE test_version_id = $1 LIMIT 1`, |
||||
[v1Id] |
||||
); |
||||
q1Id = qu[0].id; |
||||
const { rows: op } = await pool.query( |
||||
`SELECT id FROM answer_options WHERE question_id = $1 AND is_correct = true LIMIT 1`, |
||||
[q1Id] |
||||
); |
||||
opt1Id = op[0].id; |
||||
|
||||
const { rows: at } = await pool.query( |
||||
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status, correct_count, total_questions, passed)
|
||||
VALUES ($1, $2, 1, 'completed', 1, 1, true) RETURNING id`,
|
||||
[v1Id, userId] |
||||
); |
||||
attemptId = at[0].id; |
||||
await pool.query( |
||||
`INSERT INTO user_answers (attempt_id, question_id, selected_options) VALUES ($1, $2, $3::uuid[])`, |
||||
[attemptId, q1Id, [opt1Id]] |
||||
); |
||||
|
||||
const out = await saveTestDraft(pool, userId, testId, qPayload('post-fork')); |
||||
assert.equal(out.forked, true, 'должна создаться новая версия после попытки'); |
||||
|
||||
const { rows: att } = await pool.query( |
||||
`SELECT test_version_id FROM test_attempts WHERE id = $1`, |
||||
[attemptId] |
||||
); |
||||
assert.equal( |
||||
att[0].test_version_id, |
||||
v1Id, |
||||
'попытка остаётся на версии, с которой проходили' |
||||
); |
||||
const { rows: ua } = await pool.query( |
||||
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`, |
||||
[attemptId] |
||||
); |
||||
assert.equal(ua[0].question_id, q1Id); |
||||
assert.equal(ua[0].selected_options[0], opt1Id); |
||||
|
||||
const { rows: qExists } = await pool.query( |
||||
`SELECT 1 FROM questions WHERE id = $1 AND test_version_id = $2`, |
||||
[q1Id, v1Id] |
||||
); |
||||
assert.equal(qExists.length, 1, 'старый вопрос остаётся в старой версии'); |
||||
|
||||
const { rows: active } = await pool.query( |
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||
[testId] |
||||
); |
||||
assert.notEqual(active[0].id, v1Id, 'новая версия — активна'); |
||||
} finally { |
||||
if (userId && testId) { |
||||
await purgeTestChain(pool, testId, userId); |
||||
} |
||||
await pool.end(); |
||||
} |
||||
} |
||||
); |
||||
@ -0,0 +1,41 @@
|
||||
/** Тексты ответов API для пользователей (русский). */ |
||||
export const RU = { |
||||
loginAndPasswordRequired: 'Укажите логин и пароль.', |
||||
invalidCredentials: 'Неверный логин или пароль.', |
||||
useHrLogin: 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).', |
||||
hrDatabaseUrlMissing: |
||||
'База кадровой системы не настроена: задайте HR_DATABASE_URL на backend.', |
||||
hrDatabaseNotConfigured: 'База кадровой системы не настроена.', |
||||
noStaffForLogin: |
||||
'К учётной записи не привязан сотрудник: в HR в карточке сотрудника должно совпадать поле веб-логина (web_login) с логином входа, как в кабинете сотрудника.', |
||||
loggedOut: 'Вы вышли из системы.', |
||||
logoutFailed: 'Не удалось выйти. Повторите попытку.', |
||||
userDataFailed: 'Не удалось загрузить данные пользователя.', |
||||
loginFailed: 'Ошибка входа. Повторите попытку.', |
||||
authRequired: 'Требуется вход в систему.', |
||||
tokenInvalid: 'Сессия истекла или недействительна. Войдите снова.', |
||||
userNotFound: 'Пользователь не найден.', |
||||
authError: 'Ошибка проверки доступа.', |
||||
insufficientPermissions: 'Недостаточно прав.', |
||||
departmentAccessDenied: 'Нет доступа к этому подразделению.', |
||||
notFound: 'Не найдено.', |
||||
fileFieldRequired: 'Прикрепите файл к полю file.', |
||||
uploadFailed: 'Не удалось принять файл.', |
||||
titleRequired: 'Укажите название.', |
||||
assignmentUserRequired: 'Передайте userId (UUID) или staffId (число, сотрудник из HR).', |
||||
assignmentUserOrStaff: 'Укажите только userId, или только staffId — не оба сразу.', |
||||
testNotFound: 'Тест не найден.', |
||||
forbidden: 'Доступ запрещён.', |
||||
versionNotFound: 'Версия не найдена.', |
||||
chainActiveRequired: 'Передайте chainActive: true/false в теле запроса.', |
||||
noActiveVersion: 'Нет активной версии теста.', |
||||
internal: 'Внутренняя ошибка сервера.', |
||||
fileTooLarge: 'Файл слишком большой (максимум 10 МБ).', |
||||
unsupportedFileType: |
||||
'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.', |
||||
attemptNotFound: 'Попытка не найдена.', |
||||
attemptNotInProgress: 'Попытка уже завершена или просрочена.', |
||||
attemptNotCompleted: 'Попытка ещё не завершена — подробный разбор доступен после отправки ответов.', |
||||
testHasNoQuestions: 'В активной версии нет вопросов. Добавьте вопросы и сохраните черновик.', |
||||
invalidOptionForQuestion: 'Выбран вариант ответа, не относящийся к вопросу.', |
||||
}; |
||||
@ -0,0 +1,171 @@
|
||||
/** |
||||
* Authorization Middleware |
||||
* JWT authentication and role-based access control |
||||
*/ |
||||
|
||||
import { verifyToken } from '../utils/auth.js'; |
||||
import { query } from '../db/db.js'; |
||||
import { RU } from '../messages/ru.js'; |
||||
|
||||
/** |
||||
* Extract token from cookie |
||||
* @param {Object} req - Express request object |
||||
* @returns {string|null} Token from cookie |
||||
*/ |
||||
function getTokenFromCookie(req) { |
||||
return req.cookies?.token || null; |
||||
} |
||||
|
||||
/** |
||||
* Middleware to authenticate JWT token |
||||
* Adds user data to req.user |
||||
*/ |
||||
export async function authenticate(req, res, next) { |
||||
try { |
||||
const token = getTokenFromCookie(req); |
||||
|
||||
if (!token) { |
||||
return res.status(401).json({ error: RU.authRequired }); |
||||
} |
||||
|
||||
const decoded = verifyToken(token); |
||||
|
||||
if (!decoded) { |
||||
return res.status(401).json({ error: RU.tokenInvalid }); |
||||
} |
||||
|
||||
const result = await query( |
||||
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1', |
||||
[decoded.userId] |
||||
); |
||||
|
||||
if (result.rows.length === 0) { |
||||
return res.status(401).json({ error: RU.userNotFound }); |
||||
} |
||||
|
||||
const user = result.rows[0]; |
||||
const staffId = user.staff_id ?? decoded.staffId; |
||||
|
||||
req.user = { |
||||
id: user.id, |
||||
login: user.login, |
||||
fullName: user.full_name, |
||||
role: user.role, |
||||
departmentId: user.department_id, |
||||
}; |
||||
if (staffId != null) { |
||||
req.user.staffId = staffId; |
||||
} |
||||
|
||||
next(); |
||||
} catch (error) { |
||||
console.error('Auth middleware error:', error); |
||||
return res.status(500).json({ error: RU.authError }); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Middleware factory to require specific roles |
||||
* @param {string|string[]} roles - Required role(s) |
||||
* @returns {Function} Express middleware |
||||
*/ |
||||
export function requireRole(roles) { |
||||
const allowedRoles = Array.isArray(roles) ? roles : [roles]; |
||||
|
||||
return (req, res, next) => { |
||||
if (!req.user) { |
||||
return res.status(401).json({ error: RU.authRequired }); |
||||
} |
||||
|
||||
if (!allowedRoles.includes(req.user.role)) { |
||||
return res.status(403).json({ error: RU.insufficientPermissions }); |
||||
} |
||||
|
||||
next(); |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Middleware to require specific department access |
||||
* For managers to access their department's data |
||||
* @param {number} departmentId - Required department ID |
||||
* @returns {Function} Express middleware |
||||
*/ |
||||
export function requireDepartment(departmentId) { |
||||
return (req, res, next) => { |
||||
if (!req.user) { |
||||
return res.status(401).json({ error: RU.authRequired }); |
||||
} |
||||
|
||||
// Admins can access all departments
|
||||
if (req.user.role === 'admin') { |
||||
return next(); |
||||
} |
||||
|
||||
// Managers can only access their department
|
||||
if (req.user.role === 'manager' && req.user.departmentId !== departmentId) { |
||||
return res.status(403).json({ error: RU.departmentAccessDenied }); |
||||
} |
||||
|
||||
next(); |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Optional authentication middleware |
||||
* Attaches user to request if token is valid, but doesn't require it |
||||
*/ |
||||
export async function optionalAuth(req, res, next) { |
||||
try { |
||||
const token = getTokenFromCookie(req); |
||||
|
||||
if (!token) { |
||||
req.user = null; |
||||
return next(); |
||||
} |
||||
|
||||
const decoded = verifyToken(token); |
||||
|
||||
if (!decoded) { |
||||
req.user = null; |
||||
return next(); |
||||
} |
||||
|
||||
const result = await query( |
||||
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1', |
||||
[decoded.userId] |
||||
); |
||||
|
||||
if (result.rows.length === 0) { |
||||
req.user = null; |
||||
return next(); |
||||
} |
||||
|
||||
const user = result.rows[0]; |
||||
const staffId = user.staff_id ?? decoded.staffId; |
||||
|
||||
req.user = { |
||||
id: user.id, |
||||
login: user.login, |
||||
fullName: user.full_name, |
||||
role: user.role, |
||||
departmentId: user.department_id, |
||||
}; |
||||
if (staffId != null) { |
||||
req.user.staffId = staffId; |
||||
} |
||||
|
||||
next(); |
||||
} catch (error) { |
||||
// Don't block request on auth errors
|
||||
req.user = null; |
||||
next(); |
||||
} |
||||
} |
||||
|
||||
export default { |
||||
authenticate, |
||||
requireRole, |
||||
requireDepartment, |
||||
optionalAuth, |
||||
}; |
||||
@ -0,0 +1,188 @@
|
||||
/** |
||||
* A.1–A.4: локальный bcrypt (dev) и HR (HR_AUTH=1 + Werkzeug + staff_id) |
||||
*/ |
||||
import express from 'express'; |
||||
import { query } from '../db/db.js'; |
||||
import { comparePassword, generateToken } from '../utils/auth.js'; |
||||
import { authenticate } from '../middleware/auth.js'; |
||||
import { queryHr, getHrPool } from '../db/hrPool.js'; |
||||
import { mapHrRoleToApp } from '../utils/hrRoleMap.js'; |
||||
import { |
||||
isHrAuthEnabled, |
||||
HR_MANAGED_PASSWORD_PLACEHOLDER, |
||||
} from '../config/authConstants.js'; |
||||
import { RU } from '../messages/ru.js'; |
||||
import { |
||||
getAssignmentDirectory, |
||||
getHrDepartmentNames, |
||||
} from '../services/assignmentDirectoryService.js'; |
||||
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js'; |
||||
|
||||
const router = express.Router(); |
||||
|
||||
router.post('/login', async (req, res) => { |
||||
try { |
||||
const { login, password } = req.body; |
||||
if (!login || !password) { |
||||
return res.status(400).json({ error: RU.loginAndPasswordRequired }); |
||||
} |
||||
|
||||
if (isHrAuthEnabled()) { |
||||
if (!getHrPool()) { |
||||
return res.status(500).json({ error: RU.hrDatabaseUrlMissing }); |
||||
} |
||||
const u = await queryHr( |
||||
`SELECT id, username, password_hash, role
|
||||
FROM users |
||||
WHERE LOWER(TRIM(username)) = LOWER(TRIM($1))`,
|
||||
[login] |
||||
); |
||||
if (u.rows.length === 0 || !u.rows[0].password_hash) { |
||||
return res.status(401).json({ error: RU.invalidCredentials }); |
||||
} |
||||
const row = u.rows[0]; |
||||
const ok = await comparePassword(password, row.password_hash); |
||||
if (!ok) { |
||||
return res.status(401).json({ error: RU.invalidCredentials }); |
||||
} |
||||
const s = await queryHr( |
||||
`SELECT id, fio FROM staff_members
|
||||
WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM($1))`,
|
||||
[login] |
||||
); |
||||
if (s.rows.length === 0) { |
||||
return res.status(403).json({ error: RU.noStaffForLogin }); |
||||
} |
||||
const staffId = s.rows[0].id; |
||||
const fio = s.rows[0].fio || login; |
||||
const appRole = mapHrRoleToApp(row.role); |
||||
const up = await query( |
||||
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
|
||||
VALUES ($1, $2, $3, $4, null, true, $5) |
||||
ON CONFLICT (staff_id) DO UPDATE SET |
||||
login = EXCLUDED.login, |
||||
full_name = EXCLUDED.full_name, |
||||
role = EXCLUDED.role, |
||||
password_hash = EXCLUDED.password_hash |
||||
RETURNING id, login, full_name, role, department_id, staff_id`,
|
||||
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, appRole, staffId] |
||||
); |
||||
const uu = up.rows[0]; |
||||
const token = generateToken( |
||||
uu.id, |
||||
uu.role, |
||||
uu.department_id, |
||||
{ staffId: uu.staff_id } |
||||
); |
||||
res.cookie('token', token, { |
||||
httpOnly: true, |
||||
secure: process.env.NODE_ENV === 'production', |
||||
sameSite: 'strict', |
||||
maxAge: 7 * 24 * 60 * 60 * 1000, |
||||
}); |
||||
return res.json({ |
||||
user: { |
||||
id: uu.id, |
||||
login: uu.login, |
||||
fullName: uu.full_name, |
||||
role: uu.role, |
||||
departmentId: uu.department_id, |
||||
staffId: uu.staff_id, |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
const result = await query( |
||||
'SELECT id, login, password_hash, full_name, role, department_id FROM users WHERE login = $1 AND is_active = true', |
||||
[login] |
||||
); |
||||
if (result.rows.length === 0) { |
||||
return res.status(401).json({ error: RU.invalidCredentials }); |
||||
} |
||||
const user = result.rows[0]; |
||||
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) { |
||||
return res.status(401).json({ error: RU.useHrLogin }); |
||||
} |
||||
const isValidPassword = await comparePassword(password, user.password_hash); |
||||
if (!isValidPassword) { |
||||
return res.status(401).json({ error: RU.invalidCredentials }); |
||||
} |
||||
const token = generateToken(user.id, user.role, user.department_id); |
||||
res.cookie('token', token, { |
||||
httpOnly: true, |
||||
secure: process.env.NODE_ENV === 'production', |
||||
sameSite: 'strict', |
||||
maxAge: 7 * 24 * 60 * 60 * 1000, |
||||
}); |
||||
return res.json({ |
||||
user: { |
||||
id: user.id, |
||||
login: user.login, |
||||
fullName: user.full_name, |
||||
role: user.role, |
||||
departmentId: user.department_id, |
||||
staffId: null, |
||||
}, |
||||
}); |
||||
} catch (error) { |
||||
if (error.message?.includes('HR database not configured')) { |
||||
return res.status(500).json({ error: RU.hrDatabaseNotConfigured }); |
||||
} |
||||
console.error('Login error:', error); |
||||
return res.status(500).json({ error: RU.loginFailed }); |
||||
} |
||||
}); |
||||
|
||||
router.post('/logout', (req, res) => { |
||||
try { |
||||
res.clearCookie('token', { |
||||
httpOnly: true, |
||||
secure: process.env.NODE_ENV === 'production', |
||||
sameSite: 'strict', |
||||
}); |
||||
res.json({ message: RU.loggedOut }); |
||||
} catch (error) { |
||||
console.error('Logout error:', error); |
||||
res.status(500).json({ error: RU.logoutFailed }); |
||||
} |
||||
}); |
||||
|
||||
router.get('/me', authenticate, async (req, res) => { |
||||
try { |
||||
const devUi = process.env.NODE_ENV === 'development'; |
||||
const assignmentUi = isAssignmentFeatureEnabled(); |
||||
res.json({ user: req.user, devUi, assignmentUi }); |
||||
} catch (error) { |
||||
console.error('Get current user error:', error); |
||||
res.status(500).json({ error: RU.userDataFailed }); |
||||
} |
||||
}); |
||||
|
||||
/** |
||||
* Каталог сотрудников для назначения: HR (все) + отделы + поиск. Как `POST .../assign`: см. `isAssignmentFeatureEnabled()`. |
||||
* Query: q, department (имя отдела или __all__), clinic=all|with|without |
||||
*/ |
||||
router.get('/dev/assignment-directory', authenticate, async (req, res) => { |
||||
if (!isAssignmentFeatureEnabled()) { |
||||
return res.status(404).json({ error: RU.notFound }); |
||||
} |
||||
try { |
||||
const q = typeof req.query.q === 'string' ? req.query.q : ''; |
||||
const department = typeof req.query.department === 'string' ? req.query.department : ''; |
||||
const c = req.query.clinic; |
||||
const clinicFilter = |
||||
c === 'with' || c === 'without' ? c : 'all'; |
||||
const { people, source } = await getAssignmentDirectory({ |
||||
q, |
||||
department, |
||||
clinicFilter, |
||||
}); |
||||
const departments = await getHrDepartmentNames(); |
||||
res.json({ people, source, departments }); |
||||
} catch (error) { |
||||
console.error('dev assignment directory:', error); |
||||
res.status(500).json({ error: RU.userDataFailed }); |
||||
} |
||||
}); |
||||
|
||||
export default router; |
||||
@ -0,0 +1,634 @@
|
||||
/** |
||||
* V.4–V.6, D.1 — API тестов, версий, импорт файла |
||||
*/ |
||||
import express from 'express'; |
||||
import fs from 'fs/promises'; |
||||
import os from 'os'; |
||||
import multer from 'multer'; |
||||
import pool, { query } from '../db/db.js'; |
||||
import { authenticate } from '../middleware/auth.js'; |
||||
import { hasAnyAttemptForTest } from '../services/testChainService.js'; |
||||
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js'; |
||||
import { |
||||
getEditorContent, |
||||
getPlayContent, |
||||
submitAttempt, |
||||
getAttemptReviewForUser, |
||||
listTestAttemptsForAuthor, |
||||
} from '../services/testAttemptService.js'; |
||||
import { extractTextFromFile } from '../services/documentExtractService.js'; |
||||
import { generationForImportDocument } from '../services/documentGenService.js'; |
||||
import { RU } from '../messages/ru.js'; |
||||
import { isTestAuthor } from '../config/devAuthor.js'; |
||||
import { ensureClinicUserIdForStaff } from '../services/assignmentUserService.js'; |
||||
import { |
||||
queryTestsVisibleToUser, |
||||
userHasTestAccess, |
||||
} from '../services/testAccessService.js'; |
||||
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js'; |
||||
import { |
||||
generateFullTestByShape, |
||||
generateOrRephraseQuestion, |
||||
parseAndValidateShape, |
||||
} from '../services/aiEditorService.js'; |
||||
|
||||
const router = express.Router(); |
||||
const upload = multer({ |
||||
dest: os.tmpdir(), |
||||
limits: { fileSize: 10 * 1024 * 1024 }, |
||||
}); |
||||
|
||||
function asyncHandler(fn) { |
||||
return (req, res, next) => { |
||||
Promise.resolve(fn(req, res, next)).catch((err) => { |
||||
if (err.status) { |
||||
res.status(err.status).json({ error: err.message }); |
||||
} else { |
||||
next(err); |
||||
} |
||||
}); |
||||
}; |
||||
} |
||||
|
||||
/** D.1 + D.2 + D.3 (заглушка) — `POST` до маршрутов `/:id` */ |
||||
router.post( |
||||
'/import/document', |
||||
authenticate, |
||||
(req, res, next) => { |
||||
upload.single('file')(req, res, (err) => { |
||||
if (err) { |
||||
if (err.code === 'LIMIT_FILE_SIZE') { |
||||
return res.status(413).json({ error: RU.fileTooLarge }); |
||||
} |
||||
return next(err); |
||||
} |
||||
next(); |
||||
}); |
||||
}, |
||||
asyncHandler(async (req, res) => { |
||||
if (!req.file) { |
||||
return res.status(400).json({ error: RU.fileFieldRequired }); |
||||
} |
||||
const p = req.file.path; |
||||
const { mimetype, originalname } = req.file; |
||||
let size; |
||||
let extractedText; |
||||
try { |
||||
const st = await fs.stat(p); |
||||
size = st.size; |
||||
extractedText = await extractTextFromFile(mimetype, p, originalname); |
||||
} catch (e) { |
||||
try { |
||||
await fs.unlink(p); |
||||
} catch { |
||||
// ignore
|
||||
} |
||||
if (e.status) { |
||||
return res.status(e.status).json({ error: e.message }); |
||||
} |
||||
console.error('import document:', e); |
||||
return res.status(500).json({ error: RU.uploadFailed }); |
||||
} |
||||
try { |
||||
await fs.unlink(p); |
||||
} catch { |
||||
// D.5: временный файл удалён; при ошибке extract уже удалили выше
|
||||
} |
||||
const generation = await generationForImportDocument(extractedText); |
||||
res.json({ |
||||
received: true, |
||||
originalName: originalname, |
||||
mime: mimetype, |
||||
size, |
||||
extractedText, |
||||
textLength: extractedText.length, |
||||
generation, |
||||
}); |
||||
}) |
||||
); |
||||
|
||||
router.get( |
||||
'/', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const { rows } = await queryTestsVisibleToUser(req.user.id); |
||||
const { rows: hidden } = await query( |
||||
`SELECT t.id, t.title, t.description, t.is_active AS chain_active,
|
||||
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, |
||||
t.created_by, u.full_name AS author_full_name |
||||
FROM tests t |
||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true |
||||
INNER JOIN users u ON u.id = t.created_by |
||||
WHERE t.is_active = false AND t.created_by = $1 |
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
|
||||
[req.user.id] |
||||
); |
||||
res.json({ tests: rows, hiddenByYou: hidden }); |
||||
}) |
||||
); |
||||
|
||||
router.post( |
||||
'/', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const { title, description } = req.body; |
||||
if (!title || typeof title !== 'string') { |
||||
return res.status(400).json({ error: RU.titleRequired }); |
||||
} |
||||
const out = await createTestWithVersion(pool, req.user.id, { |
||||
title, |
||||
description, |
||||
}); |
||||
res.status(201).json(out); |
||||
}) |
||||
); |
||||
|
||||
/** |
||||
* V.8: краткая карточка цепочки — одна строка (активная версия), без дублей. |
||||
* Не-автор не видит скрытую с общего списка цепочку (кроме прямой ссылки автора). |
||||
*/ |
||||
router.get( |
||||
'/:id/summary', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const testId = req.params.id; |
||||
const { rows } = await query( |
||||
`SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active,
|
||||
t.created_by, t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, |
||||
u.full_name AS author_full_name |
||||
FROM tests t |
||||
LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true |
||||
LEFT JOIN users u ON u.id = t.created_by |
||||
WHERE t.id = $1`,
|
||||
[testId] |
||||
); |
||||
if (!rows.length) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
const row = rows[0]; |
||||
const isAuthor = isTestAuthor(row.created_by, req.user.id); |
||||
if (row.chain_active === false && !isAuthor) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (!isAuthor) { |
||||
const acc = await userHasTestAccess(req.user.id, testId); |
||||
if (!acc.ok) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
} |
||||
res.json({ |
||||
test: { |
||||
id: row.id, |
||||
title: row.title, |
||||
description: row.description, |
||||
passingThreshold: row.passing_threshold, |
||||
chainActive: row.chain_active, |
||||
activeVersionId: row.active_version_id, |
||||
version: row.version, |
||||
createdAt: row.created_at, |
||||
updatedAt: row.updated_at, |
||||
createdBy: row.created_by, |
||||
authorFullName: row.author_full_name, |
||||
}, |
||||
isAuthor, |
||||
hasActiveVersion: row.active_version_id != null, |
||||
}); |
||||
}) |
||||
); |
||||
|
||||
router.get( |
||||
'/:id/versions', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const testId = req.params.id; |
||||
const { rows: t } = await query( |
||||
`SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at, t.description,
|
||||
u.full_name AS author_full_name |
||||
FROM tests t |
||||
INNER JOIN users u ON u.id = t.created_by |
||||
WHERE t.id = $1`,
|
||||
[testId] |
||||
); |
||||
if (!t.length) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
||||
return res.status(403).json({ error: RU.forbidden }); |
||||
} |
||||
const testRow = t[0]; |
||||
const { rows } = await query( |
||||
`SELECT id, version, is_active, parent_id, created_at
|
||||
FROM test_versions WHERE test_id = $1 ORDER BY version`,
|
||||
[testId] |
||||
); |
||||
const hasAttempts = await hasAnyAttemptForTest(pool, testId); |
||||
res.json({ |
||||
test: { |
||||
id: testRow.id, |
||||
title: testRow.title, |
||||
description: testRow.description, |
||||
chainActive: testRow.is_active, |
||||
createdAt: testRow.created_at, |
||||
updatedAt: testRow.updated_at, |
||||
createdBy: testRow.created_by, |
||||
authorFullName: testRow.author_full_name, |
||||
}, |
||||
versions: rows, |
||||
hasAttempts, |
||||
}); |
||||
}) |
||||
); |
||||
|
||||
router.get( |
||||
'/:id/editor', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const out = await getEditorContent(pool, req.user.id, req.params.id); |
||||
res.json(out); |
||||
}) |
||||
); |
||||
|
||||
/** |
||||
* ИИ: заполнить тест по текущей сетке (число вопросов = len(shape), варианты = optionsCount). |
||||
* Только автор. Тело: { testTitle?, testDescription?, shape: [{ optionsCount, hasMultipleAnswers }] } |
||||
*/ |
||||
router.post( |
||||
'/:id/ai/generate-test', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const testId = req.params.id; |
||||
const { rows: t } = await query( |
||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||
[testId] |
||||
); |
||||
if (!t.length) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
||||
return res.status(403).json({ error: RU.forbidden }); |
||||
} |
||||
const b = req.body && typeof req.body === 'object' ? req.body : {}; |
||||
const shape = parseAndValidateShape(b.shape); |
||||
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : ''; |
||||
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : ''; |
||||
const draft = await generateFullTestByShape(testTitle, testDescription, shape); |
||||
res.json({ ok: true, draft }); |
||||
}) |
||||
); |
||||
|
||||
/** |
||||
* ИИ: один вопрос — пустой текст → сгенерировать вопрос и варианты; иначе переформулировать только текст. |
||||
* Только автор. Тело: { testTitle?, testDescription?, questionText, optionsCount, hasMultipleAnswers } |
||||
*/ |
||||
router.post( |
||||
'/:id/ai/generate-question', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const testId = req.params.id; |
||||
const { rows: tr } = await query( |
||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||
[testId] |
||||
); |
||||
if (!tr.length) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (!isTestAuthor(tr[0].created_by, req.user.id)) { |
||||
return res.status(403).json({ error: RU.forbidden }); |
||||
} |
||||
const b = req.body && typeof req.body === 'object' ? req.body : {}; |
||||
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : ''; |
||||
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : ''; |
||||
const questionText = typeof b.questionText === 'string' ? b.questionText : ''; |
||||
const optionsCount = b.optionsCount; |
||||
const hasMultipleAnswers = Boolean(b.hasMultipleAnswers); |
||||
const out = await generateOrRephraseQuestion( |
||||
testTitle, |
||||
testDescription, |
||||
questionText, |
||||
optionsCount, |
||||
hasMultipleAnswers |
||||
); |
||||
res.json({ ok: true, ...out }); |
||||
}) |
||||
); |
||||
|
||||
router.post( |
||||
'/:id/versions/:vid/activate', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const testId = req.params.id; |
||||
const versionId = req.params.vid; |
||||
const { rows: t } = await query( |
||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||
[testId] |
||||
); |
||||
if (!t.length) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
||||
return res.status(403).json({ error: RU.forbidden }); |
||||
} |
||||
const { rows: v } = await query( |
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`, |
||||
[testId, versionId] |
||||
); |
||||
if (!v.length) { |
||||
return res.status(404).json({ error: RU.versionNotFound }); |
||||
} |
||||
const client = await pool.connect(); |
||||
try { |
||||
await client.query('BEGIN'); |
||||
await client.query( |
||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1`, |
||||
[testId] |
||||
); |
||||
await client.query( |
||||
`UPDATE test_versions SET is_active = true WHERE id = $1`, |
||||
[versionId] |
||||
); |
||||
await client.query('COMMIT'); |
||||
} catch (e) { |
||||
await client.query('ROLLBACK'); |
||||
throw e; |
||||
} finally { |
||||
client.release(); |
||||
} |
||||
res.json({ ok: true, activeVersionId: versionId }); |
||||
}) |
||||
); |
||||
|
||||
router.patch( |
||||
'/:id', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const testId = req.params.id; |
||||
const { isActive, chainActive } = req.body; |
||||
const chain = chainActive ?? isActive; |
||||
if (typeof chain !== 'boolean') { |
||||
return res.status(400).json({ error: RU.chainActiveRequired }); |
||||
} |
||||
const { rows: t } = await query( |
||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||
[testId] |
||||
); |
||||
if (!t.length) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
||||
return res.status(403).json({ error: RU.forbidden }); |
||||
} |
||||
await query( |
||||
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, |
||||
[testId, chain] |
||||
); |
||||
res.json({ id: testId, chainActive: chain }); |
||||
}) |
||||
); |
||||
|
||||
/** |
||||
* Пакетное назначение — `test_assignments` + `test_assignment_targets` (user). |
||||
* Включение: `development` или `CLINIC_ASSIGNMENT_ENABLED=1`. Только автор. Тело: `userIds`, `staffIds` и/или одиночные `userId` / `staffId`. |
||||
*/ |
||||
router.post( |
||||
'/:id/assign', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
if (!isAssignmentFeatureEnabled()) { |
||||
return res.status(404).json({ error: RU.notFound }); |
||||
} |
||||
const testId = req.params.id; |
||||
const b = req.body && typeof req.body === 'object' ? req.body : {}; |
||||
const userIds = new Set(); |
||||
|
||||
if (Array.isArray(b.userIds)) { |
||||
for (const u of b.userIds) { |
||||
if (typeof u === 'string' && u.trim()) { |
||||
userIds.add(u.trim()); |
||||
} |
||||
} |
||||
} |
||||
if (Array.isArray(b.staffIds)) { |
||||
for (const s of b.staffIds) { |
||||
const n = Number(s); |
||||
if (Number.isFinite(n) && n >= 1) { |
||||
userIds.add(await ensureClinicUserIdForStaff(pool, n)); |
||||
} |
||||
} |
||||
} |
||||
if (userIds.size === 0) { |
||||
if (b.staffId != null && b.userId) { |
||||
return res.status(400).json({ error: RU.assignmentUserOrStaff }); |
||||
} |
||||
if (b.staffId != null) { |
||||
const sid = Number(b.staffId); |
||||
if (Number.isNaN(sid)) { |
||||
return res.status(400).json({ error: RU.assignmentUserRequired }); |
||||
} |
||||
userIds.add(await ensureClinicUserIdForStaff(pool, sid)); |
||||
} else if (typeof b.userId === 'string' && b.userId.trim()) { |
||||
userIds.add(b.userId.trim()); |
||||
} |
||||
} |
||||
if (userIds.size === 0) { |
||||
return res.status(400).json({ error: RU.assignmentUserRequired }); |
||||
} |
||||
|
||||
const { rows: t } = await query( |
||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||
[testId] |
||||
); |
||||
if (!t.length) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
||||
return res.status(403).json({ error: RU.forbidden }); |
||||
} |
||||
const { rows: tv } = await query( |
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, |
||||
[testId] |
||||
); |
||||
if (!tv.length) { |
||||
return res.status(400).json({ error: RU.noActiveVersion }); |
||||
} |
||||
for (const uid of userIds) { |
||||
const { rows: u } = await query( |
||||
`SELECT id FROM users WHERE id = $1 AND is_active = true`, |
||||
[uid] |
||||
); |
||||
if (!u.length) { |
||||
return res.status(400).json({ error: RU.userNotFound }); |
||||
} |
||||
} |
||||
const versionId = tv[0].id; |
||||
const client = await pool.connect(); |
||||
let assignmentId; |
||||
try { |
||||
await client.query('BEGIN'); |
||||
const { rows: ins } = await client.query( |
||||
`INSERT INTO test_assignments (test_version_id, assigned_by, max_attempts)
|
||||
VALUES ($1, $2, 5) RETURNING id`,
|
||||
[versionId, req.user.id] |
||||
); |
||||
assignmentId = ins[0].id; |
||||
for (const uid of userIds) { |
||||
await client.query( |
||||
`INSERT INTO test_assignment_targets (assignment_id, target_type, target_id)
|
||||
VALUES ($1, 'user', $2)`,
|
||||
[assignmentId, uid] |
||||
); |
||||
} |
||||
await client.query('COMMIT'); |
||||
} catch (e) { |
||||
await client.query('ROLLBACK'); |
||||
throw e; |
||||
} finally { |
||||
client.release(); |
||||
} |
||||
res |
||||
.status(201) |
||||
.json({ ok: true, assignmentId, count: userIds.size }); |
||||
}) |
||||
); |
||||
|
||||
router.post( |
||||
'/:id/draft', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const out = await saveTestDraft(pool, req.user.id, req.params.id, req.body); |
||||
res.json(out); |
||||
}) |
||||
); |
||||
|
||||
router.post( |
||||
'/:id/attempts/start', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const testId = req.params.id; |
||||
const acc = await userHasTestAccess(req.user.id, testId); |
||||
if (!acc.ok) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
const { rows: tv } = await query( |
||||
`SELECT tv.id AS test_version_id
|
||||
FROM test_versions tv |
||||
WHERE tv.test_id = $1 AND tv.is_active = true LIMIT 1`,
|
||||
[testId] |
||||
); |
||||
if (!tv.length) { |
||||
return res.status(404).json({ error: RU.noActiveVersion }); |
||||
} |
||||
const testVersionId = tv[0].test_version_id; |
||||
const { rows: mx } = await query( |
||||
`SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts
|
||||
WHERE test_version_id = $1 AND user_id = $2`,
|
||||
[testVersionId, req.user.id] |
||||
); |
||||
const nextN = (mx[0].n || 0) + 1; |
||||
const { rows: a } = await query( |
||||
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status)
|
||||
VALUES ($1, $2, $3, 'in_progress') |
||||
RETURNING id, test_version_id, user_id, attempt_number, status, started_at`,
|
||||
[testVersionId, req.user.id, nextN] |
||||
); |
||||
res.status(201).json({ attempt: a[0] }); |
||||
}) |
||||
); |
||||
|
||||
/** Только автор: список попыток по цепочке (все версии) */ |
||||
router.get( |
||||
'/:id/attempts', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const rows = await listTestAttemptsForAuthor(pool, req.user.id, req.params.id); |
||||
res.json({ |
||||
attempts: rows.map((r) => ({ |
||||
id: r.id, |
||||
userId: r.user_id, |
||||
status: r.status, |
||||
attemptNumber: r.attempt_number, |
||||
startedAt: r.started_at, |
||||
completedAt: r.completed_at, |
||||
correctCount: r.correct_count, |
||||
totalQuestions: r.total_questions, |
||||
passed: r.passed, |
||||
testVersion: r.test_version, |
||||
attempterName: r.attempter_name, |
||||
attempterLogin: r.attempter_login, |
||||
})), |
||||
}); |
||||
}) |
||||
); |
||||
|
||||
/** Разбор завершённой попытки: владелец или автор */ |
||||
router.get( |
||||
'/:id/attempts/:aid/review', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const review = await getAttemptReviewForUser( |
||||
pool, |
||||
req.user.id, |
||||
req.params.id, |
||||
req.params.aid |
||||
); |
||||
res.json(review); |
||||
}) |
||||
); |
||||
|
||||
router.get( |
||||
'/:id/attempts/:aid/play', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const out = await getPlayContent(pool, req.user.id, req.params.id, req.params.aid); |
||||
res.json(out); |
||||
}) |
||||
); |
||||
|
||||
router.post( |
||||
'/:id/attempts/:aid/submit', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const out = await submitAttempt( |
||||
pool, |
||||
req.user.id, |
||||
req.params.id, |
||||
req.params.aid, |
||||
req.body?.answers |
||||
); |
||||
res.json(out); |
||||
}) |
||||
); |
||||
|
||||
router.get( |
||||
'/:id/chain-info', |
||||
authenticate, |
||||
asyncHandler(async (req, res) => { |
||||
const testId = req.params.id; |
||||
const acc = await userHasTestAccess(req.user.id, testId); |
||||
if (acc.notFound) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (!acc.ok) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
const { rows: tr } = await query( |
||||
`SELECT t.is_active AS chain_active FROM tests t WHERE t.id = $1`, |
||||
[testId] |
||||
); |
||||
if (!tr.length) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
if (tr[0].chain_active === false) { |
||||
const { rows: auth } = await query( |
||||
`SELECT created_by FROM tests WHERE id = $1`, |
||||
[testId] |
||||
); |
||||
if (!isTestAuthor(auth[0].created_by, req.user.id)) { |
||||
return res.status(404).json({ error: RU.testNotFound }); |
||||
} |
||||
} |
||||
const has = await hasAnyAttemptForTest(pool, testId); |
||||
res.json({ testId, hasAnyAttempt: has }); |
||||
}) |
||||
); |
||||
|
||||
export default router; |
||||
@ -0,0 +1,8 @@
|
||||
import { createApp } from './app.js'; |
||||
|
||||
const app = createApp(); |
||||
const PORT = process.env.PORT || 3001; |
||||
app.listen(PORT, () => { |
||||
console.log(`Server is running on port ${PORT}`); |
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); |
||||
}); |
||||
@ -0,0 +1,197 @@
|
||||
/** |
||||
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из UI. |
||||
*/ |
||||
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js'; |
||||
import { |
||||
parseJsonFromLlmText, |
||||
validateAndNormalizeDraft, |
||||
} from './documentGenService.js'; |
||||
|
||||
/** |
||||
* @param {unknown} s |
||||
* @returns {{ optionsCount: number, hasMultipleAnswers: boolean }[]} |
||||
*/ |
||||
export function parseAndValidateShape(s) { |
||||
if (!Array.isArray(s) || s.length === 0) { |
||||
const e = new Error('Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].'); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
if (s.length > 40) { |
||||
const e = new Error('Не более 40 вопросов за раз.'); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
return s.map((row, i) => { |
||||
if (!row || typeof row !== 'object') { |
||||
const e = new Error(`shape[${i}]: ожидается объект.`); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const n = Math.floor(Number((/** @type {any} */ (row)).optionsCount)); |
||||
const hasMultipleAnswers = Boolean((/** @type {any} */ (row)).hasMultipleAnswers); |
||||
if (!Number.isFinite(n) || n < 2 || n > 12) { |
||||
const e = new Error(`shape[${i}]: optionsCount от 2 до 12.`); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
return { optionsCount: n, hasMultipleAnswers }; |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* @param {any} o parsed draft |
||||
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape |
||||
*/ |
||||
export function assertDraftMatchesShape(o, shape) { |
||||
if (!o?.questions || !Array.isArray(o.questions)) { |
||||
const e = new Error('В ответе нет questions.'); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
if (o.questions.length !== shape.length) { |
||||
const e = new Error( |
||||
`Ожидалось вопросов: ${shape.length}, в ответе: ${o.questions.length}.` |
||||
); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
for (let i = 0; i < shape.length; i++) { |
||||
const q = o.questions[i]; |
||||
const sh = shape[i]; |
||||
if (!q?.options || !Array.isArray(q.options)) { |
||||
const e = new Error(`Вопрос ${i + 1}: нет options.`); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
if (q.options.length !== sh.optionsCount) { |
||||
const e = new Error( |
||||
`Вопрос ${i + 1}: ожидалось вариантов ${sh.optionsCount}, в ответе: ${q.options.length}.` |
||||
); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
if (Boolean(q.hasMultipleAnswers) !== sh.hasMultipleAnswers) { |
||||
const e = new Error( |
||||
`Вопрос ${i + 1}: hasMultipleAnswers должен быть ${sh.hasMultipleAnswers}.` |
||||
); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @param {string} testTitle |
||||
* @param {string} testDescription |
||||
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape |
||||
*/ |
||||
export async function generateFullTestByShape(testTitle, testDescription, shape) { |
||||
const cfg = getLlmConfig(); |
||||
if (!cfg) { |
||||
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.'); |
||||
/** @type {any} */ (e).status = 503; |
||||
throw e; |
||||
} |
||||
const title = (testTitle || '').trim() || 'Тест'; |
||||
const desc = (testDescription || '').trim(); |
||||
const lines = shape.map( |
||||
(sh, i) => |
||||
`Вопрос ${i + 1}: ровно ${sh.optionsCount} вариантов ответа; ${ |
||||
sh.hasMultipleAnswers |
||||
? 'несколько вариантов помечены как верные (hasMultipleAnswers: true).' |
||||
: 'ровно один верный вариант (hasMultipleAnswers: false).' |
||||
}` |
||||
); |
||||
const system = |
||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {"title": string, "description": string (может быть пустой строкой), "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}.'; |
||||
const user = `Составь тест по теме.
|
||||
|
||||
Название (можно уточнить, но смысл сохранить): ${title} |
||||
Краткое описание / контекст темы: ${desc || 'не указано; придумай согласованную тему с названием.'} |
||||
|
||||
Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше): |
||||
${lines.join('\n')} |
||||
|
||||
Правила: варианты — осмысленные, по теме; отметь isCorrect согласно hasMultipleAnswers; для одного правильного — ровна одна true.`;
|
||||
|
||||
const raw = await chatCompletionTextContent(cfg, system, user, 0.35); |
||||
const parsed = parseJsonFromLlmText(raw); |
||||
const draft = validateAndNormalizeDraft(parsed); |
||||
assertDraftMatchesShape({ questions: draft.questions }, shape); |
||||
return { |
||||
title: draft.title, |
||||
description: draft.description, |
||||
questions: draft.questions, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Пустой вопрос → сгенерировать формулировки; непустой → переформулировать только текст вопроса. |
||||
* @param {string} testTitle |
||||
* @param {string} testDescription |
||||
* @param {string} questionText |
||||
* @param {number} optionsCount |
||||
* @param {boolean} hasMultipleAnswers |
||||
*/ |
||||
export async function generateOrRephraseQuestion( |
||||
testTitle, |
||||
testDescription, |
||||
questionText, |
||||
optionsCount, |
||||
hasMultipleAnswers |
||||
) { |
||||
const cfg = getLlmConfig(); |
||||
if (!cfg) { |
||||
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.'); |
||||
/** @type {any} */ (e).status = 503; |
||||
throw e; |
||||
} |
||||
const n = Math.floor(Number(optionsCount)); |
||||
if (!Number.isFinite(n) || n < 2 || n > 12) { |
||||
const e = new Error('optionsCount: от 2 до 12.'); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const topic = `${(testTitle || '').trim() || 'Тест'}. ${(testDescription || '').trim()}`.trim(); |
||||
const qt = (questionText || '').trim(); |
||||
|
||||
if (qt) { |
||||
const system = |
||||
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.'; |
||||
const user = `Тема теста: ${topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n${qt}`; |
||||
const raw = await chatCompletionTextContent(cfg, system, user, 0.3); |
||||
const parsed = parseJsonFromLlmText(raw); |
||||
const text = String((/** @type {any} */ (parsed)).text ?? '').trim(); |
||||
if (!text) { |
||||
const e = new Error('Пустой text в ответе модели.'); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
return { mode: 'rephrase', text }; |
||||
} |
||||
|
||||
const system = |
||||
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}. Все на русском.'; |
||||
const user = `Тема теста: ${topic} |
||||
|
||||
Сформулируй ОДИН вопрос по этой теме с ровно ${n} вариантами ответа. hasMultipleAnswers = ${ |
||||
hasMultipleAnswers |
||||
? 'true (несколько верных, минимум 2 isCorrect: true, остальные false).' |
||||
: 'false (ровно один isCorrect: true).' |
||||
}`;
|
||||
const raw = await chatCompletionTextContent(cfg, system, user, 0.35); |
||||
const parsed = parseJsonFromLlmText(raw); |
||||
const shape = [{ optionsCount: n, hasMultipleAnswers: Boolean(hasMultipleAnswers) }]; |
||||
assertDraftMatchesShape({ questions: [parsed] }, shape); |
||||
const draft = validateAndNormalizeDraft({ |
||||
title: 'временно', |
||||
questions: [parsed], |
||||
}); |
||||
return { |
||||
mode: 'full', |
||||
text: draft.questions[0].text, |
||||
hasMultipleAnswers: draft.questions[0].hasMultipleAnswers, |
||||
options: draft.questions[0].options, |
||||
}; |
||||
} |
||||
@ -0,0 +1,20 @@
|
||||
import { test } from 'node:test'; |
||||
import assert from 'node:assert/strict'; |
||||
import { parseAndValidateShape } from './aiEditorService.js'; |
||||
|
||||
test('parseAndValidateShape: валидный ввод', () => { |
||||
const s = parseAndValidateShape([ |
||||
{ optionsCount: 3, hasMultipleAnswers: false }, |
||||
{ optionsCount: 2, hasMultipleAnswers: true }, |
||||
]); |
||||
assert.equal(s.length, 2); |
||||
assert.equal(s[0].optionsCount, 3); |
||||
assert.equal(s[1].hasMultipleAnswers, true); |
||||
}); |
||||
|
||||
test('parseAndValidateShape: пусто — ошибка', () => { |
||||
assert.throws( |
||||
() => parseAndValidateShape([]), |
||||
/Передайте/ |
||||
); |
||||
}); |
||||
@ -0,0 +1,125 @@
|
||||
/** |
||||
* Каталог для назначения: HR (staff_members + отделы) + учётки clinic_tests по staff_id. |
||||
* Две БД — данные сливаем в Node. |
||||
*/ |
||||
import { getHrPool, queryHr } from '../db/hrPool.js'; |
||||
import pool from '../db/db.js'; |
||||
|
||||
/** |
||||
* @param {{ q?: string, department?: string, clinicFilter?: 'all' | 'with' | 'without' }} p |
||||
*/ |
||||
export async function getAssignmentDirectory(p) { |
||||
const { rows: clinicByStaff } = await pool.query( |
||||
`SELECT id, staff_id, login, full_name
|
||||
FROM users |
||||
WHERE is_active = true AND staff_id IS NOT NULL` |
||||
); |
||||
const byStaff = new Map(); |
||||
for (const r of clinicByStaff) { |
||||
byStaff.set(r.staff_id, { clinicUserId: r.id, login: r.login, fullName: r.full_name }); |
||||
} |
||||
|
||||
if (!getHrPool()) { |
||||
const { rows } = await pool.query( |
||||
`SELECT u.id, u.staff_id, u.full_name AS fio, u.login AS "webLogin"
|
||||
FROM users u WHERE u.is_active = true ORDER BY u.full_name NULLS LAST, u.login` |
||||
); |
||||
let people = rows.map((r) => ({ |
||||
staffId: r.staff_id, |
||||
fio: r.fio || r.webLogin, |
||||
webLogin: r.webLogin, |
||||
departments: '', |
||||
clinicUserId: r.id, |
||||
})); |
||||
const qx = (p.q || '').trim().toLowerCase(); |
||||
if (qx) { |
||||
people = people.filter( |
||||
(x) => |
||||
(x.fio && x.fio.toLowerCase().includes(qx)) || |
||||
(x.webLogin && x.webLogin.toLowerCase().includes(qx)) || |
||||
(x.clinicUserId && x.clinicUserId.toLowerCase().includes(qx)) |
||||
); |
||||
} |
||||
return { people, source: 'clinic' }; |
||||
} |
||||
|
||||
const q = (p.q || '').trim(); |
||||
const dept = (p.department || '').trim(); |
||||
const clinicFilter = p.clinicFilter || 'all'; |
||||
|
||||
const { rows: staffRows } = await queryHr( |
||||
`SELECT sm.id AS staff_id, sm.fio, sm.web_login
|
||||
FROM staff_members sm`,
|
||||
[] |
||||
); |
||||
if (!staffRows.length) { |
||||
return { people: [], source: 'hr' }; |
||||
} |
||||
|
||||
const { rows: edRows } = await queryHr( |
||||
`SELECT staff_id, department FROM employees_departments
|
||||
WHERE department IS NOT NULL AND trim(department) <> ''`,
|
||||
[] |
||||
); |
||||
const deptsByStaff = new Map(); |
||||
for (const r of edRows) { |
||||
if (!deptsByStaff.has(r.staff_id)) { |
||||
deptsByStaff.set(r.staff_id, new Set()); |
||||
} |
||||
deptsByStaff.get(r.staff_id).add(r.department); |
||||
} |
||||
|
||||
let people = staffRows.map((r) => { |
||||
const dset = deptsByStaff.get(r.staff_id); |
||||
const departments = dset |
||||
? [...dset].sort((a, b) => a.localeCompare(b, 'ru')).join(', ') |
||||
: ''; |
||||
const cu = byStaff.get(r.staff_id) || null; |
||||
return { |
||||
staffId: r.staff_id, |
||||
fio: r.fio || '—', |
||||
webLogin: r.web_login, |
||||
departments, |
||||
clinicUserId: cu ? cu.clinicUserId : null, |
||||
}; |
||||
}); |
||||
|
||||
if (q) { |
||||
const low = q.toLowerCase(); |
||||
people = people.filter( |
||||
(x) => |
||||
(x.fio && x.fio.toLowerCase().includes(low)) || |
||||
(x.webLogin && x.webLogin.toLowerCase().includes(low)) |
||||
); |
||||
} |
||||
if (dept && dept !== '__all__') { |
||||
people = people.filter((x) => { |
||||
const s = deptsByStaff.get(x.staffId); |
||||
return s && s.has(dept); |
||||
}); |
||||
} |
||||
if (clinicFilter === 'with') { |
||||
people = people.filter((x) => x.clinicUserId != null); |
||||
} else if (clinicFilter === 'without') { |
||||
people = people.filter((x) => x.clinicUserId == null); |
||||
} |
||||
|
||||
people.sort((a, b) => (a.fio || '').localeCompare(b.fio || '', 'ru')); |
||||
return { people, source: 'hr' }; |
||||
} |
||||
|
||||
/** |
||||
* @returns {Promise<string[]>} |
||||
*/ |
||||
export async function getHrDepartmentNames() { |
||||
if (!getHrPool()) { |
||||
return []; |
||||
} |
||||
const { rows } = await queryHr( |
||||
`SELECT DISTINCT TRIM(department) AS d
|
||||
FROM employees_departments |
||||
WHERE department IS NOT NULL AND TRIM(department) <> '' |
||||
ORDER BY 1` |
||||
); |
||||
return rows.map((r) => r.d).filter(Boolean); |
||||
} |
||||
@ -0,0 +1,64 @@
|
||||
/** |
||||
* Создать/найти запись `clinic_tests.users` по staff_id (HR), чтобы назначить target_id = uuid. |
||||
*/ |
||||
import { queryHr, getHrPool } from '../db/hrPool.js'; |
||||
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js'; |
||||
import { RU } from '../messages/ru.js'; |
||||
|
||||
/** |
||||
* @param {import('pg').Pool} pool |
||||
* @param {number} staffId |
||||
* @returns {Promise<string>} uuid в clinic_tests.users |
||||
*/ |
||||
export async function ensureClinicUserIdForStaff(pool, staffId) { |
||||
const n = Math.floor(Number(staffId)); |
||||
if (!Number.isFinite(n) || n < 1) { |
||||
const e = new Error(RU.assignmentUserRequired); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const { rows: ex } = await pool.query( |
||||
`SELECT id FROM users WHERE staff_id = $1 AND is_active = true LIMIT 1`, |
||||
[n] |
||||
); |
||||
if (ex.length) { |
||||
return ex[0].id; |
||||
} |
||||
if (!getHrPool()) { |
||||
const e = new Error('Нет HR БД: нельзя завести учётку по staff_id.'); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const { rows: st } = await queryHr( |
||||
`SELECT id, fio, web_login FROM staff_members WHERE id = $1`, |
||||
[n] |
||||
); |
||||
if (!st.length) { |
||||
const e = new Error('Сотрудник не найден в HR.'); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const fio = st[0].fio || `staff #${n}`; |
||||
const rawLogin = (st[0].web_login && String(st[0].web_login).trim()) || null; |
||||
let login = rawLogin; |
||||
if (!login) { |
||||
login = `staff_${n}@clinic.local`; |
||||
} |
||||
const { rows: taken } = await pool.query( |
||||
`SELECT 1 FROM users WHERE LOWER(TRIM(login)) = LOWER(TRIM($1)) AND (staff_id IS NULL OR staff_id <> $2) LIMIT 1`, |
||||
[login, n] |
||||
); |
||||
if (taken.length) { |
||||
login = `staff_${n}@clinic.local`; |
||||
} |
||||
const ins = await pool.query( |
||||
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
|
||||
VALUES ($1, $2, $3, 'employee', null, true, $4) |
||||
ON CONFLICT (staff_id) DO UPDATE SET |
||||
full_name = EXCLUDED.full_name, |
||||
is_active = true |
||||
RETURNING id`,
|
||||
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, n] |
||||
); |
||||
return ins.rows[0].id; |
||||
} |
||||
@ -0,0 +1,66 @@
|
||||
/** |
||||
* D.2 — извлечение текста из PDF, DOCX, TXT (см. card1.md). |
||||
*/ |
||||
import { readFile } from 'fs/promises'; |
||||
import { createRequire } from 'node:module'; |
||||
import mammoth from 'mammoth'; |
||||
import { RU } from '../messages/ru.js'; |
||||
|
||||
const require = createRequire(import.meta.url); |
||||
const pdfParse = require('pdf-parse'); |
||||
|
||||
/** @param {string} mime @param {string} [originalName] */ |
||||
export function resolveDocumentKind(mime, originalName = '') { |
||||
const m = (mime || '').toLowerCase(); |
||||
const n = originalName.toLowerCase(); |
||||
if (m === 'application/pdf' || n.endsWith('.pdf')) { |
||||
return 'pdf'; |
||||
} |
||||
if ( |
||||
m === |
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || |
||||
n.endsWith('.docx') |
||||
) { |
||||
return 'docx'; |
||||
} |
||||
if (m === 'text/plain' || m === 'text/markdown' || n.endsWith('.txt') || n.endsWith('.md')) { |
||||
return 'text'; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* @param {string} mimetype |
||||
* @param {string} filePath |
||||
* @param {string} [originalName] |
||||
* @returns {Promise<string>} извлечённый плоский текст |
||||
*/ |
||||
export async function extractTextFromFile(mimetype, filePath, originalName) { |
||||
const kind = resolveDocumentKind(mimetype, originalName); |
||||
if (!kind) { |
||||
const e = new Error(RU.unsupportedFileType); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const buf = await readFile(filePath); |
||||
return extractTextFromBuffer(kind, buf); |
||||
} |
||||
|
||||
/** |
||||
* @param {'pdf'|'docx'|'text'} kind |
||||
* @param {Buffer} buffer |
||||
*/ |
||||
export async function extractTextFromBuffer(kind, buffer) { |
||||
if (kind === 'text') { |
||||
return buffer.toString('utf8'); |
||||
} |
||||
if (kind === 'docx') { |
||||
const { value } = await mammoth.extractRawText({ buffer }); |
||||
return (value || '').replace(/\r\n/g, '\n').trim(); |
||||
} |
||||
if (kind === 'pdf') { |
||||
const data = await pdfParse(buffer); |
||||
return ((data && data.text) || '').replace(/\r\n/g, '\n').trim(); |
||||
} |
||||
return ''; |
||||
} |
||||
@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test'; |
||||
import assert from 'node:assert/strict'; |
||||
import { |
||||
extractTextFromBuffer, |
||||
resolveDocumentKind, |
||||
} from './documentExtractService.js'; |
||||
|
||||
test('resolveDocumentKind: PDF по MIME и по имени', () => { |
||||
assert.equal(resolveDocumentKind('application/pdf'), 'pdf'); |
||||
assert.equal(resolveDocumentKind('', 'X.PDF'), 'pdf'); |
||||
assert.equal(resolveDocumentKind('application/octet-stream', 'a.pdf'), 'pdf'); |
||||
}); |
||||
|
||||
test('resolveDocumentKind: docx, txt, неизвестно', () => { |
||||
assert.equal( |
||||
resolveDocumentKind( |
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' |
||||
), |
||||
'docx' |
||||
); |
||||
assert.equal(resolveDocumentKind('text/plain', 'x.txt'), 'text'); |
||||
assert.equal(resolveDocumentKind('', 'readme.md'), 'text'); |
||||
assert.equal(resolveDocumentKind('image/png'), null); |
||||
assert.equal(resolveDocumentKind('application/octet-stream', 'a.exe'), null); |
||||
}); |
||||
|
||||
test('extractTextFromBuffer: text UTF-8', async () => { |
||||
const t = await extractTextFromBuffer( |
||||
'text', |
||||
Buffer.from('Проверка D.2', 'utf8') |
||||
); |
||||
assert.equal(t, 'Проверка D.2'); |
||||
}); |
||||
@ -0,0 +1,176 @@
|
||||
/** |
||||
* D.3 — генерация структуры теста из извлечённого текста (OpenAI-совместимый Chat Completions). |
||||
* Ключ: DEEPSEEK_API_KEY (по умолчанию api.deepseek.com) или OPENAI_API_KEY. Опц.: LLM_BASE_URL, LLM_MODEL. |
||||
*/ |
||||
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js'; |
||||
|
||||
const MAX_EXTRACT_CHARS = 14000; |
||||
|
||||
/** |
||||
* @param {string} text |
||||
* @returns {string} |
||||
*/ |
||||
export function parseJsonFromLlmText(text) { |
||||
if (typeof text !== 'string' || !text.trim()) { |
||||
const e = new Error('Пустой ответ модели.'); |
||||
e.code = 'llm_empty'; |
||||
throw e; |
||||
} |
||||
let t = text.trim(); |
||||
const fence = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(t); |
||||
if (fence) { |
||||
t = fence[1].trim(); |
||||
} |
||||
let parsed; |
||||
try { |
||||
parsed = JSON.parse(t); |
||||
} catch (err) { |
||||
const e = new Error('Ответ модели не является корректным JSON.'); |
||||
e.code = 'llm_json_parse'; |
||||
throw e; |
||||
} |
||||
return parsed; |
||||
} |
||||
|
||||
/** |
||||
* @param {unknown} o |
||||
* @returns {{ title: string, description: string | null, questions: Array<{ text: string, hasMultipleAnswers: boolean, options: Array<{ text: string, isCorrect: boolean }> }> }} |
||||
*/ |
||||
export function validateAndNormalizeDraft(o) { |
||||
if (!o || typeof o !== 'object') { |
||||
const e = new Error('JSON не содержит объекта с данными.'); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
const title = String((/** @type {any} */ (o)).title ?? '').trim(); |
||||
if (!title) { |
||||
const e = new Error('В ответе нет поля title.'); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
const desc = (/** @type {any} */ (o)).description; |
||||
const description = |
||||
desc != null && String(desc).trim() ? String(desc).trim() : null; |
||||
const rawQs = (/** @type {any} */ (o)).questions; |
||||
if (!Array.isArray(rawQs) || rawQs.length === 0) { |
||||
const e = new Error('В ответе нет вопросов (questions).'); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
if (rawQs.length > 40) { |
||||
const e = new Error('Слишком много вопросов в ответе (макс. 40).'); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
const questions = rawQs.map((q, i) => { |
||||
if (!q || typeof q !== 'object') { |
||||
const e = new Error(`Вопрос ${i + 1}: неверный формат.`); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
const text = String((/** @type {any} */ (q)).text ?? '').trim(); |
||||
if (!text) { |
||||
const e = new Error(`Вопрос ${i + 1}: пустой текст.`); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
const hasMultipleAnswers = Boolean( |
||||
(/** @type {any} */ (q)).hasMultipleAnswers |
||||
); |
||||
const rawOpts = (/** @type {any} */ (q)).options; |
||||
if (!Array.isArray(rawOpts) || rawOpts.length < 2) { |
||||
const e = new Error(`Вопрос ${i + 1}: нужны минимум 2 варианта ответа.`); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
if (rawOpts.length > 12) { |
||||
const e = new Error(`Вопрос ${i + 1}: слишком много вариантов (макс. 12).`); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
const options = rawOpts.map((op, j) => { |
||||
if (!op || typeof op !== 'object') { |
||||
const e = new Error( |
||||
`Вопрос ${i + 1}, вариант ${j + 1}: неверный формат.` |
||||
); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
return { |
||||
text: String((/** @type {any} */ (op)).text ?? '').trim() || `Вариант ${j + 1}`, |
||||
isCorrect: Boolean((/** @type {any} */ (op)).isCorrect), |
||||
}; |
||||
}); |
||||
const correctN = options.filter((x) => x.isCorrect).length; |
||||
if (correctN === 0) { |
||||
const e = new Error( |
||||
`Вопрос ${i + 1}: отметьте минимум один правильный вариант.` |
||||
); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
if (!hasMultipleAnswers && correctN > 1) { |
||||
const e = new Error( |
||||
`Вопрос ${i + 1}: с одним правильным ответом должен быть один вариант isCorrect, либо укажите hasMultipleAnswers: true.` |
||||
); |
||||
e.code = 'llm_shape'; |
||||
throw e; |
||||
} |
||||
return { text, hasMultipleAnswers, options }; |
||||
}); |
||||
return { title, description, questions }; |
||||
} |
||||
|
||||
/** |
||||
* D.1/D.2/D.3 — ответ для POST /import/document (клиент не получает сырые ключи). |
||||
* @param {string} extractedText |
||||
*/ |
||||
export async function generationForImportDocument(extractedText) { |
||||
const text = (extractedText || '').trim(); |
||||
if (!text) { |
||||
return { |
||||
available: false, |
||||
message: 'Нет извлечённого текста — нечего передавать в модель.', |
||||
}; |
||||
} |
||||
const cfg = getLlmConfig(); |
||||
if (!cfg) { |
||||
return { |
||||
available: false, |
||||
message: |
||||
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY (см. backend/.env.example). Ниже — превью текста; можно вставить в черновик вручную.', |
||||
textPreview: text.slice(0, 4000), |
||||
}; |
||||
} |
||||
const slice = |
||||
text.length > MAX_EXTRACT_CHARS |
||||
? `${text.slice(0, MAX_EXTRACT_CHARS)}\n\n[…фрагмент обрезан для API]` |
||||
: text; |
||||
try { |
||||
const system = |
||||
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {"title": string, "description"?: string, "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.'; |
||||
const user = |
||||
'Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n' + slice; |
||||
const raw = await chatCompletionTextContent(cfg, system, user, 0.25); |
||||
const parsed = parseJsonFromLlmText(raw); |
||||
const draft = validateAndNormalizeDraft(parsed); |
||||
return { |
||||
available: true, |
||||
message: `Сгенерировано: «${draft.title}», вопросов: ${draft.questions.length}. Нажмите «Применить сгенерированный черновик» ниже.`, |
||||
draft: { |
||||
title: draft.title, |
||||
description: draft.description, |
||||
questions: draft.questions, |
||||
}, |
||||
}; |
||||
} catch (e) { |
||||
const msg = e instanceof Error ? e.message : String(e); |
||||
const code = e instanceof Error && 'code' in e ? (/** @type {any} */ (e)).code : 'llm_error'; |
||||
return { |
||||
available: false, |
||||
message: `Генерация не удалась: ${msg}`, |
||||
errorCode: code, |
||||
textPreview: text.slice(0, 4000), |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,63 @@
|
||||
import { test } from 'node:test'; |
||||
import assert from 'node:assert/strict'; |
||||
import { |
||||
parseJsonFromLlmText, |
||||
validateAndNormalizeDraft, |
||||
} from './documentGenService.js'; |
||||
|
||||
test('parseJsonFromLlmText: чистый JSON', () => { |
||||
const o = parseJsonFromLlmText('{"title":"T","questions":[{"text":"Q","options":[{"text":"a","isCorrect":true},{"text":"b","isCorrect":false}]}]}'); |
||||
assert.equal(o.title, 'T'); |
||||
assert.equal(o.questions.length, 1); |
||||
}); |
||||
|
||||
test('parseJsonFromLlmText: JSON в markdown-заборе', () => { |
||||
const raw = '```json\n{"title":"X","questions":[{"text":"1","options":[{"text":"+","isCorrect":true},{"text":"-","isCorrect":false}]}]}\n```'; |
||||
const o = parseJsonFromLlmText(raw); |
||||
assert.equal(o.title, 'X'); |
||||
}); |
||||
|
||||
test('parseJsonFromLlmText: невалидный JSON — ошибка', () => { |
||||
assert.throws( |
||||
() => parseJsonFromLlmText('not json'), |
||||
/JSON/i |
||||
); |
||||
}); |
||||
|
||||
test('validateAndNormalizeDraft: валидный черновик', () => { |
||||
const d = validateAndNormalizeDraft({ |
||||
title: ' Экзамен ', |
||||
description: ' оп ', |
||||
questions: [ |
||||
{ |
||||
text: '2+2?', |
||||
hasMultipleAnswers: false, |
||||
options: [ |
||||
{ text: '4', isCorrect: true }, |
||||
{ text: '5', isCorrect: false }, |
||||
], |
||||
}, |
||||
], |
||||
}); |
||||
assert.equal(d.title, 'Экзамен'); |
||||
assert.equal(d.description, 'оп'); |
||||
assert.equal(d.questions[0].options.length, 2); |
||||
}); |
||||
|
||||
test('validateAndNormalizeDraft: нет title', () => { |
||||
assert.throws( |
||||
() => |
||||
validateAndNormalizeDraft({ |
||||
questions: [ |
||||
{ |
||||
text: 'Q', |
||||
options: [ |
||||
{ text: 'a', isCorrect: true }, |
||||
{ text: 'b', isCorrect: false }, |
||||
], |
||||
}, |
||||
], |
||||
}), |
||||
/title/i |
||||
); |
||||
}); |
||||
@ -0,0 +1,98 @@
|
||||
/** |
||||
* OpenAI-совместимый Chat Completions. Общий для импорта и редактора. |
||||
*/ |
||||
|
||||
/** |
||||
* @returns {null | { provider: string, apiKey: string, baseUrl: string, model: string }} |
||||
*/ |
||||
export function getLlmConfig() { |
||||
if (process.env.DEEPSEEK_API_KEY) { |
||||
return { |
||||
provider: 'deepseek', |
||||
apiKey: process.env.DEEPSEEK_API_KEY, |
||||
baseUrl: (process.env.LLM_BASE_URL || 'https://api.deepseek.com/v1').replace( |
||||
/\/+$/, |
||||
'' |
||||
), |
||||
model: process.env.LLM_MODEL || 'deepseek-chat', |
||||
}; |
||||
} |
||||
if (process.env.OPENAI_API_KEY) { |
||||
return { |
||||
provider: 'openai', |
||||
apiKey: process.env.OPENAI_API_KEY, |
||||
baseUrl: (process.env.LLM_BASE_URL || 'https://api.openai.com/v1').replace( |
||||
/\/+$/, |
||||
'' |
||||
), |
||||
model: process.env.LLM_MODEL || 'gpt-4o-mini', |
||||
}; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* @param {{ baseUrl: string, apiKey: string, model: string }} cfg |
||||
* @param {string} system |
||||
* @param {string} user |
||||
* @param {number} [temperature] |
||||
* @returns {Promise<string>} raw assistant message |
||||
*/ |
||||
export async function chatCompletionTextContent(cfg, system, user, temperature = 0.25) { |
||||
const url = `${cfg.baseUrl}/chat/completions`; |
||||
const body = { |
||||
model: cfg.model, |
||||
messages: [ |
||||
{ role: 'system', content: system }, |
||||
{ role: 'user', content: user }, |
||||
], |
||||
temperature, |
||||
}; |
||||
if (process.env.LLM_NO_JSON !== '1') { |
||||
body.response_format = { type: 'json_object' }; |
||||
} |
||||
const ac = new AbortController(); |
||||
const t = setTimeout(() => ac.abort(), 120000); |
||||
let res; |
||||
try { |
||||
res = await fetch(url, { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
Authorization: `Bearer ${cfg.apiKey}`, |
||||
}, |
||||
body: JSON.stringify(body), |
||||
signal: ac.signal, |
||||
}); |
||||
} catch (e) { |
||||
if (e.name === 'AbortError') { |
||||
const err = new Error('Превышен таймаут ожидания ответа LLM (120 с).'); |
||||
err.code = 'llm_timeout'; |
||||
throw err; |
||||
} |
||||
const err = new Error( |
||||
e instanceof Error ? e.message : 'Сбой сети при обращении к LLM' |
||||
); |
||||
err.code = 'llm_network'; |
||||
throw err; |
||||
} finally { |
||||
clearTimeout(t); |
||||
} |
||||
if (!res.ok) { |
||||
const errText = await res.text(); |
||||
const err = new Error( |
||||
`LLM ${res.status}: ${errText.replace(/\s+/g, ' ').slice(0, 280)}` |
||||
); |
||||
err.code = 'llm_http'; |
||||
err.status = res.status; |
||||
throw err; |
||||
} |
||||
const data = await res.json(); |
||||
const content = data?.choices?.[0]?.message?.content; |
||||
if (typeof content !== 'string' || !content.trim()) { |
||||
const e = new Error('Пустой content в ответе API.'); |
||||
e.code = 'llm_empty'; |
||||
throw e; |
||||
} |
||||
return content; |
||||
} |
||||
@ -0,0 +1,64 @@
|
||||
/** |
||||
* Кто видит тест: автор цепочки и пользователи с назначением (target user = clinic user id). |
||||
*/ |
||||
import { isTestAuthor } from '../config/devAuthor.js'; |
||||
import { query } from '../db/db.js'; |
||||
|
||||
/** |
||||
* @param {string} userId |
||||
* @param {string} testId |
||||
* @returns {Promise<{ ok: boolean, isAuthor: boolean, notFound: boolean }>} |
||||
*/ |
||||
export async function userHasTestAccess(userId, testId) { |
||||
const { rows } = await query( |
||||
`SELECT t.created_by FROM tests t WHERE t.id = $1`, |
||||
[testId] |
||||
); |
||||
if (!rows.length) { |
||||
return { ok: false, isAuthor: false, notFound: true }; |
||||
} |
||||
if (isTestAuthor(rows[0].created_by, userId)) { |
||||
return { ok: true, isAuthor: true, notFound: false }; |
||||
} |
||||
const { rows: ar } = await query( |
||||
`SELECT 1
|
||||
FROM test_assignments ta |
||||
INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id |
||||
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id |
||||
WHERE tv_a.test_id = $1 |
||||
AND tat.target_type = 'user' |
||||
AND tat.target_id = $2 |
||||
LIMIT 1`,
|
||||
[testId, userId] |
||||
); |
||||
return { ok: ar.length > 0, isAuthor: false, notFound: false }; |
||||
} |
||||
|
||||
/** |
||||
* Список тестов в каталоге: только `is_active` цепочка + (автор OR назначен). |
||||
*/ |
||||
export async function queryTestsVisibleToUser(userId) { |
||||
return query( |
||||
`SELECT DISTINCT t.id, t.title, t.description, t.is_active AS chain_active,
|
||||
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, |
||||
t.created_by, u.full_name AS author_full_name |
||||
FROM tests t |
||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true |
||||
INNER JOIN users u ON u.id = t.created_by |
||||
WHERE t.is_active = true |
||||
AND ( |
||||
t.created_by = $1 |
||||
OR EXISTS ( |
||||
SELECT 1 |
||||
FROM test_assignments ta |
||||
INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id |
||||
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id |
||||
WHERE tv2.test_id = t.id |
||||
AND tat.target_type = 'user' |
||||
AND tat.target_id = $1 |
||||
) |
||||
) |
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
|
||||
[userId] |
||||
); |
||||
} |
||||
@ -0,0 +1,477 @@
|
||||
/** |
||||
* Прохождение теста: контент для игры, проверка ответов, завершение попытки. |
||||
*/ |
||||
import { RU } from '../messages/ru.js'; |
||||
import { isTestAuthor } from '../config/devAuthor.js'; |
||||
|
||||
/** |
||||
* @param {import('pg').Pool|import('pg').PoolClient} db |
||||
* @param {string} testVersionId |
||||
* @param {{ includeCorrect: boolean }} opts |
||||
*/ |
||||
export async function loadQuestionsForVersion(db, testVersionId, opts) { |
||||
const { rows: qrows } = await db.query( |
||||
`SELECT id, text, question_order, has_multiple_answers
|
||||
FROM questions |
||||
WHERE test_version_id = $1 |
||||
ORDER BY question_order`,
|
||||
[testVersionId] |
||||
); |
||||
const out = []; |
||||
for (const row of qrows) { |
||||
const { rows: orows } = await db.query( |
||||
`SELECT id, text, is_correct, option_order
|
||||
FROM answer_options |
||||
WHERE question_id = $1 |
||||
ORDER BY option_order`,
|
||||
[row.id] |
||||
); |
||||
const options = orows.map((o) => { |
||||
const base = { |
||||
id: o.id, |
||||
text: o.text, |
||||
optionOrder: o.option_order, |
||||
}; |
||||
if (opts.includeCorrect) { |
||||
return { ...base, isCorrect: o.is_correct }; |
||||
} |
||||
return base; |
||||
}); |
||||
out.push({ |
||||
id: row.id, |
||||
text: row.text, |
||||
questionOrder: row.question_order, |
||||
hasMultipleAnswers: row.has_multiple_answers, |
||||
options, |
||||
}); |
||||
} |
||||
return out; |
||||
} |
||||
|
||||
function sortUuidStrings(arr) { |
||||
return [...new Set(arr)].map(String).sort(); |
||||
} |
||||
|
||||
function sameSelection(selected, correctIds) { |
||||
const a = sortUuidStrings(selected); |
||||
const b = sortUuidStrings(correctIds); |
||||
if (a.length !== b.length) { |
||||
return false; |
||||
} |
||||
return a.every((x, i) => x === b[i]); |
||||
} |
||||
|
||||
/** |
||||
* @param {import('pg').Pool} pool |
||||
* @param {string} userId |
||||
* @param {string} testId |
||||
*/ |
||||
export async function getEditorContent(pool, userId, testId) { |
||||
const { rows: tr } = await pool.query( |
||||
`SELECT t.id, t.title, t.description, t.passing_threshold, t.created_by
|
||||
FROM tests t WHERE t.id = $1`,
|
||||
[testId] |
||||
); |
||||
if (!tr.length) { |
||||
const e = new Error(RU.testNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
if (!isTestAuthor(tr[0].created_by, userId)) { |
||||
const e = new Error(RU.forbidden); |
||||
e.status = 403; |
||||
throw e; |
||||
} |
||||
const { rows: tv } = await pool.query( |
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, |
||||
[testId] |
||||
); |
||||
if (!tv.length) { |
||||
const e = new Error(RU.noActiveVersion); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const versionId = tv[0].id; |
||||
const questions = await loadQuestionsForVersion(pool, versionId, { |
||||
includeCorrect: true, |
||||
}); |
||||
return { |
||||
test: { |
||||
id: tr[0].id, |
||||
title: tr[0].title, |
||||
description: tr[0].description, |
||||
passingThreshold: tr[0].passing_threshold, |
||||
}, |
||||
activeVersionId: versionId, |
||||
questions, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* @param {import('pg').Pool} pool |
||||
* @param {string} userId |
||||
* @param {string} testId |
||||
* @param {string} attemptId |
||||
*/ |
||||
export async function getPlayContent(pool, userId, testId, attemptId) { |
||||
const { rows: arows } = await pool.query( |
||||
`SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, t.title, t.passing_threshold
|
||||
FROM test_attempts ta |
||||
INNER JOIN test_versions tv ON tv.id = ta.test_version_id |
||||
INNER JOIN tests t ON t.id = tv.test_id |
||||
WHERE ta.id = $1`,
|
||||
[attemptId] |
||||
); |
||||
if (!arows.length) { |
||||
const e = new Error(RU.attemptNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
const a = arows[0]; |
||||
if (a.test_id !== testId) { |
||||
const e = new Error(RU.attemptNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
if (a.user_id !== userId) { |
||||
const e = new Error(RU.forbidden); |
||||
e.status = 403; |
||||
throw e; |
||||
} |
||||
if (a.status !== 'in_progress') { |
||||
const e = new Error(RU.attemptNotInProgress); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const questions = await loadQuestionsForVersion(pool, a.test_version_id, { |
||||
includeCorrect: false, |
||||
}); |
||||
return { |
||||
testTitle: a.title, |
||||
passingThreshold: a.passing_threshold, |
||||
attemptId: a.id, |
||||
questions, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* @param {import('pg').Pool} pool |
||||
* @param {string} userId |
||||
* @param {string} testId |
||||
* @param {string} attemptId |
||||
* @param {Record<string, string | string[] | undefined> | null | undefined} rawAnswers |
||||
*/ |
||||
export async function submitAttempt(pool, userId, testId, attemptId, rawAnswers) { |
||||
const answers = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {}; |
||||
const client = await pool.connect(); |
||||
try { |
||||
await client.query('BEGIN'); |
||||
const { rows: arows } = await client.query( |
||||
`SELECT id, user_id, status, test_version_id
|
||||
FROM test_attempts |
||||
WHERE id = $1 |
||||
FOR UPDATE`,
|
||||
[attemptId] |
||||
); |
||||
if (!arows.length) { |
||||
const e = new Error(RU.attemptNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
const a0 = arows[0]; |
||||
const { rows: trows } = await client.query( |
||||
`SELECT t.passing_threshold, tv.test_id
|
||||
FROM test_versions tv |
||||
INNER JOIN tests t ON t.id = tv.test_id |
||||
WHERE tv.id = $1`,
|
||||
[a0.test_version_id] |
||||
); |
||||
if (!trows.length) { |
||||
const e = new Error(RU.testNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
const link = trows[0]; |
||||
const a = { |
||||
test_id: link.test_id, |
||||
user_id: a0.user_id, |
||||
status: a0.status, |
||||
test_version_id: a0.test_version_id, |
||||
passing_threshold: link.passing_threshold, |
||||
}; |
||||
if (a.test_id !== testId) { |
||||
const e = new Error(RU.attemptNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
if (a.user_id !== userId) { |
||||
const e = new Error(RU.forbidden); |
||||
e.status = 403; |
||||
throw e; |
||||
} |
||||
if (a.status !== 'in_progress') { |
||||
const e = new Error(RU.attemptNotInProgress); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const versionId = a.test_version_id; |
||||
const threshold = Number(a.passing_threshold) || 0; |
||||
|
||||
const { rows: qrows } = await client.query( |
||||
`SELECT id, has_multiple_answers
|
||||
FROM questions |
||||
WHERE test_version_id = $1`,
|
||||
[versionId] |
||||
); |
||||
if (!qrows.length) { |
||||
const e = new Error(RU.testHasNoQuestions); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
|
||||
const { rows: allOpts } = await client.query( |
||||
`SELECT a.id, a.question_id, a.is_correct
|
||||
FROM answer_options a |
||||
INNER JOIN questions q ON q.id = a.question_id |
||||
WHERE q.test_version_id = $1`,
|
||||
[versionId] |
||||
); |
||||
const byQuestion = new Map(); |
||||
for (const o of allOpts) { |
||||
if (!byQuestion.has(o.question_id)) { |
||||
byQuestion.set(o.question_id, { all: new Set(), correct: [] }); |
||||
} |
||||
const g = byQuestion.get(o.question_id); |
||||
g.all.add(String(o.id)); |
||||
if (o.is_correct) { |
||||
g.correct.push(String(o.id)); |
||||
} |
||||
} |
||||
|
||||
let correctCount = 0; |
||||
for (const q of qrows) { |
||||
const qid = String(q.id); |
||||
let selected = answers[qid] ?? answers[q.id]; |
||||
if (selected == null) { |
||||
selected = []; |
||||
} else if (!Array.isArray(selected)) { |
||||
selected = [String(selected)]; |
||||
} else { |
||||
selected = selected.map(String); |
||||
} |
||||
const g = byQuestion.get(q.id); |
||||
if (!g) { |
||||
continue; |
||||
} |
||||
for (const sid of selected) { |
||||
if (!g.all.has(sid)) { |
||||
const e = new Error(RU.invalidOptionForQuestion); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
} |
||||
if (sameSelection(selected, g.correct)) { |
||||
correctCount += 1; |
||||
} |
||||
} |
||||
const total = qrows.length; |
||||
const percent = (correctCount / total) * 100; |
||||
const passed = percent + 1e-9 >= threshold; |
||||
|
||||
await client.query(`DELETE FROM user_answers WHERE attempt_id = $1`, [attemptId]); |
||||
for (const q of qrows) { |
||||
const qid = String(q.id); |
||||
let selected = answers[qid] ?? answers[q.id] ?? []; |
||||
if (!Array.isArray(selected)) { |
||||
selected = [String(selected)]; |
||||
} else { |
||||
selected = selected.map(String); |
||||
} |
||||
await client.query( |
||||
`INSERT INTO user_answers (attempt_id, question_id, selected_options)
|
||||
VALUES ($1, $2, $3::uuid[])`,
|
||||
[attemptId, q.id, selected] |
||||
); |
||||
} |
||||
await client.query( |
||||
`UPDATE test_attempts
|
||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP, |
||||
correct_count = $2, total_questions = $3, passed = $4 |
||||
WHERE id = $1`,
|
||||
[attemptId, correctCount, total, passed] |
||||
); |
||||
await client.query('COMMIT'); |
||||
const base = { |
||||
attemptId, |
||||
correctCount, |
||||
totalQuestions: total, |
||||
percent: Math.round(percent * 10) / 10, |
||||
passed, |
||||
passingThreshold: threshold, |
||||
}; |
||||
const review = await buildReviewFromDb(pool, attemptId); |
||||
return { ...base, review }; |
||||
} catch (e) { |
||||
await client.query('ROLLBACK'); |
||||
throw e; |
||||
} finally { |
||||
client.release(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Подробный разбор завершённой попытки (для API и ответа submit). |
||||
* @param {import('pg').Pool|import('pg').PoolClient} pool |
||||
* @param {string} attemptId |
||||
*/ |
||||
export async function buildReviewFromDb(pool, attemptId) { |
||||
const { rows: arows } = await pool.query( |
||||
`SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions,
|
||||
ta.passed, ta.started_at, ta.completed_at, |
||||
t.id AS test_id, t.title, t.passing_threshold, |
||||
u.full_name AS attempter_name, u.login AS attempter_login |
||||
FROM test_attempts ta |
||||
INNER JOIN test_versions tv ON tv.id = ta.test_version_id |
||||
INNER JOIN tests t ON t.id = tv.test_id |
||||
INNER JOIN users u ON u.id = ta.user_id |
||||
WHERE ta.id = $1`,
|
||||
[attemptId] |
||||
); |
||||
if (!arows.length) { |
||||
const e = new Error(RU.attemptNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
const a = arows[0]; |
||||
if (a.status !== 'completed') { |
||||
const e = new Error(RU.attemptNotCompleted); |
||||
e.status = 400; |
||||
throw e; |
||||
} |
||||
const questions = await loadQuestionsForVersion(pool, a.test_version_id, { |
||||
includeCorrect: true, |
||||
}); |
||||
const { rows: uans } = await pool.query( |
||||
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`, |
||||
[attemptId] |
||||
); |
||||
const selByQ = new Map(); |
||||
for (const r of uans) { |
||||
selByQ.set(String(r.question_id), (r.selected_options || []).map(String)); |
||||
} |
||||
const threshold = Number(a.passing_threshold) || 0; |
||||
const total = a.total_questions || questions.length; |
||||
const percent = |
||||
total > 0 |
||||
? Math.round(((a.correct_count || 0) / total) * 1000) / 10 |
||||
: 0; |
||||
const qOut = questions.map((q) => { |
||||
const selected = sortUuidStrings(selByQ.get(String(q.id)) || []); |
||||
const correctIdList = sortUuidStrings( |
||||
q.options.filter((o) => o.isCorrect).map((o) => String(o.id)) |
||||
); |
||||
const isUserCorrect = sameSelection(selected, correctIdList); |
||||
const selectedSet = new Set(selected); |
||||
return { |
||||
id: q.id, |
||||
text: q.text, |
||||
hasMultipleAnswers: q.hasMultipleAnswers, |
||||
isUserCorrect, |
||||
options: q.options.map((o) => ({ |
||||
id: o.id, |
||||
text: o.text, |
||||
isCorrect: o.isCorrect, |
||||
selected: selectedSet.has(String(o.id)), |
||||
})), |
||||
}; |
||||
}); |
||||
return { |
||||
attemptId: a.id, |
||||
testId: a.test_id, |
||||
testTitle: a.title, |
||||
passingThreshold: threshold, |
||||
correctCount: a.correct_count, |
||||
totalQuestions: total, |
||||
percent, |
||||
passed: a.passed, |
||||
startedAt: a.started_at, |
||||
completedAt: a.completed_at, |
||||
attempterUserId: a.user_id, |
||||
attempterName: a.attempter_name, |
||||
attempterLogin: a.attempter_login, |
||||
questions: qOut, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Разбор попытки: владелец попытки или автор теста. |
||||
* @param {import('pg').Pool} pool |
||||
* @param {string} currentUserId |
||||
* @param {string} testId |
||||
* @param {string} attemptId |
||||
*/ |
||||
export async function getAttemptReviewForUser(pool, currentUserId, testId, attemptId) { |
||||
const { rows } = await pool.query( |
||||
`SELECT ta.user_id, t.created_by, tv.test_id
|
||||
FROM test_attempts ta |
||||
INNER JOIN test_versions tv ON tv.id = ta.test_version_id |
||||
INNER JOIN tests t ON t.id = tv.test_id |
||||
WHERE ta.id = $1`,
|
||||
[attemptId] |
||||
); |
||||
if (!rows.length) { |
||||
const e = new Error(RU.attemptNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
const r0 = rows[0]; |
||||
if (r0.test_id !== testId) { |
||||
const e = new Error(RU.attemptNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
const isOwner = r0.user_id === currentUserId; |
||||
const isAuthor = isTestAuthor(r0.created_by, currentUserId); |
||||
if (!isOwner && !isAuthor) { |
||||
const e = new Error(RU.forbidden); |
||||
e.status = 403; |
||||
throw e; |
||||
} |
||||
return buildReviewFromDb(pool, attemptId); |
||||
} |
||||
|
||||
/** |
||||
* Список всех попыток по цепочке (все версии) — только автор. |
||||
* @param {import('pg').Pool} pool |
||||
* @param {string} authorId |
||||
* @param {string} testId |
||||
*/ |
||||
export async function listTestAttemptsForAuthor(pool, authorId, testId) { |
||||
const { rows: t } = await pool.query( |
||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||
[testId] |
||||
); |
||||
if (!t.length) { |
||||
const e = new Error(RU.testNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
if (!isTestAuthor(t[0].created_by, authorId)) { |
||||
const e = new Error(RU.forbidden); |
||||
e.status = 403; |
||||
throw e; |
||||
} |
||||
const { rows } = await pool.query( |
||||
`SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at,
|
||||
ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, |
||||
u.full_name AS attempter_name, u.login AS attempter_login |
||||
FROM test_attempts ta |
||||
INNER JOIN test_versions tv ON tv.id = ta.test_version_id |
||||
INNER JOIN users u ON u.id = ta.user_id |
||||
WHERE tv.test_id = $1 |
||||
ORDER BY ta.started_at DESC NULLS LAST |
||||
LIMIT 200`,
|
||||
[testId] |
||||
); |
||||
return rows; |
||||
} |
||||
@ -0,0 +1,18 @@
|
||||
/** |
||||
* Логика «цепочки» теста: попытки и версии (см. docs/revision_task/card1.md V.2). |
||||
* @param {import('pg').Pool | { query: Function }} pool пул или объект с методом query(sql, params) |
||||
* @param {string} testId UUID теста (tests.id) |
||||
* @returns {Promise<boolean>} true, если по любой версии этой цепочки есть хотя бы одна попытка |
||||
*/ |
||||
export async function hasAnyAttemptForTest(pool, testId) { |
||||
const { rows } = await pool.query( |
||||
`SELECT EXISTS (
|
||||
SELECT 1 |
||||
FROM test_attempts ta |
||||
INNER JOIN test_versions tv ON ta.test_version_id = tv.id |
||||
WHERE tv.test_id = $1 |
||||
) AS has_any`,
|
||||
[testId] |
||||
); |
||||
return rows[0].has_any === true; |
||||
} |
||||
@ -0,0 +1,23 @@
|
||||
import { test } from 'node:test'; |
||||
import assert from 'node:assert/strict'; |
||||
import { hasAnyAttemptForTest } from './testChainService.js'; |
||||
|
||||
test('hasAnyAttemptForTest: false, если в базе пусто', async () => { |
||||
const pool = { |
||||
async query() { |
||||
return { rows: [{ has_any: false }] }; |
||||
}, |
||||
}; |
||||
const result = await hasAnyAttemptForTest(pool, '00000000-0000-0000-0000-000000000001'); |
||||
assert.equal(result, false); |
||||
}); |
||||
|
||||
test('hasAnyAttemptForTest: true, если есть попытка', async () => { |
||||
const pool = { |
||||
async query() { |
||||
return { rows: [{ has_any: true }] }; |
||||
}, |
||||
}; |
||||
const result = await hasAnyAttemptForTest(pool, '00000000-0000-0000-0000-000000000001'); |
||||
assert.equal(result, true); |
||||
}); |
||||
@ -0,0 +1,218 @@
|
||||
/** |
||||
* V.3 saveTestDraft, fork версии, контент вопросов. |
||||
*/ |
||||
import { hasAnyAttemptForTest } from './testChainService.js'; |
||||
import { RU } from '../messages/ru.js'; |
||||
import { isTestAuthor } from '../config/devAuthor.js'; |
||||
|
||||
/** |
||||
* @param {import('pg').PoolClient} client |
||||
* @param {string} testId |
||||
*/ |
||||
export async function getActiveVersionRow(client, testId) { |
||||
const { rows } = await client.query( |
||||
`SELECT * FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, |
||||
[testId] |
||||
); |
||||
return rows[0] || null; |
||||
} |
||||
|
||||
/** |
||||
* @param {import('pg').PoolClient} client |
||||
* @param {string} fromVersionId |
||||
* @param {string} toVersionId |
||||
*/ |
||||
export async function copyQuestionTree(client, fromVersionId, toVersionId) { |
||||
const { rows: questions } = await client.query( |
||||
`SELECT * FROM questions WHERE test_version_id = $1 ORDER BY question_order`, |
||||
[fromVersionId] |
||||
); |
||||
for (const q of questions) { |
||||
const { rows: insQ } = await client.query( |
||||
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[toVersionId, q.text, q.question_order, q.has_multiple_answers] |
||||
); |
||||
const nqid = insQ[0].id; |
||||
const { rows: options } = await client.query( |
||||
`SELECT * FROM answer_options WHERE question_id = $1 ORDER BY option_order`, |
||||
[q.id] |
||||
); |
||||
for (const o of options) { |
||||
await client.query( |
||||
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[nqid, o.text, o.is_correct, o.option_order] |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @param {import('pg').PoolClient} client |
||||
* @param {string} testVersionId |
||||
* @param {{ questions?: Array<{ text: string, question_order?: number, hasMultipleAnswers?: boolean, options?: Array<{ text: string, isCorrect?: boolean, option_order?: number }> }> }} payload |
||||
*/ |
||||
export async function replaceVersionContent(client, testVersionId, payload) { |
||||
await client.query( |
||||
`DELETE FROM answer_options WHERE question_id IN
|
||||
(SELECT id FROM questions WHERE test_version_id = $1)`,
|
||||
[testVersionId] |
||||
); |
||||
await client.query(`DELETE FROM questions WHERE test_version_id = $1`, [ |
||||
testVersionId, |
||||
]); |
||||
const questions = payload.questions || []; |
||||
for (let i = 0; i < questions.length; i++) { |
||||
const q = questions[i]; |
||||
const { rows: insQ } = await client.query( |
||||
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[ |
||||
testVersionId, |
||||
q.text, |
||||
q.question_order ?? i + 1, |
||||
q.hasMultipleAnswers || false, |
||||
] |
||||
); |
||||
const qid = insQ[0].id; |
||||
const opts = q.options || []; |
||||
for (let j = 0; j < opts.length; j++) { |
||||
const o = opts[j]; |
||||
await client.query( |
||||
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[qid, o.text, !!o.isCorrect, o.option_order ?? j + 1] |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* @param {import('pg').PoolClient} client |
||||
* @param {string} testId |
||||
*/ |
||||
export async function forkNewVersion(client, testId) { |
||||
const av = await getActiveVersionRow(client, testId); |
||||
if (!av) { |
||||
throw new Error(RU.noActiveVersion); |
||||
} |
||||
const { rows: mx } = await client.query( |
||||
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`, |
||||
[testId] |
||||
); |
||||
const nextV = (mx[0].v || 0) + 1; |
||||
// Сначала снять is_active с цепочки: частичный уникальный индекс
|
||||
// uq_test_versions_one_active_per_test — не более одной true на test_id.
|
||||
await client.query( |
||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1`, |
||||
[testId] |
||||
); |
||||
const { rows: nv } = await client.query( |
||||
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES ($1, $2, true, $3) RETURNING *`,
|
||||
[testId, nextV, av.id] |
||||
); |
||||
const newRow = nv[0]; |
||||
await copyQuestionTree(client, av.id, newRow.id); |
||||
return newRow; |
||||
} |
||||
|
||||
/** |
||||
* @param {import('pg').Pool} pool |
||||
* @param {string} authorId |
||||
* @param {string} testId |
||||
* @param {{ title?: string, description?: string, questions?: Array<unknown> }} payload |
||||
*/ |
||||
export async function saveTestDraft(pool, authorId, testId, payload) { |
||||
const { rows: tr } = await pool.query( |
||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||
[testId] |
||||
); |
||||
if (!tr.length) { |
||||
const e = new Error(RU.testNotFound); |
||||
e.status = 404; |
||||
throw e; |
||||
} |
||||
const t = tr[0]; |
||||
if (!isTestAuthor(t.created_by, authorId)) { |
||||
const e = new Error(RU.forbidden); |
||||
e.status = 403; |
||||
throw e; |
||||
} |
||||
|
||||
const client = await pool.connect(); |
||||
let forked = false; |
||||
try { |
||||
await client.query('BEGIN'); |
||||
if (payload.title != null || payload.description != null) { |
||||
await client.query( |
||||
`UPDATE tests SET title = COALESCE($2, title), description = COALESCE($3, description),
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
||||
[testId, payload.title ?? null, payload.description ?? null] |
||||
); |
||||
} |
||||
if (payload.passingThreshold !== undefined && payload.passingThreshold !== null) { |
||||
const raw = Number(payload.passingThreshold); |
||||
if (Number.isFinite(raw)) { |
||||
const pt = Math.max(0, Math.min(100, Math.round(raw))); |
||||
await client.query( |
||||
`UPDATE tests SET passing_threshold = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, |
||||
[testId, pt] |
||||
); |
||||
} |
||||
} |
||||
const hasAttempts = await hasAnyAttemptForTest(client, testId); |
||||
let versionRow = await getActiveVersionRow(client, testId); |
||||
if (!versionRow) { |
||||
const e = new Error(RU.noActiveVersion); |
||||
e.status = 500; |
||||
throw e; |
||||
} |
||||
if (hasAttempts && payload.questions !== undefined) { |
||||
versionRow = await forkNewVersion(client, testId); |
||||
forked = true; |
||||
} |
||||
if (payload.questions) { |
||||
await replaceVersionContent(client, versionRow.id, payload); |
||||
} |
||||
await client.query('COMMIT'); |
||||
return { testId, versionId: versionRow.id, forked }; |
||||
} catch (e) { |
||||
await client.query('ROLLBACK'); |
||||
throw e; |
||||
} finally { |
||||
client.release(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Создать пустой тест (цепочка) с одной версией 1. |
||||
* @param {import('pg').Pool} pool |
||||
* @param {string} authorId |
||||
* @param {{ title: string, description?: string }} meta |
||||
*/ |
||||
export async function createTestWithVersion(pool, authorId, meta) { |
||||
const client = await pool.connect(); |
||||
try { |
||||
await client.query('BEGIN'); |
||||
const { rows: t } = await client.query( |
||||
`INSERT INTO tests (title, description, created_by, is_active, is_versioned)
|
||||
VALUES ($1, $2, $3, true, true) RETURNING id`,
|
||||
[meta.title, meta.description || null, authorId] |
||||
); |
||||
const testId = t[0].id; |
||||
const { rows: v } = await client.query( |
||||
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||
VALUES ($1, 1, true, NULL) RETURNING id`,
|
||||
[testId] |
||||
); |
||||
await client.query('COMMIT'); |
||||
return { testId, versionId: v[0].id }; |
||||
} catch (e) { |
||||
await client.query('ROLLBACK'); |
||||
throw e; |
||||
} finally { |
||||
client.release(); |
||||
} |
||||
} |
||||
@ -0,0 +1,94 @@
|
||||
/** |
||||
* Authentication Utilities |
||||
* Password hashing and JWT token management |
||||
*/ |
||||
|
||||
import { hash, compare } from 'bcryptjs'; |
||||
import jwt from 'jsonwebtoken'; |
||||
import dotenv from 'dotenv'; |
||||
import { checkWerkzeugPassword } from './werkzeugPassword.js'; |
||||
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js'; |
||||
|
||||
dotenv.config(); |
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET; |
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; |
||||
|
||||
// Salt rounds (bcryptjs — тот же формат $2*, без нативной сборки — проще Docker/ARM/musl)
|
||||
const SALT_ROUNDS = 10; |
||||
|
||||
/** |
||||
* Hash a password using bcrypt |
||||
* @param {string} password - Plain text password |
||||
* @returns {Promise<string>} Hashed password |
||||
*/ |
||||
export async function hashPassword(password) { |
||||
return hash(password, SALT_ROUNDS); |
||||
} |
||||
|
||||
/** |
||||
* Compare a plain text password with a hashed password |
||||
* @param {string} password - Plain text password |
||||
* @param {string} hash - Hashed password |
||||
* @returns {Promise<boolean>} True if passwords match |
||||
*/ |
||||
export async function comparePassword(password, hash) { |
||||
if (!hash) { |
||||
return false; |
||||
} |
||||
if (hash === HR_MANAGED_PASSWORD_PLACEHOLDER) { |
||||
return false; |
||||
} |
||||
if (hash.startsWith('scrypt:') || hash.startsWith('pbkdf2:')) { |
||||
return checkWerkzeugPassword(hash, password); |
||||
} |
||||
if (hash.startsWith('$2')) { |
||||
return compare(password, hash); |
||||
} |
||||
return checkWerkzeugPassword(hash, password); |
||||
} |
||||
|
||||
/** |
||||
* @param {string} userId - clinic_tests.users.id |
||||
* @param {string} role |
||||
* @param {string|null|undefined} departmentId |
||||
* @param {{ staffId?: number } | null} [meta] |
||||
* @returns {string} |
||||
*/ |
||||
export function generateToken(userId, role, departmentId = null, meta = null) { |
||||
const payload = { |
||||
userId, |
||||
role, |
||||
}; |
||||
|
||||
if (departmentId !== null && departmentId !== undefined) { |
||||
payload.departmentId = departmentId; |
||||
} |
||||
if (meta?.staffId != null) { |
||||
payload.staffId = meta.staffId; |
||||
} |
||||
|
||||
return jwt.sign(payload, JWT_SECRET, { |
||||
expiresIn: JWT_EXPIRES_IN, |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Verify and decode a JWT token |
||||
* @param {string} token - JWT token |
||||
* @returns {Object|null} Decoded token payload or null if invalid |
||||
*/ |
||||
export function verifyToken(token) { |
||||
try { |
||||
return jwt.verify(token, JWT_SECRET); |
||||
} catch (error) { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
export default { |
||||
hashPassword, |
||||
comparePassword, |
||||
generateToken, |
||||
verifyToken, |
||||
}; |
||||
@ -0,0 +1,21 @@
|
||||
/** |
||||
* Сопоставление role из hr_bot_test.users (varchar) с ролью в модуле тестов. |
||||
* A.5 MVP — уточняется при подключении staff_role_assignments. |
||||
* @param {string|null|undefined} hrRole |
||||
* @returns {'hr' | 'manager' | 'employee'} |
||||
*/ |
||||
export function mapHrRoleToApp(hrRole) { |
||||
const r = String(hrRole || '') |
||||
.toLowerCase() |
||||
.trim(); |
||||
if (!r) { |
||||
return 'employee'; |
||||
} |
||||
if (r === 'admin' || r.includes('hr') || r.includes('дире')) { |
||||
return 'hr'; |
||||
} |
||||
if (r.includes('manager') || r.includes('рук') || r.includes('завед')) { |
||||
return 'manager'; |
||||
} |
||||
return 'employee'; |
||||
} |
||||
@ -0,0 +1,74 @@
|
||||
/** |
||||
* Проверка хеша в формате Werkzeug 3 (scrypt:… / pbkdf2:…). |
||||
* @see https://github.com/pallets/werkzeug/blob/main/src/werkzeug/security.py
|
||||
*/ |
||||
import crypto from 'crypto'; |
||||
|
||||
/** |
||||
* @param {string} pwhash |
||||
* @param {string} password |
||||
* @returns {boolean} |
||||
*/ |
||||
function hashInternal(method, salt, password) { |
||||
const methodParts = method.split(':'); |
||||
const kind = methodParts[0]; |
||||
const saltBytes = Buffer.from(salt, 'utf8'); |
||||
const passwordBytes = Buffer.from(password, 'utf8'); |
||||
|
||||
if (kind === 'scrypt') { |
||||
const n = methodParts[1] ? parseInt(methodParts[1], 10) : 2 ** 15; |
||||
const r = methodParts[2] ? parseInt(methodParts[2], 10) : 8; |
||||
const p = methodParts[3] ? parseInt(methodParts[3], 10) : 1; |
||||
const maxmem = 132 * n * r * p; |
||||
return crypto |
||||
.scryptSync(passwordBytes, saltBytes, 64, { N: n, r, p, maxmem }) |
||||
.toString('hex'); |
||||
} |
||||
|
||||
if (kind === 'pbkdf2') { |
||||
const hashName = methodParts[1] || 'sha256'; |
||||
const iterStr = methodParts[2]; |
||||
if (!iterStr) { |
||||
throw new Error('pbkdf2: missing iterations'); |
||||
} |
||||
const iterations = parseInt(iterStr, 10); |
||||
return crypto |
||||
.pbkdf2Sync(passwordBytes, saltBytes, iterations, 32, hashName) |
||||
.toString('hex'); |
||||
} |
||||
|
||||
throw new Error(`Invalid hash method: ${kind}`); |
||||
} |
||||
|
||||
/** |
||||
* @param {string} pwhash |
||||
* @param {string} password |
||||
* @returns {boolean} |
||||
*/ |
||||
export function checkWerkzeugPassword(pwhash, password) { |
||||
if (!pwhash || pwhash.length < 3) { |
||||
return false; |
||||
} |
||||
const parts = pwhash.split('$'); |
||||
if (parts.length < 3) { |
||||
return false; |
||||
} |
||||
const hashval = parts.pop(); |
||||
const salt = parts.pop(); |
||||
const method = parts.join('$'); |
||||
if (!method || !salt || !hashval) { |
||||
return false; |
||||
} |
||||
let computed; |
||||
try { |
||||
computed = hashInternal(method, salt, password); |
||||
} catch { |
||||
return false; |
||||
} |
||||
const a = Buffer.from(computed, 'hex'); |
||||
const b = Buffer.from(hashval, 'hex'); |
||||
if (a.length !== b.length) { |
||||
return false; |
||||
} |
||||
return crypto.timingSafeEqual(a, b); |
||||
} |
||||
@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test'; |
||||
import assert from 'node:assert/strict'; |
||||
import crypto from 'crypto'; |
||||
import { checkWerkzeugPassword } from './werkzeugPassword.js'; |
||||
|
||||
test('pbkdf2:sha256 self-consistency', () => { |
||||
const salt = 'AbCdEfGhIjKlMnOp'; |
||||
const iterations = 1000; |
||||
const password = 'secret'; |
||||
const hashval = crypto |
||||
.pbkdf2Sync(password, salt, iterations, 32, 'sha256') |
||||
.toString('hex'); |
||||
const pwhash = `pbkdf2:sha256:${iterations}$${salt}$${hashval}`; |
||||
assert.equal(checkWerkzeugPassword(pwhash, 'secret'), true); |
||||
assert.equal(checkWerkzeugPassword(pwhash, 'wrong'), false); |
||||
}); |
||||
|
||||
test('scrypt self-consistency', () => { |
||||
const salt = 'AbCdEfGhIjKlMnOp'; |
||||
const n = 32768; |
||||
const r = 8; |
||||
const p = 1; |
||||
const maxmem = 132 * n * r * p; |
||||
const method = `scrypt:${n}:${r}:${p}`; |
||||
const password = 'x'; |
||||
const h = crypto |
||||
.scryptSync(password, salt, 64, { N: n, r, p, maxmem }) |
||||
.toString('hex'); |
||||
const pwhash = `${method}$${salt}$${h}`; |
||||
assert.equal(checkWerkzeugPassword(pwhash, 'x'), true); |
||||
}); |
||||
@ -0,0 +1,38 @@
|
||||
# Система тестирования + общий Postgres (Postgres_TG_Bots / hr_postgres_dev). |
||||
# Требуется: сеть hr_postgres_dev_net и поднятый hr_postgres_dev. |
||||
# cd ../Postgres_TG_Bots && docker compose -f docker-compose.dev.yml up -d |
||||
# База clinic_tests: один раз |
||||
# psql "postgresql://hr_bot_user:hrbot123@localhost:5432/postgres" -c "CREATE DATABASE clinic_tests;" |
||||
# |
||||
# Flask UI (кабинетный стиль): http://localhost:3107 |
||||
|
||||
services: |
||||
testing-flask: |
||||
build: |
||||
context: ./flask_app |
||||
dockerfile: Dockerfile |
||||
container_name: testing_webapp_flask |
||||
environment: |
||||
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: |
||||
- "3107:3107" |
||||
networks: |
||||
- app |
||||
- postgres |
||||
|
||||
networks: |
||||
app: |
||||
postgres: |
||||
name: hr_postgres_dev_net |
||||
external: true |
||||
@ -0,0 +1,22 @@
|
||||
# По умолчанию этот compose ничего не поднимает — используется ОБЩИЙ Postgres из |
||||
# ../Postgres_TG_Bots/docker-compose.dev.yml (порт 5432 на хосте, сеть hr_postgres_dev_net). |
||||
# В backend/.env: DATABASE_URL=...localhost:5432/clinic_tests (см. backend/.env.example). |
||||
# |
||||
# Только если общий кластер не нужен, изолированный Postgres (порт 5433): |
||||
# docker compose --profile standalone up -d |
||||
|
||||
services: |
||||
postgres: |
||||
profiles: ["standalone"] |
||||
image: postgres:15 |
||||
environment: |
||||
POSTGRES_DB: clinic_tests |
||||
POSTGRES_USER: developer |
||||
POSTGRES_PASSWORD: dev_password |
||||
ports: |
||||
- "5433:5432" |
||||
volumes: |
||||
- postgres_data:/var/lib/postgresql/data |
||||
|
||||
volumes: |
||||
postgres_data: |
||||
@ -0,0 +1,64 @@
|
||||
# Как пользоваться стендом **dev** (простыми словами) |
||||
|
||||
**Для кого:** тот, кто **проверяет** интерфейс на своей машине или на общем dev-сервере, без погрузки в код. |
||||
|
||||
**Адрес по умолчанию:** [http://localhost:3107](http://localhost:3107) — если вы подняли проект командой `docker compose -f docker-compose.dev.yml up` из корня репозитория. В браузере откройте **:3107** (интерфейс); запросы к `/api/…` с этой страницы идут на бэкенд через Nginx. Прямой адрес API с вашего ПК: **http://localhost:3001** (если смотрите health или тестируете curl). |
||||
|
||||
Если кто-то дал **другой URL** (например, внутренний хост клиники) — откройте его; логика та же. |
||||
|
||||
--- |
||||
|
||||
## 1. Вход |
||||
|
||||
1. Откройте в браузере адрес стенда. Должна открыться **страница входа** (логин, пароль, кнопка «Войти»). |
||||
2. Введите **логин и пароль**, которые вам выдали для **этой** базы `clinic_tests` (или HR, если включён `HR_AUTH` — смотрите, что сказал разработчик). |
||||
3. После успешного входа вы попадаете в раздел **«Тесты»**. В **шапке** справа: **Фамилия с инициалами** и роль; слева — название портала. **«Выйти»** завершает сессию. |
||||
|
||||
Если не пускает — не подбирайте пароль: напишите тому, кто администрирует БД или `.env` на стенде. |
||||
|
||||
--- |
||||
|
||||
## 2. Список «Тесты» |
||||
|
||||
- Каждая **строка** — один тест (одна **цепочка**; номер версии в подписи внизу строки). |
||||
- **Слева** — **название** (клик открывает **карточку** теста: настройки, вопросы, назначения — если вы автор, или краткую информацию — если вам только **назначили**). |
||||
- **Справа** — кнопка **«Пройти»**: начать попытку **сразу** по **текущей активной** версии (именно с неё, а не с «старой из назначения»). |
||||
- Под названием: **Автор: Вы** — если вы создали тест; **Автор: Фамилия И. О.** — если тест чужой, но вам **назначен**. |
||||
|
||||
Пустой список: либо вам **ничего не назначили** и вы **не создавали** тесты, либо всё **скрыто** из списка (у автора внизу может быть блок «Скрытые вами»). |
||||
|
||||
--- |
||||
|
||||
## 3. Карточка теста (автор) |
||||
|
||||
Блоки **сворачиваются** (заголовок — тап). Внизу экрана на телефоне часто закреплены **«Сохранить черновик»** и **«К списку»**. |
||||
|
||||
- **О тесте** — название, описание, **порог зачёта**. **Сохранить черновик** — записывает правки. Если по тесту **уже были прогоны**, при изменении **содержимого** система заведёт **новую версию** (и предупредит, что так и задумано). |
||||
- **Вопросы** — тексты вопросов и варианты, отметка верных, **ИИ** (если есть), внизу секции — **«Документ в вопросы»** (загрузка файла и вставка в черновик, если на стенде настроен LLM). |
||||
- **История** — **Версии** (все версии, **сделать активной** с подтверждением) и **Прохождения** (кто сдавал; **Разбор** по вопросам). |
||||
- **Показ в каталоге** — **Видимость** (скрыть из общего списка или снова показать); при включённом назначении — **Кому выдать** (поиск, **«Назначить выбранных»**). |
||||
|
||||
Автор **не** запускает экзамен **с карточки** в том же сценарии, что сотрудник: для самопрохождения — **«Пройти»** в **списке** «Тесты». |
||||
|
||||
--- |
||||
|
||||
## 4. Прохождение и разбор (сотрудник или самопроверка автора) |
||||
|
||||
1. В списке нажмите **«Пройти»** у нужного теста. |
||||
2. Ответьте на вопросы, нажмите **«Завершить тест»**. |
||||
3. Увидите **сводку** (сколько верно, процент, зачёт/незачёт) и **разбор по вопросам** (что отмечено, что было верно). Есть ссылка на **отдельную страницу разбора** — удобно, если нужно вернуться позже. |
||||
|
||||
--- |
||||
|
||||
## 5. Что сказать разработчику, если «что-то не так» |
||||
|
||||
- **«Белый экран» / ошибка** — сделайте скриншот и опишите, **на какой странице** (например, «после Войти» или «после Пройти»). |
||||
- **«Не вижу тест»** — уточните, вы **автор** или вам должны были **назначить**; проверьте блок **скрытых** тестов. |
||||
- **«Сохранил, а версия не та»** — скажите, были ли **уже** попытки у других: после первой попытки **любая** смена содержимого **увеличивает** номер версии **специально**, чтобы старые ответы не «переписывались». |
||||
|
||||
--- |
||||
|
||||
## 6. Где почитать подробности для разработки |
||||
|
||||
- [PROJECT_STATUS.md](PROJECT_STATUS.md) — что в целом сделано и что в планах. |
||||
- [../README.md](../README.md) — Docker, БД, переменные окружения. |
||||
@ -0,0 +1,148 @@
|
||||
# Состояние проекта |
||||
|
||||
**Репозиторий:** [TestingWebApp](https://git.pirogov.ai/l_konstantin/TestingWebApp) · ветка разработки **`dev`** |
||||
**Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** |
||||
**Дата среза:** 2026-04-28 |
||||
|
||||
Не дубль ТЗ, а карта «что реально работает в коде, на каком контуре, |
||||
и что логично сделать дальше». |
||||
|
||||
--- |
||||
|
||||
## 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). |
||||
|
||||
Главный трекер по спринтам — [`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)). |
||||
|
||||
### Долгий бэклог |
||||
|
||||
| Направление | Суть | |
||||
|---|---| |
||||
| Дашборды (ТЗ этап 2) | Единая картина по отделу / клинике, фильтры, история. | |
||||
| MAX / мини-приложение | Встраивание в общий HR-контур клиники. | |
||||
| Таймер, подсказки, медиа в вопросах | Режимы прохождения и вложения — отдельные этапы ТЗ. | |
||||
| E2E и интеграционные тесты | Расширение `V.9`, стабильный CI. | |
||||
| Назначения по отделу | Сроки, лимит попыток, групповые назначения. | |
||||
|
||||
--- |
||||
|
||||
## Связанные документы |
||||
|
||||
- [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,380 @@
|
||||
# Анализ таблиц для тестирования сотрудников |
||||
|
||||
*Модуль **TestingWebApp** использует отдельную БД `clinic_tests` (см. [PROJECT_STATUS.md](PROJECT_STATUS.md) и [README.md](../README.md)). Ниже — разбор **наследуемых** / смежных сущностей в другой схеме, для сравнения и миграционных дискуссий.* |
||||
|
||||
## Обзор существующих таблиц |
||||
|
||||
В базе данных существуют следующие таблицы, связанные с тестированием: |
||||
|
||||
### 1. [`training_questions`](hr_web_viewer/models.py) - Вопросы обучения |
||||
|
||||
| Колонка | Тип | Описание | |
||||
|---------|-----|----------| |
||||
| `id` | integer | Первичный ключ | |
||||
| `position` | text | Должность/категория | |
||||
| `test_type` | text | Тип темы/теста | |
||||
| `question` | text | Текст вопроса | |
||||
| `answer_1` - `answer_12` | text | Варианты ответов | |
||||
| `answer_count` | smallint | Количество правильных ответов | |
||||
|
||||
**Проблемы:** |
||||
- Отсутствует явное указание правильного ответа |
||||
- Нет типа вопроса (одиночный/множественный выбор, текстовый, сопоставление) |
||||
- Нет баллов за вопрос |
||||
- Нет порядка вопросов |
||||
- Поле `position` используется как категория, но не связано с должностями |
||||
|
||||
### 2. [`training_results`](hr_web_viewer/models.py) - Результаты обучения |
||||
|
||||
| Колонка | Тип | Описание | |
||||
|---------|-----|----------| |
||||
| `id` | integer | Первичный ключ | |
||||
| `telegram_id` | bigint | ID сотрудника | |
||||
| `correct_answers` | integer | Правильные ответы | |
||||
| `total_questions` | integer | Всего вопросов | |
||||
| `score` | integer | Балл | |
||||
| `completed_at` | timestamp | Дата завершения | |
||||
| `passed` | boolean | Пройден/не пройден | |
||||
|
||||
**Индексы:** |
||||
- `idx_training_results_telegram_id` - по telegram_id |
||||
|
||||
**Проблемы:** |
||||
- Нет связи с конкретным тестом (test_type) |
||||
- Нет количества попыток |
||||
- Нет детализации по ответам |
||||
|
||||
### 3. [`training_settings`](hr_web_viewer/models.py) - Настройки обучения |
||||
|
||||
| Колонка | Тип | Описание | |
||||
|---------|-----|----------| |
||||
| `id` | integer | Первичный ключ | |
||||
| `position` | varchar(100) | Должность | |
||||
| `question_count` | integer | Количество вопросов (по умолчанию 10) | |
||||
| `passing_score` | integer | Проходной балл (по умолчанию 70) | |
||||
| `time_limit` | integer | Ограничение времени в минутах (по умолчанию 30) | |
||||
| `active` | boolean | Активен/неактивен | |
||||
|
||||
**Индексы:** |
||||
- `idx_training_settings_position` - по position |
||||
|
||||
**Проблемы:** |
||||
- Нет связи с категорией теста |
||||
- Нет ограничения количества попыток |
||||
- Нет настройки случайного порядка вопросов |
||||
|
||||
### 4. [`test_assignments`](hr_web_viewer/models.py) - Назначения тестов |
||||
|
||||
| Колонка | Тип | Описание | |
||||
|---------|-----|----------| |
||||
| `id` | integer | Первичный ключ | |
||||
| `la_name` | text | Название адаптации | |
||||
| `intern_fio` | text | ФИО стажера | |
||||
| `user_credentials` | text | Учетные данные | |
||||
| `test_theme` | text | Тема теста | |
||||
| `test_subtheme` | text | Подтема теста | |
||||
| `attempts_allowed` | integer | Количество попыток | |
||||
| `passing_score` | integer | Проходной балл | |
||||
| `la_id` | integer | Ссылка на адаптацию | |
||||
| `intern_id` | bigint | ID сотрудника (staff_members.id) | |
||||
| `deadline` | timestamp | Срок сдачи | |
||||
|
||||
**Внешние ключи:** |
||||
- `intern_id` -> `staff_members(id)` |
||||
- `la_id` -> `learning_adaptations(id)` |
||||
|
||||
**Проблемы:** |
||||
- Назначения привязаны к конкретным сотрудникам, а не к должностям |
||||
- Нет статуса прохождения |
||||
- Нет связи с результатами |
||||
|
||||
### 5. [`test_table`](hr_web_viewer/models.py) - Таблица тестов |
||||
|
||||
| Колонка | Тип | Описание | |
||||
|---------|-----|----------| |
||||
| `id` | integer | Первичный ключ | |
||||
| `name` | varchar(100) | Название теста | |
||||
|
||||
**Проблемы:** |
||||
- Минимальная структура, практически не используется |
||||
|
||||
### 6. [`corp_groups_tester`](hr_web_viewer/models.py) - Тестировщики (корпоративные группы) |
||||
|
||||
| Колонка | Тип | Описание | |
||||
|---------|-----|----------| |
||||
| `id` | integer | Первичный ключ | |
||||
| `fio` | text | ФИО | |
||||
| `telegram_id` | varchar(20) | Telegram ID | |
||||
| `position` | varchar(200) | Должность | |
||||
| `department` | varchar(200) | Отдел | |
||||
| `phone` | varchar(20) | Телефон | |
||||
| `email` | varchar(100) | Email | |
||||
| `hire_date` | date | Дата приема | |
||||
|
||||
**Проблемы:** |
||||
- Дублирует данные staff_members |
||||
- Не используется в текущей системе |
||||
|
||||
--- |
||||
|
||||
## Рекомендуемая расширенная схема для ClinicTestingApp |
||||
|
||||
### Новые таблицы |
||||
|
||||
#### 1. `tests` - Основные тесты |
||||
|
||||
```sql |
||||
CREATE TABLE tests ( |
||||
id SERIAL PRIMARY KEY, |
||||
name VARCHAR(255) NOT NULL, -- Название теста |
||||
description TEXT, -- Описание |
||||
category VARCHAR(100), -- Категория (тема) |
||||
position VARCHAR(100), -- Должность (nullable - для всех) |
||||
question_count INTEGER DEFAULT 10, -- Количество вопросов в тесте |
||||
time_limit_minutes INTEGER, -- Ограничение времени (null = без ограничений) |
||||
attempts_allowed INTEGER DEFAULT 3, -- Количество попыток |
||||
passing_score_percent INTEGER DEFAULT 70, -- Проходной процент |
||||
random_questions BOOLEAN DEFAULT FALSE, -- Случайный порядок вопросов |
||||
is_active BOOLEAN DEFAULT TRUE, -- Активен |
||||
created_by INTEGER, -- ID администратора |
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
||||
); |
||||
|
||||
CREATE INDEX idx_tests_category ON tests(category); |
||||
CREATE INDEX idx_tests_position ON tests(position); |
||||
``` |
||||
|
||||
#### 2. `test_questions` - Вопросы тестов |
||||
|
||||
```sql |
||||
CREATE TABLE test_questions ( |
||||
id SERIAL PRIMARY KEY, |
||||
test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE, |
||||
question_text TEXT NOT NULL, -- Текст вопроса |
||||
question_type VARCHAR(50) NOT NULL, -- single_choice, multiple_choice, text, matching, ordering |
||||
points INTEGER DEFAULT 1, -- Баллы за вопрос |
||||
question_order INTEGER, -- Порядок вопроса |
||||
explanation TEXT, -- Пояснение к ответу |
||||
is_active BOOLEAN DEFAULT TRUE, |
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
||||
); |
||||
|
||||
CREATE INDEX idx_test_questions_test_id ON test_questions(test_id); |
||||
``` |
||||
|
||||
#### 3. `test_answers` - Ответы на вопросы |
||||
|
||||
```sql |
||||
CREATE TABLE test_answers ( |
||||
id SERIAL PRIMARY KEY, |
||||
question_id INTEGER NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE, |
||||
answer_text TEXT NOT NULL, -- Текст ответа |
||||
is_correct BOOLEAN DEFAULT FALSE, -- Правильный ответ |
||||
answer_order INTEGER, -- Порядок (для сопоставления/порядка) |
||||
points_if_correct INTEGER DEFAULT 1 -- Баллы (если отличаются от question.points) |
||||
); |
||||
|
||||
CREATE INDEX idx_test_answers_question_id ON test_answers(question_id); |
||||
``` |
||||
|
||||
#### 4. `test_assignments_extended` - Расширенные назначения тестов |
||||
|
||||
```sql |
||||
CREATE TABLE test_assignments_extended ( |
||||
id SERIAL PRIMARY KEY, |
||||
test_id INTEGER NOT NULL REFERENCES tests(id) ON DELETE CASCADE, |
||||
staff_id INTEGER NOT NULL REFERENCES staff_members(id) ON DELETE CASCADE, |
||||
assigned_by INTEGER, -- ID администратора |
||||
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
deadline TIMESTAMP, -- Срок сдачи |
||||
attempts_allowed INTEGER, -- Переопределение количества попыток (null = из теста) |
||||
status VARCHAR(50) DEFAULT 'pending', -- pending, in_progress, completed, expired |
||||
UNIQUE(test_id, staff_id) |
||||
); |
||||
|
||||
CREATE INDEX idx_test_assignments_test_id ON test_assignments_extended(test_id); |
||||
CREATE INDEX idx_test_assignments_staff_id ON test_assignments_extended(staff_id); |
||||
``` |
||||
|
||||
#### 5. `test_attempts` - Попытки прохождения |
||||
|
||||
```sql |
||||
CREATE TABLE test_attempts ( |
||||
id SERIAL PRIMARY KEY, |
||||
assignment_id INTEGER NOT NULL REFERENCES test_assignments_extended(id) ON DELETE CASCADE, |
||||
attempt_number INTEGER NOT NULL, -- Номер попытки |
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
||||
completed_at TIMESTAMP, -- Завершена |
||||
score_points INTEGER, -- Набрано баллов |
||||
score_percent NUMERIC(5,2), -- Процент |
||||
passed BOOLEAN, -- Пройден/не пройден |
||||
time_spent_seconds INTEGER -- Потраченное время |
||||
); |
||||
|
||||
CREATE INDEX idx_test_attempts_assignment_id ON test_attempts(assignment_id); |
||||
``` |
||||
|
||||
#### 6. `test_answers_given` - Ответы пользователя |
||||
|
||||
```sql |
||||
CREATE TABLE test_answers_given ( |
||||
id SERIAL PRIMARY KEY, |
||||
attempt_id INTEGER NOT NULL REFERENCES test_attempts(id) ON DELETE CASCADE, |
||||
question_id INTEGER NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE, |
||||
given_answer_ids INTEGER[], -- ID выбранных ответов (для choice) |
||||
given_text TEXT, -- Текстовый ответ |
||||
is_correct BOOLEAN, -- Правильный/неправильный |
||||
points_earned INTEGER, -- Полученные баллы |
||||
answered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
||||
); |
||||
|
||||
CREATE INDEX idx_test_answers_given_attempt_id ON test_answers_given(attempt_id); |
||||
``` |
||||
|
||||
#### 7. `test_categories` - Категории тестов |
||||
|
||||
```sql |
||||
CREATE TABLE test_categories ( |
||||
id SERIAL PRIMARY KEY, |
||||
name VARCHAR(100) NOT NULL UNIQUE, |
||||
description TEXT, |
||||
parent_id INTEGER REFERENCES test_categories(id), |
||||
is_active BOOLEAN DEFAULT TRUE |
||||
); |
||||
``` |
||||
|
||||
#### 8. `test_reports` - Сформированные отчеты |
||||
|
||||
```sql |
||||
CREATE TABLE test_reports ( |
||||
id SERIAL PRIMARY KEY, |
||||
report_type VARCHAR(50) NOT NULL, -- department, employee, category |
||||
parameters JSONB, -- Параметры отчета |
||||
file_path VARCHAR(500), -- Путь к файлу |
||||
format VARCHAR(10), -- pdf, xlsx |
||||
generated_by INTEGER, -- ID администратора |
||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP |
||||
); |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Расширение существующих таблиц (миграции) |
||||
|
||||
### training_questions |
||||
|
||||
```sql |
||||
-- Добавить тип вопроса |
||||
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS question_type VARCHAR(50) DEFAULT 'single_choice'; |
||||
|
||||
-- Добавить баллы |
||||
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS points INTEGER DEFAULT 1; |
||||
|
||||
-- Добавить правильный ответ (индекс) |
||||
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS correct_answer_index INTEGER; |
||||
|
||||
-- Добавить порядок |
||||
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS sort_order INTEGER; |
||||
|
||||
-- Добавить пояснение |
||||
ALTER TABLE training_questions ADD COLUMN IF NOT EXISTS explanation TEXT; |
||||
``` |
||||
|
||||
### training_results |
||||
|
||||
```sql |
||||
-- Добавить связь с тестом |
||||
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS test_id INTEGER REFERENCES tests(id); |
||||
|
||||
-- Добавить номер попытки |
||||
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS attempt_number INTEGER DEFAULT 1; |
||||
|
||||
-- Добавить время прохождения |
||||
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS time_spent_seconds INTEGER; |
||||
|
||||
-- Добавить детализацию ответов (JSON) |
||||
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS answers_detail JSONB; |
||||
``` |
||||
|
||||
### training_settings |
||||
|
||||
```sql |
||||
-- Добавить связь с тестом |
||||
ALTER TABLE training_settings ADD COLUMN IF NOT EXISTS test_id INTEGER REFERENCES tests(id); |
||||
|
||||
-- Добавить категорию |
||||
ALTER TABLE training_settings ADD COLUMN IF NOT EXISTS category VARCHAR(100); |
||||
|
||||
-- Добавить случайный порядок |
||||
ALTER TABLE training_settings ADD COLUMN IF NOT EXISTS random_order BOOLEAN DEFAULT FALSE; |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Связь с staff_members |
||||
|
||||
Текущая проблема: используется `telegram_id` для связи с сотрудниками. |
||||
|
||||
Решение: перейти на использование `staff_members.id` как универсального идентификатора: |
||||
|
||||
```sql |
||||
-- Добавить staff_id в training_results |
||||
ALTER TABLE training_results ADD COLUMN IF NOT EXISTS staff_id INTEGER REFERENCES staff_members(id); |
||||
|
||||
-- Миграция данных |
||||
UPDATE training_results tr |
||||
SET staff_id = sm.id |
||||
FROM staff_members sm |
||||
WHERE tr.telegram_id = sm.telegram_id; |
||||
|
||||
-- Создать внешний ключ после миграции |
||||
ALTER TABLE training_results |
||||
ADD CONSTRAINT training_results_staff_id_fkey |
||||
FOREIGN KEY (staff_id) REFERENCES staff_members(id); |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Типы вопросов |
||||
|
||||
| Тип | Код | Описание | |
||||
|-----|-----|----------| |
||||
| Одиночный выбор | `single_choice` | Один правильный ответ из нескольких | |
||||
| Множественный выбор | `multiple_choice` | Несколько правильных ответов | |
||||
| Текстовый ответ | `text` | Свободный текст | |
||||
| Сопоставление | `matching` | Сопоставление пар | |
||||
| Порядок элементов | `ordering` | Расстановка в правильном порядке | |
||||
|
||||
--- |
||||
|
||||
## API Endpoints (рекомендуемые) |
||||
|
||||
### Тесты |
||||
- `GET /api/tests` - Список тестов |
||||
- `POST /api/tests` - Создать тест |
||||
- `GET /api/tests/{id}` - Получить тест с вопросами |
||||
- `PUT /api/tests/{id}` - Обновить тест |
||||
- `DELETE /api/tests/{id}` - Удалить тест |
||||
|
||||
### Вопросы |
||||
- `GET /api/tests/{test_id}/questions` - Список вопросов |
||||
- `POST /api/tests/{test_id}/questions` - Добавить вопрос |
||||
- `PUT /api/questions/{id}` - Обновить вопрос |
||||
- `DELETE /api/questions/{id}` - Удалить вопрос |
||||
|
||||
### Назначения |
||||
- `GET /api/assignments` - Список назначений |
||||
- `POST /api/assignments` - Назначить тест |
||||
- `GET /api/employees/{id}/assignments` - Назначения сотрудника |
||||
|
||||
### Прохождение |
||||
- `POST /api/tests/{id}/start` - Начать тест |
||||
- `POST /api/attempts/{id}/answer` - Ответить на вопрос |
||||
- `POST /api/attempts/{id}/complete` - Завершить тест |
||||
|
||||
### Отчеты |
||||
- `GET /api/reports/department` - Отчет по отделениям |
||||
- `GET /api/reports/employee/{id}` - Отчет по сотруднику |
||||
- `GET /api/reports/category/{id}` - Отчет по категории |
||||
- `GET /api/reports/export` - Экспорт отчета (PDF/Excel) |
||||
@ -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 рабочих дней** (в зависимости от объёма автотестов и глубины рефакторинга генератора). |
||||
@ -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`. Бизнес-логика **не** добавлялась. | |
||||
@ -0,0 +1,87 @@
|
||||
# Перенос тестирования на кабинет HR — простым языком |
||||
|
||||
Это **короткий проектный документ** для заказчика и команды: зачем две базы, как они «сходятся» по людям, что делаем по шагам. Технические детали, таблицы и спринты — в отдельном файле: [migration-to-tgflaskform.md](migration-to-tgflaskform.md). |
||||
|
||||
--- |
||||
|
||||
## 1. В чём суть |
||||
|
||||
Сейчас модуль тестирования может жить так: |
||||
|
||||
- **Старое приложение** (то, что уже есть): своя программа и своя база **`clinic_tests`**. В ней заведены «пользователи модуля» (логин, пароль и т.д.) и все тесты, попытки, ответы. |
||||
- **Целевое место** — общий HR-кабинет на Python (**`tgFlaskForm`**): там уже есть раздел тестирования, данные лежат в другой базе — **`hr_bot_test`**, и каждый человек привязан к **карточке сотрудника** в HR (`staff_members`). |
||||
|
||||
**Перенос** — это не «скопировать файлы», а **аккуратно переложить смысл** из одной базы в другую так, чтобы в HR было понятно: *этот тест написал тот же Иванов, эту попытку прошла та же Петрова*, и чтобы баллы и история не потерялись. |
||||
|
||||
На переходный период можно держать **новый экран на Flask отдельно** (папка `flask_app` в этом репозитории) — тот же подход, что в кабинете, но свой адрес в браузере, пока не готовы полностью перейти на один вход. |
||||
|
||||
--- |
||||
|
||||
## 2. Две базы — зачем и как они связаны |
||||
|
||||
| База | Простыми словами | |
||||
|------|------------------| |
||||
| **`clinic_tests`** | «Песочница» модуля тестирования: здесь живут тесты, версии, попытки в том виде, в каком их делало старое приложение. | |
||||
| **`hr_bot_test`** | «Общий дом» HR: сотрудники, отделы, права, и при переносе — **те же тесты**, но уже в таблицах вида `testing_*`, привязанные к сотрудникам. | |
||||
|
||||
Обе базы обычно стоят **на одном сервере PostgreSQL**, но это **разные логические хранилища** (как два разных диска с разными папками). Скрипт переноса подключается к обеим и **переписывает данные** из одной в другую по правилам (сначала тесты и вопросы, потом попытки и ответы — чтобы ничего не «повисло» без ссылки). |
||||
|
||||
--- |
||||
|
||||
## 3. Связка «пользователь модуля» ↔ «сотрудник в HR» |
||||
|
||||
В **`clinic_tests`** человек заведён как запись в таблице **`users`** (логин, роль в модуле и т.д.). |
||||
|
||||
В **`hr_bot_test`** человек — это **`staff_members`** (та самая карточка из кадрового контура). |
||||
|
||||
Чтобы перенос сработал, для **каждого**, кто важен для истории (автор теста, кто проходил, кто назначал), нужно знать одно число: **идентификатор сотрудника в HR** — `staff_members.id`. |
||||
|
||||
На практике это делается так: |
||||
|
||||
1. В таблице **`users`** (в `clinic_tests`) есть поле **`staff_id`** — туда записывается как раз **`staff_members.id`** из HR. Тогда программа понимает: *логин `ivanov` в модуле тестов = сотрудник № 12345 в HR*. |
||||
2. Если **`staff_id` пустой** — автоматом не понять, кто это. Тогда до переноса нужно **вручную или полуавтоматом** составить соответствия: например таблица «логин / email / ФИО → номер сотрудника в HR», заполнить `staff_id` или отдать это скрипту миграции отдельным файлом. |
||||
|
||||
**Имеется в виду не «настроить взаимодействие двух баз в реальном времени»** (как два приложения, которые постоянно синхронизируются), а **один раз правильно сопоставить людей**, чтобы при копировании данных в HR не оказалось «логин есть, а сотрудник не найден». |
||||
|
||||
--- |
||||
|
||||
## 4. Что делаем по этапам (без жаргона) |
||||
|
||||
**Подготовка** |
||||
|
||||
- Решить: пока живём на **старой базе** в новом Flask или сразу пишем в **HR-базу** — и не вести параллельно «два источника правды» без правил. |
||||
- Список сценариев: создание теста, версии, назначение, прохождение, разбор, отчёты — и отметить, что уже есть в кабинете, чего не хватает. |
||||
|
||||
**Данные** |
||||
|
||||
- Проверить **`staff_id`** у нужных `users`. |
||||
- Сделать **резервную копию** обеих баз. |
||||
- Запустить скрипт в режиме **«только посмотреть»** (`--dry-run`): он ничего не пишет в HR, только показывает, сколько чего нашёл. |
||||
- На **копии** HR-базы один раз прогнать **настоящий перенос**, открыть несколько тестов и попыток глазами. |
||||
- В согласованное короткое окно (когда никто не правит тесты) — перенос на боевую HR-базу, проверка, смена ссылок для пользователей на кабинет. |
||||
|
||||
**После переноса** |
||||
|
||||
- Старое приложение можно оставить только для чтения или выключить, когда убедились, что в кабинете всё ок. |
||||
- Бэкап старой базы и журнал переноса хранить по правилам клиники. |
||||
|
||||
Подробные шаги ETL, порядок таблиц и ограничения текущего скрипта — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md), раздел 4. Скрипт: в монорепозитории HR, файл `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`. |
||||
|
||||
--- |
||||
|
||||
## 5. Что может пойти не так |
||||
|
||||
- **Не все люди сопоставлены с HR** — часть тестов или попыток не перенесётся или перенесётся с ошибками. Лечится заранее: отчёт по пустым `staff_id` и дозаполнение. |
||||
- **Два места, куда одновременно пишут** — данные разъедутся. Лечится правилом: в период перехода пишем только в одно место (или второе только для пилота). |
||||
- **Назначения «на весь отдел»** в старой базе — в HR их нужно либо развернуть в список конкретных сотрудников на дату переноса, либо доработать логику отдельно — это заранее обсуждается с заказчиком. |
||||
|
||||
--- |
||||
|
||||
## 6. Куда смотреть дальше |
||||
|
||||
| Нужно | Файл | |
||||
|--------|------| |
||||
| Технический план, спринты, таблицы | [migration-to-tgflaskform.md](migration-to-tgflaskform.md) | |
||||
| Состояние кода старого приложения | [PROJECT_STATUS.md](PROJECT_STATUS.md) | |
||||
| Запуск нового Flask-контура в Docker | [../flask_app/README.md](../flask_app/README.md) | |
||||
| Установка и базы в целом | [../README.md](../README.md) | |
||||
@ -0,0 +1,102 @@
|
||||
# Этап 2 (на будущее) — слияние TestingWebApp с `HR_TG_Bot/tgFlaskForm` |
||||
|
||||
> **Не текущая фаза.** Текущая работа — **Этап 1**: унификация стека внутри TestingWebApp (Express → Flask + React → Jinja). См. [migration-final.md](migration-final.md). Этот документ — план **последующего** слияния, когда заказчик решит объединять. |
||||
|
||||
**Простое объяснение тех же шагов:** [migration-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.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. Предусловие — Этап 1 закрыт |
||||
|
||||
К моменту, когда этот документ берётся в работу, в TestingWebApp **уже** должно быть: |
||||
|
||||
- Бэкенд переписан с Express на Flask внутри [`flask_app/`](../flask_app/), все 22 эндпоинта работают. |
||||
- Фронтенд переписан с React на Jinja-шаблоны в `flask_app/app/templates/`. |
||||
- БД — по-прежнему `clinic_tests`, схема не менялась. |
||||
- В репозитории остался один сервис приложения. |
||||
|
||||
Если что-то из этого ещё не готово — Этап 2 не начинается. |
||||
|
||||
--- |
||||
|
||||
## 1. Что меняется при слиянии |
||||
|
||||
| Аспект | После Этапа 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 (по спринтам) |
||||
|
||||
### E2.0 — Сверка кода и моделей |
||||
|
||||
- Сравнить структуру `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` на цепочке или на версии). |
||||
|
||||
### E2.1 — Перенос кода как blueprint |
||||
|
||||
- Скопировать роуты, сервисы, шаблоны из `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. Что трогаем в HR-кабинете до Этапа 2 |
||||
|
||||
**Ничего.** Существующий модуль `tgFlaskForm/webApp/interfaces/testing/` развивается своим темпом командой HR-кабинета и **не** должен подстраиваться под TestingWebApp до момента слияния. Если в нём появляются полезные правки — переносим в `flask_app/` обратным потоком. |
||||
|
||||
--- |
||||
|
||||
## 4. Риски Этапа 2 и как их снимать |
||||
|
||||
| Риск | Мера | |
||||
|------|------| |
||||
| Несовпадение `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. | |
||||
|
||||
--- |
||||
|
||||
## 5. Производительность кабинета (общее) |
||||
|
||||
Если после переноса страницы кабинета или мини-приложения работают медленно — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). Это вспомогательный документ, не часть этапов миграции. |
||||
@ -0,0 +1,191 @@
|
||||
# Производительность страниц Flask (кабинет / мини-приложение): рабочий документ |
||||
|
||||
Документ написан так, чтобы **человек без контекста проекта** мог по нему понять: *что за система, где код, что именно оптимизировать, в каком порядке и как понять, что задача сделана*. |
||||
|
||||
--- |
||||
|
||||
## 1. Для кого и зачем этот файл |
||||
|
||||
- **Аудитория:** ты сам через полгода, новый разработчик, DevOps, тимлид на планировании. |
||||
- **Проблема от пользователей:** «страницы мини-приложения на Flask грузятся долго». |
||||
- **Цель документа:** не угадать решение («перепишем на React»), а **зафиксировать процесс**: сначала измерить и локализовать узкое место, потом применить исправления с наибольшим эффектом при наименьшем риске. |
||||
|
||||
--- |
||||
|
||||
## 2. Где живёт проект (карта репозитория) |
||||
|
||||
Рабочая копия — монорепозиторий **`HR`** (корень: `ClinicProjects/HR` или аналог). Для задачи производительности важны в первую очередь два контура: |
||||
|
||||
| Контур | Путь в репозитории | Назначение | |
||||
|--------|-------------------|------------| |
||||
| **Основной веб-кабинет HR** | `HR_TG_Bot/tgFlaskForm/` | Flask-приложение: авторизация, кабинет, разделы в т.ч. **тестирование сотрудников**. Именно сюда чаще всего относят жалобы «мини-приложение / кабинет на Flask». | |
||||
| **Отдельный Flask-скелет под тестирование** | `TestingWebApp/flask_app/` | Упрощённое приложение того же стека (переходный контур, Docker `testing-flask`, порт **3107**). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. | |
||||
|
||||
Связанные по смыслу документы (миграция данных, две БД): |
||||
|
||||
- `TestingWebApp/docs/migration-to-tgflaskform.md` — технический план. |
||||
- `TestingWebApp/docs/migration-to-tgflaskform-plain.md` — коротко «для людей». |
||||
|
||||
**Важно:** жалоба «долго грузится» может относиться к: |
||||
|
||||
1. **Веб-кабинет в браузере** (`tgFlaskForm`, типичный порт локально **3104** в `web_run.py`). |
||||
2. **Встроенный WebView в мини-приложении** (Telegram MAX и т.п.) — тот же HTML с того же хоста, но **другая сеть, кэш, DNS, TLS**; воспроизведение обязательно на целевом клиенте. |
||||
3. **Переходный контур** `TestingWebApp` на **3107** — проверять отдельно, если пользователи реально ходят туда. |
||||
|
||||
Перед оптимизацией **уточнить URL/контур** у тех, кто жалуется. |
||||
|
||||
--- |
||||
|
||||
## 3. Что такое «страница грузится долго» в технических терминах |
||||
|
||||
Раздели время на части (это основа всей работы): |
||||
|
||||
1. **Сеть до сервера** — DNS, TCP/TLS, RTT, прокси (nginx перед Flask). |
||||
2. **Время до первого байта (TTFB)** — всё, что происходит на сервере до начала ответа: middleware, сессия, запросы к БД, рендер Jinja2, формирование заголовков. |
||||
3. **Загрузка тела ответа** — размер HTML, сжатие (gzip/brotli). |
||||
4. **Параллельная загрузка подресурсов** — CSS, JS, шрифты, картинки: их число, размер, кэширование (`Cache-Control`), HTTP/2. |
||||
5. **Выполнение JS на клиенте** — если на странице тяжёлый скрипт; для классического SSR-кабинета часто вторично по сравнению с TTFB. |
||||
|
||||
**Твоя первая задача** — для 2–3 типичных страниц (логин после редиректа, дашборд тестирования, список тестов, прохождение теста) записать: **TTFB**, **DOMContentLoaded**, **полный LCP** (или хотя бы «визуально готово»). Без этого нельзя честно выбрать между «чиним SQL» и «чиним статику». |
||||
|
||||
Инструменты: |
||||
|
||||
- Chrome DevTools → **Network** (колонка Time, размер, waterfall), **Performance**. |
||||
- На сервере: логирование длительности запроса (middleware или reverse proxy `request_time` в nginx). |
||||
|
||||
--- |
||||
|
||||
## 4. Как устроена загрузка страницы в `tgFlaskForm` (ментальная модель) |
||||
|
||||
Упрощённая цепочка для защищённого маршрута, например дашборда тестирования: |
||||
|
||||
1. Браузер запрашивает URL вида **`/cabinet/testing/`** (blueprint в `webApp/interfaces/testing/__init__.py`, префикс `/cabinet/testing`). |
||||
2. Срабатывают глобальные хуки Flask (в т.ч. **cabinet access gate** в `webApp/auth.py`: `register_cabinet_access_gate` — проверка пути, сессии, статуса «Работает»). |
||||
3. Декоратор **`@login_required`** на view: редирект на `/login` или вызов функции. |
||||
4. View (например `routes_dashboard.py`) вызывает функции из **`db/queries/testing_queries.py`** и др., собирает контекст и вызывает **`render_template(...)`**. |
||||
5. Jinja2 собирает HTML из шаблонов в `webApp/templates/` (часто с `extends` / `include` — чем больше вложенность и данных в контексте, тем дольше CPU на рендер). |
||||
6. Ответ уходит клиенту; дальше грузятся статические файлы с `/static/...`. |
||||
|
||||
Узкое место может быть на **любом** шаге; чаще всего в таких приложениях — **шаг 4 (БД + много мелких запросов)** и **шаг 5 (большой шаблон)**. |
||||
|
||||
--- |
||||
|
||||
## 5. Гипотезы, специфичные для этого кода (куда смотреть первым делом) |
||||
|
||||
Ниже — не обвинение кода, а **чек-лист для проверки** после замеров. |
||||
|
||||
### 5.1. Создание движка БД на каждый вызов сессии |
||||
|
||||
Файл: `HR_TG_Bot/tgFlaskForm/db/session.py`. |
||||
|
||||
Раньше `get_engine()` на каждом вызове делал `create_engine(...)` — новый пул и большие накладные расходы при десятках `get_session()` из `db/queries/*.py` (в т.ч. **`testing_queries.py`**). |
||||
|
||||
**Сделано (код):** в `db/session.py` один **потокобезопасный** engine на процесс и один переиспользуемый `sessionmaker`; `get_session()` по-прежнему возвращает новую ORM-сессию, но поверх общего пула. |
||||
|
||||
**Дальше:** при необходимости сокращать число **отдельных** сессий на один HTTP-запрос (§5.2) — это отдельная оптимизация. |
||||
|
||||
### 5.2. Много открытий/закрытий сессий и запросов на одну страницу |
||||
|
||||
Паттерн в `testing_queries.py`: почти каждая функция делает `s = get_session()`, `try/finally: s.close()`. Одна страница может дернуть **несколько** таких функций подряд → несколько раундов к БД. |
||||
|
||||
**Что сделать:** для «тяжёлых» страниц — либо **одна сессия на request** и передача её вниз, либо **один агрегирующий запрос** вместо N мелких (устранение N+1). Конкретные места — смотреть по trace конкретного URL. |
||||
|
||||
### 5.3. Декораторы и before_request |
||||
|
||||
`login_required` и `cabinet_employment_ok_from_session()` в основном опираются на **сессию**, но gate и другие хуки могут добавлять логику. Если туда когда-нибудь добавят тяжёлые проверки в БД на **каждый** запрос — это сразу ударит по TTFB. |
||||
|
||||
**Что сделать:** убедиться, что на горячем пути нет лишних запросов к БД без необходимости. |
||||
|
||||
### 5.4. Шаблоны и статика |
||||
|
||||
- Большие базовые layout’ы, много `include`, тяжёлые циклы в Jinja — растёт CPU на рендер. |
||||
- Статика без длинного кэша — каждый переход визуально «тормозит». |
||||
|
||||
**Что сделать:** Network → сколько запросов к `/static`, какие размеры; для продакшена — заголовки кэша и сжатие на nginx (если nginx есть в цепочке — см. `HR_TG_Bot/docker-compose*.yml` и свою прод-конфигурацию). |
||||
|
||||
### 5.5. Окружение |
||||
|
||||
- `web_run.py`: в non-production используется встроенный сервер Flask; для нагрузочного теста ближе к прод — **waitress** / gunicorn (как в `TestingWebApp/flask_app/run.py` через `WEB_USE_WAITRESS`). |
||||
- Сравнение «локально быстро, у пользователей медленно» — почти всегда **сеть, БД на другом хосте, холодный пул, отсутствие индексов на прод-данных**. |
||||
|
||||
--- |
||||
|
||||
## 6. План работы (что делать по шагам) |
||||
|
||||
### Фаза 0 — уточнение (полдня максимум) |
||||
|
||||
- [ ] Точный **URL/продукт** (кабинет HR vs TestingWebApp:3107 vs мини-app WebView). |
||||
- [ ] **Роль пользователя** и сценарий (первый заход, каждый клик, только раздел тестирования). |
||||
- [ ] Есть ли **nginx / CDN** перед приложением. |
||||
|
||||
### Фаза 1 — измерение (обязательно) |
||||
|
||||
- [ ] Зафиксировать 3–5 URL и для каждого: TTFB, размер HTML, число запросов, суммарный вес. |
||||
- [ ] На сервере: время обработки запроса (middleware: `before_request` timestamp vs `after_request`). |
||||
- [ ] Для самого медленного URL: **список вызовов к БД** (SQLAlchemy events, логирование, или APM, если есть). |
||||
|
||||
**Выход фазы:** одно предложение вида: «узкое место — TTFB из-за БД» или «узкое месте — 40 запросов к статике без кэша». |
||||
|
||||
### Фаза 2 — правки по приоритету (типичный порядок) |
||||
|
||||
1. **Инфраструктура БД:** один engine на процесс; пул; при необходимости индексы (после анализа `EXPLAIN` самых тяжёлых запросов). |
||||
2. **Сократить число round-trips к БД** на страницу: объединение запросов, eager loading где уместно, кэш редко меняющихся справочников (с инвалидацией или коротким TTL). |
||||
3. **Шаблоны:** убрать лишние данные из контекста; упростить самые тяжёлые `include`. |
||||
4. **Статика:** fingerprint + `Cache-Control: immutable` для бандлов; минификация; не тянуть огромные библиотеки на каждую страницу без нужды. |
||||
5. **Прод-сервер приложений:** waitress/gunicorn, адекватное число воркеров за reverse proxy. |
||||
|
||||
### Фаза 3 — если «всё ещё медленно именно при переходах между страницами» |
||||
|
||||
Это уже про **полную перезагрузку HTML**, а не про «Flask медленный»: |
||||
|
||||
- Вариант **A:** [HTMX](https://htmx.org/) / **Turbo** — сервер по-прежнему отдаёт HTML, обновляются фрагменты; стек остаётся Python + Jinja. |
||||
- Вариант **B:** точечный **React/Vite** только для тяжёлого экрана (остальной кабинет не трогать) — выше стоимость сопровождения. |
||||
|
||||
Выбор между A и B — после фазы 1: если TTFB уже низкий, а больно от полной перезагрузки — имеет смысл A/B; если узкое место всё ещё сервер — сначала дожать фазу 2. |
||||
|
||||
--- |
||||
|
||||
## 7. Критерии готовности (Definition of Done) |
||||
|
||||
Задачу по производительности можно закрыть, когда: |
||||
|
||||
1. Есть **замеры до/после** по тем же URL и тем же окружению (или согласованная методика). |
||||
2. Задокументировано **узкое место** и **что изменено** (1–2 абзаца в changelog или в этом файле внизу секция «Итог»). |
||||
3. Для пользовательского сценария выполняется согласованный **SLO** (например: TTFB p95 < X ms, полная загрузка ключевой страницы < Y s на 4G) — пороги задаёт продукт/команда, не этот документ. |
||||
|
||||
--- |
||||
|
||||
## 8. Риски и что не делать |
||||
|
||||
- **Не** менять стек на SPA «с нуля» без измерений — высокий риск и долгий срок при том, что проблема может быть в пуле БД или кэше статики. |
||||
- **Не** оптимизировать только локально на пустой БД — планы запросов на прод-объёме другие. |
||||
- **Не** кэшировать персональные страницы на CDN без понимания заголовков и кук — риск утечки данных между пользователями. |
||||
|
||||
--- |
||||
|
||||
## 9. Быстрый указатель файлов |
||||
|
||||
| Тема | Путь | |
||||
|------|------| |
||||
| Точка входа веба | `HR_TG_Bot/tgFlaskForm/web_run.py` | |
||||
| Регистрация приложения / blueprints | `HR_TG_Bot/tgFlaskForm/webApp/__init__.py` (и связанные модули) | |
||||
| Модуль тестирования (маршруты) | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/routes_*.py` | |
||||
| Запросы к БД тестирования | `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` | |
||||
| Сессия и engine | `HR_TG_Bot/tgFlaskForm/db/session.py` | |
||||
| Авторизация и 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 (по умолчанию 3107 legacy) | `TestingWebApp/docker-compose.dev.yml` | |
||||
| Docker dev кабинета | `HR_TG_Bot/docker-compose.dev.yml` | |
||||
|
||||
--- |
||||
|
||||
## 10. Секция «Итог» (заполнять по мере работы) |
||||
|
||||
| Дата | Контур | Узкое место | Что сделано | Метрика до → после | |
||||
|------|--------|-------------|-------------|-------------------| |
||||
| 2026-04-27 | `tgFlaskForm` | Новый SQLAlchemy engine на каждый `get_session()` | Singleton `get_engine()` + кэш `sessionmaker` в `db/session.py` | _замерить на стенде_ | |
||||
|
||||
--- |
||||
|
||||
*Документ создан как рабочая инструкция по задаче «медленная загрузка страниц Flask». Обновляй таблицу в §10 и при необходимости добавляй ссылки на PR/коммиты.* |
||||
@ -0,0 +1,93 @@
|
||||
# Декомпозиция доработки (по ТЗ [revision_task/task.md](task.md)) |
||||
|
||||
**Стек (репозиторий [TestingWebApp](../..)):** **Node.js** (backend), **PostgreSQL**, **Docker**; фронт — desktop-first SPA. Экосистема клиники: см. [HR_TG_Bot README](../../../HR_TG_Bot/README.md); перенос на Python/FastAPI **не** считается обязательным для этого репо — **контракт** данных и [card1.md](revision_task/card1.md) важнее. |
||||
|
||||
**Карта больших кусков работ:** [card1.md](card1.md) (версии **V**, документ **D**, авторизация **HR A**). |
||||
**Идеи и пожелания (простой язык):** [BACKLOG_IDEAS.md](BACKLOG_IDEAS.md) |
||||
**Журнал проверок по спринтам (авто + ручные шаги для заказчика):** [TESTING_JOURNAL.md](TESTING_JOURNAL.md) |
||||
**Сводка «что сделано / что дальше» (простым языком):** [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · [инструкция для dev-стенда](../DEV_CONTOUR_USER_GUIDE.md) |
||||
|
||||
**Этап 1 (ТЗ §4)** — пять фич: 4.1–4.5 (части можно параллелить). |
||||
**Этап 2 (ТЗ §5)** — дашборды. |
||||
**Этапы 3–5** — интеграция в общий HR, MAX, уведомления. |
||||
|
||||
**Первые спринты:** [sprint-01.md](sprint-01.md), [sprint-02.md](sprint-02.md) (и при наличии `sprint-02-testing`). |
||||
|
||||
**Данные и интеграция с HR (зафиксировано):** один инстанс Postgres, **`clinic_tests`** — схема тестов; **`hr_bot_test`** — сотрудники и **RBAC**. Идентичность в процессах — **`staff_members.id`**; **`telegram_id`** только для справки, не для логики. Итоговая проверка прав — существующая HR-модель / API, без параллельной «второй» RBAC. Подробно: [card1](card1.md#хранение-связь-с-сотрудниками-rbac-зафиксировано), [README](../../README.md#данные-сотрудники-интеграция-с-hr). |
||||
|
||||
--- |
||||
|
||||
## A. Подготовка и база (фундамент) |
||||
|
||||
| ID | Подзадача | |
||||
| --- | --- | |
||||
| A.1 | Репозиторий, Docker Compose (PostgreSQL), .env, health | |
||||
| A.2 | Схема БД, миграции: пользователи, подразделения, тесты, версии, попытки (см. [card1 V.1](card1.md)) | |
||||
| A.3 | Аутентификация: **локальная** (dev) **или** **через** [Postgres_TG_Bots / HR users](card1.md#часть-a--авторизация-по-паролю-бд-postgres_tg_bots); в прод — привязка к `staff_members.id`, RBAC из HR | |
||||
| A.4 | CRUD тест/назначение/прохождение (база шагов `docs/шаги/`) + затем **B** | |
||||
|
||||
*Если A.1–A.4 частично сданы — добить по [sprint-01](sprint-01.md) и [card1](card1.md).* |
||||
|
||||
--- |
||||
|
||||
## B. Фича 4.1 — Версионирование тестов |
||||
|
||||
См. полностью [card1.md — Часть V](card1.md#часть-v--версионирование-цель-корректная-история-при-правках-автора) (V.1–V.10). |
||||
|
||||
--- |
||||
|
||||
## C. Фича 4.2 — AI-помощник (DeepSeek) |
||||
|
||||
| ID | Подзадача | |
||||
| --- | --- | |
||||
| C.1 | Ключ в БД; `/settings`; «Проверить»; ключ не на фронт | |
||||
| C.2 | OpenAI-совместимый клиент, `json_object` | |
||||
| C.3 | Сгенерировать/проверить/улучшить тест; модалки, было→стало | |
||||
| C.4 | Вопрос: улучшить, дистракторы, подсказка (с 4.4) | |
||||
|
||||
*Импорт из документа ([card1 D](card1.md)) тянет C.1–C.3.* |
||||
|
||||
--- |
||||
|
||||
## D. Импорт из документа |
||||
|
||||
См. [card1 D.1–D.5](card1.md#часть-d--загрузка-документа--черновик-теста). |
||||
|
||||
--- |
||||
|
||||
## E–F. Подсказки и режимы (§4.4–4.5) |
||||
|
||||
| ID | Кратко | |
||||
| --- | --- | |
||||
| E.x | Подсказка в вопросе, показ по режиму | |
||||
| F.x | Таймер, мгновенная оценка, итог в конце | |
||||
|
||||
--- |
||||
|
||||
## G. Этап 2 — Дашборды (§5) |
||||
|
||||
| ID | Подзадача | |
||||
| --- | --- | |
||||
| G.1 | Дашборд сотрудника | |
||||
| G.2 | Руководитель подразделения | |
||||
| G.3 | Директор / вся клиника | |
||||
|
||||
--- |
||||
|
||||
## H. Спринт 1 — сопоставление |
||||
|
||||
| Спринт | Охват | |
||||
| --- | --- | |
||||
| **Спринт 1** | A (дозакрытие) + **B** = [card1 V](card1.md) + при согласовании [A](card1.md) (Postgres_TG_Bots) | |
||||
| **Спринт 2** | **C** + начало D при наличии LLM; иначе D без генерации = только текст/ручной ввод | |
||||
| Далее | E, F, G, интеграция MAX, уведомления | |
||||
|
||||
--- |
||||
|
||||
## Сопоставление с файлами |
||||
|
||||
| Документ | Содержание | |
||||
| --- | --- | |
||||
| [card1.md](card1.md) | Задачи Card 1: версии, документ, auth HR | |
||||
| [sprint-01.md](sprint-01.md) | Спринт 1, кратко | |
||||
| [task.md](task.md) | ТЗ 1.0 | |
||||
@ -0,0 +1,33 @@
|
||||
# Идеи и пожелания по доработкам (для согласования с заказчиком) |
||||
|
||||
*Язык простой, без жаргона разработки. Сюда попадает всё, что всплыло в обсуждениях и ещё не вошло в жёсткое ТЗ.* |
||||
|
||||
**Как пользоваться:** приоритеты и «да/нет» фиксируем отдельно; пункты **не** удаляем — переносим в раздел **Решено** с кратким итогом, если идея закрыта или отклонена. |
||||
**Что уже в продукте (кратко):** [../PROJECT_STATUS.md](../PROJECT_STATUS.md). |
||||
|
||||
--- |
||||
|
||||
## На рассмотрении |
||||
|
||||
| № | Суть (что даст клинике) | Примечание | |
||||
|---|-------------------------|------------| |
||||
| 1 | **Напоминания** о сроке теста в мессенджере (когда срок близок или прошёл) | Связь с будущим HR-приложением в MAX; не путать с самим прохождением теста — только напоминание зайти в кабинет. | |
||||
| 2 | **Один раз скачать** свод по отделу или по клинике в **таблицу** (для руководителя), без «технических» деталей | Уточнить, нужен ли **Excel** / PDF и какие столбцы обязательны. | |
||||
| 3 | **Памятка рядом с тестом** — кратко: зачем тест, на что обратить внимание (текст от автора) | Улучшает вовлечение; не подменяет **описание** теста, если оно уже есть. | |
||||
| 4 | **Сравнение** «как сотрудник ответил в прошлый раз» с текущим прохождением | Для **повторных** тестов по той же теме; важна приватность и согласие кадров. | |
||||
| 5 | **Крупный шрифт** и **контраст** в режиме «стресс/смена» для сотрудников, много работающих в перчатках с экраном | Доступность; опциональная **тема** в настройках профиля. | |
||||
| 6 | **Печатная** версия **итога** (сдал/не сдал) для **личного** дела — один лист, без лишнего | Не путать с полноценной «выгрузкой для 1С»; это про человеко-понятный **итог** для сотрудника. | |
||||
| 7 | **Повтор** одного вопроса в конце теста — «самопроверка» (опционально, как у автора) | Снижает нервозность; выключаемо **на уровне** теста, чтобы на экзамене не мешать. | |
||||
| 8 | **Аудит:** кто из администраторов менял активную **версию** теста и когда | Для **разборов** при споре «кому показывали старую/новую редакцию». | |
||||
|
||||
--- |
||||
|
||||
## Решено или «не делаем в этой волне» |
||||
|
||||
| № | Суть | Итог | |
||||
|---|------|------| |
||||
| — | *Пока пусто* | | |
||||
|
||||
--- |
||||
|
||||
*Обновляйте дату: **2026-04-23** (создание файла).* |
||||
@ -0,0 +1,95 @@
|
||||
# Единый журнал проверок по спринтам |
||||
|
||||
**Для кого этот документ.** Часть проверок — на стороне разработки (раздел A). **Ручные проверки с заказчиком** ведутся **так:** |
||||
|
||||
1. **Ассистент** в чате выдаёт **ровно одно** поручение за раз, обычно в духе: «зайди в…», «нажми…», «посмотри, видно ли…» — **без** длинного списка вперёд. |
||||
2. Вы отвечаете **только** **ОК** или **не ОК** (при **не ОК** — **одна** короткая фраза, что не сработало). |
||||
3. **Ассистент сам** вносит результат в **раздел B** (таблицу): код шага, суть поручения **по факту**, ваш ответ, дата. В таблице **не** нужно ждать, пока вы сами куда-то переносите — это делает ассистент. |
||||
|
||||
Ниже в разделе B таблица — **журнал уже прошедших** шагов. Новые шаги приходят **сначала в чат**, потом дублируются сюда. |
||||
|
||||
**Ветка / коммит последней привязки:** `dev` (обновлять при релизе на проверку; актуализация документации 2026-04-24 — [../PROJECT_STATUS.md](../PROJECT_STATUS.md)) |
||||
|
||||
**Адрес стенда:** `http://localhost:3107` (UI; при стеке `docker compose -f docker-compose.dev.yml up` — тот же origin для `/api/…` через Nginx; прямой API с хоста — `http://localhost:3001`). |
||||
|
||||
**Актуальный UI (после 2026-04-24):** старт прохождения — **не** с карточки теста, а со **списка «Тесты»**: в каждой строке **справа** кнопка **«Пройти»**; **слева** — ссылка на карточку. Под названием — **«Автор: Вы»** или **«Автор: Фамилия И. О.»**; в шапке — **Фамилия И. О.**, полное ФИО в подсказке. После **«Завершить тест»** — **разбор** по вопросам; у автора в карточке — **«Прогоны и разбор»** по завершённым попыткам. Шаги **S1-07** и **S1-13** в таблице ниже описывают **старый** вариант («Старт/Начать попытку» на карточке) — оставлены в журнале как история. Регресс по новому потоку — **S1-14** и далее. |
||||
|
||||
**Текущий шаг для ручной проверки (код в чате = тот же номер):** **S1-14** — см. раздел B. |
||||
|
||||
--- |
||||
|
||||
## Спринт 1 — Версии тестов и честная история прогонов |
||||
|
||||
**Смысл для бизнеса.** Если руководитель поправил тест после того, как кто-то уже прошёл его, **старые результаты** должны оставаться привязаны к **той редакции**, по которой человек реально отвечал — без путаницы в разборе ошибок. |
||||
|
||||
### Раздел A — Проверки без участия заказчика (разработка / ассистент) |
||||
|
||||
| № | Что проверено | Статус | Дата | |
||||
|---|----------------|--------|------| |
||||
| A1 | В проекте есть миграция базы: связь версий «родитель» (`parent_id`) и правило «только одна активная версия на тест» | [x] `002_…sql` | 2026-04-24 | |
||||
| A2 | Линтер (`npm run lint`): **0 errors**; остаются **warnings** `no-console` в существующих файлах | готово (errors) | 2026-04-24 | |
||||
| A3 | `npm test` в `backend/`: hasAny, Werkzeug, V.9 smoke, **D.2** `documentExtract` — `src/**/*.test.js` (10+ тестов) | [x] готово | 2026-04-25 | |
||||
| A4 | Запрос «здоров ли сервер» по адресу `/api/health` при запущенном backend | [x] `{"status":"ok"}` | 2026-04-24 | |
||||
| A5 | Реализация card1: API тестов/версий, черновик, HR-login (опц.), D.1 upload, UI списка/версий/черновика (в `dev`) | [x] код | 2026-04-25 | |
||||
|
||||
**Техническая заметка:** реализация `hasAnyAttemptForTest` в `backend/src/services/testChainService.js`, тесты в `testChainService.test.js`. |
||||
|
||||
--- |
||||
|
||||
### Раздел B — Журнал ручных шагов (заполняет ассистент после ответа в чате) |
||||
|
||||
| Код | Что попросили сделать (кратко) | Ваш ответ | Дата | |
||||
|-----|------------------------------|-----------|------| |
||||
| S1-00 | Открыть `TESTING_JOURNAL.md`, просмотреть верх и раздел B; в таблице — строка S1-00 «ожидает…» | ОК | 2026-04-23 | |
||||
| S1-01 | Открыть `card1.md`, убедиться, что есть блок про V.1 / V.2 / V.3 (сохранение / форк) | ОК | 2026-04-24 | |
||||
| S1-02 | Открыть в браузере `http://localhost:3107` — должна загрузиться **страница входа** (заголовок «Клинические тесты» / «Войдите в систему», поля логин и пароль, кнопка «Войти») | ОК | 2026-04-23 | |
||||
| S1-03 | В браузере открыть `http://localhost:3107/api/health` (или `http://localhost:3001/api/health` при прямом доступе к API) — в ответе виден JSON c полем `status` со значением `ok` (страница не «404» и не пустая ошибка) | ОК | 2026-04-23 | |
||||
| S1-04 | С экрана входа войти: учётка **вашей** среды (локальный `users` в `clinic_tests` **или** при `HR_AUTH=1` — логин HR). После **«Войти»** должен открыться экран **«Тесты»** с **шапкой** (слева бренд, справа **Фамилия И. О.**/роль и кнопка **«Выйти»**; не обязательно полное тройное ФИО в одну строку). Список тестов может быть пустым. | ОК | 2026-04-24 | |
||||
| S1-05 | На экране «Тесты» в поле **«Новый тест — название»** ввести любое имя, нажать **«Создать»**. Должен открыться экран карточки теста (ссылка «← к списку», блок **Версии**, черновик и т.д.). | ОК | 2026-04-24 | |
||||
| S1-06 | На карточке теста в блоке **«Черновик (V.3)»** (при необходимости изменить текст вопроса) нажать **«Сохранить черновик»**. Под кнопкой появляется пояснение (например, что черновик применён) или пусто без ошибки на красном. Раздел **Версии** остаётся / обновляется без сообщения «Доступ запрещён». | ОК | 2026-04-24 | |
||||
| S1-07 | В блоке **«Прохождение (V.4)»** нажать **«Старт попытки»**. Под кнопкой/рядом появляется сообщение, что **попытка стартовала** (с id или без), без «Доступ запрещён» / без красного текста с ошибкой API. | ОК | 2026-04-24 | |
||||
| S1-08 | Нажать **«← к списку»** и убедиться, что **ваш тест** отображается в списке (название, строка с v… и фрагментом id активной версии). | ОК | 2026-04-24 | |
||||
| S1-09 (опц.) | В шапке нажать **«Выйти»** — должен открыться экран входа. Снова **«Войти»** с теми же данными — снова экран **«Тесты»** (список на месте). | ОК | 2026-04-24 | |
||||
| S1-10 | **История версий** (card1 V.7): в карточке теста видны **заголовок**, таблица версий (версия, активна, дата). Если **≥2** версий — нажать **«сделать активной»** на неактивной, согласиться в confirm; в таблице **текущая** переносится; в списке **«Тесты»** в метке строки обновился **фрагмент id** активной версии. | ОК | 2026-04-25 | |
||||
| S1-11 | **Публикация / V.6**: **«Скрыть из списка»** — в верхнем списке теста нет; на странице «Тесты» внизу блок **«Скрытые вами из списка»** — открыть карточку — **«Снова показать в списке»** — тест снова в верхнем списке. | ОК | 2026-04-25 | |
||||
| S1-12 | В блоке **«Содержание: вопросы…»** задать вопрос(ы) и варианты, отметить верные, **«Сохранить черновик»** — без красной ошибки; **История версий** / заголовок обновляются при необходимости. | ОК | 2026-04-24 | |
||||
| S1-13 | **«Начать попытку»** — открывается экран с вопросами (радио/чекбоксы); **«Завершить тест»** — виден результат: правильно из N, %, сравнение с порогом, без 400 «нет вопросов» при сохранённых вопросах. | ОК | 2026-04-24 | |
||||
| S1-14 | Экран **«Тесты»** (`/tests`): у строки с тестом **справа** видна кнопка **«Пройти»**; **слева** клик по названию открывает **карточку** без автоматического старта попытки. | *ожидает* | — | |
||||
| S1-15 | Со **списка** нажать **«Пройти»** у теста с сохранёнными вопросами: открывается экран попытки; **«Завершить тест»** — результат (корректно из N, %, порог), без красной ошибки API. | *ожидает* | — | |
||||
| S1-16 | **Карточка в режиме «не автор»** (сотрудник / другой пользователь): **нет** кнопки «Начать попытку»; есть короткий текст, что пройти тест из **каталога** кнопкой «Пройти» справа. | *ожидает* | — | |
||||
| S1-17 (опц.) | **Автор** в карточке своего теста: раздела **«Прохождение»** с «Начать попытку» **нет**; после **«Сохранить черновик»** сообщение о статусе — **под** кнопками в блоке **«Содержание: название, порог, вопросы»**. | *ожидает* | — | |
||||
|
||||
*Старые номера S1-01… сведём к той же таблице, когда появятся экраны; формулировки шагов вы получите **только** в чате, по одному.* |
||||
|
||||
**Итог спринта 1:** дата **2026-04-25** комментарий заказчика одной фразой: **смоук + V.6–V.7 (S1-02…S1-11) и сценарий черновик→прохождение (S1-12, S1-13) пройдены; карточка card1 в объёме приёмки сценария закрыта, остаётся бэклог D.2+ / V.9 E2E** |
||||
|
||||
--- |
||||
|
||||
## Спринт 2 — *(заготовка)* |
||||
|
||||
### Раздел A — автопроверки |
||||
|
||||
| № | Описание | Статус | Дата | |
||||
|---|----------|--------|------| |
||||
| | | [ ] | | |
||||
|
||||
### Раздел B — поручения заказчику |
||||
|
||||
| Код | Действие | Ответ | Зафиксировано | |
||||
|-----|----------|-------|----------------| |
||||
| | | | | |
||||
|
||||
--- |
||||
|
||||
## Сводка по спринтам (для статус-встречи) |
||||
|
||||
| Спринт | Тема простыми словами | Раздел A | Раздел B | |
||||
|--------|------------------------|----------|----------| |
||||
| 1 | Версии, история прогонов | приём (код в dev) | S1-02…S1-13 ОК; **регресс UI:** S1-14… *(в процессе)* | |
||||
| 2 | *(по мере появления)* | | | |
||||
|
||||
--- |
||||
|
||||
*Связанные файлы: [sprint-01-testing.md](sprint-01-testing.md) (черновик чек-листа), [card1.md](card1.md) (задачи).* |
||||
|
||||
**Очередь (по запросу / спринт 2):** закрыть **S1-14**–**S1-17** (новый сценарий «Пройти»); затем — регресс после релизов; **D.2–D.5**; **V.9** E2E; углубление V.8 (назначения / «мои тесты») по card1. |
||||
@ -0,0 +1,109 @@
|
||||
# Карта задач: Card 1 — история прохождений, документ, авторизация HR |
||||
|
||||
**Связь со спринтами:** основная масса пунктов **V.x** — **Спринт 1** (версионирование + инфра); **D.x** — загрузка документа (может пересекаться со **Спринтом 2** / AI, если без LLM черновик не делается); **A.x** — авторизация **базой** `Postgres_TG_Bots` (выполнять после/параллельно с V.1, чтобы не плодить дубли пользователей на долгий срок). |
||||
|
||||
**Фактический стек репозитория [TestingWebApp](../../README.md):** Node.js (backend), PostgreSQL, Docker; фронт — SPA в `frontend/`. План доработок **не** привязывать к FastAPI, если в коде API на Express/Node. |
||||
|
||||
### Хранение, связь с сотрудниками, RBAC (зафиксировано) |
||||
|
||||
- **Один кластер PostgreSQL** (как в [Postgres_TG_Bots](../../../Postgres_TG_Bots)): отдельные **базы** — **`clinic_tests`** (тесты, назначения, попытки, миграции модуля) и **`hr_bot_test`** (штат, справочники, уже реализованный **RBAC**). Таблицы модуля тестов **не** вешаем в `hr_bot_test`, чтобы не конфликтовать по именам (`users`, `departments` и т.д. уже заняты смыслами HR). |
||||
- **Сотрудник в бизнес-процессах модуля тестирования** идентифицируется по **`staff_members.id`** (БД `hr_bot_test` / экосистема [hr_web_viewer](../../../Postgres_TG_Bots/hr_web_viewer)). В `clinic_tests` храним **суррогатные ссылки** на сотрудника (например `staff_id` / UUID той же сущности), без дублирования ФИО и кадровых данных в долгую. |
||||
- **`telegram_id`** в данных сотрудника — **только справка** (в т.ч. для исторических выгрузок, отображения). **Ни назначения, ни проверок прав, ни выбор сотрудника в сценариях модуля** от `telegram_id` **не** зависят и не должны зависеть. |
||||
- **RBAC:** единая цель — опираться на **уже существующую** в клинике модель (роли, привязки к сотруднику, permissions). Проверка «можно ли действие» в конечном варианте — через **HR API** / общий auth-контур и/или согласованное чтение RBAC-таблиц; в `clinic_tests` **не** строим параллельную полную матрицу ролей. На переходных этапах допустимы упрощения (MVP-флаги), пока в контракте не зафиксировано иное. |
||||
|
||||
--- |
||||
|
||||
## Часть V — Версионирование (цель: корректная история при правках автора) |
||||
|
||||
**Правила (приёмка):** |
||||
|
||||
1. Пока по **цепочке теста** (`tests.id`) **не было ни одной** попытки в `test_attempts` (через любую `test_version_id` этой цепочки) — автор **редактирует на месте** текущую единственную строку `test_versions` и дочерние вопросы/ответы; поле `version` **не** увеличивается. |
||||
2. Как только появилась **хотя бы одна** попытка — **каждое** сохранение с изменениями контента создаёт **новую** строку `test_versions`: `version = max+1`, `parent_id` → id предыдущей версии, прежняя версия `is_active = false`, новая `is_active = true`; старые вопросы/ответы **копируются** в новую версию. |
||||
3. Все версии одной цепочки — общий `test_id`; цепочка линейна по `parent_id` (и/или по монотонному `version` при одном `test_id`). |
||||
4. `test_attempts.test_version_id` **NOT NULL** — попытка всегда на снимок версии, разбор старых результатов не «плывёт» при новых правках. |
||||
5. Списки тестов для **сотрудников** и **авторов**: только **активная** версия цепочки (`test_versions.is_active` и `tests` не скрыт деактивацией цепочки). |
||||
6. **История версий** в UI: просмотр, **ручной** выбор активной версии; при выборе в транзакции: у всех версий данного `test_id` `is_active = false`, у выбранной `is_active = true`. Новые старты попыток — по **текущей** активной версии. |
||||
7. **Деактивация теста целиком** — флаг на уровне `tests` (скрыть цепочку из списков, **без** удаления строк). |
||||
|
||||
**Задачи (детализация):** |
||||
|
||||
| ID | Задача | Критерий | |
||||
| --- | --- | --- | |
||||
| V.1 | Миграция БД: `test_versions.parent_id` (FK на `test_versions.id`, ON DELETE NO ACTION/RESTRICT), частичный уникальный индекс: не более **одной** `is_active = true` на `test_id` (если СУБД поддерживает; иначе — уникальный индекс + проверка в сервисе) | `migrate` отрабатывает пусто на повтор | |
||||
| V.2 | Сервис `hasAnyAttemptForTest(testId)` + unit-тесты | Покрыты кейсы: 0 попыток / 1+ по любой версии цепочки | |
||||
| V.3 | `saveTestDraft(author, testId, payload)`: ветвление in-place **vs** `forkNewVersion` (копия вопросов/опций) | Соответствие правилам 1–2 | |
||||
| V.4 | Старт попытки: в `test_attempts` писать `test_version_id` **той** версии, что была **активна** в момент старта | Нет перезаписи `version_id` позже | |
||||
| V.5 | API: `GET /tests` (роль) — только активные цепочки; `GET /tests/:id/versions`; `POST /tests/:id/versions/:vid/activate` | 403/404 по политике | |
||||
| V.6 | API: `PATCH /tests/:id` (деактивация цепочки `tests.is_active` или отдельное поле) | Список пустеет, данные на месте | |
||||
| V.7 | UI автора: номер/метка версии, предупреждение при «после первой попытки», экран **история версий**, кнопка **сменить активную** (с confirm) | Смоук `sprint-01-testing.md` | |
||||
| V.8 | UI списки сотрудника/автора: **один** ряд на цепочку, без дублей версий | `GET /tests/:id/summary` + упрощённая карточка для не-автора; список `GET /tests` с JOIN на активную версию | |
||||
| V.9 | Интеграционные тесты API + регресс «разбор старой попытки» по старым `question_id` | `backend/src/integration/v9card1.test.js` при `CLINIC_TESTS_INTEGRATION=1` и миграциях; без БД — skip | |
||||
| V.10 | *Продукт:* при новой версии `test_assignments` **не** переносим на новый `test_version_id`; старт попытки — по **активной** версии (см. [task.md §2.6](task.md)) | Зафиксировано в ТЗ | |
||||
|
||||
--- |
||||
|
||||
## Часть D — Загрузка документа → черновик теста |
||||
|
||||
**Цель:** загрузить файл (PDF, DOCX — перечислить лимиты), извлечь текст, **сгенерировать** структуру вопросов (логично тянуть **LLM** из ТЗ §4.2) или дать мастер «вставьте текст». |
||||
|
||||
| ID | Задача | Критерий | |
||||
| --- | --- | --- | |
||||
| D.1 | Эндпоинт `POST /tests/import/document` (multipart), валидация размера/типа, сохранение во временное хранилище | 413/400 при нарушении | |
||||
| D.2 | Извлечение текста: PDF (библиотека на выбор), DOCX (zip/xml) | Юнит на фикстуре | |
||||
| D.3 | Вызов слоя **генерации** (если **есть** ключ DeepSeek — → промпт; иначе — заглушка «только вручную») с ответом JSON по схеме вопросов/ответов | Согласовано с §4.2 | |
||||
| D.4 | UI: кнопка «Из документа», превью, применение → дальше **тот же** поток сохранения, что и ручной редактор (в т.ч. V.1–V.3) | — | |
||||
| D.5 | Удаление временных файлов после обработки / TTL | — | |
||||
|
||||
--- |
||||
|
||||
## Часть A — Авторизация по паролю, БД [Postgres_TG_Bots](../../../Postgres_TG_Bots) |
||||
|
||||
**Контекст:** в `Postgres_TG_Bots`/`hr_web_viewer` учёт `users` с полями `username`, `password_hash` (Werkzeug `pbkdf2:sha256` / `scrypt` через `werkzeug.security` — см. `hr_web_viewer/models/user_models.py`). **Идентичность сотрудника** для сценариев тестов — по **`staff_members.id`** (см. блок «Хранение…» выше); **`telegram_id` не используем** в логике входа, назначений и прав. |
||||
|
||||
| ID | Задача | Критерий | |
||||
| --- | --- | --- | |
||||
| A.1 | Вторичное соединение: `HR_DATABASE_URL` (или DSN) к **`hr_bot_test`**, read-only **или** отдельный пользователь с `SELECT` на `users` и `staff_members` (для связки логин → `staff_id`, подразделение и т.д.) **отдельно** от `DATABASE_URL` в **`clinic_tests`** | Пример `.env` в `TestingWebApp` | |
||||
| A.2 | Реализация `verifyPassword` в Node, **совместимой** с `check_password_hash` (использовать пакет, понимающий префиксы `pbkdf2:sha256:` и `scrypt:`) | Тест: тот же хеш, что сгенерировал Werkzeug | |
||||
| A.3 | Логин: по `username` → `users` в `hr_bot_test`; при успехе — токен **TestingWebApp** с привязкой к **`staff_members.id`** (и при необходимости к локальному `users` в `clinic_tests` только как технический mirror **без** отдельного жизненного цикла пароля). Пароли **только** в HR-таблице `users` | Нет дублирования паролей в долгую | |
||||
| A.4 | Отключить/не использовать регистрацию с локальным `password_hash` в прод-режиме, если включён `HR_AUTH=1` | Флаг в `.env` | |
||||
| A.5 | Маппинг ролей: взять из **существующей** RBAC-модели HR (см. `staff_role_assignments` / roles & permissions) **или** согласованный вызов **HR API**; MVP — ограниченный набор, без опоры на `telegram_id` | [README — данные и интеграция](../../README.md#данные-сотрудники-интеграция-с-hr) | |
||||
|
||||
--- |
||||
|
||||
## Порядок работ (рекомендация) |
||||
|
||||
1. **V.1** → **V.2** → **V.3** (ядро версий). |
||||
2. **A.1**–**A.3** параллельно или сразу после **V.1** (нужен стабильный логин для стенда). |
||||
3. **V.4**–**V.9** и UI **V.7**–**V.8**. |
||||
4. **D.*** после появления клиента LLM (или D.1–D.2 + ручной встав текста без LLM). |
||||
5. **V.10** — решение по назначениям до **V.5** если назначения уже в проде. |
||||
|
||||
--- |
||||
|
||||
## Ссылки |
||||
|
||||
- ТЗ: [task.md](task.md) §4.1, §4.2 |
||||
- Спринт 1: [sprint-01.md](sprint-01.md) |
||||
- Проверки и журнал: [TESTING_JOURNAL.md](TESTING_JOURNAL.md) |
||||
- Старый чек-лист: [sprint-01-testing.md](sprint-01-testing.md) |
||||
- Анализ таблиц (если ведёте): [TEST_TABLES_ANALYSIS.md](../TEST_TABLES_ANALYSIS.md) |
||||
|
||||
--- |
||||
|
||||
## Статус реализации (сводно, 2026-04-25+) |
||||
|
||||
| ID | Статус | Комментарий | |
||||
| --- | --- | --- | |
||||
| V.1–V.6, V.7, V.10 | Код + API + UI приёмка | [TESTING_JOURNAL S1-10, S1-11](TESTING_JOURNAL.md); публикация, скрытые, история. | |
||||
| V.8 | Расширен MVP | `GET /api/tests` — **только свои** (`created_by`) **и** тесты с **назначением** на `users.id` (`test_accessService.js`). Карточка/попытка/starter/chain — та же логика. Старт **«Пройти»** в списке. Назначение: `POST /api/tests/:id/assign` (только **автор**), при production — `CLINIC_ASSIGNMENT_ENABLED=1` (`featureFlags.js`); в `development` включено. UI: блок «Назначение сотрудникам» + `GET /api/auth/dev/assignment-directory` при `assignmentUi` из `/api/auth/me`. | |
||||
| V.9 | Частично | + `v9card1.test.js`: 0 попыток → in-place; после попытки → форк, старая попытка на старых `version_id` / `question_id`. Запуск: `CLINIC_TESTS_INTEGRATION=1` + `DATABASE_URL` (или DB_*), `npm run migrate`. | |
||||
| D.1 | Готово | `POST /tests/import/document`, 400/413, multipart. | |
||||
| D.2 | Готово | `documentExtractService.js`: PDF (`pdf-parse`), DOCX (`mammoth`), TXT/MD. | |
||||
| D.3 | MVP (импорт) | `documentGenService.js`: вызов Chat Completions (DeepSeek по умолчанию или OpenAI), JSON-черновик → `generation.draft` в `POST /import/document`; `LLM_NO_JSON=1` при несовместимости API. | |
||||
| D.4 | MVP | Карточка теста: выбор файла, превью, «Вставить в поле вопроса» → тот же черновик. | |
||||
| D.5 | Готово | Временный файл удаляется после чтения; Nginx `client_max_body_size 10m`. | |
||||
| A.1–A.4 | Код + compose | `HR_AUTH` + `HR_DATABASE_URL` в `docker-compose.dev.yml`. | |
||||
| A.5 | MVP | `mapHrRoleToApp` без полного RBAC из HR-таблиц. | |
||||
| UI | 2026-04+ | Список тестов: подпись **автора** (Вы / Фамилия И. О.); шапка: **Фамилия И. О.** Разбор попыток: API + UI после `submit` и маршрут `/tests/:id/attempts/:aid/review` ([PROJECT_STATUS.md](../PROJECT_STATUS.md)). | |
||||
|
||||
**Следующий шаг по card1:** **V.9** — довести supertest/HTTP-регресс при необходимости; **D.3+** — отдельные кнопки в редакторе (сгенерировать/проверить/улучшить), ключ в БД, `/settings` (см. [sprint-02](sprint-02.md)); **A.5** — `staff_role_assignments` / HR API; по желанию назначения по **отделу**. Навигация по сценариям: [DEV_CONTOUR_USER_GUIDE.md](../DEV_CONTOUR_USER_GUIDE.md). |
||||
@ -0,0 +1,68 @@
|
||||
# Спринт 1 — тестирование (версионирование) |
||||
|
||||
**Актуальный ведённый журнал (авто + ручные шаги, ответы ОК/не ОК):** [TESTING_JOURNAL.md](TESTING_JOURNAL.md) — *этот файл остаётся как подробный черновик сценариев*. |
||||
|
||||
**Сводка «что в коде»:** [../PROJECT_STATUS.md](../PROJECT_STATUS.md). |
||||
|
||||
Ниже: **автоматизировано / проверено при разработке** и **ручная приёмка** — дублирует структуру; статусы переносите в `TESTING_JOURNAL.md`. |
||||
|
||||
**Окружение:** зафиксировать ветку/коммит, URL стенда, тестовые учётки (роль автора, роль сотрудника). |
||||
|
||||
--- |
||||
|
||||
## 1. Автоматизировано и self-check (разработка) |
||||
|
||||
_Отмечайте [ ] → [x] по мере выполнения._ |
||||
|
||||
### 1.1 Автотесты и статический анализ |
||||
|
||||
- [ ] Unit-тесты правила «0 попыток — правка на месте без новой версии» |
||||
- [ ] Unit-тесты: после появления попытки — сохранение создаёт новую версию, старая неактивна |
||||
- [ ] Тест: попытка хранит `version_id` совпадающий с версией, по которой проходили |
||||
- [ ] Integration/API: нельзя «потерять» цепочку при смене активной версии |
||||
- [ ] Линтеры/CI зелёные на MR спринта 1 |
||||
|
||||
### 1.2 Смоук вручную (быстрый) перед передачей |
||||
|
||||
- [ ] Создать тест, несколько раз сохранить **до** назначения/прохождения — версия одна |
||||
- [ ] Назначить, пройти тест, снова изменить тест — новая версия, старая в истории |
||||
- [ ] Список тестов у сотрудника без дублей цепочки |
||||
- [ ] Смена активной версии: новые прохождения идут по новой активной; старая попытка в разборе по старой версии |
||||
|
||||
--- |
||||
|
||||
## 2. Ручная приёмка (я / заказчик) |
||||
|
||||
_Сценарии для прогона в «боевом» темпе, без доступа к коду._ |
||||
|
||||
### 2.1 Автор: жизненный цикл без попыток |
||||
|
||||
- [ ] Создать тест, изменить вопросы/порог — всё в одной версии, номер версии ожидаемо стабилен |
||||
- [ ] Убедиться, что в UI явно видно, что тест ещё «ни разу не проходили» (если предусмотрено ТЗ/UX) |
||||
|
||||
### 2.2 Сотрудник + первая попытка |
||||
|
||||
- [ ] Назначить тест, пройти его полностью |
||||
- [ ] Как автор изменить вопрос/варианты, сохранить — появляется **новая** версия; старая доступна в истории |
||||
|
||||
### 2.3 Корректность данных |
||||
|
||||
- [ ] Открыть разбор/результат **старой** попытки: формулировки вопросов и правильные ответы соответствуют **той** версии, с которой проходили |
||||
- [ ] Новое назначение/новое прохождение — по **актуальной активной** версии |
||||
|
||||
### 2.4 Управление версиями |
||||
|
||||
- [ ] История версий отображается полностью и понятно (номера, даты при наличии) |
||||
- [ ] Переключение **активной** версии на предыдущую: списки обновляются; новая попытка идёт по выбранной версии |
||||
- [ ] **Деактивация** теста: цепочка не светится сотруднику; данные на месте, старые результаты открываются (если доступ по ролям предусмотрен) |
||||
|
||||
### 2.5 Визуальная согласованность |
||||
|
||||
- [ ] Экраны редактора и списков **визуально** согласованы с остальным internal web (отступы, шрифты, кнопки, таблицы, ошибки) — **без отклонения** от принятого дизайна |
||||
|
||||
### 2.6 Негатив |
||||
|
||||
- [ ] Попытка не может «сломать» цепочку (ошибки пользователю понятны) |
||||
|
||||
--- |
||||
|
||||
@ -0,0 +1,60 @@
|
||||
# Спринт 1 — Редактор тестов: версионирование (desktop web) |
||||
|
||||
**Формат:** отдельное веб desktop-приложение (согласно ТЗ `task.md`, этап 1; приёмка руководителями подразделений). |
||||
**Граница спринта:** **до** готовой цепочки **версий** теста, **привязки попыток** к версии, **UI** списков/истории/смены активной версии и **деактивации** цепочки. |
||||
**Детальная нарезка и правила приёмки:** [card1.md](card1.md) (части **V.x**, **A.x** по мере готовности). |
||||
|
||||
**Дизайн:** не отходить от визуального языка внутренних веб-экранов клиники (типографика, отступы, таблицы, модалки). |
||||
|
||||
**Стек (факт по репозиторию [TestingWebApp](../../README.md)):** **Node.js** (API в `backend/`), **PostgreSQL**, **Docker**; фронтенд **desktop-first** SPA. Интеграция с [HR_TG_Bot](../../../HR_TG_Bot/README.md) / экосистемой — по готовности API-контрактов, **не** обязана совпадать с FastAPI, если в этом репо бэкенд на Node. При переносе на Python — пересмотреть только слой API, **смысл** [card1.md](card1.md) не меняется. |
||||
|
||||
**Данные:** БД `clinic_tests` на общем кластере; сотрудник в сценариях — `staff_members.id`; `telegram_id` — только справка; RBAC — из HR. См. [card1 (вступление)](card1.md#хранение-связь-с-сотрудниками-rbac-зафиксировано). |
||||
|
||||
**Текущая реализация (сводка):** [../PROJECT_STATUS.md](../PROJECT_STATUS.md). |
||||
|
||||
--- |
||||
|
||||
## Цель спринта |
||||
|
||||
Реализовать **§4.1 ТЗ** (версионирование) end-to-end: модель данных, бизнес-правила, API, UI автора, поведение для сотрудника и разбора **старых** результатов. |
||||
|
||||
--- |
||||
|
||||
## Функциональные критерии готовности (из ТЗ 4.1) |
||||
|
||||
1. Пока по тесту **не было попыток** — автор редактирует **на месте**, номер версии **не** инкрементируется. |
||||
2. После **первой** попытки каждое сохранение изменений создаёт **новую** версию (`version + 1`, связь `parent_id`), предыдущая версия **неактивна**, данные **сохраняются**. |
||||
3. Попытки привязаны к **конкретной** версии; разбор старых попыток **валиден** после правок. |
||||
4. В списках — **только одна** активная версия цепочки. |
||||
5. **История версий** + **ручная** смена активной версии; остальные — неактивны. |
||||
6. Тест можно **деактивировать** целиком (скрыть из списков, **без** удаления данных). |
||||
|
||||
--- |
||||
|
||||
## Технические подзадачи (этапы внутри спринта) |
||||
|
||||
| # | Задача | См. в card1 | |
||||
| --- | --- | --- | |
||||
| 1 | Миграция: `parent_id`, инвариант «одна активная версия на `test_id`», флаг цепочки (деактивация) | V.1, V.6 | |
||||
| 2 | Сервис: «0 попыток — in-place; иначе — fork + копия вопросов» | V.2, V.3 | |
||||
| 3 | Попытка: `test_version_id` фиксируется **на старте**; не перезаписывается | V.4 | |
||||
| 4 | API: CRUD с версиями; переключение активной; деактивация; списки без дублей | V.5, V.6, V.10 | |
||||
| 5 | UI: версия, история, смена активной, списки | V.7, V.8 | |
||||
| 6 | Тесты: unit + integration, регресс разбора | V.9 | |
||||
|
||||
**Авторизация на БД [Postgres_TG_Bots](../../../Postgres_TG_Bots):** [card1.md](card1.md) **A.1**–**A.5** — **в рамке спринта 1**, если согласовано (иначе — отдельной задачей сразу после V.1). |
||||
|
||||
**Загрузка документа → тест:** [card1.md](card1.md) **D.1**–**D.5** — **вне** замыкания **только** на версии; может пойти **после** V.3 или параллельно, зависит от **LLM** (§4.2). |
||||
|
||||
--- |
||||
|
||||
## Вне спринта 1 (как в ТЗ) |
||||
|
||||
- Полный **AI-**модуль (§4.2) сверх **импорта** из карточки, **медиа** (§4.3), **подсказки** (§4.4), **режимы** (§4.5), **дашборды** (этап 2). |
||||
|
||||
--- |
||||
|
||||
## Документы тестирования |
||||
|
||||
- **Единый журнал** (автопроверки + **ваши** шаги с ответом ОК/не ОК): [TESTING_JOURNAL.md](TESTING_JOURNAL.md) |
||||
- Черновик чек-листа: [sprint-01-testing.md](sprint-01-testing.md) |
||||
@ -0,0 +1,73 @@
|
||||
# Спринт 2 — тестирование (AI-помощники) |
||||
|
||||
**Состояние продукта:** [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · чек-лист импорта и API — ниже. |
||||
|
||||
**Предпосылка:** спринт 1 (версии) принят. |
||||
**Секреты:** для стенда допускается отдельный ключ DeepSeek; в логах/скриншотах **не** светить полный ключ. |
||||
|
||||
**MVP 2026-04 (импорт + LLM, до полного спринта 2):** в `backend` задать `DEEPSEEK_API_KEY` *или* `OPENAI_API_KEY` → `POST /api/tests/import/document` при загрузке файла возвращает `generation.draft` → в карточке теста, блок «Импорт из файла», кнопка **«Применить сгенерированный черновик»** → затем **«Сохранить черновик»**. Юнит-тесты: `documentGenService.test.js`. |
||||
|
||||
--- |
||||
|
||||
## 1. Автоматизировано и self-check (разработка) |
||||
|
||||
_Отмечайте [ ] → [x] по мере выполнения._ |
||||
|
||||
### 1.1 Безопасность и API |
||||
|
||||
- [ ] Эндпоинты настроек не возвращают сырой ключ на клиент |
||||
- [ ] Ошибка при отсутствии/невалидном ключе — структурированная, с кодом/текстом для UI |
||||
- [ ] «Проверить подключение» при валидном ключе — успех; при фейле — сообщение об ошибке сети/401 и т.д. |
||||
|
||||
### 1.2 Моки / контракты (по возможности) |
||||
|
||||
- [ ] Парсинг JSON-ответов LLM: при невалидном JSON — пользователю сообщение, не 500 без текста |
||||
- [ ] Unit-тесты маппинга «ответ LLM → черновик теста/вопроса» (на фикстурах) |
||||
|
||||
### 1.3 Смоук перед передачей |
||||
|
||||
- [ ] Сгенерировать тест: только с названием; превью; применить — вопросы в редакторе |
||||
- [ ] Проверить тест: модалка с текстом |
||||
- [ ] Улучшить вопрос: чекбоксы, применение частично |
||||
- [ ] Дистракторы: к существующим ответам добавилось 3 варианта |
||||
- [ ] После применения AI-изменений сохранение теста согласовано с правилами **версий** (если были попытки) |
||||
|
||||
--- |
||||
|
||||
## 2. Ручная приёмка (я / заказчик) |
||||
|
||||
### 2.1 Настройки |
||||
|
||||
- [ ] Сохранить ключ, обновить страницу — приложение не показывает ключ, но AI-функции работают |
||||
- [ ] Очистить/испортить ключ — AI показывает ошибку и **есть** переход/ссылка на настройки |
||||
- [ ] «Проверить подключение» отражает реальное состояние (успех/ошибка) |
||||
|
||||
### 2.2 Уровень теста |
||||
|
||||
- [ ] **Сгенерировать тест** недоступен при пустом названии; при заполненном — выдаёт осмысленный черновик, применяется **целиком** по кнопке |
||||
- [ ] **Проверить тест** — рекомендации читаемы, модалка закрывается, данные теста не портит без явного применения |
||||
- [ ] **Предложить улучшение** — сравнение было/стало, выбор чекбоксами, применяется только отмеченное |
||||
|
||||
### 2.3 Уровень вопроса |
||||
|
||||
- [ ] **Улучшить вопрос** — нет молчаливой перезаписи; подтверждение через чекбоксы/применить |
||||
- [ ] **Дистракторы** — три новых **не** заменяют старые ответы |
||||
- [ ] **Сгенерировать подсказку** — текст появляется в поле, можно отредактировать и сохранить |
||||
|
||||
### 2.4 Версионирование + AI (регресс) |
||||
|
||||
- [ ] На тесте **без** попыток: массовое применение AI не создаёт лишних версий бессмысленно (ожидание как в спринте 1) |
||||
- [ ] На тесте **с** попытками: осмысленные сохранения ведут себя по правилам 4.1 (новая версия при изменении) |
||||
|
||||
### 2.5 Дизайн |
||||
|
||||
- [ ] Кнопки, модалки, превью, состояния загрузки/ошибок **визуально** в одном ряду с остальным модулем (как спринт 1) |
||||
|
||||
### 2.6 Качество UX |
||||
|
||||
- [ ] Долгий ответ LLM: индикатор ожидания, нельзя «задвоить» запросы без контроля |
||||
- [ ] Понятные сообщения при сбое сети или API DeepSeek |
||||
|
||||
--- |
||||
|
||||
**Итог приёмки спринта 2:** дата __________, комментарий _________________________ |
||||
@ -0,0 +1,65 @@
|
||||
# Спринт 2 — Редактор тестов: AI-помощники (desktop web) |
||||
|
||||
**Формат:** отдельное веб desktop-приложение (этап 1, фича §4.2). |
||||
**Граница спринта:** начинается **после** приёмки спринта 1; заканчивается готовностью **всех** функций AI-помощника из ТЗ (настройки ключа, проверка подключения, сценарии уровня теста и уровня вопроса) на базе **DeepSeek** и сохранения **идентичности дизайна** с остальным приложением. |
||||
**Предпосылка:** версионирование (спринт 1) работает; сгенерированные/изменённые черновики сохраняются в модель **текущей редактируемой версии** согласно правилам 4.1. |
||||
|
||||
**Стек (целевой в ТЗ):** Python, FastAPI — **в репозитории TestingWebApp фактически Node.js + Express**, LLM через HTTP (OpenAI-совместимый API, в т.ч. DeepSeek); ключ в окружении или (в целевом виде спринта) в БД, не на клиенте. Сводка MVP: [../PROJECT_STATUS.md](../PROJECT_STATUS.md). |
||||
|
||||
--- |
||||
|
||||
## Цель спринта |
||||
|
||||
Реализовать **§4.2 ТЗ**: интеграция DeepSeek, страница настроек, все табличные функции уровня теста и вопроса, обработка отсутствия ключа, UI с превью и подтверждением (без «тихой» перезаписи там, где ТЗ требует сравнения с чекбоксами). |
||||
|
||||
--- |
||||
|
||||
## Функции (контрольный список из ТЗ) |
||||
|
||||
### Интеграция и настройки |
||||
|
||||
- Ключ DeepSeek на `/settings`, хранение в БД, не отдаётся на фронт |
||||
- «Проверить подключение» — тестовый запрос |
||||
- Все AI-действия при отсутствии ключа — понятная ошибка + ссылка на настройки |
||||
|
||||
### Уровень теста |
||||
|
||||
| Функция | Критерий | |
||||
| --- | --- | |
||||
| Сгенерировать тест | Только при заполненном названии; превью; применение целиком | |
||||
| Проверить тест | Модалка с рекомендациями | |
||||
| Предложить улучшение всего теста | Постатейно было → стало, чекбоксы, применение выбранного | |
||||
|
||||
### Уровень вопроса |
||||
|
||||
| Функция | Критерий | |
||||
| --- | --- | |
||||
| Улучшить вопрос | Модалка, было/стало по частям, чекбоксы, **без** прямой замены без подтверждения | |
||||
| Дистракторы | +3 неправдоподобных варианта **добавляются**, не заменяют | |
||||
| Сгенерировать подсказку | Текст в поле подсказки; автор правит/удаляет (связь с §4.4 в следующих спринтах) | |
||||
|
||||
--- |
||||
|
||||
## Технические подзадачи |
||||
|
||||
| # | Задача | |
||||
| --- | --- | |
||||
| 1 | Модель настроек (ключ), API save/test, маскирование в ответах | |
||||
| 2 | Промпты и контракты JSON для каждой функции; валидация ответа LLM | |
||||
| 3 | Эндпоинты/сервисы: 6 сценариев + единая обёртка ошибок/квот | |
||||
| 4 | UI: кнопки в редакторе, модалки, превью «сгенерировать тест», дифы с чекбоксами | |
||||
| 5 | Соблюдение правил 4.1 при сохранении применённых AI-изменений | |
||||
| 6 | Логи без утечки ключа; rate-limit/таймауты по best effort | |
||||
|
||||
--- |
||||
|
||||
## Вне спринта 2 |
||||
|
||||
- Медиа (§4.3), полноценное поведение подсказок в прохождении (§4.4 + §4.5) — отдельные спринты, если не входят в минимум для кнопки «подсказка» в редакторе |
||||
- Дашборды (этап 2 ТЗ), HR-интеграция, MAX |
||||
|
||||
--- |
||||
|
||||
## Документ тестирования |
||||
|
||||
Чек-лист: `sprint-02-testing.md`. |
||||
@ -0,0 +1,264 @@
|
||||
# Техническое задание на доработку |
||||
|
||||
**Система тестирования сотрудников клиники** |
||||
|
||||
| Поле | Значение | |
||||
| --- | --- | |
||||
| Версия | 1.0 | |
||||
| Дата | 2026-04-23 | |
||||
| Статус | Черновик | |
||||
| Адресат | Константин Л. (разработчик) | |
||||
| Базовый репозиторий | https://git.pirogov.ai/l_konstantin/TestingWebApp | |
||||
|
||||
**Состояние репозитория (не ТЗ, а факт):** ветка `dev` — [PROJECT_STATUS.md](../PROJECT_STATUS.md); как пользоваться локальным стендом — [DEV_CONTOUR_USER_GUIDE.md](../DEV_CONTOUR_USER_GUIDE.md). |
||||
|
||||
## 1. Контекст и зачем это делается |
||||
|
||||
### Зачем клинике система тестирования |
||||
|
||||
Система нужна, чтобы сделать проверку знаний сотрудников управляемым процессом, а не бумажной рутиной. Сегодня проверка знаний по регламентам, стандартам работы с пациентами, новым протоколам и внутренним правилам проходит устно или на бумаге — это тяжело воспроизводить, невозможно сравнивать результаты между людьми и отделами, а руководитель не видит целостной картины по своему подразделению. |
||||
|
||||
### Что система даёт каждой роли |
||||
|
||||
- **Руководителю подразделения** — единое место, где видно, кто из сотрудников прошёл обязательные тесты, кто не справился, у кого приближается дедлайн. Вместо рассылки регламентов «в чат и на почту» — проверяемая практика: назначил тест → увидел результат → понял, где у отдела пробелы. AI-помощник снимает главный барьер: придумать и сформулировать хороший тест — это отдельная работа, которой у руководителя обычно нет времени. С помощником создание теста занимает минуты, а не часы. |
||||
|
||||
- **Сотруднику** — понятный личный кабинет: какие тесты назначены, когда сдать, какой результат был в прошлый раз, какие ошибки стоит разобрать. Разбор ошибок после теста превращает проверку в короткий обучающий цикл. Если автор составит задорный тест — с живыми формулировками, неочевидными вариантами ответов, лёгким юмором, — прохождение перестаёт восприниматься как рутинная формальность и приобретает элементы фана и геймификации. Это снижает сопротивление и делает регулярные тесты нормальной частью рабочего дня. |
||||
|
||||
- **Директору и его помощнику** — объективная картина по всей клинике: какие подразделения сильнее, какие отстают, где нужно вмешательство. Это инструмент управленческих решений по обучению и кадрам, а не просто отчётность. |
||||
|
||||
- **HR** — структурированная история знаний каждого сотрудника, которая в будущем ляжет в общий HR-контур (онбординг, индивидуальные планы развития, кадровые решения). |
||||
|
||||
### Стратегическая роль: точка входа в HR-приложение в MAX |
||||
|
||||
У модуля тестирования есть ещё одна важная роль — он становится одной из первых регулярно используемых частей общего HR-приложения в боте MAX. Тесты назначаются регулярно и требуют обязательного прохождения, а значит, сотрудник будет заходить в HR-приложение не время от времени, а стабильно. Это формирует привычку: открыть HR в MAX — привычное действие. Следом подтянутся и другие HR-сервисы (справочная информация, заявки, графики, отпуска, обратная связь), которые без точки притяжения могли бы оставаться «пустым модулем». Таким образом, система тестирования популяризует само HR-приложение внутри MAX и помогает ему стать ежедневным рабочим инструментом, а не редко открываемым разделом. |
||||
|
||||
Отдельно важен формат взаимодействия. Внутри бота MAX система тестирования открывается как полноценное веб-приложение (мини-приложение) — с нормальной вёрсткой форм, навигацией, медиа в вопросах, удобными таблицами результатов. Это принципиально лучший пользовательский опыт, чем предыдущая реализация в Telegram, где интерфейс был ограничен форматом бота (сообщения, кнопки, линейные диалоги) и не давал ни удобного ввода, ни нормального отображения сложного контента. Перевод в веб-формат внутри MAX — это не просто смена канала, а качественный скачок в удобстве работы с тестами для всех ролей. |
||||
|
||||
### Текущее состояние |
||||
|
||||
Уже реализовано: авторизация по логину/паролю, создание тестов автором, назначение теста сотрудникам из списка, прохождение теста сотрудником в браузере. |
||||
|
||||
Доработка расширяет эту реализацию новыми возможностями и готовит её к встраиванию в общую HR-систему клиники. В HR-системе уже есть собственная авторизация и собственная система разграничения прав (кто какие разделы и данные видит), поэтому на более поздних этапах собственная авторизация модуля тестирования будет отключена в пользу HR-авторизации. До этого момента текущая авторизация используется как есть. |
||||
|
||||
## 2. Ключевые дополнительные возможности |
||||
|
||||
Помимо базового функционала (создание тестов, назначение, прохождение), который уже реализован, в этой доработке добавляются пять возможностей, радикально расширяющих ценность системы. Они перечислены в порядке значимости для пользователя. |
||||
|
||||
### 2.1. AI-помощники при создании тестов — ключевая возможность |
||||
|
||||
Это главный выигрыш всей доработки. Создание теста с нуля — это отдельная работа: нужно придумать вопросы, сформулировать варианты ответов, подобрать неочевидные неправильные ответы (дистракторы), проверить качество формулировок. У руководителя подразделения этого времени обычно нет. Без AI-помощника сама задача «писать тесты регулярно» фактически невыполнима — она превращается в проект. |
||||
|
||||
AI-помощник меняет это кардинально: |
||||
|
||||
- **Сокращение времени в разы.** Вместо нескольких часов ручной работы — несколько минут на редактирование черновика, который сгенерировал AI. |
||||
- **Более удобный формат работы.** Автор не пишет с пустого листа, а работает в режиме редактора: получает готовое, правит, подтверждает. Отдельные кнопки позволяют улучшить конкретный вопрос, добавить дистракторы, сгенерировать подсказку, проверить весь тест на качество. |
||||
- **Выше качество формулировок.** AI предлагает варианты, о которых автор мог не подумать — правдоподобные дистракторы, более чёткие формулировки, корректную подсказку. |
||||
|
||||
Без этой возможности регулярное создание тестов останется узким местом и система не заработает в полную силу. С ней — создание теста становится быстрой повседневной операцией, которую может делать любой руководитель. |
||||
|
||||
### 2.2. Версионирование тестов |
||||
|
||||
Руководитель может править тест после первых прохождений, не теряя корректность старых результатов. Старые попытки по-прежнему корректно разбираются по той версии теста, которая была на момент их прохождения. |
||||
|
||||
### 2.3. Медиа в вопросах |
||||
|
||||
К вопросу можно прикрепить изображение или видео: фото инструмента, рентгеновский снимок, ролик с правильной техникой манипуляции. Тесты перестают быть «только про текст» — это особенно важно в медицинской тематике. |
||||
|
||||
### 2.4. Подсказки и режимы прохождения (таймер, мгновенная оценка) |
||||
|
||||
Один и тот же тест можно проводить как строгую проверку (с таймером, без подсказок, итог в конце) или как мягкий обучающий тренажёр (с подсказками, без таймера, мгновенная обратная связь по каждому вопросу). Набор режимов — независимые настройки, автор комбинирует их под задачу. |
||||
|
||||
### 2.5. Дашборды для всех ролей |
||||
|
||||
Замена ручного сбора статистики на один экран с нужным срезом: сотрудник видит свои тесты и историю, руководитель — своё подразделение, директор — всю клинику с возможностью посмотреть любое подразделение и любого сотрудника. |
||||
|
||||
### 2.6. Хранение в PostgreSQL, сотрудники и RBAC (зафиксировано для реализации) |
||||
|
||||
- **Один кластер PostgreSQL** (как в экосистеме Postgres_TG_Bots / HR): отдельная база **`clinic_tests`** — тесты, версии, назначения, попытки и миграции модуля; база **`hr_bot_test`** — штат, справочники, веб-логины и **уже реализованный RBAC**. Схемы не объединять в одну БД без отдельного решения: в `hr_bot_test` заняты имена и смыслы таблиц (`users`, `departments` и др.) под HR. |
||||
- Во **всех бизнес-процессах** модуля тестирования сотрудник идентифицируется по **`staff_members.id`**. В `clinic_tests` хранятся **ссылки** на этот идентификатор; кадровые данные и структура подразделений — из HR, без дублирования «второго реестра людей». |
||||
- Поле **`telegram_id`** у сотрудника **не используется** в логике модуля (вход, назначения, фильтры, права) — только как **справочная** информация при необходимости. |
||||
- **Разграничение прав** в целевом виде — через **существующую** в клинике систему (роли, permissions, привязки к сотруднику); модуль **не** строит параллельную полную копию RBAC. Допустимы временные упрощения до согласования API с HR. |
||||
- **Назначения и новые версии (V.10 / card1):** запись `test_assignments` **не** перепривязываем автоматически к новой `test_version_id` при форке версии. **Старт попытки** (V.4) фиксирует **активную** версию на момент «Старт», а не версию из строки назначения. Авто-обновление `test_assignments` при смене активной версии **не** делаем. |
||||
|
||||
Детализация: [card1.md](card1.md) (вступление), [README](../../README.md#данные-сотрудники-интеграция-с-hr). |
||||
|
||||
## 3. Этапы |
||||
|
||||
| № | Этап | Формат | Кто принимает | |
||||
| --- | --- | --- | --- | |
||||
| 1 | Доработка редактора тестов | Web desktop | Руководители подразделений | |
||||
| 2 | Дашборды (сотрудника / руководителя / директора) | Web desktop | Руководители подразделений + директор | |
||||
| 3 | Интеграция с HR-системой | Backend-интеграция + изменения авторизации | Совместно с командой HR | |
||||
| 4 | Адаптация под мини-приложение в боте MAX | Mini-app | Совместно с командой HR | |
||||
| 5 | Уведомления | В рамках общей системы HR | Совместно с командой HR | |
||||
|
||||
Этапы 1 и 2 реализуются как отдельные desktop-приложения и принимаются независимо друг от друга. Этапы 3–5 выполняются позже, совместно с командой большой HR-системы — в этом ТЗ описаны верхнеуровнево. |
||||
|
||||
## 4. Этап 1 — Доработка редактора тестов |
||||
|
||||
Этап расширяет существующий редактор пятью возможностями. Все пять — независимые фичи, могут реализовываться в любом порядке и приниматься отдельно. |
||||
|
||||
### 4.1. Версионирование тестов |
||||
|
||||
**Цель:** сохранить корректность истории прохождений, когда автор правит тест после первых попыток. |
||||
|
||||
**Правила:** |
||||
|
||||
- Пока по тесту не было ни одной попытки — автор редактирует тест на месте, номер версии не меняется. |
||||
- Как только появилась хотя бы одна попытка — любое сохранение изменений создаёт новую версию теста (`version + 1`, связь со старой версией через `parent_id`). Старая версия становится неактивной, но сохраняется в базе. |
||||
- Все версии теста связаны в цепочку. Каждая попытка прохождения привязана к конкретной версии, по которой сотрудник проходил тест, — разбор ошибок по старым результатам остаётся корректным даже после того, как автор изменил тест. |
||||
- В списке тестов для сотрудников и авторов показывается только одна активная версия каждой цепочки. |
||||
- Автор может открыть историю версий теста и вручную переключить активную версию на любую другую из цепочки — остальные при этом автоматически становятся неактивными. |
||||
- Тест можно деактивировать целиком (скрыть всю цепочку из списка, данные не удаляются). |
||||
|
||||
### 4.2. AI-помощник при создании и редактировании тестов |
||||
|
||||
**Цель:** ускорить и повысить качество создания тестов силами LLM. |
||||
|
||||
**Интеграция:** |
||||
|
||||
- Используется DeepSeek API (совместим с форматом OpenAI — подключается через библиотеку `openai` с `base_url=https://api.deepseek.com`, модель `deepseek-chat`). |
||||
- Для структурированных ответов использовать `response_format={"type": "json_object"}`. |
||||
- API-ключ DeepSeek вводится на отдельной странице настроек (`/settings`) и хранится в БД. На фронтенд ключ не передаётся. |
||||
- На странице настроек — кнопка «Проверить подключение», которая выполняет тестовый запрос к API. |
||||
- Все AI-функции требуют настроенного ключа; при его отсутствии возвращается понятная ошибка со ссылкой на «Настройки». |
||||
|
||||
**Функции уровня всего теста:** |
||||
|
||||
| Функция | Описание | |
||||
| --- | --- | |
||||
| Сгенерировать тест | По названию теста AI генерирует набор вопросов с вариантами ответов. Кнопка доступна только когда название заполнено. Результат показывается в превью, автор применяет его целиком. | |
||||
| Проверить тест | AI анализирует весь тест и выдаёт рекомендации по улучшению (чёткость формулировок, качество дистракторов, охват темы). Показывается в модальном окне. | |
||||
| Предложить улучшение всего теста | AI предлагает улучшенные формулировки всех вопросов и ответов. Результат отображается как постатейное сравнение (было → стало) с чекбоксами — автор выбирает, какие изменения применить. | |
||||
|
||||
**Функции уровня одного вопроса:** |
||||
|
||||
| Функция | Описание | |
||||
| --- | --- | |
||||
| Улучшить вопрос | AI переформулирует выбранный вопрос и его варианты ответов. Результат показывается в модальном окне с постатейным сравнением и чекбоксами (вопрос + каждый вариант отдельно). Прямая замена без подтверждения не допускается. | |
||||
| Дистракторы | AI генерирует 3 новых правдоподобных неправильных варианта ответа. Они добавляются к существующим, а не заменяют их. | |
||||
| Сгенерировать подсказку | AI пишет подсказку к вопросу (см. раздел 4.4). Автор может отредактировать или переписать полученный текст. | |
||||
|
||||
### 4.3. Медиа в вопросах |
||||
|
||||
**Цель:** к вопросу можно прикрепить изображение или видео, которое сотрудник увидит при прохождении. |
||||
|
||||
**Требования:** |
||||
|
||||
- К одному вопросу может быть прикреплён один медиа-файл (изображение или видео). |
||||
- **Поддерживаемые форматы:** изображения: JPG, PNG, WebP; видео: MP4, WebM. |
||||
- **Ограничения по размеру:** изображение — до 5 МБ; видео — до 50 МБ. |
||||
- Файлы хранятся локально на сервере (например, в папке `uploads/`). Внешние хранилища (S3/MinIO) не используются. |
||||
- В редакторе вопроса — отдельное поле «Медиа» с загрузкой файла и превью, кнопкой удаления. |
||||
- При прохождении теста медиа отображается над текстом вопроса. Видео — с нативным плеером браузера. |
||||
- Имена файлов на диске должны быть непредсказуемыми (например, UUID), чтобы исключить угадывание ссылок. |
||||
|
||||
### 4.4. Подсказки к вопросам |
||||
|
||||
**Цель:** при прохождении теста в режиме с подсказками сотрудник может запросить подсказку к вопросу. |
||||
|
||||
**Требования:** |
||||
|
||||
- Каждый вопрос имеет одно необязательное текстовое поле «Подсказка». |
||||
- Заполнение подсказки — на усмотрение автора. Три способа: |
||||
1. Автор пишет подсказку вручную. |
||||
2. Автор нажимает «Сгенерировать подсказку» — AI генерирует текст подсказки; автор может сохранить как есть, отредактировать или удалить. |
||||
3. Оставить поле пустым — подсказки по этому вопросу не будет даже в режиме с подсказками. |
||||
- Подсказка показывается сотруднику только если тест запущен в режиме с подсказками (см. 4.5) и автор её заполнил. |
||||
|
||||
### 4.5. Режимы прохождения теста |
||||
|
||||
**Цель:** автор при создании теста выбирает, как именно сотрудник будет его проходить. |
||||
|
||||
Три независимых настройки теста (устанавливаются при создании, сохраняются в версии теста): |
||||
|
||||
| Настройка | Варианты | Поведение при прохождении | |
||||
| --- | --- | --- | |
||||
| Подсказки | Включены / выключены | Если включены и у вопроса заполнена подсказка — сотруднику доступна кнопка «Показать подсказку» под вопросом. Факт использования подсказки фиксируется в попытке (в будущем может влиять на балл). | |
||||
| Таймер | Выключен / N минут | Если задан — отображается обратный отсчёт. По истечении — тест автоматически завершается, попытка считается сданной с тем, что ответил сотрудник. | |
||||
| Мгновенная оценка | Включена / выключена | Если включена — после ответа на каждый вопрос сразу показывается правильный ответ и комментарий (разбор по этому вопросу), затем переход к следующему. Если выключена — разбор и итог только после завершения всего теста. | |
||||
|
||||
Настройки отображаются на странице прохождения так, чтобы сотрудник заранее понимал условия (есть ли таймер, будет ли сразу виден правильный ответ и т. д.). |
||||
|
||||
## 5. Этап 2 — Дашборды |
||||
|
||||
**Цель:** предоставить каждой роли индивидуальный экран с релевантной для неё информацией. Этап реализуется отдельным desktop-приложением (или отдельным разделом того же приложения — на усмотрение разработчика). |
||||
|
||||
### 5.1. Дашборд сотрудника |
||||
|
||||
Что видит сотрудник на своей главной странице: |
||||
|
||||
- **Назначенные тесты** — таблица или карточки со статусом (Не начат, В процессе, Завершён, Просрочен) и датой дедлайна. |
||||
- **График дедлайнов** — визуализация (таймлайн или календарь) по ближайшим срокам сдачи. |
||||
- **История попыток** — все попытки сотрудника: тест, версия, дата начала/завершения, результат, зачёт/незачёт. |
||||
- Из строки истории — переход на разбор ошибок конкретной попытки. |
||||
|
||||
### 5.2. Дашборд руководителя подразделения |
||||
|
||||
Что видит руководитель подразделения — только по своему подразделению: |
||||
|
||||
- **Сводка по сотрудникам:** список сотрудников с колонками — назначено тестов / сдано / просрочено / средний балл. По клику на сотрудника — его история попыток и назначенных тестов. |
||||
- **Сводка по назначенным тестам:** по каждому тесту, назначенному подразделению — процент сдавших, список сдавших и несдавших. |
||||
- **Фильтры:** по диапазону дат, по конкретному тесту. |
||||
|
||||
### 5.3. Дашборд директора и помощника директора |
||||
|
||||
Что видят директор и его помощник — по всей клинике: |
||||
|
||||
- **Общая сводка:** число активных тестов, число сотрудников, общий процент сдачи, средний балл. |
||||
- **Сравнение подразделений:** таблица подразделений с колонками — число сотрудников, процент сдачи, средний балл. Сортировка по любой колонке. По клику на подразделение открывается вид как у руководителя этого подразделения (см. 5.2). |
||||
- По клику на сотрудника (из любого уровня) — его история попыток. |
||||
|
||||
## 6. Этап 3 — Интеграция с HR-системой |
||||
|
||||
**Цель:** модуль тестирования становится частью большой HR-системы клиники. |
||||
|
||||
**Ключевые изменения:** |
||||
|
||||
- Собственная авторизация модуля тестирования отключается. Вход выполняется через HR (SSO, JWT или другой механизм, который будет определён командой HR). |
||||
- Пользователи, подразделения и роли приходят из HR — не хранятся в локальной БД модуля тестирования (или хранятся как кэш, синхронизируемый с HR). **Идентичность сотрудника** в данных и при интеграции — по **`staff_members.id`** (см. §2.6); идентификаторы **Telegram** в этих цепочках **не** используются. |
||||
- Разграничение прав доступа (кто что видит и что может делать) выполняется по **существующей** HR-модели RBAC (роли, permissions, привязка к `staff_members.id`). Соответствие ролей HR-системы и возможностей модуля тестирования определяется отдельно в начале этапа. |
||||
- Назначение тестов остаётся внутри модуля тестирования (а не в HR). Это отдельный пользовательский сценарий, который удобнее оставить рядом с редактором и трекером. |
||||
- Дашборды используют ФИО, подразделения и иерархию из HR. |
||||
|
||||
Детальные контракты (API HR, формат токена, справочники) будут описаны отдельным документом совместно с командой HR перед стартом этапа. |
||||
|
||||
## 7. Этап 4 — Мини-приложение для бота MAX |
||||
|
||||
**Цель:** сотрудник может проходить назначенные тесты прямо из бота MAX, без перехода в браузер. |
||||
|
||||
**Верхнеуровневые требования:** |
||||
|
||||
- Desktop-интерфейс сотрудника адаптируется под размер мини-приложения MAX (адаптивная вёрстка, упрощённая навигация, без многоуровневого меню). |
||||
- Внутри мини-приложения доступны: список назначенных тестов, прохождение теста, результат и разбор ошибок. |
||||
- Функции авторов тестов и руководителей в mini-app не выносятся — для них остаётся полноценный desktop-интерфейс. |
||||
- Авторизация в mini-app — через MAX → HR (конкретная схема определяется на старте этапа). |
||||
|
||||
## 8. Этап 5 — Уведомления |
||||
|
||||
Реализуются в рамках общей системы уведомлений большой HR-системы, а не как отдельный модуль системы тестирования. |
||||
|
||||
**События**, которые должна знать система тестирования и передавать в общую систему уведомлений: |
||||
|
||||
- Сотруднику назначен новый тест. |
||||
- Приближается дедлайн сдачи теста (за N дней, N — настраивается). |
||||
- Дедлайн теста просрочен без сдачи. |
||||
|
||||
Канал (MAX / e-mail / другое) и формат сообщений определяются общей системой HR. |
||||
|
||||
## 9. Вне scope |
||||
|
||||
В рамках этой доработки не реализуются: |
||||
|
||||
- Экспорт отчётов в Excel / PDF. |
||||
- Собственная система уведомлений внутри модуля тестирования — уведомления будут реализованы в общей HR-системе. |
||||
|
||||
## 10. Порядок приёмки |
||||
|
||||
Общий принцип: каждый этап принимается отдельно. Следующий этап не начинается, пока предыдущий не принят. |
||||
|
||||
1. **Этап 1** — по мере готовности каждой из пяти функций (4.1–4.5) руководители подразделений вручную проходят по ней сценарии использования, заводят замечания, разработчик их исправляет. Этап принят, когда все пять функций прошли приёмку. |
||||
2. **Этап 2** — дашборды тестируются по ролям: сотрудник → руководитель подразделения → директор. Проверяется, что каждая роль видит только разрешённые данные и что переходы между уровнями (клиника → подразделение → сотрудник) работают корректно. |
||||
3. **Этапы 3–5** — приёмка проводится совместно с командой большой HR-системы, критерии и сценарии определяются в начале каждого этапа. |
||||
|
||||
Подробные чек-листы тестирования для каждой функции готовятся перед стартом соответствующего этапа и ведутся в отдельных документах в папке DOC/. |
||||
|
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 |
@ -0,0 +1,158 @@
|
||||
# Предложение по редизайну страницы «Создание теста» |
||||
|
||||
## Актуализация (кабинет `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`. |
||||
|
||||
--- |
||||
|
||||
## 1. Цель |
||||
|
||||
Сделать форму создания теста читаемее: разбить «полотно» на три смысловые группы и чётко отделить редактируемое содержимое от служебных команд. Заодно — дать пользователю возможность сгенерировать тест сразу нужной формы, не нащёлкивая «+ вопрос» / «+ вариант» вручную. |
||||
|
||||
## 2. Текущее состояние (что есть) |
||||
|
||||
`TestForm/index.tsx` сейчас визуально устроен так: |
||||
|
||||
``` |
||||
┌─────────────────────────────────────────┐ |
||||
│ ← Назад Заголовок │ |
||||
├─────────────────────────────────────────┤ |
||||
│ Card «Основные настройки» │ |
||||
│ • название │ |
||||
│ • описание │ |
||||
│ • порог зачёта │ |
||||
│ • таймер │ |
||||
│ • разрешить возврат │ |
||||
├─────────────────────────────────────────┤ |
||||
│ [Сгенерировать с AI] [Проверить тест] │ ← вне карточек, между мета и вопросами |
||||
├─────────────────────────────────────────┤ |
||||
│ Card «Вопрос 1» │ |
||||
│ ... │ |
||||
│ Card «Вопрос N» │ |
||||
│ [+ Добавить вопрос] │ |
||||
├─────────────────────────────────────────┤ |
||||
│ [Создать тест] [Отмена] │ |
||||
└─────────────────────────────────────────┘ |
||||
``` |
||||
|
||||
Замечания: |
||||
- AI-кнопки висят «в воздухе» — не видно, к какой части формы они относятся. |
||||
- «Сгенерировать с AI» жёстко создаёт **7 вопросов** (см. `TestForm/index.tsx:123` — `llmApi.generate(title.trim(), 7)`), без выбора структуры. |
||||
- В fallback-сообщении ошибки упоминается «API ключ в настройках» (`TestForm/index.tsx:244`). |
||||
|
||||
## 3. Что меняем |
||||
|
||||
### 3.1. Три смысловых блока |
||||
|
||||
| Блок | Содержит | Визуально | |
||||
|------|----------|-----------| |
||||
| **Метаинформация** | название, описание, порог, таймер, навигация назад | `Card title="Метаинформация"` | |
||||
| **Содержание** | кнопка «Сгенерировать с AI», список вопросов, «+ Добавить вопрос» | `Card title="Содержание"` (вложенные карточки вопросов остаются) | |
||||
| **Команды** | «Создать тест», «Отмена», «Проверить тест» | блок `Space` снизу страницы | |
||||
|
||||
Кнопка **«Проверить тест»** логически — это команда над всем тестом, поэтому переезжает в нижнюю панель команд. Кнопка **«Сгенерировать с AI»** становится первым элементом блока «Содержание» — у неё там естественное место (она формирует именно содержание). |
||||
|
||||
### 3.2. Wireframe после редизайна |
||||
|
||||
``` |
||||
┌─────────────────────────────────────────┐ |
||||
│ ← Назад Создание теста │ |
||||
├─────────────────────────────────────────┤ |
||||
│ Card «Метаинформация» │ |
||||
│ • название │ |
||||
│ • описание │ |
||||
│ • порог зачёта │ |
||||
│ • таймер │ |
||||
│ • разрешить возврат │ |
||||
├─────────────────────────────────────────┤ |
||||
│ Card «Содержание» │ |
||||
│ ┌─ AI-генерация ────────────────────┐ │ |
||||
│ │ тема: [_________________] │ │ |
||||
│ │ вопросов: [7] вариантов: [3] │ │ |
||||
│ │ [🤖 Сгенерировать] │ │ |
||||
│ └──────────────────────────────────┘ │ |
||||
│ │ |
||||
│ Card «Вопрос 1» ... │ |
||||
│ Card «Вопрос N» ... │ |
||||
│ [+ Добавить вопрос] │ |
||||
├─────────────────────────────────────────┤ |
||||
│ [Создать тест] [Проверить тест] [Отмена] │ |
||||
└─────────────────────────────────────────┘ |
||||
``` |
||||
|
||||
### 3.3. Форма AI-генерации с тремя полями |
||||
|
||||
Внутри блока «Содержание» — компактный мини-блок (не модалка) с тремя полями: |
||||
|
||||
| Поле | Тип | По умолчанию | Лимиты | |
||||
|------|-----|--------------|--------| |
||||
| Тема | `Input` | значение `title` из метаинформации (auto-fill) | непустая | |
||||
| Количество вопросов | `InputNumber` | 7 | 1…30 | |
||||
| Количество вариантов на вопрос | `InputNumber` | 3 | 2…8 | |
||||
|
||||
Кнопка **«Сгенерировать»** запускает текущий поток `handleGenerate` (модалка-превью → «Применить все вопросы»), но передаёт оба числа в API. Превью показывает то же, что и сейчас. |
||||
|
||||
Поведение существующих кнопок «Улучшить» и «Дистракторы» внутри карточки вопроса **не меняется** — они и так локальные. |
||||
|
||||
### 3.4. Уход от текста про API-ключи |
||||
|
||||
Единственное место в форме, где упоминается ключ, — fallback-сообщение об ошибке: |
||||
|
||||
```ts |
||||
// TestForm/index.tsx:244 |
||||
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.') |
||||
``` |
||||
|
||||
Заменяем на нейтральное: |
||||
|
||||
```ts |
||||
setReviewText('Не удалось получить рекомендации. Попробуйте позже или обратитесь к администратору.') |
||||
``` |
||||
|
||||
Сама страница `Settings` остаётся как есть — это её прямое назначение, и её редактируют только администраторы. |
||||
|
||||
## 4. План работ (чек-лист для исполнителя) |
||||
|
||||
- [ ] **Backend** (опционально, если ещё не принимает параметры): расширить схему `LLMGenerateRequest` в `backend/app/schemas/llm.py` полями `questions_count: int = 7`, `answers_count: int = 3`; передать их в промпт сервиса `app/services/llm.py`. |
||||
- [ ] **API-клиент**: в `frontend/src/api/llm.ts` обновить сигнатуру `generate(topic, questionsCount, answersCount)`. |
||||
- [ ] **TestForm**: обернуть текущий блок «Основные настройки» в `Card title="Метаинформация"`. |
||||
- [ ] **TestForm**: создать новый `Card title="Содержание"`, в него поместить: |
||||
- мини-блок AI-генерации (3 поля + кнопка), |
||||
- текущий `Form.List` вопросов с кнопкой «+ Добавить вопрос». |
||||
- [ ] **TestForm**: убрать текущий ряд из двух AI-кнопок между мета и вопросами; «Проверить тест» перенести в нижнюю панель команд рядом с «Создать»/«Отмена». |
||||
- [ ] **TestForm**: добавить локальный стейт `aiQuestionsCount`, `aiAnswersCount`, инициализация: 7 / 3. |
||||
- [ ] **TestForm**: в `handleGenerate` передавать оба числа; при `Применить` создавать соответствующее число пустых ответов в каждом вопросе (если бэк прислал меньше — добить пустыми, если больше — обрезать). |
||||
- [ ] **TestForm**: заменить fallback-текст про API-ключ. |
||||
- [ ] Прогнать `docker compose up`, проверить вручную: создание с дефолтами, генерация на 5 вопросов × 4 ответа, проверка теста, отмена. |
||||
- [ ] Документ-шаг в `DOC/ШАГИ/ШАГ_<дата>_<NNN>.md` по факту выполнения. |
||||
|
||||
## 5. Что **не** делаем в этой ветке |
||||
|
||||
- Не трогаем форму редактирования (`TestEdit`) — формально использует тот же `TestForm`, но эффект редизайна нужно отдельно проверить с уже заполненными вопросами и опциями версионирования. |
||||
- Не меняем поведение `Form.List` валидации (минимум 7 вопросов / 3 варианта) — это отдельная история. |
||||
- Не вводим drag-and-drop переупорядочивание вопросов. |
||||
|
||||
## 6. Открытые вопросы для согласования |
||||
|
||||
1. Нужно ли визуально подсвечивать мини-блок генерации (фон, бордер), чтобы он отличался от карточек вопросов? |
||||
2. После применения сгенерированных вопросов — нужно ли скрывать мини-блок, чтобы он не путал? |
||||
3. Лимиты «1…30 вопросов / 2…8 вариантов» — устраивают или нужны другие? |
||||
@ -0,0 +1,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. **Стратегический:** хлебные крошки и внутренняя навигация по разделу; масштабирование списка назначений; пустые состояния; продуктовая аналитика (например, доходят ли авторы до «Показ в каталоге»). |
||||
|
||||
--- |
||||
|
||||
*Документ можно дополнять по мере внедрения и новых скринов.* |
||||
@ -0,0 +1,67 @@
|
||||
# Спринты: мобильный UI кабинета тестов |
||||
|
||||
--- |
||||
|
||||
## Спринт 1 — быстрые исправления |
||||
|
||||
**Цель:** выровнять кнопки, мета-строку списка, историю версий, назначение и safe-area; без смены контентной модели страниц. |
||||
|
||||
- [x] **1.1** Панель «Сохранить черновик / К списку»: убрать конфликт `inline-actions .btn { width: auto }` с `btn-primary` — колонка на всю ширину (`.actions-bar`) |
||||
- [x] **1.2** Touch: `min-height` у `.btn--sm` (убрать, удалить вопрос, сделать активной…) |
||||
- [x] **1.3** Список тестов: не разбивать «· v1» — хвост в `list-row__meta-tail` + `white-space: nowrap` |
||||
- [x] **1.4** «История версий»: вместо `<table>` — карточки (`surface-card` + flex) |
||||
- [x] **1.5** «Назначение»: не рендерить пустой `.assign-list` (убрать «коробку» без людей) |
||||
- [x] **1.6** Сильнее рамка `.btn-ghost` (согласование с полями) |
||||
- [x] **1.7** `padding-bottom` у `.cabinet-main` + `env(safe-area-inset-bottom)` |
||||
- [x] **1.8** «Публикация»: на узком экране — кнопка на всю ширину (`.inline-actions--block-mobile`) |
||||
|
||||
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`, `frontend/src/pages/TestsList.jsx`. |
||||
|
||||
--- |
||||
|
||||
## Спринт 2 — карточки, импорт, вопрос, радио/чек, фикс-футер |
||||
|
||||
- [x] **2.1** «Прогоны и разбор»: таблица заменена на список карточек (`.attempts-card-list`) |
||||
- [x] **2.2** «Импорт из файла»: скрытый `input` + `label` с `.btn` (`.import-file-input` / `.import-file-label`) |
||||
- [x] **2.3** «Вопрос N» + «Сгенерировать вопрос (ИИ)»: колонка на мобилке, ряд от `min-width: 520px` (`.question-editor-block__header`) |
||||
- [x] **2.4** Варианты: `type="radio"` при одном верном, `checkbox` при нескольких |
||||
- [x] **2.5** Моб. фикс-футер `≤640px` с «Сохранить» / «К списку» + статус черновика; панель в потоке скрыта |
||||
|
||||
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`. |
||||
|
||||
--- |
||||
|
||||
## Спринт 3 — структура «карточки теста», копи, доводка списка и кабинета |
||||
|
||||
**Цель:** человекочитаемые названия и группировка разделов; согласованные подсказки и тап-зоны; доводка списка тестов, истории, назначения и редактора по замечаниям. |
||||
|
||||
### Страница теста (`TestDetail`) |
||||
|
||||
- [x] **3.1** `AccSection`: подзаголовок под заголовком (`subtitle` + стили `cabinet-disclosure__summary-*`) |
||||
- [x] **3.2** У «тела» аккордеона — верхний внутренний отступ: `.cabinet-disclosure__body` (контент не «прилипает» к header) |
||||
- [x] **3.3** Переименования: «О тесте», «Вопросы» (вместо мета/содержания); подзаголовки-описания по смыслу |
||||
- [x] **3.4** **Импорт** встроен в «Вопросы»: подсекция «Документ в вопросы»; отдельного аккордеона «Импорт из файла» нет |
||||
- [x] **3.5** **История**: одна секция, подпункты «Версии» + «Прохождения»; пустое/ошибочное состояние у прогонов |
||||
- [x] **3.6** Ошибка загрузки прогонов: не в герое страницы, а внутри «Прохождения» |
||||
- [x] **3.7** **Показ в каталоге:** подсекция «Видимость» + (при `assignmentUi`) «Кому выдать»; кнопка видимости **не** на всю ширину (`.publication-visibility__actions`) |
||||
- [x] **3.8** Панель ИИ-генерации: `.test-detail-ai-panel` (фон/рамка в духе кабинета, без «лишней» карточки) |
||||
- [x] **3.9** Вопрос: блоки `.question-editor-block` + первый вопрос без лишнего верхнего бордера (`.question-editor-block--first`) |
||||
- [x] **3.10** Варианты: удаление — **иконка** «закрыть» + `aria-label` (вместо текста «убрать») |
||||
- [x] **3.11** Одна панель `.question-editor__footer`: «+ вариант» и «Удалить вопрос»; «+ вопрос» вынесен в `.test-detail-add-question` над блоком импорта |
||||
- [x] **3.12** **Назначение:** кнопка **«Выбрать всех (N)»** по текущему отфильтрованному списку; подсказки в подсекциях |
||||
- [x] **3.13** Стили подсекций: `.test-detail-subsection`, `.test-detail-subsection__title`, `.test-detail-hint` |
||||
- [x] **3.14** Импорт: на мобилке **полная ширина** кнопки «Выбрать файл» (`.import-file-row--block`); ровнее отступы у превью |
||||
- [x] **3.15** Классы списка версий: на узком экране у `.version-card-list__main` `flex-grow: 0` в column — **без пустой «вытянутой» карточки v1** |
||||
|
||||
### Список тестов и шапка |
||||
|
||||
- [x] **3.16** **Список:** на `≤640px` карточка в **колонку** (заголовок на ширину экрана, **«Пройти»** снизу на **всю ширину**; без пустой полосы) |
||||
- [x] **3.17** `hover` у строк списка — только при `@media (hover: hover) and (pointer: fine)`; лёгкий **`:active`** на ссылке (тач-фидбек) |
||||
- [x] **3.18** **Шапка** `cabinet-header__inner`: учёт **safe-area** сверху и по бокам |
||||
- [x] **3.19** **Назначение (кабинет):** у `.assign-list` — выше область прокрутки на мобилке; у строк — линия раздела чуть заметнее; **центр чекбокса** + **line-clamp** у мета; `accent-color` у чекбокса |
||||
- [x] **3.20** Ритм **аккордеонов** на `test-detail-page` — чуть больше `margin` между `cabinet-brick`; **нижний отступ** у страницы под **фикс-футер** увеличен |
||||
- [x] **3.21** **Поле поиска** в назначении: более «строчный» вид (min/max `height` + `padding` у `.assign-toolbar__search`) |
||||
|
||||
**Файлы:** `frontend/src/styles/cabinet-theme.css`, `frontend/src/pages/TestDetail.jsx`, `frontend/src/pages/TestsList.jsx` (только косвенно через стили, разметка списка ранее). |
||||
|
||||
**Памятка для пользователей (не-разработчиков), иллюстрации:** [РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.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-дизайну. |
||||
|
||||
--- |
||||
|
||||
*— Справочник можно дополнять по мере появления новых терминов —* |
||||
@ -0,0 +1,9 @@
|
||||
# Пошаговая спецификация (`docs/шаги/`) |
||||
|
||||
Файлы **01**–**11** — **проектные шаги** (целевое поведение и API), а не автоматическая копия кода. Фактическое состояние фич, сценарии «как у пользователя» и ветка **`dev`** описаны в: |
||||
|
||||
- [../PROJECT_STATUS.md](../PROJECT_STATUS.md) — что сделано и что в планах; |
||||
- [../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). |
||||