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