bugfix
This commit is contained in:
@@ -0,0 +1,220 @@
|
|||||||
|
# Спринты по bugfix-задачам (Тесты / Доступ / Генерация)
|
||||||
|
|
||||||
|
Дата: 2026-04-30
|
||||||
|
Контур: `flask_app` (UI + API + сервисы генерации)
|
||||||
|
|
||||||
|
## Цели пакета
|
||||||
|
|
||||||
|
1. Дать доступ всем авторизованным пользователям ко всем активным тестам (без назначений).
|
||||||
|
2. Привести поведение шаблона генерации теста к ожидаемому (кол-во вопросов/вариантов/правильных ответов).
|
||||||
|
3. Добавить массовые настройки «несколько вариантов ответа».
|
||||||
|
4. Улучшить поток работы с подсказками и прозрачность прогресса генерации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спринт 1 — Доступ всем авторизованным (без назначений)
|
||||||
|
|
||||||
|
### Объём
|
||||||
|
|
||||||
|
- Убрать зависимость прохождения теста от назначений (`TestAssignment*`).
|
||||||
|
- Разрешить доступ к активным тестам для любого авторизованного пользователя.
|
||||||
|
- Проверить, что каталог и старт попытки работают консистентно с новой политикой.
|
||||||
|
|
||||||
|
### Задачи
|
||||||
|
|
||||||
|
1. **Политика доступа**
|
||||||
|
- В `user_has_test_access` вернуть `ok=True` для любого активного теста, если тест существует и пользователь авторизован.
|
||||||
|
- Оставить проверки авторства только там, где они нужны для редактора/версий/админских действий.
|
||||||
|
|
||||||
|
2. **Проверка API прохождения**
|
||||||
|
- `start_attempt`, `play`, `submit`, `review` не должны требовать назначения на пользователя.
|
||||||
|
- Ошибка «Доступ запрещён» не возникает для обычного авторизованного сотрудника при прохождении активного теста.
|
||||||
|
|
||||||
|
### Критерии приёмки
|
||||||
|
|
||||||
|
- Любой авторизованный пользователь может открыть и пройти любой активный тест, даже без назначения.
|
||||||
|
- При этом операции автора (редактор, версии, массовые действия) остаются ограничены автором.
|
||||||
|
|
||||||
|
### Оценка
|
||||||
|
|
||||||
|
- 0.5 дня.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спринт 2 — Шаблон генерации: контракт и предсказуемость
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
|
||||||
|
Сейчас пользователь создаёт шаблон (например, 12×4), затем генерирует из документа и получает иной результат (например, 10×3). Ожидания и фактический контракт не совпадают.
|
||||||
|
|
||||||
|
### Объём
|
||||||
|
|
||||||
|
- Зафиксировать единый контракт: какие параметры шаблона обязательны для генератора.
|
||||||
|
- Принудительно соблюдать:
|
||||||
|
- количество вопросов,
|
||||||
|
- количество вариантов в вопросе,
|
||||||
|
- границы количества правильных ответов.
|
||||||
|
- Добавить валидацию и пост-проверку результата генерации.
|
||||||
|
|
||||||
|
### Задачи
|
||||||
|
|
||||||
|
1. **Формальный контракт шаблона**
|
||||||
|
- Явно определить обязательные поля shape:
|
||||||
|
- `questions_count`
|
||||||
|
- `options_per_question`
|
||||||
|
- `multiple_answers_default`
|
||||||
|
- `correct_answers_min/max`
|
||||||
|
- Хранить shape вместе с тестом/версией как источник истины.
|
||||||
|
|
||||||
|
2. **Генерация по документу с shape**
|
||||||
|
- Передавать shape в генератор при `generate from document`.
|
||||||
|
- После генерации валидировать фактическую структуру.
|
||||||
|
- При расхождении:
|
||||||
|
- либо автоматически нормализовать (добить/сжать до нужного формата),
|
||||||
|
- либо показать понятную ошибку и не сохранять черновик.
|
||||||
|
|
||||||
|
3. **Пользовательская обратная связь**
|
||||||
|
- На экране до запуска показывать «Будет сгенерировано: 12 вопросов, 4 варианта».
|
||||||
|
- После завершения показывать фактический итог и предупреждение, если пришлось авто-нормализовать.
|
||||||
|
|
||||||
|
### Критерии приёмки
|
||||||
|
|
||||||
|
- Для шаблона 12×4 итоговый тест всегда 12 вопросов по 4 варианта.
|
||||||
|
- Несоответствие не проходит «тихо»: либо авто-исправление с уведомлением, либо явная ошибка.
|
||||||
|
|
||||||
|
### Оценка
|
||||||
|
|
||||||
|
- 1.5–2.5 дня.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спринт 3 — Массовый контроль «Несколько вариантов ответа»
|
||||||
|
|
||||||
|
### Объём
|
||||||
|
|
||||||
|
- Добавить глобальный чекбокс «Несколько вариантов ответа» (для всех вопросов).
|
||||||
|
- В шаблоне добавить диапазон правильных ответов: `от _ до _`.
|
||||||
|
- При отключении мультивыбора на конкретном вопросе нижняя граница «от» фиксируется в `1`.
|
||||||
|
|
||||||
|
### Задачи
|
||||||
|
|
||||||
|
1. **UI шаблона**
|
||||||
|
- Глобальный switch/checkbox: «Несколько вариантов ответа для всех вопросов».
|
||||||
|
- Поля диапазона:
|
||||||
|
- `Мин. правильных` (от),
|
||||||
|
- `Макс. правильных` (до),
|
||||||
|
- валидация `1 <= min <= max <= options_per_question`.
|
||||||
|
|
||||||
|
2. **Применение к вопросам**
|
||||||
|
- При включении глобального флага обновлять все вопросы:
|
||||||
|
- `hasMultipleAnswers=true`.
|
||||||
|
- При выключении:
|
||||||
|
- `hasMultipleAnswers=false`,
|
||||||
|
- `minCorrect=1`, `maxCorrect=1`.
|
||||||
|
- На уровне отдельного вопроса разрешить override.
|
||||||
|
|
||||||
|
3. **Правило «заморозки min=1»**
|
||||||
|
- Если на вопросе `hasMultipleAnswers=false`, то:
|
||||||
|
- `minCorrect` автоматически = `1`,
|
||||||
|
- поле `minCorrect` read-only/disabled.
|
||||||
|
|
||||||
|
4. **Серверная валидация**
|
||||||
|
- API сохраняет/проверяет тот же инвариант.
|
||||||
|
- Невалидные комбинации отклоняются с понятным сообщением.
|
||||||
|
|
||||||
|
### Критерии приёмки
|
||||||
|
|
||||||
|
- Глобальный флаг влияет на все вопросы.
|
||||||
|
- Локальное отключение мультивыбора фиксирует `min=1`.
|
||||||
|
- Генератор и редактор работают в одинаковой логике, без рассинхрона.
|
||||||
|
|
||||||
|
### Оценка
|
||||||
|
|
||||||
|
- 1.5–2 дня.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спринт 4 — Подсказки и прогресс генерации
|
||||||
|
|
||||||
|
### Объём
|
||||||
|
|
||||||
|
- В параметрах теста при включённых подсказках показать действие «Сгенерировать подсказки».
|
||||||
|
- Генерация подсказок работает только по заполненным вопросам.
|
||||||
|
- Если тест генерируется с нуля и подсказки уже включены — подсказки генерируются в том же пайплайне.
|
||||||
|
- Добавить прогресс по этапам генерации + локальные индикаторы загрузки в соответствующих блоках UI.
|
||||||
|
|
||||||
|
### Задачи
|
||||||
|
|
||||||
|
1. **Кнопка/ссылка «Сгенерировать подсказки»**
|
||||||
|
- Показ только при `hintsEnabled=true`.
|
||||||
|
- Показ количества: `N без подсказок`.
|
||||||
|
- Запуск только по вопросам, где есть текст + варианты.
|
||||||
|
|
||||||
|
2. **Пайплайн генерации «с нуля»**
|
||||||
|
- Если `hintsEnabled=true`, после генерации вопросов автоматически запускать генерацию подсказок.
|
||||||
|
- Ошибки подсказок не должны ломать весь тест: частичный результат допустим с отчётом.
|
||||||
|
|
||||||
|
3. **Прогресс и статусы**
|
||||||
|
- Этапы:
|
||||||
|
- подготовка документа,
|
||||||
|
- извлечение текста,
|
||||||
|
- генерация структуры,
|
||||||
|
- генерация вопросов,
|
||||||
|
- генерация подсказок,
|
||||||
|
- финализация.
|
||||||
|
- В UI показывать текущий этап и процент/счётчик.
|
||||||
|
- Спиннеры показывать только у активного блока (не глобально на всю форму).
|
||||||
|
|
||||||
|
4. **Наблюдаемость**
|
||||||
|
- Логи этапов и длительности.
|
||||||
|
- В ответ API возвращать breakdown по шагам/ошибкам.
|
||||||
|
|
||||||
|
### Критерии приёмки
|
||||||
|
|
||||||
|
- Пользователь видит, что именно сейчас генерируется.
|
||||||
|
- Подсказки отдельно запускаются и генерируются только для валидных вопросов.
|
||||||
|
- При генерации «с нуля» с включёнными подсказками подсказки появляются автоматически.
|
||||||
|
|
||||||
|
### Оценка
|
||||||
|
|
||||||
|
- 2–3 дня.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спринт 5 — Регрессия, UX-полировка, выпуск
|
||||||
|
|
||||||
|
### Объём
|
||||||
|
|
||||||
|
- Сквозное тестирование сценариев.
|
||||||
|
- Документация для пользователей и команды.
|
||||||
|
- Подготовка релиз-нота.
|
||||||
|
|
||||||
|
### Тест-кейсы (минимум)
|
||||||
|
|
||||||
|
1. Неавторизованный пользователь открывает приватные URL → редирект на логин.
|
||||||
|
2. Каталог тестов: есть переход «На главную».
|
||||||
|
3. Шаблон 12×4 + генерация из PDF → на выходе 12×4.
|
||||||
|
4. Глобальный мультивыбор + локальное выключение на 1 вопросе → `min=1` на этом вопросе.
|
||||||
|
5. Включены подсказки:
|
||||||
|
- кнопка «Сгенерировать подсказки» доступна,
|
||||||
|
- генерируются только по заполненным вопросам.
|
||||||
|
6. Генерация с нуля + подсказки включены → подсказки сгенерированы в том же запуске.
|
||||||
|
7. Прогресс этапов отображается корректно, загрузка локальная.
|
||||||
|
|
||||||
|
### Оценка
|
||||||
|
|
||||||
|
- 1–1.5 дня.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Приоритеты
|
||||||
|
|
||||||
|
- **P0:** Спринт 1 (доступ всем авторизованным), критичный функциональный bugfix.
|
||||||
|
- **P1:** Спринт 2 (контракт шаблона), устранение основного функционального несоответствия.
|
||||||
|
- **P1:** Спринт 3 (массовый мультивыбор), важная продуктовая логика.
|
||||||
|
- **P2:** Спринт 4 (подсказки + прогресс), прозрачность и удобство.
|
||||||
|
- **P2:** Спринт 5 (регрессия + выпуск).
|
||||||
|
|
||||||
|
## Суммарная оценка
|
||||||
|
|
||||||
|
Ориентир: **6.5–10 рабочих дней** (в зависимости от объёма автотестов и глубины рефакторинга генератора).
|
||||||
@@ -33,7 +33,30 @@ def parse_and_validate_shape(s: Any) -> list[dict]:
|
|||||||
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
||||||
if n < 2 or n > 12:
|
if n < 2 or n > 12:
|
||||||
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
||||||
out.append({'optionsCount': n, 'hasMultipleAnswers': bool(row.get('hasMultipleAnswers'))})
|
has_multi = bool(row.get('hasMultipleAnswers'))
|
||||||
|
raw_min = row.get('minCorrect', 1)
|
||||||
|
raw_max = row.get('maxCorrect', n if has_multi else 1)
|
||||||
|
try:
|
||||||
|
min_c = int(float(raw_min))
|
||||||
|
max_c = int(float(raw_max))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HttpError(400, f'shape[{i}]: minCorrect/maxCorrect должны быть числами.')
|
||||||
|
if not has_multi:
|
||||||
|
min_c = 1
|
||||||
|
max_c = 1
|
||||||
|
if min_c < 1 or max_c < 1 or min_c > max_c or max_c > n:
|
||||||
|
raise HttpError(
|
||||||
|
400,
|
||||||
|
f'shape[{i}]: корректный диапазон правильных ответов — от 1 до {n}, min<=max.',
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
'optionsCount': n,
|
||||||
|
'hasMultipleAnswers': has_multi,
|
||||||
|
'minCorrect': min_c,
|
||||||
|
'maxCorrect': max_c,
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +74,10 @@ def generate_full_test_by_shape(test_title: str, test_description: str, shape: l
|
|||||||
lines = []
|
lines = []
|
||||||
for i, sh in enumerate(shape):
|
for i, sh in enumerate(shape):
|
||||||
if sh['hasMultipleAnswers']:
|
if sh['hasMultipleAnswers']:
|
||||||
tail = 'несколько вариантов помечены как верные (hasMultipleAnswers: true).'
|
tail = (
|
||||||
|
'несколько вариантов помечены как верные (hasMultipleAnswers: true), '
|
||||||
|
f'число правильных от {sh["minCorrect"]} до {sh["maxCorrect"]}.'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
tail = 'ровно один верный вариант (hasMultipleAnswers: false).'
|
tail = 'ровно один верный вариант (hasMultipleAnswers: false).'
|
||||||
lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}')
|
lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .draft_validator import (
|
from .draft_validator import (
|
||||||
|
normalize_draft_to_shape,
|
||||||
parse_json_from_llm_text,
|
parse_json_from_llm_text,
|
||||||
validate_and_normalize_draft,
|
validate_and_normalize_draft,
|
||||||
)
|
)
|
||||||
@@ -11,7 +12,7 @@ from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
|||||||
MAX_EXTRACT_CHARS = 14000
|
MAX_EXTRACT_CHARS = 14000
|
||||||
|
|
||||||
|
|
||||||
def generation_for_import_document(extracted_text: str, user_hint: str = '') -> dict:
|
def generation_for_import_document(extracted_text: str, user_hint: str = '', shape: list[dict] | None = None) -> dict:
|
||||||
text = (extracted_text or '').strip()
|
text = (extracted_text or '').strip()
|
||||||
if not text:
|
if not text:
|
||||||
return {
|
return {
|
||||||
@@ -42,13 +43,27 @@ def generation_for_import_document(extracted_text: str, user_hint: str = '') ->
|
|||||||
'Текст и формулировки — на русском, по содержанию входного материала.'
|
'Текст и формулировки — на русском, по содержанию входного материала.'
|
||||||
)
|
)
|
||||||
hint_block = f'\n\nДополнительные инструкции от автора теста:\n{user_hint.strip()}' if user_hint and user_hint.strip() else ''
|
hint_block = f'\n\nДополнительные инструкции от автора теста:\n{user_hint.strip()}' if user_hint and user_hint.strip() else ''
|
||||||
|
shape_block = ''
|
||||||
|
if shape:
|
||||||
|
rows = []
|
||||||
|
for i, sh in enumerate(shape):
|
||||||
|
if sh.get('hasMultipleAnswers'):
|
||||||
|
rows.append(
|
||||||
|
f'- Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов, '
|
||||||
|
f'правильных от {sh.get("minCorrect", 1)} до {sh.get("maxCorrect", sh["optionsCount"])}.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows.append(f'- Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов, ровно 1 правильный.')
|
||||||
|
shape_block = '\n\nСтрого соблюди шаблон:\n' + '\n'.join(rows)
|
||||||
user = (
|
user = (
|
||||||
'Составь тест с вопросами с одним или несколькими правильными ответами '
|
'Составь тест с вопросами с одним или несколькими правильными ответами '
|
||||||
'на основе текста:\n\n' + slice_ + hint_block
|
'на основе текста:\n\n' + slice_ + hint_block + shape_block
|
||||||
)
|
)
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
||||||
parsed = parse_json_from_llm_text(raw)
|
parsed = parse_json_from_llm_text(raw)
|
||||||
draft = validate_and_normalize_draft(parsed)
|
draft = validate_and_normalize_draft(parsed)
|
||||||
|
if shape:
|
||||||
|
draft = normalize_draft_to_shape(draft, shape)
|
||||||
return {
|
return {
|
||||||
'available': True,
|
'available': True,
|
||||||
'message': (
|
'message': (
|
||||||
|
|||||||
@@ -103,3 +103,65 @@ def assert_draft_matches_shape(o: dict, shape: list[dict]) -> None:
|
|||||||
f'Вопрос {i + 1}: hasMultipleAnswers должен быть {sh["hasMultipleAnswers"]}.',
|
f'Вопрос {i + 1}: hasMultipleAnswers должен быть {sh["hasMultipleAnswers"]}.',
|
||||||
code='llm_shape',
|
code='llm_shape',
|
||||||
)
|
)
|
||||||
|
min_c = int(sh.get('minCorrect', 1))
|
||||||
|
max_c = int(sh.get('maxCorrect', sh['optionsCount']))
|
||||||
|
correct_n = sum(1 for op in opts if bool(op.get('isCorrect')))
|
||||||
|
if correct_n < min_c or correct_n > max_c:
|
||||||
|
raise LlmError(
|
||||||
|
f'Вопрос {i + 1}: правильных ответов должно быть от {min_c} до {max_c}, в ответе: {correct_n}.',
|
||||||
|
code='llm_shape',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_draft_to_shape(draft: dict, shape: list[dict]) -> dict:
|
||||||
|
"""Приводит draft к shape: число вопросов/вариантов/мульти и диапазон correct."""
|
||||||
|
qs = list((draft or {}).get('questions') or [])
|
||||||
|
out_qs = []
|
||||||
|
|
||||||
|
def _mk_option(i: int) -> dict:
|
||||||
|
return {'text': f'Вариант {i + 1}', 'isCorrect': False}
|
||||||
|
|
||||||
|
for i, sh in enumerate(shape):
|
||||||
|
src = qs[i] if i < len(qs) and isinstance(qs[i], dict) else {}
|
||||||
|
text = str(src.get('text') or '').strip() or f'Вопрос {i + 1}'
|
||||||
|
has_multi = bool(sh.get('hasMultipleAnswers'))
|
||||||
|
min_c = int(sh.get('minCorrect', 1))
|
||||||
|
max_c = int(sh.get('maxCorrect', sh['optionsCount']))
|
||||||
|
if not has_multi:
|
||||||
|
min_c = 1
|
||||||
|
max_c = 1
|
||||||
|
|
||||||
|
raw_opts = src.get('options') if isinstance(src.get('options'), list) else []
|
||||||
|
opts = []
|
||||||
|
for j in range(sh['optionsCount']):
|
||||||
|
if j < len(raw_opts) and isinstance(raw_opts[j], dict):
|
||||||
|
t = str(raw_opts[j].get('text') or '').strip() or f'Вариант {j + 1}'
|
||||||
|
opts.append({'text': t, 'isCorrect': bool(raw_opts[j].get('isCorrect'))})
|
||||||
|
else:
|
||||||
|
opts.append(_mk_option(j))
|
||||||
|
|
||||||
|
true_idx = [idx for idx, op in enumerate(opts) if op['isCorrect']]
|
||||||
|
if not has_multi:
|
||||||
|
keep = true_idx[0] if true_idx else 0
|
||||||
|
for idx, op in enumerate(opts):
|
||||||
|
op['isCorrect'] = (idx == keep)
|
||||||
|
else:
|
||||||
|
if len(true_idx) < min_c:
|
||||||
|
for idx in range(len(opts)):
|
||||||
|
if idx not in true_idx:
|
||||||
|
opts[idx]['isCorrect'] = True
|
||||||
|
true_idx.append(idx)
|
||||||
|
if len(true_idx) >= min_c:
|
||||||
|
break
|
||||||
|
if len(true_idx) > max_c:
|
||||||
|
keep = set(true_idx[:max_c])
|
||||||
|
for idx, op in enumerate(opts):
|
||||||
|
op['isCorrect'] = idx in keep
|
||||||
|
|
||||||
|
out_qs.append({'text': text, 'hasMultipleAnswers': has_multi, 'options': opts})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'title': str((draft or {}).get('title') or '').strip() or 'Тест',
|
||||||
|
'description': (draft or {}).get('description'),
|
||||||
|
'questions': out_qs,
|
||||||
|
}
|
||||||
|
|||||||
@@ -364,9 +364,18 @@ def count_missing_hints(session_or_eng, test_id: str) -> dict:
|
|||||||
if not active_version:
|
if not active_version:
|
||||||
return {'total': 0, 'missing': 0}
|
return {'total': 0, 'missing': 0}
|
||||||
|
|
||||||
all_qs = session.query(Question).filter(Question.test_version_id == active_version.id).all()
|
all_qs = session.query(Question).options(selectinload(Question.options)).filter(
|
||||||
total = len(all_qs)
|
Question.test_version_id == active_version.id
|
||||||
missing = sum(1 for q in all_qs if not q.ai_hint)
|
).all()
|
||||||
|
filled_qs = []
|
||||||
|
for q in all_qs:
|
||||||
|
if not (q.text or '').strip():
|
||||||
|
continue
|
||||||
|
if len([o for o in q.options if (o.text or '').strip()]) < 2:
|
||||||
|
continue
|
||||||
|
filled_qs.append(q)
|
||||||
|
total = len(filled_qs)
|
||||||
|
missing = sum(1 for q in filled_qs if not q.ai_hint)
|
||||||
return {'total': total, 'missing': missing}
|
return {'total': total, 'missing': missing}
|
||||||
|
|
||||||
|
|
||||||
@@ -400,8 +409,16 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
generated = failed = 0
|
generated = failed = skipped = 0
|
||||||
for q in missing_qs:
|
for q in missing_qs:
|
||||||
|
# Подсказку строим только по заполненному вопросу (есть текст и >=2 непустых варианта).
|
||||||
|
if not (q.text or '').strip():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
valid_opts = [o for o in q.options if (o.text or '').strip()]
|
||||||
|
if len(valid_opts) < 2:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
opt_payload = [{'text': o.text, 'isCorrect': bool(o.is_correct)} for o in q.options]
|
opt_payload = [{'text': o.text, 'isCorrect': bool(o.is_correct)} for o in q.options]
|
||||||
hint = generate_question_hint(question_text=q.text, options=opt_payload)
|
hint = generate_question_hint(question_text=q.text, options=opt_payload)
|
||||||
if hint:
|
if hint:
|
||||||
@@ -410,7 +427,7 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str
|
|||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
session.commit()
|
session.commit()
|
||||||
return {'generated': generated, 'failed': failed, 'total': len(missing_qs)}
|
return {'generated': generated, 'failed': failed, 'skipped': skipped, 'total': len(missing_qs)}
|
||||||
|
|
||||||
|
|
||||||
def check_question_for_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str,
|
def check_question_for_attempt(session_or_eng, user_id: str, test_id: str, attempt_id: str,
|
||||||
|
|||||||
@@ -31,6 +31,13 @@
|
|||||||
const aiTopicEl = $('#ai-topic');
|
const aiTopicEl = $('#ai-topic');
|
||||||
const aiQCountEl = $('#ai-q-count');
|
const aiQCountEl = $('#ai-q-count');
|
||||||
const aiOCountEl = $('#ai-o-count');
|
const aiOCountEl = $('#ai-o-count');
|
||||||
|
const templateGlobalMultiEl = $('#template-global-multi');
|
||||||
|
const templateMinCorrectEl = $('#template-min-correct');
|
||||||
|
const templateMaxCorrectEl = $('#template-max-correct');
|
||||||
|
const hintsStatusEl = $('#hints-status');
|
||||||
|
const hintsActionsEl = $('#test-hints-actions');
|
||||||
|
const generateHintsBtn = $('#btn-generate-hints');
|
||||||
|
const docProgressEl = $('#doc-progress');
|
||||||
const introUpdatedEl = $('#intro-updated');
|
const introUpdatedEl = $('#intro-updated');
|
||||||
const introForkBannerEl = $('#intro-fork-banner');
|
const introForkBannerEl = $('#intro-fork-banner');
|
||||||
const versionsListEl = $('#versions-list');
|
const versionsListEl = $('#versions-list');
|
||||||
@@ -56,6 +63,34 @@
|
|||||||
let baselineDraftKey = '';
|
let baselineDraftKey = '';
|
||||||
let dirtyCheckQueued = false;
|
let dirtyCheckQueued = false;
|
||||||
|
|
||||||
|
function getTemplateCorrectRange(optionsCount, hasMultipleAnswers) {
|
||||||
|
const maxOpt = Math.max(2, Number(optionsCount || 2));
|
||||||
|
const rawMin = Math.max(1, Number(templateMinCorrectEl?.value || 1) || 1);
|
||||||
|
const rawMax = Math.max(1, Number(templateMaxCorrectEl?.value || 1) || 1);
|
||||||
|
if (!hasMultipleAnswers) return { minCorrect: 1, maxCorrect: 1 };
|
||||||
|
const minCorrect = Math.min(maxOpt, rawMin);
|
||||||
|
const maxCorrect = Math.max(minCorrect, Math.min(maxOpt, rawMax));
|
||||||
|
return { minCorrect, maxCorrect };
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTemplateRangeUi() {
|
||||||
|
const hasMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
|
||||||
|
const maxOpt = Math.min(MAX_OPTIONS, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
|
||||||
|
const range = getTemplateCorrectRange(maxOpt, hasMulti);
|
||||||
|
if (templateMinCorrectEl) {
|
||||||
|
templateMinCorrectEl.min = '1';
|
||||||
|
templateMinCorrectEl.max = String(maxOpt);
|
||||||
|
templateMinCorrectEl.value = String(range.minCorrect);
|
||||||
|
templateMinCorrectEl.disabled = !hasMulti;
|
||||||
|
}
|
||||||
|
if (templateMaxCorrectEl) {
|
||||||
|
templateMaxCorrectEl.min = '1';
|
||||||
|
templateMaxCorrectEl.max = String(maxOpt);
|
||||||
|
templateMaxCorrectEl.value = String(range.maxCorrect);
|
||||||
|
templateMaxCorrectEl.disabled = !hasMulti;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function currentDraftKey() {
|
function currentDraftKey() {
|
||||||
return JSON.stringify(collectPayload());
|
return JSON.stringify(collectPayload());
|
||||||
}
|
}
|
||||||
@@ -379,15 +414,22 @@
|
|||||||
const mode = document.querySelector('input[name="result-mode"]:checked');
|
const mode = document.querySelector('input[name="result-mode"]:checked');
|
||||||
const isImmediate = mode && mode.value === 'immediate';
|
const isImmediate = mode && mode.value === 'immediate';
|
||||||
if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none';
|
if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none';
|
||||||
|
if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl && hintsEl.checked) ? '' : 'none';
|
||||||
if (hintsEl && !isImmediate) hintsEl.checked = false;
|
if (hintsEl && !isImmediate) hintsEl.checked = false;
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (hintsEl) {
|
if (hintsEl) {
|
||||||
hintsEl.checked = !!initial.test.hintsEnabled;
|
hintsEl.checked = !!initial.test.hintsEnabled;
|
||||||
hintsEl.addEventListener('change', scheduleDirtyCheck);
|
hintsEl.addEventListener('change', () => {
|
||||||
|
const mode = document.querySelector('input[name="result-mode"]:checked');
|
||||||
|
const isImmediate = mode && mode.value === 'immediate';
|
||||||
|
if (hintsActionsEl) hintsActionsEl.style.display = (isImmediate && hintsEl.checked) ? '' : 'none';
|
||||||
|
scheduleDirtyCheck();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none';
|
if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none';
|
||||||
|
if (hintsActionsEl) hintsActionsEl.style.display = (initMode === 'immediate' && hintsEl && hintsEl.checked) ? '' : 'none';
|
||||||
|
|
||||||
questionsEl.innerHTML = '';
|
questionsEl.innerHTML = '';
|
||||||
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
@@ -452,10 +494,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collectShape() {
|
function collectShape() {
|
||||||
return $$('#questions .q-item').map((li) => ({
|
return $$('#questions .q-item').map((li) => {
|
||||||
optionsCount: Math.max(2, $$('.opt-item', li).length || 4),
|
const optionsCount = Math.max(2, $$('.opt-item', li).length || 4);
|
||||||
hasMultipleAnswers: $('.q-multi', li).checked,
|
const hasMultipleAnswers = $('.q-multi', li).checked;
|
||||||
}));
|
const range = getTemplateCorrectRange(optionsCount, hasMultipleAnswers);
|
||||||
|
return {
|
||||||
|
optionsCount,
|
||||||
|
hasMultipleAnswers,
|
||||||
|
minCorrect: hasMultipleAnswers ? range.minCorrect : 1,
|
||||||
|
maxCorrect: hasMultipleAnswers ? range.maxCorrect : 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentDraftQuietly() {
|
||||||
|
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().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить черновик перед генерацией подсказок.');
|
||||||
|
resetBaselineDraft();
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── actions ───────────────────────────────────────────────────────
|
// ─── actions ───────────────────────────────────────────────────────
|
||||||
@@ -520,9 +581,10 @@
|
|||||||
if (saveModal) saveModal.showModal();
|
if (saveModal) saveModal.showModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const skipped = Number(gd.skipped || 0);
|
||||||
const tail = gd.failed
|
const tail = gd.failed
|
||||||
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось.`
|
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.`
|
||||||
: ` Подсказки созданы (${gd.generated}).`;
|
: ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`;
|
||||||
if (saveMsg) saveMsg.textContent = msg + tail;
|
if (saveMsg) saveMsg.textContent = msg + tail;
|
||||||
} else {
|
} else {
|
||||||
if (saveMsg) saveMsg.textContent = msg;
|
if (saveMsg) saveMsg.textContent = msg;
|
||||||
@@ -561,11 +623,15 @@
|
|||||||
}
|
}
|
||||||
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||||
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
|
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
|
||||||
|
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
|
||||||
|
const globalRange = getTemplateCorrectRange(nO, globalMulti);
|
||||||
const shape = Array.from({ length: nQ }, () => ({
|
const shape = Array.from({ length: nQ }, () => ({
|
||||||
optionsCount: nO,
|
optionsCount: nO,
|
||||||
hasMultipleAnswers: false,
|
hasMultipleAnswers: globalMulti,
|
||||||
|
minCorrect: globalMulti ? globalRange.minCorrect : 1,
|
||||||
|
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
|
||||||
}));
|
}));
|
||||||
aiStatusEl.textContent = 'Генерируем…';
|
aiStatusEl.textContent = 'Генерируем структуру и вопросы…';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -589,6 +655,25 @@
|
|||||||
renumber();
|
renumber();
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
||||||
|
const hintsEl = document.getElementById('test-hints-enabled');
|
||||||
|
const modeEl = document.querySelector('input[name="result-mode"]:checked');
|
||||||
|
if (hintsEl && hintsEl.checked && modeEl && modeEl.value === 'immediate') {
|
||||||
|
aiStatusEl.textContent = 'Сохраняем черновик…';
|
||||||
|
try {
|
||||||
|
await saveCurrentDraftQuietly();
|
||||||
|
aiStatusEl.textContent = 'Генерируем вопросы… затем подсказки…';
|
||||||
|
const hr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
||||||
|
const hd = await hr.json().catch(() => ({}));
|
||||||
|
if (hr.ok) {
|
||||||
|
const skipped = Number(hd.skipped || 0);
|
||||||
|
aiStatusEl.textContent = skipped
|
||||||
|
? `Готово: вопросы + подсказки (${hd.generated}, пропущено ${skipped}).`
|
||||||
|
: `Готово: вопросы + подсказки (${hd.generated}).`;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Оставляем базовый статус готовности вопросов.
|
||||||
|
}
|
||||||
|
}
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiStatusEl.textContent = '';
|
aiStatusEl.textContent = '';
|
||||||
@@ -662,16 +747,29 @@
|
|||||||
docGenerateBtn.disabled = true;
|
docGenerateBtn.disabled = true;
|
||||||
docGenerateBtn.textContent = 'Генерируем…';
|
docGenerateBtn.textContent = 'Генерируем…';
|
||||||
aiStatusEl.textContent = 'Генерируем тест из документа…';
|
aiStatusEl.textContent = 'Генерируем тест из документа…';
|
||||||
|
if (docProgressEl) docProgressEl.textContent = 'Шаг 1/3: подготовка шаблона…';
|
||||||
try {
|
try {
|
||||||
|
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||||
|
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
|
||||||
|
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
|
||||||
|
const globalRange = getTemplateCorrectRange(nO, globalMulti);
|
||||||
|
const shape = Array.from({ length: nQ }, () => ({
|
||||||
|
optionsCount: nO,
|
||||||
|
hasMultipleAnswers: globalMulti,
|
||||||
|
minCorrect: globalMulti ? globalRange.minCorrect : 1,
|
||||||
|
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
|
||||||
|
}));
|
||||||
|
if (docProgressEl) docProgressEl.textContent = 'Шаг 2/3: генерация вопросов…';
|
||||||
const r = await fetch('/api/tests/generate-from-extracted', {
|
const r = await fetch('/api/tests/generate-from-extracted', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ extractedText: _extractedText, userHint }),
|
body: JSON.stringify({ extractedText: _extractedText, userHint, shape }),
|
||||||
});
|
});
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) throw new Error(data.error || 'Ошибка генерации.');
|
if (!r.ok) throw new Error(data.error || 'Ошибка генерации.');
|
||||||
const g = data.generation || {};
|
const g = data.generation || {};
|
||||||
aiStatusEl.textContent = '';
|
aiStatusEl.textContent = '';
|
||||||
|
if (docProgressEl) docProgressEl.textContent = 'Шаг 3/3: подготовка к применению…';
|
||||||
|
|
||||||
if (!g.available) {
|
if (!g.available) {
|
||||||
openImportModal(
|
openImportModal(
|
||||||
@@ -741,6 +839,7 @@
|
|||||||
docGenerateBtn.disabled = false;
|
docGenerateBtn.disabled = false;
|
||||||
docGenerateBtn.innerHTML = '<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span> Сгенерировать из документа';
|
docGenerateBtn.innerHTML = '<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span> Сгенерировать из документа';
|
||||||
}
|
}
|
||||||
|
if (docProgressEl) setTimeout(() => { docProgressEl.textContent = ''; }, 2500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1393,6 +1492,8 @@
|
|||||||
createTemplateBtn.addEventListener('click', () => {
|
createTemplateBtn.addEventListener('click', () => {
|
||||||
const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10)));
|
const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10)));
|
||||||
const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10)));
|
const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10)));
|
||||||
|
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
|
||||||
|
const range = getTemplateCorrectRange(oCount, globalMulti);
|
||||||
const existing = $$('#questions .q-item').length;
|
const existing = $$('#questions .q-item').length;
|
||||||
if (existing > 0) {
|
if (existing > 0) {
|
||||||
const ok = confirm(
|
const ok = confirm(
|
||||||
@@ -1405,9 +1506,9 @@
|
|||||||
for (let qi = 0; qi < qCount; qi++) {
|
for (let qi = 0; qi < qCount; qi++) {
|
||||||
const opts = [];
|
const opts = [];
|
||||||
for (let oi = 0; oi < oCount; oi++) {
|
for (let oi = 0; oi < oCount; oi++) {
|
||||||
opts.push({ text: '', isCorrect: oi === 0 });
|
opts.push({ text: '', isCorrect: oi < range.minCorrect });
|
||||||
}
|
}
|
||||||
questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: false, options: opts }));
|
questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: globalMulti, options: opts }));
|
||||||
}
|
}
|
||||||
renumber();
|
renumber();
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
@@ -1416,6 +1517,39 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateHintsForCurrentTest() {
|
||||||
|
if (!generateHintsBtn) return;
|
||||||
|
generateHintsBtn.disabled = true;
|
||||||
|
if (hintsStatusEl) hintsStatusEl.textContent = 'Сохраняем текущие изменения…';
|
||||||
|
try {
|
||||||
|
await saveCurrentDraftQuietly();
|
||||||
|
if (hintsStatusEl) hintsStatusEl.textContent = 'Генерируем подсказки…';
|
||||||
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Не удалось сгенерировать подсказки.');
|
||||||
|
const skipped = Number(data.skipped || 0);
|
||||||
|
if (hintsStatusEl) {
|
||||||
|
hintsStatusEl.textContent = data.failed
|
||||||
|
? `Создано ${data.generated}, ошибок ${data.failed}${skipped ? `, пропущено ${skipped}` : ''}.`
|
||||||
|
: `Подсказки созданы: ${data.generated}${skipped ? `, пропущено ${skipped}` : ''}.`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (hintsStatusEl) hintsStatusEl.textContent = e.message || 'Ошибка генерации подсказок.';
|
||||||
|
} finally {
|
||||||
|
generateHintsBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generateHintsBtn) {
|
||||||
|
generateHintsBtn.addEventListener('click', generateHintsForCurrentTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', syncTemplateRangeUi);
|
||||||
|
if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('input', syncTemplateRangeUi);
|
||||||
|
if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('input', syncTemplateRangeUi);
|
||||||
|
if (aiOCountEl) aiOCountEl.addEventListener('input', syncTemplateRangeUi);
|
||||||
|
syncTemplateRangeUi();
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
|
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
|
||||||
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),
|
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="settings-radio">
|
<label class="settings-radio">
|
||||||
<input type="radio" name="result-mode" value="immediate" />
|
<input type="radio" name="result-mode" value="immediate" />
|
||||||
<span>Сразу после ответа <span class="settings-row__hint">(с ИИ-подсказкой)</span></span>
|
<span>Сразу после ответа <span class="settings-row__hint">(с подсказкой)</span></span>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -94,6 +94,10 @@
|
|||||||
</span>
|
</span>
|
||||||
<input id="test-hints-enabled" type="checkbox" />
|
<input id="test-hints-enabled" type="checkbox" />
|
||||||
</label>
|
</label>
|
||||||
|
<div class="settings-row settings-row--block" id="test-hints-actions" style="display:none;">
|
||||||
|
<button id="btn-generate-hints" class="btn btn-ghost btn--sm" type="button">Сгенерировать подсказки</button>
|
||||||
|
<p id="hints-status" class="settings-row__hint" style="margin-top:0.35rem;"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
|
<div class="settings-row settings-row--block" style="padding-top:0.75rem; border-top:1px solid var(--outline-variant); margin-top:0.25rem;">
|
||||||
<span class="settings-row__label">Видимость в каталоге</span>
|
<span class="settings-row__label">Видимость в каталоге</span>
|
||||||
@@ -137,6 +141,23 @@
|
|||||||
Создать шаблон
|
Создать шаблон
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-3 grid gap-2 sm:grid-cols-3 items-end">
|
||||||
|
<label class="inline-flex items-center gap-2 min-h-9">
|
||||||
|
<input id="template-global-multi" type="checkbox"
|
||||||
|
class="rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||||
|
<span class="text-sm">Несколько правильных ответов (все вопросы)</span>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="form-label">Правильных: от</span>
|
||||||
|
<input id="template-min-correct" type="number" min="1" max="8" step="1" value="1"
|
||||||
|
class="form-input" style="width:90px;" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="form-label">до</span>
|
||||||
|
<input id="template-max-correct" type="number" min="1" max="8" step="1" value="1"
|
||||||
|
class="form-input" style="width:90px;" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Заполнить через ИИ по теме ──────────────────────────── #}
|
{# ── Заполнить через ИИ по теме ──────────────────────────── #}
|
||||||
@@ -197,6 +218,7 @@
|
|||||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span>
|
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span>
|
||||||
Сгенерировать из документа
|
Сгенерировать из документа
|
||||||
</button>
|
</button>
|
||||||
|
<p id="doc-progress" class="mt-2 text-xs text-ink-500 min-h-[1rem]"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Модалка результата импорта документа ─────────────────── #}
|
{# ── Модалка результата импорта документа ─────────────────── #}
|
||||||
@@ -356,7 +378,7 @@
|
|||||||
placeholder="Краткий текст подсказки (если в параметрах теста включены подсказки и режим «Сразу после ответа»)"
|
placeholder="Краткий текст подсказки (если в параметрах теста включены подсказки и режим «Сразу после ответа»)"
|
||||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||||
</label>
|
</label>
|
||||||
<p class="mt-1.5 text-xs text-ink-400 leading-snug">Необязательно. Показывается участнику во всплывающем окне при верном ответе.</p>
|
<p class="mt-1.5 text-xs text-ink-400 leading-snug">Необязательно. Показывается участнику во всплывающем окне после ответа на вопрос.</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -545,9 +545,16 @@ def api_generate_from_extracted():
|
|||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
extracted = (body.get('extractedText') or '').strip()
|
extracted = (body.get('extractedText') or '').strip()
|
||||||
user_hint = (body.get('userHint') or '').strip()
|
user_hint = (body.get('userHint') or '').strip()
|
||||||
|
shape_raw = body.get('shape')
|
||||||
if not extracted:
|
if not extracted:
|
||||||
return jsonify(error='Нет текста для генерации.'), 400
|
return jsonify(error='Нет текста для генерации.'), 400
|
||||||
generation = generation_for_import_document(extracted, user_hint=user_hint)
|
shape = None
|
||||||
|
if shape_raw:
|
||||||
|
try:
|
||||||
|
shape = parse_and_validate_shape(shape_raw)
|
||||||
|
except (AiHttpError, LlmError) as e:
|
||||||
|
return _ai_error_response(e)
|
||||||
|
generation = generation_for_import_document(extracted, user_hint=user_hint, shape=shape)
|
||||||
return jsonify(generation=generation)
|
return jsonify(generation=generation)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user