Compare commits
No commits in common. 'dev' and 'master' have entirely different histories.
@ -1,20 +0,0 @@ |
|||||||
# Шаг 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` по чек-листу из предложения — остаётся на стороне исполнителя. |
|
||||||
@ -1,5 +0,0 @@ |
|||||||
# Шаг 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` (мета-строка). |
|
||||||
@ -1,4 +0,0 @@ |
|||||||
# Шаг 2026-04-27 — спринт 2 (мобильный UI) |
|
||||||
|
|
||||||
- См. [`docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md`](../../docs/СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md): пункты 2.1–2.5 отмечены выполненными. |
|
||||||
- Реализация: `TestDetail.jsx` (прогоны карточками, импорт через label+input, заголовок вопроса, radio/checkbox, фикс-футер), `cabinet-theme.css` (классы спринта 2). |
|
||||||
@ -1,200 +1,130 @@ |
|||||||
# Система тестирования сотрудников клиники |
# Система тестирования сотрудников клиники |
||||||
|
|
||||||
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. |
Веб-приложение для проведения внутреннего тестирования сотрудников клиники. Руководители подразделений и HR-менеджеры создают тесты и назначают их сотрудникам. Система фиксирует все попытки и результаты. |
||||||
Руководители подразделений и HR-менеджеры создают тесты и назначают их |
|
||||||
сотрудникам. Все попытки и результаты сохраняются. |
|
||||||
|
|
||||||
- **Прод:** **[https://edullm.pirogov.ai/](https://edullm.pirogov.ai/)** |
**Версия ТЗ:** 1.2 |
||||||
- **Ветка разработки:** `dev` |
**Дата:** 2026-03-21 |
||||||
- **ТЗ:** v1.2 от 2026-03-21 (статус — согласовано) |
**Статус:** Согласовано |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Стек и состояние |
## Содержание |
||||||
|
|
||||||
**Целевой и единственный рабочий стек** — Python 3.11 + Flask 3 + |
- [Функциональные возможности](#функциональные-возможности) |
||||||
Jinja2 + Tailwind CDN + SQLAlchemy / psycopg2, код в |
- [Роли и права доступа](#роли-и-права-доступа) |
||||||
[`flask_app/`](flask_app/). На нём работает и прод, и dev (кабинетный UI, |
- [Установка и запуск](#установка-и-запуск) |
||||||
порт **:3107** в Docker, см. ниже). |
- [Нефункциональные требования](#нефункциональные-требования) |
||||||
|
- [Вне scope](#вне-scope-не-реализуется-в-данной-версии) |
||||||
|
|
||||||
Старые каталоги `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-менеджер / Руководитель подразделения / Сотрудник |
||||||
|
|
||||||
Единственный вариант UI — **как у основного HR-веба**: в |
### Создание и редактирование тестов |
||||||
[`base.html`](flask_app/app/templates/base.html) корень |
|
||||||
`cabinet-app` → шапка `cabinet-header` → контент `cabinet-main`; на |
|
||||||
`<body>` всегда класс **`ui-legacy`**, стили в [`app.css`](flask_app/app/static/css/app.css) |
|
||||||
с префиксом **`body.ui-legacy`** (primary/teal, `.btn`, `.surface-card`, |
|
||||||
`legacy-list-shell`, `test-detail-page` и т.д.). |
|
||||||
|
|
||||||
В [`docker-compose.dev.yml`](docker-compose.dev.yml) один сервис |
**Тест содержит:** |
||||||
**`testing-flask`** (`container_name: testing_webapp_flask`), порт **3107**. |
- Название теста |
||||||
|
- Описание (опционально) |
||||||
|
- Список вопросов (минимум 7) |
||||||
|
- Порог зачёта — минимальный % правильных ответов |
||||||
|
- Таймер прохождения — лимит в минутах (опционально) |
||||||
|
|
||||||
--- |
**Вопрос содержит:** |
||||||
|
- Текст вопроса |
||||||
|
- Минимум 3 варианта ответа |
||||||
|
- Один или несколько правильных ответов |
||||||
|
|
||||||
## Что уже работает на новом (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). | |
|
||||||
|
|
||||||
--- |
**Версионирование:** |
||||||
|
- Автор может редактировать тест пока никто его не проходил |
||||||
|
- Если тест уже проходили — создаётся новая версия (`version + 1`), старая сохраняется |
||||||
|
- Все версии теста хранятся; результаты привязаны к конкретной версии |
||||||
|
- Активная версия — та, которую видят сотрудники; автор может вручную переключить активную версию |
||||||
|
- Тест можно деактивировать (скрыть из списка, не удалять) |
||||||
|
|
||||||
## Установка и запуск |
### Назначение теста |
||||||
|
|
||||||
### Предпосылка: общий Postgres |
- Список получателей (отдел или конкретные сотрудники) |
||||||
|
- Срок сдачи — дата дедлайна |
||||||
|
- Допустимое количество попыток (1 или более) |
||||||
|
|
||||||
Используется **тот же** 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 |
|
||||||
``` |
|
||||||
|
|
||||||
| Что | URL | |
Единый интерфейс просмотра всех попыток прохождения тестов: |
||||||
|---|---| |
- Фильтрация по подразделению, сотруднику, тесту, статусу, результату |
||||||
| Приложение (Flask) | <http://localhost:3107> | |
- Пагинация и сортировка |
||||||
| Health-check | <http://localhost:3107/health> | |
|
||||||
|
|
||||||
`docker-compose.dev.yml` пробрасывает в контейнер **`testing-flask`**: |
### AI-помощник |
||||||
- `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` репозитория. |
|
||||||
|
|
||||||
### Локально без Docker |
Интеграция с LLM для помощи при создании тестов: |
||||||
|
|
||||||
См. [`flask_app/README.md`](flask_app/README.md) — `venv` + |
| Функция | Описание | |
||||||
`pip install -r requirements.txt` + `python run.py`. |
|---------|----------| |
||||||
|
| Генерация теста | AI генерирует готовый набор вопросов с вариантами ответов по теме | |
||||||
|
| Улучшение формулировки | AI переформулирует выбранный вопрос более чётко | |
||||||
|
| Добавление дистракторов | AI генерирует правдоподобные неправильные варианты ответов | |
||||||
|
| Проверка качества | AI анализирует весь тест и выдаёт рекомендации | |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Данные и интеграция с 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, Директор клиники | ✅ | Всем сотрудникам клиники | Всех сотрудников | |
||||||
|
| **Руководитель подразделения** | Главный врач, рук. службы администраторов | ✅ | Только своему подразделению | Только своего подразделения | |
||||||
|
| **Сотрудник** | Все остальные работники | ❌ | ❌ | Только свои | |
||||||
|
|
||||||
## Роли и права (по ТЗ) |
--- |
||||||
|
|
||||||
| Роль | Кто | Создаёт тесты | Назначает | Видит результаты | |
## Установка и запуск |
||||||
|---|---|:---:|:---:|:---:| |
|
||||||
| **HR-менеджер** | Руководитель HR, директор | ✅ | Всем | Всех | |
|
||||||
| **Руководитель подразделения** | Главврач, рук. отделения | ✅ | Только своему подразделению | Только своего подразделения | |
|
||||||
| **Сотрудник** | Все остальные | ❌ | ❌ | Только свои | |
|
||||||
|
|
||||||
> На текущем Flask-контуре (E1.0–E1.3, E1.8) проверяется только |
Инструкции по установке и запуску приложения будут добавлены после выбора технологического стека. |
||||||
> `@login_required`; разделение по ролям задействуется на E1.4–E1.5. |
|
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Нефункциональные требования |
## Нефункциональные требования |
||||||
|
|
||||||
| Параметр | Значение | |
| Параметр | Значение | |
||||||
|---|---| |
|----------|----------| |
||||||
| Количество пользователей | 50–200 человек | |
| Количество пользователей | 50–200 человек | |
||||||
| Платформа | Веб, браузер; mobile-friendly | |
| Платформа | Веб-приложение, браузер (desktop-first) | |
||||||
| Доступность | Внутренняя сеть клиники | |
| Доступность | Внутренняя сеть клиники | |
||||||
| Язык интерфейса | Русский | |
| Язык интерфейса | Русский | |
||||||
| Время отклика | < 2 секунды | |
| Время отклика | < 2 секунды | |
||||||
|
|
||||||
## Вне scope (в текущей версии не делаем) |
|
||||||
|
|
||||||
- Интеграция с AD / LDAP. |
|
||||||
- Нативное мобильное приложение. |
|
||||||
- Вопросы с вложениями (картинки, видео). |
|
||||||
- Экспорт отчётов в Excel / PDF. |
|
||||||
- Уведомления в MAX (отдельный спринт). |
|
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## Документация |
## Вне scope (не реализуется в данной версии) |
||||||
|
|
||||||
| Файл | О чём | |
- Интеграция с AD/LDAP |
||||||
|---|---| |
- Мобильное приложение |
||||||
| [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) | Что работает «прямо сейчас», что в работе, что в бэклоге. | |
- Вопросы с вложениями (изображения, видео) |
||||||
| [`docs/migration-final.md`](docs/migration-final.md) | Главный трекер миграции: спринты Этапа 1, журнал, критерии готовности. | |
- Экспорт отчётов в Excel / PDF |
||||||
| [`docs/migration-final-inventory.md`](docs/migration-final-inventory.md) | Карта 22 эндпоинтов Express + gap-analysis с `tgFlaskForm`. | |
- Уведомления в MAX (отдельный спринт) |
||||||
| [`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) | Исходное ТЗ заказчика. | |
|
||||||
|
|||||||
@ -1,46 +0,0 @@ |
|||||||
# --- Рекомендуемый вариант: ОБЩИЙ кластер (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= |
|
||||||
@ -1,15 +0,0 @@ |
|||||||
{ |
|
||||||
"env": { |
|
||||||
"es2022": true, |
|
||||||
"node": true |
|
||||||
}, |
|
||||||
"parserOptions": { |
|
||||||
"ecmaVersion": "latest", |
|
||||||
"sourceType": "module" |
|
||||||
}, |
|
||||||
"extends": ["eslint:recommended"], |
|
||||||
"rules": { |
|
||||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], |
|
||||||
"no-console": "warn" |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
{ |
|
||||||
"semi": true, |
|
||||||
"singleQuote": true, |
|
||||||
"tabWidth": 2, |
|
||||||
"trailingComma": "es5", |
|
||||||
"printWidth": 100 |
|
||||||
} |
|
||||||
@ -1,8 +0,0 @@ |
|||||||
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"] |
|
||||||
@ -1,54 +0,0 @@ |
|||||||
# 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 |
|
||||||
@ -1,6 +0,0 @@ |
|||||||
#!/bin/sh |
|
||||||
set -e |
|
||||||
echo "Running database migrations…" |
|
||||||
node src/db/migrate.js |
|
||||||
echo "Starting API…" |
|
||||||
exec node src/server.js |
|
||||||
@ -1,37 +0,0 @@ |
|||||||
{ |
|
||||||
"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" |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,26 +0,0 @@ |
|||||||
/** |
|
||||||
* 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); |
|
||||||
}); |
|
||||||
@ -1,49 +0,0 @@ |
|||||||
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; |
|
||||||
} |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
/** Пароль-заглушка: вход только через 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'; |
|
||||||
} |
|
||||||
@ -1,6 +0,0 @@ |
|||||||
/** |
|
||||||
* Правка цепочки теста (черновик, версии, публикация, редактор) — только создатель (`tests.created_by`). |
|
||||||
*/ |
|
||||||
export function isTestAuthor(createdBy, userId) { |
|
||||||
return createdBy === userId; |
|
||||||
} |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
/** |
|
||||||
* Флаги продуктовых фич (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; |
|
||||||
} |
|
||||||
@ -1,100 +0,0 @@ |
|||||||
/** |
|
||||||
* 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; |
|
||||||
@ -1,27 +0,0 @@ |
|||||||
/** |
|
||||||
* 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 }; |
|
||||||
@ -1,147 +0,0 @@ |
|||||||
/** |
|
||||||
* 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(); |
|
||||||
@ -1,130 +0,0 @@ |
|||||||
-- 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 |
|
||||||
@ -1,14 +0,0 @@ |
|||||||
-- 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); |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
-- Связь пользователя клиник-теста с сотрудником 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; |
|
||||||
@ -1,65 +0,0 @@ |
|||||||
/** |
|
||||||
* Параметры пула 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, |
|
||||||
}; |
|
||||||
} |
|
||||||
@ -1,234 +0,0 @@ |
|||||||
/** |
|
||||||
* 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(); |
|
||||||
} |
|
||||||
} |
|
||||||
); |
|
||||||
@ -1,41 +0,0 @@ |
|||||||
/** Тексты ответов 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: 'Выбран вариант ответа, не относящийся к вопросу.', |
|
||||||
}; |
|
||||||
@ -1,171 +0,0 @@ |
|||||||
/** |
|
||||||
* 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, |
|
||||||
}; |
|
||||||
@ -1,188 +0,0 @@ |
|||||||
/** |
|
||||||
* A.1–A.4: локальный bcrypt (dev) и HR (HR_AUTH=1 + Werkzeug + staff_id) |
|
||||||
*/ |
|
||||||
import express from 'express'; |
|
||||||
import { query } from '../db/db.js'; |
|
||||||
import { comparePassword, generateToken } from '../utils/auth.js'; |
|
||||||
import { authenticate } from '../middleware/auth.js'; |
|
||||||
import { queryHr, getHrPool } from '../db/hrPool.js'; |
|
||||||
import { mapHrRoleToApp } from '../utils/hrRoleMap.js'; |
|
||||||
import { |
|
||||||
isHrAuthEnabled, |
|
||||||
HR_MANAGED_PASSWORD_PLACEHOLDER, |
|
||||||
} from '../config/authConstants.js'; |
|
||||||
import { RU } from '../messages/ru.js'; |
|
||||||
import { |
|
||||||
getAssignmentDirectory, |
|
||||||
getHrDepartmentNames, |
|
||||||
} from '../services/assignmentDirectoryService.js'; |
|
||||||
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js'; |
|
||||||
|
|
||||||
const router = express.Router(); |
|
||||||
|
|
||||||
router.post('/login', async (req, res) => { |
|
||||||
try { |
|
||||||
const { login, password } = req.body; |
|
||||||
if (!login || !password) { |
|
||||||
return res.status(400).json({ error: RU.loginAndPasswordRequired }); |
|
||||||
} |
|
||||||
|
|
||||||
if (isHrAuthEnabled()) { |
|
||||||
if (!getHrPool()) { |
|
||||||
return res.status(500).json({ error: RU.hrDatabaseUrlMissing }); |
|
||||||
} |
|
||||||
const u = await queryHr( |
|
||||||
`SELECT id, username, password_hash, role
|
|
||||||
FROM users |
|
||||||
WHERE LOWER(TRIM(username)) = LOWER(TRIM($1))`,
|
|
||||||
[login] |
|
||||||
); |
|
||||||
if (u.rows.length === 0 || !u.rows[0].password_hash) { |
|
||||||
return res.status(401).json({ error: RU.invalidCredentials }); |
|
||||||
} |
|
||||||
const row = u.rows[0]; |
|
||||||
const ok = await comparePassword(password, row.password_hash); |
|
||||||
if (!ok) { |
|
||||||
return res.status(401).json({ error: RU.invalidCredentials }); |
|
||||||
} |
|
||||||
const s = await queryHr( |
|
||||||
`SELECT id, fio FROM staff_members
|
|
||||||
WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM($1))`,
|
|
||||||
[login] |
|
||||||
); |
|
||||||
if (s.rows.length === 0) { |
|
||||||
return res.status(403).json({ error: RU.noStaffForLogin }); |
|
||||||
} |
|
||||||
const staffId = s.rows[0].id; |
|
||||||
const fio = s.rows[0].fio || login; |
|
||||||
const appRole = mapHrRoleToApp(row.role); |
|
||||||
const up = await query( |
|
||||||
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
|
|
||||||
VALUES ($1, $2, $3, $4, null, true, $5) |
|
||||||
ON CONFLICT (staff_id) DO UPDATE SET |
|
||||||
login = EXCLUDED.login, |
|
||||||
full_name = EXCLUDED.full_name, |
|
||||||
role = EXCLUDED.role, |
|
||||||
password_hash = EXCLUDED.password_hash |
|
||||||
RETURNING id, login, full_name, role, department_id, staff_id`,
|
|
||||||
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, appRole, staffId] |
|
||||||
); |
|
||||||
const uu = up.rows[0]; |
|
||||||
const token = generateToken( |
|
||||||
uu.id, |
|
||||||
uu.role, |
|
||||||
uu.department_id, |
|
||||||
{ staffId: uu.staff_id } |
|
||||||
); |
|
||||||
res.cookie('token', token, { |
|
||||||
httpOnly: true, |
|
||||||
secure: process.env.NODE_ENV === 'production', |
|
||||||
sameSite: 'strict', |
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, |
|
||||||
}); |
|
||||||
return res.json({ |
|
||||||
user: { |
|
||||||
id: uu.id, |
|
||||||
login: uu.login, |
|
||||||
fullName: uu.full_name, |
|
||||||
role: uu.role, |
|
||||||
departmentId: uu.department_id, |
|
||||||
staffId: uu.staff_id, |
|
||||||
}, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
const result = await query( |
|
||||||
'SELECT id, login, password_hash, full_name, role, department_id FROM users WHERE login = $1 AND is_active = true', |
|
||||||
[login] |
|
||||||
); |
|
||||||
if (result.rows.length === 0) { |
|
||||||
return res.status(401).json({ error: RU.invalidCredentials }); |
|
||||||
} |
|
||||||
const user = result.rows[0]; |
|
||||||
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) { |
|
||||||
return res.status(401).json({ error: RU.useHrLogin }); |
|
||||||
} |
|
||||||
const isValidPassword = await comparePassword(password, user.password_hash); |
|
||||||
if (!isValidPassword) { |
|
||||||
return res.status(401).json({ error: RU.invalidCredentials }); |
|
||||||
} |
|
||||||
const token = generateToken(user.id, user.role, user.department_id); |
|
||||||
res.cookie('token', token, { |
|
||||||
httpOnly: true, |
|
||||||
secure: process.env.NODE_ENV === 'production', |
|
||||||
sameSite: 'strict', |
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, |
|
||||||
}); |
|
||||||
return res.json({ |
|
||||||
user: { |
|
||||||
id: user.id, |
|
||||||
login: user.login, |
|
||||||
fullName: user.full_name, |
|
||||||
role: user.role, |
|
||||||
departmentId: user.department_id, |
|
||||||
staffId: null, |
|
||||||
}, |
|
||||||
}); |
|
||||||
} catch (error) { |
|
||||||
if (error.message?.includes('HR database not configured')) { |
|
||||||
return res.status(500).json({ error: RU.hrDatabaseNotConfigured }); |
|
||||||
} |
|
||||||
console.error('Login error:', error); |
|
||||||
return res.status(500).json({ error: RU.loginFailed }); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
router.post('/logout', (req, res) => { |
|
||||||
try { |
|
||||||
res.clearCookie('token', { |
|
||||||
httpOnly: true, |
|
||||||
secure: process.env.NODE_ENV === 'production', |
|
||||||
sameSite: 'strict', |
|
||||||
}); |
|
||||||
res.json({ message: RU.loggedOut }); |
|
||||||
} catch (error) { |
|
||||||
console.error('Logout error:', error); |
|
||||||
res.status(500).json({ error: RU.logoutFailed }); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
router.get('/me', authenticate, async (req, res) => { |
|
||||||
try { |
|
||||||
const devUi = process.env.NODE_ENV === 'development'; |
|
||||||
const assignmentUi = isAssignmentFeatureEnabled(); |
|
||||||
res.json({ user: req.user, devUi, assignmentUi }); |
|
||||||
} catch (error) { |
|
||||||
console.error('Get current user error:', error); |
|
||||||
res.status(500).json({ error: RU.userDataFailed }); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
/** |
|
||||||
* Каталог сотрудников для назначения: HR (все) + отделы + поиск. Как `POST .../assign`: см. `isAssignmentFeatureEnabled()`. |
|
||||||
* Query: q, department (имя отдела или __all__), clinic=all|with|without |
|
||||||
*/ |
|
||||||
router.get('/dev/assignment-directory', authenticate, async (req, res) => { |
|
||||||
if (!isAssignmentFeatureEnabled()) { |
|
||||||
return res.status(404).json({ error: RU.notFound }); |
|
||||||
} |
|
||||||
try { |
|
||||||
const q = typeof req.query.q === 'string' ? req.query.q : ''; |
|
||||||
const department = typeof req.query.department === 'string' ? req.query.department : ''; |
|
||||||
const c = req.query.clinic; |
|
||||||
const clinicFilter = |
|
||||||
c === 'with' || c === 'without' ? c : 'all'; |
|
||||||
const { people, source } = await getAssignmentDirectory({ |
|
||||||
q, |
|
||||||
department, |
|
||||||
clinicFilter, |
|
||||||
}); |
|
||||||
const departments = await getHrDepartmentNames(); |
|
||||||
res.json({ people, source, departments }); |
|
||||||
} catch (error) { |
|
||||||
console.error('dev assignment directory:', error); |
|
||||||
res.status(500).json({ error: RU.userDataFailed }); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
export default router; |
|
||||||
@ -1,634 +0,0 @@ |
|||||||
/** |
|
||||||
* V.4–V.6, D.1 — API тестов, версий, импорт файла |
|
||||||
*/ |
|
||||||
import express from 'express'; |
|
||||||
import fs from 'fs/promises'; |
|
||||||
import os from 'os'; |
|
||||||
import multer from 'multer'; |
|
||||||
import pool, { query } from '../db/db.js'; |
|
||||||
import { authenticate } from '../middleware/auth.js'; |
|
||||||
import { hasAnyAttemptForTest } from '../services/testChainService.js'; |
|
||||||
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js'; |
|
||||||
import { |
|
||||||
getEditorContent, |
|
||||||
getPlayContent, |
|
||||||
submitAttempt, |
|
||||||
getAttemptReviewForUser, |
|
||||||
listTestAttemptsForAuthor, |
|
||||||
} from '../services/testAttemptService.js'; |
|
||||||
import { extractTextFromFile } from '../services/documentExtractService.js'; |
|
||||||
import { generationForImportDocument } from '../services/documentGenService.js'; |
|
||||||
import { RU } from '../messages/ru.js'; |
|
||||||
import { isTestAuthor } from '../config/devAuthor.js'; |
|
||||||
import { ensureClinicUserIdForStaff } from '../services/assignmentUserService.js'; |
|
||||||
import { |
|
||||||
queryTestsVisibleToUser, |
|
||||||
userHasTestAccess, |
|
||||||
} from '../services/testAccessService.js'; |
|
||||||
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js'; |
|
||||||
import { |
|
||||||
generateFullTestByShape, |
|
||||||
generateOrRephraseQuestion, |
|
||||||
parseAndValidateShape, |
|
||||||
} from '../services/aiEditorService.js'; |
|
||||||
|
|
||||||
const router = express.Router(); |
|
||||||
const upload = multer({ |
|
||||||
dest: os.tmpdir(), |
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, |
|
||||||
}); |
|
||||||
|
|
||||||
function asyncHandler(fn) { |
|
||||||
return (req, res, next) => { |
|
||||||
Promise.resolve(fn(req, res, next)).catch((err) => { |
|
||||||
if (err.status) { |
|
||||||
res.status(err.status).json({ error: err.message }); |
|
||||||
} else { |
|
||||||
next(err); |
|
||||||
} |
|
||||||
}); |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
/** D.1 + D.2 + D.3 (заглушка) — `POST` до маршрутов `/:id` */ |
|
||||||
router.post( |
|
||||||
'/import/document', |
|
||||||
authenticate, |
|
||||||
(req, res, next) => { |
|
||||||
upload.single('file')(req, res, (err) => { |
|
||||||
if (err) { |
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') { |
|
||||||
return res.status(413).json({ error: RU.fileTooLarge }); |
|
||||||
} |
|
||||||
return next(err); |
|
||||||
} |
|
||||||
next(); |
|
||||||
}); |
|
||||||
}, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
if (!req.file) { |
|
||||||
return res.status(400).json({ error: RU.fileFieldRequired }); |
|
||||||
} |
|
||||||
const p = req.file.path; |
|
||||||
const { mimetype, originalname } = req.file; |
|
||||||
let size; |
|
||||||
let extractedText; |
|
||||||
try { |
|
||||||
const st = await fs.stat(p); |
|
||||||
size = st.size; |
|
||||||
extractedText = await extractTextFromFile(mimetype, p, originalname); |
|
||||||
} catch (e) { |
|
||||||
try { |
|
||||||
await fs.unlink(p); |
|
||||||
} catch { |
|
||||||
// ignore
|
|
||||||
} |
|
||||||
if (e.status) { |
|
||||||
return res.status(e.status).json({ error: e.message }); |
|
||||||
} |
|
||||||
console.error('import document:', e); |
|
||||||
return res.status(500).json({ error: RU.uploadFailed }); |
|
||||||
} |
|
||||||
try { |
|
||||||
await fs.unlink(p); |
|
||||||
} catch { |
|
||||||
// D.5: временный файл удалён; при ошибке extract уже удалили выше
|
|
||||||
} |
|
||||||
const generation = await generationForImportDocument(extractedText); |
|
||||||
res.json({ |
|
||||||
received: true, |
|
||||||
originalName: originalname, |
|
||||||
mime: mimetype, |
|
||||||
size, |
|
||||||
extractedText, |
|
||||||
textLength: extractedText.length, |
|
||||||
generation, |
|
||||||
}); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.get( |
|
||||||
'/', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const { rows } = await queryTestsVisibleToUser(req.user.id); |
|
||||||
const { rows: hidden } = await query( |
|
||||||
`SELECT t.id, t.title, t.description, t.is_active AS chain_active,
|
|
||||||
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, |
|
||||||
t.created_by, u.full_name AS author_full_name |
|
||||||
FROM tests t |
|
||||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true |
|
||||||
INNER JOIN users u ON u.id = t.created_by |
|
||||||
WHERE t.is_active = false AND t.created_by = $1 |
|
||||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
|
|
||||||
[req.user.id] |
|
||||||
); |
|
||||||
res.json({ tests: rows, hiddenByYou: hidden }); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.post( |
|
||||||
'/', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const { title, description } = req.body; |
|
||||||
if (!title || typeof title !== 'string') { |
|
||||||
return res.status(400).json({ error: RU.titleRequired }); |
|
||||||
} |
|
||||||
const out = await createTestWithVersion(pool, req.user.id, { |
|
||||||
title, |
|
||||||
description, |
|
||||||
}); |
|
||||||
res.status(201).json(out); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
/** |
|
||||||
* V.8: краткая карточка цепочки — одна строка (активная версия), без дублей. |
|
||||||
* Не-автор не видит скрытую с общего списка цепочку (кроме прямой ссылки автора). |
|
||||||
*/ |
|
||||||
router.get( |
|
||||||
'/:id/summary', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const testId = req.params.id; |
|
||||||
const { rows } = await query( |
|
||||||
`SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active,
|
|
||||||
t.created_by, t.created_at, t.updated_at, tv.id AS active_version_id, tv.version, |
|
||||||
u.full_name AS author_full_name |
|
||||||
FROM tests t |
|
||||||
LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true |
|
||||||
LEFT JOIN users u ON u.id = t.created_by |
|
||||||
WHERE t.id = $1`,
|
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!rows.length) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
const row = rows[0]; |
|
||||||
const isAuthor = isTestAuthor(row.created_by, req.user.id); |
|
||||||
if (row.chain_active === false && !isAuthor) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (!isAuthor) { |
|
||||||
const acc = await userHasTestAccess(req.user.id, testId); |
|
||||||
if (!acc.ok) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
} |
|
||||||
res.json({ |
|
||||||
test: { |
|
||||||
id: row.id, |
|
||||||
title: row.title, |
|
||||||
description: row.description, |
|
||||||
passingThreshold: row.passing_threshold, |
|
||||||
chainActive: row.chain_active, |
|
||||||
activeVersionId: row.active_version_id, |
|
||||||
version: row.version, |
|
||||||
createdAt: row.created_at, |
|
||||||
updatedAt: row.updated_at, |
|
||||||
createdBy: row.created_by, |
|
||||||
authorFullName: row.author_full_name, |
|
||||||
}, |
|
||||||
isAuthor, |
|
||||||
hasActiveVersion: row.active_version_id != null, |
|
||||||
}); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.get( |
|
||||||
'/:id/versions', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const testId = req.params.id; |
|
||||||
const { rows: t } = await query( |
|
||||||
`SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at, t.description,
|
|
||||||
u.full_name AS author_full_name |
|
||||||
FROM tests t |
|
||||||
INNER JOIN users u ON u.id = t.created_by |
|
||||||
WHERE t.id = $1`,
|
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!t.length) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
|
||||||
return res.status(403).json({ error: RU.forbidden }); |
|
||||||
} |
|
||||||
const testRow = t[0]; |
|
||||||
const { rows } = await query( |
|
||||||
`SELECT id, version, is_active, parent_id, created_at
|
|
||||||
FROM test_versions WHERE test_id = $1 ORDER BY version`,
|
|
||||||
[testId] |
|
||||||
); |
|
||||||
const hasAttempts = await hasAnyAttemptForTest(pool, testId); |
|
||||||
res.json({ |
|
||||||
test: { |
|
||||||
id: testRow.id, |
|
||||||
title: testRow.title, |
|
||||||
description: testRow.description, |
|
||||||
chainActive: testRow.is_active, |
|
||||||
createdAt: testRow.created_at, |
|
||||||
updatedAt: testRow.updated_at, |
|
||||||
createdBy: testRow.created_by, |
|
||||||
authorFullName: testRow.author_full_name, |
|
||||||
}, |
|
||||||
versions: rows, |
|
||||||
hasAttempts, |
|
||||||
}); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.get( |
|
||||||
'/:id/editor', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const out = await getEditorContent(pool, req.user.id, req.params.id); |
|
||||||
res.json(out); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
/** |
|
||||||
* ИИ: заполнить тест по текущей сетке (число вопросов = len(shape), варианты = optionsCount). |
|
||||||
* Только автор. Тело: { testTitle?, testDescription?, shape: [{ optionsCount, hasMultipleAnswers }] } |
|
||||||
*/ |
|
||||||
router.post( |
|
||||||
'/:id/ai/generate-test', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const testId = req.params.id; |
|
||||||
const { rows: t } = await query( |
|
||||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!t.length) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
|
||||||
return res.status(403).json({ error: RU.forbidden }); |
|
||||||
} |
|
||||||
const b = req.body && typeof req.body === 'object' ? req.body : {}; |
|
||||||
const shape = parseAndValidateShape(b.shape); |
|
||||||
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : ''; |
|
||||||
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : ''; |
|
||||||
const draft = await generateFullTestByShape(testTitle, testDescription, shape); |
|
||||||
res.json({ ok: true, draft }); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
/** |
|
||||||
* ИИ: один вопрос — пустой текст → сгенерировать вопрос и варианты; иначе переформулировать только текст. |
|
||||||
* Только автор. Тело: { testTitle?, testDescription?, questionText, optionsCount, hasMultipleAnswers } |
|
||||||
*/ |
|
||||||
router.post( |
|
||||||
'/:id/ai/generate-question', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const testId = req.params.id; |
|
||||||
const { rows: tr } = await query( |
|
||||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!tr.length) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (!isTestAuthor(tr[0].created_by, req.user.id)) { |
|
||||||
return res.status(403).json({ error: RU.forbidden }); |
|
||||||
} |
|
||||||
const b = req.body && typeof req.body === 'object' ? req.body : {}; |
|
||||||
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : ''; |
|
||||||
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : ''; |
|
||||||
const questionText = typeof b.questionText === 'string' ? b.questionText : ''; |
|
||||||
const optionsCount = b.optionsCount; |
|
||||||
const hasMultipleAnswers = Boolean(b.hasMultipleAnswers); |
|
||||||
const out = await generateOrRephraseQuestion( |
|
||||||
testTitle, |
|
||||||
testDescription, |
|
||||||
questionText, |
|
||||||
optionsCount, |
|
||||||
hasMultipleAnswers |
|
||||||
); |
|
||||||
res.json({ ok: true, ...out }); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.post( |
|
||||||
'/:id/versions/:vid/activate', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const testId = req.params.id; |
|
||||||
const versionId = req.params.vid; |
|
||||||
const { rows: t } = await query( |
|
||||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!t.length) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
|
||||||
return res.status(403).json({ error: RU.forbidden }); |
|
||||||
} |
|
||||||
const { rows: v } = await query( |
|
||||||
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`, |
|
||||||
[testId, versionId] |
|
||||||
); |
|
||||||
if (!v.length) { |
|
||||||
return res.status(404).json({ error: RU.versionNotFound }); |
|
||||||
} |
|
||||||
const client = await pool.connect(); |
|
||||||
try { |
|
||||||
await client.query('BEGIN'); |
|
||||||
await client.query( |
|
||||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
await client.query( |
|
||||||
`UPDATE test_versions SET is_active = true WHERE id = $1`, |
|
||||||
[versionId] |
|
||||||
); |
|
||||||
await client.query('COMMIT'); |
|
||||||
} catch (e) { |
|
||||||
await client.query('ROLLBACK'); |
|
||||||
throw e; |
|
||||||
} finally { |
|
||||||
client.release(); |
|
||||||
} |
|
||||||
res.json({ ok: true, activeVersionId: versionId }); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.patch( |
|
||||||
'/:id', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const testId = req.params.id; |
|
||||||
const { isActive, chainActive } = req.body; |
|
||||||
const chain = chainActive ?? isActive; |
|
||||||
if (typeof chain !== 'boolean') { |
|
||||||
return res.status(400).json({ error: RU.chainActiveRequired }); |
|
||||||
} |
|
||||||
const { rows: t } = await query( |
|
||||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!t.length) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
|
||||||
return res.status(403).json({ error: RU.forbidden }); |
|
||||||
} |
|
||||||
await query( |
|
||||||
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, |
|
||||||
[testId, chain] |
|
||||||
); |
|
||||||
res.json({ id: testId, chainActive: chain }); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
/** |
|
||||||
* Пакетное назначение — `test_assignments` + `test_assignment_targets` (user). |
|
||||||
* Включение: `development` или `CLINIC_ASSIGNMENT_ENABLED=1`. Только автор. Тело: `userIds`, `staffIds` и/или одиночные `userId` / `staffId`. |
|
||||||
*/ |
|
||||||
router.post( |
|
||||||
'/:id/assign', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
if (!isAssignmentFeatureEnabled()) { |
|
||||||
return res.status(404).json({ error: RU.notFound }); |
|
||||||
} |
|
||||||
const testId = req.params.id; |
|
||||||
const b = req.body && typeof req.body === 'object' ? req.body : {}; |
|
||||||
const userIds = new Set(); |
|
||||||
|
|
||||||
if (Array.isArray(b.userIds)) { |
|
||||||
for (const u of b.userIds) { |
|
||||||
if (typeof u === 'string' && u.trim()) { |
|
||||||
userIds.add(u.trim()); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
if (Array.isArray(b.staffIds)) { |
|
||||||
for (const s of b.staffIds) { |
|
||||||
const n = Number(s); |
|
||||||
if (Number.isFinite(n) && n >= 1) { |
|
||||||
userIds.add(await ensureClinicUserIdForStaff(pool, n)); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
if (userIds.size === 0) { |
|
||||||
if (b.staffId != null && b.userId) { |
|
||||||
return res.status(400).json({ error: RU.assignmentUserOrStaff }); |
|
||||||
} |
|
||||||
if (b.staffId != null) { |
|
||||||
const sid = Number(b.staffId); |
|
||||||
if (Number.isNaN(sid)) { |
|
||||||
return res.status(400).json({ error: RU.assignmentUserRequired }); |
|
||||||
} |
|
||||||
userIds.add(await ensureClinicUserIdForStaff(pool, sid)); |
|
||||||
} else if (typeof b.userId === 'string' && b.userId.trim()) { |
|
||||||
userIds.add(b.userId.trim()); |
|
||||||
} |
|
||||||
} |
|
||||||
if (userIds.size === 0) { |
|
||||||
return res.status(400).json({ error: RU.assignmentUserRequired }); |
|
||||||
} |
|
||||||
|
|
||||||
const { rows: t } = await query( |
|
||||||
`SELECT id, created_by FROM tests WHERE id = $1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!t.length) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (!isTestAuthor(t[0].created_by, req.user.id)) { |
|
||||||
return res.status(403).json({ error: RU.forbidden }); |
|
||||||
} |
|
||||||
const { rows: tv } = await query( |
|
||||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!tv.length) { |
|
||||||
return res.status(400).json({ error: RU.noActiveVersion }); |
|
||||||
} |
|
||||||
for (const uid of userIds) { |
|
||||||
const { rows: u } = await query( |
|
||||||
`SELECT id FROM users WHERE id = $1 AND is_active = true`, |
|
||||||
[uid] |
|
||||||
); |
|
||||||
if (!u.length) { |
|
||||||
return res.status(400).json({ error: RU.userNotFound }); |
|
||||||
} |
|
||||||
} |
|
||||||
const versionId = tv[0].id; |
|
||||||
const client = await pool.connect(); |
|
||||||
let assignmentId; |
|
||||||
try { |
|
||||||
await client.query('BEGIN'); |
|
||||||
const { rows: ins } = await client.query( |
|
||||||
`INSERT INTO test_assignments (test_version_id, assigned_by, max_attempts)
|
|
||||||
VALUES ($1, $2, 5) RETURNING id`,
|
|
||||||
[versionId, req.user.id] |
|
||||||
); |
|
||||||
assignmentId = ins[0].id; |
|
||||||
for (const uid of userIds) { |
|
||||||
await client.query( |
|
||||||
`INSERT INTO test_assignment_targets (assignment_id, target_type, target_id)
|
|
||||||
VALUES ($1, 'user', $2)`,
|
|
||||||
[assignmentId, uid] |
|
||||||
); |
|
||||||
} |
|
||||||
await client.query('COMMIT'); |
|
||||||
} catch (e) { |
|
||||||
await client.query('ROLLBACK'); |
|
||||||
throw e; |
|
||||||
} finally { |
|
||||||
client.release(); |
|
||||||
} |
|
||||||
res |
|
||||||
.status(201) |
|
||||||
.json({ ok: true, assignmentId, count: userIds.size }); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.post( |
|
||||||
'/:id/draft', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const out = await saveTestDraft(pool, req.user.id, req.params.id, req.body); |
|
||||||
res.json(out); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.post( |
|
||||||
'/:id/attempts/start', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const testId = req.params.id; |
|
||||||
const acc = await userHasTestAccess(req.user.id, testId); |
|
||||||
if (!acc.ok) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
const { rows: tv } = await query( |
|
||||||
`SELECT tv.id AS test_version_id
|
|
||||||
FROM test_versions tv |
|
||||||
WHERE tv.test_id = $1 AND tv.is_active = true LIMIT 1`,
|
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!tv.length) { |
|
||||||
return res.status(404).json({ error: RU.noActiveVersion }); |
|
||||||
} |
|
||||||
const testVersionId = tv[0].test_version_id; |
|
||||||
const { rows: mx } = await query( |
|
||||||
`SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts
|
|
||||||
WHERE test_version_id = $1 AND user_id = $2`,
|
|
||||||
[testVersionId, req.user.id] |
|
||||||
); |
|
||||||
const nextN = (mx[0].n || 0) + 1; |
|
||||||
const { rows: a } = await query( |
|
||||||
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status)
|
|
||||||
VALUES ($1, $2, $3, 'in_progress') |
|
||||||
RETURNING id, test_version_id, user_id, attempt_number, status, started_at`,
|
|
||||||
[testVersionId, req.user.id, nextN] |
|
||||||
); |
|
||||||
res.status(201).json({ attempt: a[0] }); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
/** Только автор: список попыток по цепочке (все версии) */ |
|
||||||
router.get( |
|
||||||
'/:id/attempts', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const rows = await listTestAttemptsForAuthor(pool, req.user.id, req.params.id); |
|
||||||
res.json({ |
|
||||||
attempts: rows.map((r) => ({ |
|
||||||
id: r.id, |
|
||||||
userId: r.user_id, |
|
||||||
status: r.status, |
|
||||||
attemptNumber: r.attempt_number, |
|
||||||
startedAt: r.started_at, |
|
||||||
completedAt: r.completed_at, |
|
||||||
correctCount: r.correct_count, |
|
||||||
totalQuestions: r.total_questions, |
|
||||||
passed: r.passed, |
|
||||||
testVersion: r.test_version, |
|
||||||
attempterName: r.attempter_name, |
|
||||||
attempterLogin: r.attempter_login, |
|
||||||
})), |
|
||||||
}); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
/** Разбор завершённой попытки: владелец или автор */ |
|
||||||
router.get( |
|
||||||
'/:id/attempts/:aid/review', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const review = await getAttemptReviewForUser( |
|
||||||
pool, |
|
||||||
req.user.id, |
|
||||||
req.params.id, |
|
||||||
req.params.aid |
|
||||||
); |
|
||||||
res.json(review); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.get( |
|
||||||
'/:id/attempts/:aid/play', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const out = await getPlayContent(pool, req.user.id, req.params.id, req.params.aid); |
|
||||||
res.json(out); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.post( |
|
||||||
'/:id/attempts/:aid/submit', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const out = await submitAttempt( |
|
||||||
pool, |
|
||||||
req.user.id, |
|
||||||
req.params.id, |
|
||||||
req.params.aid, |
|
||||||
req.body?.answers |
|
||||||
); |
|
||||||
res.json(out); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
router.get( |
|
||||||
'/:id/chain-info', |
|
||||||
authenticate, |
|
||||||
asyncHandler(async (req, res) => { |
|
||||||
const testId = req.params.id; |
|
||||||
const acc = await userHasTestAccess(req.user.id, testId); |
|
||||||
if (acc.notFound) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (!acc.ok) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
const { rows: tr } = await query( |
|
||||||
`SELECT t.is_active AS chain_active FROM tests t WHERE t.id = $1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!tr.length) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
if (tr[0].chain_active === false) { |
|
||||||
const { rows: auth } = await query( |
|
||||||
`SELECT created_by FROM tests WHERE id = $1`, |
|
||||||
[testId] |
|
||||||
); |
|
||||||
if (!isTestAuthor(auth[0].created_by, req.user.id)) { |
|
||||||
return res.status(404).json({ error: RU.testNotFound }); |
|
||||||
} |
|
||||||
} |
|
||||||
const has = await hasAnyAttemptForTest(pool, testId); |
|
||||||
res.json({ testId, hasAnyAttempt: has }); |
|
||||||
}) |
|
||||||
); |
|
||||||
|
|
||||||
export default router; |
|
||||||
@ -1,8 +0,0 @@ |
|||||||
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'}`); |
|
||||||
}); |
|
||||||
@ -1,197 +0,0 @@ |
|||||||
/** |
|
||||||
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из 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, |
|
||||||
}; |
|
||||||
} |
|
||||||
@ -1,20 +0,0 @@ |
|||||||
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([]), |
|
||||||
/Передайте/ |
|
||||||
); |
|
||||||
}); |
|
||||||
@ -1,125 +0,0 @@ |
|||||||
/** |
|
||||||
* Каталог для назначения: 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); |
|
||||||
} |
|
||||||
@ -1,64 +0,0 @@ |
|||||||
/** |
|
||||||
* Создать/найти запись `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; |
|
||||||
} |
|
||||||
@ -1,66 +0,0 @@ |
|||||||
/** |
|
||||||
* 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 ''; |
|
||||||
} |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
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'); |
|
||||||
}); |
|
||||||
@ -1,176 +0,0 @@ |
|||||||
/** |
|
||||||
* 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), |
|
||||||
}; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,63 +0,0 @@ |
|||||||
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 |
|
||||||
); |
|
||||||
}); |
|
||||||
@ -1,98 +0,0 @@ |
|||||||
/** |
|
||||||
* 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; |
|
||||||
} |
|
||||||
@ -1,64 +0,0 @@ |
|||||||
/** |
|
||||||
* Кто видит тест: автор цепочки и пользователи с назначением (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] |
|
||||||
); |
|
||||||
} |
|
||||||
@ -1,477 +0,0 @@ |
|||||||
/** |
|
||||||
* Прохождение теста: контент для игры, проверка ответов, завершение попытки. |
|
||||||
*/ |
|
||||||
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; |
|
||||||
} |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
/** |
|
||||||
* Логика «цепочки» теста: попытки и версии (см. 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; |
|
||||||
} |
|
||||||
@ -1,23 +0,0 @@ |
|||||||
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); |
|
||||||
}); |
|
||||||
@ -1,218 +0,0 @@ |
|||||||
/** |
|
||||||
* 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(); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,94 +0,0 @@ |
|||||||
/** |
|
||||||
* 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, |
|
||||||
}; |
|
||||||
@ -1,21 +0,0 @@ |
|||||||
/** |
|
||||||
* Сопоставление 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'; |
|
||||||
} |
|
||||||
@ -1,74 +0,0 @@ |
|||||||
/** |
|
||||||
* Проверка хеша в формате 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); |
|
||||||
} |
|
||||||
@ -1,31 +0,0 @@ |
|||||||
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); |
|
||||||
}); |
|
||||||
@ -1,38 +0,0 @@ |
|||||||
# Система тестирования + общий 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 |
|
||||||
@ -1,22 +0,0 @@ |
|||||||
# По умолчанию этот 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: |
|
||||||
@ -1,64 +0,0 @@ |
|||||||
# Как пользоваться стендом **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, БД, переменные окружения. |
|
||||||
@ -1,148 +0,0 @@ |
|||||||
# Состояние проекта |
|
||||||
|
|
||||||
**Репозиторий:** [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) |
|
||||||
@ -1,414 +0,0 @@ |
|||||||
# Инструкция для тестировщика: версионирование тестов и 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. **Учётная запись**, под которой воспроизвели (логин, без пароля). |
|
||||||
|
|
||||||
Этого достаточно — лезть в консоль/код/базу не нужно и не надо. |
|
||||||
@ -1,380 +0,0 @@ |
|||||||
# Анализ таблиц для тестирования сотрудников |
|
||||||
|
|
||||||
*Модуль **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) |
|
||||||
@ -1,340 +0,0 @@ |
|||||||
# UX-аудит страницы теста и предложение новой информационной архитектуры |
|
||||||
|
|
||||||
**Продукт:** HR system — модуль тестирования |
|
||||||
**Платформа:** Цифровые сервисы клиники им. Е. Н. Оленевой |
|
||||||
**Объект анализа:** страница `/tests/{id}` — создание/редактирование теста |
|
||||||
**Дата:** 29 апреля 2026 |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Краткая сводка |
|
||||||
|
|
||||||
Текущая страница `/tests/{id}` совмещает три разные пользовательские задачи в одном экране: |
|
||||||
|
|
||||||
1. **Авторскую** — придумать и оформить тест (название, описание, вопросы, варианты). |
|
||||||
2. **Управленческую** — назначить тест 1–N сотрудникам. |
|
||||||
3. **Аналитическую** — посмотреть, кто из сотрудников и какие версии проходил. |
|
||||||
|
|
||||||
Эти задачи различаются по ролям, частоте, объёму данных и контексту. Сейчас они смешаны в одном длинном аккордеоне, что приводит к ряду проблем — от потери изменений до невозможности масштабировать список аудитории за пределы 100–200 человек. |
|
||||||
|
|
||||||
Документ состоит из двух частей: |
|
||||||
|
|
||||||
- **Часть 1** — аудит текущей страницы с приоритизированными проблемами (critical / major / minor) и ссылками на скриншоты. |
|
||||||
- **Часть 2** — предложение новой IA с раздельными разделами «Тесты», «Назначения», «Отчёты», ролевой моделью и описанием жизненного цикла версии теста. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
# Часть 1. Аудит текущей страницы |
|
||||||
|
|
||||||
Все скриншоты сделаны 29.04.2026 на странице `https://edullm.pirogov.ai/tests/298a64af-...` под ролью `employee` (см. п. M-3). |
|
||||||
|
|
||||||
## 1.1. Шапка, заголовок и баннер версионирования |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
Что мы видим: глобальная шапка «Тестирование», подпись пользователя `Разорвин А. М. · employee`, кнопка «Выйти». Ниже — хлебная крошка «← к списку», название теста, автор, дата обновления, **жёлтый баннер «При сохранении будет создана новая версия теста.»** и схлопнутый аккордеон «О тесте». |
|
||||||
|
|
||||||
Замечания: |
|
||||||
|
|
||||||
- **C-1 [critical] Баннер о новой версии показывается ВСЕГДА**, независимо от того, изменил ли пользователь хоть что-то. Это сбивает с толку: автор открывает существующий тест, ничего не трогает — и думает, что версия уже создана. Должен показываться только при наличии несохранённых изменений (dirty state). |
|
||||||
- **m-2 [minor] Роль `employee` написана по-английски** в шапке. В русском интерфейсе должно быть «сотрудник» / «автор» / другое (см. ролевую модель в Части 2). |
|
||||||
- **m-3 [minor] Опечатка** в `<title>` вкладки: «Система тестирования» (нет «и»). Влияет на закладки, историю, поиск. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1.2. Секция «О тесте» |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
Замечания: |
|
||||||
|
|
||||||
- **M-1 [major] Аккордеон по умолчанию схлопнут.** Чтобы начать редактировать главный объект страницы (вопросы), нужно сделать лишний клик. На странице редактирования теста раздел «Вопросы» (а возможно, и «О тесте») должен быть открыт по умолчанию. |
|
||||||
- **M-2 [major] Поле «Порог зачёта, %» не имеет валидации min/max.** Что произойдёт при вводе 0, 100, 150, –5, 70.5, или вообще буквы? Минимум: подсказка «от 1 до 100», атрибуты `min/max/step` на input, инлайн-ошибка при некорректном вводе. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1.3. Раздел «Вопросы» — генерация и Вопрос 1 |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
Что мы видим: блок **«Генерация сетки вопросов (ИИ)»** с полями «Тема», «Вопросов: 7», «Вариантов: 3» и кнопкой «Сгенерировать тест (ИИ)». Ниже — Вопрос 1 с собственной кнопкой «Сгенерировать вопрос (ИИ)» в правом верхнем углу, чекбоксом «Несколько верных ответов», тремя вариантами с радиокнопками и крестиками «удалить». |
|
||||||
|
|
||||||
Замечания: |
|
||||||
|
|
||||||
- **C-2 [critical] ИИ-генерация без подтверждения и без отображения хода работы.** Кнопка «Сгенерировать тест (ИИ)» одной нажатием перезаписывает существующие вопросы — а они уже могут быть наполовину написаны вручную. То же касается кнопки «Сгенерировать вопрос (ИИ)» рядом с уже заполненным вопросом. |
|
||||||
- Нужно: confirm-диалог «Заменить текущие N вопросов?», индикатор прогресса генерации, возможность откатить (undo) последний результат генерации. |
|
||||||
- **M-3 [major] Чекбокс «Несколько верных ответов» меняет семантику варианта без явного намёка.** Когда выкл — радиокнопки (один верный), когда вкл — должны стать чекбоксами (несколько). Лучше переписать подпись в зависимости от состояния: «один верный» / «несколько верных», и/или показать рядом подсказку, как изменится контрол. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1.4. Вопросы 3–5 |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
Замечания: |
|
||||||
|
|
||||||
- **M-4 [major] Нет нумерации/перетаскивания вопросов.** «Вопрос 1, 2, 3…» — порядок фиксирован тем, в каком порядке добавляли. Для длинных тестов нужен drag-handle или хотя бы стрелки «вверх / вниз». |
|
||||||
- **M-5 [major] «Удалить вопрос» без подтверждения.** Случайный клик уничтожит написанный вопрос. Минимум — confirm-диалог; лучше — undo-toast «Вопрос удалён · Отменить». |
|
||||||
- **m-4 [minor] Маленькая видимая «вода» между вопросами.** Карточки вопросов мало отделены друг от друга визуально, при пролистывании они сливаются в стену форм. Стоит увеличить вертикальный отступ между карточками или добавить разделитель. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1.5. Вопрос 7 — обрыв длинного варианта |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
Это один из самых наглядных багов: |
|
||||||
|
|
||||||
- **M-6 [major] Длинный текст варианта обрезается.** В Q7 первый вариант отображается как «Максимальное количество токенов, которое модель может о…» — текст уходит за правый край однострочного `<input>`. Для содержательных тестов (особенно медицинских) ответы часто длинные. Нужно: либо `<textarea>` с автовысотой, либо горизонтальный скролл с tooltip всего текста на ховере. |
|
||||||
- **m-5 [minor] Загрузка файла «Документ в вопросы» — без drag-and-drop, без ограничений по размеру/формату на UI, без обратной связи.** Подсказка «PDF, Word или текст — вставьте в черновик вопросов» — хорошая по-человечески, но не объясняет, что произойдёт после загрузки: заменит ли существующие вопросы, добавит ли в конец, есть ли превью результата. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1.6. Кнопка «Сохранить черновик» в середине + История |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
Здесь главная архитектурная проблема страницы: |
|
||||||
|
|
||||||
- **C-3 [critical] Кнопка «Сохранить черновик» расположена в середине страницы.** Сразу после неё ниже идут ещё две большие секции — «История» и «Показ в каталоге». Пользователь, открывший «Показ в каталоге» и поменявший там аудиторию, психологически ищет «Сохранить» внизу страницы — но там его нет. Очень высокий риск потерять изменения. |
|
||||||
- Решения, любое или все: (а) sticky-панель сохранения внизу страницы; (б) дубль кнопки после последней секции; (в) автосохранение черновика; (г) предупреждение перед уходом со страницы при наличии несохранённых изменений. |
|
||||||
- **M-7 [major] Раздел «Прохождения» показывает сырые ENUM-значения.** Видно `v1 · in_progress` — это техническое значение, а не пользовательский текст. Должно быть «в процессе» / «пройдено» / «не пройдено», лучше с цветной плашкой-индикатором. |
|
||||||
- **M-8 [major] Дубль кнопки «К списку».** Хлебная крошка «← к списку» наверху + кнопка «К списку» рядом с «Сохранить черновик» — две точки выхода с разным визуальным весом. Кнопка справа от primary-кнопки создаёт ложное ощущение симметричности с действием. Оставить либо крошку, либо превратить вторую кнопку в текстовую ссылку «Отмена». |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1.7. «Показ в каталоге» — Видимость и фильтры |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
- **M-9 [major] Контрол «Видимость» неясен по текущему состоянию.** Кнопка «Скрыть из списка» — это сейчас действие или текущее состояние? Если тест уже скрыт — кнопка должна называться «Показать в списке». Лучше — переключатель (toggle/switch) с подписью «Тест виден в каталоге», чтобы текущее состояние читалось без действий. |
|
||||||
- **m-6 [minor] Поле поиска и два селекта** «Все отделы» / «Все» расположены без подписей — что делает второй селект, без раскрытия не понятно. Нужны явные label или persistent placeholder. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1.8. Список «Кому выдать» — 147 сотрудников |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
Этот блок — корень главной IA-проблемы (см. Часть 2): |
|
||||||
|
|
||||||
- **C-4 [critical] Назначение тестов не должно жить на странице теста.** Это управленческая задача отдельной роли (HR-менеджер, руководитель отделения), а не авторская. Подробно — в Часть 2. |
|
||||||
- **M-10 [major] Список из 147 человек без виртуализации и счётчика выбранных.** Нужно как минимум: счётчик «выбрано N из 147», фильтр «только выбранные», сохранение выбранного при изменении фильтра, виртуальный скролл (на 1000+ сотрудников страница встанет колом). |
|
||||||
- **M-11 [major] «Назначить выбранных» внутри контейнера списка.** Кнопка стоит на нижней границе скролл-контейнера — её очень легко не заметить. И непонятно: «Назначить» — это отдельное действие или часть общего «Сохранить черновик» наверху? |
|
||||||
- **m-7 [minor] Подпись «нет учётки (создадим при назначении)»** — хорошая идея (ленивая выдача учёток), но требует пояснения: что значит «при назначении», что получит сотрудник после, как ему придёт первый пароль. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1.9. Сводная таблица замечаний |
|
||||||
|
|
||||||
| ID | Приоритет | Что | Место | |
|
||||||
|---|---|---|---| |
|
||||||
| C-1 | critical | Баннер «новая версия» виден всегда, не только при изменениях | 1.1 | |
|
||||||
| C-2 | critical | ИИ-генерация без confirm и без прогресса | 1.3 | |
|
||||||
| C-3 | critical | Кнопка «Сохранить» в середине страницы | 1.6 | |
|
||||||
| C-4 | critical | Назначение сотрудников не должно жить на странице теста | 1.8 | |
|
||||||
| M-1 | major | Аккордеоны схлопнуты по умолчанию, включая «Вопросы» | 1.2 | |
|
||||||
| M-2 | major | «Порог зачёта» без валидации min/max | 1.2 | |
|
||||||
| M-3 | major | Чекбокс «Несколько верных» меняет семантику без подсказки | 1.3 | |
|
||||||
| M-4 | major | Нет переупорядочивания вопросов | 1.4 | |
|
||||||
| M-5 | major | «Удалить вопрос» без подтверждения и undo | 1.4 | |
|
||||||
| M-6 | major | Длинный текст варианта ответа обрезается | 1.5 | |
|
||||||
| M-7 | major | Сырые ENUM-значения в статусах прохождений | 1.6 | |
|
||||||
| M-8 | major | Дубль точек выхода («← к списку» + «К списку») | 1.6 | |
|
||||||
| M-9 | major | Контрол «Видимость» неясен по состоянию | 1.7 | |
|
||||||
| M-10 | major | Список 147 сотрудников без виртуализации/счётчиков | 1.8 | |
|
||||||
| M-11 | major | «Назначить выбранных» теряется внутри контейнера | 1.8 | |
|
||||||
| m-1 | minor | Логотип на странице логина обрезан | вне скрина | |
|
||||||
| m-2 | minor | Роль `employee` латиницей в шапке | 1.1 | |
|
||||||
| m-3 | minor | Опечатка «тестирования» в `<title>` | 1.1 | |
|
||||||
| m-4 | minor | Карточки вопросов слабо отделены друг от друга | 1.4 | |
|
||||||
| m-5 | minor | Загрузка файла без drag-and-drop и описания результата | 1.5 | |
|
||||||
| m-6 | minor | Селекты в фильтрах без явных label | 1.7 | |
|
||||||
| m-7 | minor | «Нет учётки (создадим при назначении)» — нужно пояснение | 1.8 | |
|
||||||
|
|
||||||
Не проверено и стоит протестировать отдельно: валидация при сохранении пустого вопроса/вариантов, мобильная вёрстка, клавиатурная навигация и focus ring, контрастность по WCAG 2.2, поведение под другими ролями (руководитель, HR, директор). |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
# Часть 2. Предлагаемая новая IA |
|
||||||
|
|
||||||
## 2.1. Что не так с текущей IA |
|
||||||
|
|
||||||
Сейчас одна страница `/tests/{id}` решает три разные задачи разных ролей: |
|
||||||
|
|
||||||
| Задача | Кто делает | Как часто | Какие данные | |
|
||||||
|---|---|---|---| |
|
||||||
| Сочинить тест | автор / методолог | один раз при создании, далее редко | вопросы, варианты, порог | |
|
||||||
| Назначить кому проходить | автор (иногда) или HR / руководитель | каждый раз для нового сотрудника или потока | список из 100–10 000 сотрудников, фильтры | |
|
||||||
| Посмотреть кто прошёл | руководитель / HR / директор | регулярно | результаты, динамика, агрегаты | |
|
||||||
|
|
||||||
Это три разных пользовательских ритма, три разных набора фильтров, три разных уровня доступа. Складывать их в один аккордеон — экономия на маршрутизации и проигрыш во всём остальном (см. C-3, C-4, M-9, M-10, M-11). |
|
||||||
|
|
||||||
## 2.2. Карта разделов после редизайна |
|
||||||
|
|
||||||
``` |
|
||||||
HR system (модуль «Тестирование») |
|
||||||
│ |
|
||||||
├── Главная / Дашборд |
|
||||||
│ сводка: «назначено N тестов, X% прошли, Y просрочены» |
|
||||||
│ (вид зависит от роли — см. 2.4) |
|
||||||
│ |
|
||||||
├── Тесты |
|
||||||
│ ├── Каталог тестов ← список, поиск, фильтры |
|
||||||
│ ├── Создать тест ← минимальный wizard: название → пустой черновик |
|
||||||
│ └── /tests/{id} ← страница теста |
|
||||||
│ ├── Просмотр ← все, у кого есть доступ |
|
||||||
│ │ краткая сводка прохождений (89 / 147, средний 6.2/7) |
|
||||||
│ │ кнопка «Назначить» (открывает модалку из 2.3) |
|
||||||
│ │ кнопка «Редактировать» (если есть права) |
|
||||||
│ └── Редактирование ← только автор / методолог |
|
||||||
│ ├── О тесте |
|
||||||
│ ├── Вопросы |
|
||||||
│ └── Версии теста ← (вместо «История» — только версии) |
|
||||||
│ |
|
||||||
├── Назначения ← новый раздел |
|
||||||
│ ├── Список назначений ← таблица «тест × сотрудник × срок × статус» |
|
||||||
│ ├── Создать назначение ← массовый wizard (см. 2.3) |
|
||||||
│ └── /assignments/{id} ← страница назначения, где можно отозвать, |
|
||||||
│ продлить срок, посмотреть прогресс |
|
||||||
│ |
|
||||||
├── Отчёты ← новый раздел |
|
||||||
│ ├── По тесту ← кто прошёл, средний балл, кривые |
|
||||||
│ ├── По сотруднику ← все тесты сотрудника, история |
|
||||||
│ └── По отделу ← агрегаты для руководителей |
|
||||||
│ |
|
||||||
├── Сотрудники ← справочник, синхронизация с кадрами |
|
||||||
│ |
|
||||||
└── Настройки ← роли, подразделения, шаблоны уведомлений |
|
||||||
``` |
|
||||||
|
|
||||||
## 2.3. Сценарий «Назначить тест» через модалку |
|
||||||
|
|
||||||
Поскольку автор иногда сам назначает тест, а иногда передаёт это HR/руководителю, кнопка «Назначить» нужна **в двух местах**: |
|
||||||
|
|
||||||
- На странице теста (для автора, который сразу выдаёт тест). |
|
||||||
- В разделе «Назначения → Создать» (для HR/руководителя, который отбирает аудиторию массово). |
|
||||||
|
|
||||||
Обе точки открывают **одну и ту же модалку / визард** с шагами: |
|
||||||
|
|
||||||
1. **Кому.** Сначала фильтры по отделу/должности → одной кнопкой «Все из отделения хирургии (38)» или вручную чекбоксами. Сохранение выбранного при смене фильтра. Виртуализированный список. |
|
||||||
2. **Когда.** Дедлайн, опционально дата старта (например, новый сотрудник получает тест на 3-й рабочий день). |
|
||||||
3. **Параметры.** Сколько попыток допустимо, нужен ли пересдача после неуспеха, кому уведомления о результате. |
|
||||||
4. **Подтверждение.** «Назначить тест „Введение про LLM v1“ 38 сотрудникам отделения хирургии до 15 мая 2026 — назначить?» |
|
||||||
|
|
||||||
После назначения автор/HR попадает на страницу созданного назначения, где видит прогресс: кто открыл, кто проходит, кто завершил. |
|
||||||
|
|
||||||
## 2.4. Ролевая модель и матрица доступа |
|
||||||
|
|
||||||
Четыре роли из ваших пояснений: **сотрудник**, **руководитель подразделения**, **HR-менеджер**, **директор**. Плюс отдельно — **методолог/автор**, которая может присваиваться поверх любой из роли (директор, HR или руководитель могут также быть авторами). |
|
||||||
|
|
||||||
| Раздел / действие | Сотрудник | Рук. подр. | HR | Директор | Автор | |
|
||||||
|---|---|---|---|---|---| |
|
||||||
| Главная | свои назначения | свой отдел | вся клиника | вся клиника | свои тесты | |
|
||||||
| Каталог тестов — просмотр | да (только видимые) | да | да | да | да | |
|
||||||
| Создать тест | — | — | да | да | да | |
|
||||||
| Редактировать тест | — | — | (свои) | да | свои | |
|
||||||
| Опубликовать новую версию | — | — | (свои) | да | свои | |
|
||||||
| Удалить/архивировать тест | — | — | (свои) | да | свои | |
|
||||||
| Назначить тест | — | свой отдел | вся клиника | вся клиника | (если сам назначает) | |
|
||||||
| Отозвать назначение | — | свои | свои + HR-уровня | все | свои | |
|
||||||
| Отчёты по сотруднику | свои | подчинённые | все | все | свои тесты | |
|
||||||
| Отчёты по отделу | — | свой отдел | все | все | — | |
|
||||||
| Настройки ролей | — | — | да | да | — | |
|
||||||
|
|
||||||
«—» — действие не доступно. Точные границы (например, может ли HR редактировать чужой тест) уточняются на этапе требований. |
|
||||||
|
|
||||||
## 2.5. Жизненный цикл версии теста и поведение при активных прохождениях |
|
||||||
|
|
||||||
Версионирование уже сделано правильно — оно фиксирует, какую именно версию проходил сотрудник, и не ломает прошлые результаты. Но в UI нужно явно показать состояния и поведение при апдейте. |
|
||||||
|
|
||||||
``` |
|
||||||
┌──────────┐ |
|
||||||
│ Черновик │ ← автор может править свободно, |
|
||||||
└────┬─────┘ назначения нельзя выдать |
|
||||||
│ |
|
||||||
«Опубликовать как v2» |
|
||||||
│ |
|
||||||
▼ |
|
||||||
┌──────────┐ |
|
||||||
│ Активная │ ← новые назначения идут на эту версию; |
|
||||||
└────┬─────┘ уже идущие прохождения остаются на старой |
|
||||||
│ |
|
||||||
«Опубликовать как v3» |
|
||||||
│ |
|
||||||
▼ |
|
||||||
┌──────────┐ |
|
||||||
│ Архив │ ← новые назначения нельзя; старые |
|
||||||
└──────────┘ прохождения видны в отчётах |
|
||||||
``` |
|
||||||
|
|
||||||
Что должно быть видно в UI: |
|
||||||
|
|
||||||
- **Бейдж версии** рядом с названием теста: `Введение про LLM · v2 (активна)`. |
|
||||||
- **На странице редактирования** — явно: «Редактируется черновик v3 на основе активной v2». |
|
||||||
- **При публикации новой версии** — диалог: «Сейчас тест проходят 12 сотрудников на v2. Они закончат на v2; новые назначения пойдут на v3. Опубликовать v3?» |
|
||||||
- **В отчётах** — фильтр по версии теста. |
|
||||||
- **В назначении** — версия зафиксирована: «Назначен на тесте Введение про LLM v2». |
|
||||||
|
|
||||||
## 2.6. Состояние «черновик» страницы теста |
|
||||||
|
|
||||||
Сейчас единственная кнопка — «Сохранить черновик». Лучше добавить два глагола: |
|
||||||
|
|
||||||
- **«Сохранить черновик»** — сохранить промежуточно, не публиковать. Не создаёт новой версии. |
|
||||||
- **«Опубликовать как новую версию»** — фиксирует версию, делает её активной, открывает диалог из 2.5. |
|
||||||
|
|
||||||
Тогда жёлтый баннер из C-1 превращается в осмысленную подсказку: он показывается **только при наличии несохранённых изменений** и говорит «Чтобы изменения попали в назначения — опубликуйте новую версию». |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
# Часть 3. Чеклист изменений |
|
||||||
|
|
||||||
Разбит на три волны по приоритету и независимости работ. |
|
||||||
|
|
||||||
## Волна 1 — быстрые правки на текущей странице (1–2 спринта) |
|
||||||
|
|
||||||
Не требуют структурных изменений, можно делать параллельно с разработкой новой IA: |
|
||||||
|
|
||||||
- [ ] **C-1** Скрыть баннер версионирования при отсутствии изменений (dirty state). |
|
||||||
- [ ] **C-2** Confirm-диалог + прогресс для ИИ-генерации, undo последнего результата. |
|
||||||
- [ ] **C-3** Sticky-панель «Сохранить» внизу + предупреждение `beforeunload` при unsaved changes. |
|
||||||
- [ ] **M-1** Аккордеон «Вопросы» открыт по умолчанию. |
|
||||||
- [ ] **M-2** Валидация порога зачёта (1–100, целое число). |
|
||||||
- [ ] **M-3** Поясняющий текст для «Несколько верных ответов». |
|
||||||
- [ ] **M-5** Confirm + undo для «Удалить вопрос». |
|
||||||
- [ ] **M-6** Длинные варианты — `textarea` с автовысотой. |
|
||||||
- [ ] **M-7** Перевод ENUM-значений статусов прохождения. |
|
||||||
- [ ] **M-9** Toggle-switch для «Видимость» вместо одной кнопки. |
|
||||||
- [ ] **m-1, m-2, m-3** Косметика: логотип логина, роль, опечатка title. |
|
||||||
|
|
||||||
## Волна 2 — выделение разделов (новая IA) |
|
||||||
|
|
||||||
- [ ] Выделить раздел «Назначения» с собственной таблицей и фильтрами. |
|
||||||
- [ ] Перенести «Кому выдать» со страницы теста в модалку «Назначить» из 2.3. |
|
||||||
- [ ] Выделить раздел «Отчёты» из секции «История», расширить фильтрами и агрегатами. |
|
||||||
- [ ] Реализовать ролевую модель из 2.4 (RBAC): меню, разделы и действия зависят от роли. |
|
||||||
- [ ] Реализовать жизненный цикл версии (2.5) и явную публикацию. |
|
||||||
|
|
||||||
## Волна 3 — масштабирование и качество |
|
||||||
|
|
||||||
- [ ] Виртуализация списков сотрудников (поддержка 5 000+). |
|
||||||
- [ ] Drag-and-drop для перестановки вопросов (M-4). |
|
||||||
- [ ] Drag-and-drop загрузка файла с превью результата (m-5). |
|
||||||
- [ ] Аудит доступности (WCAG 2.2 AA): клавиатурная навигация, focus-ring, контрастность. |
|
||||||
- [ ] Адаптивная вёрстка для мобильных и планшетов. |
|
||||||
- [ ] Уведомления (e-mail, в системе) для назначений, дедлайнов, результатов. |
|
||||||
- [ ] Связка с курсами/треками (когда появятся). |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
# Что дальше |
|
||||||
|
|
||||||
После согласования этого документа имеет смысл: |
|
||||||
|
|
||||||
1. Сделать кликабельный прототип в Figma на 2 ключевых сценария: «автор создаёт и сразу назначает тест», «HR назначает существующий тест 200 сотрудникам». Это покажет, как именно ложится новая IA на реальные действия и где остались дыры. |
|
||||||
2. Прогнать прототип на 2–3 пользователях каждой роли (автор, HR, руководитель) — модерируемое юзабилити-тестирование на 30–40 минут. По итогам — финальные правки до старта разработки. |
|
||||||
3. Параллельно запустить Волну 1 — она независима от IA и сразу снимает большую часть пользовательской боли. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
*Если по какому-то пункту нужно больше деталей или хочется, чтобы я подготовил визард-сценарий «Назначить» в виде последовательных макетов — скажите.* |
|
||||||
@ -1,220 +0,0 @@ |
|||||||
# Спринты по bugfix-задачам (Тесты / Доступ / Генерация) |
|
||||||
|
|
||||||
Дата: 2026-04-30 |
|
||||||
Контур: `flask_app` (UI + API + сервисы генерации) |
|
||||||
|
|
||||||
## Цели пакета |
|
||||||
|
|
||||||
1. Дать доступ всем авторизованным пользователям ко всем активным тестам (без назначений). |
|
||||||
2. Привести поведение шаблона генерации теста к ожидаемому (кол-во вопросов/вариантов/правильных ответов). |
|
||||||
3. Добавить массовые настройки «несколько вариантов ответа». |
|
||||||
4. Улучшить поток работы с подсказками и прозрачность прогресса генерации. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Спринт 1 — Доступ всем авторизованным (без назначений) |
|
||||||
|
|
||||||
### Объём |
|
||||||
|
|
||||||
- Убрать зависимость прохождения теста от назначений (`TestAssignment*`). |
|
||||||
- Разрешить доступ к активным тестам для любого авторизованного пользователя. |
|
||||||
- Проверить, что каталог и старт попытки работают консистентно с новой политикой. |
|
||||||
|
|
||||||
### Задачи |
|
||||||
|
|
||||||
1. **Политика доступа** |
|
||||||
- В `user_has_test_access` вернуть `ok=True` для любого активного теста, если тест существует и пользователь авторизован. |
|
||||||
- Оставить проверки авторства только там, где они нужны для редактора/версий/админских действий. |
|
||||||
|
|
||||||
2. **Проверка API прохождения** |
|
||||||
- `start_attempt`, `play`, `submit`, `review` не должны требовать назначения на пользователя. |
|
||||||
- Ошибка «Доступ запрещён» не возникает для обычного авторизованного сотрудника при прохождении активного теста. |
|
||||||
|
|
||||||
### Критерии приёмки |
|
||||||
|
|
||||||
- Любой авторизованный пользователь может открыть и пройти любой активный тест, даже без назначения. |
|
||||||
- При этом операции автора (редактор, версии, массовые действия) остаются ограничены автором. |
|
||||||
|
|
||||||
### Оценка |
|
||||||
|
|
||||||
- 0.5 дня. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Спринт 2 — Шаблон генерации: контракт и предсказуемость |
|
||||||
|
|
||||||
### Проблема |
|
||||||
|
|
||||||
Сейчас пользователь создаёт шаблон (например, 12×4), затем генерирует из документа и получает иной результат (например, 10×3). Ожидания и фактический контракт не совпадают. |
|
||||||
|
|
||||||
### Объём |
|
||||||
|
|
||||||
- Зафиксировать единый контракт: какие параметры шаблона обязательны для генератора. |
|
||||||
- Принудительно соблюдать: |
|
||||||
- количество вопросов, |
|
||||||
- количество вариантов в вопросе, |
|
||||||
- границы количества правильных ответов. |
|
||||||
- Добавить валидацию и пост-проверку результата генерации. |
|
||||||
|
|
||||||
### Задачи |
|
||||||
|
|
||||||
1. **Формальный контракт шаблона** |
|
||||||
- Явно определить обязательные поля shape: |
|
||||||
- `questions_count` |
|
||||||
- `options_per_question` |
|
||||||
- `multiple_answers_default` |
|
||||||
- `correct_answers_min/max` |
|
||||||
- Хранить shape вместе с тестом/версией как источник истины. |
|
||||||
|
|
||||||
2. **Генерация по документу с shape** |
|
||||||
- Передавать shape в генератор при `generate from document`. |
|
||||||
- После генерации валидировать фактическую структуру. |
|
||||||
- При расхождении: |
|
||||||
- либо автоматически нормализовать (добить/сжать до нужного формата), |
|
||||||
- либо показать понятную ошибку и не сохранять черновик. |
|
||||||
|
|
||||||
3. **Пользовательская обратная связь** |
|
||||||
- На экране до запуска показывать «Будет сгенерировано: 12 вопросов, 4 варианта». |
|
||||||
- После завершения показывать фактический итог и предупреждение, если пришлось авто-нормализовать. |
|
||||||
|
|
||||||
### Критерии приёмки |
|
||||||
|
|
||||||
- Для шаблона 12×4 итоговый тест всегда 12 вопросов по 4 варианта. |
|
||||||
- Несоответствие не проходит «тихо»: либо авто-исправление с уведомлением, либо явная ошибка. |
|
||||||
|
|
||||||
### Оценка |
|
||||||
|
|
||||||
- 1.5–2.5 дня. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Спринт 3 — Массовый контроль «Несколько вариантов ответа» |
|
||||||
|
|
||||||
### Объём |
|
||||||
|
|
||||||
- Добавить глобальный чекбокс «Несколько вариантов ответа» (для всех вопросов). |
|
||||||
- В шаблоне добавить диапазон правильных ответов: `от _ до _`. |
|
||||||
- При отключении мультивыбора на конкретном вопросе нижняя граница «от» фиксируется в `1`. |
|
||||||
|
|
||||||
### Задачи |
|
||||||
|
|
||||||
1. **UI шаблона** |
|
||||||
- Глобальный switch/checkbox: «Несколько вариантов ответа для всех вопросов». |
|
||||||
- Поля диапазона: |
|
||||||
- `Мин. правильных` (от), |
|
||||||
- `Макс. правильных` (до), |
|
||||||
- валидация `1 <= min <= max <= options_per_question`. |
|
||||||
|
|
||||||
2. **Применение к вопросам** |
|
||||||
- При включении глобального флага обновлять все вопросы: |
|
||||||
- `hasMultipleAnswers=true`. |
|
||||||
- При выключении: |
|
||||||
- `hasMultipleAnswers=false`, |
|
||||||
- `minCorrect=1`, `maxCorrect=1`. |
|
||||||
- На уровне отдельного вопроса разрешить override. |
|
||||||
|
|
||||||
3. **Правило «заморозки min=1»** |
|
||||||
- Если на вопросе `hasMultipleAnswers=false`, то: |
|
||||||
- `minCorrect` автоматически = `1`, |
|
||||||
- поле `minCorrect` read-only/disabled. |
|
||||||
|
|
||||||
4. **Серверная валидация** |
|
||||||
- API сохраняет/проверяет тот же инвариант. |
|
||||||
- Невалидные комбинации отклоняются с понятным сообщением. |
|
||||||
|
|
||||||
### Критерии приёмки |
|
||||||
|
|
||||||
- Глобальный флаг влияет на все вопросы. |
|
||||||
- Локальное отключение мультивыбора фиксирует `min=1`. |
|
||||||
- Генератор и редактор работают в одинаковой логике, без рассинхрона. |
|
||||||
|
|
||||||
### Оценка |
|
||||||
|
|
||||||
- 1.5–2 дня. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Спринт 4 — Подсказки и прогресс генерации |
|
||||||
|
|
||||||
### Объём |
|
||||||
|
|
||||||
- В параметрах теста при включённых подсказках показать действие «Сгенерировать подсказки». |
|
||||||
- Генерация подсказок работает только по заполненным вопросам. |
|
||||||
- Если тест генерируется с нуля и подсказки уже включены — подсказки генерируются в том же пайплайне. |
|
||||||
- Добавить прогресс по этапам генерации + локальные индикаторы загрузки в соответствующих блоках UI. |
|
||||||
|
|
||||||
### Задачи |
|
||||||
|
|
||||||
1. **Кнопка/ссылка «Сгенерировать подсказки»** |
|
||||||
- Показ только при `hintsEnabled=true`. |
|
||||||
- Показ количества: `N без подсказок`. |
|
||||||
- Запуск только по вопросам, где есть текст + варианты. |
|
||||||
|
|
||||||
2. **Пайплайн генерации «с нуля»** |
|
||||||
- Если `hintsEnabled=true`, после генерации вопросов автоматически запускать генерацию подсказок. |
|
||||||
- Ошибки подсказок не должны ломать весь тест: частичный результат допустим с отчётом. |
|
||||||
|
|
||||||
3. **Прогресс и статусы** |
|
||||||
- Этапы: |
|
||||||
- подготовка документа, |
|
||||||
- извлечение текста, |
|
||||||
- генерация структуры, |
|
||||||
- генерация вопросов, |
|
||||||
- генерация подсказок, |
|
||||||
- финализация. |
|
||||||
- В UI показывать текущий этап и процент/счётчик. |
|
||||||
- Спиннеры показывать только у активного блока (не глобально на всю форму). |
|
||||||
|
|
||||||
4. **Наблюдаемость** |
|
||||||
- Логи этапов и длительности. |
|
||||||
- В ответ API возвращать breakdown по шагам/ошибкам. |
|
||||||
|
|
||||||
### Критерии приёмки |
|
||||||
|
|
||||||
- Пользователь видит, что именно сейчас генерируется. |
|
||||||
- Подсказки отдельно запускаются и генерируются только для валидных вопросов. |
|
||||||
- При генерации «с нуля» с включёнными подсказками подсказки появляются автоматически. |
|
||||||
|
|
||||||
### Оценка |
|
||||||
|
|
||||||
- 2–3 дня. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Спринт 5 — Регрессия, UX-полировка, выпуск |
|
||||||
|
|
||||||
### Объём |
|
||||||
|
|
||||||
- Сквозное тестирование сценариев. |
|
||||||
- Документация для пользователей и команды. |
|
||||||
- Подготовка релиз-нота. |
|
||||||
|
|
||||||
### Тест-кейсы (минимум) |
|
||||||
|
|
||||||
1. Неавторизованный пользователь открывает приватные URL → редирект на логин. |
|
||||||
2. Каталог тестов: есть переход «На главную». |
|
||||||
3. Шаблон 12×4 + генерация из PDF → на выходе 12×4. |
|
||||||
4. Глобальный мультивыбор + локальное выключение на 1 вопросе → `min=1` на этом вопросе. |
|
||||||
5. Включены подсказки: |
|
||||||
- кнопка «Сгенерировать подсказки» доступна, |
|
||||||
- генерируются только по заполненным вопросам. |
|
||||||
6. Генерация с нуля + подсказки включены → подсказки сгенерированы в том же запуске. |
|
||||||
7. Прогресс этапов отображается корректно, загрузка локальная. |
|
||||||
|
|
||||||
### Оценка |
|
||||||
|
|
||||||
- 1–1.5 дня. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Приоритеты |
|
||||||
|
|
||||||
- **P0:** Спринт 1 (доступ всем авторизованным), критичный функциональный bugfix. |
|
||||||
- **P1:** Спринт 2 (контракт шаблона), устранение основного функционального несоответствия. |
|
||||||
- **P1:** Спринт 3 (массовый мультивыбор), важная продуктовая логика. |
|
||||||
- **P2:** Спринт 4 (подсказки + прогресс), прозрачность и удобство. |
|
||||||
- **P2:** Спринт 5 (регрессия + выпуск). |
|
||||||
|
|
||||||
## Суммарная оценка |
|
||||||
|
|
||||||
Ориентир: **6.5–10 рабочих дней** (в зависимости от объёма автотестов и глубины рефакторинга генератора). |
|
||||||
@ -1,100 +0,0 @@ |
|||||||
# Унификация стека TestingWebApp с `tgFlaskForm` — план и журнал |
|
||||||
|
|
||||||
> **Корректировка курса от 2026-04-27.** |
|
||||||
> Ранее в этом документе фигурировал «полный переезд в HR-кабинет (`tgFlaskForm`) с cutover'ом и удалением React/Express». Это было забеганием вперёд. Текущая фаза — **только унификация стека**, без слияния репозиториев и без миграции данных. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Назначение документа |
|
||||||
|
|
||||||
Зафиксировать разделение работы на **два этапа** и текущий статус каждого. Этот файл — **трекер решения и журнал**, основной план Этапа 1 — здесь же; план Этапа 2 (на будущее) — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md). |
|
||||||
|
|
||||||
**Связано:** [migration-final-inventory.md](migration-final-inventory.md) (карта 22 эндпоинтов Express, БД, env, зависимости, плюс справочный gap-analysis с уже существующим модулем в `tgFlaskForm` — пригодится в Этапе 2), [PROJECT_STATUS.md](PROJECT_STATUS.md), [README](../README.md), [`flask_app/README.md`](../flask_app/README.md), [СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md). |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Этап 1 (текущий) — единый стек: Express → Flask, React → Jinja, **внутри TestingWebApp** |
|
||||||
|
|
||||||
### Цель |
|
||||||
|
|
||||||
Привести TestingWebApp к **тому же стеку**, что у `HR_TG_Bot/tgFlaskForm`: |
|
||||||
|
|
||||||
- **Бэкенд:** Python 3 + Flask, точечный SQL/SQLAlchemy в стиле `tgFlaskForm`. Развивается в каталоге [`flask_app/`](../flask_app/) этого репозитория (сейчас — минимальный каркас). |
|
||||||
- **Фронтенд:** Jinja-шаблоны в `flask_app/app/templates/`, мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) переносится один в один. React (`frontend/`) уходит **после** того, как Jinja-версия закроет все экраны. |
|
||||||
- **БД:** **остаётся `clinic_tests`** (со своими UUID-ключами). Никаких изменений схемы. |
|
||||||
- **Авторизация:** JWT/bcrypt + опциональный `HR_AUTH=1` (как в Express) — переносим как есть. |
|
||||||
|
|
||||||
### Что **не** делаем в Этапе 1 |
|
||||||
|
|
||||||
- **Не** трогаем `HR_TG_Bot/tgFlaskForm/`. Его модуль `cabinet/testing` живёт своей жизнью. |
|
||||||
- **Не** мигрируем данные `clinic_tests → hr_bot_test`. ETL-скрипт `migrate_clinic_tests_to_hr.py` есть, но он — для Этапа 2. |
|
||||||
- **Не** удаляем `backend/` и `frontend/` сразу. Они работают параллельно с `flask_app/` до полного паритета. Удаление — последним PR этапа. |
|
||||||
|
|
||||||
### Стартовая точка `flask_app/` |
|
||||||
|
|
||||||
| Что есть | Файл / артефакт | |
|
||||||
|---|---| |
|
||||||
| Flask-приложение (`create_app`) | `flask_app/app/__init__.py` | |
|
||||||
| Точка входа (dev / waitress) | `flask_app/run.py` | |
|
||||||
| `/health` | `flask_app/app/__init__.py` | |
|
||||||
| Пустой `index.html` | `flask_app/app/templates/index.html` | |
|
||||||
| Зависимости | `flask_app/requirements.txt` (Flask, python-dotenv, waitress) | |
|
||||||
| Docker Flask (порт 3107) | `flask_app/Dockerfile`, `docker-compose.dev.yml` (сервис `testing-flask`) | |
|
||||||
|
|
||||||
Всё остальное — **писать**. |
|
||||||
|
|
||||||
### План Этапа 1 (по спринтам) |
|
||||||
|
|
||||||
| Спринт | Цель | Артефакты | |
|
||||||
|---|---|---| |
|
||||||
| **E1.0 — База Flask-приложения** ✅ | БД-пул (SQLAlchemy + psycopg2), Flask sessions через `SECRET_KEY`, конфиг через `.env`, структура blueprint'ов, шаблон `base.html` в стиле кабинета, обработчики 404/500, `/health` с проверкой БД. **Без бизнес-логики.** | `flask_app/app/db.py`, `flask_app/app/__init__.py`, `flask_app/app/blueprints/main.py`, `flask_app/app/templates/{base,index,404,500}.html`, `flask_app/app/static/css/app.css`, обновлённые `requirements.txt` и `.env.example` | |
|
||||||
| **E1.1 — Auth и `/api/me`** ✅ | Flask sessions (signed cookie), bcrypt + Werkzeug (`werkzeug.security.check_password_hash`), опц. `HR_AUTH=1` с UPSERT в `clinic_tests.users` по `staff_id`. UI-страница `/login`, JSON-API `/api/auth/{login,logout,me}`, декораторы `login_required`/`require_role`, `current_user` доступен в шаблонах. | `flask_app/app/auth/{routes,services,decorators,hr_role}.py`, `flask_app/app/{config,messages}.py`, `flask_app/app/templates/auth/login.html`, обновлены `base.html`, `__init__.py`, `requirements.txt` (+`bcrypt`) | |
|
||||||
| **E1.2 — Тесты: список и редактор** ✅ | Перенесены 10 эндпоинтов из Express: `GET/POST /api/tests`, `GET /api/tests/:id/{summary,versions,editor}`, `POST /api/tests/:id/draft`, `POST /api/tests/:id/versions/:vid/activate`, `PATCH /api/tests/:id`, `POST /api/tests/:id/ai/{generate-test,generate-question}`. UI: `/tests` (каталог + создание), `/tests/:id/edit` (рабочий редактор с AI). Полная мобильная отполировка UX (4 аккордеона + fixed footer + drag-n-drop) — в **E1.7**. | `flask_app/app/services/{llm_client,draft_validator,ai_editor,test_access,test_chain,test_draft,editor_content}.py`, `flask_app/app/tests/{__init__,routes}.py`, `flask_app/app/templates/tests/{list,editor}.html`, `flask_app/app/static/js/editor.js`, обновлены `base.html`, `index.html`, `__init__.py` | |
|
||||||
| **E1.3 — Импорт документов** ✅ | `POST /api/tests/import/document` (PDF/DOCX/TXT/MD извлечение текста через `pypdf` и `python-docx`), интеграция с AI-генерацией черновика (`generation_for_import_document`), кнопка «Импорт документа» в AI-панели редактора, лимит 16 МБ. | `flask_app/app/services/{document_extract,document_gen}.py`, эндпоинт в `flask_app/app/tests/routes.py`, кнопка в `editor.html` + `editor.js`, `requirements.txt` (+`pypdf`, `python-docx`) | |
|
||||||
| **E1.4 — Назначение и прохождение** | Эндпоинты `assign`, `attempts/start`, `attempts/:id/play`, `attempts/:id/submit`, `attempts/:id/review`. Шаблоны: `assign.html`, `take_test.html`, `test_result.html`. | `flask_app/app/assignments/`, `flask_app/app/attempts/`, шаблоны | |
|
||||||
| **E1.5 — Трекер и настройки** | Трекер прохождений, настройки модуля (ключи AI и т.д.), цепочки тестов. Шаблоны `tracker.html`, `settings.html`. | `flask_app/app/tracker/`, `flask_app/app/settings/` | |
|
||||||
| **E1.6 — Cutover внутри репозитория** | `docker-compose.dev.yml` указывает на `flask_app/` как основной сервис; Nginx маршрутизирует `/api` и UI на новый Flask. Удаление `backend/` и `frontend/` отдельным PR. README → актуальные команды. | `docker-compose.dev.yml`, корневой `README.md`, `frontend/` и `backend/` удаляются | |
|
||||||
| **E1.7 — UX-полировка редактора** | Перевод базового редактора (E1.2) на мобильный UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md): 4 аккордеона (Шапка / AI-помощник / Вопросы / Действия), sticky footer, drag-n-drop вопросов, импорт документа в подразделе AI-блока (после E1.3). | `flask_app/app/templates/tests/editor.html`, `flask_app/app/static/js/editor.js`, новый `static/css/testing.css` | |
|
||||||
| **E1.8 — AI-функции v2** ✅ | `/settings` (статус ключа из ENV + ping), `POST /api/llm/ping`, на тесте — `ai/generate-by-title` (без сетки), `ai/check` (рецензия), `ai/improve` (массовое «было → стало» с чекбоксами). На уровне вопроса — уже есть `ai/generate-question` из E1.2 (создаёт вопрос или переформулирует). Все AI-эндпоинты унифицированы: при отсутствии ключа — `{ error, code, settingsUrl: '/settings' }`. | `flask_app/app/services/{ai_editor,llm_client}.py`, `flask_app/app/blueprints/settings.py`, `flask_app/app/templates/settings.html`, ссылка «Настройки» в `base.html`, обновлены `tests/routes.py`, `editor.html`, `editor.js` | |
|
||||||
|
|
||||||
### Критерии готовности Этапа 1 |
|
||||||
|
|
||||||
- Все 22 эндпоинта Express (см. [migration-final-inventory.md](migration-final-inventory.md)) реализованы в `flask_app/` и проходят smoke-тесты. |
|
||||||
- Все экраны мобильного UX из [Спринта 3](СПРИНТЫ_МОБИЛЬНЫЙ_ДИЗАЙН.md) воспроизведены в Jinja. |
|
||||||
- В `docker-compose.dev.yml` остался **один** сервис приложения (Flask). `backend/` и `frontend/` удалены или перенесены в ветку `legacy/clinic-tests-node`. |
|
||||||
- БД — по-прежнему `clinic_tests`, схема не менялась. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Этап 2 (на будущее, без сроков) — слияние с `tgFlaskForm` |
|
||||||
|
|
||||||
Когда заказчик решит «вот теперь объединяем» — **вся** разработанная Flask-логика и шаблоны легко переносятся в `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` и `templates/cabinet/testing/`, потому что **стек уже совпадает**. Это и есть смысл Этапа 1. |
|
||||||
|
|
||||||
Что нужно сделать в Этапе 2 (план — в [migration-to-tgflaskform.md](migration-to-tgflaskform.md)): |
|
||||||
|
|
||||||
1. Перенести код Flask-приложения как blueprint в `tgFlaskForm`. |
|
||||||
2. Адаптировать модели под существующие `Testing*` таблицы (`hr_bot_test.testing_*`). |
|
||||||
3. Перевести авторизацию на сессии общего HR-кабинета. |
|
||||||
4. Прогнать ETL `clinic_tests → hr_bot_test` (скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py` уже готов: 437 строк, режимы `--dry-run`/`--apply`, идемпотентность через `_clinic_tests_migration_map`). |
|
||||||
5. Cutover (если к тому моменту появятся реальные пользователи; сейчас TestingWebApp — песочница для тестировщиков). |
|
||||||
|
|
||||||
**Решения, которые относятся к Этапу 2** (зафиксированы заранее, чтобы потом не переоткрывать): |
|
||||||
|
|
||||||
- **`test_assignments`:** переносим 1:1, дописывая отдельный блок в ETL (сейчас скрипт переносит только пары через попытки). |
|
||||||
- **Пользователи без `staff_id`:** игнорируем с WARN; по договорённости настоящие пользователи всегда привязаны к `staff_members`. |
|
||||||
- **Cutover / окно простоя:** не нужны, пока TestingWebApp остаётся песочницей. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Журнал |
|
||||||
|
|
||||||
| Дата | Что сделано | |
|
||||||
|------|-------------| |
|
||||||
| 2026-04-27 | Спринт 0 («инвентаризация» в старой нумерации) закрыт: артефакт [migration-final-inventory.md](migration-final-inventory.md) — карта 22 эндпоинтов Express, БД, env, зависимости. | |
|
||||||
| 2026-04-27 | Принято решение: **сценарий B + b1** (полный переезд в HR-кабинет). | |
|
||||||
| 2026-04-27 | **Курс скорректирован:** Этап 1 = унификация стека внутри TestingWebApp (Express → Flask + React → Jinja, БД остаётся `clinic_tests`). Этап 2 = слияние с `tgFlaskForm` — на будущее. ETL и удаление React переходят в Этап 2. Документы переписаны под двух-этапную картину. Эксперимент с правкой `tgFlaskForm/cabinet/testing/test_editor.html` (ветка `feat/testing-editor-jinja-redesign`) откачен и не оставил следов в HR-репо. | |
|
||||||
| 2026-04-27 | **E1.8 закрыт.** AI v2: страница `/settings` (статус ключа из ENV, `Проверить подключение` → `POST /api/llm/ping`). Три новых эндпоинта на тесте: `POST /api/tests/<id>/ai/generate-by-title` (генерация только по названию + опции «сколько вопросов / сколько вариантов»), `POST /api/tests/<id>/ai/check` (рецензия: вердикт + разделы рекомендаций), `POST /api/tests/<id>/ai/improve` (массовое «было → стало» с проверкой неизменности сетки). UI редактора: кнопки «Сгенерировать по названию», «Проверить тест», «Улучшить тест»; общий `<dialog>` для модалок check/improve; чекбоксы в improve позволяют применять изменения по выбранным вопросам. Все AI-эндпоинты унифицированы: при отсутствии ключа возвращают `{ error, code, settingsUrl: '/settings' }` 502 — фронт предлагает открыть Настройки. | |
|
||||||
| 2026-04-27 | **E1.3 закрыт.** Импорт документов: `app/services/document_extract.py` (PDF через `pypdf`, DOCX через `python-docx`, TXT/MD), `app/services/document_gen.py` (`generation_for_import_document` — извлекает текст, при наличии LLM-ключа просит модель собрать draft через `validate_and_normalize_draft`), эндпоинт `POST /api/tests/import/document` под `@login_required` с лимитом 16 МБ. UI редактора: кнопка «Импорт документа» в AI-панели, после загрузки — confirm с предложением применить черновик; если ключа нет — алерт с превью текста. В `requirements.txt` добавлены `pypdf>=4` и `python-docx>=1.1`. | |
|
||||||
| 2026-04-27 | **E1.2 закрыт.** Перенесены `backend/src/routes/tests.js` (только E1.2-эндпоинты — без `import/document`/`assign`/`attempts`/`chain-info`, они уйдут в E1.3-E1.5) + сервисы `testDraftService.js`, `testAccessService.js`, `testChainService.js`, `aiEditorService.js`, `documentGenService.js` (только парсер JSON и валидатор draft), `llmClient.js`, `getEditorContent` из `testAttemptService.js`. Эндпоинты регистрируются в blueprint `tests`. AI-генерация: `parseAndValidateShape` 1:1, ошибки LLM (`llm_*`-коды) пробрасываются как 502 с кодом в JSON. UI: каталог тестов с кнопкой создания (модалка `<dialog>`) и рабочий редактор (inline-поля, AI-кнопки «весь тест» / «один вопрос», добавление/удаление/перемещение вопросов и вариантов, сохранение черновика, переключатель «Цепочка активна»). Полный мобильный UX редактора (аккордеоны+fixed footer+drag-n-drop из Спринта 3) вынесен в новый спринт **E1.7** — этот PR закрывает функциональность, не дизайн. | |
|
||||||
| 2026-04-27 | **E1.1 закрыт.** Перенесены `backend/src/routes/auth.js` + `middleware/auth.js` + `utils/{auth,werkzeugPassword,hrRoleMap}.js` в `flask_app/app/auth/`. Решение: Flask sessions (signed cookie) вместо JWT, как договорились (вариант A). Поддерживаются bcrypt-хеши (`$2*`) и Werkzeug-хеши (`scrypt:`/`pbkdf2:`). Эндпоинты — те же пути, что в Express: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` (отдаёт `user`, `devUi`, `assignmentUi`). Дополнительно — HTML-страница `/login` (форма) и `POST /logout`. Декораторы: `@login_required`, `@require_role(...)`. В шаблонах доступны `current_user`, `hr_auth_enabled`, `dev_ui`, `assignment_ui`. Защита от open-redirect в параметре `?next=`. Главная (`/`) теперь требует логин. | |
|
||||||
| 2026-04-27 | **E1.0 закрыт.** В `flask_app/`: SQLAlchemy/psycopg2-пул в стиле `tgFlaskForm/db/session.py` (`app/db.py`, основная БД `clinic_tests` + опциональная HR-БД при `HR_AUTH=1`), фабрика `create_app` с регистрацией blueprint'ов, обработчиками 404/500 и Flask sessions, главный blueprint `main` с `/` и `/health` (smoke-проверка БД), `base.html` в стиле кабинета HR (Tailwind CDN + Manrope + Material Symbols, без зависимостей от HR-репо), шаблоны `index/404/500`, минимальный `static/css/app.css`. Бизнес-логика **не** добавлялась. | |
|
||||||
@ -1,87 +0,0 @@ |
|||||||
# Перенос тестирования на кабинет 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) | |
|
||||||
@ -1,102 +0,0 @@ |
|||||||
# Этап 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). Это вспомогательный документ, не часть этапов миграции. |
|
||||||
@ -1,191 +0,0 @@ |
|||||||
# Производительность страниц Flask (кабинет / мини-приложение): рабочий документ |
|
||||||
|
|
||||||
Документ написан так, чтобы **человек без контекста проекта** мог по нему понять: *что за система, где код, что именно оптимизировать, в каком порядке и как понять, что задача сделана*. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 1. Для кого и зачем этот файл |
|
||||||
|
|
||||||
- **Аудитория:** ты сам через полгода, новый разработчик, DevOps, тимлид на планировании. |
|
||||||
- **Проблема от пользователей:** «страницы мини-приложения на Flask грузятся долго». |
|
||||||
- **Цель документа:** не угадать решение («перепишем на React»), а **зафиксировать процесс**: сначала измерить и локализовать узкое место, потом применить исправления с наибольшим эффектом при наименьшем риске. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 2. Где живёт проект (карта репозитория) |
|
||||||
|
|
||||||
Рабочая копия — монорепозиторий **`HR`** (корень: `ClinicProjects/HR` или аналог). Для задачи производительности важны в первую очередь два контура: |
|
||||||
|
|
||||||
| Контур | Путь в репозитории | Назначение | |
|
||||||
|--------|-------------------|------------| |
|
||||||
| **Основной веб-кабинет HR** | `HR_TG_Bot/tgFlaskForm/` | Flask-приложение: авторизация, кабинет, разделы в т.ч. **тестирование сотрудников**. Именно сюда чаще всего относят жалобы «мини-приложение / кабинет на Flask». | |
|
||||||
| **Отдельный Flask-скелет под тестирование** | `TestingWebApp/flask_app/` | Упрощённое приложение того же стека (переходный контур, Docker `testing-flask`, порт **3107**). Может быть медленным по тем же причинам (БД, шаблоны, отсутствие кэша статики), но **это не обязательно тот же инстанс**, что видят пользователи в проде. | |
|
||||||
|
|
||||||
Связанные по смыслу документы (миграция данных, две БД): |
|
||||||
|
|
||||||
- `TestingWebApp/docs/migration-to-tgflaskform.md` — технический план. |
|
||||||
- `TestingWebApp/docs/migration-to-tgflaskform-plain.md` — коротко «для людей». |
|
||||||
|
|
||||||
**Важно:** жалоба «долго грузится» может относиться к: |
|
||||||
|
|
||||||
1. **Веб-кабинет в браузере** (`tgFlaskForm`, типичный порт локально **3104** в `web_run.py`). |
|
||||||
2. **Встроенный WebView в мини-приложении** (Telegram MAX и т.п.) — тот же HTML с того же хоста, но **другая сеть, кэш, DNS, TLS**; воспроизведение обязательно на целевом клиенте. |
|
||||||
3. **Переходный контур** `TestingWebApp` на **3107** — проверять отдельно, если пользователи реально ходят туда. |
|
||||||
|
|
||||||
Перед оптимизацией **уточнить URL/контур** у тех, кто жалуется. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 3. Что такое «страница грузится долго» в технических терминах |
|
||||||
|
|
||||||
Раздели время на части (это основа всей работы): |
|
||||||
|
|
||||||
1. **Сеть до сервера** — DNS, TCP/TLS, RTT, прокси (nginx перед Flask). |
|
||||||
2. **Время до первого байта (TTFB)** — всё, что происходит на сервере до начала ответа: middleware, сессия, запросы к БД, рендер Jinja2, формирование заголовков. |
|
||||||
3. **Загрузка тела ответа** — размер HTML, сжатие (gzip/brotli). |
|
||||||
4. **Параллельная загрузка подресурсов** — CSS, JS, шрифты, картинки: их число, размер, кэширование (`Cache-Control`), HTTP/2. |
|
||||||
5. **Выполнение JS на клиенте** — если на странице тяжёлый скрипт; для классического SSR-кабинета часто вторично по сравнению с TTFB. |
|
||||||
|
|
||||||
**Твоя первая задача** — для 2–3 типичных страниц (логин после редиректа, дашборд тестирования, список тестов, прохождение теста) записать: **TTFB**, **DOMContentLoaded**, **полный LCP** (или хотя бы «визуально готово»). Без этого нельзя честно выбрать между «чиним SQL» и «чиним статику». |
|
||||||
|
|
||||||
Инструменты: |
|
||||||
|
|
||||||
- Chrome DevTools → **Network** (колонка Time, размер, waterfall), **Performance**. |
|
||||||
- На сервере: логирование длительности запроса (middleware или reverse proxy `request_time` в nginx). |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 4. Как устроена загрузка страницы в `tgFlaskForm` (ментальная модель) |
|
||||||
|
|
||||||
Упрощённая цепочка для защищённого маршрута, например дашборда тестирования: |
|
||||||
|
|
||||||
1. Браузер запрашивает URL вида **`/cabinet/testing/`** (blueprint в `webApp/interfaces/testing/__init__.py`, префикс `/cabinet/testing`). |
|
||||||
2. Срабатывают глобальные хуки Flask (в т.ч. **cabinet access gate** в `webApp/auth.py`: `register_cabinet_access_gate` — проверка пути, сессии, статуса «Работает»). |
|
||||||
3. Декоратор **`@login_required`** на view: редирект на `/login` или вызов функции. |
|
||||||
4. View (например `routes_dashboard.py`) вызывает функции из **`db/queries/testing_queries.py`** и др., собирает контекст и вызывает **`render_template(...)`**. |
|
||||||
5. Jinja2 собирает HTML из шаблонов в `webApp/templates/` (часто с `extends` / `include` — чем больше вложенность и данных в контексте, тем дольше CPU на рендер). |
|
||||||
6. Ответ уходит клиенту; дальше грузятся статические файлы с `/static/...`. |
|
||||||
|
|
||||||
Узкое место может быть на **любом** шаге; чаще всего в таких приложениях — **шаг 4 (БД + много мелких запросов)** и **шаг 5 (большой шаблон)**. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 5. Гипотезы, специфичные для этого кода (куда смотреть первым делом) |
|
||||||
|
|
||||||
Ниже — не обвинение кода, а **чек-лист для проверки** после замеров. |
|
||||||
|
|
||||||
### 5.1. Создание движка БД на каждый вызов сессии |
|
||||||
|
|
||||||
Файл: `HR_TG_Bot/tgFlaskForm/db/session.py`. |
|
||||||
|
|
||||||
Раньше `get_engine()` на каждом вызове делал `create_engine(...)` — новый пул и большие накладные расходы при десятках `get_session()` из `db/queries/*.py` (в т.ч. **`testing_queries.py`**). |
|
||||||
|
|
||||||
**Сделано (код):** в `db/session.py` один **потокобезопасный** engine на процесс и один переиспользуемый `sessionmaker`; `get_session()` по-прежнему возвращает новую ORM-сессию, но поверх общего пула. |
|
||||||
|
|
||||||
**Дальше:** при необходимости сокращать число **отдельных** сессий на один HTTP-запрос (§5.2) — это отдельная оптимизация. |
|
||||||
|
|
||||||
### 5.2. Много открытий/закрытий сессий и запросов на одну страницу |
|
||||||
|
|
||||||
Паттерн в `testing_queries.py`: почти каждая функция делает `s = get_session()`, `try/finally: s.close()`. Одна страница может дернуть **несколько** таких функций подряд → несколько раундов к БД. |
|
||||||
|
|
||||||
**Что сделать:** для «тяжёлых» страниц — либо **одна сессия на request** и передача её вниз, либо **один агрегирующий запрос** вместо N мелких (устранение N+1). Конкретные места — смотреть по trace конкретного URL. |
|
||||||
|
|
||||||
### 5.3. Декораторы и before_request |
|
||||||
|
|
||||||
`login_required` и `cabinet_employment_ok_from_session()` в основном опираются на **сессию**, но gate и другие хуки могут добавлять логику. Если туда когда-нибудь добавят тяжёлые проверки в БД на **каждый** запрос — это сразу ударит по TTFB. |
|
||||||
|
|
||||||
**Что сделать:** убедиться, что на горячем пути нет лишних запросов к БД без необходимости. |
|
||||||
|
|
||||||
### 5.4. Шаблоны и статика |
|
||||||
|
|
||||||
- Большие базовые layout’ы, много `include`, тяжёлые циклы в Jinja — растёт CPU на рендер. |
|
||||||
- Статика без длинного кэша — каждый переход визуально «тормозит». |
|
||||||
|
|
||||||
**Что сделать:** Network → сколько запросов к `/static`, какие размеры; для продакшена — заголовки кэша и сжатие на nginx (если nginx есть в цепочке — см. `HR_TG_Bot/docker-compose*.yml` и свою прод-конфигурацию). |
|
||||||
|
|
||||||
### 5.5. Окружение |
|
||||||
|
|
||||||
- `web_run.py`: в non-production используется встроенный сервер Flask; для нагрузочного теста ближе к прод — **waitress** / gunicorn (как в `TestingWebApp/flask_app/run.py` через `WEB_USE_WAITRESS`). |
|
||||||
- Сравнение «локально быстро, у пользователей медленно» — почти всегда **сеть, БД на другом хосте, холодный пул, отсутствие индексов на прод-данных**. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 6. План работы (что делать по шагам) |
|
||||||
|
|
||||||
### Фаза 0 — уточнение (полдня максимум) |
|
||||||
|
|
||||||
- [ ] Точный **URL/продукт** (кабинет HR vs TestingWebApp:3107 vs мини-app WebView). |
|
||||||
- [ ] **Роль пользователя** и сценарий (первый заход, каждый клик, только раздел тестирования). |
|
||||||
- [ ] Есть ли **nginx / CDN** перед приложением. |
|
||||||
|
|
||||||
### Фаза 1 — измерение (обязательно) |
|
||||||
|
|
||||||
- [ ] Зафиксировать 3–5 URL и для каждого: TTFB, размер HTML, число запросов, суммарный вес. |
|
||||||
- [ ] На сервере: время обработки запроса (middleware: `before_request` timestamp vs `after_request`). |
|
||||||
- [ ] Для самого медленного URL: **список вызовов к БД** (SQLAlchemy events, логирование, или APM, если есть). |
|
||||||
|
|
||||||
**Выход фазы:** одно предложение вида: «узкое место — TTFB из-за БД» или «узкое месте — 40 запросов к статике без кэша». |
|
||||||
|
|
||||||
### Фаза 2 — правки по приоритету (типичный порядок) |
|
||||||
|
|
||||||
1. **Инфраструктура БД:** один engine на процесс; пул; при необходимости индексы (после анализа `EXPLAIN` самых тяжёлых запросов). |
|
||||||
2. **Сократить число round-trips к БД** на страницу: объединение запросов, eager loading где уместно, кэш редко меняющихся справочников (с инвалидацией или коротким TTL). |
|
||||||
3. **Шаблоны:** убрать лишние данные из контекста; упростить самые тяжёлые `include`. |
|
||||||
4. **Статика:** fingerprint + `Cache-Control: immutable` для бандлов; минификация; не тянуть огромные библиотеки на каждую страницу без нужды. |
|
||||||
5. **Прод-сервер приложений:** waitress/gunicorn, адекватное число воркеров за reverse proxy. |
|
||||||
|
|
||||||
### Фаза 3 — если «всё ещё медленно именно при переходах между страницами» |
|
||||||
|
|
||||||
Это уже про **полную перезагрузку HTML**, а не про «Flask медленный»: |
|
||||||
|
|
||||||
- Вариант **A:** [HTMX](https://htmx.org/) / **Turbo** — сервер по-прежнему отдаёт HTML, обновляются фрагменты; стек остаётся Python + Jinja. |
|
||||||
- Вариант **B:** точечный **React/Vite** только для тяжёлого экрана (остальной кабинет не трогать) — выше стоимость сопровождения. |
|
||||||
|
|
||||||
Выбор между A и B — после фазы 1: если TTFB уже низкий, а больно от полной перезагрузки — имеет смысл A/B; если узкое место всё ещё сервер — сначала дожать фазу 2. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 7. Критерии готовности (Definition of Done) |
|
||||||
|
|
||||||
Задачу по производительности можно закрыть, когда: |
|
||||||
|
|
||||||
1. Есть **замеры до/после** по тем же URL и тем же окружению (или согласованная методика). |
|
||||||
2. Задокументировано **узкое место** и **что изменено** (1–2 абзаца в changelog или в этом файле внизу секция «Итог»). |
|
||||||
3. Для пользовательского сценария выполняется согласованный **SLO** (например: TTFB p95 < X ms, полная загрузка ключевой страницы < Y s на 4G) — пороги задаёт продукт/команда, не этот документ. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 8. Риски и что не делать |
|
||||||
|
|
||||||
- **Не** менять стек на SPA «с нуля» без измерений — высокий риск и долгий срок при том, что проблема может быть в пуле БД или кэше статики. |
|
||||||
- **Не** оптимизировать только локально на пустой БД — планы запросов на прод-объёме другие. |
|
||||||
- **Не** кэшировать персональные страницы на CDN без понимания заголовков и кук — риск утечки данных между пользователями. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 9. Быстрый указатель файлов |
|
||||||
|
|
||||||
| Тема | Путь | |
|
||||||
|------|------| |
|
||||||
| Точка входа веба | `HR_TG_Bot/tgFlaskForm/web_run.py` | |
|
||||||
| Регистрация приложения / blueprints | `HR_TG_Bot/tgFlaskForm/webApp/__init__.py` (и связанные модули) | |
|
||||||
| Модуль тестирования (маршруты) | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/routes_*.py` | |
|
||||||
| Запросы к БД тестирования | `HR_TG_Bot/tgFlaskForm/db/queries/testing_queries.py` | |
|
||||||
| Сессия и engine | `HR_TG_Bot/tgFlaskForm/db/session.py` | |
|
||||||
| Авторизация и gate | `HR_TG_Bot/tgFlaskForm/webApp/auth.py` | |
|
||||||
| Шаблоны | `HR_TG_Bot/tgFlaskForm/webApp/templates/` | |
|
||||||
| Переходный Flask + waitress | `TestingWebApp/flask_app/run.py`, `TestingWebApp/flask_app/app/` | |
|
||||||
| Docker dev (по умолчанию 3107 legacy) | `TestingWebApp/docker-compose.dev.yml` | |
|
||||||
| Docker dev кабинета | `HR_TG_Bot/docker-compose.dev.yml` | |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## 10. Секция «Итог» (заполнять по мере работы) |
|
||||||
|
|
||||||
| Дата | Контур | Узкое место | Что сделано | Метрика до → после | |
|
||||||
|------|--------|-------------|-------------|-------------------| |
|
||||||
| 2026-04-27 | `tgFlaskForm` | Новый SQLAlchemy engine на каждый `get_session()` | Singleton `get_engine()` + кэш `sessionmaker` в `db/session.py` | _замерить на стенде_ | |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
*Документ создан как рабочая инструкция по задаче «медленная загрузка страниц Flask». Обновляй таблицу в §10 и при необходимости добавляй ссылки на PR/коммиты.* |
|
||||||
@ -1,93 +0,0 @@ |
|||||||
# Декомпозиция доработки (по ТЗ [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 | |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
# Идеи и пожелания по доработкам (для согласования с заказчиком) |
|
||||||
|
|
||||||
*Язык простой, без жаргона разработки. Сюда попадает всё, что всплыло в обсуждениях и ещё не вошло в жёсткое ТЗ.* |
|
||||||
|
|
||||||
**Как пользоваться:** приоритеты и «да/нет» фиксируем отдельно; пункты **не** удаляем — переносим в раздел **Решено** с кратким итогом, если идея закрыта или отклонена. |
|
||||||
**Что уже в продукте (кратко):** [../PROJECT_STATUS.md](../PROJECT_STATUS.md). |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## На рассмотрении |
|
||||||
|
|
||||||
| № | Суть (что даст клинике) | Примечание | |
|
||||||
|---|-------------------------|------------| |
|
||||||
| 1 | **Напоминания** о сроке теста в мессенджере (когда срок близок или прошёл) | Связь с будущим HR-приложением в MAX; не путать с самим прохождением теста — только напоминание зайти в кабинет. | |
|
||||||
| 2 | **Один раз скачать** свод по отделу или по клинике в **таблицу** (для руководителя), без «технических» деталей | Уточнить, нужен ли **Excel** / PDF и какие столбцы обязательны. | |
|
||||||
| 3 | **Памятка рядом с тестом** — кратко: зачем тест, на что обратить внимание (текст от автора) | Улучшает вовлечение; не подменяет **описание** теста, если оно уже есть. | |
|
||||||
| 4 | **Сравнение** «как сотрудник ответил в прошлый раз» с текущим прохождением | Для **повторных** тестов по той же теме; важна приватность и согласие кадров. | |
|
||||||
| 5 | **Крупный шрифт** и **контраст** в режиме «стресс/смена» для сотрудников, много работающих в перчатках с экраном | Доступность; опциональная **тема** в настройках профиля. | |
|
||||||
| 6 | **Печатная** версия **итога** (сдал/не сдал) для **личного** дела — один лист, без лишнего | Не путать с полноценной «выгрузкой для 1С»; это про человеко-понятный **итог** для сотрудника. | |
|
||||||
| 7 | **Повтор** одного вопроса в конце теста — «самопроверка» (опционально, как у автора) | Снижает нервозность; выключаемо **на уровне** теста, чтобы на экзамене не мешать. | |
|
||||||
| 8 | **Аудит:** кто из администраторов менял активную **версию** теста и когда | Для **разборов** при споре «кому показывали старую/новую редакцию». | |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
## Решено или «не делаем в этой волне» |
|
||||||
|
|
||||||
| № | Суть | Итог | |
|
||||||
|---|------|------| |
|
||||||
| — | *Пока пусто* | | |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
*Обновляйте дату: **2026-04-23** (создание файла).* |
|
||||||
@ -1,95 +0,0 @@ |
|||||||
# Единый журнал проверок по спринтам |
|
||||||
|
|
||||||
**Для кого этот документ.** Часть проверок — на стороне разработки (раздел 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. |
|
||||||
@ -1,109 +0,0 @@ |
|||||||
# Карта задач: 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). |
|
||||||
@ -1,68 +0,0 @@ |
|||||||
# Спринт 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 Негатив |
|
||||||
|
|
||||||
- [ ] Попытка не может «сломать» цепочку (ошибки пользователю понятны) |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
@ -1,60 +0,0 @@ |
|||||||
# Спринт 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) |
|
||||||
@ -1,73 +0,0 @@ |
|||||||
# Спринт 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:** дата __________, комментарий _________________________ |
|
||||||
@ -1,65 +0,0 @@ |
|||||||
# Спринт 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`. |
|
||||||
@ -1,264 +0,0 @@ |
|||||||
# Техническое задание на доработку |
|
||||||
|
|
||||||
**Система тестирования сотрудников клиники** |
|
||||||
|
|
||||||
| Поле | Значение | |
|
||||||
| --- | --- | |
|
||||||
| Версия | 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/. |
|
||||||
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 21 KiB |
@ -1,158 +0,0 @@ |
|||||||
# Предложение по редизайну страницы «Создание теста» |
|
||||||
|
|
||||||
## Актуализация (кабинет `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 вариантов» — устраивают или нужны другие? |
|
||||||
@ -1,56 +0,0 @@ |
|||||||
# Кабинет тестов: коротко, как пользоваться |
|
||||||
|
|
||||||
*Для врачей, заведующих, кураторов — без IT-терминов. |
|
||||||
Иллюстрации: [images/cabinet-ui/](images/cabinet-ui/) (схемы-заглушки, можно заменить на скриншоты, см. `README` в той папке).* |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
### 1. Список тестов |
|
||||||
|
|
||||||
Все тесты, к которым у вас есть доступ. **Название** (слева) ведёт в **редактирование** или просмотр, **«Пройти»** (справа) — **сдать** тест, если вам тест **назначили** или открыт самопроход. Редактирование — не у всех ролей. |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
### 2. О тесте |
|
||||||
|
|
||||||
Название, **описание** для коллег, **порог зачёта** (%). «Паспорт» теста: **что проверяете** и **с какой планкой** зачёт/незачёт. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
### 3. Вопросы |
|
||||||
|
|
||||||
**Вопросы и варианты** пишите здесь. Слева от варианта — **верные** отметки: один вариант как контрольный; несколько верных — чекбокс **«Несколько верных ответов»**. |
|
||||||
|
|
||||||
- **+ вопрос** / **+ вариант** — добавить. **Крестик** у варианта — убрать лишний ответ. |
|
||||||
- **Документ в вопросы** — при необходимости загрузить файл (PDF, Word, текст) и вставить в черновик; не обязательно, если ввели всё вручную. |
|
||||||
- **ИИ** (если включён) — подсказка, не готовый клинический документ: **проверьте и исправьте** перед публикацией. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
### 4. Сохранить |
|
||||||
|
|
||||||
**«Сохранить черновик»** (часто **внизу** на телефоне) — чтобы не потерять правки. **«К списку»** — выход; если **уже сохранялись** — данные в черновике записаны. |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
### 5. История |
|
||||||
|
|
||||||
- **Версии** — когда и как менялся тест. Актуальная отмечена. **«Сделать активной»** — редко, обычно согласуя с IT/методистом. |
|
||||||
- **Прохождения** — кто уже **сдавал**; **«Разбор»** — ответы по вопросам (если вам **открыт** доступ). |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
### 6. Показ в каталоге |
|
||||||
|
|
||||||
- **Видимость** — показать в **общем** списке тестов или **скрыть** (тест **не** удаляется, просто **не** светится в ленте). «Старые» **ссылки** у кого-то **могут** ещё открываться, если **переадресовали** вручную. |
|
||||||
- **Кому выдать** (если раздел есть) — **назначение** сотрудникам: **поиск, фильтры, галочки**; **«Выбрать всех»** — только в **текущем** отфильтрованном списке; затем **«Назначить выбранных»**. Это **про людей**, не про редактуру вопросов. |
|
||||||
|
|
||||||
 |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
**В одном движении:** написали **вопросы** → **«Сохранить»** → при **необходимости** **показали** в списке и/или **кому-то** **выдали** тест. Остальное — **по ситуации**. |
|
||||||
@ -1,72 +0,0 @@ |
|||||||
# Рекомендации 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. **Стратегический:** хлебные крошки и внутренняя навигация по разделу; масштабирование списка назначений; пустые состояния; продуктовая аналитика (например, доходят ли авторы до «Показ в каталоге»). |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
*Документ можно дополнять по мере внедрения и новых скринов.* |
|
||||||
@ -1,67 +0,0 @@ |
|||||||
# Спринты: мобильный 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) |
|
||||||
@ -1,298 +0,0 @@ |
|||||||
# Словарь терминов проектирования |
|
||||||
|
|
||||||
**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-дизайну. |
|
||||||
|
|
||||||
--- |
|
||||||
|
|
||||||
*— Справочник можно дополнять по мере появления новых терминов —* |
|
||||||
@ -1,9 +0,0 @@ |
|||||||
# Пошаговая спецификация (`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). |
|
||||||