testingwebapp fixes, weeek tasks 2948-2958
This commit is contained in:
@@ -225,11 +225,64 @@ def check_test_quality(test_title: str, test_description: str, questions: list[d
|
||||
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-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса.
|
||||
|
||||
Для каждого вопроса: original + suggested, флаги textChanged/optionsChanged.
|
||||
UI решает, что применить (чекбоксы).
|
||||
focus: all | questions | distractors | options
|
||||
"""
|
||||
cfg = _require_cfg()
|
||||
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:
|
||||
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.')
|
||||
|
||||
system = (
|
||||
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь '
|
||||
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку '
|
||||
'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и '
|
||||
'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. '
|
||||
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
|
||||
'"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.'
|
||||
)
|
||||
focus = (focus or 'all').strip().lower()
|
||||
if focus not in ('all', 'questions', 'distractors', 'options'):
|
||||
focus = 'all'
|
||||
|
||||
system_by_focus = {
|
||||
'all': (
|
||||
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь '
|
||||
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку '
|
||||
'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 = {
|
||||
'title': title,
|
||||
'description': desc,
|
||||
@@ -280,7 +362,9 @@ def improve_test_full(test_title: str, test_description: str, questions: list[di
|
||||
draft = validate_and_normalize_draft(
|
||||
{'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 = []
|
||||
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(
|
||||
@@ -403,45 +533,80 @@ def generate_or_rephrase_question(
|
||||
|
||||
topic = (((test_title or '').strip() or 'Тест') + '. ' + (test_description or '').strip()).strip()
|
||||
qt = (question_text or '').strip()
|
||||
mode = (mode or '').strip() if mode else ''
|
||||
|
||||
# ── Режим дистракторов: есть вопрос + часть вариантов пуста ─────────────
|
||||
if qt and mode == 'distractors' and existing_options:
|
||||
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()] )
|
||||
if empty_count > 0:
|
||||
filled_lines = '\n'.join(
|
||||
f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}'
|
||||
for o in filled
|
||||
) or '(нет)'
|
||||
system = (
|
||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: '
|
||||
f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — '
|
||||
f'ровно {empty_count} объекта в массиве. '
|
||||
'Все тексты на русском, без нумерации, без кавычек.'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Вопрос: {qt}\n\n'
|
||||
f'Уже заполненные варианты:\n{filled_lines}\n\n'
|
||||
f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов '
|
||||
f'(isCorrect: false), которые не повторяют уже существующие варианты '
|
||||
f'и выглядят похоже на реальные ответы.'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
opts = []
|
||||
if isinstance(parsed, dict):
|
||||
opts = parsed.get('options') or []
|
||||
elif isinstance(parsed, list):
|
||||
opts = parsed
|
||||
opts = [
|
||||
{'text': str(o.get('text') or '').strip(), 'isCorrect': False}
|
||||
for o in opts if (o.get('text') or '').strip()
|
||||
][:empty_count]
|
||||
return {'mode': 'distractors', 'text': qt, 'options': opts}
|
||||
empty_count = len([o for o in existing_options if not (o.get('text') or '').strip()])
|
||||
if empty_count <= 0:
|
||||
raise HttpError(400, 'Нет пустых полей для дистракторов — добавьте пустые варианты или выберите другой режим.')
|
||||
filled_lines = '\n'.join(
|
||||
f'- {"✓" if o.get("isCorrect") else "✗"} {o["text"]}'
|
||||
for o in filled
|
||||
) or '(нет)'
|
||||
system = (
|
||||
'Ты составитель учебных тестов. Отвечай ТОЛЬКО JSON: '
|
||||
f'{{"options": [{{"text": string, "isCorrect": false}}, ...]}} — '
|
||||
f'ровно {empty_count} объекта в массиве. '
|
||||
'Все тексты на русском, без нумерации, без кавычек.'
|
||||
)
|
||||
user = (
|
||||
f'Тема теста: {topic}\n\n'
|
||||
f'Вопрос: {qt}\n\n'
|
||||
f'Уже заполненные варианты:\n{filled_lines}\n\n'
|
||||
f'Придумай ровно {empty_count} правдоподобных, но НЕВЕРНЫХ дистракторов '
|
||||
f'(isCorrect: false), которые не повторяют уже существующие варианты '
|
||||
f'и выглядят похоже на реальные ответы.'
|
||||
)
|
||||
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
||||
parsed = parse_json_from_llm_text(raw)
|
||||
opts = []
|
||||
if isinstance(parsed, dict):
|
||||
opts = parsed.get('options') or []
|
||||
elif isinstance(parsed, list):
|
||||
opts = parsed
|
||||
opts = [
|
||||
{'text': str(o.get('text') or '').strip(), 'isCorrect': False}
|
||||
for o in opts if (o.get('text') or '').strip()
|
||||
][: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 = (
|
||||
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — '
|
||||
'чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости '
|
||||
@@ -458,6 +623,12 @@ def generate_or_rephrase_question(
|
||||
raise LlmError('Пустой text в ответе модели.', code='llm_shape')
|
||||
return {'mode': 'rephrase', 'text': text}
|
||||
|
||||
if qt:
|
||||
raise HttpError(
|
||||
400,
|
||||
'Укажите режим: distractors, improve_options или rephrase — или очистите текст вопроса для полной генерации.',
|
||||
)
|
||||
|
||||
# ── Полная генерация: вопрос пуст ────────────────────────────────────────
|
||||
system = (
|
||||
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
|
||||
|
||||
@@ -27,7 +27,9 @@ SUPPORTED_EXT = {
|
||||
|
||||
|
||||
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()
|
||||
if m in SUPPORTED_MIME:
|
||||
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)}
|
||||
|
||||
|
||||
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,
|
||||
question_id: str, selected_option_ids: list[str]) -> dict:
|
||||
session = get_session()
|
||||
|
||||
Reference in New Issue
Block a user