"""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 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]}) q0 = draft['questions'][0] shuffle_options_in_questions([q0]) return { 'mode': 'full', 'text': q0['text'], 'hasMultipleAnswers': q0['hasMultipleAnswers'], 'options': q0['options'], }