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
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'], |
|
}
|
|
|