458 lines
21 KiB
Python
458 lines
21 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,
|
|
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.')
|
|
out.append({'optionsCount': n, 'hasMultipleAnswers': bool(row.get('hasMultipleAnswers'))})
|
|
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).'
|
|
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'],
|
|
}
|