Compare commits
No commits in common. '1c4dacbc85ec47a9bf40de2329f8c05779017732' and 'b3e3757a9213e0dd410522975426b25bea68c4f5' have entirely different histories.
1c4dacbc85
...
b3e3757a92
57 changed files with 568 additions and 6118 deletions
Binary file not shown.
@ -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,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,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-сервис на порту 3108 | `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,102 +1,175 @@ |
|||||||
# Этап 2 (на будущее) — слияние TestingWebApp с `HR_TG_Bot/tgFlaskForm` |
# Перенос 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-to-tgflaskform-plain.md](migration-to-tgflaskform-plain.md). |
**Назначение документа:** зафиксировать целевую архитектуру, **спринтовый план** доведения функциональности до паритета и **порядок миграции данных** из отдельного приложения (`Express` + `React` + БД `clinic_tests`) в кабинет **`tgFlaskForm`** (Flask, шаблоны, общая БД `hr_bot_test`, таблицы `testing_*`). |
||||||
|
|
||||||
**Связано:** [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`. |
**Связанные материалы:** [PROJECT_STATUS.md](PROJECT_STATUS.md), [README.md](../README.md), [TEST_TABLES_ANALYSIS.md](TEST_TABLES_ANALYSIS.md), код модуля в репозитории HR: `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/`, модели: `HR_TG_Bot/tgFlaskForm/db/models.py`. |
||||||
|
**Каркас нового контура в этом репозитории:** [../flask_app/README.md](../flask_app/README.md). |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## 0. Предусловие — Этап 1 закрыт |
## 0. Стратегия переходного периода (отдельное приложение, тот же стек) |
||||||
|
|
||||||
К моменту, когда этот документ берётся в работу, в TestingWebApp **уже** должно быть: |
**Решение:** переписывание с Node/React на **тот же стек, что у мини-приложения и кабинета HR** — Python 3, **Flask**, шаблоны (Jinja2), статический JS, работа с PostgreSQL в духе `tgFlaskForm`. При этом сервис **пока живёт отдельно**: свой процесс, свой URL/порт, **не** обязан совпадать с деплоем полного `HR_TG_Bot/tgFlaskForm`. |
||||||
|
|
||||||
- Бэкенд переписан с Express на Flask внутри [`flask_app/`](../flask_app/), все 22 эндпоинта работают. |
**Зачем так:** быстрее выйти на паритет по UX и данным, **без** риска «большого взрыва» в едином кабинете; позже либо встраиваете модуль в кабинет (общий `webApp`), либо оставляете отдельный вход — стек уже совпадает. |
||||||
- Фронтенд переписан с React на Jinja-шаблоны в `flask_app/app/templates/`. |
|
||||||
- БД — по-прежнему `clinic_tests`, схема не менялась. |
|
||||||
- В репозитории остался один сервис приложения. |
|
||||||
|
|
||||||
Если что-то из этого ещё не готово — Этап 2 не начинается. |
**Обязательно зафиксировать продуктово:** |
||||||
|
|
||||||
|
| Вопрос | Рекомендация | |
||||||
|
|--------|----------------| |
||||||
|
| Где **пишут** тесты и попытки, пока два контура? | Один «канонический» контур на запись; второй read-only или только пилот — иначе разъедутся данные. | |
||||||
|
| База | Либо по-прежнему **`clinic_tests`** в новом Flask до ETL, либо сразу **`hr_bot_test`** + `testing_*` (как в кабинете) — одно из двух, не смешивать без миграции. | |
||||||
|
| ETL | Скрипт `HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`: бэкап → `--dry-run` → проверка на копии → короткое окно → `--apply`. | |
||||||
|
|
||||||
|
**Технически:** в репозитории TestingWebApp заведён каталог **`flask_app/`** — минимальное приложение-заготовка; развитие переноса идёт там (или копированием готовых модулей из `HR_TG_Bot/tgFlaskForm`). |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## 1. Что меняется при слиянии |
## 1. Зачем переносить |
||||||
|
|
||||||
| Аспект | После Этапа 1 (отдельный сервис) | После Этапа 2 (часть HR-кабинета) | |
| Аспект | Сейчас (TestingWebApp) | Цель (tgFlaskForm) | |
||||||
|---|---|---| |
|--------|------------------------|---------------------| |
||||||
| Репозиторий | `TestingWebApp/flask_app/` | `HR_TG_Bot/tgFlaskForm/webApp/interfaces/testing/` | |
| Стек | Node.js (Express), React (Vite), отдельный деплой | Python 3, Flask, Jinja/PyPug, статический JS в шаблонах — **единый кабинет** с остальным HR | |
||||||
| Деплой | Свой Docker-сервис, свой URL/порт | Часть основного `tgFlaskForm`, общий URL `/cabinet/testing/...` | |
| База | PostgreSQL, схема `clinic_tests`, UUID-ключи, локальные `users` | Та же инфраструктура Postgres, БД **`hr_bot_test`**, целочисленные `id`, связь с **`staff_members`** | |
||||||
| БД | `clinic_tests`, UUID | `hr_bot_test`, integer ID, схема `testing_*` | |
| Авторизация | Собственные логин/JWT + опция `HR_AUTH` | Сессии кабинета, RBAC через HR (`testing_head_positions`, флаги HR и т.д.) | |
||||||
| Авторизация | JWT/bcrypt + опциональный `HR_AUTH` | Сессии общего HR-кабинета, привязка к `staff_members` | |
| Модуль тестирования | Полный цикл в одном репозитории | В **`tgFlaskForm` уже есть** blueprint `/cabinet/testing`, запросы в `db/queries/testing_queries.py` — задача переноса = **паритет фич + данные + вывод из эксплуатации** старого UI/API | |
||||||
| Модели | Свои (как в 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/` | |
Итог после **полной** консолидации: один вход для сотрудника, одна БД «истины» по людям, меньше дублирования интеграций с HR. На переходном этапе допустим **отдельный** Flask-инстанс с тем же стеком (см. §0). |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## 2. План Этапа 2 (по спринтам) |
## 2. Исходный и целевой стек (кратко) |
||||||
|
|
||||||
|
**Исходный (TestingWebApp):** |
||||||
|
|
||||||
### E2.0 — Сверка кода и моделей |
- Backend: `express`, `pg`, миграции SQL в `backend/src/db/migrations/`. |
||||||
|
- Frontend: `react`, `react-router-dom`, `vite`. |
||||||
|
- Данные: цепочки `tests` → `test_versions` → `questions` → `answer_options`; назначения с `test_assignment_targets` (отдел/пользователь); попытки `test_attempts`, ответы `user_answers` (массив UUID вариантов). |
||||||
|
|
||||||
- Сравнить структуру `flask_app/` (после Этапа 1) с уже существующим модулем `tgFlaskForm/webApp/interfaces/testing/`. Где функции называются иначе — выбрать одно имя. |
**Целевой (`HR_TG_Bot/tgFlaskForm`) и отдельный контур в этом репозитории (`flask_app/`):** |
||||||
- Сверить модели: `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` на цепочке или на версии). |
- Приложение: `Flask`, точка входа `web_run.py`, фабрика/приложение `webApp/__init__.py`. |
||||||
|
- Шаблоны: `webApp/templates/cabinet/testing/*.html`, клиентский JS в `templates/static/js/cabinet/testing_*`. |
||||||
|
- ORM/запросы: SQLAlchemy-модели `TestingTest`, `TestingTestVersion`, `TestingQuestion`, `TestingAnswer`, `TestingAssignment`, `TestingAttempt`, `TestingAttemptAnswer`, `TestingSetting`, `TestingHeadPosition` в `db/models.py`; бизнес-запросы — `db/queries/testing_queries.py`. |
||||||
|
- Сервер: dev `flask run`, prod типично `waitress` (см. `web_run.py`). |
||||||
|
- **Отдельный деплой в TestingWebApp:** каталог `flask_app/` — `run.py`, шаблоны в `flask_app/app/templates/` (см. §0). |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
### E2.1 — Перенос кода как blueprint |
## 3. Спринтовый план (переписывание = паритет + миграция + снятие стенда) |
||||||
|
|
||||||
- Скопировать роуты, сервисы, шаблоны из `flask_app/` в `tgFlaskForm`. **Адаптировать**: |
Длительность спринта ориентировочно **2 календарные недели**; границы можно сжимать/растягивать под состав команды. Нумерация условная: **Спринт 0** — подготовка, далее функциональные слои. |
||||||
- Импорты — на `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) |
### Спринт 0 — Инвентаризация и критерии готовности |
||||||
|
|
||||||
Скрипт уже готов: [`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`). |
**Цель:** зафиксировать разрыв «TestingWebApp ↔ tgFlaskForm» и правила миграции. |
||||||
|
|
||||||
Перед прогоном **на актуальных данных** дописать: |
- Составить **матрицу сценариев** по [ТЗ.md](ТЗ.md) и [PROJECT_STATUS.md](PROJECT_STATUS.md): редактор теста, версии, назначения, прохождение, разбор, трекер, настройки модуля, AI. |
||||||
|
- Зафиксировать отличия схемы: UUID vs integer, модель назначений (цель: каждая строка `TestingAssignment` = один `staff_id`). |
||||||
|
- Решение по **импорту из PDF/DOCX** (в Node-версии есть извлечение текста для черновика): либо перенос в Python (`tgFlaskForm`), либо явный scope «после миграции». |
||||||
|
- **Критерий выхода:** подписанный чек-лист паритета + утверждённый порядок миграции (раздел 4 этого документа). |
||||||
|
|
||||||
- **Перенос `test_assignments` 1:1** — сейчас скрипт переносит только пары «тест-сотрудник» через попытки; нужны и «висящие» назначения без попыток. (Решение Этапа 2.) |
### Спринт 1 — Данные и идентификаторы |
||||||
- **Логирование пользователей без `staff_id`:** автор → WARN, попытка → WARN; никаких хардовых ошибок. (Решение Этапа 2.) |
|
||||||
|
|
||||||
**Порядок:** |
**Цель:** подготовить перенос без потери смысла связей. |
||||||
|
|
||||||
1. Бэкап `clinic_tests` и `hr_bot_test`. |
- Убедиться, что у всех значимых пользователей `clinic_tests.users` есть сопоставление с **`staff_members.id`** (колонка `staff_id` и/или правила сопоставления по логину из HR). |
||||||
2. `--dry-run` на копии прод-БД, разбор лога. |
- Спроектировать **таблицы соответствия** для одноразового ETL (например временные таблицы или JSON-маппинги: `old_test_uuid → testing_tests.id`, `old_version_uuid → testing_test_versions.id`, и т.д.). |
||||||
3. `--apply` на той же копии, ручная сверка через UI HR-кабинета. |
- Реализовать **скрипт миграции** — в репозитории HR: [`HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py`](../../HR_TG_Bot/tgFlaskForm/tools/migrate_clinic_tests_to_hr.py) (Python, `psycopg2`, два URL). Режимы: `--dry-run` (только отчёт) и `--apply` (одна транзакция `COMMIT` в `hr_bot_test`). Переменные или флаги: `CLINIC_TESTS_URL`, `HR_BOT_URL`; опция `--skip-missing-staff` пропускает цепочки, у автора нет `users.staff_id`. |
||||||
4. После приёмки — `--dry-run` + `--apply` на боевой БД. |
- **Критерий выхода:** dry-run на копии прод-дампа `clinic_tests` + smoke-проверки количества строк (тесты, версии, вопросы, попытки). |
||||||
|
|
||||||
### E2.3 — Cutover |
### Спринт 2 — Паритет бизнес-логики в Flask |
||||||
|
|
||||||
Если к этому моменту у TestingWebApp всё ещё «песочница для тестировщиков» (как сейчас) — простое переключение, без окна простоя и баннеров. Если появятся реальные пользователи — добавить пункт E2.3.1: коммуникация и redirect. |
**Цель:** закрыть расхождения поведения, а не только UI. |
||||||
|
|
||||||
- Заморозка записи в `flask_app/` старой инсталляции (read-only). |
- Версионирование: правила «первая правка без попыток / новая версия после попыток», активная версия — согласовать с уже реализованным в `testing_queries.py` и довести до полного соответствия ТЗ при необходимости. |
||||||
- Прогон ETL на боевом. |
- Назначения: если в `clinic_tests` остались назначения **на отдел**, описать стратегию **разворачивания** в N строк `TestingAssignment` (по списку `staff_id` отдела на дату миграции) или доработать модель в HR (отдельное решение продукт-оунера). |
||||||
- Маршрутизация: внешние ссылки `clinic-tests.example.com/*` → `hr-cabinet.example.com/cabinet/testing/*`. |
- Прохождение: таймер, лимит попыток, дедлайн, случайный порядок вопросов (`question_seed`) — сверка с ТЗ и доработка в Python при расхождении. |
||||||
- В корневом репозитории TestingWebApp — ветка `legacy/clinic-tests-flask`, в README — ссылка на этот документ и дату EOL. |
- **Критерий выхода:** автоматические тесты на критичные запросы (где их ещё нет) + ручной прогон чек-листа из спринта 0. |
||||||
|
|
||||||
**Критерий выхода:** мониторинг ошибок (например Sentry, уже подключён в `webApp/__init__.py`), отсутствие P1 в первую неделю. |
### Спринт 3 — UI/UX кабинета и интеграция в меню |
||||||
|
|
||||||
|
**Цель:** пользователь не возвращается к старому хосту. |
||||||
|
|
||||||
|
- Пункты меню кабинета, бейджи «назначенные тесты», единый стиль с `cabinet/base.html`. |
||||||
|
- Довести страницы: список «мои тесты», редактор, назначение, прохождение, результат/разбор, трекер, настройки — по чек-листу. |
||||||
|
- Импорт документов (если включён в scope спринта 0): эндпоинт + UI в шаблоне, ключи API только на сервере (`TestingSetting` / env). |
||||||
|
- **Критерий выхода:** UX-приёмка на стенде, совпадающий с ТЗ сценарий для HR / руководителя / сотрудника. |
||||||
|
|
||||||
|
### Спринт 4 — Миграция prod, cutover, архив TestingWebApp |
||||||
|
|
||||||
|
**Цель:** переключить реальных пользователей и зафиксировать артефакты. |
||||||
|
|
||||||
|
- Заморозка записи в TestingWebApp (режим только чтение или техническое окно). |
||||||
|
- Прогон ETL на прод-копии → валидация → прогон на боевой БД в согласованное окно. |
||||||
|
- Обновление ссылок (внутренние порталы, документация, docker-compose): вместо `:3107` / отдельного сервиса — URL кабинета HR с `/cabinet/testing/...`. |
||||||
|
- Репозиторий TestingWebApp: ветка **`legacy/clinic-tests-node`**, в README — ссылка на этот документ и дата end-of-life API/UI. |
||||||
|
- **Критерий выхода:** мониторинг ошибок (например Sentry уже в `webApp/__init__.py`), отсутствие P1 по тестам в первую неделю после cutover. |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## 3. Что трогаем в HR-кабинете до Этапа 2 |
## 4. Как происходит миграция данных (пошагово) |
||||||
|
|
||||||
|
### 4.1 Предпосылки |
||||||
|
|
||||||
**Ничего.** Существующий модуль `tgFlaskForm/webApp/interfaces/testing/` развивается своим темпом командой HR-кабинета и **не** должен подстраиваться под TestingWebApp до момента слияния. Если в нём появляются полезные правки — переносим в `flask_app/` обратным потоком. |
1. Доступ к **двум** базам с одной машины (или логическое копирование дампа): `clinic_tests` и `hr_bot_test`. |
||||||
|
2. Маппинг **пользователь → сотрудник:** для каждой строки `users` в `clinic_tests` должен быть известен **`staff_members.id`**. Если `staff_id` пустой — заранее ручной/полуавтоматический справочник соответствий (логин, email, ФИО). |
||||||
|
3. Зафиксированная **версия кода** `tgFlaskForm`, в которой пройдены регрессионные тесты модуля тестирования. |
||||||
|
|
||||||
|
### 4.2 Порядок загрузки сущностей (чтобы не нарушить FK) |
||||||
|
|
||||||
|
Рекомендуемый порядок транзакций/батчей: |
||||||
|
|
||||||
|
1. **`testing_tests`** — из цепочек `tests`: `title`, `description`, `created_by` ← `users.staff_id`, `is_active`, `created_at` (по политике: локальное время vs UTC). |
||||||
|
2. **`testing_test_versions`** — из `test_versions`: связь `test_id` через маппинг; `version_number` ← `version`; `passing_score_percent` ← порог из версии/цепочки (в старой схеме часть полей была на `tests` — нормализовать в версию как в SQLAlchemy-модели); `time_limit_minutes`, `allow_back_navigation`, `is_active_version`, флаг единственной активной версии на цепочку. |
||||||
|
3. **`testing_questions`** — из `questions`: текст, тип (`single`/`multiple` из `has_multiple_answers`), `sort_order` ← `question_order`. |
||||||
|
4. **`testing_answers`** — из `answer_options`: текст, `is_correct`, порядок. |
||||||
|
5. **`testing_assignments`** — из `test_assignments` + `test_assignment_targets`: |
||||||
|
- для целей типа **пользователь:** одна строка на пару (тест, `staff_id`); |
||||||
|
- для целей **отдел:** развернуть в множество строк по сотрудникам отдела на момент миграции (с явным логом «создано из department_id=…»); |
||||||
|
- `assigned_by` ← `staff_id` постановщика; `deadline`, `max_attempts`, `assigned_at`. |
||||||
|
6. **`testing_attempts`** — из `test_attempts`: связь с новым `assignment_id` (если в старой модели попытка шла от `user_id` без отдельного assignment — потребуется **восстановление** или создание синтетических назначений; зафиксировать правило в спринте 0). |
||||||
|
7. **`testing_attempt_answers`** — из `user_answers`: каждый выбранный UUID варианта → строка с новым `answer_id` (через маппинг `answer_options.id` → `testing_answers.id`). |
||||||
|
|
||||||
|
Везде, где в старой БД использовались **UUID**, скрипт хранит таблицу **`public._clinic_tests_migration_map`** (`entity`, `old_uuid` → `new_id`) в `hr_bot_test` для идемпотентного повторного прогона. |
||||||
|
|
||||||
|
**Замечание по назначениям:** в текущей версии скрипта строки `clinic_tests.test_assignments` / `test_assignment_targets` **не** переносятся пакетно; для каждой пары (тест HR, сотрудник) при переносе **попыток** создаётся или находится строка `testing_assignments` (синтетическое назначение, `max_attempts = 99`). Полный импорт истории назначений из clinic — отдельная доработка при необходимости. |
||||||
|
|
||||||
|
### 4.3 Валидация после ETL |
||||||
|
|
||||||
|
- Сравнение **агрегатов:** число тестов, версий, вопросов, назначений, завершённых попыток, строк ответов. |
||||||
|
- Выборочная сверка: 5–10 последних попыток — ручной разбор «вопрос / выбранные варианты / балл» в старом и новом UI. |
||||||
|
- Проверка уникальности «одна активная версия на тест» и отсутствия «висячих» FK. |
||||||
|
|
||||||
|
### 4.4 Cutover (переключение) |
||||||
|
|
||||||
|
1. Объявить **окно**: остановка записи в TestingWebApp. |
||||||
|
2. Инкрементальный дамп изменений с последней реплики (если делали пробный перенос ранее) или финальный полный перенос. |
||||||
|
3. Прогон ETL в транзакции (или по крупным батчам с чекпоинтами) → `VACUUM ANALYZE` при необходимости. |
||||||
|
4. Включить пользователям ссылку на **кабинет**; проверить права `can_create_tests` / HR. |
||||||
|
5. Сохранить **бэкап** `clinic_tests` и лог миграции минимум на срок, определённый политикой клиники (типично 30–90 дней). |
||||||
|
|
||||||
|
### 4.5 Откат |
||||||
|
|
||||||
|
- Если после cutover обнаружен блокирующий дефект: вернуть пользователей на временный старый стенд **только для чтения** при наличии бэкапа; новые данные в `hr_bot_test` после cutover при откате не синхронизируются автоматически — риск фиксируется заранее (короткое окно, «freeze» повторных действий). |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## 4. Риски Этапа 2 и как их снимать |
## 5. Риски и как их снимать |
||||||
|
|
||||||
| Риск | Мера | |
| Риск | Мера | |
||||||
|------|------| |
|------|------| |
||||||
| Несовпадение `users.staff_id` ↔ `staff_members.id` | Проверка перед `--apply`; пользователей без `staff_id` пропускаем по решению. | |
| Неполное сопоставление `users` ↔ `staff_members` | Закрыть в спринте 1; не начинать ETL без процента покрытия, согласованного с заказчиком | |
||||||
| Расхождение моделей (UUID vs integer, поля «на цепочке» vs «на версии») | Закрыть в E2.0; подкрепить unit-тестами на конвертацию. | |
| Разная семантика назначений (отдел, версия) | Явные правила в спринте 0 + лог развёртки отделов | |
||||||
| Назначения «отдел → N сотрудников» | Логировать развёртку с пометкой `created_from_department=...`. | |
| Потеря истории попыток из-за смены модели assignment | Моделирование на копии БД в спринте 1–2 | |
||||||
| Двойное развитие модуля HR-кабинета | До Этапа 2 — не править `tgFlaskForm/cabinet/testing` под нужды TestingWebApp. | |
| Дублирование разработки UI | Опираться на уже существующий модуль в `tgFlaskForm`, не переписывать с нуля параллельный SPA | |
||||||
|
|
||||||
--- |
--- |
||||||
|
|
||||||
## 5. Производительность кабинета (общее) |
## 6. Итог |
||||||
|
|
||||||
|
Переписывание в данном контексте — это не «ещё один greenfield на Flask», а **консолидация** уже начатого модуля в `tgFlaskForm` с **одноразовой миграцией** из `clinic_tests` и выводом из эксплуатации связки React + Express. Спринты 0–4 дают сквозной маршрут от анализа до cutover; детали ETL должны быть закреплены в коде скрипта и журнале прогона к концу **спринта 1**. |
||||||
|
|
||||||
Если после переноса страницы кабинета или мини-приложения работают медленно — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). Это вспомогательный документ, не часть этапов миграции. |
**См. также:** если пользователи жалуются на медленную загрузку страниц кабинета/Flask — пошаговый план измерений и правок: [performance-flask-mini-app.md](performance-flask-mini-app.md). |
||||||
|
|||||||
@ -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,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,25 +1,7 @@ |
|||||||
# ─── HTTP сервер ───────────────────────────────────────────────── |
# Порт HTTP (не пересекать с :3107 текущего compose) |
||||||
# Порт (не пересекать с :3107 текущего docker-compose.dev.yml) |
|
||||||
PORT=3108 |
PORT=3108 |
||||||
FLASK_DEBUG=1 |
FLASK_DEBUG=1 |
||||||
# В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml) |
|
||||||
# WEB_USE_WAITRESS=1 |
|
||||||
|
|
||||||
# Секрет для подписи cookies / Flask sessions. |
|
||||||
# В dev можно оставить заглушку — будет сгенерирован случайный, но при рестарте |
|
||||||
# процесса сессии "обнулятся" (это критично для логина/CSRF). |
|
||||||
SECRET_KEY=change-me-in-dev-only |
SECRET_KEY=change-me-in-dev-only |
||||||
|
|
||||||
# ─── База данных (clinic_tests, та же, что у Express-бэкенда) ──── |
# В Docker задайте WEB_USE_WAITRESS=1 (см. docker-compose.dev.yml) |
||||||
# Этап 1: продолжаем работать с clinic_tests, схему не меняем. |
# WEB_USE_WAITRESS=1 |
||||||
# Локально: |
|
||||||
# DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests |
|
||||||
# В Docker рядом с HR (общая сеть Postgres_TG_Bots): |
|
||||||
# DATABASE_URL=postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests |
|
||||||
DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests |
|
||||||
|
|
||||||
# ─── Опциональная HR-аутентификация (как в Express-бэкенде) ────── |
|
||||||
# Если HR_AUTH=1, используем БД hr_bot_test для проверки логина/пароля |
|
||||||
# (Werkzeug-хеш в public.users.password по web_login = username). |
|
||||||
# HR_AUTH=1 |
|
||||||
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test |
|
||||||
|
|||||||
@ -1,5 +0,0 @@ |
|||||||
"""Auth: логин/логаут/me — пара UI-страниц и JSON-API. |
|
||||||
|
|
||||||
См. routes.py, services.py, decorators.py. |
|
||||||
""" |
|
||||||
from .routes import auth_bp # noqa: F401 |
|
||||||
@ -1,69 +0,0 @@ |
|||||||
"""Декораторы доступа: подгружают пользователя из сессии. |
|
||||||
|
|
||||||
`g.current_user` доступен в шаблонах как `current_user` (см. context_processor в `app/__init__.py`). |
|
||||||
""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from functools import wraps |
|
||||||
from typing import Iterable |
|
||||||
|
|
||||||
from flask import g, jsonify, redirect, request, session, url_for |
|
||||||
|
|
||||||
from ..messages import RU |
|
||||||
from .services import AuthUser, load_user_by_id |
|
||||||
|
|
||||||
|
|
||||||
def _wants_json() -> bool: |
|
||||||
if request.path.startswith('/api/'): |
|
||||||
return True |
|
||||||
accept = request.headers.get('Accept', '') |
|
||||||
return 'application/json' in accept and 'text/html' not in accept |
|
||||||
|
|
||||||
|
|
||||||
def _load_current_user() -> AuthUser | None: |
|
||||||
if hasattr(g, 'current_user'): |
|
||||||
return g.current_user |
|
||||||
user_id = session.get('user_id') |
|
||||||
user = load_user_by_id(user_id) if user_id else None |
|
||||||
g.current_user = user |
|
||||||
return user |
|
||||||
|
|
||||||
|
|
||||||
def login_required(fn): |
|
||||||
@wraps(fn) |
|
||||||
def wrapper(*args, **kwargs): |
|
||||||
user = _load_current_user() |
|
||||||
if user is None: |
|
||||||
if _wants_json(): |
|
||||||
return jsonify(error=RU['authRequired']), 401 |
|
||||||
return redirect(url_for('auth.login_page', next=request.full_path or '/')) |
|
||||||
return fn(*args, **kwargs) |
|
||||||
|
|
||||||
return wrapper |
|
||||||
|
|
||||||
|
|
||||||
def require_role(roles: str | Iterable[str]): |
|
||||||
allowed = {roles} if isinstance(roles, str) else set(roles) |
|
||||||
|
|
||||||
def decorator(fn): |
|
||||||
@wraps(fn) |
|
||||||
def wrapper(*args, **kwargs): |
|
||||||
user = _load_current_user() |
|
||||||
if user is None: |
|
||||||
if _wants_json(): |
|
||||||
return jsonify(error=RU['authRequired']), 401 |
|
||||||
return redirect(url_for('auth.login_page', next=request.full_path or '/')) |
|
||||||
if user.role not in allowed: |
|
||||||
if _wants_json(): |
|
||||||
return jsonify(error=RU['insufficientPermissions']), 403 |
|
||||||
return ('Доступ запрещён.', 403) |
|
||||||
return fn(*args, **kwargs) |
|
||||||
|
|
||||||
return wrapper |
|
||||||
|
|
||||||
return decorator |
|
||||||
|
|
||||||
|
|
||||||
def current_user() -> AuthUser | None: |
|
||||||
"""Хелпер для шаблонов и view-функций.""" |
|
||||||
return _load_current_user() |
|
||||||
@ -1,13 +0,0 @@ |
|||||||
"""Маппинг роли HR → роль модуля тестов (порт `backend/src/utils/hrRoleMap.js`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
|
|
||||||
def map_hr_role_to_app(hr_role: str | None) -> str: |
|
||||||
r = (hr_role or '').strip().lower() |
|
||||||
if not r: |
|
||||||
return 'employee' |
|
||||||
if r == 'admin' or 'hr' in r or 'дире' in r: |
|
||||||
return 'hr' |
|
||||||
if 'manager' in r or 'рук' in r or 'завед' in r: |
|
||||||
return 'manager' |
|
||||||
return 'employee' |
|
||||||
@ -1,107 +0,0 @@ |
|||||||
"""Маршруты auth: HTML (`/login`, `/logout`) и JSON (`/api/auth/*`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
import logging |
|
||||||
|
|
||||||
from flask import ( |
|
||||||
Blueprint, |
|
||||||
flash, |
|
||||||
jsonify, |
|
||||||
redirect, |
|
||||||
render_template, |
|
||||||
request, |
|
||||||
session, |
|
||||||
url_for, |
|
||||||
) |
|
||||||
|
|
||||||
from ..config import is_assignment_feature_enabled, is_dev_ui |
|
||||||
from ..messages import RU |
|
||||||
from .decorators import login_required, current_user |
|
||||||
from .services import AuthError, authenticate_credentials |
|
||||||
|
|
||||||
log = logging.getLogger(__name__) |
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__) |
|
||||||
|
|
||||||
|
|
||||||
def _safe_next(default: str = '/') -> str: |
|
||||||
"""Защита от open-redirect: разрешаем только относительные пути.""" |
|
||||||
nxt = request.values.get('next') or default |
|
||||||
if not nxt.startswith('/') or nxt.startswith('//'): |
|
||||||
return default |
|
||||||
return nxt |
|
||||||
|
|
||||||
|
|
||||||
def _do_login(login: str, password: str): |
|
||||||
user = authenticate_credentials(login, password) |
|
||||||
session.clear() |
|
||||||
session['user_id'] = user.id |
|
||||||
session.permanent = True |
|
||||||
return user |
|
||||||
|
|
||||||
|
|
||||||
# ─── HTML ──────────────────────────────────────────────────────────── |
|
||||||
|
|
||||||
@auth_bp.route('/login', methods=['GET']) |
|
||||||
def login_page(): |
|
||||||
if current_user() is not None: |
|
||||||
return redirect(_safe_next('/')) |
|
||||||
return render_template('auth/login.html', next=_safe_next('/')) |
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/login', methods=['POST']) |
|
||||||
def login_submit(): |
|
||||||
login = (request.form.get('login') or '').strip() |
|
||||||
password = request.form.get('password') or '' |
|
||||||
try: |
|
||||||
_do_login(login, password) |
|
||||||
except AuthError as e: |
|
||||||
flash(e.message, 'error') |
|
||||||
return render_template('auth/login.html', next=_safe_next('/'), login=login), e.status |
|
||||||
except Exception: |
|
||||||
log.exception('login_submit failed') |
|
||||||
flash(RU['loginFailed'], 'error') |
|
||||||
return render_template('auth/login.html', next=_safe_next('/'), login=login), 500 |
|
||||||
return redirect(_safe_next('/')) |
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/logout', methods=['POST', 'GET']) |
|
||||||
def logout(): |
|
||||||
session.clear() |
|
||||||
if request.method == 'GET': |
|
||||||
return redirect(url_for('auth.login_page')) |
|
||||||
return redirect(url_for('auth.login_page')) |
|
||||||
|
|
||||||
|
|
||||||
# ─── JSON API ──────────────────────────────────────────────────────── |
|
||||||
|
|
||||||
@auth_bp.route('/api/auth/login', methods=['POST']) |
|
||||||
def api_login(): |
|
||||||
data = request.get_json(silent=True) or {} |
|
||||||
login = (data.get('login') or '').strip() |
|
||||||
password = data.get('password') or '' |
|
||||||
try: |
|
||||||
user = _do_login(login, password) |
|
||||||
except AuthError as e: |
|
||||||
return jsonify(error=e.message), e.status |
|
||||||
except Exception: |
|
||||||
log.exception('api_login failed') |
|
||||||
return jsonify(error=RU['loginFailed']), 500 |
|
||||||
return jsonify(user=user.to_public_dict()) |
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/api/auth/logout', methods=['POST']) |
|
||||||
def api_logout(): |
|
||||||
session.clear() |
|
||||||
return jsonify(message=RU['loggedOut']) |
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/api/auth/me', methods=['GET']) |
|
||||||
@login_required |
|
||||||
def api_me(): |
|
||||||
user = current_user() |
|
||||||
return jsonify( |
|
||||||
user=user.to_public_dict() if user else None, |
|
||||||
devUi=is_dev_ui(), |
|
||||||
assignmentUi=is_assignment_feature_enabled(), |
|
||||||
) |
|
||||||
@ -1,217 +0,0 @@ |
|||||||
"""Бизнес-логика логина — порт `backend/src/routes/auth.js` + `utils/auth.js`. |
|
||||||
|
|
||||||
Поддерживает оба режима: |
|
||||||
- Локальный (по умолчанию): bcrypt в `clinic_tests.users.password_hash`. |
|
||||||
- HR_AUTH=1: пароль проверяется в `hr_bot_test.users` (Werkzeug scrypt/pbkdf2), |
|
||||||
затем находим запись `staff_members` по `web_login` и UPSERT-им в |
|
||||||
`clinic_tests.users` по `staff_id`. |
|
||||||
|
|
||||||
Werkzeug-хеши проверяются через `werkzeug.security.check_password_hash`, |
|
||||||
bcrypt-хеши — через пакет `bcrypt`. |
|
||||||
""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from dataclasses import dataclass |
|
||||||
from typing import Optional |
|
||||||
|
|
||||||
import bcrypt |
|
||||||
from sqlalchemy import text |
|
||||||
from werkzeug.security import check_password_hash as _werkzeug_check |
|
||||||
|
|
||||||
from ..config import ( |
|
||||||
HR_MANAGED_PASSWORD_PLACEHOLDER, |
|
||||||
is_hr_auth_enabled, |
|
||||||
) |
|
||||||
from ..db import get_engine, get_hr_engine |
|
||||||
from ..messages import RU |
|
||||||
from .hr_role import map_hr_role_to_app |
|
||||||
|
|
||||||
|
|
||||||
@dataclass |
|
||||||
class AuthUser: |
|
||||||
id: str # UUID в виде строки |
|
||||||
login: str |
|
||||||
full_name: str | None |
|
||||||
role: str |
|
||||||
department_id: Optional[str] |
|
||||||
staff_id: Optional[int] |
|
||||||
|
|
||||||
def to_public_dict(self) -> dict: |
|
||||||
out = { |
|
||||||
'id': str(self.id), |
|
||||||
'login': self.login, |
|
||||||
'fullName': self.full_name, |
|
||||||
'role': self.role, |
|
||||||
'departmentId': self.department_id, |
|
||||||
} |
|
||||||
out['staffId'] = self.staff_id |
|
||||||
return out |
|
||||||
|
|
||||||
|
|
||||||
class AuthError(Exception): |
|
||||||
"""Ошибка авторизации с HTTP-кодом и сообщением для пользователя.""" |
|
||||||
|
|
||||||
def __init__(self, status: int, message: str) -> None: |
|
||||||
super().__init__(message) |
|
||||||
self.status = status |
|
||||||
self.message = message |
|
||||||
|
|
||||||
|
|
||||||
def _verify_password(plain: str, hashed: str | None) -> bool: |
|
||||||
"""Универсальная проверка пароля: bcrypt + Werkzeug (scrypt/pbkdf2).""" |
|
||||||
if not hashed: |
|
||||||
return False |
|
||||||
if hashed == HR_MANAGED_PASSWORD_PLACEHOLDER: |
|
||||||
return False |
|
||||||
if hashed.startswith('scrypt:') or hashed.startswith('pbkdf2:'): |
|
||||||
try: |
|
||||||
return _werkzeug_check(hashed, plain) |
|
||||||
except Exception: |
|
||||||
return False |
|
||||||
if hashed.startswith('$2'): |
|
||||||
try: |
|
||||||
return bcrypt.checkpw(plain.encode('utf-8'), hashed.encode('utf-8')) |
|
||||||
except Exception: |
|
||||||
return False |
|
||||||
try: |
|
||||||
return _werkzeug_check(hashed, plain) |
|
||||||
except Exception: |
|
||||||
return False |
|
||||||
|
|
||||||
|
|
||||||
def authenticate_credentials(login: str, password: str) -> AuthUser: |
|
||||||
"""Главная точка входа. Возвращает AuthUser или поднимает AuthError.""" |
|
||||||
login = (login or '').strip() |
|
||||||
password = password or '' |
|
||||||
if not login or not password: |
|
||||||
raise AuthError(400, RU['loginAndPasswordRequired']) |
|
||||||
|
|
||||||
if is_hr_auth_enabled(): |
|
||||||
return _authenticate_via_hr(login, password) |
|
||||||
return _authenticate_local(login, password) |
|
||||||
|
|
||||||
|
|
||||||
# ─── локальный режим ──────────────────────────────────────────────── |
|
||||||
|
|
||||||
def _authenticate_local(login: str, password: str) -> AuthUser: |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
row = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, login, password_hash, full_name, role, department_id, staff_id ' |
|
||||||
'FROM users WHERE login = :login AND is_active = true' |
|
||||||
), |
|
||||||
{'login': login}, |
|
||||||
).mappings().first() |
|
||||||
|
|
||||||
if not row: |
|
||||||
raise AuthError(401, RU['invalidCredentials']) |
|
||||||
|
|
||||||
if row['password_hash'] == HR_MANAGED_PASSWORD_PLACEHOLDER: |
|
||||||
raise AuthError(401, RU['useHrLogin']) |
|
||||||
|
|
||||||
if not _verify_password(password, row['password_hash']): |
|
||||||
raise AuthError(401, RU['invalidCredentials']) |
|
||||||
|
|
||||||
return AuthUser( |
|
||||||
id=str(row['id']), |
|
||||||
login=row['login'], |
|
||||||
full_name=row['full_name'], |
|
||||||
role=row['role'], |
|
||||||
department_id=row['department_id'], |
|
||||||
staff_id=row['staff_id'], |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
# ─── HR_AUTH=1 ────────────────────────────────────────────────────── |
|
||||||
|
|
||||||
def _authenticate_via_hr(login: str, password: str) -> AuthUser: |
|
||||||
hr_eng = get_hr_engine() |
|
||||||
if hr_eng is None: |
|
||||||
raise AuthError(500, RU['hrDatabaseUrlMissing']) |
|
||||||
|
|
||||||
with hr_eng.connect() as hr_conn: |
|
||||||
u = hr_conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, username, password_hash, role FROM users ' |
|
||||||
'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))' |
|
||||||
), |
|
||||||
{'login': login}, |
|
||||||
).mappings().first() |
|
||||||
if not u or not u['password_hash']: |
|
||||||
raise AuthError(401, RU['invalidCredentials']) |
|
||||||
if not _verify_password(password, u['password_hash']): |
|
||||||
raise AuthError(401, RU['invalidCredentials']) |
|
||||||
|
|
||||||
s = hr_conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, fio FROM staff_members ' |
|
||||||
"WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))" |
|
||||||
), |
|
||||||
{'login': login}, |
|
||||||
).mappings().first() |
|
||||||
if not s: |
|
||||||
raise AuthError(403, RU['noStaffForLogin']) |
|
||||||
|
|
||||||
staff_id = int(s['id']) |
|
||||||
fio = s['fio'] or login |
|
||||||
app_role = map_hr_role_to_app(u['role']) |
|
||||||
|
|
||||||
eng = get_engine() |
|
||||||
with eng.begin() as conn: |
|
||||||
row = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
INSERT INTO users (login, password_hash, full_name, role, |
|
||||||
department_id, is_active, staff_id) |
|
||||||
VALUES (:login, :ph, :fn, :role, NULL, true, :staff_id) |
|
||||||
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': login, |
|
||||||
'ph': HR_MANAGED_PASSWORD_PLACEHOLDER, |
|
||||||
'fn': fio, |
|
||||||
'role': app_role, |
|
||||||
'staff_id': staff_id, |
|
||||||
}, |
|
||||||
).mappings().first() |
|
||||||
|
|
||||||
return AuthUser( |
|
||||||
id=str(row['id']), |
|
||||||
login=row['login'], |
|
||||||
full_name=row['full_name'], |
|
||||||
role=row['role'], |
|
||||||
department_id=row['department_id'], |
|
||||||
staff_id=row['staff_id'], |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
def load_user_by_id(user_id: str) -> Optional[AuthUser]: |
|
||||||
"""Догружает пользователя из `clinic_tests.users` (используется при каждом запросе).""" |
|
||||||
if not user_id: |
|
||||||
return None |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
row = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, login, full_name, role, department_id, staff_id ' |
|
||||||
'FROM users WHERE id = :id AND is_active = true' |
|
||||||
), |
|
||||||
{'id': user_id}, |
|
||||||
).mappings().first() |
|
||||||
if not row: |
|
||||||
return None |
|
||||||
return AuthUser( |
|
||||||
id=str(row['id']), |
|
||||||
login=row['login'], |
|
||||||
full_name=row['full_name'], |
|
||||||
role=row['role'], |
|
||||||
department_id=row['department_id'], |
|
||||||
staff_id=row['staff_id'], |
|
||||||
) |
|
||||||
@ -1,31 +0,0 @@ |
|||||||
"""Главный blueprint — посадочная страница и health-чек. |
|
||||||
|
|
||||||
В E1.0 здесь нет бизнес-логики; страницы и эндпоинты добавляются в следующих |
|
||||||
спринтах (E1.1 — auth, E1.2 — тесты, и т.д.). |
|
||||||
""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, render_template |
|
||||||
|
|
||||||
from .. import db as app_db |
|
||||||
from ..auth.decorators import login_required |
|
||||||
|
|
||||||
main_bp = Blueprint('main', __name__) |
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/health') |
|
||||||
def health(): |
|
||||||
"""Smoke-проверка приложения и подключений к БД (без авторизации).""" |
|
||||||
db_status = app_db.ping() |
|
||||||
overall = 'ok' if db_status.get('main') == 'ok' else 'degraded' |
|
||||||
return jsonify( |
|
||||||
status=overall, |
|
||||||
service='testing-flask-app', |
|
||||||
db=db_status, |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/') |
|
||||||
@login_required |
|
||||||
def index(): |
|
||||||
return render_template('index.html') |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
"""Страница настроек: статус LLM-ключа и проверка подключения (E1.8). |
|
||||||
|
|
||||||
Ключ — общий, читается из ENV (`DEEPSEEK_API_KEY` / `OPENAI_API_KEY`). Здесь — |
|
||||||
только просмотр статуса и smoke-проверка. Изменение ключа — через `.env` и |
|
||||||
рестарт процесса. |
|
||||||
""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, render_template |
|
||||||
|
|
||||||
from ..auth.decorators import login_required |
|
||||||
from ..services.llm_client import get_llm_config, ping_llm |
|
||||||
|
|
||||||
settings_bp = Blueprint('settings', __name__) |
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/settings', methods=['GET']) |
|
||||||
@login_required |
|
||||||
def settings_page(): |
|
||||||
cfg = get_llm_config() |
|
||||||
return render_template( |
|
||||||
'settings.html', |
|
||||||
configured=cfg is not None, |
|
||||||
provider=cfg.provider if cfg else None, |
|
||||||
model=cfg.model if cfg else None, |
|
||||||
base_url=cfg.base_url if cfg else None, |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/api/llm/ping', methods=['POST', 'GET']) |
|
||||||
@login_required |
|
||||||
def api_llm_ping(): |
|
||||||
return jsonify(ping_llm()) |
|
||||||
@ -1,39 +0,0 @@ |
|||||||
"""Точечные настройки и feature-флаги (1:1 с Express-бэкендом).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
import os |
|
||||||
|
|
||||||
|
|
||||||
HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$' |
|
||||||
"""Заглушка пароля для пользователей, попавших в clinic_tests через HR-апсёрт. |
|
||||||
При локальном входе compare всегда даёт False (см. authenticate_local).""" |
|
||||||
|
|
||||||
|
|
||||||
def _truthy(val: str | None) -> bool: |
|
||||||
return (val or '').strip().lower() in ('1', 'true', 'yes', 'on') |
|
||||||
|
|
||||||
|
|
||||||
def is_hr_auth_enabled() -> bool: |
|
||||||
"""`HR_AUTH=1` → логин через `hr_bot_test.users` (Werkzeug).""" |
|
||||||
return _truthy(os.environ.get('HR_AUTH')) |
|
||||||
|
|
||||||
|
|
||||||
def is_assignment_feature_enabled() -> bool: |
|
||||||
"""API/UI назначения тестов сотрудникам (см. backend/src/config/featureFlags.js).""" |
|
||||||
if (os.environ.get('FLASK_ENV') or '').lower() == 'development': |
|
||||||
return True |
|
||||||
if (os.environ.get('FLASK_DEBUG') or '').strip() == '1': |
|
||||||
return True |
|
||||||
raw = (os.environ.get('CLINIC_ASSIGNMENT_ENABLED') or '').strip().lower() |
|
||||||
if raw in ('1', 'true', 'yes'): |
|
||||||
return True |
|
||||||
if raw in ('0', 'false', 'no'): |
|
||||||
return False |
|
||||||
return False |
|
||||||
|
|
||||||
|
|
||||||
def is_dev_ui() -> bool: |
|
||||||
"""В Express это `NODE_ENV=development`. У нас — FLASK_ENV/FLASK_DEBUG.""" |
|
||||||
if (os.environ.get('FLASK_ENV') or '').lower() == 'development': |
|
||||||
return True |
|
||||||
return (os.environ.get('FLASK_DEBUG') or '').strip() == '1' |
|
||||||
@ -1,141 +0,0 @@ |
|||||||
"""Подключение к PostgreSQL — тот же паттерн, что в HR_TG_Bot/tgFlaskForm/db/session.py. |
|
||||||
|
|
||||||
В Этапе 1 работаем с БД `clinic_tests` (схема не меняется). Опционально доступна |
|
||||||
вторая БД `hr_bot_test` для HR-аутентификации (см. .env.example, флаг HR_AUTH). |
|
||||||
""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
import os |
|
||||||
import threading |
|
||||||
from typing import Optional |
|
||||||
|
|
||||||
from sqlalchemy import create_engine |
|
||||||
from sqlalchemy.engine import Engine |
|
||||||
from sqlalchemy.orm import sessionmaker |
|
||||||
from sqlalchemy.pool import QueuePool |
|
||||||
|
|
||||||
_lock = threading.Lock() |
|
||||||
_engine: Optional[Engine] = None |
|
||||||
_session_local: Optional[sessionmaker] = None |
|
||||||
_hr_engine: Optional[Engine] = None |
|
||||||
_hr_session_local: Optional[sessionmaker] = None |
|
||||||
|
|
||||||
|
|
||||||
def get_database_url() -> str: |
|
||||||
"""URL основной БД (`clinic_tests`). |
|
||||||
|
|
||||||
Приоритет: DATABASE_URL → отдельные DB_*-переменные. |
|
||||||
""" |
|
||||||
if db_url := os.environ.get('DATABASE_URL'): |
|
||||||
return db_url.strip() |
|
||||||
|
|
||||||
db_host = os.environ.get('DB_HOST', 'localhost') |
|
||||||
db_port = os.environ.get('DB_PORT', '5432') |
|
||||||
db_name = os.environ.get('DB_NAME', 'clinic_tests') |
|
||||||
db_user = os.environ.get('DB_USER', 'hr_bot_user') |
|
||||||
db_password = os.environ.get('DB_PASSWORD', 'hrbot123') |
|
||||||
return f'postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}' |
|
||||||
|
|
||||||
|
|
||||||
def get_hr_database_url() -> Optional[str]: |
|
||||||
"""URL БД HR (`hr_bot_test`) — только если включён HR_AUTH.""" |
|
||||||
if not _hr_auth_enabled(): |
|
||||||
return None |
|
||||||
if url := os.environ.get('HR_DATABASE_URL'): |
|
||||||
return url.strip() |
|
||||||
return None |
|
||||||
|
|
||||||
|
|
||||||
def _hr_auth_enabled() -> bool: |
|
||||||
val = (os.environ.get('HR_AUTH') or '').strip().lower() |
|
||||||
return val in ('1', 'true', 'yes', 'on') |
|
||||||
|
|
||||||
|
|
||||||
def get_engine() -> Engine: |
|
||||||
"""Возвращает общий engine основной БД (singleton на процесс).""" |
|
||||||
global _engine |
|
||||||
if _engine is not None: |
|
||||||
return _engine |
|
||||||
with _lock: |
|
||||||
if _engine is not None: |
|
||||||
return _engine |
|
||||||
_engine = create_engine( |
|
||||||
get_database_url(), |
|
||||||
poolclass=QueuePool, |
|
||||||
pool_size=5, |
|
||||||
max_overflow=10, |
|
||||||
pool_pre_ping=True, |
|
||||||
) |
|
||||||
return _engine |
|
||||||
|
|
||||||
|
|
||||||
def get_session(): |
|
||||||
"""Создаёт новую ORM-сессию поверх общего engine.""" |
|
||||||
global _session_local |
|
||||||
if _session_local is None: |
|
||||||
with _lock: |
|
||||||
if _session_local is None: |
|
||||||
_session_local = sessionmaker(bind=get_engine()) |
|
||||||
return _session_local() |
|
||||||
|
|
||||||
|
|
||||||
def get_hr_engine() -> Optional[Engine]: |
|
||||||
"""Engine для HR-БД. Возвращает None, если HR_AUTH не включён.""" |
|
||||||
if not _hr_auth_enabled(): |
|
||||||
return None |
|
||||||
global _hr_engine |
|
||||||
if _hr_engine is not None: |
|
||||||
return _hr_engine |
|
||||||
url = get_hr_database_url() |
|
||||||
if not url: |
|
||||||
return None |
|
||||||
with _lock: |
|
||||||
if _hr_engine is not None: |
|
||||||
return _hr_engine |
|
||||||
_hr_engine = create_engine( |
|
||||||
url, |
|
||||||
poolclass=QueuePool, |
|
||||||
pool_size=3, |
|
||||||
max_overflow=5, |
|
||||||
pool_pre_ping=True, |
|
||||||
) |
|
||||||
return _hr_engine |
|
||||||
|
|
||||||
|
|
||||||
def get_hr_session(): |
|
||||||
"""Сессия для HR-БД (или None при выключенном HR_AUTH).""" |
|
||||||
eng = get_hr_engine() |
|
||||||
if eng is None: |
|
||||||
return None |
|
||||||
global _hr_session_local |
|
||||||
if _hr_session_local is None: |
|
||||||
with _lock: |
|
||||||
if _hr_session_local is None: |
|
||||||
_hr_session_local = sessionmaker(bind=eng) |
|
||||||
return _hr_session_local() |
|
||||||
|
|
||||||
|
|
||||||
def ping() -> dict: |
|
||||||
"""Smoke-проверка подключения к БД (используется в /health).""" |
|
||||||
out: dict = {'main': 'unknown'} |
|
||||||
try: |
|
||||||
with get_engine().connect() as conn: |
|
||||||
conn.exec_driver_sql('SELECT 1') |
|
||||||
out['main'] = 'ok' |
|
||||||
except Exception as e: |
|
||||||
out['main'] = f'error: {type(e).__name__}: {e}' |
|
||||||
|
|
||||||
if _hr_auth_enabled(): |
|
||||||
out['hr'] = 'unknown' |
|
||||||
try: |
|
||||||
eng = get_hr_engine() |
|
||||||
if eng is None: |
|
||||||
out['hr'] = 'disabled (HR_DATABASE_URL not set)' |
|
||||||
else: |
|
||||||
with eng.connect() as conn: |
|
||||||
conn.exec_driver_sql('SELECT 1') |
|
||||||
out['hr'] = 'ok' |
|
||||||
except Exception as e: |
|
||||||
out['hr'] = f'error: {type(e).__name__}: {e}' |
|
||||||
|
|
||||||
return out |
|
||||||
@ -1,25 +0,0 @@ |
|||||||
"""Русские сообщения API (порт `backend/src/messages/ru.js`).""" |
|
||||||
|
|
||||||
RU = { |
|
||||||
'loginAndPasswordRequired': 'Укажите логин и пароль.', |
|
||||||
'invalidCredentials': 'Неверный логин или пароль.', |
|
||||||
'useHrLogin': 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).', |
|
||||||
'hrDatabaseUrlMissing': 'База кадровой системы не настроена: задайте HR_DATABASE_URL.', |
|
||||||
'hrDatabaseNotConfigured': 'База кадровой системы не настроена.', |
|
||||||
'noStaffForLogin': ( |
|
||||||
'К учётной записи не привязан сотрудник: в HR в карточке сотрудника ' |
|
||||||
'должно совпадать поле веб-логина (web_login) с логином входа.' |
|
||||||
), |
|
||||||
'loggedOut': 'Вы вышли из системы.', |
|
||||||
'logoutFailed': 'Не удалось выйти. Повторите попытку.', |
|
||||||
'userDataFailed': 'Не удалось загрузить данные пользователя.', |
|
||||||
'loginFailed': 'Ошибка входа. Повторите попытку.', |
|
||||||
'authRequired': 'Требуется вход в систему.', |
|
||||||
'tokenInvalid': 'Сессия истекла или недействительна. Войдите снова.', |
|
||||||
'userNotFound': 'Пользователь не найден.', |
|
||||||
'authError': 'Ошибка проверки доступа.', |
|
||||||
'insufficientPermissions': 'Недостаточно прав.', |
|
||||||
'departmentAccessDenied': 'Нет доступа к этому подразделению.', |
|
||||||
'notFound': 'Не найдено.', |
|
||||||
'internal': 'Внутренняя ошибка сервера.', |
|
||||||
} |
|
||||||
@ -1,352 +0,0 @@ |
|||||||
"""AI-генерация теста/вопроса в редакторе (порт `services/aiEditorService.js`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from typing import Any |
|
||||||
|
|
||||||
from .draft_validator import ( |
|
||||||
assert_draft_matches_shape, |
|
||||||
parse_json_from_llm_text, |
|
||||||
validate_and_normalize_draft, |
|
||||||
) |
|
||||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config |
|
||||||
|
|
||||||
|
|
||||||
class HttpError(Exception): |
|
||||||
def __init__(self, status: int, message: str): |
|
||||||
super().__init__(message) |
|
||||||
self.status = status |
|
||||||
self.message = message |
|
||||||
|
|
||||||
|
|
||||||
def parse_and_validate_shape(s: Any) -> list[dict]: |
|
||||||
if not isinstance(s, list) or not s: |
|
||||||
raise HttpError(400, 'Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].') |
|
||||||
if len(s) > 40: |
|
||||||
raise HttpError(400, 'Не более 40 вопросов за раз.') |
|
||||||
out = [] |
|
||||||
for i, row in enumerate(s): |
|
||||||
if not isinstance(row, dict): |
|
||||||
raise HttpError(400, f'shape[{i}]: ожидается объект.') |
|
||||||
try: |
|
||||||
n = int(float(row.get('optionsCount'))) |
|
||||||
except (TypeError, ValueError): |
|
||||||
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.') |
|
||||||
if n < 2 or n > 12: |
|
||||||
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.') |
|
||||||
out.append({'optionsCount': n, 'hasMultipleAnswers': bool(row.get('hasMultipleAnswers'))}) |
|
||||||
return out |
|
||||||
|
|
||||||
|
|
||||||
def _require_cfg(): |
|
||||||
cfg = get_llm_config() |
|
||||||
if cfg is None: |
|
||||||
raise HttpError(503, 'Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.') |
|
||||||
return cfg |
|
||||||
|
|
||||||
|
|
||||||
def generate_full_test_by_shape(test_title: str, test_description: str, shape: list[dict]) -> dict: |
|
||||||
cfg = _require_cfg() |
|
||||||
title = (test_title or '').strip() or 'Тест' |
|
||||||
desc = (test_description or '').strip() |
|
||||||
lines = [] |
|
||||||
for i, sh in enumerate(shape): |
|
||||||
if sh['hasMultipleAnswers']: |
|
||||||
tail = 'несколько вариантов помечены как верные (hasMultipleAnswers: true).' |
|
||||||
else: |
|
||||||
tail = 'ровно один верный вариант (hasMultipleAnswers: false).' |
|
||||||
lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}') |
|
||||||
|
|
||||||
system = ( |
|
||||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. ' |
|
||||||
'Схема: {"title": string, "description": string (может быть пустой строкой), ' |
|
||||||
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", ' |
|
||||||
'"options": [{ "text", "isCorrect" }]}.' |
|
||||||
) |
|
||||||
user = ( |
|
||||||
'Составь тест по теме.\n\n' |
|
||||||
f'Название (можно уточнить, но смысл сохранить): {title}\n' |
|
||||||
f'Краткое описание / контекст темы: ' |
|
||||||
f'{desc or "не указано; придумай согласованную тему с названием."}\n\n' |
|
||||||
f'Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):\n' |
|
||||||
+ '\n'.join(lines) |
|
||||||
+ '\n\nПравила: варианты — осмысленные, по теме; отметь isCorrect согласно ' |
|
||||||
'hasMultipleAnswers; для одного правильного — ровна одна true.' |
|
||||||
) |
|
||||||
|
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.35) |
|
||||||
parsed = parse_json_from_llm_text(raw) |
|
||||||
draft = validate_and_normalize_draft(parsed) |
|
||||||
assert_draft_matches_shape({'questions': draft['questions']}, shape) |
|
||||||
return { |
|
||||||
'title': draft['title'], |
|
||||||
'description': draft['description'], |
|
||||||
'questions': draft['questions'], |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
# ─── E1.8: AI v2 ────────────────────────────────────────────────────── |
|
||||||
|
|
||||||
|
|
||||||
def generate_test_by_title( |
|
||||||
test_title: str, |
|
||||||
test_description: str = '', |
|
||||||
questions_count: int = 10, |
|
||||||
options_count: int = 4, |
|
||||||
has_multiple_answers: bool = False, |
|
||||||
) -> dict: |
|
||||||
"""Генерация теста ТОЛЬКО по названию: AI сам предлагает вопросы. |
|
||||||
|
|
||||||
Сетка не задаётся жёстко: пользователю даётся подсказка о желаемом числе |
|
||||||
вопросов и вариантов, но мы валидируем мягко (не assert_draft_matches_shape). |
|
||||||
""" |
|
||||||
cfg = _require_cfg() |
|
||||||
title = (test_title or '').strip() |
|
||||||
if not title: |
|
||||||
raise HttpError(400, 'Укажите название теста.') |
|
||||||
desc = (test_description or '').strip() |
|
||||||
n_q = max(3, min(40, int(questions_count or 10))) |
|
||||||
n_opt = max(2, min(12, int(options_count or 4))) |
|
||||||
|
|
||||||
system = ( |
|
||||||
'Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним ' |
|
||||||
'JSON-объектом на русском. Схема: {"title", "description", "questions": [' |
|
||||||
'{"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect"}]}' |
|
||||||
']}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.' |
|
||||||
) |
|
||||||
user = ( |
|
||||||
'Составь учебный тест по этой теме.\n\n' |
|
||||||
f'Название теста: {title}\n' |
|
||||||
f'Описание/контекст: {desc or "не указано — определи по названию."}\n\n' |
|
||||||
f'Подсказка по сетке: примерно {n_q} вопросов, в каждом по {n_opt} вариантов ' |
|
||||||
f'ответа; ' |
|
||||||
f'тип ответа — {"несколько правильных" if has_multiple_answers else "один правильный"} ' |
|
||||||
f'(но если по смыслу нужно отступить — отступи). ' |
|
||||||
'Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно ' |
|
||||||
'неверными. Текст — короткий, понятный.' |
|
||||||
) |
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.45) |
|
||||||
parsed = parse_json_from_llm_text(raw) |
|
||||||
draft = validate_and_normalize_draft(parsed) |
|
||||||
return { |
|
||||||
'title': draft['title'], |
|
||||||
'description': draft['description'], |
|
||||||
'questions': draft['questions'], |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
def check_test_quality(test_title: str, test_description: str, questions: list[dict]) -> dict: |
|
||||||
"""AI-рецензия теста: общий вердикт + список рекомендаций по разделам.""" |
|
||||||
cfg = _require_cfg() |
|
||||||
title = (test_title or '').strip() or 'Тест' |
|
||||||
desc = (test_description or '').strip() |
|
||||||
qs = questions or [] |
|
||||||
if not qs: |
|
||||||
raise HttpError(400, 'В тесте нет вопросов — нечего проверять.') |
|
||||||
|
|
||||||
system = ( |
|
||||||
'Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {"verdict": "ok"|"warn"|"bad", ' |
|
||||||
'"summary": string (1-2 предложения), ' |
|
||||||
'"sections": [{"title": string, "items": [string, ...]}]}. ' |
|
||||||
'Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», ' |
|
||||||
'"Охват темы», «Сбалансированность сложности». Пропусти раздел, если ' |
|
||||||
'претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные ' |
|
||||||
'проблемы. Все тексты — на русском, короткие и предметные.' |
|
||||||
) |
|
||||||
test_dump = { |
|
||||||
'title': title, |
|
||||||
'description': desc, |
|
||||||
'questions': [ |
|
||||||
{ |
|
||||||
'text': q.get('text', ''), |
|
||||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')), |
|
||||||
'options': [ |
|
||||||
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))} |
|
||||||
for o in (q.get('options') or []) |
|
||||||
], |
|
||||||
} |
|
||||||
for q in qs |
|
||||||
], |
|
||||||
} |
|
||||||
import json as _json |
|
||||||
|
|
||||||
user = 'Проверь качество теста и дай рекомендации:\n\n' + _json.dumps( |
|
||||||
test_dump, ensure_ascii=False |
|
||||||
) |
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.25) |
|
||||||
parsed = parse_json_from_llm_text(raw) |
|
||||||
if not isinstance(parsed, dict): |
|
||||||
raise LlmError('Неверный формат ответа модели.', code='llm_shape') |
|
||||||
verdict = str(parsed.get('verdict') or '').strip().lower() |
|
||||||
if verdict not in ('ok', 'warn', 'bad'): |
|
||||||
verdict = 'warn' |
|
||||||
summary = str(parsed.get('summary') or '').strip() |
|
||||||
raw_sections = parsed.get('sections') or [] |
|
||||||
sections: list[dict] = [] |
|
||||||
if isinstance(raw_sections, list): |
|
||||||
for s in raw_sections: |
|
||||||
if not isinstance(s, dict): |
|
||||||
continue |
|
||||||
t = str(s.get('title') or '').strip() |
|
||||||
items = s.get('items') or [] |
|
||||||
if not t or not isinstance(items, list) or not items: |
|
||||||
continue |
|
||||||
clean_items = [str(x).strip() for x in items if str(x).strip()] |
|
||||||
if clean_items: |
|
||||||
sections.append({'title': t, 'items': clean_items}) |
|
||||||
return {'verdict': verdict, 'summary': summary, 'sections': sections} |
|
||||||
|
|
||||||
|
|
||||||
def improve_test_full(test_title: str, test_description: str, questions: list[dict]) -> dict: |
|
||||||
"""AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса. |
|
||||||
|
|
||||||
Для каждого вопроса: original + suggested, флаги textChanged/optionsChanged. |
|
||||||
UI решает, что применить (чекбоксы). |
|
||||||
""" |
|
||||||
cfg = _require_cfg() |
|
||||||
title = (test_title or '').strip() or 'Тест' |
|
||||||
desc = (test_description or '').strip() |
|
||||||
qs = questions or [] |
|
||||||
if not qs: |
|
||||||
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.') |
|
||||||
|
|
||||||
system = ( |
|
||||||
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь ' |
|
||||||
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку ' |
|
||||||
'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и ' |
|
||||||
'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. ' |
|
||||||
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", ' |
|
||||||
'"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.' |
|
||||||
) |
|
||||||
test_dump = { |
|
||||||
'title': title, |
|
||||||
'description': desc, |
|
||||||
'questions': [ |
|
||||||
{ |
|
||||||
'text': q.get('text', ''), |
|
||||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')), |
|
||||||
'options': [ |
|
||||||
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))} |
|
||||||
for o in (q.get('options') or []) |
|
||||||
], |
|
||||||
} |
|
||||||
for q in qs |
|
||||||
], |
|
||||||
} |
|
||||||
import json as _json |
|
||||||
|
|
||||||
user = 'Улучши тест без изменения сетки:\n\n' + _json.dumps( |
|
||||||
test_dump, ensure_ascii=False |
|
||||||
) |
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.3) |
|
||||||
parsed = parse_json_from_llm_text(raw) |
|
||||||
|
|
||||||
shape = [ |
|
||||||
{ |
|
||||||
'optionsCount': len(q.get('options') or []), |
|
||||||
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')), |
|
||||||
} |
|
||||||
for q in qs |
|
||||||
] |
|
||||||
assert_draft_matches_shape(parsed, shape) |
|
||||||
draft = validate_and_normalize_draft( |
|
||||||
{'title': title, 'questions': parsed.get('questions') or []} |
|
||||||
) |
|
||||||
suggested_qs = draft['questions'] |
|
||||||
|
|
||||||
items = [] |
|
||||||
for i, (orig, sug) in enumerate(zip(qs, suggested_qs)): |
|
||||||
orig_opts = [ |
|
||||||
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))} |
|
||||||
for o in (orig.get('options') or []) |
|
||||||
] |
|
||||||
sug_opts = sug['options'] |
|
||||||
text_changed = (str(orig.get('text', '')).strip() != sug['text']) |
|
||||||
options_changed = ( |
|
||||||
len(orig_opts) != len(sug_opts) |
|
||||||
or any( |
|
||||||
a['text'] != b['text'] or a['isCorrect'] != b['isCorrect'] |
|
||||||
for a, b in zip(orig_opts, sug_opts) |
|
||||||
) |
|
||||||
) |
|
||||||
items.append( |
|
||||||
{ |
|
||||||
'index': i, |
|
||||||
'original': { |
|
||||||
'text': str(orig.get('text', '')).strip(), |
|
||||||
'hasMultipleAnswers': bool(orig.get('hasMultipleAnswers')), |
|
||||||
'options': orig_opts, |
|
||||||
}, |
|
||||||
'suggested': { |
|
||||||
'text': sug['text'], |
|
||||||
'hasMultipleAnswers': sug['hasMultipleAnswers'], |
|
||||||
'options': sug_opts, |
|
||||||
}, |
|
||||||
'textChanged': text_changed, |
|
||||||
'optionsChanged': options_changed, |
|
||||||
'changed': text_changed or options_changed, |
|
||||||
} |
|
||||||
) |
|
||||||
|
|
||||||
return {'items': items} |
|
||||||
|
|
||||||
|
|
||||||
def generate_or_rephrase_question( |
|
||||||
test_title: str, |
|
||||||
test_description: str, |
|
||||||
question_text: str, |
|
||||||
options_count: Any, |
|
||||||
has_multiple_answers: bool, |
|
||||||
) -> dict: |
|
||||||
cfg = _require_cfg() |
|
||||||
try: |
|
||||||
n = int(float(options_count)) |
|
||||||
except (TypeError, ValueError): |
|
||||||
raise HttpError(400, 'optionsCount: от 2 до 12.') |
|
||||||
if n < 2 or n > 12: |
|
||||||
raise HttpError(400, 'optionsCount: от 2 до 12.') |
|
||||||
|
|
||||||
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip() |
|
||||||
qt = (question_text or '').strip() |
|
||||||
|
|
||||||
if qt: |
|
||||||
system = ( |
|
||||||
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — ' |
|
||||||
'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости ' |
|
||||||
'от сложности исходного черновика, без вариантов ответа.' |
|
||||||
) |
|
||||||
user = ( |
|
||||||
f'Тема теста: {topic}\n\n' |
|
||||||
f'Исходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{qt}' |
|
||||||
) |
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.3) |
|
||||||
parsed = parse_json_from_llm_text(raw) |
|
||||||
text = str((parsed or {}).get('text') or '').strip() |
|
||||||
if not text: |
|
||||||
raise LlmError('Пустой text в ответе модели.', code='llm_shape') |
|
||||||
return {'mode': 'rephrase', 'text': text} |
|
||||||
|
|
||||||
system = ( |
|
||||||
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", ' |
|
||||||
'"options": [{ "text", "isCorrect" }]}. Все на русском.' |
|
||||||
) |
|
||||||
multi_clause = ( |
|
||||||
'true (несколько верных, минимум 2 isCorrect: true, остальные false).' |
|
||||||
if has_multiple_answers |
|
||||||
else 'false (ровно один isCorrect: true).' |
|
||||||
) |
|
||||||
user = ( |
|
||||||
f'Тема теста: {topic}\n\n' |
|
||||||
f'Сформулируй ОДИН вопрос по этой теме с ровно {n} вариантами ответа. ' |
|
||||||
f'hasMultipleAnswers = {multi_clause}' |
|
||||||
) |
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.35) |
|
||||||
parsed = parse_json_from_llm_text(raw) |
|
||||||
shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}] |
|
||||||
assert_draft_matches_shape({'questions': [parsed]}, shape) |
|
||||||
draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]}) |
|
||||||
return { |
|
||||||
'mode': 'full', |
|
||||||
'text': draft['questions'][0]['text'], |
|
||||||
'hasMultipleAnswers': draft['questions'][0]['hasMultipleAnswers'], |
|
||||||
'options': draft['questions'][0]['options'], |
|
||||||
} |
|
||||||
@ -1,89 +0,0 @@ |
|||||||
"""Извлечение текста из PDF/DOCX/TXT/MD (порт `services/documentExtractService.js`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from io import BytesIO |
|
||||||
from typing import Optional |
|
||||||
|
|
||||||
|
|
||||||
class HttpError(Exception): |
|
||||||
def __init__(self, status: int, message: str): |
|
||||||
super().__init__(message) |
|
||||||
self.status = status |
|
||||||
self.message = message |
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_MIME = { |
|
||||||
'application/pdf': 'pdf', |
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', |
|
||||||
'text/plain': 'text', |
|
||||||
'text/markdown': 'text', |
|
||||||
} |
|
||||||
SUPPORTED_EXT = { |
|
||||||
'.pdf': 'pdf', |
|
||||||
'.docx': 'docx', |
|
||||||
'.txt': 'text', |
|
||||||
'.md': 'text', |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
def resolve_document_kind(mimetype: str | None, original_name: str | None = '') -> Optional[str]: |
|
||||||
m = (mimetype or '').lower() |
|
||||||
n = (original_name or '').lower() |
|
||||||
if m in SUPPORTED_MIME: |
|
||||||
return SUPPORTED_MIME[m] |
|
||||||
for ext, kind in SUPPORTED_EXT.items(): |
|
||||||
if n.endswith(ext): |
|
||||||
return kind |
|
||||||
return None |
|
||||||
|
|
||||||
|
|
||||||
def extract_text_from_buffer(kind: str, buf: bytes) -> str: |
|
||||||
if kind == 'text': |
|
||||||
try: |
|
||||||
return buf.decode('utf-8') |
|
||||||
except UnicodeDecodeError: |
|
||||||
return buf.decode('utf-8', errors='replace') |
|
||||||
|
|
||||||
if kind == 'docx': |
|
||||||
try: |
|
||||||
from docx import Document |
|
||||||
except ImportError: |
|
||||||
raise HttpError(500, 'python-docx не установлен (см. requirements.txt).') |
|
||||||
doc = Document(BytesIO(buf)) |
|
||||||
parts = [] |
|
||||||
for p in doc.paragraphs: |
|
||||||
if p.text: |
|
||||||
parts.append(p.text) |
|
||||||
for table in doc.tables: |
|
||||||
for row in table.rows: |
|
||||||
for cell in row.cells: |
|
||||||
if cell.text: |
|
||||||
parts.append(cell.text) |
|
||||||
return '\n'.join(parts).replace('\r\n', '\n').strip() |
|
||||||
|
|
||||||
if kind == 'pdf': |
|
||||||
try: |
|
||||||
from pypdf import PdfReader |
|
||||||
except ImportError: |
|
||||||
raise HttpError(500, 'pypdf не установлен (см. requirements.txt).') |
|
||||||
reader = PdfReader(BytesIO(buf)) |
|
||||||
parts = [] |
|
||||||
for page in reader.pages: |
|
||||||
try: |
|
||||||
t = page.extract_text() or '' |
|
||||||
except Exception: |
|
||||||
t = '' |
|
||||||
if t: |
|
||||||
parts.append(t) |
|
||||||
return '\n'.join(parts).replace('\r\n', '\n').strip() |
|
||||||
|
|
||||||
return '' |
|
||||||
|
|
||||||
|
|
||||||
def extract_text_from_file(mimetype: str | None, file_storage, original_name: str | None) -> str: |
|
||||||
"""`file_storage` — werkzeug FileStorage. Читает целиком в память (≤16 МБ).""" |
|
||||||
kind = resolve_document_kind(mimetype, original_name) |
|
||||||
if not kind: |
|
||||||
raise HttpError(400, 'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.') |
|
||||||
buf = file_storage.read() |
|
||||||
return extract_text_from_buffer(kind, buf) |
|
||||||
@ -1,72 +0,0 @@ |
|||||||
"""Генерация черновика теста из извлечённого текста (порт части `documentGenService.js`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from .draft_validator import ( |
|
||||||
parse_json_from_llm_text, |
|
||||||
validate_and_normalize_draft, |
|
||||||
) |
|
||||||
from .llm_client import LlmError, chat_completion_text_content, get_llm_config |
|
||||||
|
|
||||||
|
|
||||||
MAX_EXTRACT_CHARS = 14000 |
|
||||||
|
|
||||||
|
|
||||||
def generation_for_import_document(extracted_text: str) -> dict: |
|
||||||
text = (extracted_text or '').strip() |
|
||||||
if not text: |
|
||||||
return { |
|
||||||
'available': False, |
|
||||||
'message': 'Нет извлечённого текста — нечего передавать в модель.', |
|
||||||
} |
|
||||||
cfg = get_llm_config() |
|
||||||
if cfg is None: |
|
||||||
return { |
|
||||||
'available': False, |
|
||||||
'message': ( |
|
||||||
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY ' |
|
||||||
'в .env. Превью текста ниже — можно вставить вручную.' |
|
||||||
), |
|
||||||
'textPreview': text[:4000], |
|
||||||
} |
|
||||||
if len(text) > MAX_EXTRACT_CHARS: |
|
||||||
slice_ = text[:MAX_EXTRACT_CHARS] + '\n\n[…фрагмент обрезан для API]' |
|
||||||
else: |
|
||||||
slice_ = text |
|
||||||
try: |
|
||||||
system = ( |
|
||||||
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом ' |
|
||||||
'без пояснений. Схема: {"title": string, "description"?: string, ' |
|
||||||
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, ' |
|
||||||
'"options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. ' |
|
||||||
'Для одиночного выбора ровно один isCorrect: true. ' |
|
||||||
'Текст и формулировки — на русском, по содержанию входного материала.' |
|
||||||
) |
|
||||||
user = ( |
|
||||||
'Составь тест с вопросами с одним или несколькими правильными ответами ' |
|
||||||
'на основе текста:\n\n' + slice_ |
|
||||||
) |
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.25) |
|
||||||
parsed = parse_json_from_llm_text(raw) |
|
||||||
draft = validate_and_normalize_draft(parsed) |
|
||||||
return { |
|
||||||
'available': True, |
|
||||||
'message': ( |
|
||||||
f'Сгенерировано: «{draft["title"]}», вопросов: ' |
|
||||||
f'{len(draft["questions"])}. Нажмите «Применить сгенерированный черновик».' |
|
||||||
), |
|
||||||
'draft': draft, |
|
||||||
} |
|
||||||
except LlmError as e: |
|
||||||
return { |
|
||||||
'available': False, |
|
||||||
'message': f'Генерация не удалась: {e}', |
|
||||||
'errorCode': e.code, |
|
||||||
'textPreview': text[:4000], |
|
||||||
} |
|
||||||
except Exception as e: |
|
||||||
return { |
|
||||||
'available': False, |
|
||||||
'message': f'Генерация не удалась: {e}', |
|
||||||
'errorCode': 'unknown', |
|
||||||
'textPreview': text[:4000], |
|
||||||
} |
|
||||||
@ -1,105 +0,0 @@ |
|||||||
"""Парсер JSON от LLM и валидатор draft (порт частей `documentGenService.js`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
import json as _json |
|
||||||
import re |
|
||||||
from typing import Any |
|
||||||
|
|
||||||
from .llm_client import LlmError |
|
||||||
|
|
||||||
|
|
||||||
_FENCE_RE = re.compile(r'^```(?:json)?\s*([\s\S]*?)```$', re.MULTILINE) |
|
||||||
|
|
||||||
|
|
||||||
def parse_json_from_llm_text(text: str) -> Any: |
|
||||||
if not isinstance(text, str) or not text.strip(): |
|
||||||
raise LlmError('Пустой ответ модели.', code='llm_empty') |
|
||||||
t = text.strip() |
|
||||||
if m := _FENCE_RE.match(t): |
|
||||||
t = m.group(1).strip() |
|
||||||
try: |
|
||||||
return _json.loads(t) |
|
||||||
except _json.JSONDecodeError: |
|
||||||
raise LlmError('Ответ модели не является корректным JSON.', code='llm_json_parse') |
|
||||||
|
|
||||||
|
|
||||||
def validate_and_normalize_draft(o: Any) -> dict: |
|
||||||
if not isinstance(o, dict): |
|
||||||
raise LlmError('JSON не содержит объекта с данными.', code='llm_shape') |
|
||||||
title = str(o.get('title') or '').strip() |
|
||||||
if not title: |
|
||||||
raise LlmError('В ответе нет поля title.', code='llm_shape') |
|
||||||
desc = o.get('description') |
|
||||||
description = str(desc).strip() if desc and str(desc).strip() else None |
|
||||||
|
|
||||||
raw_qs = o.get('questions') |
|
||||||
if not isinstance(raw_qs, list) or not raw_qs: |
|
||||||
raise LlmError('В ответе нет вопросов (questions).', code='llm_shape') |
|
||||||
if len(raw_qs) > 40: |
|
||||||
raise LlmError('Слишком много вопросов в ответе (макс. 40).', code='llm_shape') |
|
||||||
|
|
||||||
questions = [] |
|
||||||
for i, q in enumerate(raw_qs): |
|
||||||
if not isinstance(q, dict): |
|
||||||
raise LlmError(f'Вопрос {i + 1}: неверный формат.', code='llm_shape') |
|
||||||
text = str(q.get('text') or '').strip() |
|
||||||
if not text: |
|
||||||
raise LlmError(f'Вопрос {i + 1}: пустой текст.', code='llm_shape') |
|
||||||
has_multi = bool(q.get('hasMultipleAnswers')) |
|
||||||
raw_opts = q.get('options') |
|
||||||
if not isinstance(raw_opts, list) or len(raw_opts) < 2: |
|
||||||
raise LlmError(f'Вопрос {i + 1}: нужны минимум 2 варианта ответа.', code='llm_shape') |
|
||||||
if len(raw_opts) > 12: |
|
||||||
raise LlmError(f'Вопрос {i + 1}: слишком много вариантов (макс. 12).', code='llm_shape') |
|
||||||
|
|
||||||
options = [] |
|
||||||
for j, op in enumerate(raw_opts): |
|
||||||
if not isinstance(op, dict): |
|
||||||
raise LlmError(f'Вопрос {i + 1}, вариант {j + 1}: неверный формат.', code='llm_shape') |
|
||||||
options.append( |
|
||||||
{ |
|
||||||
'text': (str(op.get('text') or '').strip() or f'Вариант {j + 1}'), |
|
||||||
'isCorrect': bool(op.get('isCorrect')), |
|
||||||
} |
|
||||||
) |
|
||||||
correct_n = sum(1 for x in options if x['isCorrect']) |
|
||||||
if correct_n == 0: |
|
||||||
raise LlmError( |
|
||||||
f'Вопрос {i + 1}: отметьте минимум один правильный вариант.', |
|
||||||
code='llm_shape', |
|
||||||
) |
|
||||||
if not has_multi and correct_n > 1: |
|
||||||
raise LlmError( |
|
||||||
f'Вопрос {i + 1}: с одним правильным ответом должен быть один вариант ' |
|
||||||
f'isCorrect, либо укажите hasMultipleAnswers: true.', |
|
||||||
code='llm_shape', |
|
||||||
) |
|
||||||
questions.append({'text': text, 'hasMultipleAnswers': has_multi, 'options': options}) |
|
||||||
|
|
||||||
return {'title': title, 'description': description, 'questions': questions} |
|
||||||
|
|
||||||
|
|
||||||
def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None: |
|
||||||
"""Проверяет, что число вопросов и вариантов = ровно как в shape.""" |
|
||||||
qs = o.get('questions') if isinstance(o, dict) else None |
|
||||||
if not isinstance(qs, list): |
|
||||||
raise LlmError('В ответе нет questions.', code='llm_shape') |
|
||||||
if len(qs) != len(shape): |
|
||||||
raise LlmError( |
|
||||||
f'Ожидалось вопросов: {len(shape)}, в ответе: {len(qs)}.', |
|
||||||
code='llm_shape', |
|
||||||
) |
|
||||||
for i, (q, sh) in enumerate(zip(qs, shape)): |
|
||||||
opts = q.get('options') if isinstance(q, dict) else None |
|
||||||
if not isinstance(opts, list): |
|
||||||
raise LlmError(f'Вопрос {i + 1}: нет options.', code='llm_shape') |
|
||||||
if len(opts) != sh['optionsCount']: |
|
||||||
raise LlmError( |
|
||||||
f'Вопрос {i + 1}: ожидалось вариантов {sh["optionsCount"]}, в ответе: {len(opts)}.', |
|
||||||
code='llm_shape', |
|
||||||
) |
|
||||||
if bool(q.get('hasMultipleAnswers')) != sh['hasMultipleAnswers']: |
|
||||||
raise LlmError( |
|
||||||
f'Вопрос {i + 1}: hasMultipleAnswers должен быть {sh["hasMultipleAnswers"]}.', |
|
||||||
code='llm_shape', |
|
||||||
) |
|
||||||
@ -1,95 +0,0 @@ |
|||||||
"""Контент редактора: тест + активная версия + дерево вопросов с правильными вариантами. |
|
||||||
|
|
||||||
Порт `getEditorContent` + `loadQuestionsForVersion` (только includeCorrect=true вариант) |
|
||||||
из `services/testAttemptService.js`. |
|
||||||
""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from sqlalchemy import text |
|
||||||
|
|
||||||
from ..db import get_engine |
|
||||||
from ..messages import RU |
|
||||||
from .test_access import is_test_author |
|
||||||
|
|
||||||
|
|
||||||
class HttpError(Exception): |
|
||||||
def __init__(self, status: int, message: str): |
|
||||||
super().__init__(message) |
|
||||||
self.status = status |
|
||||||
self.message = message |
|
||||||
|
|
||||||
|
|
||||||
def load_questions_for_version(conn, test_version_id, *, include_correct: bool) -> list[dict]: |
|
||||||
qrows = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, text, question_order, has_multiple_answers ' |
|
||||||
'FROM questions WHERE test_version_id = :v ORDER BY question_order' |
|
||||||
), |
|
||||||
{'v': test_version_id}, |
|
||||||
).mappings().all() |
|
||||||
out = [] |
|
||||||
for r in qrows: |
|
||||||
orows = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, text, is_correct, option_order ' |
|
||||||
'FROM answer_options WHERE question_id = :q ORDER BY option_order' |
|
||||||
), |
|
||||||
{'q': r['id']}, |
|
||||||
).mappings().all() |
|
||||||
options = [] |
|
||||||
for o in orows: |
|
||||||
base = { |
|
||||||
'id': str(o['id']), |
|
||||||
'text': o['text'], |
|
||||||
'optionOrder': o['option_order'], |
|
||||||
} |
|
||||||
if include_correct: |
|
||||||
base['isCorrect'] = bool(o['is_correct']) |
|
||||||
options.append(base) |
|
||||||
out.append( |
|
||||||
{ |
|
||||||
'id': str(r['id']), |
|
||||||
'text': r['text'], |
|
||||||
'questionOrder': r['question_order'], |
|
||||||
'hasMultipleAnswers': bool(r['has_multiple_answers']), |
|
||||||
'options': options, |
|
||||||
} |
|
||||||
) |
|
||||||
return out |
|
||||||
|
|
||||||
|
|
||||||
def get_editor_content(user_id: str, test_id: str) -> dict: |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
tr = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, title, description, passing_threshold, created_by ' |
|
||||||
'FROM tests WHERE id = :id' |
|
||||||
), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().first() |
|
||||||
if not tr: |
|
||||||
raise HttpError(404, 'Тест не найден.') |
|
||||||
if not is_test_author(tr['created_by'], user_id): |
|
||||||
raise HttpError(403, 'Доступ запрещён.') |
|
||||||
tv = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1' |
|
||||||
), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().first() |
|
||||||
if not tv: |
|
||||||
raise HttpError(400, 'Нет активной версии теста.') |
|
||||||
version_id = tv['id'] |
|
||||||
questions = load_questions_for_version(conn, version_id, include_correct=True) |
|
||||||
|
|
||||||
return { |
|
||||||
'test': { |
|
||||||
'id': str(tr['id']), |
|
||||||
'title': tr['title'], |
|
||||||
'description': tr['description'], |
|
||||||
'passingThreshold': tr['passing_threshold'], |
|
||||||
}, |
|
||||||
'activeVersionId': str(version_id), |
|
||||||
'questions': questions, |
|
||||||
} |
|
||||||
@ -1,156 +0,0 @@ |
|||||||
"""OpenAI-совместимый клиент Chat Completions (порт `services/llmClient.js`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
import os |
|
||||||
from dataclasses import dataclass |
|
||||||
from typing import Optional |
|
||||||
|
|
||||||
import urllib.request |
|
||||||
import urllib.error |
|
||||||
import json as _json |
|
||||||
|
|
||||||
|
|
||||||
class LlmError(Exception): |
|
||||||
"""Ошибка работы с LLM API.""" |
|
||||||
|
|
||||||
def __init__(self, message: str, code: str = 'llm_error', status: int | None = None): |
|
||||||
super().__init__(message) |
|
||||||
self.code = code |
|
||||||
self.status = status |
|
||||||
|
|
||||||
|
|
||||||
@dataclass |
|
||||||
class LlmConfig: |
|
||||||
provider: str |
|
||||||
api_key: str |
|
||||||
base_url: str |
|
||||||
model: str |
|
||||||
|
|
||||||
|
|
||||||
def get_llm_config() -> Optional[LlmConfig]: |
|
||||||
if k := os.environ.get('DEEPSEEK_API_KEY'): |
|
||||||
return LlmConfig( |
|
||||||
provider='deepseek', |
|
||||||
api_key=k, |
|
||||||
base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.deepseek.com/v1').rstrip('/'), |
|
||||||
model=os.environ.get('LLM_MODEL') or 'deepseek-chat', |
|
||||||
) |
|
||||||
if k := os.environ.get('OPENAI_API_KEY'): |
|
||||||
return LlmConfig( |
|
||||||
provider='openai', |
|
||||||
api_key=k, |
|
||||||
base_url=(os.environ.get('LLM_BASE_URL') or 'https://api.openai.com/v1').rstrip('/'), |
|
||||||
model=os.environ.get('LLM_MODEL') or 'gpt-4o-mini', |
|
||||||
) |
|
||||||
return None |
|
||||||
|
|
||||||
|
|
||||||
def chat_completion_text_content( |
|
||||||
cfg: LlmConfig, |
|
||||||
system: str, |
|
||||||
user: str, |
|
||||||
temperature: float = 0.25, |
|
||||||
timeout: int = 120, |
|
||||||
) -> str: |
|
||||||
"""Возвращает `assistant.message.content` (строку).""" |
|
||||||
body: dict = { |
|
||||||
'model': cfg.model, |
|
||||||
'messages': [ |
|
||||||
{'role': 'system', 'content': system}, |
|
||||||
{'role': 'user', 'content': user}, |
|
||||||
], |
|
||||||
'temperature': temperature, |
|
||||||
} |
|
||||||
if (os.environ.get('LLM_NO_JSON') or '').strip() != '1': |
|
||||||
body['response_format'] = {'type': 'json_object'} |
|
||||||
|
|
||||||
req = urllib.request.Request( |
|
||||||
f'{cfg.base_url}/chat/completions', |
|
||||||
data=_json.dumps(body).encode('utf-8'), |
|
||||||
headers={ |
|
||||||
'Content-Type': 'application/json', |
|
||||||
'Authorization': f'Bearer {cfg.api_key}', |
|
||||||
}, |
|
||||||
method='POST', |
|
||||||
) |
|
||||||
try: |
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp: |
|
||||||
data = _json.loads(resp.read().decode('utf-8')) |
|
||||||
except urllib.error.HTTPError as e: |
|
||||||
text = '' |
|
||||||
try: |
|
||||||
text = e.read().decode('utf-8', errors='replace') |
|
||||||
except Exception: |
|
||||||
pass |
|
||||||
raise LlmError( |
|
||||||
f'LLM {e.code}: {(text or "").replace(chr(10), " ")[:280]}', |
|
||||||
code='llm_http', |
|
||||||
status=e.code, |
|
||||||
) |
|
||||||
except (urllib.error.URLError, TimeoutError) as e: |
|
||||||
msg = str(getattr(e, 'reason', '') or e) |
|
||||||
if 'timed out' in msg.lower(): |
|
||||||
raise LlmError('Превышен таймаут ожидания ответа LLM (120 с).', code='llm_timeout') |
|
||||||
raise LlmError(f'Сбой сети при обращении к LLM: {msg}', code='llm_network') |
|
||||||
|
|
||||||
try: |
|
||||||
content = data['choices'][0]['message']['content'] |
|
||||||
except (KeyError, IndexError, TypeError): |
|
||||||
content = None |
|
||||||
if not isinstance(content, str) or not content.strip(): |
|
||||||
raise LlmError('Пустой content в ответе API.', code='llm_empty') |
|
||||||
return content |
|
||||||
|
|
||||||
|
|
||||||
def ping_llm(timeout: int = 30) -> dict: |
|
||||||
"""Smoke-проверка подключения к LLM. Не бросает исключений — всё в результате. |
|
||||||
|
|
||||||
Возвращает: {'ok': bool, 'provider', 'model', 'error'?, 'latencyMs'?, 'sample'?} |
|
||||||
""" |
|
||||||
import time |
|
||||||
|
|
||||||
cfg = get_llm_config() |
|
||||||
if cfg is None: |
|
||||||
return { |
|
||||||
'ok': False, |
|
||||||
'configured': False, |
|
||||||
'error': 'Ключ не задан. Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY в .env.', |
|
||||||
} |
|
||||||
started = time.monotonic() |
|
||||||
try: |
|
||||||
raw = chat_completion_text_content( |
|
||||||
cfg, |
|
||||||
'Отвечай ТОЛЬКО JSON: {"ok": true}.', |
|
||||||
'ping', |
|
||||||
temperature=0.0, |
|
||||||
timeout=timeout, |
|
||||||
) |
|
||||||
ms = int((time.monotonic() - started) * 1000) |
|
||||||
return { |
|
||||||
'ok': True, |
|
||||||
'configured': True, |
|
||||||
'provider': cfg.provider, |
|
||||||
'model': cfg.model, |
|
||||||
'latencyMs': ms, |
|
||||||
'sample': raw[:120], |
|
||||||
} |
|
||||||
except LlmError as e: |
|
||||||
ms = int((time.monotonic() - started) * 1000) |
|
||||||
return { |
|
||||||
'ok': False, |
|
||||||
'configured': True, |
|
||||||
'provider': cfg.provider, |
|
||||||
'model': cfg.model, |
|
||||||
'latencyMs': ms, |
|
||||||
'error': str(e), |
|
||||||
'code': e.code, |
|
||||||
} |
|
||||||
except Exception as e: |
|
||||||
return { |
|
||||||
'ok': False, |
|
||||||
'configured': True, |
|
||||||
'provider': cfg.provider, |
|
||||||
'model': cfg.model, |
|
||||||
'error': f'{type(e).__name__}: {e}', |
|
||||||
'code': 'unknown', |
|
||||||
} |
|
||||||
@ -1,108 +0,0 @@ |
|||||||
"""Кто видит тест: автор + назначенные пользователи (порт `services/testAccessService.js`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from dataclasses import dataclass |
|
||||||
|
|
||||||
from sqlalchemy import text |
|
||||||
|
|
||||||
from ..db import get_engine |
|
||||||
|
|
||||||
|
|
||||||
def is_test_author(created_by, user_id) -> bool: |
|
||||||
"""`tests.created_by` — UUID. Сравниваем по строковому представлению.""" |
|
||||||
if created_by is None or user_id is None: |
|
||||||
return False |
|
||||||
return str(created_by) == str(user_id) |
|
||||||
|
|
||||||
|
|
||||||
@dataclass |
|
||||||
class AccessResult: |
|
||||||
ok: bool |
|
||||||
is_author: bool |
|
||||||
not_found: bool |
|
||||||
|
|
||||||
|
|
||||||
def user_has_test_access(user_id: str, test_id: str) -> AccessResult: |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
row = conn.execute( |
|
||||||
text('SELECT created_by FROM tests WHERE id = :id'), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().first() |
|
||||||
if not row: |
|
||||||
return AccessResult(ok=False, is_author=False, not_found=True) |
|
||||||
if is_test_author(row['created_by'], user_id): |
|
||||||
return AccessResult(ok=True, is_author=True, not_found=False) |
|
||||||
ar = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
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 = :test_id |
|
||||||
AND tat.target_type = 'user' |
|
||||||
AND tat.target_id = :user_id |
|
||||||
LIMIT 1 |
|
||||||
""" |
|
||||||
), |
|
||||||
{'test_id': test_id, 'user_id': user_id}, |
|
||||||
).first() |
|
||||||
return AccessResult(ok=ar is not None, is_author=False, not_found=False) |
|
||||||
|
|
||||||
|
|
||||||
def list_visible_tests(user_id: str) -> list[dict]: |
|
||||||
"""Каталог: только активная цепочка + (автор OR назначен).""" |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
rows = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
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 = :uid |
|
||||||
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 = :uid |
|
||||||
) |
|
||||||
) |
|
||||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC |
|
||||||
""" |
|
||||||
), |
|
||||||
{'uid': user_id}, |
|
||||||
).mappings().all() |
|
||||||
return [dict(r) for r in rows] |
|
||||||
|
|
||||||
|
|
||||||
def list_hidden_by_author(user_id: str) -> list[dict]: |
|
||||||
"""Скрытые автором цепочки (`is_active = false`) — видны только автору.""" |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
rows = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
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 = :uid |
|
||||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC |
|
||||||
""" |
|
||||||
), |
|
||||||
{'uid': user_id}, |
|
||||||
).mappings().all() |
|
||||||
return [dict(r) for r in rows] |
|
||||||
@ -1,22 +0,0 @@ |
|||||||
"""Утилиты по цепочке теста (попытки/версии).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from sqlalchemy import text |
|
||||||
|
|
||||||
|
|
||||||
def has_any_attempt_for_test(conn, test_id: str) -> bool: |
|
||||||
"""`conn` может быть Connection или Engine — обе поддерживают .execute().""" |
|
||||||
row = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
SELECT EXISTS ( |
|
||||||
SELECT 1 |
|
||||||
FROM test_attempts ta |
|
||||||
INNER JOIN test_versions tv ON ta.test_version_id = tv.id |
|
||||||
WHERE tv.test_id = :test_id |
|
||||||
) AS has_any |
|
||||||
""" |
|
||||||
), |
|
||||||
{'test_id': test_id}, |
|
||||||
).first() |
|
||||||
return bool(row[0]) |
|
||||||
@ -1,234 +0,0 @@ |
|||||||
"""Создание/правка теста, fork версии при наличии попыток (порт `testDraftService.js`).""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
from typing import Any |
|
||||||
|
|
||||||
from sqlalchemy import text |
|
||||||
|
|
||||||
from ..db import get_engine |
|
||||||
from ..messages import RU |
|
||||||
from .test_access import is_test_author |
|
||||||
from .test_chain import has_any_attempt_for_test |
|
||||||
|
|
||||||
|
|
||||||
class HttpError(Exception): |
|
||||||
def __init__(self, status: int, message: str): |
|
||||||
super().__init__(message) |
|
||||||
self.status = status |
|
||||||
self.message = message |
|
||||||
|
|
||||||
|
|
||||||
def create_test_with_version(author_id: str, *, title: str, description: str | None) -> dict: |
|
||||||
eng = get_engine() |
|
||||||
with eng.begin() as conn: |
|
||||||
t = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
INSERT INTO tests (title, description, created_by, is_active, is_versioned) |
|
||||||
VALUES (:title, :desc, :uid, true, true) RETURNING id |
|
||||||
""" |
|
||||||
), |
|
||||||
{'title': title, 'desc': description or None, 'uid': author_id}, |
|
||||||
).mappings().first() |
|
||||||
test_id = t['id'] |
|
||||||
v = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
INSERT INTO test_versions (test_id, version, is_active, parent_id) |
|
||||||
VALUES (:tid, 1, true, NULL) RETURNING id |
|
||||||
""" |
|
||||||
), |
|
||||||
{'tid': test_id}, |
|
||||||
).mappings().first() |
|
||||||
return {'testId': str(test_id), 'versionId': str(v['id'])} |
|
||||||
|
|
||||||
|
|
||||||
def _get_active_version_row(conn, test_id: str) -> dict | None: |
|
||||||
row = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT * FROM test_versions WHERE test_id = :id AND is_active = true LIMIT 1' |
|
||||||
), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().first() |
|
||||||
return dict(row) if row else None |
|
||||||
|
|
||||||
|
|
||||||
def _copy_question_tree(conn, from_version_id, to_version_id) -> None: |
|
||||||
questions = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, text, question_order, has_multiple_answers ' |
|
||||||
'FROM questions WHERE test_version_id = :v ORDER BY question_order' |
|
||||||
), |
|
||||||
{'v': from_version_id}, |
|
||||||
).mappings().all() |
|
||||||
for q in questions: |
|
||||||
new_q = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers) |
|
||||||
VALUES (:v, :text, :ord, :multi) RETURNING id |
|
||||||
""" |
|
||||||
), |
|
||||||
{ |
|
||||||
'v': to_version_id, |
|
||||||
'text': q['text'], |
|
||||||
'ord': q['question_order'], |
|
||||||
'multi': q['has_multiple_answers'], |
|
||||||
}, |
|
||||||
).mappings().first() |
|
||||||
nqid = new_q['id'] |
|
||||||
opts = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT text, is_correct, option_order FROM answer_options ' |
|
||||||
'WHERE question_id = :q ORDER BY option_order' |
|
||||||
), |
|
||||||
{'q': q['id']}, |
|
||||||
).mappings().all() |
|
||||||
for o in opts: |
|
||||||
conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
INSERT INTO answer_options (question_id, text, is_correct, option_order) |
|
||||||
VALUES (:q, :text, :ic, :ord) |
|
||||||
""" |
|
||||||
), |
|
||||||
{'q': nqid, 'text': o['text'], 'ic': o['is_correct'], 'ord': o['option_order']}, |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
def _replace_version_content(conn, test_version_id, payload: dict) -> None: |
|
||||||
conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
DELETE FROM answer_options WHERE question_id IN ( |
|
||||||
SELECT id FROM questions WHERE test_version_id = :v |
|
||||||
) |
|
||||||
""" |
|
||||||
), |
|
||||||
{'v': test_version_id}, |
|
||||||
) |
|
||||||
conn.execute( |
|
||||||
text('DELETE FROM questions WHERE test_version_id = :v'), |
|
||||||
{'v': test_version_id}, |
|
||||||
) |
|
||||||
questions = payload.get('questions') or [] |
|
||||||
for i, q in enumerate(questions): |
|
||||||
ins_q = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers) |
|
||||||
VALUES (:v, :text, :ord, :multi) RETURNING id |
|
||||||
""" |
|
||||||
), |
|
||||||
{ |
|
||||||
'v': test_version_id, |
|
||||||
'text': q.get('text'), |
|
||||||
'ord': q.get('question_order') or (i + 1), |
|
||||||
'multi': bool(q.get('hasMultipleAnswers')), |
|
||||||
}, |
|
||||||
).mappings().first() |
|
||||||
qid = ins_q['id'] |
|
||||||
opts = q.get('options') or [] |
|
||||||
for j, o in enumerate(opts): |
|
||||||
conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
INSERT INTO answer_options (question_id, text, is_correct, option_order) |
|
||||||
VALUES (:q, :text, :ic, :ord) |
|
||||||
""" |
|
||||||
), |
|
||||||
{ |
|
||||||
'q': qid, |
|
||||||
'text': o.get('text'), |
|
||||||
'ic': bool(o.get('isCorrect')), |
|
||||||
'ord': o.get('option_order') or (j + 1), |
|
||||||
}, |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
def _fork_new_version(conn, test_id: str) -> dict: |
|
||||||
av = _get_active_version_row(conn, test_id) |
|
||||||
if not av: |
|
||||||
raise HttpError(500, RU['internal']) # invariant: должна быть активная версия |
|
||||||
mx = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = :t' |
|
||||||
), |
|
||||||
{'t': test_id}, |
|
||||||
).mappings().first() |
|
||||||
next_v = (mx['v'] or 0) + 1 |
|
||||||
conn.execute( |
|
||||||
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'), |
|
||||||
{'t': test_id}, |
|
||||||
) |
|
||||||
nv = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
INSERT INTO test_versions (test_id, version, is_active, parent_id) |
|
||||||
VALUES (:t, :ver, true, :parent) RETURNING * |
|
||||||
""" |
|
||||||
), |
|
||||||
{'t': test_id, 'ver': next_v, 'parent': av['id']}, |
|
||||||
).mappings().first() |
|
||||||
_copy_question_tree(conn, av['id'], nv['id']) |
|
||||||
return dict(nv) |
|
||||||
|
|
||||||
|
|
||||||
def save_test_draft(author_id: str, test_id: str, payload: dict) -> dict: |
|
||||||
if not isinstance(payload, dict): |
|
||||||
payload = {} |
|
||||||
eng = get_engine() |
|
||||||
with eng.begin() as conn: |
|
||||||
t = conn.execute( |
|
||||||
text('SELECT id, created_by FROM tests WHERE id = :id'), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().first() |
|
||||||
if not t: |
|
||||||
raise HttpError(404, RU['testNotFound'] if 'testNotFound' in RU else 'Тест не найден.') |
|
||||||
if not is_test_author(t['created_by'], author_id): |
|
||||||
raise HttpError(403, 'Доступ запрещён.') |
|
||||||
|
|
||||||
if payload.get('title') is not None or payload.get('description') is not None: |
|
||||||
conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
UPDATE tests |
|
||||||
SET title = COALESCE(:title, title), |
|
||||||
description = COALESCE(:desc, description), |
|
||||||
updated_at = CURRENT_TIMESTAMP |
|
||||||
WHERE id = :id |
|
||||||
""" |
|
||||||
), |
|
||||||
{ |
|
||||||
'title': payload.get('title'), |
|
||||||
'desc': payload.get('description'), |
|
||||||
'id': test_id, |
|
||||||
}, |
|
||||||
) |
|
||||||
if payload.get('passingThreshold') is not None: |
|
||||||
try: |
|
||||||
raw = float(payload['passingThreshold']) |
|
||||||
pt = max(0, min(100, round(raw))) |
|
||||||
conn.execute( |
|
||||||
text( |
|
||||||
'UPDATE tests SET passing_threshold = :pt, updated_at = CURRENT_TIMESTAMP WHERE id = :id' |
|
||||||
), |
|
||||||
{'pt': pt, 'id': test_id}, |
|
||||||
) |
|
||||||
except (TypeError, ValueError): |
|
||||||
pass |
|
||||||
|
|
||||||
has_attempts = has_any_attempt_for_test(conn, test_id) |
|
||||||
version_row = _get_active_version_row(conn, test_id) |
|
||||||
if not version_row: |
|
||||||
raise HttpError(500, 'Нет активной версии теста.') |
|
||||||
|
|
||||||
forked = False |
|
||||||
if has_attempts and 'questions' in payload and payload.get('questions') is not None: |
|
||||||
version_row = _fork_new_version(conn, test_id) |
|
||||||
forked = True |
|
||||||
|
|
||||||
if payload.get('questions') is not None: |
|
||||||
_replace_version_content(conn, version_row['id'], payload) |
|
||||||
|
|
||||||
return {'testId': test_id, 'versionId': str(version_row['id']), 'forked': forked} |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
/* Точечные стили поверх Tailwind CDN. |
|
||||||
В E1.0 файл почти пустой — задаёт только сглаживание иконок и базовый focus-ring, |
|
||||||
чтобы кнопки/ссылки были консистентны со стилем кабинета HR. */ |
|
||||||
|
|
||||||
.material-symbols-outlined { |
|
||||||
font-variation-settings: |
|
||||||
'FILL' 0, |
|
||||||
'wght' 400, |
|
||||||
'GRAD' 0, |
|
||||||
'opsz' 20; |
|
||||||
} |
|
||||||
|
|
||||||
:focus-visible { |
|
||||||
outline: 2px solid #6366f1; /* brand-500 */ |
|
||||||
outline-offset: 2px; |
|
||||||
border-radius: 6px; |
|
||||||
} |
|
||||||
@ -1,546 +0,0 @@ |
|||||||
/* Редактор теста: рабочий минимум. |
|
||||||
* Работает с эндпоинтами /api/tests/<id>/{draft, ai/generate-test, ai/generate-question} |
|
||||||
* и /api/tests/<id> (PATCH chainActive). |
|
||||||
* |
|
||||||
* Полная мобильная отполировка UX (4 аккордеона, fixed footer, drag-n-drop) |
|
||||||
* запланирована отдельным спринтом E1.7. |
|
||||||
*/ |
|
||||||
(() => { |
|
||||||
'use strict'; |
|
||||||
|
|
||||||
const root = document.getElementById('editor-root'); |
|
||||||
if (!root) return; |
|
||||||
|
|
||||||
const TEST_ID = root.dataset.testId; |
|
||||||
const initial = JSON.parse(root.dataset.initial); |
|
||||||
|
|
||||||
const $ = (sel, parent = document) => parent.querySelector(sel); |
|
||||||
const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel)); |
|
||||||
|
|
||||||
const titleEl = $('#test-title'); |
|
||||||
const descEl = $('#test-description'); |
|
||||||
const thresholdEl = $('#test-threshold'); |
|
||||||
const questionsEl = $('#questions'); |
|
||||||
const qCountEl = $('#q-count'); |
|
||||||
const saveStatusEl = $('#save-status'); |
|
||||||
const aiStatusEl = $('#ai-status'); |
|
||||||
const chainActiveEl = $('#chain-active'); |
|
||||||
|
|
||||||
const tplQ = $('#tpl-question'); |
|
||||||
const tplO = $('#tpl-option'); |
|
||||||
|
|
||||||
let chainActive = true; |
|
||||||
|
|
||||||
// ─── render ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function renderQuestion(q) { |
|
||||||
const node = tplQ.content.firstElementChild.cloneNode(true); |
|
||||||
node._q = { id: q.id || null }; |
|
||||||
$('.q-text', node).value = q.text || ''; |
|
||||||
$('.q-multi', node).checked = !!q.hasMultipleAnswers; |
|
||||||
|
|
||||||
const optsEl = $('.q-options', node); |
|
||||||
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o))); |
|
||||||
|
|
||||||
bindQuestionEvents(node); |
|
||||||
return node; |
|
||||||
} |
|
||||||
|
|
||||||
function renderOption(o) { |
|
||||||
const node = tplO.content.firstElementChild.cloneNode(true); |
|
||||||
$('.opt-text', node).value = o.text || ''; |
|
||||||
$('.opt-correct', node).checked = !!o.isCorrect; |
|
||||||
$('.opt-delete', node).addEventListener('click', () => { |
|
||||||
node.remove(); |
|
||||||
}); |
|
||||||
return node; |
|
||||||
} |
|
||||||
|
|
||||||
function bindQuestionEvents(node) { |
|
||||||
$('.q-delete', node).addEventListener('click', () => { |
|
||||||
if (!confirm('Удалить вопрос?')) return; |
|
||||||
node.remove(); |
|
||||||
renumber(); |
|
||||||
}); |
|
||||||
$('.q-up', node).addEventListener('click', () => { |
|
||||||
if (node.previousElementSibling) { |
|
||||||
node.parentNode.insertBefore(node, node.previousElementSibling); |
|
||||||
renumber(); |
|
||||||
} |
|
||||||
}); |
|
||||||
$('.q-down', node).addEventListener('click', () => { |
|
||||||
if (node.nextElementSibling) { |
|
||||||
node.parentNode.insertBefore(node.nextElementSibling, node); |
|
||||||
renumber(); |
|
||||||
} |
|
||||||
}); |
|
||||||
$('.q-add-option', node).addEventListener('click', () => { |
|
||||||
$('.q-options', node).appendChild(renderOption({ text: '', isCorrect: false })); |
|
||||||
}); |
|
||||||
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node)); |
|
||||||
} |
|
||||||
|
|
||||||
function renumber() { |
|
||||||
$$('#questions .q-item').forEach((li, i) => { |
|
||||||
$('.q-num', li).textContent = `Вопрос #${i + 1}`; |
|
||||||
}); |
|
||||||
qCountEl.textContent = $$('#questions .q-item').length; |
|
||||||
} |
|
||||||
|
|
||||||
function loadInitial() { |
|
||||||
titleEl.value = initial.test.title || ''; |
|
||||||
descEl.value = initial.test.description || ''; |
|
||||||
thresholdEl.value = |
|
||||||
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold); |
|
||||||
|
|
||||||
questionsEl.innerHTML = ''; |
|
||||||
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); |
|
||||||
renumber(); |
|
||||||
} |
|
||||||
|
|
||||||
// ─── collect ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function collectPayload() { |
|
||||||
const questions = $$('#questions .q-item').map((li, i) => ({ |
|
||||||
text: $('.q-text', li).value.trim(), |
|
||||||
question_order: i + 1, |
|
||||||
hasMultipleAnswers: $('.q-multi', li).checked, |
|
||||||
options: $$('.opt-item', li).map((op, j) => ({ |
|
||||||
text: $('.opt-text', op).value.trim(), |
|
||||||
isCorrect: $('.opt-correct', op).checked, |
|
||||||
option_order: j + 1, |
|
||||||
})), |
|
||||||
})); |
|
||||||
const payload = { |
|
||||||
title: titleEl.value.trim() || null, |
|
||||||
description: descEl.value.trim() || null, |
|
||||||
questions, |
|
||||||
}; |
|
||||||
const t = thresholdEl.value; |
|
||||||
if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t); |
|
||||||
return payload; |
|
||||||
} |
|
||||||
|
|
||||||
function collectShape() { |
|
||||||
return $$('#questions .q-item').map((li) => ({ |
|
||||||
optionsCount: Math.max(2, $$('.opt-item', li).length || 4), |
|
||||||
hasMultipleAnswers: $('.q-multi', li).checked, |
|
||||||
})); |
|
||||||
} |
|
||||||
|
|
||||||
// ─── actions ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
$('#add-question').addEventListener('click', () => { |
|
||||||
questionsEl.appendChild( |
|
||||||
renderQuestion({ |
|
||||||
text: '', |
|
||||||
hasMultipleAnswers: false, |
|
||||||
options: [ |
|
||||||
{ text: '', isCorrect: true }, |
|
||||||
{ text: '', isCorrect: false }, |
|
||||||
{ text: '', isCorrect: false }, |
|
||||||
{ text: '', isCorrect: false }, |
|
||||||
], |
|
||||||
}), |
|
||||||
); |
|
||||||
renumber(); |
|
||||||
}); |
|
||||||
|
|
||||||
$('#save-draft').addEventListener('click', async () => { |
|
||||||
saveStatusEl.textContent = 'Сохраняем…'; |
|
||||||
try { |
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/draft`, { |
|
||||||
method: 'POST', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify(collectPayload()), |
|
||||||
}); |
|
||||||
const data = await r.json(); |
|
||||||
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить.'); |
|
||||||
if (chainActiveEl.checked !== chainActive) { |
|
||||||
const r2 = await fetch(`/api/tests/${TEST_ID}`, { |
|
||||||
method: 'PATCH', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify({ chainActive: chainActiveEl.checked }), |
|
||||||
}); |
|
||||||
if (r2.ok) chainActive = chainActiveEl.checked; |
|
||||||
} |
|
||||||
saveStatusEl.textContent = data.forked |
|
||||||
? 'Сохранено (создана новая версия — есть попытки прохождения).' |
|
||||||
: 'Сохранено.'; |
|
||||||
setTimeout(() => (saveStatusEl.textContent = ''), 4000); |
|
||||||
} catch (e) { |
|
||||||
saveStatusEl.textContent = ''; |
|
||||||
alert(e.message || 'Не удалось сохранить.'); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
$('#ai-generate-test').addEventListener('click', async () => { |
|
||||||
const shape = collectShape(); |
|
||||||
if (!shape.length) { |
|
||||||
alert('Сначала добавьте хотя бы один вопрос (его настройки определяют сетку).'); |
|
||||||
return; |
|
||||||
} |
|
||||||
aiStatusEl.textContent = 'Генерируем…'; |
|
||||||
try { |
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, { |
|
||||||
method: 'POST', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify({ |
|
||||||
testTitle: titleEl.value, |
|
||||||
testDescription: descEl.value, |
|
||||||
shape, |
|
||||||
}), |
|
||||||
}); |
|
||||||
const data = await r.json(); |
|
||||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); |
|
||||||
const draft = data.draft; |
|
||||||
if (draft.title) titleEl.value = draft.title; |
|
||||||
if (draft.description) descEl.value = draft.description; |
|
||||||
questionsEl.innerHTML = ''; |
|
||||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); |
|
||||||
renumber(); |
|
||||||
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`; |
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000); |
|
||||||
} catch (e) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
alert(e.message || 'AI: ошибка.'); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// ─── импорт документа (E1.3) ───────────────────────────────────
|
|
||||||
|
|
||||||
$('#ai-import-file').addEventListener('change', async (ev) => { |
|
||||||
const file = ev.target.files && ev.target.files[0]; |
|
||||||
ev.target.value = ''; |
|
||||||
if (!file) return; |
|
||||||
aiStatusEl.textContent = `Загружаем «${file.name}»…`; |
|
||||||
try { |
|
||||||
const fd = new FormData(); |
|
||||||
fd.append('file', file); |
|
||||||
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd }); |
|
||||||
const data = await r.json(); |
|
||||||
if (!r.ok) throw new Error(data.error || 'Не удалось импортировать.'); |
|
||||||
const g = data.generation || {}; |
|
||||||
if (!g.available) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
const msg = g.message || 'AI недоступен.'; |
|
||||||
const preview = (g.textPreview || data.extractedText || '').slice(0, 600); |
|
||||||
alert(msg + (preview ? '\n\nПервые 600 символов:\n' + preview : '')); |
|
||||||
return; |
|
||||||
} |
|
||||||
const ok = confirm( |
|
||||||
`${g.message}\n\nПрименить как новый черновик?\n` + |
|
||||||
`Текущие вопросы будут заменены.`, |
|
||||||
); |
|
||||||
if (!ok) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
return; |
|
||||||
} |
|
||||||
const draft = g.draft; |
|
||||||
if (draft.title) titleEl.value = draft.title; |
|
||||||
if (draft.description) descEl.value = draft.description; |
|
||||||
questionsEl.innerHTML = ''; |
|
||||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); |
|
||||||
renumber(); |
|
||||||
aiStatusEl.textContent = `Применено: ${draft.questions?.length || 0} вопросов.`; |
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000); |
|
||||||
} catch (e) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
alert(e.message || 'Не удалось импортировать.'); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
|
|
||||||
|
|
||||||
function aiAlert(data, fallback) { |
|
||||||
const msg = (data && data.error) || fallback || 'AI: ошибка.'; |
|
||||||
if (data && data.settingsUrl) { |
|
||||||
if (confirm(msg + '\n\nОткрыть Настройки?')) { |
|
||||||
window.location.href = data.settingsUrl; |
|
||||||
} |
|
||||||
return; |
|
||||||
} |
|
||||||
alert(msg); |
|
||||||
} |
|
||||||
|
|
||||||
function escHtml(s) { |
|
||||||
return String(s == null ? '' : s) |
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
|
||||||
.replace(/"/g, '"').replace(/'/g, '''); |
|
||||||
} |
|
||||||
|
|
||||||
const modal = $('#ai-modal'); |
|
||||||
const modalTitle = $('#ai-modal-title'); |
|
||||||
const modalBody = $('#ai-modal-body'); |
|
||||||
const modalActions = $('#ai-modal-actions'); |
|
||||||
$('#ai-modal-close').addEventListener('click', () => modal.close()); |
|
||||||
|
|
||||||
function openModal(title, bodyHtml, actions) { |
|
||||||
modalTitle.textContent = title; |
|
||||||
modalBody.innerHTML = bodyHtml; |
|
||||||
modalActions.innerHTML = ''; |
|
||||||
(actions || []).forEach((a) => { |
|
||||||
const b = document.createElement('button'); |
|
||||||
b.textContent = a.label; |
|
||||||
b.className = a.className |
|
||||||
|| 'px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm'; |
|
||||||
b.addEventListener('click', () => a.onClick(b)); |
|
||||||
modalActions.appendChild(b); |
|
||||||
}); |
|
||||||
modal.showModal(); |
|
||||||
} |
|
||||||
|
|
||||||
$('#ai-generate-by-title').addEventListener('click', async () => { |
|
||||||
const title = titleEl.value.trim(); |
|
||||||
if (!title) { |
|
||||||
alert('Сначала заполните название теста.'); |
|
||||||
titleEl.focus(); |
|
||||||
return; |
|
||||||
} |
|
||||||
const nQRaw = prompt('Сколько вопросов сгенерировать?', '10'); |
|
||||||
if (nQRaw == null) return; |
|
||||||
const nQ = Math.max(3, Math.min(40, parseInt(nQRaw, 10) || 10)); |
|
||||||
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4'); |
|
||||||
if (nORaw == null) return; |
|
||||||
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4)); |
|
||||||
aiStatusEl.textContent = 'Генерируем по названию…'; |
|
||||||
try { |
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, { |
|
||||||
method: 'POST', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify({ |
|
||||||
testTitle: title, |
|
||||||
testDescription: descEl.value, |
|
||||||
questionsCount: nQ, |
|
||||||
optionsCount: nO, |
|
||||||
}), |
|
||||||
}); |
|
||||||
const data = await r.json(); |
|
||||||
if (!r.ok) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
return aiAlert(data); |
|
||||||
} |
|
||||||
const draft = data.draft; |
|
||||||
const ok = confirm( |
|
||||||
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` + |
|
||||||
'Применить как черновик? Текущие вопросы будут заменены.', |
|
||||||
); |
|
||||||
if (!ok) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
return; |
|
||||||
} |
|
||||||
if (draft.title) titleEl.value = draft.title; |
|
||||||
if (draft.description) descEl.value = draft.description; |
|
||||||
questionsEl.innerHTML = ''; |
|
||||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); |
|
||||||
renumber(); |
|
||||||
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`; |
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000); |
|
||||||
} catch (e) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
aiAlert(null, e.message); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
$('#ai-check').addEventListener('click', async () => { |
|
||||||
const payload = collectPayload(); |
|
||||||
if (!payload.questions.length) { |
|
||||||
alert('В тесте нет вопросов — нечего проверять.'); |
|
||||||
return; |
|
||||||
} |
|
||||||
aiStatusEl.textContent = 'Анализируем…'; |
|
||||||
try { |
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, { |
|
||||||
method: 'POST', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify({ |
|
||||||
testTitle: titleEl.value, |
|
||||||
testDescription: descEl.value, |
|
||||||
questions: payload.questions, |
|
||||||
}), |
|
||||||
}); |
|
||||||
const data = await r.json(); |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
if (!r.ok) return aiAlert(data); |
|
||||||
const rev = data.review || {}; |
|
||||||
const verdict = rev.verdict || 'warn'; |
|
||||||
const verdictMap = { |
|
||||||
ok: ['Годен', 'bg-green-50 text-green-800 border-green-200'], |
|
||||||
warn: ['Есть замечания', 'bg-yellow-50 text-yellow-800 border-yellow-200'], |
|
||||||
bad: ['Серьёзные проблемы', 'bg-red-50 text-red-800 border-red-200'], |
|
||||||
}; |
|
||||||
const [verdictText, verdictCls] = verdictMap[verdict] || verdictMap.warn; |
|
||||||
let html = `<div class="rounded-lg border ${verdictCls} p-3 text-sm">
|
|
||||||
<div class="font-semibold">${verdictText}</div> |
|
||||||
<div class="mt-1">${escHtml(rev.summary || '')}</div></div>`; |
|
||||||
if (Array.isArray(rev.sections) && rev.sections.length) { |
|
||||||
html += rev.sections.map((s) => ` |
|
||||||
<div class="mt-4"> |
|
||||||
<div class="font-semibold">${escHtml(s.title)}</div> |
|
||||||
<ul class="mt-1 list-disc pl-5 text-sm space-y-1"> |
|
||||||
${s.items.map((it) => `<li>${escHtml(it)}</li>`).join('')} |
|
||||||
</ul> |
|
||||||
</div>`).join(''); |
|
||||||
} else { |
|
||||||
html += '<p class="mt-4 text-sm text-ink-500">Замечаний нет.</p>'; |
|
||||||
} |
|
||||||
openModal('Проверка теста', html, [ |
|
||||||
{ label: 'Закрыть', onClick: () => modal.close(), |
|
||||||
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' }, |
|
||||||
]); |
|
||||||
} catch (e) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
aiAlert(null, e.message); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
$('#ai-improve').addEventListener('click', async () => { |
|
||||||
const payload = collectPayload(); |
|
||||||
if (!payload.questions.length) { |
|
||||||
alert('В тесте нет вопросов — нечего улучшать.'); |
|
||||||
return; |
|
||||||
} |
|
||||||
aiStatusEl.textContent = 'Улучшаем…'; |
|
||||||
try { |
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, { |
|
||||||
method: 'POST', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify({ |
|
||||||
testTitle: titleEl.value, |
|
||||||
testDescription: descEl.value, |
|
||||||
questions: payload.questions, |
|
||||||
}), |
|
||||||
}); |
|
||||||
const data = await r.json(); |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
if (!r.ok) return aiAlert(data); |
|
||||||
const items = data.items || []; |
|
||||||
if (!items.length) { |
|
||||||
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [ |
|
||||||
{ label: 'Закрыть', onClick: () => modal.close() }, |
|
||||||
]); |
|
||||||
return; |
|
||||||
} |
|
||||||
const changed = items.filter((i) => i.changed); |
|
||||||
if (!changed.length) { |
|
||||||
openModal('Улучшение теста', '<p>AI не предложил изменений.</p>', [ |
|
||||||
{ label: 'Закрыть', onClick: () => modal.close() }, |
|
||||||
]); |
|
||||||
return; |
|
||||||
} |
|
||||||
let html = `<p class="text-sm text-ink-500 mb-3">
|
|
||||||
Отметьте вопросы, к которым нужно применить улучшения. ${changed.length} из ${items.length}.</p>`; |
|
||||||
html += changed.map((it) => ` |
|
||||||
<div class="rounded-xl border border-ink-300/60 p-3 mb-3" data-idx="${it.index}"> |
|
||||||
<label class="inline-flex items-center gap-2 text-sm font-medium"> |
|
||||||
<input type="checkbox" class="apply-q rounded border-ink-300 text-brand-600 focus:ring-brand-500" checked /> |
|
||||||
<span>Вопрос #${it.index + 1}</span> |
|
||||||
</label> |
|
||||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"> |
|
||||||
<div> |
|
||||||
<div class="text-xs uppercase text-ink-500">Было</div> |
|
||||||
<div class="mt-1 ${it.textChanged ? 'line-through text-ink-500' : ''}"> |
|
||||||
${escHtml(it.original.text)} |
|
||||||
</div> |
|
||||||
<ul class="mt-1 list-disc pl-5"> |
|
||||||
${it.original.options.map((o) => |
|
||||||
`<li class="${it.optionsChanged ? 'text-ink-500' : ''}">
|
|
||||||
${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')} |
|
||||||
</ul> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<div class="text-xs uppercase text-brand-700">Стало</div> |
|
||||||
<div class="mt-1 ${it.textChanged ? 'font-medium' : ''}"> |
|
||||||
${escHtml(it.suggested.text)} |
|
||||||
</div> |
|
||||||
<ul class="mt-1 list-disc pl-5"> |
|
||||||
${it.suggested.options.map((o) => |
|
||||||
`<li>${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')} |
|
||||||
</ul> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div>`).join(''); |
|
||||||
openModal('Улучшение теста', html, [ |
|
||||||
{ label: 'Отмена', onClick: () => modal.close() }, |
|
||||||
{ |
|
||||||
label: 'Применить выбранное', |
|
||||||
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm', |
|
||||||
onClick: () => { |
|
||||||
const qs = $$('#questions .q-item'); |
|
||||||
modalBody.querySelectorAll('[data-idx]').forEach((row) => { |
|
||||||
if (!$('.apply-q', row).checked) return; |
|
||||||
const idx = parseInt(row.dataset.idx, 10); |
|
||||||
const it = items.find((x) => x.index === idx); |
|
||||||
if (!it || !qs[idx]) return; |
|
||||||
const node = qs[idx]; |
|
||||||
$('.q-text', node).value = it.suggested.text; |
|
||||||
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers; |
|
||||||
const optsEl = $('.q-options', node); |
|
||||||
optsEl.innerHTML = ''; |
|
||||||
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o))); |
|
||||||
}); |
|
||||||
modal.close(); |
|
||||||
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.'; |
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 5000); |
|
||||||
}, |
|
||||||
}, |
|
||||||
]); |
|
||||||
} catch (e) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
aiAlert(null, e.message); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
async function aiGenerateQuestion(node) { |
|
||||||
const qText = $('.q-text', node).value.trim(); |
|
||||||
const optsCount = Math.max(2, $$('.opt-item', node).length || 4); |
|
||||||
const multi = $('.q-multi', node).checked; |
|
||||||
aiStatusEl.textContent = 'AI: один вопрос…'; |
|
||||||
try { |
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, { |
|
||||||
method: 'POST', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify({ |
|
||||||
testTitle: titleEl.value, |
|
||||||
testDescription: descEl.value, |
|
||||||
questionText: qText, |
|
||||||
optionsCount: optsCount, |
|
||||||
hasMultipleAnswers: multi, |
|
||||||
}), |
|
||||||
}); |
|
||||||
const data = await r.json(); |
|
||||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.'); |
|
||||||
$('.q-text', node).value = data.text || ''; |
|
||||||
if (data.mode === 'full' && Array.isArray(data.options)) { |
|
||||||
const optsEl = $('.q-options', node); |
|
||||||
optsEl.innerHTML = ''; |
|
||||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o))); |
|
||||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers; |
|
||||||
} |
|
||||||
aiStatusEl.textContent = data.mode === 'full' ? 'AI: вопрос сгенерирован.' : 'AI: формулировка обновлена.'; |
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000); |
|
||||||
} catch (e) { |
|
||||||
aiStatusEl.textContent = ''; |
|
||||||
alert(e.message || 'AI: ошибка.'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
|
|
||||||
|
|
||||||
fetch(`/api/tests/${TEST_ID}/summary`) |
|
||||||
.then((r) => r.json()) |
|
||||||
.then((data) => { |
|
||||||
if (data && data.test && typeof data.test.chainActive === 'boolean') { |
|
||||||
chainActive = data.test.chainActive; |
|
||||||
chainActiveEl.checked = chainActive; |
|
||||||
} else { |
|
||||||
chainActiveEl.checked = true; |
|
||||||
chainActive = true; |
|
||||||
} |
|
||||||
}) |
|
||||||
.catch(() => { |
|
||||||
chainActiveEl.checked = true; |
|
||||||
}); |
|
||||||
|
|
||||||
loadInitial(); |
|
||||||
})(); |
|
||||||
@ -1,9 +0,0 @@ |
|||||||
{% extends "base.html" %} |
|
||||||
{% block title %}404 — страница не найдена{% endblock %} |
|
||||||
{% block content %} |
|
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center"> |
|
||||||
<span class="material-symbols-outlined text-5xl text-brand-600">search_off</span> |
|
||||||
<h1 class="mt-2 text-xl font-semibold">Страница не найдена</h1> |
|
||||||
<p class="mt-1 text-ink-500">Проверьте адрес или вернитесь на <a class="text-brand-600 hover:underline" href="{{ url_for('main.index') }}">главную</a>.</p> |
|
||||||
</section> |
|
||||||
{% endblock %} |
|
||||||
@ -1,9 +0,0 @@ |
|||||||
{% extends "base.html" %} |
|
||||||
{% block title %}500 — внутренняя ошибка{% endblock %} |
|
||||||
{% block content %} |
|
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-8 text-center"> |
|
||||||
<span class="material-symbols-outlined text-5xl text-red-600">error</span> |
|
||||||
<h1 class="mt-2 text-xl font-semibold">Что-то пошло не так</h1> |
|
||||||
<p class="mt-1 text-ink-500">Попробуйте обновить страницу. Если ошибка повторяется — посмотрите логи сервера.</p> |
|
||||||
</section> |
|
||||||
{% endblock %} |
|
||||||
@ -1,58 +0,0 @@ |
|||||||
{% extends "base.html" %} |
|
||||||
{% block title %}Вход — Тестирование{% endblock %} |
|
||||||
|
|
||||||
{% block content %} |
|
||||||
<section class="mx-auto max-w-md mt-8"> |
|
||||||
<div class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6"> |
|
||||||
<div class="flex items-center gap-2"> |
|
||||||
<span class="material-symbols-outlined text-brand-600">login</span> |
|
||||||
<h1 class="text-xl font-semibold">Вход в систему</h1> |
|
||||||
</div> |
|
||||||
<p class="mt-1 text-sm text-ink-500"> |
|
||||||
Используйте логин и пароль. |
|
||||||
{% if hr_auth_enabled %} |
|
||||||
Учётка кадровой системы (HR). |
|
||||||
{% endif %} |
|
||||||
</p> |
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %} |
|
||||||
{% if messages %} |
|
||||||
<div class="mt-4 space-y-2"> |
|
||||||
{% for category, msg in messages %} |
|
||||||
<div class="px-3 py-2 rounded-lg text-sm |
|
||||||
{% if category == 'error' %}bg-red-50 text-red-700 border border-red-200 |
|
||||||
{% else %}bg-brand-50 text-brand-700 border border-brand-100{% endif %}"> |
|
||||||
{{ msg }} |
|
||||||
</div> |
|
||||||
{% endfor %} |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
{% endwith %} |
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.login_submit') }}" class="mt-5 space-y-4" novalidate> |
|
||||||
<input type="hidden" name="next" value="{{ next or '/' }}"> |
|
||||||
|
|
||||||
<label class="block"> |
|
||||||
<span class="text-sm font-medium text-ink-700">Логин</span> |
|
||||||
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username" |
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900 |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> |
|
||||||
</label> |
|
||||||
|
|
||||||
<label class="block"> |
|
||||||
<span class="text-sm font-medium text-ink-700">Пароль</span> |
|
||||||
<input type="password" name="password" required autocomplete="current-password" |
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900 |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> |
|
||||||
</label> |
|
||||||
|
|
||||||
<button type="submit" |
|
||||||
class="w-full inline-flex items-center justify-center gap-2 rounded-lg |
|
||||||
bg-brand-600 hover:bg-brand-700 text-white font-medium px-4 py-2 transition"> |
|
||||||
<span class="material-symbols-outlined text-base">login</span> |
|
||||||
Войти |
|
||||||
</button> |
|
||||||
</form> |
|
||||||
</div> |
|
||||||
</section> |
|
||||||
{% endblock %} |
|
||||||
@ -1,115 +0,0 @@ |
|||||||
<!doctype html> |
|
||||||
<html lang="ru"> |
|
||||||
<head> |
|
||||||
<meta charset="utf-8" /> |
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> |
|
||||||
<title>{% block title %}Тестирование персонала{% endblock %}</title> |
|
||||||
|
|
||||||
{# Tailwind CDN — на E1.0 этого достаточно. В Этапе 2/CI можно заменить на сборку. #} |
|
||||||
<script src="https://cdn.tailwindcss.com"></script> |
|
||||||
<script> |
|
||||||
// Минимальная палитра в стиле кабинета HR. Без зависимостей от HR-репо. |
|
||||||
tailwind.config = { |
|
||||||
theme: { |
|
||||||
extend: { |
|
||||||
fontFamily: { |
|
||||||
sans: ['Manrope', 'Inter', 'system-ui', 'sans-serif'], |
|
||||||
}, |
|
||||||
colors: { |
|
||||||
brand: { |
|
||||||
50: '#eef2ff', |
|
||||||
100: '#e0e7ff', |
|
||||||
500: '#6366f1', |
|
||||||
600: '#4f46e5', |
|
||||||
700: '#4338ca', |
|
||||||
}, |
|
||||||
ink: { |
|
||||||
900: '#0f172a', |
|
||||||
700: '#334155', |
|
||||||
500: '#64748b', |
|
||||||
300: '#cbd5e1', |
|
||||||
100: '#f1f5f9', |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}; |
|
||||||
</script> |
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" /> |
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
|
||||||
<link |
|
||||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" |
|
||||||
rel="stylesheet" |
|
||||||
/> |
|
||||||
<link |
|
||||||
rel="stylesheet" |
|
||||||
href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined" |
|
||||||
/> |
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" /> |
|
||||||
{% block head %}{% endblock %} |
|
||||||
</head> |
|
||||||
<body class="min-h-screen bg-ink-100 text-ink-900 font-sans antialiased"> |
|
||||||
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-ink-300/60"> |
|
||||||
<div class="mx-auto max-w-6xl px-4 h-14 flex items-center justify-between"> |
|
||||||
<a href="{{ url_for('main.index') }}" class="flex items-center gap-2 font-semibold text-ink-900"> |
|
||||||
<span class="material-symbols-outlined text-brand-600">quiz</span> |
|
||||||
<span>Тестирование</span> |
|
||||||
</a> |
|
||||||
<nav class="flex items-center gap-1 sm:gap-2 text-sm"> |
|
||||||
{% if current_user %} |
|
||||||
<a href="{{ url_for('tests.tests_list_page') }}" |
|
||||||
class="inline-flex items-center justify-center gap-1 |
|
||||||
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg |
|
||||||
text-ink-700 hover:bg-ink-100" |
|
||||||
title="Каталог тестов" aria-label="Каталог тестов"> |
|
||||||
<span class="material-symbols-outlined text-base">list_alt</span> |
|
||||||
<span class="hidden sm:inline">Тесты</span> |
|
||||||
</a> |
|
||||||
<a href="{{ url_for('settings.settings_page') }}" |
|
||||||
class="inline-flex items-center justify-center gap-1 |
|
||||||
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg |
|
||||||
text-ink-700 hover:bg-ink-100" |
|
||||||
title="Настройки" aria-label="Настройки"> |
|
||||||
<span class="material-symbols-outlined text-base">settings</span> |
|
||||||
<span class="hidden sm:inline">Настройки</span> |
|
||||||
</a> |
|
||||||
<span class="hidden md:inline text-ink-500"> |
|
||||||
{{ current_user.full_name or current_user.login }} |
|
||||||
<span class="text-ink-300">·</span> |
|
||||||
<span class="text-brand-700">{{ current_user.role }}</span> |
|
||||||
</span> |
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}" class="inline"> |
|
||||||
<button type="submit" |
|
||||||
class="inline-flex items-center justify-center gap-1 |
|
||||||
min-w-10 min-h-10 px-2 sm:px-3 rounded-lg |
|
||||||
text-ink-700 hover:bg-ink-100 transition" |
|
||||||
title="Выйти" aria-label="Выйти"> |
|
||||||
<span class="material-symbols-outlined text-base">logout</span> |
|
||||||
<span class="hidden sm:inline">Выйти</span> |
|
||||||
</button> |
|
||||||
</form> |
|
||||||
{% else %} |
|
||||||
<a href="{{ url_for('auth.login_page') }}" |
|
||||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg |
|
||||||
text-brand-700 hover:bg-brand-50 transition min-h-10"> |
|
||||||
<span class="material-symbols-outlined text-base">login</span> |
|
||||||
Войти |
|
||||||
</a> |
|
||||||
{% endif %} |
|
||||||
</nav> |
|
||||||
</div> |
|
||||||
</header> |
|
||||||
|
|
||||||
<main class="mx-auto max-w-6xl px-4 py-6"> |
|
||||||
{% block content %}{% endblock %} |
|
||||||
</main> |
|
||||||
|
|
||||||
<footer class="mx-auto max-w-6xl px-4 py-8 text-xs text-ink-500"> |
|
||||||
{% block footer %}testing-flask-app · Этап 1{% endblock %} |
|
||||||
</footer> |
|
||||||
|
|
||||||
{% block scripts %}{% endblock %} |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -1,45 +1,9 @@ |
|||||||
{% extends "base.html" %} |
<!DOCTYPE html> |
||||||
{% block title %}Тестирование — главная{% endblock %} |
<html lang="ru"> |
||||||
|
<head> |
||||||
{% block content %} |
<meta charset="UTF-8" /> |
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6"> |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
<h1 class="text-2xl font-semibold text-ink-900">Сервис тестирования персонала</h1> |
<title>Тестирование</title> |
||||||
<p class="mt-2 text-ink-500"> |
</head> |
||||||
Этап 1 миграции: переход на единый стек (Flask + Jinja). Бизнес-функции |
<body></body> |
||||||
переносятся последовательно — авторизация, каталог тестов, редактор, |
</html> |
||||||
назначения, прохождение, импорт/AI. |
|
||||||
</p> |
|
||||||
|
|
||||||
<div class="mt-5 flex flex-wrap gap-2 text-sm"> |
|
||||||
<a href="{{ url_for('tests.tests_list_page') }}" |
|
||||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white transition"> |
|
||||||
<span class="material-symbols-outlined text-base">list_alt</span> |
|
||||||
Каталог тестов |
|
||||||
</a> |
|
||||||
<a href="{{ url_for('main.health') }}" |
|
||||||
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-50 text-brand-700 hover:bg-brand-100 transition"> |
|
||||||
<span class="material-symbols-outlined text-base">monitoring</span> |
|
||||||
Health-check |
|
||||||
</a> |
|
||||||
</div> |
|
||||||
</section> |
|
||||||
|
|
||||||
<section class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 mt-4"> |
|
||||||
{% for title, descr, icon in [ |
|
||||||
('Авторизация', 'E1.1 — логин по HR/локальному пользователю.', 'login'), |
|
||||||
('Тесты', 'E1.2 — каталог, фильтры, карточки.', 'list_alt'), |
|
||||||
('Редактор', 'E1.3 — создание/правка теста, AI-помощник.', 'edit_note'), |
|
||||||
('Назначения', 'E1.4 — назначить сотрудникам, отслеживать.', 'assignment'), |
|
||||||
('Прохождение', 'E1.5 — UI прохождения теста сотрудником.', 'fact_check'), |
|
||||||
('Импорт/AI', 'E1.6 — генерация черновиков из документов.', 'auto_awesome'), |
|
||||||
] %} |
|
||||||
<article class="rounded-xl bg-white border border-ink-300/60 p-4"> |
|
||||||
<div class="flex items-center gap-2"> |
|
||||||
<span class="material-symbols-outlined text-brand-600">{{ icon }}</span> |
|
||||||
<h3 class="font-semibold">{{ title }}</h3> |
|
||||||
</div> |
|
||||||
<p class="mt-1 text-sm text-ink-500">{{ descr }}</p> |
|
||||||
</article> |
|
||||||
{% endfor %} |
|
||||||
</section> |
|
||||||
{% endblock %} |
|
||||||
|
|||||||
@ -1,101 +0,0 @@ |
|||||||
{% extends "base.html" %} |
|
||||||
{% block title %}Настройки — LLM{% endblock %} |
|
||||||
|
|
||||||
{% block content %} |
|
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-6 max-w-2xl"> |
|
||||||
<div class="flex items-center gap-2"> |
|
||||||
<span class="material-symbols-outlined text-brand-600">settings</span> |
|
||||||
<h1 class="text-2xl font-semibold">Настройки</h1> |
|
||||||
</div> |
|
||||||
|
|
||||||
<h2 class="mt-5 font-semibold">Подключение к LLM</h2> |
|
||||||
<p class="mt-1 text-sm text-ink-500"> |
|
||||||
Ключ задаётся в <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> сервера |
|
||||||
(общий, не на пользователя). Поддерживаются DeepSeek и OpenAI-совместимые API. |
|
||||||
После изменения <code class="px-1 py-0.5 rounded bg-ink-100">.env</code> нужен рестарт процесса. |
|
||||||
</p> |
|
||||||
|
|
||||||
<dl class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm"> |
|
||||||
<dt class="text-ink-500">Статус ключа</dt> |
|
||||||
<dd> |
|
||||||
{% if configured %} |
|
||||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-50 text-green-700 border border-green-200"> |
|
||||||
<span class="material-symbols-outlined text-base">check_circle</span> Задан |
|
||||||
</span> |
|
||||||
{% else %} |
|
||||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-50 text-red-700 border border-red-200"> |
|
||||||
<span class="material-symbols-outlined text-base">error</span> Не задан |
|
||||||
</span> |
|
||||||
{% endif %} |
|
||||||
</dd> |
|
||||||
<dt class="text-ink-500">Провайдер</dt> |
|
||||||
<dd>{{ provider or '—' }}</dd> |
|
||||||
<dt class="text-ink-500">Модель</dt> |
|
||||||
<dd>{{ model or '—' }}</dd> |
|
||||||
<dt class="text-ink-500">Base URL</dt> |
|
||||||
<dd class="break-all">{{ base_url or '—' }}</dd> |
|
||||||
</dl> |
|
||||||
|
|
||||||
{% if not configured %} |
|
||||||
<div class="mt-5 rounded-lg bg-ink-100/60 border border-ink-300/60 p-4 text-sm"> |
|
||||||
<p class="font-medium">Как задать ключ</p> |
|
||||||
<pre class="mt-2 text-xs whitespace-pre-wrap font-mono">DEEPSEEK_API_KEY=sk-... |
|
||||||
# либо |
|
||||||
OPENAI_API_KEY=sk-... |
|
||||||
# опционально: |
|
||||||
# LLM_BASE_URL=https://api.deepseek.com/v1 |
|
||||||
# LLM_MODEL=deepseek-chat</pre> |
|
||||||
<p class="mt-2 text-ink-500"> |
|
||||||
Файл: <code>flask_app/.env</code>. После сохранения — рестарт процесса. |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
<div class="mt-5 flex items-center gap-3"> |
|
||||||
<button id="btn-ping" |
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg |
|
||||||
bg-brand-600 hover:bg-brand-700 text-white text-sm"> |
|
||||||
<span class="material-symbols-outlined text-base">cable</span> |
|
||||||
Проверить подключение |
|
||||||
</button> |
|
||||||
<span id="ping-status" class="text-sm text-ink-500"></span> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div id="ping-result" class="mt-4 hidden text-sm rounded-lg p-3 border"></div> |
|
||||||
</section> |
|
||||||
{% endblock %} |
|
||||||
|
|
||||||
{% block scripts %} |
|
||||||
<script> |
|
||||||
(() => { |
|
||||||
const btn = document.getElementById('btn-ping'); |
|
||||||
const status = document.getElementById('ping-status'); |
|
||||||
const result = document.getElementById('ping-result'); |
|
||||||
btn.addEventListener('click', async () => { |
|
||||||
status.textContent = 'Запрос…'; |
|
||||||
btn.disabled = true; |
|
||||||
try { |
|
||||||
const r = await fetch('/api/llm/ping', { method: 'POST' }); |
|
||||||
const d = await r.json(); |
|
||||||
result.classList.remove('hidden', 'bg-green-50', 'border-green-200', 'text-green-800', |
|
||||||
'bg-red-50', 'border-red-200', 'text-red-800'); |
|
||||||
if (d.ok) { |
|
||||||
result.classList.add('bg-green-50', 'border-green-200', 'text-green-800'); |
|
||||||
result.innerHTML = `<b>OK</b> · ${d.provider} / ${d.model} · ${d.latencyMs} мс` |
|
||||||
+ (d.sample ? `<br><span class="text-xs opacity-80">Ответ: ${d.sample.replace(/</g,'<')}</span>` : ''); |
|
||||||
} else { |
|
||||||
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800'); |
|
||||||
result.innerHTML = `<b>Ошибка</b> · ${d.code || ''}<br>${(d.error || '').replace(/</g,'<')}`; |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
result.classList.remove('hidden'); |
|
||||||
result.classList.add('bg-red-50', 'border-red-200', 'text-red-800'); |
|
||||||
result.textContent = e.message || 'Сбой запроса.'; |
|
||||||
} finally { |
|
||||||
btn.disabled = false; |
|
||||||
status.textContent = ''; |
|
||||||
} |
|
||||||
}); |
|
||||||
})(); |
|
||||||
</script> |
|
||||||
{% endblock %} |
|
||||||
@ -1,240 +0,0 @@ |
|||||||
{% extends "base.html" %} |
|
||||||
{% block title %}{{ content.test.title }} — редактор{% endblock %} |
|
||||||
|
|
||||||
{% block content %} |
|
||||||
<div id="editor-root" |
|
||||||
class="space-y-4 sm:space-y-5 pb-24" |
|
||||||
data-test-id="{{ test_id }}" |
|
||||||
data-initial='{{ content | tojson | safe }}'> |
|
||||||
|
|
||||||
{# ── 1. Шапка теста ─────────────────────────────────────────── #} |
|
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-5"> |
|
||||||
<h2 class="text-xs font-medium text-ink-500 uppercase tracking-wide">Тест</h2> |
|
||||||
|
|
||||||
<label class="mt-2 block"> |
|
||||||
<span class="sr-only">Название</span> |
|
||||||
<input id="test-title" type="text" maxlength="200" placeholder="Название теста" |
|
||||||
class="w-full rounded-lg border border-ink-300 px-3 py-3 text-lg font-semibold |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> |
|
||||||
</label> |
|
||||||
|
|
||||||
<label class="mt-3 block"> |
|
||||||
<span class="text-xs font-medium text-ink-500">Описание</span> |
|
||||||
<textarea id="test-description" rows="2" placeholder="Краткое описание (необязательно)" |
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea> |
|
||||||
</label> |
|
||||||
|
|
||||||
<label class="mt-3 flex items-center justify-between gap-3"> |
|
||||||
<span class="text-xs font-medium text-ink-500">Проходной балл, %</span> |
|
||||||
<input id="test-threshold" type="number" min="0" max="100" step="1" |
|
||||||
inputmode="numeric" |
|
||||||
class="w-24 text-right rounded-lg border border-ink-300 px-3 py-2 |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> |
|
||||||
</label> |
|
||||||
</section> |
|
||||||
|
|
||||||
{# ── 2. AI-помощник ─────────────────────────────────────────── #} |
|
||||||
<section class="rounded-2xl bg-brand-50/60 border border-brand-100 p-4 sm:p-5"> |
|
||||||
<div class="flex items-center gap-2"> |
|
||||||
<span class="material-symbols-outlined text-brand-600">auto_awesome</span> |
|
||||||
<h2 class="font-semibold text-brand-700">AI-помощник</h2> |
|
||||||
</div> |
|
||||||
|
|
||||||
{# Группа A — генерация. Главные действия. На sm+ — в одну строку. #} |
|
||||||
<div class="mt-3"> |
|
||||||
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Создать вопросы</p> |
|
||||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2"> |
|
||||||
<button id="ai-generate-by-title" |
|
||||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg |
|
||||||
bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium min-h-11"> |
|
||||||
<span class="material-symbols-outlined text-base">edit_note</span> |
|
||||||
По названию |
|
||||||
</button> |
|
||||||
<button id="ai-generate-test" |
|
||||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg |
|
||||||
bg-white border border-brand-300/60 text-brand-700 hover:bg-brand-50 |
|
||||||
text-sm font-medium min-h-11"> |
|
||||||
<span class="material-symbols-outlined text-base">stars</span> |
|
||||||
По текущей сетке |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
{# Группа B — анализ существующего. #} |
|
||||||
<div class="mt-4"> |
|
||||||
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Улучшить существующее</p> |
|
||||||
<div class="mt-2 grid grid-cols-2 gap-2"> |
|
||||||
<button id="ai-check" |
|
||||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg |
|
||||||
bg-white border border-ink-300/60 hover:border-brand-300 |
|
||||||
text-sm min-h-11"> |
|
||||||
<span class="material-symbols-outlined text-base">fact_check</span> |
|
||||||
Проверить |
|
||||||
</button> |
|
||||||
<button id="ai-improve" |
|
||||||
class="inline-flex items-center justify-center gap-2 px-3 py-3 rounded-lg |
|
||||||
bg-white border border-ink-300/60 hover:border-brand-300 |
|
||||||
text-sm min-h-11"> |
|
||||||
<span class="material-symbols-outlined text-base">tune</span> |
|
||||||
Улучшить |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
{# Группа C — импорт. #} |
|
||||||
<div class="mt-4"> |
|
||||||
<p class="text-xs font-medium text-ink-500 uppercase tracking-wide">Импортировать</p> |
|
||||||
<label class="mt-2 inline-flex w-full items-center justify-center gap-2 px-3 py-3 |
|
||||||
rounded-lg bg-white border border-ink-300/60 hover:border-brand-300 |
|
||||||
text-sm cursor-pointer min-h-11"> |
|
||||||
<span class="material-symbols-outlined text-base text-brand-600">upload_file</span> |
|
||||||
<span>Загрузить документ (PDF, DOCX, TXT, MD)</span> |
|
||||||
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" /> |
|
||||||
</label> |
|
||||||
<p class="mt-1.5 text-xs text-ink-500"> |
|
||||||
До 16 МБ. AI извлечёт текст и предложит черновик теста. |
|
||||||
</p> |
|
||||||
</div> |
|
||||||
|
|
||||||
<p id="ai-status" class="mt-3 text-sm text-ink-500 min-h-[1.25rem]"></p> |
|
||||||
</section> |
|
||||||
|
|
||||||
{# ── 3. Вопросы ─────────────────────────────────────────────── #} |
|
||||||
<section> |
|
||||||
<div class="flex items-center justify-between gap-2 px-1"> |
|
||||||
<h2 class="font-semibold">Вопросы (<span id="q-count">0</span>)</h2> |
|
||||||
<button id="add-question" |
|
||||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg |
|
||||||
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10"> |
|
||||||
<span class="material-symbols-outlined text-base">add</span> |
|
||||||
<span class="hidden sm:inline">Добавить вопрос</span> |
|
||||||
<span class="sm:hidden">Добавить</span> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
<ol id="questions" class="mt-3 space-y-3"></ol> |
|
||||||
</section> |
|
||||||
</div> |
|
||||||
|
|
||||||
{# ── Sticky-footer: «Цепочка активна» + «Сохранить» ────────────── #} |
|
||||||
<div class="fixed bottom-0 inset-x-0 z-30 bg-white/95 backdrop-blur border-t border-ink-300/60 |
|
||||||
pb-[env(safe-area-inset-bottom)]"> |
|
||||||
<div class="mx-auto max-w-6xl px-4 py-3 |
|
||||||
flex items-center justify-between gap-3"> |
|
||||||
<label class="inline-flex items-center gap-2 text-sm min-w-0"> |
|
||||||
<input id="chain-active" type="checkbox" |
|
||||||
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> |
|
||||||
<span class="truncate">Цепочка активна</span> |
|
||||||
</label> |
|
||||||
<div class="flex items-center gap-2 shrink-0"> |
|
||||||
<a href="{{ url_for('tests.tests_list_page') }}" |
|
||||||
class="hidden sm:inline-flex px-3 py-2 rounded-lg text-ink-700 hover:bg-ink-100 text-sm"> |
|
||||||
К каталогу |
|
||||||
</a> |
|
||||||
<button id="save-draft" |
|
||||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg |
|
||||||
bg-brand-600 hover:bg-brand-700 text-white font-medium min-h-11"> |
|
||||||
<span class="material-symbols-outlined text-base">save</span> |
|
||||||
Сохранить |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<p id="save-status" class="mx-auto max-w-6xl px-4 pb-2 text-xs text-ink-500"></p> |
|
||||||
</div> |
|
||||||
|
|
||||||
{# ── Шаблон вопроса ─────────────────────────────────────────────── #} |
|
||||||
<template id="tpl-question"> |
|
||||||
<li class="rounded-xl bg-white border border-ink-300/60 p-3 sm:p-4 q-item"> |
|
||||||
{# Шапка карточки вопроса: номер слева, кнопки справа. #} |
|
||||||
<div class="flex items-center justify-between gap-2"> |
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded-md |
|
||||||
bg-brand-50 text-brand-700 text-xs font-medium q-num">Вопрос #</span> |
|
||||||
<div class="flex items-center gap-0.5"> |
|
||||||
<button class="q-up p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" |
|
||||||
title="Выше" aria-label="Поднять выше"> |
|
||||||
<span class="material-symbols-outlined text-base">arrow_upward</span> |
|
||||||
</button> |
|
||||||
<button class="q-down p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" |
|
||||||
title="Ниже" aria-label="Опустить ниже"> |
|
||||||
<span class="material-symbols-outlined text-base">arrow_downward</span> |
|
||||||
</button> |
|
||||||
<button class="q-delete p-2 rounded hover:bg-red-50 text-red-600 min-w-10 min-h-10" |
|
||||||
title="Удалить" aria-label="Удалить вопрос"> |
|
||||||
<span class="material-symbols-outlined text-base">delete</span> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
<textarea class="q-text mt-3 w-full rounded-lg border border-ink-300 px-3 py-2 |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" |
|
||||||
rows="2" placeholder="Формулировка вопроса"></textarea> |
|
||||||
|
|
||||||
{# Тип ответа + AI — две полные строки на мобиле, в строку на sm+. #} |
|
||||||
<div class="mt-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm"> |
|
||||||
<label class="inline-flex items-center gap-2 min-h-9"> |
|
||||||
<input type="checkbox" |
|
||||||
class="q-multi rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> |
|
||||||
<span>Несколько правильных ответов</span> |
|
||||||
</label> |
|
||||||
<button class="q-ai inline-flex items-center justify-center gap-1 px-2.5 py-2 rounded-lg |
|
||||||
bg-brand-50 hover:bg-brand-100 text-brand-700 text-sm min-h-10"> |
|
||||||
<span class="material-symbols-outlined text-base">auto_awesome</span> |
|
||||||
AI: вопрос/переформулировать |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
|
|
||||||
<ul class="q-options mt-3 space-y-2"></ul> |
|
||||||
<button class="q-add-option mt-2 inline-flex items-center gap-1 px-2 py-2 rounded |
|
||||||
text-sm text-brand-700 hover:bg-brand-50 min-h-10"> |
|
||||||
<span class="material-symbols-outlined text-base">add</span> |
|
||||||
Добавить вариант |
|
||||||
</button> |
|
||||||
</li> |
|
||||||
</template> |
|
||||||
|
|
||||||
{# ── Шаблон варианта ────────────────────────────────────────────── #} |
|
||||||
<template id="tpl-option"> |
|
||||||
<li class="flex items-center gap-2 opt-item"> |
|
||||||
{# Чекбокс «Правильный» — обёрнут в большой tap-target. #} |
|
||||||
<label class="inline-flex items-center justify-center w-10 h-10 shrink-0 cursor-pointer |
|
||||||
rounded hover:bg-ink-100" title="Правильный ответ"> |
|
||||||
<input type="checkbox" |
|
||||||
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" /> |
|
||||||
</label> |
|
||||||
<input type="text" |
|
||||||
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2 |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" |
|
||||||
placeholder="Вариант ответа" /> |
|
||||||
<button class="opt-delete shrink-0 w-10 h-10 inline-flex items-center justify-center |
|
||||||
rounded hover:bg-red-50 text-red-600" |
|
||||||
title="Удалить" aria-label="Удалить вариант"> |
|
||||||
<span class="material-symbols-outlined text-base">close</span> |
|
||||||
</button> |
|
||||||
</li> |
|
||||||
</template> |
|
||||||
|
|
||||||
{# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #} |
|
||||||
<dialog id="ai-modal" |
|
||||||
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-3xl sm:w-full sm:max-h-[90vh] |
|
||||||
sm:rounded-2xl sm:m-auto bg-white backdrop:bg-black/50"> |
|
||||||
<div class="flex flex-col h-full sm:max-h-[90vh]"> |
|
||||||
<div class="flex items-center justify-between gap-3 px-4 sm:px-5 py-3 border-b border-ink-300/60"> |
|
||||||
<h3 id="ai-modal-title" class="text-lg font-semibold truncate">AI</h3> |
|
||||||
<button id="ai-modal-close" |
|
||||||
class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" |
|
||||||
aria-label="Закрыть"> |
|
||||||
<span class="material-symbols-outlined">close</span> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
<div id="ai-modal-body" class="flex-1 overflow-y-auto px-4 sm:px-5 py-4"></div> |
|
||||||
<div id="ai-modal-actions" |
|
||||||
class="px-4 sm:px-5 py-3 border-t border-ink-300/60 |
|
||||||
flex items-center justify-end gap-2 flex-wrap |
|
||||||
pb-[max(env(safe-area-inset-bottom),0.75rem)]"></div> |
|
||||||
</div> |
|
||||||
</dialog> |
|
||||||
{% endblock %} |
|
||||||
|
|
||||||
{% block scripts %} |
|
||||||
<script src="{{ url_for('static', filename='js/editor.js') }}"></script> |
|
||||||
{% endblock %} |
|
||||||
@ -1,141 +0,0 @@ |
|||||||
{% extends "base.html" %} |
|
||||||
{% block title %}Тесты — каталог{% endblock %} |
|
||||||
|
|
||||||
{% block content %} |
|
||||||
<section class="rounded-2xl bg-white shadow-sm border border-ink-300/60 p-4 sm:p-6"> |
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> |
|
||||||
<div> |
|
||||||
<h1 class="text-xl sm:text-2xl font-semibold">Каталог тестов</h1> |
|
||||||
<p class="mt-1 text-sm text-ink-500">Активные тесты, к которым у вас есть доступ.</p> |
|
||||||
</div> |
|
||||||
<button id="btn-create-test" |
|
||||||
class="inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg |
|
||||||
bg-brand-600 hover:bg-brand-700 text-white font-medium transition |
|
||||||
min-h-11 w-full sm:w-auto"> |
|
||||||
<span class="material-symbols-outlined text-base">add</span> |
|
||||||
Создать тест |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
|
|
||||||
{% if visible %} |
|
||||||
<ul class="mt-5 grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> |
|
||||||
{% for t in visible %} |
|
||||||
<li class="rounded-xl border border-ink-300/60 hover:border-brand-300 hover:shadow-sm transition bg-white"> |
|
||||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" |
|
||||||
class="block p-4 active:bg-ink-100/40"> |
|
||||||
<div class="flex items-start justify-between gap-2"> |
|
||||||
<h3 class="font-semibold text-ink-900 line-clamp-2 min-w-0">{{ t.title }}</h3> |
|
||||||
<span class="text-xs text-ink-500 shrink-0 mt-0.5">v{{ t.version }}</span> |
|
||||||
</div> |
|
||||||
{% if t.description %} |
|
||||||
<p class="mt-1 text-sm text-ink-500 line-clamp-3">{{ t.description }}</p> |
|
||||||
{% endif %} |
|
||||||
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-ink-500"> |
|
||||||
<span class="truncate">Автор: {{ t.author_full_name or '—' }}</span> |
|
||||||
<span class="inline-flex items-center gap-1 text-brand-700"> |
|
||||||
<span class="material-symbols-outlined text-sm">edit_note</span> |
|
||||||
Открыть |
|
||||||
</span> |
|
||||||
</div> |
|
||||||
</a> |
|
||||||
</li> |
|
||||||
{% endfor %} |
|
||||||
</ul> |
|
||||||
{% else %} |
|
||||||
<p class="mt-5 text-ink-500 text-sm">Доступных тестов пока нет.</p> |
|
||||||
{% endif %} |
|
||||||
|
|
||||||
{% if hidden %} |
|
||||||
<details class="mt-6 rounded-xl border border-ink-300/60 bg-ink-100/50 p-4"> |
|
||||||
<summary class="cursor-pointer font-medium text-ink-700"> |
|
||||||
Скрытые вами цепочки ({{ hidden|length }}) |
|
||||||
</summary> |
|
||||||
<ul class="mt-3 space-y-2"> |
|
||||||
{% for t in hidden %} |
|
||||||
<li class="flex items-center justify-between gap-2 text-sm"> |
|
||||||
<span>{{ t.title }} <span class="text-ink-500">· v{{ t.version }}</span></span> |
|
||||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" |
|
||||||
class="text-brand-700 hover:underline">Открыть</a> |
|
||||||
</li> |
|
||||||
{% endfor %} |
|
||||||
</ul> |
|
||||||
</details> |
|
||||||
{% endif %} |
|
||||||
</section> |
|
||||||
|
|
||||||
<dialog id="dlg-create" |
|
||||||
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-md sm:w-full sm:m-auto |
|
||||||
sm:rounded-2xl bg-white backdrop:bg-ink-900/50"> |
|
||||||
<form method="dialog" class="flex flex-col h-full sm:h-auto bg-white sm:rounded-2xl"> |
|
||||||
<div class="px-4 sm:px-5 py-3 border-b border-ink-300/60 flex items-center justify-between"> |
|
||||||
<h2 class="text-lg font-semibold">Новый тест</h2> |
|
||||||
<button type="button" id="dlg-cancel-x" |
|
||||||
class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" |
|
||||||
aria-label="Закрыть"> |
|
||||||
<span class="material-symbols-outlined">close</span> |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
<div class="flex-1 overflow-y-auto px-4 sm:px-5 py-4 space-y-3"> |
|
||||||
<label class="block"> |
|
||||||
<span class="text-sm font-medium text-ink-700">Название</span> |
|
||||||
<input id="new-test-title" type="text" required maxlength="200" |
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-3 |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> |
|
||||||
</label> |
|
||||||
<label class="block"> |
|
||||||
<span class="text-sm font-medium text-ink-700">Описание (опц.)</span> |
|
||||||
<textarea id="new-test-desc" rows="3" |
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 |
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea> |
|
||||||
</label> |
|
||||||
</div> |
|
||||||
<div class="px-4 sm:px-5 py-3 border-t border-ink-300/60 flex justify-end gap-2 |
|
||||||
bg-ink-100/40 sm:rounded-b-2xl |
|
||||||
pb-[max(env(safe-area-inset-bottom),0.75rem)]"> |
|
||||||
<button type="button" id="dlg-cancel" |
|
||||||
class="px-4 py-2.5 rounded-lg text-ink-700 hover:bg-ink-100 min-h-11">Отмена</button> |
|
||||||
<button type="button" id="dlg-submit" |
|
||||||
class="px-4 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white min-h-11"> |
|
||||||
Создать |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</form> |
|
||||||
</dialog> |
|
||||||
{% endblock %} |
|
||||||
|
|
||||||
{% block scripts %} |
|
||||||
<script> |
|
||||||
(() => { |
|
||||||
const dlg = document.getElementById('dlg-create'); |
|
||||||
const titleEl = document.getElementById('new-test-title'); |
|
||||||
const descEl = document.getElementById('new-test-desc'); |
|
||||||
|
|
||||||
document.getElementById('btn-create-test').addEventListener('click', () => { |
|
||||||
titleEl.value = ''; |
|
||||||
descEl.value = ''; |
|
||||||
if (typeof dlg.showModal === 'function') dlg.showModal(); |
|
||||||
else dlg.setAttribute('open', 'open'); |
|
||||||
setTimeout(() => titleEl.focus(), 50); |
|
||||||
}); |
|
||||||
document.getElementById('dlg-cancel').addEventListener('click', () => dlg.close()); |
|
||||||
document.getElementById('dlg-cancel-x').addEventListener('click', () => dlg.close()); |
|
||||||
|
|
||||||
document.getElementById('dlg-submit').addEventListener('click', async () => { |
|
||||||
const title = titleEl.value.trim(); |
|
||||||
if (!title) { titleEl.focus(); return; } |
|
||||||
try { |
|
||||||
const r = await fetch('/api/tests', { |
|
||||||
method: 'POST', |
|
||||||
headers: { 'Content-Type': 'application/json' }, |
|
||||||
body: JSON.stringify({ title, description: descEl.value.trim() || null }), |
|
||||||
}); |
|
||||||
const data = await r.json(); |
|
||||||
if (!r.ok) throw new Error(data.error || 'Не удалось создать тест.'); |
|
||||||
window.location.href = `/tests/${data.testId}/edit`; |
|
||||||
} catch (e) { |
|
||||||
alert(e.message || 'Не удалось создать тест.'); |
|
||||||
} |
|
||||||
}); |
|
||||||
})(); |
|
||||||
</script> |
|
||||||
{% endblock %} |
|
||||||
@ -1,2 +0,0 @@ |
|||||||
"""Blueprint `tests`: JSON API (`/api/tests/*`) и UI (`/tests`, `/tests/<id>/edit`).""" |
|
||||||
from .routes import tests_bp # noqa: F401 |
|
||||||
@ -1,465 +0,0 @@ |
|||||||
"""Маршруты тестов (E1.2). |
|
||||||
|
|
||||||
Покрытие Express → Flask: |
|
||||||
- GET /api/tests/ — каталог + hidden by you |
|
||||||
- POST /api/tests/ — создать тест (цепочку с версией 1) |
|
||||||
- GET /api/tests/<id>/summary — краткая карточка |
|
||||||
- GET /api/tests/<id>/versions — список версий + hasAttempts |
|
||||||
- GET /api/tests/<id>/editor — контент редактора |
|
||||||
- POST /api/tests/<id>/draft — saveTestDraft (fork если нужно) |
|
||||||
- POST /api/tests/<id>/versions/<vid>/activate |
|
||||||
- PATCH /api/tests/<id> — chainActive |
|
||||||
- POST /api/tests/<id>/ai/generate-test |
|
||||||
- POST /api/tests/<id>/ai/generate-question |
|
||||||
|
|
||||||
UI-страницы: |
|
||||||
- GET /tests — каталог |
|
||||||
- GET /tests/<id>/edit — редактор (вызывает /api/tests/...) |
|
||||||
""" |
|
||||||
from __future__ import annotations |
|
||||||
|
|
||||||
import logging |
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, render_template, request |
|
||||||
from sqlalchemy import text |
|
||||||
|
|
||||||
from ..auth.decorators import current_user, login_required |
|
||||||
from ..db import get_engine |
|
||||||
from ..messages import RU |
|
||||||
from ..services.ai_editor import ( |
|
||||||
HttpError as AiHttpError, |
|
||||||
check_test_quality, |
|
||||||
generate_full_test_by_shape, |
|
||||||
generate_or_rephrase_question, |
|
||||||
generate_test_by_title, |
|
||||||
improve_test_full, |
|
||||||
parse_and_validate_shape, |
|
||||||
) |
|
||||||
from ..services.document_extract import ( |
|
||||||
HttpError as DocExtractHttpError, |
|
||||||
extract_text_from_file, |
|
||||||
) |
|
||||||
from ..services.document_gen import generation_for_import_document |
|
||||||
from ..services.draft_validator import LlmError |
|
||||||
from ..services.editor_content import HttpError as EditorHttpError, get_editor_content |
|
||||||
from ..services.test_access import is_test_author, list_hidden_by_author, list_visible_tests |
|
||||||
from ..services.test_chain import has_any_attempt_for_test |
|
||||||
from ..services.test_draft import ( |
|
||||||
HttpError as DraftHttpError, |
|
||||||
create_test_with_version, |
|
||||||
save_test_draft, |
|
||||||
) |
|
||||||
|
|
||||||
log = logging.getLogger(__name__) |
|
||||||
|
|
||||||
tests_bp = Blueprint('tests', __name__) |
|
||||||
|
|
||||||
|
|
||||||
# ─── helpers ───────────────────────────────────────────────────────── |
|
||||||
|
|
||||||
def _stringify_uuids(d: dict) -> dict: |
|
||||||
"""Преобразует UUID-поля в строки для безопасной JSON-сериализации.""" |
|
||||||
out = {} |
|
||||||
for k, v in d.items(): |
|
||||||
if hasattr(v, 'hex') and not isinstance(v, (str, bytes)): |
|
||||||
out[k] = str(v) |
|
||||||
else: |
|
||||||
out[k] = v |
|
||||||
return out |
|
||||||
|
|
||||||
|
|
||||||
def _check_test_author_or_404(test_id: str, user_id: str) -> dict: |
|
||||||
"""Загружает {id, created_by}; 404 если нет, 403 если не автор.""" |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
row = conn.execute( |
|
||||||
text('SELECT id, created_by FROM tests WHERE id = :id'), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().first() |
|
||||||
if not row: |
|
||||||
from werkzeug.exceptions import NotFound |
|
||||||
|
|
||||||
raise NotFound(RU['notFound']) |
|
||||||
if not is_test_author(row['created_by'], user_id): |
|
||||||
from werkzeug.exceptions import Forbidden |
|
||||||
|
|
||||||
raise Forbidden('Доступ запрещён.') |
|
||||||
return dict(row) |
|
||||||
|
|
||||||
|
|
||||||
# ─── JSON API ──────────────────────────────────────────────────────── |
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/', methods=['GET']) |
|
||||||
@tests_bp.route('/api/tests', methods=['GET']) |
|
||||||
@login_required |
|
||||||
def api_list_tests(): |
|
||||||
user = current_user() |
|
||||||
visible = list_visible_tests(user.id) |
|
||||||
hidden = list_hidden_by_author(user.id) |
|
||||||
return jsonify( |
|
||||||
tests=[_stringify_uuids(r) for r in visible], |
|
||||||
hiddenByYou=[_stringify_uuids(r) for r in hidden], |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/', methods=['POST']) |
|
||||||
@tests_bp.route('/api/tests', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_create_test(): |
|
||||||
user = current_user() |
|
||||||
body = request.get_json(silent=True) or {} |
|
||||||
title = body.get('title') |
|
||||||
if not isinstance(title, str) or not title.strip(): |
|
||||||
return jsonify(error='Укажите название.'), 400 |
|
||||||
out = create_test_with_version(user.id, title=title.strip(), description=body.get('description')) |
|
||||||
return jsonify(out), 201 |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/summary', methods=['GET']) |
|
||||||
@login_required |
|
||||||
def api_test_summary(test_id): |
|
||||||
user = current_user() |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
row = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
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 = :id |
|
||||||
""" |
|
||||||
), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().first() |
|
||||||
if not row: |
|
||||||
return jsonify(error=RU['notFound']), 404 |
|
||||||
|
|
||||||
is_author = is_test_author(row['created_by'], user.id) |
|
||||||
if not row['chain_active'] and not is_author: |
|
||||||
return jsonify(error=RU['notFound']), 404 |
|
||||||
|
|
||||||
if not is_author: |
|
||||||
from ..services.test_access import user_has_test_access |
|
||||||
|
|
||||||
acc = user_has_test_access(user.id, test_id) |
|
||||||
if not acc.ok: |
|
||||||
return jsonify(error=RU['notFound']), 404 |
|
||||||
|
|
||||||
return jsonify( |
|
||||||
test={ |
|
||||||
'id': str(row['id']), |
|
||||||
'title': row['title'], |
|
||||||
'description': row['description'], |
|
||||||
'passingThreshold': row['passing_threshold'], |
|
||||||
'chainActive': row['chain_active'], |
|
||||||
'activeVersionId': str(row['active_version_id']) if row['active_version_id'] else None, |
|
||||||
'version': row['version'], |
|
||||||
'createdAt': row['created_at'].isoformat() if row['created_at'] else None, |
|
||||||
'updatedAt': row['updated_at'].isoformat() if row['updated_at'] else None, |
|
||||||
'createdBy': str(row['created_by']) if row['created_by'] else None, |
|
||||||
'authorFullName': row['author_full_name'], |
|
||||||
}, |
|
||||||
isAuthor=is_author, |
|
||||||
hasActiveVersion=row['active_version_id'] is not None, |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/versions', methods=['GET']) |
|
||||||
@login_required |
|
||||||
def api_test_versions(test_id): |
|
||||||
user = current_user() |
|
||||||
eng = get_engine() |
|
||||||
with eng.connect() as conn: |
|
||||||
t = conn.execute( |
|
||||||
text( |
|
||||||
""" |
|
||||||
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 = :id |
|
||||||
""" |
|
||||||
), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().first() |
|
||||||
if not t: |
|
||||||
return jsonify(error=RU['notFound']), 404 |
|
||||||
if not is_test_author(t['created_by'], user.id): |
|
||||||
return jsonify(error='Доступ запрещён.'), 403 |
|
||||||
|
|
||||||
rows = conn.execute( |
|
||||||
text( |
|
||||||
'SELECT id, version, is_active, parent_id, created_at ' |
|
||||||
'FROM test_versions WHERE test_id = :id ORDER BY version' |
|
||||||
), |
|
||||||
{'id': test_id}, |
|
||||||
).mappings().all() |
|
||||||
has_attempts = has_any_attempt_for_test(conn, test_id) |
|
||||||
|
|
||||||
return jsonify( |
|
||||||
test={ |
|
||||||
'id': str(t['id']), |
|
||||||
'title': t['title'], |
|
||||||
'description': t['description'], |
|
||||||
'chainActive': t['is_active'], |
|
||||||
'createdAt': t['created_at'].isoformat() if t['created_at'] else None, |
|
||||||
'updatedAt': t['updated_at'].isoformat() if t['updated_at'] else None, |
|
||||||
'createdBy': str(t['created_by']) if t['created_by'] else None, |
|
||||||
'authorFullName': t['author_full_name'], |
|
||||||
}, |
|
||||||
versions=[ |
|
||||||
{ |
|
||||||
'id': str(r['id']), |
|
||||||
'version': r['version'], |
|
||||||
'is_active': r['is_active'], |
|
||||||
'parent_id': str(r['parent_id']) if r['parent_id'] else None, |
|
||||||
'created_at': r['created_at'].isoformat() if r['created_at'] else None, |
|
||||||
} |
|
||||||
for r in rows |
|
||||||
], |
|
||||||
hasAttempts=has_attempts, |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/editor', methods=['GET']) |
|
||||||
@login_required |
|
||||||
def api_test_editor(test_id): |
|
||||||
user = current_user() |
|
||||||
try: |
|
||||||
out = get_editor_content(user.id, test_id) |
|
||||||
except EditorHttpError as e: |
|
||||||
return jsonify(error=e.message), e.status |
|
||||||
return jsonify(out) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/draft', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_save_draft(test_id): |
|
||||||
user = current_user() |
|
||||||
payload = request.get_json(silent=True) or {} |
|
||||||
try: |
|
||||||
out = save_test_draft(user.id, test_id, payload) |
|
||||||
except DraftHttpError as e: |
|
||||||
return jsonify(error=e.message), e.status |
|
||||||
return jsonify(out) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/versions/<version_id>/activate', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_activate_version(test_id, version_id): |
|
||||||
user = current_user() |
|
||||||
_check_test_author_or_404(test_id, user.id) |
|
||||||
eng = get_engine() |
|
||||||
with eng.begin() as conn: |
|
||||||
v = conn.execute( |
|
||||||
text('SELECT id FROM test_versions WHERE test_id = :t AND id = :v'), |
|
||||||
{'t': test_id, 'v': version_id}, |
|
||||||
).first() |
|
||||||
if not v: |
|
||||||
return jsonify(error='Версия не найдена.'), 404 |
|
||||||
conn.execute( |
|
||||||
text('UPDATE test_versions SET is_active = false WHERE test_id = :t'), |
|
||||||
{'t': test_id}, |
|
||||||
) |
|
||||||
conn.execute( |
|
||||||
text('UPDATE test_versions SET is_active = true WHERE id = :v'), |
|
||||||
{'v': version_id}, |
|
||||||
) |
|
||||||
return jsonify(ok=True, activeVersionId=str(version_id)) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>', methods=['PATCH']) |
|
||||||
@login_required |
|
||||||
def api_patch_test(test_id): |
|
||||||
user = current_user() |
|
||||||
body = request.get_json(silent=True) or {} |
|
||||||
chain = body.get('chainActive', body.get('isActive')) |
|
||||||
if not isinstance(chain, bool): |
|
||||||
return jsonify(error='Передайте chainActive: true/false в теле запроса.'), 400 |
|
||||||
_check_test_author_or_404(test_id, user.id) |
|
||||||
eng = get_engine() |
|
||||||
with eng.begin() as conn: |
|
||||||
conn.execute( |
|
||||||
text( |
|
||||||
'UPDATE tests SET is_active = :v, updated_at = CURRENT_TIMESTAMP WHERE id = :id' |
|
||||||
), |
|
||||||
{'v': chain, 'id': test_id}, |
|
||||||
) |
|
||||||
return jsonify(id=test_id, chainActive=chain) |
|
||||||
|
|
||||||
|
|
||||||
# ─── AI ────────────────────────────────────────────────────────────── |
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-test', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_ai_generate_test(test_id): |
|
||||||
user = current_user() |
|
||||||
_check_test_author_or_404(test_id, user.id) |
|
||||||
body = request.get_json(silent=True) or {} |
|
||||||
try: |
|
||||||
shape = parse_and_validate_shape(body.get('shape')) |
|
||||||
draft = generate_full_test_by_shape( |
|
||||||
body.get('testTitle') or '', |
|
||||||
body.get('testDescription') or '', |
|
||||||
shape, |
|
||||||
) |
|
||||||
except (AiHttpError, LlmError) as e: |
|
||||||
return _ai_error_response(e) |
|
||||||
return jsonify(ok=True, draft=draft) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-question', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_ai_generate_question(test_id): |
|
||||||
user = current_user() |
|
||||||
_check_test_author_or_404(test_id, user.id) |
|
||||||
body = request.get_json(silent=True) or {} |
|
||||||
try: |
|
||||||
out = generate_or_rephrase_question( |
|
||||||
body.get('testTitle') or '', |
|
||||||
body.get('testDescription') or '', |
|
||||||
body.get('questionText') or '', |
|
||||||
body.get('optionsCount'), |
|
||||||
bool(body.get('hasMultipleAnswers')), |
|
||||||
) |
|
||||||
except (AiHttpError, LlmError) as e: |
|
||||||
return _ai_error_response(e) |
|
||||||
return jsonify(ok=True, **out) |
|
||||||
|
|
||||||
|
|
||||||
# ─── AI v2 (E1.8) ──────────────────────────────────────────────────── |
|
||||||
|
|
||||||
def _ai_error_response(e): |
|
||||||
"""Единый JSON-формат ошибки для AI-эндпоинтов.""" |
|
||||||
if isinstance(e, AiHttpError): |
|
||||||
return jsonify(error=e.message), e.status |
|
||||||
if isinstance(e, LlmError): |
|
||||||
log.warning('LLM error: %s (%s)', e, e.code) |
|
||||||
return ( |
|
||||||
jsonify(error=str(e), code=e.code, settingsUrl='/settings'), |
|
||||||
e.status or 502, |
|
||||||
) |
|
||||||
raise e |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/ai/generate-by-title', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_ai_generate_by_title(test_id): |
|
||||||
user = current_user() |
|
||||||
_check_test_author_or_404(test_id, user.id) |
|
||||||
body = request.get_json(silent=True) or {} |
|
||||||
title = (body.get('testTitle') or '').strip() |
|
||||||
if not title: |
|
||||||
return jsonify(error='Заполните название теста.'), 400 |
|
||||||
try: |
|
||||||
draft = generate_test_by_title( |
|
||||||
title, |
|
||||||
body.get('testDescription') or '', |
|
||||||
int(body.get('questionsCount') or 10), |
|
||||||
int(body.get('optionsCount') or 4), |
|
||||||
bool(body.get('hasMultipleAnswers')), |
|
||||||
) |
|
||||||
except (AiHttpError, LlmError) as e: |
|
||||||
return _ai_error_response(e) |
|
||||||
return jsonify(ok=True, draft=draft) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/ai/check', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_ai_check_test(test_id): |
|
||||||
user = current_user() |
|
||||||
_check_test_author_or_404(test_id, user.id) |
|
||||||
body = request.get_json(silent=True) or {} |
|
||||||
try: |
|
||||||
review = check_test_quality( |
|
||||||
body.get('testTitle') or '', |
|
||||||
body.get('testDescription') or '', |
|
||||||
body.get('questions') or [], |
|
||||||
) |
|
||||||
except (AiHttpError, LlmError) as e: |
|
||||||
return _ai_error_response(e) |
|
||||||
return jsonify(ok=True, review=review) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/ai/improve', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_ai_improve_test(test_id): |
|
||||||
user = current_user() |
|
||||||
_check_test_author_or_404(test_id, user.id) |
|
||||||
body = request.get_json(silent=True) or {} |
|
||||||
try: |
|
||||||
out = improve_test_full( |
|
||||||
body.get('testTitle') or '', |
|
||||||
body.get('testDescription') or '', |
|
||||||
body.get('questions') or [], |
|
||||||
) |
|
||||||
except (AiHttpError, LlmError) as e: |
|
||||||
return _ai_error_response(e) |
|
||||||
return jsonify(ok=True, **out) |
|
||||||
|
|
||||||
|
|
||||||
# ─── Импорт документа (E1.3) ──────────────────────────────────────── |
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/import/document', methods=['POST']) |
|
||||||
@login_required |
|
||||||
def api_import_document(): |
|
||||||
"""PDF/DOCX/TXT/MD → извлечённый текст + AI-черновик (если задан LLM-ключ). |
|
||||||
|
|
||||||
Ограничения: размер файла — `MAX_CONTENT_LENGTH = 16 МБ` (см. фабрику). |
|
||||||
""" |
|
||||||
f = request.files.get('file') |
|
||||||
if f is None or not f.filename: |
|
||||||
return jsonify(error='Прикрепите файл к полю file.'), 400 |
|
||||||
try: |
|
||||||
extracted = extract_text_from_file(f.mimetype, f, f.filename) |
|
||||||
except DocExtractHttpError as e: |
|
||||||
return jsonify(error=e.message), e.status |
|
||||||
except Exception: |
|
||||||
log.exception('extract_text_from_file failed') |
|
||||||
return jsonify(error='Не удалось разобрать файл.'), 500 |
|
||||||
|
|
||||||
generation = generation_for_import_document(extracted) |
|
||||||
return jsonify( |
|
||||||
received=True, |
|
||||||
originalName=f.filename, |
|
||||||
mime=f.mimetype, |
|
||||||
size=len(extracted.encode('utf-8')), |
|
||||||
extractedText=extracted, |
|
||||||
textLength=len(extracted), |
|
||||||
generation=generation, |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
# ─── UI (Jinja) ────────────────────────────────────────────────────── |
|
||||||
|
|
||||||
@tests_bp.route('/tests', methods=['GET']) |
|
||||||
@login_required |
|
||||||
def tests_list_page(): |
|
||||||
user = current_user() |
|
||||||
visible = list_visible_tests(user.id) |
|
||||||
hidden = list_hidden_by_author(user.id) |
|
||||||
return render_template( |
|
||||||
'tests/list.html', |
|
||||||
visible=[_stringify_uuids(r) for r in visible], |
|
||||||
hidden=[_stringify_uuids(r) for r in hidden], |
|
||||||
) |
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/tests/<test_id>/edit', methods=['GET']) |
|
||||||
@login_required |
|
||||||
def tests_editor_page(test_id): |
|
||||||
user = current_user() |
|
||||||
try: |
|
||||||
content = get_editor_content(user.id, test_id) |
|
||||||
except EditorHttpError as e: |
|
||||||
if e.status == 404: |
|
||||||
return render_template('404.html'), 404 |
|
||||||
if e.status == 403: |
|
||||||
return ('Доступ запрещён.', 403) |
|
||||||
return render_template('500.html'), 500 |
|
||||||
return render_template('tests/editor.html', content=content, test_id=test_id) |
|
||||||
Loading…
Reference in new issue