Compare commits

..

51 Commits
master ... dev

Author SHA1 Message Date
Константин Лебединский 1ea83aa6b4 testingwebapp fixes, weeek tasks 2948-2958 2 weeks ago
Константин Лебединский 0229bc250b remove UI split (legacy/modern) 2 weeks ago
Константин Лебединский 1494b839f5 tset preview fix 2 weeks ago
Константин Лебединский 9511fcb555 fix test editor UI and test completition UI 2 weeks ago
Константин Лебединский b72b485fce UI bugfixes with boss 2 weeks ago
Константин Лебединский df6e770f90 bugfix 2 weeks ago
Константин Лебединский 44366a2865 bugfix 2 weeks ago
Константин Лебединский 2b429f0b08 bugfix 2 weeks ago
Константин Лебединский e6b85f3944 bugfix 2 weeks ago
Константин Лебединский db9851eeda bugfix 2 weeks ago
Константин Лебединский ebb58d4b5a access denied fix 2 weeks ago
Константин Лебединский c4a7d2ef08 Прохождение теста: один вопрос на экран, прогресс сверху, мобильная вёрстка 2 weeks ago
Константин Лебединский 09d996ead0 Миграция questions.ai_hint и подсказки в редакторе теста 2 weeks ago
Константин Лебединский fba11ff4cc Alembic: колонки tests.hints_enabled и tests.result_mode 2 weeks ago
Константин Лебединский bba96f8f9f блоки 2 и 3 доработки интерфейса системы тестирования 2 weeks ago
Константин Лебединский eff3fda5b0 Дорабоки интерфейса системы тестирования. Раздел 1 Шапка+Верхний brick 2 weeks ago
Константин Лебединский 1c4dacbc85 Merge branch 'dev-redisign' into dev 3 weeks ago
Константин Лебединский c3bdb406d6 docs(qa): уточнить — для тестирования подойдёт любая учётка 3 weeks ago
Константин Лебединский 47d673496b docs(qa): переписать инструкцию для тестировщика — только UI, без консолей и SQL 3 weeks ago
Константин Лебединский 2d6d75fb3c ui(mobile): полировка расположения редактора и каталога 3 weeks ago
Константин Лебединский 547840d671 docs(qa): tester guide for versioning and AI features 3 weeks ago
Константин Лебединский 4b0d56ff0e feat(flask): E1.0–E1.3, E1.8 — миграция на Python/Flask + AI v2 3 weeks ago
Константин Лебединский 31b51b7768 docs: move user guide out of sprints; sync design docs with UI 3 weeks ago
Константин Лебединский 5cd94c05ad docs: user guide for cabinet, sprint 4, SVG placeholders; drop token sprint 3 weeks ago
Константин Лебединский f1f5223076 feat(test detail): regroup sections, copy, and editor affordances 3 weeks ago
Константин Лебединский 72a5863871 fix(mobile): version card height, assign list, header safe-area 3 weeks ago
Константин Лебединский f7d9cbb1c4 fix(tests list): stack card on mobile, full-width title and Pass button 3 weeks ago
Константин Лебединский 9a85a13e08 fix(tests list): full-height link in split row for mobile tap 3 weeks ago
Константин Лебединский 3e70f4322d Sprint 2: attempt cards, file import button, question layout, radio/check, sticky save 3 weeks ago
Константин Лебединский 1db3653e66 docs: sprint checklist use [x]/[ ] task list format 3 weeks ago
Константин Лебединский 5db12c2348 Mobile UI sprint 1: actions-bar, version cards, meta line, safe area 3 weeks ago
Константин Лебединский 2a05f41b65 Redesign test editor: meta, content, AI shape, command bar 3 weeks ago
Константин Лебединский b3e3757a92 docs: миграция на tgFlaskForm и производительность Flask; контур flask_app; UI без лишних описаний 3 weeks ago
Константин Лебединский 47279c72e3 chore: фронт :3107, API :3001 (Docker, Vite, CORS, доки) 3 weeks ago
Константин Лебединский 42b5e9ad44 chore: API на хосте 3107 (согласовано с Vite proxy) 3 weeks ago
Константин Лебединский 0fe04d4d99 feat: полный бэк и фронт (попытки, разбор, импорт, ИИ, назначения) 3 weeks ago
Константин Лебединский a68331c86b docs: статус проекта, инструкция dev, обновление всех .md 3 weeks ago
Константин Лебединский 4801ea9f19 UI: фамилия с инициалами в шапке, подпись автора у тестов 3 weeks ago
Константин Лебединский 89da5b60b7 feat(docker): docker-compose.dev — backend+nginx, общий Postgres, миграции в entrypoint 3 weeks ago
Константин Лебединский 8ffd104f64 chore: общий Postgres по умолчанию; compose standalone + подсказки .env 3 weeks ago
Константин Лебединский 699277be07 fix(migrate): освобождать клиент пула, диагностика ECONNREFUSED и AggregateError 3 weeks ago
Константин Лебединский 5631d85238 feat(card1): версии тестов API, черновик, HR-login, import, UI 3 weeks ago
Константин Лебединский 7fa6f98ee1 docs: БД clinic_tests + hr_bot_test, staff_id, RBAC, telegram_id справка 3 weeks ago
Константин Лебединский 675555531f feat(db): DATABASE_URL и общий Postgres (Postgres_TG_Bots), БД clinic_tests 3 weeks ago
Константин Лебединский c381283ee4 chore: eslint — убрать 2 error (unused), журнал A1–A4 проверки 3 weeks ago
Константин Лебединский 26b5eddefa docs: S1-01 ручная проверка — card1 V.1/V.2/V.3 3 weeks ago
Константин Лебединский fcc6fae463 docs: S1-00 ручная проверка — журнал и раздел B 3 weeks ago
Константин Лебединский 997a71b974 docs: ручные проверки — один шаг из чата, ответ ОК/не ОК, заполняет ассистент 3 weeks ago
Константин Лебединский e87168d3a0 feat: журнал тестирования, бэклог идей; V.2 hasAnyAttemptForTest + unit tests; ссылки в спринтах 3 weeks ago
Константин Лебединский 4eeb3fbc62 docs: card1 (версии, документ, auth Postgres_TG_Bots); миграция 002 parent_id+unique active; спринт1+бэклог под Node 3 weeks ago
Константин Лебединский 93dcfcf4ff docs: add revision task spec (dev) 3 weeks ago
  1. BIN
      .DS_Store
  2. 9
      .gitignore
  3. 1
      DOC/ШАГИ/Untitled
  4. 20
      DOC/ШАГИ/ШАГ_2026-04-27_001.md
  5. 5
      DOC/ШАГИ/ШАГ_2026-04-27_002.md
  6. 4
      DOC/ШАГИ/ШАГ_2026-04-27_003.md
  7. 236
      README.md
  8. 5
      backend/.dockerignore
  9. 46
      backend/.env.example
  10. 15
      backend/.eslintrc.json
  11. 7
      backend/.prettierrc
  12. 8
      backend/Dockerfile
  13. 54
      backend/PROGRESS.md
  14. 6
      backend/docker-entrypoint.sh
  15. 3212
      backend/package-lock.json
  16. 37
      backend/package.json
  17. 26
      backend/src/apiSmoke.test.js
  18. 49
      backend/src/app.js
  19. 7
      backend/src/config/authConstants.js
  20. 6
      backend/src/config/devAuthor.js
  21. 18
      backend/src/config/featureFlags.js
  22. 100
      backend/src/db/db.js
  23. 27
      backend/src/db/hrPool.js
  24. 147
      backend/src/db/migrate.js
  25. 130
      backend/src/db/migrations/001_initial.sql
  26. 14
      backend/src/db/migrations/002_test_version_parent_and_active_unique.sql
  27. 7
      backend/src/db/migrations/003_users_staff_id_hr_link.sql
  28. 65
      backend/src/db/poolConfig.js
  29. 234
      backend/src/integration/v9card1.test.js
  30. 41
      backend/src/messages/ru.js
  31. 171
      backend/src/middleware/auth.js
  32. 188
      backend/src/routes/auth.js
  33. 634
      backend/src/routes/tests.js
  34. 8
      backend/src/server.js
  35. 197
      backend/src/services/aiEditorService.js
  36. 20
      backend/src/services/aiEditorService.test.js
  37. 125
      backend/src/services/assignmentDirectoryService.js
  38. 64
      backend/src/services/assignmentUserService.js
  39. 66
      backend/src/services/documentExtractService.js
  40. 33
      backend/src/services/documentExtractService.test.js
  41. 176
      backend/src/services/documentGenService.js
  42. 63
      backend/src/services/documentGenService.test.js
  43. 98
      backend/src/services/llmClient.js
  44. 64
      backend/src/services/testAccessService.js
  45. 477
      backend/src/services/testAttemptService.js
  46. 18
      backend/src/services/testChainService.js
  47. 23
      backend/src/services/testChainService.test.js
  48. 218
      backend/src/services/testDraftService.js
  49. 94
      backend/src/utils/auth.js
  50. 21
      backend/src/utils/hrRoleMap.js
  51. 74
      backend/src/utils/werkzeugPassword.js
  52. 31
      backend/src/utils/werkzeugPassword.test.js
  53. 38
      docker-compose.dev.yml
  54. 22
      docker-compose.yml
  55. 64
      docs/DEV_CONTOUR_USER_GUIDE.md
  56. 148
      docs/PROJECT_STATUS.md
  57. 414
      docs/QA-versioning-and-ai.md
  58. 380
      docs/TEST_TABLES_ANALYSIS.md
  59. 340
      docs/UX_аудит_и_новая_IA_—_страница_теста.md
  60. 220
      docs/bugfix/SPRINTS_BUGFIX_TESTS_NAV_AI.md
  61. 291
      docs/migration-final-inventory.md
  62. 100
      docs/migration-final.md
  63. 87
      docs/migration-to-tgflaskform-plain.md
  64. 102
      docs/migration-to-tgflaskform.md
  65. 191
      docs/performance-flask-mini-app.md
  66. 93
      docs/revision_task/BACKLOG.md
  67. 33
      docs/revision_task/BACKLOG_IDEAS.md
  68. 95
      docs/revision_task/TESTING_JOURNAL.md
  69. 109
      docs/revision_task/card1.md
  70. 68
      docs/revision_task/sprint-01-testing.md
  71. 60
      docs/revision_task/sprint-01.md
  72. 73
      docs/revision_task/sprint-02-testing.md
  73. 65
      docs/revision_task/sprint-02.md
  74. 264
      docs/revision_task/task.md
  75. BIN
      docs/screens/01_header_intro.jpg
  76. BIN
      docs/screens/02_about_test.jpg
  77. BIN
      docs/screens/03_questions_top.jpg
  78. BIN
      docs/screens/04_questions_mid.jpg
  79. BIN
      docs/screens/05_questions_bottom.jpg
  80. BIN
      docs/screens/06_save_history.jpg
  81. BIN
      docs/screens/07_catalog_visibility.jpg
  82. BIN
      docs/screens/08_catalog_employees.jpg
  83. 158
      docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md
  84. 56
      docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md
  85. 72
      docs/Рекомендации UX по экранам теста.md
  86. 67
      docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md
  87. 298
      docs/Словарь UX-UI-IA терминов.md
  88. 2
      docs/ТЗ.md
  89. 9
      docs/шаги/01-project-setup.md
  90. 5
      docs/шаги/02-database-design.md
  91. 5
      docs/шаги/03-auth.md
  92. 5
      docs/шаги/04-users-departments.md
  93. 11
      docs/шаги/05-test-management.md
  94. 5
      docs/шаги/06-test-assignment.md
  95. 5
      docs/шаги/07-test-taking.md
  96. 6
      docs/шаги/08-results-review.md
  97. 5
      docs/шаги/09-attempt-tracking.md
  98. 5
      docs/шаги/10-ai-assistant.md
  99. 5
      docs/шаги/11-settings.md
  100. 9
      docs/шаги/README.md
  101. Some files were not shown because too many files have changed in this diff Show More

BIN
.DS_Store vendored

Binary file not shown.

9
.gitignore vendored

@ -1,5 +1,14 @@
# General
.DS_Store
node_modules/
dist/
.env
.env.local
backend/.env
frontend/.env
flask_app/.env
flask_app/.venv/
flask_app/**/__pycache__/
__MACOSX/
# Thumbnails and Metadata

1
DOC/ШАГИ/Untitled

@ -0,0 +1 @@
тестирования

20
DOC/ШАГИ/ШАГ_2026-04-27_001.md

@ -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` по чек-листу из предложения — остаётся на стороне исполнителя.

5
DOC/ШАГИ/ШАГ_2026-04-27_002.md

@ -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` (мета-строка).

4
DOC/ШАГИ/ШАГ_2026-04-27_003.md

@ -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).

236
README.md

@ -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) | Исходное ТЗ заказчика. |

5
backend/.dockerignore

@ -0,0 +1,5 @@
node_modules
.env
.env.*
*.log
.git

46
backend/.env.example

@ -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=

15
backend/.eslintrc.json

@ -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"
}
}

7
backend/.prettierrc

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

8
backend/Dockerfile

@ -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"]

54
backend/PROGRESS.md

@ -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

6
backend/docker-entrypoint.sh

@ -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

3212
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

37
backend/package.json

@ -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"
}
}

26
backend/src/apiSmoke.test.js

@ -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);
});

49
backend/src/app.js

@ -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;
}

7
backend/src/config/authConstants.js

@ -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';
}

6
backend/src/config/devAuthor.js

@ -0,0 +1,6 @@
/**
* Правка цепочки теста (черновик, версии, публикация, редактор) только создатель (`tests.created_by`).
*/
export function isTestAuthor(createdBy, userId) {
return createdBy === userId;
}

18
backend/src/config/featureFlags.js

@ -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;
}

100
backend/src/db/db.js

@ -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;

27
backend/src/db/hrPool.js

@ -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 };

147
backend/src/db/migrate.js

@ -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();

130
backend/src/db/migrations/001_initial.sql

@ -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

14
backend/src/db/migrations/002_test_version_parent_and_active_unique.sql

@ -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);

7
backend/src/db/migrations/003_users_staff_id_hr_link.sql

@ -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;

65
backend/src/db/poolConfig.js

@ -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,
};
}

234
backend/src/integration/v9card1.test.js

@ -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();
}
}
);

41
backend/src/messages/ru.js

@ -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: 'Выбран вариант ответа, не относящийся к вопросу.',
};

171
backend/src/middleware/auth.js

@ -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,
};

188
backend/src/routes/auth.js

@ -0,0 +1,188 @@
/**
* A.1A.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;

634
backend/src/routes/tests.js

@ -0,0 +1,634 @@
/**
* V.4V.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;

8
backend/src/server.js

@ -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'}`);
});

197
backend/src/services/aiEditorService.js

@ -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,
};
}

20
backend/src/services/aiEditorService.test.js

@ -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([]),
/Передайте/
);
});

125
backend/src/services/assignmentDirectoryService.js

@ -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);
}

64
backend/src/services/assignmentUserService.js

@ -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;
}

66
backend/src/services/documentExtractService.js

@ -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 '';
}

33
backend/src/services/documentExtractService.test.js

@ -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');
});

176
backend/src/services/documentGenService.js

@ -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),
};
}
}

63
backend/src/services/documentGenService.test.js

@ -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
);
});

98
backend/src/services/llmClient.js

@ -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;
}

64
backend/src/services/testAccessService.js

@ -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]
);
}

477
backend/src/services/testAttemptService.js

@ -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;
}

18
backend/src/services/testChainService.js

@ -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;
}

23
backend/src/services/testChainService.test.js

@ -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);
});

218
backend/src/services/testDraftService.js

@ -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();
}
}

94
backend/src/utils/auth.js

@ -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,
};

21
backend/src/utils/hrRoleMap.js

@ -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';
}

74
backend/src/utils/werkzeugPassword.js

@ -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);
}

31
backend/src/utils/werkzeugPassword.test.js

@ -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);
});

38
docker-compose.dev.yml

@ -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

22
docker-compose.yml

@ -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:

64
docs/DEV_CONTOUR_USER_GUIDE.md

@ -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, БД, переменные окружения.

148
docs/PROJECT_STATUS.md

@ -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)

414
docs/QA-versioning-and-ai.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. **Учётная запись**, под которой воспроизвели (логин, без пароля).
Этого достаточно — лезть в консоль/код/базу не нужно и не надо.

380
docs/TEST_TABLES_ANALYSIS.md

@ -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)

340
docs/UX_аудит_и_новая_IA_—_страница_теста.md

@ -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. Шапка, заголовок и баннер версионирования
![Шапка и баннер](screens/01_header_intro.jpg)
Что мы видим: глобальная шапка «Тестирование», подпись пользователя `Разорвин А. М. · employee`, кнопка «Выйти». Ниже — хлебная крошка «← к списку», название теста, автор, дата обновления, **жёлтый баннер «При сохранении будет создана новая версия теста.»** и схлопнутый аккордеон «О тесте».
Замечания:
- **C-1 [critical] Баннер о новой версии показывается ВСЕГДА**, независимо от того, изменил ли пользователь хоть что-то. Это сбивает с толку: автор открывает существующий тест, ничего не трогает — и думает, что версия уже создана. Должен показываться только при наличии несохранённых изменений (dirty state).
- **m-2 [minor] Роль `employee` написана по-английски** в шапке. В русском интерфейсе должно быть «сотрудник» / «автор» / другое (см. ролевую модель в Части 2).
- **m-3 [minor] Опечатка** в `<title>` вкладки: «Система тестирования» (нет «и»). Влияет на закладки, историю, поиск.
---
## 1.2. Секция «О тесте»
![О тесте раскрытый](screens/02_about_test.jpg)
Замечания:
- **M-1 [major] Аккордеон по умолчанию схлопнут.** Чтобы начать редактировать главный объект страницы (вопросы), нужно сделать лишний клик. На странице редактирования теста раздел «Вопросы» (а возможно, и «О тесте») должен быть открыт по умолчанию.
- **M-2 [major] Поле «Порог зачёта, %» не имеет валидации min/max.** Что произойдёт при вводе 0, 100, 150, –5, 70.5, или вообще буквы? Минимум: подсказка «от 1 до 100», атрибуты `min/max/step` на input, инлайн-ошибка при некорректном вводе.
---
## 1.3. Раздел «Вопросы» — генерация и Вопрос 1
![Генерация и Вопрос 1](screens/03_questions_top.jpg)
Что мы видим: блок **«Генерация сетки вопросов (ИИ)»** с полями «Тема», «Вопросов: 7», «Вариантов: 3» и кнопкой «Сгенерировать тест (ИИ)». Ниже — Вопрос 1 с собственной кнопкой «Сгенерировать вопрос (ИИ)» в правом верхнем углу, чекбоксом «Несколько верных ответов», тремя вариантами с радиокнопками и крестиками «удалить».
Замечания:
- **C-2 [critical] ИИ-генерация без подтверждения и без отображения хода работы.** Кнопка «Сгенерировать тест (ИИ)» одной нажатием перезаписывает существующие вопросы — а они уже могут быть наполовину написаны вручную. То же касается кнопки «Сгенерировать вопрос (ИИ)» рядом с уже заполненным вопросом.
- Нужно: confirm-диалог «Заменить текущие N вопросов?», индикатор прогресса генерации, возможность откатить (undo) последний результат генерации.
- **M-3 [major] Чекбокс «Несколько верных ответов» меняет семантику варианта без явного намёка.** Когда выкл — радиокнопки (один верный), когда вкл — должны стать чекбоксами (несколько). Лучше переписать подпись в зависимости от состояния: «один верный» / «несколько верных», и/или показать рядом подсказку, как изменится контрол.
---
## 1.4. Вопросы 3–5
![Вопросы 3-5](screens/04_questions_mid.jpg)
Замечания:
- **M-4 [major] Нет нумерации/перетаскивания вопросов.** «Вопрос 1, 2, 3…» — порядок фиксирован тем, в каком порядке добавляли. Для длинных тестов нужен drag-handle или хотя бы стрелки «вверх / вниз».
- **M-5 [major] «Удалить вопрос» без подтверждения.** Случайный клик уничтожит написанный вопрос. Минимум — confirm-диалог; лучше — undo-toast «Вопрос удалён · Отменить».
- **m-4 [minor] Маленькая видимая «вода» между вопросами.** Карточки вопросов мало отделены друг от друга визуально, при пролистывании они сливаются в стену форм. Стоит увеличить вертикальный отступ между карточками или добавить разделитель.
---
## 1.5. Вопрос 7 — обрыв длинного варианта
![Q7 с обрезанным вариантом + загрузка файла](screens/05_questions_bottom.jpg)
Это один из самых наглядных багов:
- **M-6 [major] Длинный текст варианта обрезается.** В Q7 первый вариант отображается как «Максимальное количество токенов, которое модель может о…» — текст уходит за правый край однострочного `<input>`. Для содержательных тестов (особенно медицинских) ответы часто длинные. Нужно: либо `<textarea>` с автовысотой, либо горизонтальный скролл с tooltip всего текста на ховере.
- **m-5 [minor] Загрузка файла «Документ в вопросы» — без drag-and-drop, без ограничений по размеру/формату на UI, без обратной связи.** Подсказка «PDF, Word или текст — вставьте в черновик вопросов» — хорошая по-человечески, но не объясняет, что произойдёт после загрузки: заменит ли существующие вопросы, добавит ли в конец, есть ли превью результата.
---
## 1.6. Кнопка «Сохранить черновик» в середине + История
![Сохранить + История](screens/06_save_history.jpg)
Здесь главная архитектурная проблема страницы:
- **C-3 [critical] Кнопка «Сохранить черновик» расположена в середине страницы.** Сразу после неё ниже идут ещё две большие секции — «История» и «Показ в каталоге». Пользователь, открывший «Показ в каталоге» и поменявший там аудиторию, психологически ищет «Сохранить» внизу страницы — но там его нет. Очень высокий риск потерять изменения.
- Решения, любое или все: (а) sticky-панель сохранения внизу страницы; (б) дубль кнопки после последней секции; (в) автосохранение черновика; (г) предупреждение перед уходом со страницы при наличии несохранённых изменений.
- **M-7 [major] Раздел «Прохождения» показывает сырые ENUM-значения.** Видно `v1 · in_progress` — это техническое значение, а не пользовательский текст. Должно быть «в процессе» / «пройдено» / «не пройдено», лучше с цветной плашкой-индикатором.
- **M-8 [major] Дубль кнопки «К списку».** Хлебная крошка «← к списку» наверху + кнопка «К списку» рядом с «Сохранить черновик» — две точки выхода с разным визуальным весом. Кнопка справа от primary-кнопки создаёт ложное ощущение симметричности с действием. Оставить либо крошку, либо превратить вторую кнопку в текстовую ссылку «Отмена».
---
## 1.7. «Показ в каталоге» — Видимость и фильтры
![Видимость](screens/07_catalog_visibility.jpg)
- **M-9 [major] Контрол «Видимость» неясен по текущему состоянию.** Кнопка «Скрыть из списка» — это сейчас действие или текущее состояние? Если тест уже скрыт — кнопка должна называться «Показать в списке». Лучше — переключатель (toggle/switch) с подписью «Тест виден в каталоге», чтобы текущее состояние читалось без действий.
- **m-6 [minor] Поле поиска и два селекта** «Все отделы» / «Все» расположены без подписей — что делает второй селект, без раскрытия не понятно. Нужны явные label или persistent placeholder.
---
## 1.8. Список «Кому выдать» — 147 сотрудников
![Список сотрудников](screens/08_catalog_employees.jpg)
Этот блок — корень главной 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 и сразу снимает большую часть пользовательской боли.
---
*Если по какому-то пункту нужно больше деталей или хочется, чтобы я подготовил визард-сценарий «Назначить» в виде последовательных макетов — скажите.*

220
docs/bugfix/SPRINTS_BUGFIX_TESTS_NAV_AI.md

@ -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 рабочих дней** (в зависимости от объёма автотестов и глубины рефакторинга генератора).

291
docs/migration-final-inventory.md

@ -0,0 +1,291 @@
# Инвентаризация backend и справочный gap-analysis с `tgFlaskForm`
**Назначение.** §1–§9 — полная карта того, что **сейчас живёт** в `backend/` (Node.js / Express). Используется в [Этапе 1](migration-final.md#этап-1-текущий--единый-стек-express--flask-react--jinja-внутри-testingwebapp) как чек-лист «что переписать на Flask внутри `flask_app/`». §10 — **справочный gap-analysis** между этим и уже готовым модулем `cabinet/testing` в `HR_TG_Bot/tgFlaskForm` — пригодится в [Этапе 2](migration-to-tgflaskform.md) при слиянии.
**Связано:** [migration-final.md](migration-final.md) (главный трекер двух этапов), [migration-to-tgflaskform.md](migration-to-tgflaskform.md) (план Этапа 2 — слияние с tgFlaskForm), [PROJECT_STATUS.md](PROJECT_STATUS.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
> **Прочитайте сначала [migration-final.md](migration-final.md)** — там зафиксировано разделение на Этап 1 (унификация стека внутри TestingWebApp, БД остаётся `clinic_tests`) и Этап 2 (слияние с HR-кабинетом, БД мигрирует на `hr_bot_test`). Содержимое §10 этого документа — материал для Этапа 2.
---
## 1. Карта HTTP-эндпоинтов (всё, что зовёт фронтенд)
Источник: `backend/src/app.js`, `backend/src/routes/*.js`. Перепроверено по фактическим вызовам из `frontend/src/**` (`frontend/src/api.js` — общий `fetch` с `credentials: 'include'`).
| # | Метод | Путь | Зовёт из фронта | Auth | Сервис(ы) | Особенности |
|---|------|------|----------------|------|----------|-------------|
| 1 | GET | `/api/health` | — | нет | inline | smoke-проверка |
| 2 | POST | `/api/auth/login` | `Login.jsx` | нет | `utils/auth`, `db/db`, `db/hrPool`, `utils/werkzeugPassword`, `utils/hrRoleMap`, `config/authConstants` | bcrypt (dev) **+** Werkzeug (HR) ветки; UPSERT `users` по `staff_id`; кладёт JWT в HTTP-only cookie |
| 3 | POST | `/api/auth/logout` | `CabinetLayout.jsx` | нет | inline | очистить cookie |
| 4 | GET | `/api/auth/me` | `CabinetLayout.jsx` | cookie JWT | `middleware/auth` + `featureFlags` | возвращает пользователя + флаги UI (`devUi`, `assignmentUi`) |
| 5 | GET | `/api/auth/dev/assignment-directory` | `TestDetail.jsx` (assign-блок) | cookie JWT, feature-flag | `services/assignmentDirectoryService` | сливает HR (`staff_members`, `employees_departments`) + `clinic_tests.users`; query: `q`, `department`, `clinic` |
| 6 | POST | `/api/tests/import/document` | `TestDetail.jsx` («Документ в вопросы») | cookie JWT | `services/documentExtractService` (PDF/DOCX/TXT), `services/documentGenService``services/llmClient` | `multer` (10 МБ, OS tmpdir); удаляет файл после извлечения |
| 7 | GET | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testAccessService.queryTestsVisibleToUser` + inline (hiddenByYou) | каталог + список «скрытые мной» |
| 8 | POST | `/api/tests` | `TestsList.jsx` | cookie JWT | `services/testDraftService.createTestWithVersion` | создаёт цепочку + версию 1 |
| 9 | GET | `/api/tests/:id/summary` | `TestDetail.jsx` | cookie JWT | inline + `testAccessService.userHasTestAccess` | карточка цепочки (одна строка) |
| 10 | GET | `/api/tests/:id/versions` | `TestDetail.jsx` | cookie JWT, **только автор** | inline + `testChainService.hasAnyAttemptForTest` | список версий + флаг `hasAttempts` |
| 11 | GET | `/api/tests/:id/editor` | `TestDetail.jsx` | cookie JWT, **только автор** | `testAttemptService.getEditorContent` | вопросы активной версии **с правильными ответами** |
| 12 | POST | `/api/tests/:id/ai/generate-test` | `TestDetail.jsx` (ИИ — целиком) | cookie JWT, **только автор** | `aiEditorService.generateFullTestByShape``llmClient` | строгая сетка `shape: [{optionsCount, hasMultipleAnswers}]`; до 40 вопросов |
| 13 | POST | `/api/tests/:id/ai/generate-question` | `TestDetail.jsx` (ИИ — один вопрос) | cookie JWT, **только автор** | `aiEditorService.generateOrRephraseQuestion``llmClient` | пустой текст → новый вопрос; непустой → переформулировка |
| 14 | POST | `/api/tests/:id/versions/:vid/activate` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | транзакция; снимает `is_active` со всех версий, потом ставит на `:vid` |
| 15 | PATCH | `/api/tests/:id` | `TestDetail.jsx` | cookie JWT, **только автор** | inline | `chainActive` (true/false) — публикация в каталоге |
| 16 | POST | `/api/tests/:id/assign` | `TestDetail.jsx` (назначение) | cookie JWT, **только автор**, feature-flag | `assignmentUserService.ensureClinicUserIdForStaff` | принимает `userIds[]`/`staffIds[]`/legacy `userId`/`staffId`; одна строка `test_assignments` + N строк `test_assignment_targets` |
| 17 | POST | `/api/tests/:id/draft` | `TestDetail.jsx` («Сохранить») | cookie JWT, **только автор** | `testDraftService.saveTestDraft` | если есть попытки и переданы `questions` — fork новой версии (V.3) |
| 18 | POST | `/api/tests/:id/attempts/start` | `TestsList.jsx` | cookie JWT (доступ через `userHasTestAccess`) | `testAccessService.userHasTestAccess` + inline | новая попытка по активной версии |
| 19 | GET | `/api/tests/:id/attempts` | `TestDetail.jsx` («Прохождения») | cookie JWT, **только автор** | `testAttemptService.listTestAttemptsForAuthor` | до 200 попыток по всем версиям |
| 20 | GET | `/api/tests/:id/attempts/:aid/review` | `TestAttemptReview.jsx` | cookie JWT (владелец **или** автор) | `testAttemptService.getAttemptReviewForUser``buildReviewFromDb` | разбор попытки |
| 21 | GET | `/api/tests/:id/attempts/:aid/play` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.getPlayContent` | вопросы **без** правильных ответов |
| 22 | POST | `/api/tests/:id/attempts/:aid/submit` | `TestAttempt.jsx` | cookie JWT (владелец) | `testAttemptService.submitAttempt` | `FOR UPDATE`, проверка ответов, перезапись `user_answers`, статус `completed` |
| 23 | GET | `/api/tests/:id/chain-info` | `TestDetail.jsx` | cookie JWT (через `userHasTestAccess`) | `testAccessService` + `testChainService` | флаг `hasAnyAttempt` |
> **Итого: 22 функциональных эндпоинта** (без `/api/health`). Все ответы — JSON. Все входы — JSON или `multipart/form-data` (только `/import/document`).
---
## 2. Сервисный уровень (что должно появиться в Flask)
| Файл (Node) | Что делает | Что нужно в Python |
|------------|-----------|---------------------|
| `services/testAccessService.js` | Запросы каталога; `userHasTestAccess(testId, userId)` | SQLAlchemy/`psycopg2` версия двух запросов |
| `services/testDraftService.js` | `createTestWithVersion`, `saveTestDraft`, `forkNewVersion`, `replaceVersionContent`, `copyQuestionTree` | Транзакции на `psycopg2`/SQLAlchemy; следить за частичным уникальным индексом `uq_test_versions_one_active_per_test` |
| `services/testAttemptService.js` | `getEditorContent`, `getPlayContent`, `submitAttempt`, `buildReviewFromDb`, `getAttemptReviewForUser`, `listTestAttemptsForAuthor`; вычисление баллов | Самый объёмный модуль; внимательно с массивами UUID (`user_answers.selected_options uuid[]`) |
| `services/testChainService.js` | `hasAnyAttemptForTest` | Один SQL `EXISTS` |
| `services/aiEditorService.js` | `parseAndValidateShape`, `generateFullTestByShape`, `generateOrRephraseQuestion`, `assertDraftMatchesShape` | Чистый Python; зависит от `llmClient` и `documentGenService.validate*` |
| `services/documentGenService.js` | `parseJsonFromLlmText`, `validateAndNormalizeDraft`, `generationForImportDocument` | Чистый Python (`json.loads` + валидация формы) |
| `services/documentExtractService.js` | `resolveDocumentKind`, `extractTextFromFile`, `extractTextFromBuffer` | **Замены пакетов:** `mammoth``python-docx` или `mammoth.py`; `pdf-parse``pypdf` (или `pdfminer.six`) |
| `services/llmClient.js` | OpenAI-совместимый Chat Completions, JSON-mode, таймаут 120 с | `httpx` / `requests` + явный `timeout` |
| `services/assignmentDirectoryService.js` | Слияние `clinic_tests.users` ↔ HR (`staff_members`, `employees_departments`); фильтры `q/department/clinic` | Два пула; `psycopg2` достаточно |
| `services/assignmentUserService.js` | `ensureClinicUserIdForStaff` (UPSERT по `staff_id`) | UPSERT с `ON CONFLICT (staff_id)` |
| `utils/auth.js` | bcrypt + JWT + (Werkzeug fallback) | `passlib[bcrypt]` или `bcrypt`; **`flask-jwt-extended`** или ручной `pyjwt` |
| `utils/werkzeugPassword.js` | `scrypt:$N:$r:$p$salt$hex`, `pbkdf2:sha256:iter$salt$hex` | Уже **родной** Python: `werkzeug.security.check_password_hash` |
| `utils/hrRoleMap.js` | строка HR-роли → `'hr'/'manager'/'employee'` | Один `def map_hr_role()` |
| `middleware/auth.js` | `authenticate`, `requireRole`, `requireDepartment`, `optionalAuth` | Flask-декоратор `@login_required`/`@roles_required` (или `before_request`) |
| `config/featureFlags.js` | `isAssignmentFeatureEnabled` | Простая функция от env |
| `config/devAuthor.js` | `isTestAuthor(createdBy, userId)` | Один сравнительный helper |
| `config/authConstants.js` | `HR_MANAGED_PASSWORD_PLACEHOLDER`, `isHrAuthEnabled` | Без изменений |
| `messages/ru.js` | Текстовые сообщения API | `app/messages/ru.py` (dict) |
> **Особое внимание:** `testAttemptService.submitAttempt` использует `FOR UPDATE` и выгружает все `answer_options` версии разом. Простую построчную проверку «правильно/нет» делает Python-функция `same_selection(set, set)`. На больших тестах помогает индекс `idx_answer_options_question_id` — он уже есть.
---
## 3. Базы данных и схемы
### 3.1 Основная — `clinic_tests` (`DATABASE_URL`)
Из `backend/src/db/migrations/001_initial.sql`:
- `departments` (UUID, name)
- `users` (UUID, login UNIQUE, password_hash, full_name, role `user_role`, department_id FK, is_active, **`staff_id`** — миграция 003, **UNIQUE**)
- `tests` (UUID, title, description, passing_threshold, time_limit, allow_back, is_active, is_versioned, created_by FK)
- `test_versions` (UUID, test_id FK, version, is_active; миграция 002 добавляет `parent_id` + частичный уникальный индекс «одна активная версия на цепочку»)
- `questions` (UUID, test_version_id FK, text, question_order, has_multiple_answers)
- `answer_options` (UUID, question_id FK, text, is_correct, option_order)
- `test_assignments` (UUID, test_version_id FK, assigned_by FK, deadline DATE, max_attempts)
- `test_assignment_targets` (UUID, assignment_id FK, target_type `'department'|'user'`, target_id UUID)
- `test_attempts` (UUID, test_version_id FK, user_id FK, attempt_number, status `attempt_status`, started_at, completed_at, correct_count, total_questions, passed; UNIQUE(test_version_id, user_id, attempt_number))
- `user_answers` (UUID, attempt_id FK, question_id FK, selected_options **UUID[]**)
- `migrations` (служебная, имена применённых SQL)
**Расширения:** `uuid-ossp`. **Кастомные типы:** `user_role`, `target_type`, `attempt_status`.
### 3.2 Дополнительная — `hr_bot_test` (`HR_DATABASE_URL`, опционально)
Используется только на чтение через `db/hrPool.js`:
- `users` — для входа (HR_AUTH=1): `id`, `username`, `password_hash` (Werkzeug), `role`
- `staff_members``id`, `fio`, `web_login`
- `employees_departments``staff_id`, `department`
### 3.3 Миграции
3 SQL-файла в `backend/src/db/migrations/`. Простой `npm run migrate` пишет в служебную таблицу `migrations`. В Flask эквивалент: **Alembic** или ручной runner на `psycopg2`. Файлы `.sql` можно переиспользовать **как есть** — никакого Node-специфичного синтаксиса в них нет.
---
## 4. Зависимости Node → Python (план замены)
| Node-пакет | Python-замена | Комментарий |
|-----------|----------------|-------------|
| `express`, `cors`, `cookie-parser` | **Flask** + `flask-cors` | сессии встроены |
| `multer` | `Flask` (`request.files`) + `werkzeug.utils.secure_filename` + `tempfile` | лимит 10 МБ — `MAX_CONTENT_LENGTH` |
| `dotenv` | `python-dotenv` (уже есть в `flask_app/run.py`) | — |
| `pg` | `psycopg2-binary` (или `psycopg[binary]` v3) | — |
| `bcryptjs` | `bcrypt` (`pip install bcrypt`) | формат `$2b$…` совместим |
| `jsonwebtoken` | `pyjwt` или `flask-jwt-extended` | важен **тот же** алгоритм/секрет, чтобы старые cookie работали в переходный период |
| `mammoth` | `mammoth` (PyPI: `pip install mammoth`) или `python-docx` | API почти идентичен |
| `pdf-parse` | `pypdf` (`pip install pypdf`) или `pdfminer.six` | `pypdf.PdfReader().pages[].extract_text()` |
| `fetch` (LLM) | `httpx` (рекомендую — есть `timeout`, async) или `requests` | сохранить `Authorization: Bearer …`, `response_format: json_object` |
**Уже в `tgFlaskForm` есть готовое** — `werkzeug.security.check_password_hash` (бесплатно), Sentry, Jinja2-шаблоны, обмен с `staff_members`. Переиспользовать, если выберем сценарий «общий кабинет».
---
## 5. Переменные окружения (полный список)
| Переменная | Где читается | Назначение | Обязательная |
|-----------|--------------|-----------|--------------|
| `NODE_ENV` | `app.js`, `auth.js`, `featureFlags.js`, `db.js` | dev vs production | да (нет → dev) |
| `PORT` | `server.js` | Express, по умолчанию 3001 | нет |
| `FRONTEND_URL` | `app.js` (CORS) | разрешённый origin в prod | в prod — да |
| `DATABASE_URL` **или** `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASSWORD` | `db/poolConfig.js` | основная БД `clinic_tests` | да |
| `DB_POOL_MAX`, `DB_IDLE_TIMEOUT`, `DB_CONNECTION_TIMEOUT` | `db/poolConfig.js` | пул | нет |
| `HR_DATABASE_URL` | `db/poolConfig.js` (`getHrPoolConfig`) | hr_bot_test (read-only) | при HR_AUTH=1 — да |
| `HR_DB_POOL_MAX` | то же | пул HR | нет |
| `HR_AUTH` (1/true) | `config/authConstants.js` | вход по HR-логину | нет |
| `JWT_SECRET` | `utils/auth.js` | подпись JWT | **да** |
| `JWT_EXPIRES_IN` | `utils/auth.js` | дефолт `7d` | нет |
| `DEEPSEEK_API_KEY` | `services/llmClient.js` | LLM, приоритет №1 | для AI/импорта — да |
| `OPENAI_API_KEY` | то же | LLM, приоритет №2 | альтернатива |
| `LLM_BASE_URL` | то же | сменить хост (proxy / vLLM) | нет |
| `LLM_MODEL` | то же | по умолчанию `deepseek-chat` или `gpt-4o-mini` | нет |
| `LLM_NO_JSON` (1) | то же | отключить `response_format: json_object` (для моделей без поддержки) | нет |
| `CLINIC_ASSIGNMENT_ENABLED` (1) | `config/featureFlags.js` | прод: включить assign | в prod — да |
В `flask_app/.env` нужно перенести **те же ключи** (имена можно сохранить, чтобы не плодить вариации).
---
## 6. Что вызывает фронтенд (карта зависимостей React → API)
Используется один тонкий клиент `frontend/src/api.js`: `fetch` с `credentials: 'include'`, базовый путь — пустой (т.е. **`/api/...` относительно текущего origin**, что в dev резолвит Vite-proxy, а в prod — Nginx). Это значит:
- **Менять фронтенд при смене бэкенда не нужно**, если новый сервис отвечает по тем же путям.
- В dev сейчас `vite.config.js` проксирует `/api` на Express (`localhost:3001`). После переноса — заменить адрес/порт на Flask (см. `flask_app/run.py`, по умолчанию `3107`).
Список путей (отсортирован по убыванию использований):
```
/api/auth/login
/api/auth/logout
/api/auth/me
/api/auth/dev/assignment-directory?q&department&clinic
/api/tests
/api/tests/:id/summary
/api/tests/:id/versions
/api/tests/:id/editor
/api/tests/:id/chain-info
/api/tests/:id/draft
/api/tests/:id/assign
/api/tests/:id/ai/generate-test
/api/tests/:id/ai/generate-question
/api/tests/:id (PATCH)
/api/tests/:id/versions/:vid/activate
/api/tests/:id/attempts
/api/tests/:id/attempts/start
/api/tests/:id/attempts/:aid/play
/api/tests/:id/attempts/:aid/review
/api/tests/:id/attempts/:aid/submit
/api/tests/import/document
```
---
## 7. Тесты, которые тянутся за собой
В `backend/src/**/*.test.js` (Node test runner):
- `apiSmoke.test.js` — smoke-проверки HTTP.
- `services/testChainService.test.js`
- `services/aiEditorService.test.js`
- `services/documentGenService.test.js`
- `services/documentExtractService.test.js`
- `utils/werkzeugPassword.test.js`
- `integration/v9card1.test.js` (требует `CLINIC_TESTS_INTEGRATION=1`)
После переноса нужны их Python-аналоги (`pytest`). Часть (валидация LLM-формы, разбор Werkzeug) тривиально превращается в юниты.
---
## 8. Чего сейчас в `flask_app/` НЕТ (чтобы не повторяться в Спринте 2)
`flask_app/` содержит только: `run.py`, `app/__init__.py``/health` и `/`), `app/templates/`, `app/static/`. Не реализовано **ничего** из перечня §1–§4. Это значит, что в Спринте 2 нужно с нуля поднять:
1. Подключение к БД (минимум — основной пул `clinic_tests`).
2. Декоратор аутентификации (cookie JWT) и эндпоинты `/api/auth/*`.
3. Роуты `/api/tests/*` поэтапно — начать с **read-only** (list, summary, versions, editor, attempts, review), потом write (draft, activate, patch, assign, attempts/start/submit), потом AI/import.
4. CORS (в dev совпадает с тем, что у Express).
5. Запуск под Vite/Nginx — обновить proxy/upstream.
---
## 9. Критерии «можно удалять `backend/`» (Спринт 4)
- ETL-скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` прогнан **на копии прод**, агрегаты совпадают (тесты, версии, вопросы, попытки) — затем прогон на проде в окне.
- Все сценарии §1 (TestingWebApp) либо имеют **рабочий аналог** в `cabinet/testing/`, либо явно **признаны не-MVP** (см. §10).
- Внутренние ссылки и закладки сотрудников переключены на URL общего кабинета (`/cabinet/testing/...`).
- Документация (`README.md`, `DEV_CONTOUR_USER_GUIDE.md`, `шаги/*`) перестала ссылаться на `backend/` и `frontend/` TestingWebApp.
- Резерв: ветка `legacy/clinic-tests-node` (или `legacy/express-backend`) зафиксирована перед удалением.
---
## 10. Gap-analysis: `tgFlaskForm/cabinet/testing` vs Express
Сверка проведена по фактическому коду `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/*` и `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` на 2026-04-27. Колонка «Что нужно сделать» — это и есть остаток Спринта 2 из [migration-to-tgflaskform.md](migration-to-tgflaskform.md).
### 10.1 Маппинг сущностей и ID
| TestingWebApp (`clinic_tests`) | tgFlaskForm (`hr_bot_test`, схема `testing_*`) | Замечание |
|---|---|---|
| `tests` (UUID) | `testing_tests` (Integer PK) | UUID → integer; маппинг хранится в `_clinic_tests_migration_map` |
| `test_versions` (UUID, `version`, `is_active`, `parent_id`) | `testing_test_versions` (Integer, `version_number`, `is_active_version`) — **+** `passing_score_percent`, `time_limit_minutes`, `allow_back_navigation` (нормализовано на версию) | В Express часть полей лежала на `tests` (`passing_threshold`, `time_limit`, `allow_back`); ETL поднимает их на версию |
| `questions` (`text`, `has_multiple_answers`, `question_order`) | `testing_questions` (`question_text`, `question_type` `'single'\|'multiple'`, `sort_order`) | `has_multiple_answers: true``question_type='multiple'` |
| `answer_options` (`text`, `is_correct`, `option_order`) | `testing_answers` (`answer_text`, `is_correct`, `sort_order`) | прямой маппинг |
| `users` + `users.staff_id` | `staff_members.id` напрямую | Express держит «свою» строку `users` со ссылкой на `staff_id`; в HR — только `staff_members` |
| `test_assignments` + `test_assignment_targets` (target_type='user', target_id=UUID) | `testing_assignments` (одна строка = одна пара тест×сотрудник, `assigned_to=staff_id`) | **Существенное расхождение модели** — см. §10.3 |
| `test_attempts` (привязан к `test_version_id`, `user_id`) | `testing_attempts` (привязан к `assignment_id` + `test_version_id`, попытка от **сотрудника**) | Если в clinic попытка не имеет связанного assignment — ETL создаёт синтетическое назначение (`max_attempts=99`) |
| `user_answers` (`selected_options uuid[]`) | `testing_attempt_answers` (одна строка на каждый выбранный `answer_id`) | Развёртка массива в N строк |
| (нет аналога) | `testing_settings` (key-value), `testing_head_positions` (право назначения по должности) | в Express не было — RBAC проще |
### 10.2 Эндпоинты Express → роуты `tgFlaskForm`
| # | Express | Соответствие в `tgFlaskForm` | Статус | Что сделать |
|---|---------|------------------------------|--------|-------------|
| 1 | `POST /api/auth/login` | `webApp/auth/*` (общая сессия HR) | ✅ есть аналог | Express-сессии не переносим; пользователи входят как обычно в HR-кабинет |
| 2 | `POST /api/auth/logout` | `webApp/auth/*` | ✅ — | — |
| 3 | `GET /api/auth/me` | session + `helpers._get_staff_id` | ✅ — | — |
| 4 | `GET /api/auth/dev/assignment-directory` | `routes_assignments.assign_form` (страница) + `testing_get_employees_for_assignment` | ✅ — | На UI: добавить «Выбрать всех» (см. Спринт 3 React-кабинета), мульти-фильтры |
| 5 | `GET /api/health` | `webApp/__init__.py` | ✅ — | — |
| 6 | `GET /api/tests` (каталог + `hiddenByYou`) | `routes_tests.test_list` (по автору) + `routes_passing.my_tests` (по назначениям) | ⚠ нет «скрытые мной» отдельным разделом | Добавить вкладку/фильтр «скрытые автором» в `cabinet/testing/test_list.html` |
| 7 | `POST /api/tests` (создать пустой) | `routes_tests.test_create` (требует full payload + ≥7 вопросов) | ⚠ другой контракт | Решить продуктово: оставить ограничение `≥7` либо разрешить «пустой тест» как в Express. **Рекомендую** разрешить пустой и валидировать только при публикации |
| 8 | `GET /api/tests/:id/summary` | вшито в `test_edit` | ⚠ нет отдельной summary | Добавить функцию `testing_get_test_summary(test_id)` если нужен лёгкий вариант для списка |
| 9 | `GET /api/tests/:id/versions` | `testing_get_test_for_edit` отдаёт активную; история — внутри `test_edit` | ⚠ — | Вынести список версий в API/шаблон («История» в UX) |
| 10 | `GET /api/tests/:id/editor` | `routes_tests.test_edit` + `testing_get_test_for_edit` | ✅ — | сверить набор полей, отдаваемых шаблону, с тем что показывает React-редактор |
| 11 | `POST /api/tests/:id/ai/generate-test` (строгая `shape: [{optionsCount, hasMultipleAnswers}]`, до 40 вопросов) | `routes_ai.ai_generate` принимает `topic, question_count` (фикс. форма) | ❌ **gap** | Добавить вариант с явной формой (передавать массив `optionsCount/hasMultipleAnswers`); либо принять, что HR-кабинет генерирует «обобщённо» |
| 12 | `POST /api/tests/:id/ai/generate-question` (один вопрос: пусто=новый, текст=переформулировать) | `routes_ai.ai_improve` (улучшить) + `ai_distractors` (варианты) | ❌ **gap** | Добавить ручку «один вопрос со строгой сеткой» (объединить `improve` + `distractors` под единый сценарий) |
| 13 | `POST /api/tests/:id/versions/:vid/activate` | `routes_tests.test_version_activate` | ✅ — | — |
| 14 | `PATCH /api/tests/:id` (`chainActive`) | `routes_tests.test_toggle` | ✅ — | сверить семантику (toggle vs явный флаг) |
| 15 | `POST /api/tests/:id/assign` (массивы `userIds`/`staffIds`) | `routes_assignments.assign_submit` (`employee_ids = staff_id[]`) | ✅ совместимо | — |
| 16 | `POST /api/tests/:id/draft` (с авто-fork версии) | `routes_tests.test_update` (тоже фаркает при `has_attempts`) | ⚠ контракт другой | Сверить, что в HR-кабинете тоже работает «правка без попыток = на месте, после попыток = новая версия». В коде есть, но нужна проверка |
| 17 | `POST /api/tests/:id/attempts/start` | `routes_passing.start_attempt(assignment_id)` | ⚠ другой ключ | Привести к виду «start by test_id» **или** оставить как есть и в UI стартовать по `assignment_id` (логичнее) |
| 18 | `GET /api/tests/:id/attempts` | `routes_results.tracker` (общая лента) — без фильтра по тесту | ⚠ нет per-test ленты | Добавить фильтр `test_id` или отдельный URL `/cabinet/testing/tests/:id/attempts` |
| 19 | `GET /api/tests/:id/attempts/:aid/review` | `routes_results.result(attempt_id)` | ✅ — | сверить, что разбор показывает выбранные/верные варианты по каждому вопросу |
| 20 | `GET /api/tests/:id/attempts/:aid/play` | `routes_passing.take_test(attempt_id)` | ✅ — | — |
| 21 | `POST /api/tests/:id/attempts/:aid/submit` | `routes_passing.finish_attempt(attempt_id)` (+ `save_answer`) | ⚠ контракт другой | Express отправляет все ответы пакетом; HR — пошагово (`save_answer` × N + `finish`). Это **OK для UX** (продолжить позже), но фронт работает иначе |
| 22 | `POST /api/tests/import/document` | `routes_import.import_document` | ✅ — | сверить mime-типы и сообщения об ошибках |
| 23 | `GET /api/tests/:id/chain-info` (`hasAnyAttempt`) | вшито в `testing_get_test_for_edit['has_attempts']` | ✅ — | — |
> **Итог по gap-analysis:** 13 из 22 эндпоинтов закрыты «как есть» или близко; 4 требуют добавления (AI «один вопрос», AI «строгая сетка», per-test attempts, summary); 5 — расхождение контракта, надо принять решение или подровнять.
### 10.3 Расхождения, которые ETL уже обходит
- **Назначения на отдел:** в `clinic_tests.test_assignment_targets` могут быть строки `target_type='department'`. ETL разворачивает их в N строк `testing_assignments` по списку сотрудников отдела на момент миграции (см. шапку `tools/migrate_clinic_tests_to_hr.py`).
- **Попытки без явного назначения:** в Express `test_attempts.user_id` напрямую идёт от пользователя; в HR попытка обязательно имеет `assignment_id`. ETL для каждой пары (тест, сотрудник) создаёт **синтетическое** назначение `max_attempts=99` и привязывает попытку к нему.
- **UUID → Integer:** идемпотентный маппинг лежит в `public._clinic_tests_migration_map` (entity, old_uuid → new_id) — повторный прогон ETL безопасен.
### 10.4 Чего точно нет в `tgFlaskForm` и потребует доработки
1. **AI «один вопрос со строгой сеткой»** (`generate_or_rephrase_question` из `aiEditorService.js`).
2. **AI «целый тест по shape»** с проверкой `optionsCount` и `hasMultipleAnswers` для каждой строки.
3. **«Скрытые автором» цепочки** как отдельный список на UI.
4. **`/tests/:id/summary`** — лёгкая ручка для карточки в списке (если потребуется в новом UI).
5. **Per-test лента попыток** (UI «Прохождения» в аккордеоне «История»).
6. **Мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md):** аккордеоны «О тесте / Вопросы / История / Показ в каталоге», фикс-футер, икон-кнопка удаления варианта, «Выбрать всех» в назначении и т.д. В Этапе 1 это переносится в Jinja-шаблоны `flask_app/app/templates/`. В Этапе 2 уже готовые шаблоны переезжают в `tgFlaskForm/webApp/templates/cabinet/testing/`.
### 10.5 Что точно НЕ переносим
- **Свой JWT/cookie/bcrypt** из `backend/src/utils/auth.js` и `middleware/auth.js`. Авторизация — через сессии общего кабинета.
- **Werkzeug-полифилл в Node** (`utils/werkzeugPassword.js`). В Python это родная функция `werkzeug.security.check_password_hash`.
- **Свои миграции SQL** из `backend/src/db/migrations/`. Целевая схема живёт в `tgFlaskForm/db/models.py` и сопутствующих DDL/Alembic.
- **`flask_app/`** в этом репозитории — каркас уже **не нужен**, в Спринте 4 решим: удалить или оставить как историческую заготовку.
- **`backend/src/**/*.test.js`** (Node test runner). Их предметная нагрузка покрывается **либо** уже существующими тестами `tgFlaskForm`, **либо** новыми pytest-юнитами.

100
docs/migration-final.md

@ -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`. Бизнес-логика **не** добавлялась. |

87
docs/migration-to-tgflaskform-plain.md

@ -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) |

102
docs/migration-to-tgflaskform.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). Это вспомогательный документ, не часть этапов миграции.

191
docs/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 &lt; X ms, полная загрузка ключевой страницы &lt; 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/коммиты.*

93
docs/revision_task/BACKLOG.md

@ -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 |

33
docs/revision_task/BACKLOG_IDEAS.md

@ -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** (создание файла).*

95
docs/revision_task/TESTING_JOURNAL.md

@ -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.

109
docs/revision_task/card1.md

@ -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).

68
docs/revision_task/sprint-01-testing.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 Негатив
- [ ] Попытка не может «сломать» цепочку (ошибки пользователю понятны)
---

60
docs/revision_task/sprint-01.md

@ -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)

73
docs/revision_task/sprint-02-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:** дата __________, комментарий _________________________

65
docs/revision_task/sprint-02.md

@ -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`.

264
docs/revision_task/task.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/.

BIN
docs/screens/01_header_intro.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/screens/02_about_test.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/screens/03_questions_top.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
docs/screens/04_questions_mid.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
docs/screens/05_questions_bottom.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
docs/screens/06_save_history.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
docs/screens/07_catalog_visibility.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
docs/screens/08_catalog_employees.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

158
docs/ПРЕДЛОЖЕНИЕ_ДИЗАЙН_СОЗДАНИЕ_ТЕСТА.md

@ -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 вариантов» — устраивают или нужны другие?

56
docs/РУКОВОДСТВО_КАБИНЕТ_ТЕСТОВ.md

@ -0,0 +1,56 @@
# Кабинет тестов: коротко, как пользоваться
*Для врачей, заведующих, кураторов — без IT-терминов.
Иллюстрации: [images/cabinet-ui/](images/cabinet-ui/) (схемы-заглушки, можно заменить на скриншоты, см. `README` в той папке).*
---
### 1. Список тестов
Все тесты, к которым у вас есть доступ. **Название** (слева) ведёт в **редактирование** или просмотр, **«Пройти»** (справа) — **сдать** тест, если вам тест **назначили** или открыт самопроход. Редактирование — не у всех ролей.
![Схема: список](images/cabinet-ui/placeholder-spisok.svg)
---
### 2. О тесте
Название, **описание** для коллег, **порог зачёта** (%). «Паспорт» теста: **что проверяете** и **с какой планкой** зачёт/незачёт.
---
### 3. Вопросы
**Вопросы и варианты** пишите здесь. Слева от варианта — **верные** отметки: один вариант как контрольный; несколько верных — чекбокс **«Несколько верных ответов»**.
- **+ вопрос** / **+ вариант** — добавить. **Крестик** у варианта — убрать лишний ответ.
- **Документ в вопросы** — при необходимости загрузить файл (PDF, Word, текст) и вставить в черновик; не обязательно, если ввели всё вручную.
- **ИИ** (если включён) — подсказка, не готовый клинический документ: **проверьте и исправьте** перед публикацией.
---
### 4. Сохранить
**«Сохранить черновик»** (часто **внизу** на телефоне) — чтобы не потерять правки. **«К списку»** — выход; если **уже сохранялись** — данные в черновике записаны.
![Схема: кнопки внизу](images/cabinet-ui/placeholder-chernovik.svg)
---
### 5. История
- **Версии** — когда и как менялся тест. Актуальная отмечена. **«Сделать активной»** — редко, обычно согласуя с IT/методистом.
- **Прохождения** — кто уже **сдавал**; **«Разбор»** — ответы по вопросам (если вам **открыт** доступ).
---
### 6. Показ в каталоге
- **Видимость** — показать в **общем** списке тестов или **скрыть** (тест **не** удаляется, просто **не** светится в ленте). «Старые» **ссылки** у кого-то **могут** ещё открываться, если **переадресовали** вручную.
- **Кому выдать** (если раздел есть) — **назначение** сотрудникам: **поиск, фильтры, галочки**; **«Выбрать всех»** — только в **текущем** отфильтрованном списке; затем **«Назначить выбранных»**. Это **про людей**, не про редактуру вопросов.
![Схема: видимость и назначение](images/cabinet-ui/placeholder-pokaz.svg)
---
**В одном движении:** написали **вопросы****«Сохранить»** → при **необходимости** **показали** в списке и/или **кому-то** **выдали** тест. Остальное — **по ситуации**.

72
docs/Рекомендации UX по экранам теста.md

@ -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. **Стратегический:** хлебные крошки и внутренняя навигация по разделу; масштабирование списка назначений; пустые состояния; продуктовая аналитика (например, доходят ли авторы до «Показ в каталоге»).
---
*Документ можно дополнять по мере внедрения и новых скринов.*

67
docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md

@ -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)

298
docs/Словарь UX-UI-IA терминов.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-дизайну.
---
*— Справочник можно дополнять по мере появления новых терминов —*

2
docs/ТЗ.md

@ -5,6 +5,8 @@
**Дата:** 2026-03-21
**Статус:** Согласовано
*Реализация в ветке `dev` (не дословно ТЗ):* [PROJECT_STATUS.md](PROJECT_STATUS.md) · [инструкция dev-стенда](DEV_CONTOUR_USER_GUIDE.md).
---
## 1. Назначение системы

9
docs/шаги/01-project-setup.md

@ -26,8 +26,8 @@
### 1.3. Настройка окружения
- Docker Compose для PostgreSQL
- Переменные окружения (.env)
- PostgreSQL: **по умолчанию** общий кластер [Postgres_TG_Bots](../../../Postgres_TG_Bots) / [HR_TG_Bot](../../../HR_TG_Bot) — `DATABASE_URL` в `backend/.env``localhost:5432` / БД `clinic_tests` (см. [README](../../README.md#установка-и-запуск)). Локальный отдельный инстанс только по необходимости: `docker compose --profile standalone up` (порт 5433). Сотрудник в интеграции — `staff_members.id`; `telegram_id` в логике модуля не используем.
- Переменные окружения (`.env` по образцу `backend/.env.example`)
- Настройка линтеров и форматтеров
### 1.4. Базовая структура API
@ -42,3 +42,8 @@
- Работающий сервер с подключением к БД
- Структура проекта готова для разработки
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

5
docs/шаги/02-database-design.md

@ -137,3 +137,8 @@
- Созданы все таблицы с связями
- Накатанные миграции
- Схема БД готова
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

5
docs/шаги/03-auth.md

@ -54,3 +54,8 @@ function requireDepartment(departmentId: string) {
- Работающий вход по логину/паролю
- Защищённые API роуты
- Разграничение прав по ролям
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

5
docs/шаги/04-users-departments.md

@ -47,3 +47,8 @@
- Админка для управления сотрудниками
- Справочник подразделений
- Назначение ролей сотрудникам
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

11
docs/шаги/05-test-management.md

@ -47,8 +47,19 @@
---
*Фактические маршруты API в `TestingWebApp`: префикс `/api/tests`, версии и черновик — см. [../PROJECT_STATUS.md](../PROJECT_STATUS.md).*
### 5.6. UI кабинета (React, `TestDetail`)
- Редактор теста: **`frontend/src/pages/TestDetail.jsx`**, стили **`frontend/src/styles/cabinet-theme.css`**: аккордеоны **«О тесте»**, **«Вопросы»** (в т.ч. **«Документ в вопросы»**), **«История»** (внутри: **Версии** + **Прохождения**), **«Показ в каталоге»** (**Видимость**; при `assignmentUi`**Кому выдать**), фикс-футер **«Сохранить / К списку»** на моб. ширинах.
- Сводка мобильного UI и чек-лист: [../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](../СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md).
## Результат
- Полноценный конструктор тестов
- Версионирование с сохранением истории
- Управление вопросами и ответами
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

5
docs/шаги/06-test-assignment.md

@ -48,3 +48,8 @@
- Назначение тестов подразделениям или сотрудникам
- Ограничение по дедлайну и попыткам
- Список назначений для сотрудника
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

5
docs/шаги/07-test-taking.md

@ -70,3 +70,8 @@
- Таймер с автозавершением
- Сохранение прогресса
- Навигация по вопросам
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

6
docs/шаги/08-results-review.md

@ -4,6 +4,8 @@
Реализовать отображение результатов теста и разбора ответов сотруднику.
**Реализация в dev:** ответ `POST …/attempts/:id/submit` включает поле `review`; отдельно `GET /api/tests/:testId/attempts/:attemptId/review` (см. [../PROJECT_STATUS.md](../PROJECT_STATUS.md)).
---
## Задачи
@ -76,3 +78,7 @@
- Итоговый балл и процент
- Статус зачёта
- Полный разбор по каждому вопросу
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

5
docs/шаги/09-attempt-tracking.md

@ -51,3 +51,8 @@
- Таблица попыток с фильтрами
- Ограничение данных по роли
- Полная история прохождений
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

5
docs/шаги/10-ai-assistant.md

@ -87,3 +87,8 @@
- AI-генерация вопросов
- Улучшение формулировок
- Рекомендации по качеству
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

5
docs/шаги/11-settings.md

@ -61,3 +61,8 @@
- Страница `/settings`
- Ввод и сохранение API ключа
- Проверка подключения к DeepSeek
---
*Актуальная привязка к коду: [../PROJECT_STATUS.md](../PROJECT_STATUS.md) · индекс шагов: [README.md](README.md).*

9
docs/шаги/README.md

@ -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).

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save