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", '