660 lines
31 KiB
Python
660 lines
31 KiB
Python
"""AI-генерация теста/вопроса в редакторе (порт `services/aiEditorService.js`)."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from .draft_validator import (
|
|
assert_draft_matches_shape,
|
|
parse_json_from_llm_text,
|
|
shuffle_options_in_questions,
|
|
validate_and_normalize_draft,
|
|
)
|
|
from .llm_client import LlmError, chat_completion_text_content, get_llm_config
|
|
|
|
|
|
class HttpError(Exception):
|
|
def __init__(self, status: int, message: str):
|
|
super().__init__(message)
|
|
self.status = status
|
|
self.message = message
|
|
|
|
|
|
def parse_and_validate_shape(s: Any) -> list[dict]:
|
|
if not isinstance(s, list) or not s:
|
|
raise HttpError(400, 'Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].')
|
|
if len(s) > 40:
|
|
raise HttpError(400, 'Не более 40 вопросов за раз.')
|
|
out = []
|
|
for i, row in enumerate(s):
|
|
if not isinstance(row, dict):
|
|
raise HttpError(400, f'shape[{i}]: ожидается объект.')
|
|
try:
|
|
n = int(float(row.get('optionsCount')))
|
|
except (TypeError, ValueError):
|
|
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
|
if n < 2 or n > 12:
|
|
raise HttpError(400, f'shape[{i}]: optionsCount от 2 до 12.')
|
|
has_multi = bool(row.get('hasMultipleAnswers'))
|
|
raw_min = row.get('minCorrect', 1)
|
|
raw_max = row.get('maxCorrect', n if has_multi else 1)
|
|
try:
|
|
min_c = int(float(raw_min))
|
|
max_c = int(float(raw_max))
|
|
except (TypeError, ValueError):
|
|
raise HttpError(400, f'shape[{i}]: minCorrect/maxCorrect должны быть числами.')
|
|
if not has_multi:
|
|
min_c = 1
|
|
max_c = 1
|
|
if min_c < 1 or max_c < 1 or min_c > max_c or max_c > n:
|
|
raise HttpError(
|
|
400,
|
|
f'shape[{i}]: корректный диапазон правильных ответов — от 1 до {n}, min<=max.',
|
|
)
|
|
out.append(
|
|
{
|
|
'optionsCount': n,
|
|
'hasMultipleAnswers': has_multi,
|
|
'minCorrect': min_c,
|
|
'maxCorrect': max_c,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _require_cfg():
|
|
cfg = get_llm_config()
|
|
if cfg is None:
|
|
raise HttpError(503, 'Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.')
|
|
return cfg
|
|
|
|
|
|
def generate_full_test_by_shape(test_title: str, test_description: str, shape: list[dict]) -> dict:
|
|
cfg = _require_cfg()
|
|
title = (test_title or '').strip() or 'Тест'
|
|
desc = (test_description or '').strip()
|
|
lines = []
|
|
for i, sh in enumerate(shape):
|
|
if sh['hasMultipleAnswers']:
|
|
tail = (
|
|
'несколько вариантов помечены как верные (hasMultipleAnswers: true), '
|
|
f'число правильных от {sh["minCorrect"]} до {sh["maxCorrect"]}.'
|
|
)
|
|
else:
|
|
tail = 'ровно один верный вариант (hasMultipleAnswers: false).'
|
|
lines.append(f'Вопрос {i + 1}: ровно {sh["optionsCount"]} вариантов ответа; {tail}')
|
|
|
|
system = (
|
|
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. '
|
|
'Схема: {"title": string, "description": string (может быть пустой строкой), '
|
|
'"questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", '
|
|
'"options": [{ "text", "isCorrect" }]}.'
|
|
)
|
|
user = (
|
|
'Составь тест по теме.\n\n'
|
|
f'Название (можно уточнить, но смысл сохранить): {title}\n'
|
|
f'Краткое описание / контекст темы: '
|
|
f'{desc or "не указано; придумай согласованную тему с названием."}\n\n'
|
|
f'Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):\n'
|
|
+ '\n'.join(lines)
|
|
+ '\n\nПравила: варианты — осмысленные, по теме; отметь isCorrect согласно '
|
|
'hasMultipleAnswers; для одного правильного — ровна одна true.'
|
|
)
|
|
|
|
raw = chat_completion_text_content(cfg, system, user, 0.35)
|
|
parsed = parse_json_from_llm_text(raw)
|
|
draft = validate_and_normalize_draft(parsed)
|
|
assert_draft_matches_shape({'questions': draft['questions']}, shape)
|
|
shuffle_options_in_questions(draft['questions'])
|
|
return {
|
|
'title': draft['title'],
|
|
'description': draft['description'],
|
|
'questions': draft['questions'],
|
|
}
|
|
|
|
|
|
# ─── E1.8: AI v2 ──────────────────────────────────────────────────────
|
|
|
|
|
|
def generate_test_by_title(
|
|
test_title: str,
|
|
test_description: str = '',
|
|
questions_count: int = 10,
|
|
options_count: int = 4,
|
|
has_multiple_answers: bool = False,
|
|
) -> dict:
|
|
"""Генерация теста ТОЛЬКО по названию: AI сам предлагает вопросы.
|
|
|
|
Сетка не задаётся жёстко: пользователю даётся подсказка о желаемом числе
|
|
вопросов и вариантов, но мы валидируем мягко (не assert_draft_matches_shape).
|
|
"""
|
|
cfg = _require_cfg()
|
|
title = (test_title or '').strip()
|
|
if not title:
|
|
raise HttpError(400, 'Укажите название теста.')
|
|
desc = (test_description or '').strip()
|
|
n_q = max(3, min(40, int(questions_count or 10)))
|
|
n_opt = max(2, min(12, int(options_count or 4)))
|
|
|
|
system = (
|
|
'Ты опытный методист, составляешь учебные тесты. Отвечай ТОЛЬКО одним '
|
|
'JSON-объектом на русском. Схема: {"title", "description", "questions": ['
|
|
'{"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect"}]}'
|
|
']}. Минимум 2 варианта, отметь хотя бы один isCorrect: true.'
|
|
)
|
|
user = (
|
|
'Составь учебный тест по этой теме.\n\n'
|
|
f'Название теста: {title}\n'
|
|
f'Описание/контекст: {desc or "не указано — определи по названию."}\n\n'
|
|
f'Подсказка по сетке: примерно {n_q} вопросов, в каждом по {n_opt} вариантов '
|
|
f'ответа; '
|
|
f'тип ответа — {"несколько правильных" if has_multiple_answers else "один правильный"} '
|
|
f'(но если по смыслу нужно отступить — отступи). '
|
|
'Покрой ключевые подтемы. Дистракторы делай правдоподобными, не очевидно '
|
|
'неверными. Текст — короткий, понятный.'
|
|
)
|
|
raw = chat_completion_text_content(cfg, system, user, 0.45)
|
|
parsed = parse_json_from_llm_text(raw)
|
|
draft = validate_and_normalize_draft(parsed)
|
|
shuffle_options_in_questions(draft['questions'])
|
|
return {
|
|
'title': draft['title'],
|
|
'description': draft['description'],
|
|
'questions': draft['questions'],
|
|
}
|
|
|
|
|
|
def check_test_quality(test_title: str, test_description: str, questions: list[dict]) -> dict:
|
|
"""AI-рецензия теста: общий вердикт + список рекомендаций по разделам."""
|
|
cfg = _require_cfg()
|
|
title = (test_title or '').strip() or 'Тест'
|
|
desc = (test_description or '').strip()
|
|
qs = questions or []
|
|
if not qs:
|
|
raise HttpError(400, 'В тесте нет вопросов — нечего проверять.')
|
|
|
|
system = (
|
|
'Ты ревьюер учебных тестов. Отвечай ТОЛЬКО JSON: {"verdict": "ok"|"warn"|"bad", '
|
|
'"summary": string (1-2 предложения), '
|
|
'"sections": [{"title": string, "items": [string, ...]}]}. '
|
|
'Разделы рекомендаций: «Чёткость формулировок», «Качество дистракторов», '
|
|
'"Охват темы», «Сбалансированность сложности». Пропусти раздел, если '
|
|
'претензий нет. Вердикт: ok — годен; warn — есть замечания; bad — серьёзные '
|
|
'проблемы. Все тексты — на русском, короткие и предметные.'
|
|
)
|
|
test_dump = {
|
|
'title': title,
|
|
'description': desc,
|
|
'questions': [
|
|
{
|
|
'text': q.get('text', ''),
|
|
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
|
'options': [
|
|
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))}
|
|
for o in (q.get('options') or [])
|
|
],
|
|
}
|
|
for q in qs
|
|
],
|
|
}
|
|
import json as _json
|
|
|
|
user = 'Проверь качество теста и дай рекомендации:\n\n' + _json.dumps(
|
|
test_dump, ensure_ascii=False
|
|
)
|
|
raw = chat_completion_text_content(cfg, system, user, 0.25)
|
|
parsed = parse_json_from_llm_text(raw)
|
|
if not isinstance(parsed, dict):
|
|
raise LlmError('Неверный формат ответа модели.', code='llm_shape')
|
|
verdict = str(parsed.get('verdict') or '').strip().lower()
|
|
if verdict not in ('ok', 'warn', 'bad'):
|
|
verdict = 'warn'
|
|
summary = str(parsed.get('summary') or '').strip()
|
|
raw_sections = parsed.get('sections') or []
|
|
sections: list[dict] = []
|
|
if isinstance(raw_sections, list):
|
|
for s in raw_sections:
|
|
if not isinstance(s, dict):
|
|
continue
|
|
t = str(s.get('title') or '').strip()
|
|
items = s.get('items') or []
|
|
if not t or not isinstance(items, list) or not items:
|
|
continue
|
|
clean_items = [str(x).strip() for x in items if str(x).strip()]
|
|
if clean_items:
|
|
sections.append({'title': t, 'items': clean_items})
|
|
return {'verdict': verdict, 'summary': summary, 'sections': sections}
|
|
|
|
|
|
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-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса.
|
|
|
|
focus: all | questions | distractors | options
|
|
"""
|
|
cfg = _require_cfg()
|
|
title = (test_title or '').strip() or 'Тест'
|
|
desc = (test_description or '').strip()
|
|
qs = questions or []
|
|
if not qs:
|
|
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.')
|
|
|
|
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,
|
|
'questions': [
|
|
{
|
|
'text': q.get('text', ''),
|
|
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
|
'options': [
|
|
{'text': o.get('text', ''), 'isCorrect': bool(o.get('isCorrect'))}
|
|
for o in (q.get('options') or [])
|
|
],
|
|
}
|
|
for q in qs
|
|
],
|
|
}
|
|
import json as _json
|
|
|
|
user = 'Улучши тест без изменения сетки:\n\n' + _json.dumps(
|
|
test_dump, ensure_ascii=False
|
|
)
|
|
raw = chat_completion_text_content(cfg, system, user, 0.3)
|
|
parsed = parse_json_from_llm_text(raw)
|
|
|
|
shape = [
|
|
{
|
|
'optionsCount': len(q.get('options') or []),
|
|
'hasMultipleAnswers': bool(q.get('hasMultipleAnswers')),
|
|
}
|
|
for q in qs
|
|
]
|
|
assert_draft_matches_shape(parsed, shape)
|
|
draft = validate_and_normalize_draft(
|
|
{'title': title, 'questions': parsed.get('questions') or []}
|
|
)
|
|
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)):
|
|
orig_opts = [
|
|
{'text': str(o.get('text', '')).strip(), 'isCorrect': bool(o.get('isCorrect'))}
|
|
for o in (orig.get('options') or [])
|
|
]
|
|
sug_opts = sug['options']
|
|
text_changed = (str(orig.get('text', '')).strip() != sug['text'])
|
|
options_changed = (
|
|
len(orig_opts) != len(sug_opts)
|
|
or any(
|
|
a['text'] != b['text'] or a['isCorrect'] != b['isCorrect']
|
|
for a, b in zip(orig_opts, sug_opts)
|
|
)
|
|
)
|
|
items.append(
|
|
{
|
|
'index': i,
|
|
'original': {
|
|
'text': str(orig.get('text', '')).strip(),
|
|
'hasMultipleAnswers': bool(orig.get('hasMultipleAnswers')),
|
|
'options': orig_opts,
|
|
},
|
|
'suggested': {
|
|
'text': sug['text'],
|
|
'hasMultipleAnswers': sug['hasMultipleAnswers'],
|
|
'options': sug_opts,
|
|
},
|
|
'textChanged': text_changed,
|
|
'optionsChanged': options_changed,
|
|
'changed': text_changed or options_changed,
|
|
}
|
|
)
|
|
|
|
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(
|
|
*,
|
|
question_text: str,
|
|
options: list[dict],
|
|
) -> str:
|
|
"""Универсальная подсказка к вопросу: 2–4 предложения, объясняет правильный ответ."""
|
|
cfg = get_llm_config()
|
|
if cfg is None:
|
|
return ''
|
|
correct_list = '; '.join(o['text'] for o in options if o.get('isCorrect'))
|
|
all_list = '; '.join(o['text'] for o in options)
|
|
system = (
|
|
'Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения), '
|
|
'без markdown и без вступлений. Объясни почему правильный вариант — правильный.'
|
|
)
|
|
user = (
|
|
f'Вопрос: {question_text}\n'
|
|
f'Варианты: {all_list}\n'
|
|
f'Правильный ответ: {correct_list or "—"}\n\n'
|
|
'Дай краткое объяснение для подсказки во всплывающем окне.'
|
|
)
|
|
try:
|
|
raw = chat_completion_text_content(cfg, system, user, 0.3, as_json=False)
|
|
return (raw or '').strip()
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning('generate_question_hint failed: %s', e)
|
|
return ''
|
|
|
|
|
|
def explain_answer(
|
|
*,
|
|
question_text: str,
|
|
options: list[dict],
|
|
selected_texts: list[str],
|
|
is_correct: bool,
|
|
) -> str:
|
|
"""Генерирует короткое объяснение результата ответа на вопрос (для попапа подсказки)."""
|
|
cfg = get_llm_config()
|
|
if cfg is None:
|
|
return ''
|
|
correct_list = '; '.join(o['text'] for o in options if o.get('isCorrect'))
|
|
sel_list = '; '.join(selected_texts) if selected_texts else '(ничего не выбрано)'
|
|
verdict = 'верно' if is_correct else 'неверно'
|
|
system = (
|
|
'Ты опытный преподаватель. Отвечай по-русски, кратко (2–4 предложения). '
|
|
'Объясни почему правильный ответ именно такой, без лишней воды и без markdown.'
|
|
)
|
|
user = (
|
|
f'Вопрос: {question_text}\n'
|
|
f'Правильный ответ: {correct_list or "—"}\n'
|
|
f'Ответ ученика ({verdict}): {sel_list}\n\n'
|
|
'Дай краткое объяснение для подсказки во всплывающем окне.'
|
|
)
|
|
try:
|
|
raw = chat_completion_text_content(cfg, system, user, 0.3, as_json=False)
|
|
return (raw or '').strip()
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning('explain_answer failed: %s', e)
|
|
return ''
|
|
|
|
|
|
def generate_or_rephrase_question(
|
|
test_title: str,
|
|
test_description: str,
|
|
question_text: str,
|
|
options_count: Any,
|
|
has_multiple_answers: bool,
|
|
mode: str | None = None,
|
|
existing_options: list[dict] | None = None,
|
|
) -> dict:
|
|
import json as _json
|
|
|
|
cfg = _require_cfg()
|
|
try:
|
|
n = int(float(options_count))
|
|
except (TypeError, ValueError):
|
|
raise HttpError(400, 'optionsCount: от 2 до 12.')
|
|
if n < 2 or n > 12:
|
|
raise HttpError(400, 'optionsCount: от 2 до 12.')
|
|
|
|
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:
|
|
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 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 полных предложения в зависимости '
|
|
'от сложности исходного черновика, без вариантов ответа.'
|
|
)
|
|
user = (
|
|
f'Тема теста: {topic}\n\n'
|
|
f'Исходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n{qt}'
|
|
)
|
|
raw = chat_completion_text_content(cfg, system, user, 0.3)
|
|
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 {'mode': 'rephrase', 'text': text}
|
|
|
|
if qt:
|
|
raise HttpError(
|
|
400,
|
|
'Укажите режим: distractors, improve_options или rephrase — или очистите текст вопроса для полной генерации.',
|
|
)
|
|
|
|
# ── Полная генерация: вопрос пуст ────────────────────────────────────────
|
|
system = (
|
|
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", '
|
|
'"options": [{ "text", "isCorrect" }]}. Все на русском.'
|
|
)
|
|
multi_clause = (
|
|
'true (несколько верных, минимум 2 isCorrect: true, остальные false).'
|
|
if has_multiple_answers
|
|
else 'false (ровно один isCorrect: true).'
|
|
)
|
|
user = (
|
|
f'Тема теста: {topic}\n\n'
|
|
f'Сформулируй ОДИН вопрос по этой теме с ровно {n} вариантами ответа. '
|
|
f'hasMultipleAnswers = {multi_clause}'
|
|
)
|
|
raw = chat_completion_text_content(cfg, system, user, 0.35)
|
|
parsed = parse_json_from_llm_text(raw)
|
|
shape = [{'optionsCount': n, 'hasMultipleAnswers': bool(has_multiple_answers)}]
|
|
assert_draft_matches_shape({'questions': [parsed]}, shape)
|
|
draft = validate_and_normalize_draft({'title': 'временно', 'questions': [parsed]})
|
|
q0 = draft['questions'][0]
|
|
shuffle_options_in_questions([q0])
|
|
return {
|
|
'mode': 'full',
|
|
'text': q0['text'],
|
|
'hasMultipleAnswers': q0['hasMultipleAnswers'],
|
|
'options': q0['options'],
|
|
}
|