testingwebapp fixes, weeek tasks 2948-2958

This commit is contained in:
Константин Лебединский
2026-05-04 21:29:23 +05:00
parent 0229bc250b
commit 1ea83aa6b4
9 changed files with 1035 additions and 214 deletions
+218 -47
View File
@@ -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", '
+3 -1
View File
@@ -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]
+64
View File
@@ -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()