diff --git a/.gitignore b/.gitignore index 1ed6022..d15ce74 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ ssl __pycache__ /VERSION.json .env -/whisper_asr_model_cache \ No newline at end of file +/whisper_asr_model_cache +/app/questions_generator/static/vkr_examples/ +/app/questions_generator/rut5-base/ diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile new file mode 100644 index 0000000..8a21aeb --- /dev/null +++ b/app/questions_generator/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.10-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git wget gcc g++ \ + libprotobuf-dev protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_DEFAULT_TIMEOUT=120 + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + "huggingface_hub[cli]" + +COPY . . + +COPY --chmod=755 init-volumes.sh /usr/local/bin/init-volumes.sh + +CMD ["bash"] diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md new file mode 100644 index 0000000..ad5d787 --- /dev/null +++ b/app/questions_generator/README.md @@ -0,0 +1,84 @@ +## Запуск (контейнер вечно крутится) +`docker-compose up` - ВАЖНО: Первый раз ОЧЕНЬ ДОЛГО билдится (30-40 минут) + +## Использование (интерактивное) +`docker compose exec app python run.py /app/static/vkr_examples/VKR1.docx --no-overflow-logs` - папка `vkr_examples` локальная + +## Пример сгенерированных вопросов по тексту ВКР + +[✔ OK] Как цель и задачи, сформулированные во введении, отражены в итоговых выводах заключения? + - relevance: True + - clarity: True + - difficulty:False + +[✔ OK] Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи? + - relevance: True + - clarity: True + - difficulty:False + +[✖ FAIL] В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы? + - relevance: False + - clarity: True + - difficulty:False + +[✔ OK] Как практическая значимость работы следует из задач и результатов исследования? + - relevance: True + - clarity: True + - difficulty:False + +[✔ OK] Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели? + - relevance: True + - clarity: True + - difficulty:False + +--- rut5-base-multitask вопросы --- + +[✖ FAIL] Что такое ЛЭТИ? + - relevance: False + - clarity: False + - difficulty:False + +[✖ FAIL] Что является целью работы в веб-приложении? + - relevance: True + - clarity: False + - difficulty:False + +[✖ FAIL] Что было проведено в конце работы? + - relevance: False + - clarity: False + - difficulty:False + +[✔ OK] Что могут изменять объекты, располагаемые на карте? + - relevance: True + - clarity: True + - difficulty:False + +[✔ OK] Что представляет собой создание набора программных средств для отображения объектов на карте? + - relevance: True + - clarity: True + - difficulty:False + +[✖ FAIL] Сформировать требования к набору программных средств? + - relevance: True + - clarity: False + - difficulty:False + +[✖ FAIL] Что является объектом исследования? + - relevance: True + - clarity: False + - difficulty:False + +[✖ FAIL] Что существует уже давно? + - relevance: True + - clarity: False + - difficulty:False + +[✔ OK] Что можно дать в контексте набора программных средств? + - relevance: True + - clarity: True + - difficulty:False + +[✖ FAIL] ГИС является интегрированной информационной системой? + - relevance: True + - clarity: False + - difficulty:False diff --git a/app/questions_generator/docker-compose.yml b/app/questions_generator/docker-compose.yml new file mode 100644 index 0000000..3705ce4 --- /dev/null +++ b/app/questions_generator/docker-compose.yml @@ -0,0 +1,25 @@ +services: + init: + build: . + entrypoint: ["/usr/local/bin/init-volumes.sh"] + volumes: + - rut5_model:/app/question_generator/rut5-base + - nltk_data:/nltk_data + restart: "no" + + app: + build: . + depends_on: + init: + condition: service_completed_successfully + stdin_open: true + tty: true + volumes: + - rut5_model:/app/question_generator/rut5-base + - nltk_data:/nltk_data + - ./static/vkr_examples:/app/static/vkr_examples + command: ["bash", "-lc", "sleep infinity"] + +volumes: + rut5_model: + nltk_data: diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py new file mode 100644 index 0000000..3c9fb7a --- /dev/null +++ b/app/questions_generator/generator.py @@ -0,0 +1,191 @@ +import re +import logging +import csv +from pathlib import Path +from typing import List, Dict + +from nltk.tokenize import sent_tokenize, word_tokenize +from nltk.corpus import stopwords +from transformers import AutoTokenizer, AutoModelForSeq2SeqLM + +from logging_utils import log_timed + + +class VkrQuestionGenerator: + """Гибридный генератор вопросов по ВКР: NLTK + rut5-base-multitask.""" + + def __init__( + self, + vkr_text: str, + model_path: str = "/app/question_generator/rut5-base", + heuristic_csv_path: str = "static/heuristic_questions.csv", + ): + self.logger = logging.getLogger(__name__) + + with log_timed(self.logger, "инициализация генератора"): + self.vkr_text = vkr_text + + with log_timed(self.logger, "токенизация предложений"): + self.sentences = sent_tokenize(vkr_text) + + with log_timed(self.logger, "загрузка стоп-слов"): + self.stopwords = set(stopwords.words("russian")) + + with log_timed(self.logger, "загрузка токенизатора", путь=model_path): + self.tokenizer = AutoTokenizer.from_pretrained( + model_path, use_fast=False + ) + + with log_timed(self.logger, "загрузка модели", путь=model_path): + self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) + + with log_timed( + self.logger, + "загрузка эвристических вопросов", + путь=heuristic_csv_path, + ): + self.heuristic_templates: List[Dict[str, str]] = [] + with Path(heuristic_csv_path).open(encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="|") + self.heuristic_templates.extend(reader) + + self.logger.info( + "Генератор готов: предложений=%d стоп-слов=%d модель=%s", + len(self.sentences), + len(self.stopwords), + model_path, + ) + + def extract_section(self, title: str) -> str: + pattern = rf""" + (?im) + ^\s*(\d+(\.\d+)*\.?\s*)?{re.escape(title)}\s*$ + (.*?) + (?=^\s*(\d+(\.\d+)*\.?\s*[А-ЯA-Z]|$\Z)) + """ + match = re.search(pattern, self.vkr_text, re.DOTALL | re.VERBOSE) + return match.group(0) if match else "" + + def extract_keywords(self, text: str) -> List[str]: + with log_timed(self.logger, "извлечение ключевых слов", длина=len(text)): + tokens = word_tokenize(text.lower()) + result = [ + t for t in tokens + if t.isalnum() and t not in self.stopwords and len(t) > 4 + ] + + self.logger.info("Ключевые слова извлечены: %d", len(result)) + return result + + def llm_generate_question(self, text_fragment: str) -> str: + prompt = f"ask: {text_fragment}" + + with log_timed( + self.logger, + "генерация вопроса LLM", + длина_фрагмента=len(text_fragment), + ): + enc = self.tokenizer(prompt, return_tensors="pt", truncation=True) + out = self.model.generate( + **enc, + max_length=64, + num_beams=5, + early_stopping=True, + ) + decoded = self.tokenizer.decode(out[0], skip_special_tokens=True) + + return decoded + + def heuristic_questions(self) -> List[str]: + with log_timed(self.logger, "эвристическая генерация вопросов"): + questions: List[str] = [] + + for item in self.heuristic_templates: + sections = item["section"] + question = item["question"] + + if not sections: + questions.append(question) + continue + + if all(self.extract_section(x) for x in sections.split(",")): + questions.append(question) + + self.logger.info( + "Эвристические вопросы сформированы: %d", + len(questions), + ) + return questions + + def generate_llm_questions(self, count: int = 5) -> List[str]: + questions: List[str] = [] + fragments = self.sentences[:40] + step = max(1, len(fragments) // count) + + self.logger.info( + "Настройка LLM: требуется=%d фрагментов=%d шаг=%d", + count, + len(fragments), + step, + ) + + with log_timed(self.logger, "LLM генерация всех вопросов", количество=count): + for i in range(0, len(fragments), step): + fragment = fragments[i] + try: + with log_timed( + self.logger, + "LLM вопрос", + индекс=i, + ): + llm_q = self.llm_generate_question(fragment) + + if len(llm_q) > 10: + questions.append(llm_q) + self.logger.info( + "LLM вопрос принят: номер=%d длина=%d", + len(questions), + len(llm_q), + ) + else: + self.logger.info( + "LLM вопрос отклонён (слишком короткий): длина=%d", + len(llm_q), + ) + + except Exception as exc: # noqa: BLE001 + self.logger.exception( + "Ошибка генерации LLM вопроса: индекс=%d ошибка=%s", + i, + exc, + ) + + if len(questions) >= count: + break + + self.logger.info( + "LLM вопросы сформированы: %d", + len(questions), + ) + return questions + + def generate_all(self) -> List[str]: + with log_timed(self.logger, "полная генерация вопросов"): + result: List[str] = [] + + with log_timed(self.logger, "эвристический блок"): + result.extend(self.heuristic_questions()) + + result.append("--- rut5-base-multitask вопросы ---") + + with log_timed(self.logger, "LLM блок"): + result.extend(self.generate_llm_questions(count=10)) + + deduped = list(dict.fromkeys(result)) + + self.logger.info( + "Генерация завершена: всего=%d уникальных=%d", + len(result), + len(deduped), + ) + return deduped diff --git a/app/questions_generator/init-volumes.sh b/app/questions_generator/init-volumes.sh new file mode 100644 index 0000000..cd348fd --- /dev/null +++ b/app/questions_generator/init-volumes.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e + +MODEL_DIR="/app/question_generator/rut5-base" + +echo "MODEL_DIR=${MODEL_DIR}" + +mkdir -p "$MODEL_DIR" + +if [ -z "$(ls -A "$MODEL_DIR" 2>/dev/null)" ]; then + echo "Не видно модельки rut5-base, грузим в папку $MODEL_DIR..." + huggingface-cli download \ + cointegrated/rut5-base-multitask \ + --local-dir "$MODEL_DIR" \ + --local-dir-use-symlinks False + echo "Загрузили" +else + echo "В директории модельки что-то есть, не будем ещё раз загружать" +fi diff --git a/app/questions_generator/logging_utils.py b/app/questions_generator/logging_utils.py new file mode 100644 index 0000000..0c5d1ec --- /dev/null +++ b/app/questions_generator/logging_utils.py @@ -0,0 +1,82 @@ +import logging +import os +import sys +import time +from contextlib import contextmanager +from typing import Optional + +DEFAULT_LOG_PATH = os.environ.get( + "VKR_LOG_PATH", + "logs/vkr_question_generator.log" +) + + +def setup_logging(log_path: Optional[str] = None) -> None: + path = log_path or DEFAULT_LOG_PATH + os.makedirs(os.path.dirname(path), exist_ok=True) + + root = logging.getLogger() + root.setLevel(logging.INFO) + + if root.handlers: + return + + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + file_handler = logging.FileHandler(path, encoding="utf-8") + file_handler.setFormatter(formatter) + file_handler.setLevel(logging.INFO) + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + console_handler.setLevel(logging.INFO) + + root.addHandler(file_handler) + root.addHandler(console_handler) + + +@contextmanager +def log_timed( + logger: logging.Logger, + operation: str, + level: int = logging.INFO, + **extra, +): + start = time.perf_counter() + logger.log( + level, + "Начало операции: %s %s", + operation, + extra if extra else "", + ) + try: + yield + finally: + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.log( + level, + "Завершение операции: %s | %.2f мс %s", + operation, + elapsed_ms, + extra if extra else "", + ) + + +@contextmanager +def suppress_console_logs(): + root = logging.getLogger() + saved = [] + + for h in root.handlers: + if isinstance(h, logging.StreamHandler): + saved.append((h, h.level)) + h.setLevel(logging.CRITICAL + 1) + + try: + yield + finally: + for h, lvl in saved: + h.setLevel(lvl) diff --git a/app/questions_generator/requirements.txt b/app/questions_generator/requirements.txt new file mode 100644 index 0000000..0c59711 --- /dev/null +++ b/app/questions_generator/requirements.txt @@ -0,0 +1,6 @@ +transformers==4.57.3 +sentencepiece==0.2.1 +nltk==3.9.2 +huggingface_hub==0.36.0 +python-docx==1.2.0 +torch==2.5.1 diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py new file mode 100644 index 0000000..67e047c --- /dev/null +++ b/app/questions_generator/run.py @@ -0,0 +1,101 @@ +import argparse +import logging +import os +import sys +from contextlib import nullcontext + +import nltk +from docx import Document + +from generator import VkrQuestionGenerator +from validator import VkrQuestionValidator +from logging_utils import ( + setup_logging, + log_timed, + suppress_console_logs, +) + + +def load_vkr_text(path: str) -> str: + logger = logging.getLogger(__name__) + + if not os.path.exists(path): + logger.error("Файл не найден: %s", path) + sys.exit(1) + + with log_timed(logger, "чтение DOCX", путь=path): + doc = Document(path) + text = "\n".join(p.text for p in doc.paragraphs) + + logger.info( + "DOCX обработан: символов=%d абзацев=%d", + len(text), + len(doc.paragraphs), + ) + return text + + +def main(): + setup_logging() + logger = logging.getLogger(__name__) + + parser = argparse.ArgumentParser() + parser.add_argument("vkr_path") + parser.add_argument("--no-overflow-logs", action="store_true") + args = parser.parse_args() + + logger.info("Запуск генерации: файл=%s", args.vkr_path) + + with log_timed(logger, "проверка NLTK"): + try: + nltk.data.find("tokenizers/punkt_tab/english") + except LookupError: + logger.info("Загрузка данных NLTK") + nltk.download("punkt_tab") + nltk.download("stopwords") + + text = load_vkr_text(args.vkr_path) + + with log_timed(logger, "инициализация генератора"): + gen = VkrQuestionGenerator(text) + + with log_timed(logger, "инициализация валидатора"): + validator = VkrQuestionValidator(text) + + with log_timed(logger, "генерация вопросов"): + questions = gen.generate_all() + + logger.info("Сгенерировано вопросов: %d", len(questions)) + + quiet = suppress_console_logs() if args.no_overflow_logs else nullcontext() + + with quiet: + for idx, q in enumerate(questions, 1): + if q.startswith("---"): + print(f"\n{q}") + continue + + rel = validator.check_relevance(q) + clr = validator.check_clarity(q) + diff = validator.check_difficulty(q) + + passed = (int(rel) + int(clr) + int(diff) >= 2) + status = "✔ OK" if passed else "✖ FAIL" + + logger.info( + "Вопрос %d статус=%s релевантность=%s ясность=%s сложность=%s", + idx, + "OK" if passed else "FAIL", + rel, + clr, + diff, + ) + + print(f"\n[{status}] {q}") + print(f" - релевантность: {rel}") + print(f" - ясность: {clr}") + print(f" - сложность:{diff}") + + +if __name__ == "__main__": + main() diff --git a/app/questions_generator/run_docker.py b/app/questions_generator/run_docker.py new file mode 100644 index 0000000..ca79ab5 --- /dev/null +++ b/app/questions_generator/run_docker.py @@ -0,0 +1,48 @@ +import argparse +import logging +import os +import subprocess +import sys + +from logging_utils import setup_logging + + +def main(): + setup_logging() + logger = logging.getLogger(__name__) + + parser = argparse.ArgumentParser() + parser.add_argument("vkr_path") + args = parser.parse_args() + + host_path = os.path.abspath(args.vkr_path) + + if not os.path.exists(host_path): + logger.error("Файл не найден: %s", host_path) + sys.exit(1) + + container_path = "/app/questions_generator/static/vkr_examples/vkr.docx" + + cmd = [ + "docker", "run", "-it", "--rm", + "-v", "rut5-model:/app/question_generator/rut5-base", + "-v", "rut5-nltk:/nltk_data", + "-v", f"{host_path}:{container_path}:ro", + "vkr-generator", + "python", "run.py", container_path, + ] + + logger.info("Запуск Docker команды: %s", " ".join(cmd)) + + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as exc: + logger.exception( + "Docker завершился с ошибкой, код=%d", + exc.returncode, + ) + sys.exit(exc.returncode) + + +if __name__ == "__main__": + main() diff --git a/app/questions_generator/static/heuristic_questions.csv b/app/questions_generator/static/heuristic_questions.csv new file mode 100644 index 0000000..ad180a0 --- /dev/null +++ b/app/questions_generator/static/heuristic_questions.csv @@ -0,0 +1,8 @@ +section|question +Введение,Заключение|Как цель и задачи, сформулированные во введении, отражены в итоговых выводах заключения? +Обзор предметной области|Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи? +Постановка задачи|В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы? +Метод решения|Как архитектура и алгоритмы, описанные в разделе «Метод решения», обеспечивают достижение поставленных требований? +Исследования|Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения? +|Как практическая значимость работы следует из задач и результатов исследования? +|Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели? diff --git a/app/questions_generator/validator.py b/app/questions_generator/validator.py new file mode 100644 index 0000000..1f60359 --- /dev/null +++ b/app/questions_generator/validator.py @@ -0,0 +1,291 @@ +import re +import logging +from datetime import datetime +from collections import Counter +from typing import List, Dict, Set + +from nltk.tokenize import word_tokenize +from nltk.corpus import stopwords + +from logging_utils import log_timed + + +class VkrQuestionValidator: + def __init__(self, vkr_text: str): + self.logger = logging.getLogger(__name__) + + with log_timed(self.logger, "инициализация валидатора"): + self.vkr_text = vkr_text.lower() + + with log_timed(self.logger, "загрузка стоп-слов"): + self.stopwords = set(stopwords.words("russian")) + + with log_timed(self.logger, "извлечение ключевых слов"): + self.keywords = self._extract_keywords() + + self.logger.info( + "Валидатор готов: стоп-слов=%d тема=%d цели=%d методология=%d", + len(self.stopwords), + len(self.keywords["theme"]), + len(self.keywords["goals"]), + len(self.keywords["methodology"]), + ) + + def _extract_keywords(self) -> Dict[str, Set[str]]: + keywords = { + "theme": set(), + "goals": set(), + "methodology": set(), + } + + with log_timed(self.logger, "извлечение введения"): + intro = self._extract_introduction() + with log_timed(self.logger, "токенизация введения"): + keywords["theme"] = self._tokenize_and_filter(intro) + + with log_timed(self.logger, "извлечение целей"): + goals = self._extract_goals_section() + with log_timed(self.logger, "токенизация целей"): + keywords["goals"] = self._tokenize_and_filter(goals) + + with log_timed(self.logger, "извлечение методологии"): + meth = self._extract_methodology_section() + with log_timed(self.logger, "токенизация методологии"): + keywords["methodology"] = self._tokenize_and_filter(meth) + + self.logger.info( + "Ключевые слова извлечены: тема=%d цели=%d методология=%d", + len(keywords["theme"]), + len(keywords["goals"]), + len(keywords["methodology"]), + ) + return keywords + + def _tokenize_and_filter(self, text: str) -> Set[str]: + """ + Токенизация и фильтрация текста для получения ключевых слов + """ + tokens = word_tokenize(text.lower()) + filtered_tokens = [ + token for token in tokens + if token.isalnum() and + token not in self.stopwords and + len(token) > 3 + ] + return set(filtered_tokens) + + def _extract_introduction(self) -> str: + intro_pattern = r'введение.*?(?=глава|раздел)' + match = re.search(intro_pattern, self.vkr_text, re.DOTALL) + return match.group(0) if match else "" + + def _extract_goals_section(self) -> str: + goals_pattern = r'(цель|задачи).*?(?=глава|раздел)' + match = re.search(goals_pattern, self.vkr_text, re.DOTALL) + return match.group(0) if match else "" + + def _extract_methodology_section(self) -> str: + meth_pattern = r'(методология|методы).*?(?=глава|раздел)' + match = re.search(meth_pattern, self.vkr_text, re.DOTALL) + return match.group(0) if match else "" + + def check_relevance(self, question: str) -> bool: + with log_timed(self.logger, "проверка релевантности", длина=len(question)): + score = 0 + score += bool(set(question.lower().split()) & self.keywords["theme"]) + score += self._calculate_actuality_score(question) + score += bool(set(question.lower().split()) & self.keywords["goals"]) + result = score >= 2 + + self.logger.info( + "Релевантность=%s балл=%d вопрос=%r", + result, + score, + question, + ) + return result + + def _calculate_actuality_score(self, question: str) -> int: + current_year = datetime.now().year + year_mentions = [int(word) for word in question.split() + if word.isdigit() and 1900 <= int(word) <= current_year] + return max(0, min(1, len(year_mentions))) + + def check_completeness(self, questions_list: List[str]) -> bool: + with log_timed( + self.logger, + "проверка полноты набора вопросов", + всего=len(questions_list), + ): + coverage = { + "теория": self._check_theory_coverage(questions_list), + "практика": self._check_practice_coverage(questions_list), + "глубина_анализа": self._check_analysis_depth(questions_list), + } + result = all(value >= 0.7 for value in coverage.values()) + + self.logger.info( + "Полнота набора вопросов=%s покрытие=%s", + result, + coverage, + ) + return result + + def _check_theory_coverage(self, questions: List[str]) -> float: + theoretical_terms = {'теория', 'модель', 'концепция', 'принцип'} + total_questions = len(questions) + theory_questions = sum( + 1 for q in questions + if any(term in q.lower() for term in theoretical_terms) + ) + return theory_questions / total_questions if total_questions > 0 else 0 + + def _check_practice_coverage(self, questions: List[str]) -> float: + practical_terms = {'применение', 'реализация', 'использование', 'результаты'} + total_questions = len(questions) + practice_questions = sum( + 1 for q in questions + if any(term in q.lower() for term in practical_terms) + ) + return practice_questions / total_questions if total_questions > 0 else 0 + + def _check_analysis_depth(self, questions: List[str]) -> float: + depth_indicators = { + 'поверхностный': {'что', 'какой'}, + 'средний': {'почему', 'как'}, + 'глубокий': {'анализ', 'оценка', 'сравнение'} + } + + depths = [] + for q in questions: + q_lower = q.lower() + depth = 0 + if any(ind in q_lower for ind in depth_indicators['глубокий']): + depth = 2 + elif any(ind in q_lower for ind in depth_indicators['средний']): + depth = 1 + elif any(ind in q_lower for ind in depth_indicators['поверхностный']): + depth = 0 + depths.append(depth) + + return sum(depths) / (len(depths) * 2) if depths else 0 + + def check_clarity(self, question: str) -> bool: + with log_timed(self.logger, "проверка ясности", длина=len(question)): + metrics = { + "length": self._check_length(question), + "complexity": self._calculate_complexity(question), + "ambiguity": self._check_ambiguity(question), + } + result = all(v >= 0.7 for v in metrics.values()) + + self.logger.info( + "Ясность=%s метрики=%s вопрос=%r", + result, + metrics, + question, + ) + return result + + def _check_length(self, question: str) -> float: + words = len(question.split()) + if words < 7: + return 0.5 * (words / 7) + elif words > 15: + return 1 - 0.5 * ((words - 15) / 15) + return 1.0 + + def _calculate_complexity(self, question: str) -> float: + words = question.split() + unique_words = set(words) + return min(1.0, len(unique_words) / len(words)) + + def _check_ambiguity(self, question: str) -> float: + ambiguous_terms = { + 'или', 'и', 'при этом', 'однако', 'тем не менее', + 'с другой стороны', 'в то же время' + } + ambiguity_score = 1.0 + for term in ambiguous_terms: + if term in question.lower(): + ambiguity_score -= 0.2 + return max(0.0, ambiguity_score) + + def check_difficulty(self, question: str) -> bool: + with log_timed(self.logger, "проверка сложности", длина=len(question)): + metrics = { + "abstraction": self._assess_abstraction(question), + "type": self._identify_question_type(question), + "level": self._match_student_level(question), + } + result = all(v == "optimal" for v in metrics.values()) + + self.logger.info( + "Сложность=%s метрики=%s вопрос=%r", + result, + metrics, + question, + ) + return result + + def _assess_abstraction(self, question: str) -> str: + abstract_terms = { + 'концепция', 'модель', 'теория', 'абстракция', + 'парадигма', 'методология' + } + concrete_terms = { + 'пример', 'факт', 'данные', 'результат', + 'показатель', 'число' + } + + abstract_count = sum(1 for term in abstract_terms + if term in question.lower()) + concrete_count = sum(1 for term in concrete_terms + if term in question.lower()) + + if abstract_count > 2 and concrete_count == 0: + return 'too_high' + elif abstract_count == 0 and concrete_count > 2: + return 'too_low' + return 'optimal' + + def _identify_question_type(self, question: str) -> str: + question_types = { + 'descriptive': {'описать', 'рассказать', 'характеризовать'}, + 'analytical': {'анализировать', 'сравнить', 'оценить'}, + 'practical': {'применить', 'использовать', 'реализовать'} + } + + type_count = Counter() + for q_type, keywords in question_types.items(): + count = sum(1 for keyword in keywords + if keyword in question.lower()) + if count > 0: + type_count[q_type] = count + + if len(type_count) >= 2: + return 'optimal' + elif len(type_count) == 0: + return 'too_simple' + return 'too_complex' + + def _match_student_level(self, question: str) -> str: + advanced_terms = { + 'методология', 'парадигма', 'теоретическая модель', + 'эмпирический анализ', 'статистическая обработка' + } + basic_terms = { + 'пример', 'факт', 'данные', 'результат', + 'показатель', 'число' + } + + advanced_count = sum(1 for term in advanced_terms + if term in question.lower()) + basic_count = sum(1 for term in basic_terms + if term in question.lower()) + + if advanced_count > 3: + return 'too_hard' + elif basic_count > 3: + return 'too_easy' + return 'optimal'