You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

483 lines
22 KiB

"""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,
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)
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)
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 improve_test_full(test_title: str, test_description: str, questions: list[dict]) -> dict:
"""AI-улучшение всего теста. Возвращает 'было → стало' для каждого вопроса.
Для каждого вопроса: original + suggested, флаги textChanged/optionsChanged.
UI решает, что применить (чекбоксы).
"""
cfg = _require_cfg()
title = (test_title or '').strip() or 'Тест'
desc = (test_description or '').strip()
qs = questions or []
if not qs:
raise HttpError(400, 'В тесте нет вопросов — нечего улучшать.')
system = (
'Ты редактор учебных тестов. Получаешь массив вопросов и предлагаешь '
'улучшения: чёткие формулировки, лучшие дистракторы, корректную разметку '
'isCorrect. Сохраняй исходную сетку: число вопросов, число вариантов и '
'значение hasMultipleAnswers НЕ меняй — иначе клиент отклонит ответ. '
'Отвечай ТОЛЬКО JSON: {"questions": [{"text", "hasMultipleAnswers", '
'"options": [{"text", "isCorrect"}]}, ...]}. Тексты — на русском, короткие.'
)
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 []}
)
suggested_qs = draft['questions']
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}
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()
# ── Режим дистракторов: есть вопрос + часть вариантов пуста ─────────────
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}
# ── Режим улучшения: вопрос есть → только переформулировать текст ────────
if qt:
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}
# ── Полная генерация: вопрос пуст ────────────────────────────────────────
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]})
return {
'mode': 'full',
'text': draft['questions'][0]['text'],
'hasMultipleAnswers': draft['questions'][0]['hasMultipleAnswers'],
'options': draft['questions'][0]['options'],
}