testingwebapp fixes, weeek tasks 2948-2958
This commit is contained in:
@@ -11,6 +11,7 @@ import secrets
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from flask import Flask, jsonify, render_template, request
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
|
|
||||||
|
|
||||||
_ROLE_LABELS = {
|
_ROLE_LABELS = {
|
||||||
@@ -89,6 +90,12 @@ def create_app() -> Flask:
|
|||||||
'format_role': _format_role,
|
'format_role': _format_role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.errorhandler(RequestEntityTooLarge)
|
||||||
|
def _payload_too_large(_e):
|
||||||
|
if _is_api_path():
|
||||||
|
return jsonify(error='Файл слишком большой (лимит загрузки на сервере).'), 413
|
||||||
|
return ('Файл слишком большой.', 413, {'Content-Type': 'text/plain; charset=utf-8'})
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def _not_found(_e):
|
def _not_found(_e):
|
||||||
if _is_api_path():
|
if _is_api_path():
|
||||||
|
|||||||
@@ -225,11 +225,64 @@ def check_test_quality(test_title: str, test_description: str, questions: list[d
|
|||||||
return {'verdict': verdict, 'summary': summary, 'sections': sections}
|
return {'verdict': verdict, 'summary': summary, 'sections': sections}
|
||||||
|
|
||||||
|
|
||||||
def improve_test_full(test_title: str, test_description: str, questions: list[dict]) -> dict:
|
def _merge_suggested_by_focus(
|
||||||
|
focus: str,
|
||||||
|
orig: dict,
|
||||||
|
sug: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Сужает предложение модели под выбранную область правки."""
|
||||||
|
orig_opts = [
|
||||||
|
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))}
|
||||||
|
for o in (orig.get('options') or [])
|
||||||
|
]
|
||||||
|
sug_opts = sug.get('options') or []
|
||||||
|
hm = bool(orig.get('hasMultipleAnswers'))
|
||||||
|
ot = str(orig.get('text', '')).strip()
|
||||||
|
|
||||||
|
if focus == 'questions':
|
||||||
|
return {
|
||||||
|
'text': str(sug.get('text', '')).strip(),
|
||||||
|
'hasMultipleAnswers': hm,
|
||||||
|
'options': [dict(o) for o in orig_opts],
|
||||||
|
}
|
||||||
|
if focus == 'distractors':
|
||||||
|
merged = []
|
||||||
|
for j, oo in enumerate(orig_opts):
|
||||||
|
so = sug_opts[j] if j < len(sug_opts) else oo
|
||||||
|
if oo['isCorrect']:
|
||||||
|
merged.append(dict(oo))
|
||||||
|
else:
|
||||||
|
merged.append(
|
||||||
|
{'text': str(so.get('text', '')).strip(), 'isCorrect': False}
|
||||||
|
)
|
||||||
|
return {'text': ot, 'hasMultipleAnswers': hm, 'options': merged}
|
||||||
|
if focus == 'options':
|
||||||
|
merged = []
|
||||||
|
for j, oo in enumerate(orig_opts):
|
||||||
|
so = sug_opts[j] if j < len(sug_opts) else oo
|
||||||
|
merged.append(
|
||||||
|
{
|
||||||
|
'text': str(so.get('text', '')).strip(),
|
||||||
|
'isCorrect': oo['isCorrect'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {'text': ot, 'hasMultipleAnswers': hm, 'options': merged}
|
||||||
|
return {
|
||||||
|
'text': str(sug.get('text', '')).strip(),
|
||||||
|
'hasMultipleAnswers': bool(sug.get('hasMultipleAnswers')),
|
||||||
|
'options': [
|
||||||
|
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))}
|
||||||
|
for o in sug_opts
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def improve_test_full(
|
||||||
|
test_title: str, test_description: str, questions: list[dict], focus: str = 'all'
|
||||||
|
) -> dict:
|
||||||
"""AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса.
|
"""AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса.
|
||||||
|
|
||||||
Для каждого вопроса: original + suggested, флаги textChanged/optionsChanged.
|
focus: all | questions | distractors | options
|
||||||
UI решает, что применить (чекбоксы).
|
|
||||||
"""
|
"""
|
||||||
cfg = _require_cfg()
|
cfg = _require_cfg()
|
||||||
title = (test_title or '').strip() or 'Тест'
|
title = (test_title or '').strip() or 'Тест'
|
||||||
@@ -238,14 +291,43 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di
|
|||||||
if not qs:
|
if not qs:
|
||||||
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.')
|
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.')
|
||||||
|
|
||||||
system = (
|
focus = (focus or 'all').strip().lower()
|
||||||
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь '
|
if focus not in ('all', 'questions', 'distractors', 'options'):
|
||||||
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку '
|
focus = 'all'
|
||||||
'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и '
|
|
||||||
'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. '
|
system_by_focus = {
|
||||||
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
'all': (
|
||||||
'"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.'
|
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь '
|
||||||
)
|
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку '
|
||||||
|
'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и '
|
||||||
|
'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. '
|
||||||
|
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||||
|
'"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.'
|
||||||
|
),
|
||||||
|
'questions': (
|
||||||
|
'Ты редактор учебных тестов. Улучши ТОЛЬКО формулировки вопросов (поле text). '
|
||||||
|
'В ответе для каждого вопроса верни options ДОСЛОВНО как во входе '
|
||||||
|
'(те же тексты и те же isCorrect), без правок. '
|
||||||
|
'Сетку не меняй. Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||||
|
'"options": [{"text", "isCorrect"}]}, ...]}.'
|
||||||
|
),
|
||||||
|
'distractors': (
|
||||||
|
'Ты редактор учебных тестов. Улучши ТОЛЬКО неверные варианты (дистракторы), '
|
||||||
|
'где isCorrect: false. Верные варианты (isCorrect: true) верни ДОСЛОВНО как во входе. '
|
||||||
|
'Текст вопроса (text) не меняй — верни как во входе. hasMultipleAnswers не меняй. '
|
||||||
|
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||||
|
'"options": [{"text", "isCorrect"}]}, ...]}.'
|
||||||
|
),
|
||||||
|
'options': (
|
||||||
|
'Ты редактор учебных тестов. Улучши формулировки всех вариантов ответа; '
|
||||||
|
'какой вариант верный, не меняй — isCorrect копируй из входа для каждого индекса. '
|
||||||
|
'Текст вопроса не меняй — верни как во входе. hasMultipleAnswers не меняй. '
|
||||||
|
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||||
|
'"options": [{"text", "isCorrect"}]}, ...]}.'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
system = system_by_focus[focus]
|
||||||
|
|
||||||
test_dump = {
|
test_dump = {
|
||||||
'title': title,
|
'title': title,
|
||||||
'description': desc,
|
'description': desc,
|
||||||
@@ -280,7 +362,9 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di
|
|||||||
draft = validate_and_normalize_draft(
|
draft = validate_and_normalize_draft(
|
||||||
{'title': title, 'questions': parsed.get('questions') or []}
|
{'title': title, 'questions': parsed.get('questions') or []}
|
||||||
)
|
)
|
||||||
suggested_qs = draft['questions']
|
raw_suggested = draft['questions']
|
||||||
|
|
||||||
|
suggested_qs = [_merge_suggested_by_focus(focus, o, s) for o, s in zip(qs, raw_suggested)]
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for i, (orig, sug) in enumerate(zip(qs, suggested_qs)):
|
for i, (orig, sug) in enumerate(zip(qs, suggested_qs)):
|
||||||
@@ -316,7 +400,53 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {'items': items}
|
return {'items': items, 'focus': focus}
|
||||||
|
|
||||||
|
|
||||||
|
def improve_single_option_text(
|
||||||
|
test_title: str,
|
||||||
|
test_description: str,
|
||||||
|
question_text: str,
|
||||||
|
options: list[dict],
|
||||||
|
option_index: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Улучшает формулировку одного варианта ответа, не меняя роль (верный/неверный)."""
|
||||||
|
cfg = _require_cfg()
|
||||||
|
opts = options or []
|
||||||
|
try:
|
||||||
|
idx = int(option_index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HttpError(400, 'Укажите целочисленный optionIndex.')
|
||||||
|
if idx < 0 or idx >= len(opts):
|
||||||
|
raise HttpError(400, 'Некорректный индекс варианта.')
|
||||||
|
|
||||||
|
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
|
||||||
|
qt = (question_text or '').strip()
|
||||||
|
lines = []
|
||||||
|
for j, o in enumerate(opts):
|
||||||
|
mark = ' ← улучшить этот вариант' if j == idx else ''
|
||||||
|
oc = 'верный' if o.get('isCorrect') else 'неверный'
|
||||||
|
lines.append(f'{j + 1}. ({oc}) {o.get("text", "")}{mark}')
|
||||||
|
|
||||||
|
system = (
|
||||||
|
'Ты редактор учебных тестов. Отвечай ТОЛЬКО JSON: {"text": string} — '
|
||||||
|
'улучшенная формулировка ОДНОГО варианта ответа. Не меняй статус верности: '
|
||||||
|
'если вариант был верным, формулировка остаётся верным ответом по смыслу; '
|
||||||
|
'если неверным — остаётся неверным дистрактором. Без лишних слов.'
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
f'Тема теста: {topic}\n\n'
|
||||||
|
f'Вопрос: {qt or "—"}\n\n'
|
||||||
|
'Варианты:\n'
|
||||||
|
+ '\n'.join(lines)
|
||||||
|
+ f'\n\nПерепиши только вариант №{idx + 1}, короче и яснее, на русском.'
|
||||||
|
)
|
||||||
|
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
||||||
|
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 {'optionIndex': idx, 'text': text}
|
||||||
|
|
||||||
|
|
||||||
def generate_question_hint(
|
def generate_question_hint(
|
||||||
@@ -403,45 +533,80 @@ def generate_or_rephrase_question(
|
|||||||
|
|
||||||
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
|
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
|
||||||
qt = (question_text or '').strip()
|
qt = (question_text or '').strip()
|
||||||
|
mode = (mode or '').strip() if mode else ''
|
||||||
|
|
||||||
# ── Режим дистракторов: есть вопрос + часть вариантов пуста ─────────────
|
# ── Режим дистракторов: есть вопрос + часть вариантов пуста ─────────────
|
||||||
if qt and mode == 'distractors' and existing_options:
|
if qt and mode == 'distractors' and existing_options:
|
||||||
filled = [o for o in existing_options if (o.get('text') or '').strip()]
|
filled = [o for o in existing_options if (o.get('text') or '').strip()]
|
||||||
empty_count = len([o for o in existing_options if not (o.get('text') or '').strip()] )
|
empty_count = len([o for o in existing_options if not (o.get('text') or '').strip()])
|
||||||
if empty_count > 0:
|
if empty_count <= 0:
|
||||||
filled_lines = '\n'.join(
|
raise HttpError(400, 'Нет пустых полей для дистракторов — добавьте пустые варианты или выберите другой режим.')
|
||||||
f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}'
|
filled_lines = '\n'.join(
|
||||||
for o in filled
|
f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}'
|
||||||
) or '(нет)'
|
for o in filled
|
||||||
system = (
|
) or '(нет)'
|
||||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: '
|
system = (
|
||||||
f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — '
|
'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: '
|
||||||
f'ровно {empty_count} объекта в массиве. '
|
f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — '
|
||||||
'Все тексты на русском, без нумерации, без кавычек.'
|
f'ровно {empty_count} объекта в массиве. '
|
||||||
)
|
'Все тексты на русском, без нумерации, без кавычек.'
|
||||||
user = (
|
)
|
||||||
f'Тема теста: {topic}\n\n'
|
user = (
|
||||||
f'Вопрос: {qt}\n\n'
|
f'Тема теста: {topic}\n\n'
|
||||||
f'Уже заполненные варианты:\n{filled_lines}\n\n'
|
f'Вопрос: {qt}\n\n'
|
||||||
f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов '
|
f'Уже заполненные варианты:\n{filled_lines}\n\n'
|
||||||
f'(isCorrect: false), которые не повторяют уже существующие варианты '
|
f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов '
|
||||||
f'и выглядят похоже на реальные ответы.'
|
f'(isCorrect: false), которые не повторяют уже существующие варианты '
|
||||||
)
|
f'и выглядят похоже на реальные ответы.'
|
||||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
)
|
||||||
parsed = parse_json_from_llm_text(raw)
|
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||||
opts = []
|
parsed = parse_json_from_llm_text(raw)
|
||||||
if isinstance(parsed, dict):
|
opts = []
|
||||||
opts = parsed.get('options') or []
|
if isinstance(parsed, dict):
|
||||||
elif isinstance(parsed, list):
|
opts = parsed.get('options') or []
|
||||||
opts = parsed
|
elif isinstance(parsed, list):
|
||||||
opts = [
|
opts = parsed
|
||||||
{'text': str(o.get('text') or '').strip(), 'isCorrect': False}
|
opts = [
|
||||||
for o in opts if (o.get('text') or '').strip()
|
{'text': str(o.get('text') or '').strip(), 'isCorrect': False}
|
||||||
][:empty_count]
|
for o in opts if (o.get('text') or '').strip()
|
||||||
return {'mode': 'distractors', 'text': qt, 'options': opts}
|
][:empty_count]
|
||||||
|
return {'mode': 'distractors', 'text': qt, 'options': opts}
|
||||||
|
|
||||||
# ── Режим улучшения: вопрос есть → только переформулировать текст ────────
|
# ── Улучшить только формулировки вариантов (без смены верности) ──────────
|
||||||
if qt:
|
if qt and mode == 'improve_options' and existing_options:
|
||||||
|
n_opts = len(existing_options)
|
||||||
|
if n_opts < 2:
|
||||||
|
raise HttpError(400, 'Нужно минимум два варианта ответа.')
|
||||||
|
dump_opts = [
|
||||||
|
{'text': str(o.get('text', '')), 'isCorrect': bool(o.get('isCorrect'))}
|
||||||
|
for o in existing_options
|
||||||
|
]
|
||||||
|
system = (
|
||||||
|
'Ты редактор учебных тестов. Отвечай ТОЛЬКО JSON: '
|
||||||
|
'{"options": [{"text": string, "isCorrect": boolean}, ...]}. '
|
||||||
|
'Число элементов и значение isCorrect на каждом индексе должны совпадать с входом; '
|
||||||
|
'улучши только формулировки text — короче и яснее, на русском.'
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
f'Тема теста: {topic}\n\n'
|
||||||
|
f'Вопрос:\n{qt}\n\n'
|
||||||
|
f'Варианты (улучши только тексты, isCorrect не меняй):\n{_json.dumps(dump_opts, ensure_ascii=False)}'
|
||||||
|
)
|
||||||
|
raw = chat_completion_text_content(cfg, system, user, 0.3)
|
||||||
|
parsed = parse_json_from_llm_text(raw)
|
||||||
|
raw_list = parsed.get('options') if isinstance(parsed, dict) else None
|
||||||
|
if not isinstance(raw_list, list) or len(raw_list) != n_opts:
|
||||||
|
raise LlmError('Неверный формат: ожидается options той же длины.', code='llm_shape')
|
||||||
|
out_opts = []
|
||||||
|
for i in range(n_opts):
|
||||||
|
ro = raw_list[i] if i < len(raw_list) else {}
|
||||||
|
orig_t = dump_opts[i]['text'].strip()
|
||||||
|
t = str((ro or {}).get('text') or '').strip() or orig_t
|
||||||
|
out_opts.append({'text': t, 'isCorrect': dump_opts[i]['isCorrect']})
|
||||||
|
return {'mode': 'improve_options', 'text': qt, 'options': out_opts}
|
||||||
|
|
||||||
|
# ── Только переформулировать текст вопроса ────────────────────────────────
|
||||||
|
if qt and mode == 'rephrase':
|
||||||
system = (
|
system = (
|
||||||
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — '
|
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — '
|
||||||
'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости '
|
'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости '
|
||||||
@@ -458,6 +623,12 @@ def generate_or_rephrase_question(
|
|||||||
raise LlmError('Пустой text в ответе модели.', code='llm_shape')
|
raise LlmError('Пустой text в ответе модели.', code='llm_shape')
|
||||||
return {'mode': 'rephrase', 'text': text}
|
return {'mode': 'rephrase', 'text': text}
|
||||||
|
|
||||||
|
if qt:
|
||||||
|
raise HttpError(
|
||||||
|
400,
|
||||||
|
'Укажите режим: distractors, improve_options или rephrase — или очистите текст вопроса для полной генерации.',
|
||||||
|
)
|
||||||
|
|
||||||
# ── Полная генерация: вопрос пуст ────────────────────────────────────────
|
# ── Полная генерация: вопрос пуст ────────────────────────────────────────
|
||||||
system = (
|
system = (
|
||||||
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
|
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ SUPPORTED_EXT = {
|
|||||||
|
|
||||||
|
|
||||||
def resolve_document_kind(mimetype: str | None, original_name: str | None = '') -> Optional[str]:
|
def resolve_document_kind(mimetype: str | None, original_name: str | None = '') -> Optional[str]:
|
||||||
m = (mimetype or '').lower()
|
# Браузеры часто шлют «text/plain; charset=utf-8» — без отсечения параметров ключ не совпадает.
|
||||||
|
raw = (mimetype or '').strip()
|
||||||
|
m = raw.split(';', 1)[0].strip().lower()
|
||||||
n = (original_name or '').lower()
|
n = (original_name or '').lower()
|
||||||
if m in SUPPORTED_MIME:
|
if m in SUPPORTED_MIME:
|
||||||
return SUPPORTED_MIME[m]
|
return SUPPORTED_MIME[m]
|
||||||
|
|||||||
@@ -446,6 +446,70 @@ def generate_missing_hints_for_test(session_or_eng, author_id: str, test_id: str
|
|||||||
return {'generated': generated, 'failed': failed, 'skipped': skipped, 'total': len(missing_qs)}
|
return {'generated': generated, 'failed': failed, 'skipped': skipped, 'total': len(missing_qs)}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_next_missing_hint_for_test(session_or_eng, author_id: str, test_id: str) -> dict:
|
||||||
|
"""Генерирует одну недостающую подсказку (для отображения прогресса в UI)."""
|
||||||
|
from .ai_editor import generate_question_hint
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
tid = _to_uuid(test_id)
|
||||||
|
|
||||||
|
test = session.get(Test, tid)
|
||||||
|
if not test:
|
||||||
|
raise HttpError(404, 'Тест не найден.')
|
||||||
|
if not is_test_author(test.created_by, author_id) and not is_test_edit_open():
|
||||||
|
raise HttpError(403, 'Доступ запрещён.')
|
||||||
|
|
||||||
|
active_version = (
|
||||||
|
session.query(TestVersion)
|
||||||
|
.filter(TestVersion.test_id == tid, TestVersion.is_active.is_(True))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not active_version:
|
||||||
|
return {'generated': 0, 'remaining': 0, 'done': True, 'totalMissing': 0}
|
||||||
|
|
||||||
|
before = count_missing_hints(None, test_id).get('missing') or 0
|
||||||
|
|
||||||
|
missing_qs = (
|
||||||
|
session.query(Question)
|
||||||
|
.options(selectinload(Question.options))
|
||||||
|
.filter(
|
||||||
|
Question.test_version_id == active_version.id,
|
||||||
|
(Question.ai_hint == None) | (Question.ai_hint == ''), # noqa: E711
|
||||||
|
)
|
||||||
|
.order_by(Question.question_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for q in missing_qs:
|
||||||
|
if not (q.text or '').strip():
|
||||||
|
continue
|
||||||
|
valid_opts = [o for o in q.options if (o.text or '').strip()]
|
||||||
|
if len(valid_opts) < 2:
|
||||||
|
continue
|
||||||
|
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)
|
||||||
|
if hint:
|
||||||
|
q.ai_hint = hint
|
||||||
|
session.commit()
|
||||||
|
after = count_missing_hints(None, test_id).get('missing') or 0
|
||||||
|
return {
|
||||||
|
'generated': 1,
|
||||||
|
'remaining': after,
|
||||||
|
'done': after == 0,
|
||||||
|
'totalMissing': before,
|
||||||
|
'failed': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
after = count_missing_hints(None, test_id).get('missing') or 0
|
||||||
|
return {
|
||||||
|
'generated': 0,
|
||||||
|
'remaining': after,
|
||||||
|
'done': after == 0,
|
||||||
|
'totalMissing': before,
|
||||||
|
'failed': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
question_id: str, selected_option_ids: list[str]) -> dict:
|
question_id: str, selected_option_ids: list[str]) -> dict:
|
||||||
session = get_session()
|
session = get_session()
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ h3 {
|
|||||||
/* Кабинетный UI (класс body.ui-legacy на корне). */
|
/* Кабинетный UI (класс body.ui-legacy на корне). */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Высота ряда шапки: лого 2rem или блок кнопки (.btn padding + текст) — для fixed-тостов под header */
|
||||||
|
body.ui-legacy {
|
||||||
|
--cabinet-header-row-h: max(2rem, calc(0.9375rem * 1.38 + 1.1rem + 3px));
|
||||||
|
}
|
||||||
|
|
||||||
body.ui-legacy .max-w-2xl {
|
body.ui-legacy .max-w-2xl {
|
||||||
max-width: 42rem !important;
|
max-width: 42rem !important;
|
||||||
}
|
}
|
||||||
@@ -733,12 +738,60 @@ body.ui-legacy .test-detail-subsection__title {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.editor-generation-panel__status {
|
/* Уведомления ИИ: справа под шапкой; зазор под шапкой = боковой отступ (0.65rem) */
|
||||||
margin-top: 0.75rem;
|
.editor-gen-toast {
|
||||||
margin-bottom: 0;
|
--editor-toast-inset: max(0.65rem, env(safe-area-inset-right, 0px));
|
||||||
|
position: fixed;
|
||||||
|
right: var(--editor-toast-inset);
|
||||||
|
left: auto;
|
||||||
|
/* Низ шапки: padding-top + ряд + padding-bottom + border; затем зазор 0.65rem как у боковых inset тоста */
|
||||||
|
top: calc(
|
||||||
|
max(0.75rem, env(safe-area-inset-top, 0px)) + var(--cabinet-header-row-h) + 0.75rem + 1px + 0.65rem
|
||||||
|
);
|
||||||
|
z-index: 10060;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: min(28rem, calc(100vw - 1.5rem));
|
||||||
|
min-height: 2.85rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 3px 18px rgba(15, 23, 42, 0.16);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transform: translateX(calc(100% + 1.25rem));
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
transform 0.4s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.35s ease;
|
||||||
}
|
}
|
||||||
.editor-generation-panel__status:empty {
|
.editor-gen-toast.editor-gen-toast--open {
|
||||||
display: none;
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.editor-gen-toast[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.editor-gen-toast[data-variant='info'] {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--ink-200, #e5e7eb);
|
||||||
|
color: var(--ink-800, #1f2937);
|
||||||
|
}
|
||||||
|
.editor-gen-toast[data-variant='ok'] {
|
||||||
|
background: #ecfdf5;
|
||||||
|
border: 1px solid #6ee7b7;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
.editor-gen-toast[data-variant='err'] {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Option row alignment ───────────────────────────────────────── */
|
/* ─── Option row alignment ───────────────────────────────────────── */
|
||||||
@@ -751,10 +804,19 @@ body.ui-legacy .test-detail-subsection__title {
|
|||||||
.opt-text {
|
.opt-text {
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
.opt-delete {
|
.opt-delete,
|
||||||
|
.opt-ai {
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opt-text-wrap {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
.opt-item--ai-busy .opt-correct {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Question AI overlay ────────────────────────────────────────── */
|
/* ─── Question AI overlay ────────────────────────────────────────── */
|
||||||
.q-ai-overlay {
|
.q-ai-overlay {
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
@@ -783,7 +845,7 @@ body.ui-legacy .test-detail-subsection__title {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: var(--primary, #007168) !important;
|
border-color: var(--primary, #007168) !important;
|
||||||
background-color: color-mix(in srgb, var(--primary, #007168) 6%, transparent) !important;
|
background-color: color-mix(in srgb, var(--primary, #007168) 6%, transparent) !important;
|
||||||
pointer-events: none;
|
/* не pointer-events:none — иначе нельзя снова открыть выбор файла тем же кликом по зоне */
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
|||||||
+434
-126
@@ -25,7 +25,10 @@
|
|||||||
const questionsEl = $('#questions');
|
const questionsEl = $('#questions');
|
||||||
const qCountEl = $('#q-count');
|
const qCountEl = $('#q-count');
|
||||||
const saveStatusEl = $('#save-status');
|
const saveStatusEl = $('#save-status');
|
||||||
const aiStatusEl = $('#ai-status');
|
const aiKeepTitleEl = $('#ai-keep-title');
|
||||||
|
const aiImproveFocusEl = $('#ai-improve-focus');
|
||||||
|
const aiImportClearBtn = $('#ai-import-clear');
|
||||||
|
const addQuestionAiBtn = $('#add-question-ai');
|
||||||
const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге»
|
const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге»
|
||||||
const chainActiveDisplay = $('#chain-active-display');
|
const chainActiveDisplay = $('#chain-active-display');
|
||||||
const aiTopicEl = $('#ai-topic');
|
const aiTopicEl = $('#ai-topic');
|
||||||
@@ -34,7 +37,6 @@
|
|||||||
const templateGlobalMultiEl = $('#template-global-multi');
|
const templateGlobalMultiEl = $('#template-global-multi');
|
||||||
const templateMinCorrectEl = $('#template-min-correct');
|
const templateMinCorrectEl = $('#template-min-correct');
|
||||||
const templateMaxCorrectEl = $('#template-max-correct');
|
const templateMaxCorrectEl = $('#template-max-correct');
|
||||||
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');
|
||||||
@@ -167,6 +169,16 @@
|
|||||||
if (qNode) updateOptionsCounter(qNode);
|
if (qNode) updateOptionsCounter(qNode);
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
});
|
});
|
||||||
|
const optAiBtn = $('.opt-ai', node);
|
||||||
|
if (optAiBtn && qNode) {
|
||||||
|
optAiBtn.addEventListener('click', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const opts = $$('.opt-item', qNode);
|
||||||
|
const idx = opts.indexOf(node);
|
||||||
|
if (idx < 0) return;
|
||||||
|
await improveSingleOption(qNode, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +488,92 @@
|
|||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Тост справа под шапкой: выезд справа, variant ok/err/info, авто-уезд по durationMs. */
|
||||||
|
function hideToastAnimated(el) {
|
||||||
|
if (!el) return;
|
||||||
|
clearTimeout(showAiToast._timer);
|
||||||
|
clearTimeout(showAiToast._fallbackHide);
|
||||||
|
el.classList.remove('editor-gen-toast--open');
|
||||||
|
let done = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(showAiToast._fallbackHide);
|
||||||
|
el.removeEventListener('transitionend', onEnd);
|
||||||
|
el.hidden = true;
|
||||||
|
el.textContent = '';
|
||||||
|
el.removeAttribute('data-variant');
|
||||||
|
};
|
||||||
|
const onEnd = (ev) => {
|
||||||
|
if (ev.propertyName !== 'transform') return;
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
el.addEventListener('transitionend', onEnd);
|
||||||
|
showAiToast._fallbackHide = setTimeout(finish, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAiToast(message, variant = 'info', durationMs = 4800) {
|
||||||
|
const el = document.getElementById('editor-gen-toast');
|
||||||
|
if (!el) return;
|
||||||
|
clearTimeout(showAiToast._timer);
|
||||||
|
clearTimeout(showAiToast._fallbackHide);
|
||||||
|
if (!message) {
|
||||||
|
if (!el.hidden) hideToastAnimated(el);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (el.classList.contains('editor-gen-toast--open') && !el.hidden) {
|
||||||
|
el.textContent = message;
|
||||||
|
el.dataset.variant = variant;
|
||||||
|
if (durationMs != null && durationMs > 0) {
|
||||||
|
showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.textContent = message;
|
||||||
|
el.dataset.variant = variant;
|
||||||
|
el.hidden = false;
|
||||||
|
el.classList.remove('editor-gen-toast--open');
|
||||||
|
void el.offsetWidth;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.classList.add('editor-gen-toast--open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (durationMs != null && durationMs > 0) {
|
||||||
|
showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEtaSeconds(sec) {
|
||||||
|
const s = Math.min(7200, Math.max(5, Math.round(sec)));
|
||||||
|
if (s < 60) return `примерно ${s} с`;
|
||||||
|
const m = Math.ceil(s / 60);
|
||||||
|
if (m === 1) return 'примерно 1 мин';
|
||||||
|
return `примерно ${m} мин`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function etaFullGenerateSeconds(nQ) {
|
||||||
|
return Math.min(900, Math.max(25, 20 + nQ * 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHintsProgressFromServer(startMissing) {
|
||||||
|
const total = Math.max(0, Number(startMissing) || 0);
|
||||||
|
if (!total) return;
|
||||||
|
let iter = 0;
|
||||||
|
const maxIter = Math.min(500, Math.max(total * 3, total + 15));
|
||||||
|
while (iter < maxIter) {
|
||||||
|
iter += 1;
|
||||||
|
const line = `Подсказки ИИ: шаг ${iter} из ~${total}`;
|
||||||
|
showAiToast(line, 'info', 0);
|
||||||
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate-next`, { method: 'POST' });
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(d.error || 'Не удалось сгенерировать подсказку.');
|
||||||
|
if (d.done || d.remaining === 0) break;
|
||||||
|
if (d.generated) continue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── collect ───────────────────────────────────────────────────────
|
// ─── collect ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function collectPayload() {
|
function collectPayload() {
|
||||||
@@ -668,26 +766,22 @@
|
|||||||
+ 'Сгенерировать недостающие подсказки через ИИ?',
|
+ 'Сгенерировать недостающие подсказки через ИИ?',
|
||||||
);
|
);
|
||||||
if (okGen) {
|
if (okGen) {
|
||||||
saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`;
|
try {
|
||||||
const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
await runHintsProgressFromServer(st.missing);
|
||||||
const gd = await gr.json().catch(() => ({}));
|
if (saveMsg) saveMsg.textContent = `${msg} Подсказки обновлены через ИИ.`;
|
||||||
if (!gr.ok) {
|
try {
|
||||||
|
await refreshHintsInForm();
|
||||||
|
} catch (_) {
|
||||||
|
/* не блокируем успех сохранения */
|
||||||
|
}
|
||||||
|
showAiToast('Подсказки сгенерированы.', 'ok');
|
||||||
|
} catch (he) {
|
||||||
saveStatusEl.textContent = '';
|
saveStatusEl.textContent = '';
|
||||||
alert(gd.error || 'Не удалось сгенерировать подсказки.');
|
alert(he.message || 'Не удалось сгенерировать подсказки.');
|
||||||
if (saveMsg) saveMsg.textContent = msg + ' (подсказки не созданы)';
|
if (saveMsg) saveMsg.textContent = `${msg} (подсказки не созданы)`;
|
||||||
if (saveModal) saveModal.showModal();
|
if (saveModal) saveModal.showModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const skipped = Number(gd.skipped || 0);
|
|
||||||
const tail = gd.failed
|
|
||||||
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.`
|
|
||||||
: ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`;
|
|
||||||
if (saveMsg) saveMsg.textContent = msg + tail;
|
|
||||||
try {
|
|
||||||
await refreshHintsInForm();
|
|
||||||
} catch (_) {
|
|
||||||
/* не блокируем успех сохранения */
|
|
||||||
}
|
|
||||||
} else if (saveMsg) {
|
} else if (saveMsg) {
|
||||||
saveMsg.textContent = msg;
|
saveMsg.textContent = msg;
|
||||||
}
|
}
|
||||||
@@ -716,14 +810,15 @@
|
|||||||
alert('Укажите тему.');
|
alert('Укажите тему.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Предупреждение, если в тесте уже есть вопросы или заполненное название/описание
|
const keepTitle = !!(aiKeepTitleEl && aiKeepTitleEl.checked);
|
||||||
const hasContent = questionsEl.children.length > 0
|
const hasContent = questionsEl.children.length > 0
|
||||||
|| titleEl.value.trim()
|
|| titleEl.value.trim()
|
||||||
|| descEl.value.trim();
|
|| descEl.value.trim();
|
||||||
if (hasContent) {
|
if (hasContent) {
|
||||||
const ok = confirm(
|
const warn = keepTitle
|
||||||
'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?'
|
? 'Полная генерация заменит описание и все вопросы. Название в редакторе не будет заменено, если включено «Не менять название».\n\nПродолжить?'
|
||||||
);
|
: 'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?';
|
||||||
|
const ok = confirm(warn);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
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));
|
||||||
@@ -736,7 +831,11 @@
|
|||||||
minCorrect: globalMulti ? globalRange.minCorrect : 1,
|
minCorrect: globalMulti ? globalRange.minCorrect : 1,
|
||||||
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
|
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
|
||||||
}));
|
}));
|
||||||
aiStatusEl.textContent = 'Генерируем структуру и вопросы…';
|
showAiToast(
|
||||||
|
`Генерация ${nQ} вопросов · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
|
||||||
|
'info',
|
||||||
|
0,
|
||||||
|
);
|
||||||
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',
|
||||||
@@ -750,44 +849,48 @@
|
|||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||||
const draft = data.draft;
|
const draft = data.draft;
|
||||||
if (draft.title) {
|
if (draft.title && !keepTitle) {
|
||||||
titleEl.value = draft.title;
|
titleEl.value = draft.title;
|
||||||
if (aiTopicEl) aiTopicEl.value = draft.title;
|
if (aiTopicEl) aiTopicEl.value = draft.title;
|
||||||
|
} else if (aiTopicEl) {
|
||||||
|
aiTopicEl.value = topic;
|
||||||
}
|
}
|
||||||
if (draft.description) descEl.value = draft.description;
|
if (draft.description) descEl.value = draft.description;
|
||||||
questionsEl.innerHTML = '';
|
questionsEl.innerHTML = '';
|
||||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
renumber();
|
renumber();
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
|
|
||||||
const hintsEl = document.getElementById('test-hints-enabled');
|
const hintsEl = document.getElementById('test-hints-enabled');
|
||||||
const modeEl = document.querySelector('input[name="result-mode"]:checked');
|
const modeEl = document.querySelector('input[name="result-mode"]:checked');
|
||||||
if (hintsEl && hintsEl.checked && modeEl && modeEl.value === 'immediate') {
|
if (hintsEl && hintsEl.checked && modeEl && modeEl.value === 'immediate') {
|
||||||
aiStatusEl.textContent = 'Сохраняем черновик…';
|
showAiToast('Сохраняем черновик перед подсказками…', 'info', 0);
|
||||||
try {
|
try {
|
||||||
await saveCurrentDraftQuietly();
|
await saveCurrentDraftQuietly();
|
||||||
aiStatusEl.textContent = 'Генерируем вопросы… затем подсказки…';
|
const hs = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`).then((x) => x.json());
|
||||||
const hr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
|
const miss = Number(hs.missing || 0);
|
||||||
const hd = await hr.json().catch(() => ({}));
|
if (miss > 0) {
|
||||||
if (hr.ok) {
|
await runHintsProgressFromServer(miss);
|
||||||
try {
|
|
||||||
await refreshHintsInForm();
|
|
||||||
} catch (_) {
|
|
||||||
// Не блокируем успех генерации вопросов.
|
|
||||||
}
|
|
||||||
const skipped = Number(hd.skipped || 0);
|
|
||||||
aiStatusEl.textContent = skipped
|
|
||||||
? `Готово: вопросы + подсказки (${hd.generated}, пропущено ${skipped}).`
|
|
||||||
: `Готово: вопросы + подсказки (${hd.generated}).`;
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await refreshHintsInForm();
|
||||||
|
} catch (_) {
|
||||||
|
/* не блокируем успех */
|
||||||
|
}
|
||||||
|
showAiToast(`Готово: ${draft.questions?.length || 0} вопросов и подсказки`, 'ok');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Оставляем базовый статус готовности вопросов.
|
showAiToast(
|
||||||
|
`Вопросы готовы (${draft.questions?.length || 0}); подсказки не созданы`,
|
||||||
|
'err',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
showAiToast(`Готово: ${draft.questions?.length || 0} вопросов`, 'ok');
|
||||||
}
|
}
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
alert(e.message || 'AI: ошибка.');
|
const msg = e.message || 'AI: ошибка.';
|
||||||
|
showAiToast(msg, 'err', 7000);
|
||||||
|
alert(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -804,6 +907,8 @@
|
|||||||
|
|
||||||
let _extractedText = '';
|
let _extractedText = '';
|
||||||
let _extractedFileName = '';
|
let _extractedFileName = '';
|
||||||
|
/** Имена всех загруженных файлов (несколько выборов из разных папок склеиваются). */
|
||||||
|
let _importFileNames = [];
|
||||||
|
|
||||||
/** HTML карточки вопроса в модалке предпросмотра импорта (как в разборе: текст, подсказка, все варианты). */
|
/** HTML карточки вопроса в модалке предпросмотра импорта (как в разборе: текст, подсказка, все варианты). */
|
||||||
function buildImportPreviewQuestionHtml(q, index) {
|
function buildImportPreviewQuestionHtml(q, index) {
|
||||||
@@ -858,31 +963,110 @@
|
|||||||
importModal.showModal();
|
importModal.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фаза 1: выбрать файл → извлечь текст, обновить метку дропзоны
|
function clearImportState() {
|
||||||
async function handleImportFile(file) {
|
_extractedText = '';
|
||||||
if (!file) return;
|
_extractedFileName = '';
|
||||||
aiStatusEl.textContent = `Загружаем «${file.name}»…`;
|
_importFileNames = [];
|
||||||
importDropzone.classList.add('import-dropzone--loading');
|
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файлы сюда или нажмите';
|
||||||
|
importDropzone?.classList.remove('import-dropzone--done', 'import-dropzone--loading');
|
||||||
|
if (docUserHint) docUserHint.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiImportClearBtn) {
|
||||||
|
aiImportClearBtn.addEventListener('click', () => {
|
||||||
|
clearImportState();
|
||||||
|
showAiToast('Загрузка сброшена.', 'info');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фаза 1: выбрать файл(ы) → извлечь текст, обновить метку дропзоны
|
||||||
|
async function handleImportFiles(fileList) {
|
||||||
|
const files = Array.from(fileList || []).filter(Boolean);
|
||||||
|
if (!files.length) return;
|
||||||
|
if (files.length > 5) {
|
||||||
|
showAiToast('Не более 5 файлов за раз.', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
||||||
|
for (const file of files) {
|
||||||
|
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
|
||||||
|
if (!allowed.includes(ext)) {
|
||||||
|
showAiToast(`Формат «${ext}» не поддерживается.`, 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const appendHint = _extractedText.trim() ? ' · добавляем к уже загруженным' : '';
|
||||||
|
showAiToast(
|
||||||
|
files.length > 1
|
||||||
|
? `Загружаем ${files.length} файла…${appendHint}`
|
||||||
|
: `Загружаем «${files[0].name}»…${appendHint}`,
|
||||||
|
'info',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
importDropzone?.classList.add('import-dropzone--loading');
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
files.forEach((f) => fd.append('files', f));
|
||||||
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd });
|
const r = await fetch('/api/tests/import/document', {
|
||||||
const data = await r.json();
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
let data;
|
||||||
|
const raw = await r.text();
|
||||||
|
try {
|
||||||
|
data = raw.trim() ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
if (r.status === 413) throw new Error('Файл слишком большой для сервера или прокси.');
|
||||||
|
if (r.status === 401) throw new Error('Сессия истекла — войдите снова и повторите загрузку.');
|
||||||
|
throw new Error(
|
||||||
|
`Сервер вернул не JSON (код ${r.status}). Обновите страницу или войдите снова.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.');
|
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.');
|
||||||
_extractedText = data.extractedText || '';
|
const batchText = (data.extractedText || '').trim();
|
||||||
_extractedFileName = file.name;
|
const names = Array.isArray(data.originalNames) ? data.originalNames : [data.originalName || ''];
|
||||||
aiStatusEl.textContent = `Файл загружен: «${file.name}» · ${data.textLength ?? 0} символов`;
|
const batchNames = names.filter(Boolean);
|
||||||
if (importDropzoneLabel) importDropzoneLabel.textContent = `✓ ${file.name}`;
|
const hadExisting = !!_extractedText.trim();
|
||||||
importDropzone.classList.add('import-dropzone--done');
|
const batchLen = Number(data.textLength) || batchText.length;
|
||||||
|
|
||||||
|
if (batchText) {
|
||||||
|
if (hadExisting) {
|
||||||
|
_extractedText = `${_extractedText.trimEnd()}\n\n---\n\n${batchText}`;
|
||||||
|
} else {
|
||||||
|
_extractedText = batchText;
|
||||||
|
}
|
||||||
|
batchNames.forEach((n) => _importFileNames.push(n));
|
||||||
|
_extractedFileName = _importFileNames.join(', ');
|
||||||
|
if (importDropzoneLabel) {
|
||||||
|
const n = _importFileNames.length;
|
||||||
|
importDropzoneLabel.textContent =
|
||||||
|
n <= 1
|
||||||
|
? `✓ ${_importFileNames[0] || files[0]?.name || 'файл'} · можно добавить ещё`
|
||||||
|
: `✓ ${n} файлов · можно добавить ещё`;
|
||||||
|
}
|
||||||
|
importDropzone?.classList.add('import-dropzone--done');
|
||||||
|
const totalLen = _extractedText.length;
|
||||||
|
if (hadExisting) {
|
||||||
|
showAiToast(`Добавлено · ${batchLen} симв. · всего ${totalLen}`, 'ok');
|
||||||
|
} else {
|
||||||
|
showAiToast(`Загружено · ${totalLen} символов`, 'ok');
|
||||||
|
}
|
||||||
|
} else if (hadExisting) {
|
||||||
|
showAiToast('Текст из этих файлов пуст — уже загруженное не меняли.', 'info', 4500);
|
||||||
|
} else {
|
||||||
|
_extractedFileName = '';
|
||||||
|
showAiToast('Текст из файлов не извлечён.', 'info', 4500);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
openImportModal(
|
openImportModal(
|
||||||
'Ошибка загрузки',
|
'Ошибка загрузки',
|
||||||
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось загрузить файл.')}</p></div>`,
|
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось загрузить файл.')}</p></div>`,
|
||||||
[{ label: 'Закрыть', onClick: () => importModal.close() }],
|
[{ label: 'Закрыть', onClick: () => importModal.close() }],
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
importDropzone.classList.remove('import-dropzone--loading');
|
importDropzone?.classList.remove('import-dropzone--loading');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,8 +1076,12 @@
|
|||||||
const userHint = docUserHint ? docUserHint.value.trim() : '';
|
const userHint = docUserHint ? docUserHint.value.trim() : '';
|
||||||
docGenerateBtn.disabled = true;
|
docGenerateBtn.disabled = true;
|
||||||
docGenerateBtn.textContent = 'Генерируем…';
|
docGenerateBtn.textContent = 'Генерируем…';
|
||||||
aiStatusEl.textContent = 'Генерируем тест из документа…';
|
const nQDoc = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
|
||||||
if (docProgressEl) docProgressEl.textContent = 'Шаг 1/3: подготовка шаблона…';
|
showAiToast(
|
||||||
|
`Документ: шаг 1/3 · подготовка · ~${formatEtaSeconds(etaFullGenerateSeconds(nQDoc))}`,
|
||||||
|
'info',
|
||||||
|
0,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
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));
|
||||||
@@ -905,7 +1093,7 @@
|
|||||||
minCorrect: globalMulti ? globalRange.minCorrect : 1,
|
minCorrect: globalMulti ? globalRange.minCorrect : 1,
|
||||||
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
|
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
|
||||||
}));
|
}));
|
||||||
if (docProgressEl) docProgressEl.textContent = 'Шаг 2/3: генерация вопросов…';
|
showAiToast('Документ: шаг 2/3 · генерация вопросов…', 'info', 0);
|
||||||
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' },
|
||||||
@@ -914,8 +1102,7 @@
|
|||||||
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 = '';
|
showAiToast('Документ: шаг 3/3 · готово к предпросмотру', 'ok', 3200);
|
||||||
if (docProgressEl) docProgressEl.textContent = 'Шаг 3/3: подготовка к применению…';
|
|
||||||
|
|
||||||
if (!g.available) {
|
if (!g.available) {
|
||||||
openImportModal(
|
openImportModal(
|
||||||
@@ -953,22 +1140,15 @@
|
|||||||
qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
renumber();
|
renumber();
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`;
|
showAiToast(`Применено: ${qs.length} вопросов`, 'ok');
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
clearImportState();
|
||||||
// Сброс зоны загрузки
|
|
||||||
_extractedText = '';
|
|
||||||
_extractedFileName = '';
|
|
||||||
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файл сюда или нажмите';
|
|
||||||
importDropzone.classList.remove('import-dropzone--done');
|
|
||||||
if (docUserHint) docUserHint.value = '';
|
|
||||||
aiStatusEl.textContent = '';
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ label: 'Отмена', onClick: () => importModal.close() },
|
{ label: 'Отмена', onClick: () => importModal.close() },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiStatusEl.textContent = '';
|
showAiToast(e.message || 'Ошибка генерации', 'err', 7000);
|
||||||
openImportModal(
|
openImportModal(
|
||||||
'Ошибка генерации',
|
'Ошибка генерации',
|
||||||
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p></div>`,
|
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p></div>`,
|
||||||
@@ -979,7 +1159,6 @@
|
|||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,9 +1169,9 @@
|
|||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
const onchange = async (ev) => {
|
const onchange = async (ev) => {
|
||||||
fileInput.removeEventListener('change', onchange);
|
fileInput.removeEventListener('change', onchange);
|
||||||
const f = ev.target.files && ev.target.files[0];
|
const picked = ev.target.files ? Array.from(ev.target.files) : [];
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
await handleImportFile(f);
|
await handleImportFiles(picked);
|
||||||
if (_extractedText) handleGenerateFromDoc();
|
if (_extractedText) handleGenerateFromDoc();
|
||||||
};
|
};
|
||||||
fileInput.addEventListener('change', onchange);
|
fileInput.addEventListener('change', onchange);
|
||||||
@@ -1004,9 +1183,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#ai-import-file').addEventListener('change', (ev) => {
|
$('#ai-import-file').addEventListener('change', (ev) => {
|
||||||
const file = ev.target.files && ev.target.files[0];
|
const picked = ev.target.files ? Array.from(ev.target.files) : [];
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
handleImportFile(file);
|
handleImportFiles(picked);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag-and-drop на зону загрузки
|
// Drag-and-drop на зону загрузки
|
||||||
@@ -1027,33 +1206,42 @@
|
|||||||
importDropzone.addEventListener('drop', (e) => {
|
importDropzone.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
importDropzone.classList.remove('import-dropzone--over');
|
importDropzone.classList.remove('import-dropzone--over');
|
||||||
const file = e.dataTransfer?.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
||||||
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
|
const dropped = Array.from(e.dataTransfer?.files || []);
|
||||||
if (!allowed.includes(ext)) {
|
const batch = dropped.filter((f) => {
|
||||||
aiStatusEl.textContent = `Формат «${ext}» не поддерживается.`;
|
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 3000);
|
return allowed.includes(ext);
|
||||||
|
}).slice(0, 5);
|
||||||
|
if (!batch.length) {
|
||||||
|
if (dropped.length) {
|
||||||
|
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleImportFile(file);
|
handleImportFiles(batch);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag-and-drop на всю страницу (когда перетаскивают извне браузера)
|
// Drag-and-drop на всю страницу (когда перетаскивают извне браузера)
|
||||||
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||||
document.addEventListener('drop', (e) => {
|
document.addEventListener('drop', (e) => {
|
||||||
if (importDropzone && importDropzone.contains(e.target)) return; // уже обработано
|
if (importDropzone && importDropzone.contains(e.target)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer?.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
const allowed = ['.pdf', '.docx', '.txt', '.md'];
|
||||||
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
|
const dropped = Array.from(e.dataTransfer?.files || []);
|
||||||
if (!allowed.includes(ext)) return;
|
const batch = dropped.filter((f) => {
|
||||||
// Подсвечиваем зону и обрабатываем
|
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
|
||||||
|
return allowed.includes(ext);
|
||||||
|
}).slice(0, 5);
|
||||||
|
if (!batch.length) {
|
||||||
|
if (dropped.length) {
|
||||||
|
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
importDropzone?.classList.add('import-dropzone--over');
|
importDropzone?.classList.add('import-dropzone--over');
|
||||||
setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600);
|
setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600);
|
||||||
handleImportFile(file);
|
handleImportFiles(batch);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
|
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
|
||||||
@@ -1120,7 +1308,11 @@
|
|||||||
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
|
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
|
||||||
if (nORaw == null) return;
|
if (nORaw == null) return;
|
||||||
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
|
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
|
||||||
aiStatusEl.textContent = 'Генерируем по названию…';
|
showAiToast(
|
||||||
|
`Генерация по названию · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
|
||||||
|
'info',
|
||||||
|
0,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1134,16 +1326,17 @@
|
|||||||
});
|
});
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
return aiAlert(data);
|
return aiAlert(data);
|
||||||
}
|
}
|
||||||
const draft = data.draft;
|
const draft = data.draft;
|
||||||
|
showAiToast('Черновик готов — подтвердите в диалоге', 'ok');
|
||||||
const ok = confirm(
|
const ok = confirm(
|
||||||
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
|
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
|
||||||
'Применить как черновик? Текущие вопросы будут заменены.',
|
'Применить как черновик? Текущие вопросы будут заменены.',
|
||||||
);
|
);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draft.title) titleEl.value = draft.title;
|
if (draft.title) titleEl.value = draft.title;
|
||||||
@@ -1152,10 +1345,9 @@
|
|||||||
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
|
||||||
renumber();
|
renumber();
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
|
showAiToast(`Применено: ${draft.questions.length} вопросов`, 'ok');
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
aiAlert(null, e.message);
|
aiAlert(null, e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1167,7 +1359,7 @@
|
|||||||
alert('В тесте нет вопросов — нечего проверять.');
|
alert('В тесте нет вопросов — нечего проверять.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
aiStatusEl.textContent = 'Анализируем…';
|
showAiToast('Проверка теста…', 'info', 0);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1179,8 +1371,9 @@
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
if (!r.ok) return aiAlert(data);
|
if (!r.ok) return aiAlert(data);
|
||||||
|
showAiToast('Проверка готова — см. окно', 'ok');
|
||||||
const rev = data.review || {};
|
const rev = data.review || {};
|
||||||
const verdict = rev.verdict || 'warn';
|
const verdict = rev.verdict || 'warn';
|
||||||
const verdictMap = {
|
const verdictMap = {
|
||||||
@@ -1208,7 +1401,7 @@
|
|||||||
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
|
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
aiAlert(null, e.message);
|
aiAlert(null, e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1220,7 +1413,7 @@
|
|||||||
alert('В тесте нет вопросов — нечего улучшать.');
|
alert('В тесте нет вопросов — нечего улучшать.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
aiStatusEl.textContent = 'Улучшаем…';
|
showAiToast('Подготовка улучшений…', 'info', 0);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1229,11 +1422,13 @@
|
|||||||
testTitle: titleEl.value,
|
testTitle: titleEl.value,
|
||||||
testDescription: descEl.value,
|
testDescription: descEl.value,
|
||||||
questions: payload.questions,
|
questions: payload.questions,
|
||||||
|
focus: (aiImproveFocusEl && aiImproveFocusEl.value) || 'all',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
if (!r.ok) return aiAlert(data);
|
if (!r.ok) return aiAlert(data);
|
||||||
|
showAiToast('Выберите в окне, что применить', 'ok');
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
|
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
|
||||||
@@ -1297,21 +1492,71 @@
|
|||||||
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
|
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
|
||||||
const optsEl = $('.q-options', node);
|
const optsEl = $('.q-options', node);
|
||||||
optsEl.innerHTML = '';
|
optsEl.innerHTML = '';
|
||||||
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
|
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
|
||||||
});
|
});
|
||||||
|
$$('#questions .q-item').forEach((node) => {
|
||||||
|
if (!node.classList.contains('q-removed')) syncOptionInputTypes(node);
|
||||||
|
});
|
||||||
|
renumber();
|
||||||
modal.close();
|
modal.close();
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
|
showAiToast('Изменения применены — сохраните тест', 'ok');
|
||||||
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiStatusEl.textContent = '';
|
showAiToast('', 'info');
|
||||||
aiAlert(null, e.message);
|
aiAlert(null, e.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function improveSingleOption(qNode, optIndex) {
|
||||||
|
const rows = $$('.opt-item', qNode);
|
||||||
|
const row = rows[optIndex];
|
||||||
|
if (!row) return;
|
||||||
|
const overlay = $('.opt-ai-overlay', row);
|
||||||
|
const ta = $('.opt-text', row);
|
||||||
|
const aiBtn = $('.opt-ai', row);
|
||||||
|
|
||||||
|
const setLocalBusy = (on) => {
|
||||||
|
if (overlay) overlay.classList.toggle('hidden', !on);
|
||||||
|
if (ta) ta.toggleAttribute('readonly', !!on);
|
||||||
|
if (aiBtn) aiBtn.disabled = !!on;
|
||||||
|
row.classList.toggle('opt-item--ai-busy', !!on);
|
||||||
|
};
|
||||||
|
|
||||||
|
setLocalBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve-option`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
testTitle: titleEl.value,
|
||||||
|
testDescription: descEl.value,
|
||||||
|
questionText: $('.q-text', qNode).value,
|
||||||
|
optionIndex: optIndex,
|
||||||
|
options: rows.map((op) => ({
|
||||||
|
text: $('.opt-text', op).value.trim(),
|
||||||
|
isCorrect: $('.opt-correct', op).checked,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) {
|
||||||
|
setLocalBusy(false);
|
||||||
|
return aiAlert(data);
|
||||||
|
}
|
||||||
|
const t = $('.opt-text', row);
|
||||||
|
t.value = (data.text != null ? data.text : t.value) || '';
|
||||||
|
autoResize(t);
|
||||||
|
scheduleDirtyCheck();
|
||||||
|
setLocalBusy(false);
|
||||||
|
} catch (e) {
|
||||||
|
setLocalBusy(false);
|
||||||
|
aiAlert(null, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */
|
/** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */
|
||||||
function shuffleQuestionOptionsDom(qNode) {
|
function shuffleQuestionOptionsDom(qNode) {
|
||||||
const optsEl = $('.q-options', qNode);
|
const optsEl = $('.q-options', qNode);
|
||||||
@@ -1325,6 +1570,44 @@
|
|||||||
rows.forEach((el) => optsEl.appendChild(el));
|
rows.forEach((el) => optsEl.appendChild(el));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickQuestionAiMode(canDistractors) {
|
||||||
|
const dlg = document.getElementById('dlg-q-ai-mode');
|
||||||
|
const distBtn = document.getElementById('q-ai-mode-distractors');
|
||||||
|
const qBtn = document.getElementById('q-ai-mode-question');
|
||||||
|
const optBtn = document.getElementById('q-ai-mode-options');
|
||||||
|
const cancelBtn = document.getElementById('q-ai-mode-cancel');
|
||||||
|
if (!dlg || typeof dlg.showModal !== 'function') {
|
||||||
|
return Promise.resolve('rephrase');
|
||||||
|
}
|
||||||
|
if (distBtn) {
|
||||||
|
distBtn.disabled = !canDistractors;
|
||||||
|
distBtn.style.opacity = canDistractors ? '' : '0.45';
|
||||||
|
distBtn.title = canDistractors ? '' : 'Нет пустых полей для дистракторов';
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const safeResolve = (val) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
const onClose = () => {
|
||||||
|
safeResolve(null);
|
||||||
|
};
|
||||||
|
dlg.addEventListener('close', onClose, { once: true });
|
||||||
|
const pick = (mode) => () => {
|
||||||
|
dlg.removeEventListener('close', onClose);
|
||||||
|
safeResolve(mode);
|
||||||
|
dlg.close();
|
||||||
|
};
|
||||||
|
distBtn?.addEventListener('click', pick('distractors'), { once: true });
|
||||||
|
qBtn?.addEventListener('click', pick('rephrase'), { once: true });
|
||||||
|
optBtn?.addEventListener('click', pick('improve_options'), { once: true });
|
||||||
|
cancelBtn?.addEventListener('click', pick(null), { once: true });
|
||||||
|
dlg.showModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function aiGenerateQuestion(node) {
|
async function aiGenerateQuestion(node) {
|
||||||
const qTextEl = $('.q-text', node);
|
const qTextEl = $('.q-text', node);
|
||||||
const qText = qTextEl.value.trim();
|
const qText = qTextEl.value.trim();
|
||||||
@@ -1333,32 +1616,25 @@
|
|||||||
const multi = $('.q-multi', node).checked;
|
const multi = $('.q-multi', node).checked;
|
||||||
const overlay = $('.q-ai-overlay', node);
|
const overlay = $('.q-ai-overlay', node);
|
||||||
|
|
||||||
// Показываем оверлей
|
|
||||||
overlay?.classList.remove('hidden');
|
|
||||||
node.style.pointerEvents = 'none';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Собираем варианты с их состоянием
|
|
||||||
const existingOptions = existingOpts.map((op) => ({
|
const existingOptions = existingOpts.map((op) => ({
|
||||||
text: $('.opt-text', op).value.trim(),
|
text: $('.opt-text', op).value.trim(),
|
||||||
isCorrect: $('.opt-correct', op).checked,
|
isCorrect: $('.opt-correct', op).checked,
|
||||||
}));
|
}));
|
||||||
const emptySlots = existingOptions.filter((o) => !o.text).length;
|
const emptySlots = existingOptions.filter((o) => !o.text).length;
|
||||||
const filledSlots = existingOptions.filter((o) => o.text).length;
|
|
||||||
|
|
||||||
// Выбираем режим:
|
|
||||||
// - нет текста вопроса → full
|
|
||||||
// - есть вопрос + есть пустые варианты (и хоть один заполнен) → distractors
|
|
||||||
// - есть вопрос, все варианты заполнены или вариантов нет → rephrase
|
|
||||||
let requestMode;
|
let requestMode;
|
||||||
if (!qText) {
|
if (!qText) {
|
||||||
requestMode = 'full';
|
requestMode = 'full';
|
||||||
} else if (emptySlots > 0 && filledSlots > 0) {
|
|
||||||
requestMode = 'distractors';
|
|
||||||
} else {
|
} else {
|
||||||
requestMode = 'rephrase';
|
const choice = await pickQuestionAiMode(emptySlots > 0);
|
||||||
|
if (!choice) return;
|
||||||
|
requestMode = choice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overlay?.classList.remove('hidden');
|
||||||
|
node.style.pointerEvents = 'none';
|
||||||
|
|
||||||
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
|
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1375,20 +1651,17 @@
|
|||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
|
||||||
|
|
||||||
// Обновляем текст вопроса (кроме режима дистракторов — текст не меняем)
|
if (data.mode === 'rephrase' || data.mode === 'full') {
|
||||||
if (data.mode !== 'distractors') {
|
|
||||||
qTextEl.value = data.text || qText;
|
qTextEl.value = data.text || qText;
|
||||||
autoResize(qTextEl);
|
autoResize(qTextEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const optsEl = $('.q-options', node);
|
const optsEl = $('.q-options', node);
|
||||||
if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) {
|
if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) {
|
||||||
// Полная замена вариантов
|
|
||||||
optsEl.innerHTML = '';
|
optsEl.innerHTML = '';
|
||||||
data.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
|
data.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
|
||||||
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
|
||||||
} else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) {
|
} else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) {
|
||||||
// Заполняем только пустые слоты
|
|
||||||
let dIdx = 0;
|
let dIdx = 0;
|
||||||
existingOpts.forEach((op) => {
|
existingOpts.forEach((op) => {
|
||||||
const t = $('.opt-text', op);
|
const t = $('.opt-text', op);
|
||||||
@@ -1399,6 +1672,15 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
shuffleQuestionOptionsDom(node);
|
shuffleQuestionOptionsDom(node);
|
||||||
|
} else if (data.mode === 'improve_options' && Array.isArray(data.options) && data.options.length) {
|
||||||
|
const rows = $$('.opt-item', node);
|
||||||
|
data.options.forEach((o, i) => {
|
||||||
|
if (!rows[i]) return;
|
||||||
|
const ta = $('.opt-text', rows[i]);
|
||||||
|
ta.value = o.text != null ? o.text : ta.value;
|
||||||
|
$('.opt-correct', rows[i]).checked = !!o.isCorrect;
|
||||||
|
autoResize(ta);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
syncOptionInputTypes(node);
|
syncOptionInputTypes(node);
|
||||||
@@ -1406,14 +1688,40 @@
|
|||||||
updateAiButtonLabel(node);
|
updateAiButtonLabel(node);
|
||||||
scheduleDirtyCheck();
|
scheduleDirtyCheck();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiStatusEl.textContent = '';
|
const msg = e.message || 'AI: ошибка.';
|
||||||
alert(e.message || 'AI: ошибка.');
|
showAiToast(msg, 'err', 7000);
|
||||||
|
alert(msg);
|
||||||
} finally {
|
} finally {
|
||||||
overlay?.classList.add('hidden');
|
overlay?.classList.add('hidden');
|
||||||
node.style.pointerEvents = '';
|
node.style.pointerEvents = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (addQuestionAiBtn) {
|
||||||
|
addQuestionAiBtn.addEventListener('click', async () => {
|
||||||
|
const node = renderQuestion({
|
||||||
|
text: '',
|
||||||
|
hasMultipleAnswers: false,
|
||||||
|
options: [
|
||||||
|
{ text: '', isCorrect: true },
|
||||||
|
{ text: '', isCorrect: false },
|
||||||
|
{ text: '', isCorrect: false },
|
||||||
|
{ text: '', isCorrect: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
questionsEl.appendChild(node);
|
||||||
|
renumber();
|
||||||
|
scheduleDirtyCheck();
|
||||||
|
try {
|
||||||
|
node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
} catch (_) {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
showAiToast('Добавлен блок — запускаем ИИ для одного вопроса…', 'info');
|
||||||
|
await aiGenerateQuestion(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
|
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
|
||||||
|
|
||||||
function updateChainActiveDisplay(active) {
|
function updateChainActiveDisplay(active) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
data-test-id="{{ test_id }}"
|
data-test-id="{{ test_id }}"
|
||||||
data-initial='{{ content | tojson | safe }}'>
|
data-initial='{{ content | tojson | safe }}'>
|
||||||
|
|
||||||
|
<div id="editor-gen-toast" class="editor-gen-toast" role="status" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
<section class="cabinet-brick cabinet-brick--hero hero-brick">
|
<section class="cabinet-brick cabinet-brick--hero hero-brick">
|
||||||
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← К тестам</a>
|
<a href="{{ url_for('tests.tests_list_page') }}" class="link-back">← К тестам</a>
|
||||||
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
<textarea id="test-title" maxlength="200" rows="1" placeholder="Название теста"
|
||||||
@@ -164,6 +166,10 @@
|
|||||||
placeholder="Например: охрана труда на производстве"
|
placeholder="Например: охрана труда на производстве"
|
||||||
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
style="resize:none; overflow:hidden; font-family:inherit;"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="mt-2 inline-flex items-start gap-2 text-sm text-ink-700 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" id="ai-keep-title" class="mt-1 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||||
|
<span>Не менять название теста в редакторе после генерации (оставить текущее)</span>
|
||||||
|
</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<button id="ai-generate-test"
|
<button id="ai-generate-test"
|
||||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||||
@@ -185,7 +191,18 @@
|
|||||||
<p class="text-xs text-ink-500 leading-snug mb-3">
|
<p class="text-xs text-ink-500 leading-snug mb-3">
|
||||||
<span class="font-medium text-ink-600">Предложить улучшение</span> — ИИ предложит правки по каждому вопросу (было → стало); вы отметите, что применить к черновику.
|
<span class="font-medium text-ink-600">Предложить улучшение</span> — ИИ предложит правки по каждому вопросу (было → стало); вы отметите, что применить к черновику.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-col sm:flex-row sm:flex-wrap sm:items-end gap-2">
|
||||||
|
<label class="flex flex-col gap-1 min-w-[12rem] flex-1">
|
||||||
|
<span class="text-xs font-medium text-ink-600">Область улучшения</span>
|
||||||
|
<select id="ai-improve-focus"
|
||||||
|
class="form-input text-sm py-2 rounded-lg border border-ink-300 bg-white">
|
||||||
|
<option value="all">Всё: вопросы и варианты</option>
|
||||||
|
<option value="questions">Только формулировки вопросов</option>
|
||||||
|
<option value="distractors">Только неверные варианты (дистракторы)</option>
|
||||||
|
<option value="options">Все варианты ответа (без смены верности)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
<button id="ai-check"
|
<button id="ai-check"
|
||||||
class="btn btn-ghost" type="button" style="min-height:43px;">
|
class="btn btn-ghost" type="button" style="min-height:43px;">
|
||||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">fact_check</span>
|
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">fact_check</span>
|
||||||
@@ -196,13 +213,14 @@
|
|||||||
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_fix_high</span>
|
<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_fix_high</span>
|
||||||
Предложить улучшение
|
Предложить улучшение
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ── Документ в вопросы ──────────────────────────────────── #}
|
{# ── Документ в вопросы ──────────────────────────────────── #}
|
||||||
<div class="question-editor-block test-detail-subsection test-detail-subsection--import">
|
<div class="question-editor-block test-detail-subsection test-detail-subsection--import">
|
||||||
<h3 class="test-detail-subsection__title">Документ в вопросы</h3>
|
<h3 class="test-detail-subsection__title">Документ в вопросы</h3>
|
||||||
<p class="text-xs text-ink-500 leading-snug mb-2">
|
<p class="text-xs text-ink-500 leading-snug mb-2 text-center">
|
||||||
<span class="font-medium text-ink-600">Сгенерировать из документа</span> — из файла извлекается текст; ИИ составляет вопросы по содержанию и шаблону из «Параметров» (число вопросов, вариантов, несколько верных и т.д.), с учётом поля «Пожелания», если оно заполнено. Перед заменой откроется предпросмотр: «Применить» подставит черновик вместо текущих вопросов; дальше сохраните тест на сервер — подсказки и версии ведут себя так же, как при генерации по теме.
|
<span class="font-medium text-ink-600">Сгенерировать из документа</span> — из файла извлекается текст; ИИ составляет вопросы по содержанию и шаблону из «Параметров» (число вопросов, вариантов, несколько верных и т.д.), с учётом поля «Пожелания», если оно заполнено. Перед заменой откроется предпросмотр: «Применить» подставит черновик вместо текущих вопросов; дальше сохраните тест на сервер — подсказки и версии ведут себя так же, как при генерации по теме.
|
||||||
</p>
|
</p>
|
||||||
<label id="ai-import-dropzone"
|
<label id="ai-import-dropzone"
|
||||||
@@ -210,10 +228,15 @@
|
|||||||
px-4 py-5 rounded-xl bg-white border-2 border-dashed border-ink-300/70
|
px-4 py-5 rounded-xl bg-white border-2 border-dashed border-ink-300/70
|
||||||
hover:border-brand-400 hover:bg-brand-50/40 cursor-pointer transition-colors">
|
hover:border-brand-400 hover:bg-brand-50/40 cursor-pointer transition-colors">
|
||||||
<span class="material-symbols-outlined text-2xl text-brand-400">upload_file</span>
|
<span class="material-symbols-outlined text-2xl text-brand-400">upload_file</span>
|
||||||
<span id="ai-import-dropzone-label" class="text-sm font-medium text-ink-700">Перетащите файл сюда или нажмите</span>
|
<span id="ai-import-dropzone-label" class="text-sm font-medium text-ink-700 text-center block w-full">Перетащите файлы сюда или нажмите</span>
|
||||||
<span class="text-xs text-ink-400">PDF, DOCX, TXT, MD · до 16 МБ</span>
|
<span class="text-xs text-ink-400 text-center block w-full">PDF, DOCX, TXT, MD · до 5 за раз · до 16 МБ · повторный выбор добавляет к уже загруженным; полный сброс — «Сбросить загрузку».</span>
|
||||||
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" class="hidden" />
|
<input id="ai-import-file" type="file" accept=".pdf,.docx,.txt,.md" multiple class="hidden" />
|
||||||
</label>
|
</label>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button type="button" id="ai-import-clear" class="btn btn-ghost btn--sm text-sm" style="min-height:36px;">
|
||||||
|
Сбросить загрузку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<label class="block mt-3">
|
<label class="block mt-3">
|
||||||
<span class="form-label">Пожелания по содержанию <span class="text-ink-400 font-normal">(необязательно)</span></span>
|
<span class="form-label">Пожелания по содержанию <span class="text-ink-400 font-normal">(необязательно)</span></span>
|
||||||
<textarea id="doc-user-hint" rows="1"
|
<textarea id="doc-user-hint" rows="1"
|
||||||
@@ -226,7 +249,6 @@
|
|||||||
<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>
|
||||||
|
|
||||||
{# ── Модалка результата импорта документа ─────────────────── #}
|
{# ── Модалка результата импорта документа ─────────────────── #}
|
||||||
@@ -238,7 +260,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<p id="ai-status" class="editor-generation-panel__status text-sm text-ink-500"></p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -257,7 +278,7 @@
|
|||||||
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
<h2 class="font-semibold text-ink-900">Вопросы (<span id="q-count-mirror">0</span>)</h2>
|
||||||
</div>
|
</div>
|
||||||
<ol id="questions" class="mt-3 space-y-4"></ol>
|
<ol id="questions" class="mt-3 space-y-4"></ol>
|
||||||
<div class="mt-3 flex justify-center">
|
<div class="mt-3 flex justify-center flex-wrap gap-2">
|
||||||
<button id="add-question"
|
<button id="add-question"
|
||||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
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
|
bg-white border border-ink-300/60 hover:border-brand-300 text-sm min-h-10
|
||||||
@@ -265,6 +286,14 @@
|
|||||||
<span class="material-symbols-outlined text-base">add</span>
|
<span class="material-symbols-outlined text-base">add</span>
|
||||||
<span>Добавить вопрос</span>
|
<span>Добавить вопрос</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="add-question-ai" type="button"
|
||||||
|
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg
|
||||||
|
bg-brand-50 border border-brand-200 hover:border-brand-400 text-sm min-h-10
|
||||||
|
btn btn-ghost question-editor__add-question-ai"
|
||||||
|
title="Добавляет пустой блок и вызывает ИИ для одного нового вопроса с вариантами">
|
||||||
|
<span class="material-symbols-outlined text-base">auto_awesome</span>
|
||||||
|
<span>Новый вопрос (ИИ)</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -396,11 +425,26 @@
|
|||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
class="opt-correct w-5 h-5 rounded border-ink-300 text-brand-600 focus:ring-brand-500" />
|
||||||
</label>
|
</label>
|
||||||
<textarea rows="1"
|
<div class="opt-text-wrap relative flex-1 min-w-0 self-start">
|
||||||
class="opt-text flex-1 min-w-0 rounded-lg border border-ink-300 px-3 py-2
|
<textarea rows="1"
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
class="opt-text w-full rounded-lg border border-ink-300 px-3 py-2
|
||||||
placeholder="Вариант ответа"
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||||
style="resize:none; overflow:hidden; font-family:inherit; line-height:1.55;"></textarea>
|
placeholder="Вариант ответа"
|
||||||
|
style="resize:none; overflow:hidden; font-family:inherit; line-height:1.55;"></textarea>
|
||||||
|
<div class="opt-ai-overlay hidden absolute inset-0 rounded-lg z-[5]
|
||||||
|
bg-white/85 backdrop-blur-[2px] flex flex-col items-center justify-center gap-1
|
||||||
|
border border-ink-200/80 shadow-sm">
|
||||||
|
<span class="inline-block w-6 h-6 rounded-full
|
||||||
|
border-[3px] border-brand-200 border-t-brand-600 animate-spin"></span>
|
||||||
|
<span class="text-xs text-ink-600 font-medium px-2 text-center leading-snug">Улучшаю…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="opt-ai shrink-0 inline-flex items-center justify-center
|
||||||
|
rounded hover:bg-brand-50 text-brand-700 px-1.5 pt-1.5"
|
||||||
|
style="min-height:2.5rem; min-width:2.25rem;"
|
||||||
|
title="Улучшить только этот вариант (ИИ)" aria-label="Улучшить вариант ИИ">
|
||||||
|
<span class="material-symbols-outlined text-base">auto_fix_high</span>
|
||||||
|
</button>
|
||||||
<button class="opt-delete shrink-0 w-10 inline-flex items-center justify-center
|
<button class="opt-delete shrink-0 w-10 inline-flex items-center justify-center
|
||||||
rounded hover:bg-red-50 text-red-600 pt-1.5"
|
rounded hover:bg-red-50 text-red-600 pt-1.5"
|
||||||
style="min-height:2.5rem;"
|
style="min-height:2.5rem;"
|
||||||
@@ -428,6 +472,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
{# ── Выбор режима ИИ для непустого вопроса (улучшить / дистракторы) ── #}
|
||||||
|
<dialog id="dlg-q-ai-mode" class="save-modal" style="max-width: 24rem; width: calc(100% - 2rem);">
|
||||||
|
<div class="save-modal__inner">
|
||||||
|
<h3 class="text-base font-semibold text-ink-900 mb-1">Что сделать с вопросом?</h3>
|
||||||
|
<p class="text-xs text-ink-500 mb-3">Выберите, что должен сделать ИИ.</p>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button type="button" id="q-ai-mode-distractors"
|
||||||
|
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
|
||||||
|
hover:bg-brand-50 hover:border-brand-200 transition-colors">
|
||||||
|
<span class="font-medium text-ink-800">Добавить дистракторы</span>
|
||||||
|
<span class="block text-ink-500 text-xs mt-0.5">Заполнить пустые поля вариантов</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="q-ai-mode-question"
|
||||||
|
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
|
||||||
|
hover:bg-brand-50 hover:border-brand-200 transition-colors">
|
||||||
|
<span class="font-medium text-ink-800">Улучшить только вопрос</span>
|
||||||
|
<span class="block text-ink-500 text-xs mt-0.5">Переформулировать текст вопроса</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="q-ai-mode-options"
|
||||||
|
class="px-3 py-2.5 rounded-lg border border-ink-200 text-left text-sm
|
||||||
|
hover:bg-brand-50 hover:border-brand-200 transition-colors">
|
||||||
|
<span class="font-medium text-ink-800">Улучшить только варианты</span>
|
||||||
|
<span class="block text-ink-500 text-xs mt-0.5">Тексты ответов, без смены верности</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<button type="button" id="q-ai-mode-cancel"
|
||||||
|
class="px-3 py-2 rounded-lg text-ink-600 hover:bg-ink-100 text-sm">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
{# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #}
|
{# ── Модалка результата AI-проверки/улучшения (fullscreen на мобиле) ── #}
|
||||||
<dialog id="ai-modal"
|
<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]
|
class="m-0 p-0 w-full h-full sm:h-auto sm:max-w-3xl sm:w-full sm:max-h-[90vh]
|
||||||
|
|||||||
@@ -19,16 +19,37 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="legacy-list-shell">
|
<section class="legacy-list-shell">
|
||||||
<h1 class="font-headline legacy-list-title">Тесты</h1>
|
<h1 class="font-headline legacy-list-title">Тесты</h1>
|
||||||
<div class="legacy-list-toolbar">
|
<div class="legacy-list-toolbar legacy-list-toolbar--wrap flex flex-wrap items-center gap-3">
|
||||||
<button id="btn-create-test" class="btn btn-ghost" type="button">
|
<div class="flex flex-col sm:flex-row flex-1 min-w-0 gap-2 sm:gap-3">
|
||||||
|
<label class="flex flex-col gap-1 flex-1 min-w-[12rem] max-w-md">
|
||||||
|
<span class="text-xs font-medium text-ink-600">Поиск по названию</span>
|
||||||
|
<input id="catalog-search" type="search" autocomplete="off" placeholder="Начните вводить…"
|
||||||
|
class="rounded-lg border border-ink-300 px-3 py-2 text-sm w-full
|
||||||
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 w-full sm:w-52 shrink-0">
|
||||||
|
<span class="text-xs font-medium text-ink-600">Автор</span>
|
||||||
|
<select id="catalog-author"
|
||||||
|
class="rounded-lg border border-ink-300 px-3 py-2 text-sm w-full bg-white
|
||||||
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20">
|
||||||
|
<option value="">Все авторы</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button id="btn-create-test" class="btn btn-ghost shrink-0" type="button">
|
||||||
Создать
|
Создать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="catalog-filter-empty" class="text-sm text-ink-500 mt-2 hidden" role="status"></p>
|
||||||
|
|
||||||
{% if visible %}
|
{% if visible %}
|
||||||
<ul class="list-stack" aria-label="Тесты в общем списке">
|
<ul class="list-stack" aria-label="Тесты в общем списке">
|
||||||
{% for t in visible %}
|
{% for t in visible %}
|
||||||
<li class="list-row list-row--split">
|
<li class="list-row list-row--split list-row--catalog"
|
||||||
|
data-catalog-row
|
||||||
|
data-title-lower="{{ (t.title or '')|lower }}"
|
||||||
|
data-author-id="{{ t.created_by or '' }}"
|
||||||
|
data-author-name="{{ (t.author_full_name or '—')|e }}">
|
||||||
<div class="list-row__main">
|
<div class="list-row__main">
|
||||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
|
||||||
<span class="list-row__title">{{ t.title }}</span>
|
<span class="list-row__title">{{ t.title }}</span>
|
||||||
@@ -55,7 +76,11 @@
|
|||||||
<h2 class="font-headline legacy-list-subtitle">Скрытые вами из списка</h2>
|
<h2 class="font-headline legacy-list-subtitle">Скрытые вами из списка</h2>
|
||||||
<ul class="list-stack" aria-label="Скрытые тесты автора">
|
<ul class="list-stack" aria-label="Скрытые тесты автора">
|
||||||
{% for t in hidden %}
|
{% for t in hidden %}
|
||||||
<li class="list-row list-row--split list-row--hidden">
|
<li class="list-row list-row--split list-row--hidden list-row--catalog"
|
||||||
|
data-catalog-row
|
||||||
|
data-title-lower="{{ (t.title or '')|lower }}"
|
||||||
|
data-author-id="{{ t.created_by or '' }}"
|
||||||
|
data-author-name="{{ (t.author_full_name or '—')|e }}">
|
||||||
<div class="list-row__main">
|
<div class="list-row__main">
|
||||||
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link">
|
||||||
<span class="list-row__title">{{ t.title }}</span>
|
<span class="list-row__title">{{ t.title }}</span>
|
||||||
@@ -124,6 +149,58 @@ class="m-0 p-0 w-full sm:w-full sm:max-w-md
|
|||||||
const dlg = document.getElementById('dlg-create');
|
const dlg = document.getElementById('dlg-create');
|
||||||
const titleEl = document.getElementById('new-test-title');
|
const titleEl = document.getElementById('new-test-title');
|
||||||
const descEl = document.getElementById('new-test-desc');
|
const descEl = document.getElementById('new-test-desc');
|
||||||
|
const catalogSearch = document.getElementById('catalog-search');
|
||||||
|
const catalogAuthor = document.getElementById('catalog-author');
|
||||||
|
const catalogEmpty = document.getElementById('catalog-filter-empty');
|
||||||
|
|
||||||
|
(function initCatalogFilter() {
|
||||||
|
if (!catalogSearch || !catalogAuthor) return;
|
||||||
|
const rows = Array.from(document.querySelectorAll('[data-catalog-row]'));
|
||||||
|
const byAuthor = new Map();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const id = (row.dataset.authorId || '').trim();
|
||||||
|
const name = (row.dataset.authorName || '').trim() || '—';
|
||||||
|
if (id && !byAuthor.has(id)) byAuthor.set(id, name);
|
||||||
|
});
|
||||||
|
const sorted = Array.from(byAuthor.entries()).sort((a, b) => a[1].localeCompare(b[1], 'ru'));
|
||||||
|
sorted.forEach(([id, name]) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = name;
|
||||||
|
catalogAuthor.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const q = (catalogSearch.value || '').trim().toLowerCase();
|
||||||
|
const author = (catalogAuthor.value || '').trim();
|
||||||
|
let visibleCount = 0;
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const title = row.dataset.titleLower || '';
|
||||||
|
const aid = (row.dataset.authorId || '').trim();
|
||||||
|
const matchQ = !q || title.includes(q);
|
||||||
|
const matchA = !author || aid === author;
|
||||||
|
const show = matchQ && matchA;
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (show) visibleCount += 1;
|
||||||
|
});
|
||||||
|
if (catalogEmpty) {
|
||||||
|
if (rows.length && visibleCount === 0) {
|
||||||
|
catalogEmpty.textContent = 'Ничего не найдено — измените запрос или фильтр.';
|
||||||
|
catalogEmpty.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
catalogEmpty.textContent = '';
|
||||||
|
catalogEmpty.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = null;
|
||||||
|
catalogSearch.addEventListener('input', () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(applyFilter, 120);
|
||||||
|
});
|
||||||
|
catalogAuthor.addEventListener('change', applyFilter);
|
||||||
|
})();
|
||||||
|
|
||||||
document.getElementById('btn-create-test').addEventListener('click', () => {
|
document.getElementById('btn-create-test').addEventListener('click', () => {
|
||||||
titleEl.value = '';
|
titleEl.value = '';
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from ..services.ai_editor import (
|
|||||||
generate_full_test_by_shape,
|
generate_full_test_by_shape,
|
||||||
generate_or_rephrase_question,
|
generate_or_rephrase_question,
|
||||||
generate_test_by_title,
|
generate_test_by_title,
|
||||||
|
improve_single_option_text,
|
||||||
improve_test_full,
|
improve_test_full,
|
||||||
parse_and_validate_shape,
|
parse_and_validate_shape,
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,7 @@ from ..services.test_attempt import (
|
|||||||
check_question_for_attempt,
|
check_question_for_attempt,
|
||||||
count_missing_hints,
|
count_missing_hints,
|
||||||
generate_missing_hints_for_test,
|
generate_missing_hints_for_test,
|
||||||
|
generate_next_missing_hint_for_test,
|
||||||
get_attempt_review_for_user,
|
get_attempt_review_for_user,
|
||||||
get_play_content,
|
get_play_content,
|
||||||
list_test_attempts_for_author,
|
list_test_attempts_for_author,
|
||||||
@@ -350,6 +352,17 @@ def api_test_hints_generate(test_id):
|
|||||||
return jsonify(out)
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/api/tests/<test_id>/ai/hints/generate-next', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_test_hints_generate_next(test_id):
|
||||||
|
user = current_user()
|
||||||
|
try:
|
||||||
|
out = generate_next_missing_hint_for_test(None, user.id, test_id)
|
||||||
|
except AttemptHttpError as e:
|
||||||
|
return jsonify(error=e.message), e.status
|
||||||
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/check', methods=['POST'])
|
@tests_bp.route('/api/tests/<test_id>/attempts/<attempt_id>/check', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_attempt_check_question(test_id, attempt_id):
|
def api_attempt_check_question(test_id, attempt_id):
|
||||||
@@ -511,6 +524,26 @@ def api_ai_improve_test(test_id):
|
|||||||
body.get('testTitle') or '',
|
body.get('testTitle') or '',
|
||||||
body.get('testDescription') or '',
|
body.get('testDescription') or '',
|
||||||
body.get('questions') or [],
|
body.get('questions') or [],
|
||||||
|
focus=body.get('focus') or 'all',
|
||||||
|
)
|
||||||
|
except (AiHttpError, LlmError) as e:
|
||||||
|
return _ai_error_response(e)
|
||||||
|
return jsonify(ok=True, **out)
|
||||||
|
|
||||||
|
|
||||||
|
@tests_bp.route('/api/tests/<test_id>/ai/improve-option', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_ai_improve_option(test_id):
|
||||||
|
user = current_user()
|
||||||
|
_check_test_author_or_404(test_id, user.id)
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
out = improve_single_option_text(
|
||||||
|
body.get('testTitle') or '',
|
||||||
|
body.get('testDescription') or '',
|
||||||
|
body.get('questionText') or '',
|
||||||
|
body.get('options') or [],
|
||||||
|
body.get('optionIndex'),
|
||||||
)
|
)
|
||||||
except (AiHttpError, LlmError) as e:
|
except (AiHttpError, LlmError) as e:
|
||||||
return _ai_error_response(e)
|
return _ai_error_response(e)
|
||||||
@@ -522,25 +555,44 @@ def api_ai_improve_test(test_id):
|
|||||||
@tests_bp.route('/api/tests/import/document', methods=['POST'])
|
@tests_bp.route('/api/tests/import/document', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_import_document():
|
def api_import_document():
|
||||||
"""Шаг 1: загрузить файл и извлечь текст. Генерация — отдельным запросом."""
|
"""Шаг 1: загрузить один или несколько файлов и извлечь текст. Генерация — отдельным запросом."""
|
||||||
f = request.files.get('file')
|
files = request.files.getlist('files')
|
||||||
if f is None or not f.filename:
|
if not files:
|
||||||
return jsonify(error='Прикрепите файл к полю file.'), 400
|
f = request.files.get('file')
|
||||||
try:
|
files = [f] if f is not None and getattr(f, 'filename', None) else []
|
||||||
extracted = extract_text_from_file(f.mimetype, f, f.filename)
|
files = [x for x in files if x is not None and getattr(x, 'filename', None)]
|
||||||
except DocExtractHttpError as e:
|
if not files:
|
||||||
return jsonify(error=e.message), e.status
|
return jsonify(error='Прикрепите файл(ы) к полю file или files.'), 400
|
||||||
except Exception:
|
if len(files) > 5:
|
||||||
log.exception('extract_text_from_file failed')
|
return jsonify(error='Не более 5 файлов за один раз.'), 400
|
||||||
return jsonify(error='Не удалось разобрать файл.'), 500
|
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
names: list[str] = []
|
||||||
|
mimes: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
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
|
||||||
|
parts.append(extracted)
|
||||||
|
names.append(f.filename or '')
|
||||||
|
mimes.append(f.mimetype or '')
|
||||||
|
|
||||||
|
combined = '\n\n---\n\n'.join(parts)
|
||||||
|
primary = files[0]
|
||||||
return jsonify(
|
return jsonify(
|
||||||
received=True,
|
received=True,
|
||||||
originalName=f.filename,
|
originalName=primary.filename,
|
||||||
mime=f.mimetype,
|
originalNames=names,
|
||||||
size=len(extracted.encode('utf-8')),
|
mime=primary.mimetype,
|
||||||
extractedText=extracted,
|
mimes=mimes,
|
||||||
textLength=len(extracted),
|
size=len(combined.encode('utf-8')),
|
||||||
|
extractedText=combined,
|
||||||
|
textLength=len(combined),
|
||||||
|
fileCount=len(files),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user