From 835b5ce92937df41df0a0caec1fec3c93b71e2bd Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:09:11 +0300 Subject: [PATCH 01/24] initial commit --- .gitignore | 4 +- app/questions_generator/Dockerfile | 28 ++ app/questions_generator/README.md | 12 + app/questions_generator/generator.py | 150 +++++++++ app/questions_generator/requirements.txt | 5 + app/questions_generator/run.py | 51 +++ app/questions_generator/validator.py | 405 +++++++++++++++++++++++ 7 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 app/questions_generator/Dockerfile create mode 100644 app/questions_generator/README.md create mode 100644 app/questions_generator/generator.py create mode 100644 app/questions_generator/requirements.txt create mode 100644 app/questions_generator/run.py create mode 100644 app/questions_generator/validator.py diff --git a/.gitignore b/.gitignore index 1ed6022..06067be 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/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..766e297 --- /dev/null +++ b/app/questions_generator/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.10-slim + +# 1. System deps +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/* + +# 2. Workdir +WORKDIR /app + +# 3. Python deps +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \ + && pip install --no-cache-dir -r requirements.txt + +# 4. NLTK +RUN python -m nltk.downloader punkt stopwords + +# 5. Copy local model +COPY rut5-base/ /app/rut5-base/ + +# 6. Copy project +COPY . . + +# 7. Run +CMD ["python", "run.py"] diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md new file mode 100644 index 0000000..ba6c080 --- /dev/null +++ b/app/questions_generator/README.md @@ -0,0 +1,12 @@ +# Запуск + +## Загрузка модели локально (единоразово) +- `powershell -ExecutionPolicy ByPass -c "irm https://hf.co/cli/install.ps1 | iex"` (windows) +- `curl -LsSf https://hf.co/cli/install.sh | bash` (linux/macos) +- `cd app\questions_generator` +- `hf download cointegrated/rut5-base-multitask --local-dir rut5-base` +## Выбор файла ВКР +- заменить в `run.py` в функции `main` путь для файла ВКР +## Запуск +- `docker build -t vkr-generator .` +- `docker run -it --rm vkr-generator` diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py new file mode 100644 index 0000000..4462f69 --- /dev/null +++ b/app/questions_generator/generator.py @@ -0,0 +1,150 @@ +import re +from typing import List, Dict +from nltk.tokenize import sent_tokenize, word_tokenize +from nltk.corpus import stopwords +from transformers import AutoTokenizer, AutoModelForSeq2SeqLM + + +class VkrQuestionGenerator: + """ + Генератор вопросов по тексту ВКР. + Основан на гибридном подходе: NLTK + rut5-base-multitask. + """ + def __init__(self, vkr_text: str, model_path: str = "./rut5-base"): + self.vkr_text = vkr_text + self.sentences = sent_tokenize(vkr_text) + self.stopwords = set(stopwords.words("russian")) + + # ---- Модель rut5 ---- + self.tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False) + self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) + + # --------------------------------------------------------- + # --- 1. ЭВРИСТИКА: Извлечение ключевых частей ВКР --- + # --------------------------------------------------------- + + def extract_section(self, title: str) -> str: + """ + Универсальный метод извлечения раздела по заголовку. + """ + pattern = rf"{title}.*?(?=\n[A-ZА-Я][^\n]*\n)" + m = re.search(pattern, self.vkr_text, re.DOTALL | re.IGNORECASE) + return m.group(0) if m else "" + + def extract_intro(self) -> str: + return self.extract_section("Введение") + + def extract_conclusion(self) -> str: + return self.extract_section("Заключение") + + def extract_methodology(self) -> str: + return self.extract_section("Методолог") + + # --------------------------------------------------------- + # --- 2. ЭВРИСТИКА: Поиск ключевых концепций --- + # --------------------------------------------------------- + + def extract_keywords(self, text: str) -> List[str]: + tokens = word_tokenize(text.lower()) + return [ + t for t in tokens + if t.isalnum() and t not in self.stopwords and len(t) > 4 + ] + + # --------------------------------------------------------- + # --- 3. Генерация вопросов через rut5 (режим ask) --- + # --------------------------------------------------------- + + def llm_generate_question(self, text_fragment: str) -> str: + """ + Генерация вопроса по фрагменту текста через rut5 ask + """ + prompt = f"ask: {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 + ) + return self.tokenizer.decode(out[0], skip_special_tokens=True) + + # --------------------------------------------------------- + # --- 4. ЭВРИСТИЧЕСКИЕ ШАБЛОНЫ (из документа) --- + # --------------------------------------------------------- + + def heuristic_questions(self) -> List[str]: + """ + Генерация вопросов по эвристикам из загруженных PDF. + """ + intro = self.extract_intro() + conc = self.extract_conclusion() + meth = self.extract_methodology() + keywords = self.extract_keywords(self.vkr_text) + + q = [] + + # --- По связям между разделами --- + if intro and conc: + q.append("Как сформулированные во введении задачи связаны с выводами работы?") + + # --- По методологии --- + if meth: + for kw in keywords[:3]: + q.append(f"Почему был выбран метод {kw} и где он применён в работе?") + + # --- По выводам --- + if conc: + q.append("На основании каких данных был сделан ключевой вывод в заключении?") + + # --- Общие вопросы (из документа) --- + q.extend([ + "Есть ли опенсорс аналоги упомянутых решений?", + "В чем практическая значимость представленного метода?", + "Какие ограничения имеет разработанный подход?", + "Для каких дополнительных задач можно применить полученные результаты?", + ]) + + return q + + # --------------------------------------------------------- + # --- 5. Гибридная генерация: LLM + эвристики --- + # --------------------------------------------------------- + + def generate_llm_questions(self, count=5) -> List[str]: + """ + Генерация N вопросов через rut5 по ключевым фрагментам документа. + """ + q = [] + fragments = self.sentences[:40] # первые ~40 предложений для контекста + + step = max(1, len(fragments) // count) + + for i in range(0, len(fragments), step): + frag = fragments[i] + try: + llm_q = self.llm_generate_question(frag) + if len(llm_q) > 10: + q.append(llm_q) + except: + continue + + if len(q) >= count: + break + + return q + + # --------------------------------------------------------- + # --- 6. Главный метод --- + # --------------------------------------------------------- + + def generate_all(self) -> List[str]: + """ + Генерирует полный набор вопросов: + - эвристические + - модельные (LLM) + """ + result = [] + result.extend(self.heuristic_questions()) + result.extend(self.generate_llm_questions(count=7)) + return list(dict.fromkeys(result)) # убрать дубли diff --git a/app/questions_generator/requirements.txt b/app/questions_generator/requirements.txt new file mode 100644 index 0000000..fce2882 --- /dev/null +++ b/app/questions_generator/requirements.txt @@ -0,0 +1,5 @@ +transformers +sentencepiece +nltk +huggingface_hub +python-docx diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py new file mode 100644 index 0000000..c8940db --- /dev/null +++ b/app/questions_generator/run.py @@ -0,0 +1,51 @@ +from generator import VkrQuestionGenerator +from validator import VkrQuestionValidator +import sys +import os +from docx import Document + + +def load_vkr_text(path: str) -> str: + if not os.path.exists(path): + print(f"[ERROR] Файл '{path}' не найден.") + sys.exit(1) + + document = Document(path) + text = [] + for paragraph in document.paragraphs: + text.append(paragraph.text) + + return '\n'.join(text) + + +def main(): + print("=== Загрузка текста ВКР ===") + text = load_vkr_text("vkr_examples/VKR1.docx") + + print("=== Инициализация генератора ===") + gen = VkrQuestionGenerator(text, model_path="/app/rut5-base") + + print("=== Инициализация валидатора ===") + validator = VkrQuestionValidator(text) + + print("=== Генерация вопросов ===") + questions = gen.generate_all() + + print("\n=== Результаты ===") + for q in questions: + rel = validator.check_relevance(q) + clr = validator.check_clarity(q) + diff = validator.check_difficulty(q) + + status = "✔ OK" if (rel and clr and diff) else "✖ FAIL" + + print(f"\n[{status}] {q}") + print(f" - relevance: {rel}") + print(f" - clarity: {clr}") + print(f" - difficulty:{diff}") + + print("\n=== Готово ===") + + +if __name__ == "__main__": + main() diff --git a/app/questions_generator/validator.py b/app/questions_generator/validator.py new file mode 100644 index 0000000..8441006 --- /dev/null +++ b/app/questions_generator/validator.py @@ -0,0 +1,405 @@ +import re +from typing import List, Dict, Set +from collections import Counter +import nltk +from nltk.tokenize import sent_tokenize, word_tokenize +from nltk.corpus import stopwords +import string +from datetime import datetime + + +class VkrQuestionValidator: + def __init__(self, vkr_text: str): + """ + Инициализация валидатора с текстом ВКР + + Args: + vkr_text: Полный текст ВКР + """ + self.vkr_text = vkr_text.lower() + self.keywords = self._extract_keywords() + self.stopwords = set(stopwords.words('russian')) + + def _extract_keywords(self) -> Dict[str, Set[str]]: + """ + Извлечение ключевых слов из текста ВКР + + Returns: + Словарь с категориями ключевых слов + """ + keywords = { + 'theme': set(), # Тематические слова + 'goals': set(), # Слова, связанные с целями + 'methodology': set() # Методологические термины + } + + # Извлечение ключевых слов из введения + intro_section = self._extract_introduction() + keywords['theme'] = self._tokenize_and_filter(intro_section) + + # Извлечение целей из соответствующего раздела + goals_section = self._extract_goals_section() + keywords['goals'] = self._tokenize_and_filter(goals_section) + + # Извлечение методологических терминов + meth_section = self._extract_methodology_section() + keywords['methodology'] = self._tokenize_and_filter(meth_section) + + return keywords + + def _tokenize_and_filter(self, text: str) -> Set[str]: + """ + Токенизация и фильтрация текста для получения ключевых слов + + Args: + text: Исходный текст для обработки + + Returns: + Множество отфильтрованных токенов + """ + 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: + """ + Извлечение введения из текста ВКР + + Returns: + Текст введения + """ + 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: + """ + Извлечение раздела с целями и задачами + + Returns: + Текст раздела с целями + """ + 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: + """ + Извлечение методологического раздела + + Returns: + Текст методологического раздела + """ + 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: + """ + Проверка релевантности вопроса + + Args: + question: Проверяемый вопрос + + Returns: + True если вопрос релевантен, False если нет + """ + score = 0 + + # Проверка соответствия теме + theme_match = len(set(question.lower().split()) & + set(self.keywords['theme'])) + if theme_match > 0: + score += 1 + + # Проверка актуальности + actuality_score = self._calculate_actuality_score(question) + score += actuality_score + + # Проверка связи с целями + goal_match = len(set(question.lower().split()) & + set(self.keywords['goals'])) + if goal_match > 0: + score += 1 + + return score >= 2 + + def _calculate_actuality_score(self, question: str) -> int: + """ + Расчёт актуальности вопроса + + Args: + question: Анализируемый вопрос + + Returns: + Оценка актуальности (0 или 1) + """ + 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: + """ + Проверка полноты набора вопросов + + Args: + questions_list: Список проверяемых вопросов + + Returns: + True если набор полный, False если нет + """ + coverage = { + 'theoretical': self._check_theory_coverage(questions_list), + 'practical': self._check_practice_coverage(questions_list), + 'analysis_levels': self._check_analysis_depth(questions_list) + } + return all(value >= 0.7 for value in coverage.values()) + + def _check_theory_coverage(self, questions: List[str]) -> float: + """ + Проверка теоретического охвата вопросами + + Args: + questions: Список вопросов для анализа + + Returns: + Значение от 0 до 1, показывающее степень покрытия + """ + 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: + """ + Проверка практического охвата вопросами + + Args: + questions: Список вопросов для анализа + + Returns: + Значение от 0 до 1, показывающее степень покрытия + """ + 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: + """ + Проверка глубины анализа в вопросах + + Args: + questions: Список вопросов для анализа + + Returns: + Значение от 0 до 1, показывающее глубину анализа + """ + 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: + """ + Проверка ясности формулировки вопроса + + Args: + question: Проверяемый вопрос + + Returns: + True если формулировка ясная, False если нет + """ + metrics = { + 'length': self._check_length(question), + 'complexity': self._calculate_complexity(question), + 'ambiguity': self._check_ambiguity(question) + } + return all(value >= 0.7 for value in metrics.values()) + + def _check_length(self, question: str) -> float: + """ + Проверка длины вопроса + + Args: + question: Проверяемый вопрос + + Returns: + Нормализованное значение от 0 до 1 + """ + words = len(question.split()) + # Оптимальная длина вопроса считается 7-15 слов + 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: + """ + Оценка сложности вопроса + + Args: + question: Анализируемый вопрос + + Returns: + Значение от 0 до 1, показывающее сложность + """ + words = question.split() + unique_words = set(words) + return min(1.0, len(unique_words) / len(words)) + + def _check_ambiguity(self, question: str) -> float: + """ + Проверка наличия двусмысленностей в вопросе + + Args: + question: Проверяемый вопрос + + Returns: + Значение от 0 до 1, где 1 - нет двусмысленностей + """ + 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: + """ + Проверка уровня сложности вопроса + + Args: + question: Проверяемый вопрос + + Returns: + True если уровень сложности оптимальный, False если нет + """ + difficulty_metrics = { + 'abstraction_level': self._assess_abstraction(question), + 'question_type': self._identify_question_type(question), + 'student_level_match': self._match_student_level(question) + } + return all(value == 'optimal' for value in difficulty_metrics.values()) + + def _assess_abstraction(self, question: str) -> str: + """ + Оценка уровня абстракции вопроса + + Args: + question: Анализируемый вопрос + + Returns: + 'optimal', 'too_high', 'too_low' + """ + 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: + """ + Определение типа вопроса + + Args: + question: Анализируемый вопрос + + Returns: + 'optimal', 'too_simple', 'too_complex' + """ + 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: + """ + Проверка соответствия вопроса уровню студента + + Args: + question: Анализируемый вопрос + + Returns: + 'optimal', 'too_hard', 'too_easy' + """ + 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' From 39d54cfdceb0b7e6445742e2cfc5e4cc37ec205e Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:54:58 +0300 Subject: [PATCH 02/24] first prototype --- app/questions_generator/Dockerfile | 2 ++ app/questions_generator/README.md | 2 +- app/questions_generator/run.py | 7 +++++++ app/questions_generator/validator.py | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile index 766e297..b6e8dda 100644 --- a/app/questions_generator/Dockerfile +++ b/app/questions_generator/Dockerfile @@ -18,6 +18,8 @@ RUN pip install --no-cache-dir --upgrade pip \ # 4. NLTK RUN python -m nltk.downloader punkt stopwords +RUN python -m nltk.downloader punkt + # 5. Copy local model COPY rut5-base/ /app/rut5-base/ diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md index ba6c080..2b22193 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -7,6 +7,6 @@ - `hf download cointegrated/rut5-base-multitask --local-dir rut5-base` ## Выбор файла ВКР - заменить в `run.py` в функции `main` путь для файла ВКР -## Запуск +## Запуск (после любых изменений) - `docker build -t vkr-generator .` - `docker run -it --rm vkr-generator` diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py index c8940db..7008cad 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -3,6 +3,7 @@ import sys import os from docx import Document +import nltk def load_vkr_text(path: str) -> str: @@ -19,6 +20,12 @@ def load_vkr_text(path: str) -> str: def main(): + try: + nltk.data.find('tokenizers/punkt_tab/english') + except LookupError: + print("Загрузка необходимых данных NLTK...") + nltk.download('punkt_tab') + print("=== Загрузка текста ВКР ===") text = load_vkr_text("vkr_examples/VKR1.docx") diff --git a/app/questions_generator/validator.py b/app/questions_generator/validator.py index 8441006..c4b8900 100644 --- a/app/questions_generator/validator.py +++ b/app/questions_generator/validator.py @@ -17,8 +17,8 @@ def __init__(self, vkr_text: str): vkr_text: Полный текст ВКР """ self.vkr_text = vkr_text.lower() - self.keywords = self._extract_keywords() self.stopwords = set(stopwords.words('russian')) + self.keywords = self._extract_keywords() def _extract_keywords(self) -> Dict[str, Set[str]]: """ From d813c886cfadb991b539f85c1b3136687a95d8b2 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:17:52 +0300 Subject: [PATCH 03/24] added LLM questions marker --- app/questions_generator/generator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 4462f69..77efe22 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -146,5 +146,6 @@ def generate_all(self) -> List[str]: """ result = [] result.extend(self.heuristic_questions()) - result.extend(self.generate_llm_questions(count=7)) + result.extend(["Начало rut5-base-multitask вопросов"]) + result.extend(self.generate_llm_questions(count=10)) return list(dict.fromkeys(result)) # убрать дубли From 8e42c8e542755f8a148367bfec28bb6e3923fabe Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:06:11 +0300 Subject: [PATCH 04/24] removed methodology --- app/questions_generator/generator.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 77efe22..292c0e9 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -37,9 +37,6 @@ def extract_intro(self) -> str: def extract_conclusion(self) -> str: return self.extract_section("Заключение") - def extract_methodology(self) -> str: - return self.extract_section("Методолог") - # --------------------------------------------------------- # --- 2. ЭВРИСТИКА: Поиск ключевых концепций --- # --------------------------------------------------------- @@ -79,7 +76,6 @@ def heuristic_questions(self) -> List[str]: """ intro = self.extract_intro() conc = self.extract_conclusion() - meth = self.extract_methodology() keywords = self.extract_keywords(self.vkr_text) q = [] @@ -88,11 +84,6 @@ def heuristic_questions(self) -> List[str]: if intro and conc: q.append("Как сформулированные во введении задачи связаны с выводами работы?") - # --- По методологии --- - if meth: - for kw in keywords[:3]: - q.append(f"Почему был выбран метод {kw} и где он применён в работе?") - # --- По выводам --- if conc: q.append("На основании каких данных был сделан ключевой вывод в заключении?") From 48ed43cbfd38d5e28750325a019269bfa4c90f5d Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:17:09 +0300 Subject: [PATCH 05/24] requirements.txt added versions --- app/questions_generator/requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/questions_generator/requirements.txt b/app/questions_generator/requirements.txt index fce2882..c833faf 100644 --- a/app/questions_generator/requirements.txt +++ b/app/questions_generator/requirements.txt @@ -1,5 +1,5 @@ -transformers -sentencepiece -nltk -huggingface_hub -python-docx +transformers==4.57.3 +sentencepiece==0.2.1 +nltk==3.9.2 +huggingface_hub==1.2.1 +python-docx==1.2.0 From 1bcf046661bdef9f0b2aeb045ac6263b649f554f Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:39:58 +0300 Subject: [PATCH 06/24] simplified docker --- app/questions_generator/Dockerfile | 24 ++++++---- app/questions_generator/README.md | 15 ++----- app/questions_generator/docker-entrypoint.sh | 47 ++++++++++++++++++++ app/questions_generator/generator.py | 2 +- app/questions_generator/requirements.txt | 2 +- app/questions_generator/run.py | 35 +++++++++++---- app/questions_generator/run_docker.py | 45 +++++++++++++++++++ 7 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 app/questions_generator/docker-entrypoint.sh create mode 100644 app/questions_generator/run_docker.py diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile index b6e8dda..4079bea 100644 --- a/app/questions_generator/Dockerfile +++ b/app/questions_generator/Dockerfile @@ -13,18 +13,24 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \ - && pip install --no-cache-dir -r requirements.txt + && pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir "huggingface_hub[cli]" -# 4. NLTK -RUN python -m nltk.downloader punkt stopwords +# NLTK будет качаться в отдельный каталог (volume) +ENV NLTK_DATA=/nltk_data -RUN python -m nltk.downloader punkt +# 4. Volume'ы: +# - /app/question_generator/rut5-base — модель ruT5 +# - /nltk_data — данные NLTK +VOLUME ["/app/question_generator/rut5-base", "/nltk_data"] -# 5. Copy local model -COPY rut5-base/ /app/rut5-base/ - -# 6. Copy project +# 5. Копируем проект COPY . . -# 7. Run +# 6. Копируем entrypoint-скрипт +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# 7. Точка входа: сначала — скрипт, затем основной CMD +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["python", "run.py"] diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md index 2b22193..c57af3c 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -1,12 +1,5 @@ -# Запуск +## Сборка +`docker build -t vkr-generator .` -## Загрузка модели локально (единоразово) -- `powershell -ExecutionPolicy ByPass -c "irm https://hf.co/cli/install.ps1 | iex"` (windows) -- `curl -LsSf https://hf.co/cli/install.sh | bash` (linux/macos) -- `cd app\questions_generator` -- `hf download cointegrated/rut5-base-multitask --local-dir rut5-base` -## Выбор файла ВКР -- заменить в `run.py` в функции `main` путь для файла ВКР -## Запуск (после любых изменений) -- `docker build -t vkr-generator .` -- `docker run -it --rm vkr-generator` +## Запуск +`python run_docker.py <путь к файлу с текстом ВКР>` \ No newline at end of file diff --git a/app/questions_generator/docker-entrypoint.sh b/app/questions_generator/docker-entrypoint.sh new file mode 100644 index 0000000..c4a5243 --- /dev/null +++ b/app/questions_generator/docker-entrypoint.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -e + +MODEL_DIR="/app/question_generator/rut5-base" +NLTK_DIR="${NLTK_DATA:-/nltk_data}" + +echo "MODEL_DIR=${MODEL_DIR}" +echo "NLTK_DIR=${NLTK_DIR}" + +# Гарантируем, что каталоги существуют +mkdir -p "$MODEL_DIR" "$NLTK_DIR" + +######################################## +# 1. Загрузка модели в volume (один раз) +######################################## +if [ -z "$(ls -A "$MODEL_DIR" 2>/dev/null)" ]; then + echo "Model directory is empty. Downloading model to $MODEL_DIR..." + huggingface-cli download \ + cointegrated/rut5-base-multitask \ + --local-dir "$MODEL_DIR" \ + --local-dir-use-symlinks False + echo "Model downloaded." +else + echo "Model directory is not empty, skipping download." +fi + +######################################## +# 2. Загрузка данных NLTK в volume +######################################## +if [ -z "$(ls -A "$NLTK_DIR" 2>/dev/null)" ]; then + echo "NLTK data directory is empty. Downloading 'punkt' and 'stopwords' to $NLTK_DIR..." + python - < str: if not os.path.exists(path): @@ -16,21 +19,37 @@ def load_vkr_text(path: str) -> str: for paragraph in document.paragraphs: text.append(paragraph.text) - return '\n'.join(text) + return "\n".join(text) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Генерация экзаменационных вопросов по тексту ВКР" + ) + parser.add_argument( + "vkr_path", + nargs="?", + default="vkr_examples/VKR1.docx", + help="Путь к .docx файлу с текстом ВКР (по умолчанию: vkr_examples/VKR1.docx)", + ) + return parser.parse_args() def main(): + args = parse_args() + vkr_path = args.vkr_path + try: - nltk.data.find('tokenizers/punkt_tab/english') + nltk.data.find("tokenizers/punkt_tab/english") except LookupError: print("Загрузка необходимых данных NLTK...") - nltk.download('punkt_tab') + nltk.download("punkt_tab") - print("=== Загрузка текста ВКР ===") - text = load_vkr_text("vkr_examples/VKR1.docx") + print(f"=== Загрузка текста ВКР из '{vkr_path}' ===") + text = load_vkr_text(vkr_path) print("=== Инициализация генератора ===") - gen = VkrQuestionGenerator(text, model_path="/app/rut5-base") + gen = VkrQuestionGenerator(text, model_path="/app/question_generator/rut5-base") print("=== Инициализация валидатора ===") validator = VkrQuestionValidator(text) diff --git a/app/questions_generator/run_docker.py b/app/questions_generator/run_docker.py new file mode 100644 index 0000000..30c986e --- /dev/null +++ b/app/questions_generator/run_docker.py @@ -0,0 +1,45 @@ +import os +import sys +import argparse +import subprocess + + +def main(): + parser = argparse.ArgumentParser( + description="Запуск генератора вопросов по ВКР внутри Docker" + ) + parser.add_argument( + "vkr_path", + help="Путь к .docx файлу с текстом ВКР (на хосте)", + ) + args = parser.parse_args() + + host_path = os.path.abspath(args.vkr_path) + + if not os.path.exists(host_path): + print(f"[ERROR] Файл не найден: {host_path}") + sys.exit(1) + + # Путь внутри контейнера — фиксированный, один и тот же для всех ОС + container_path = "/app/questions_generator/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, + ] + + print(">> Запускаю команду:") + print(" ".join(cmd)) + try: + subprocess.run(cmd, check=True) + except subprocess.CalledProcessError as e: + print(f"[ERROR] docker run завершился с ошибкой: {e.returncode}") + sys.exit(e.returncode) + + +if __name__ == "__main__": + main() From 8a54af15e1df53b7af59a090d216ee1c475941aa Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:00:22 +0300 Subject: [PATCH 07/24] heuristic patterns update --- app/questions_generator/README.md | 2 +- app/questions_generator/generator.py | 144 ++++++++++++++------------- 2 files changed, 75 insertions(+), 71 deletions(-) diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md index c57af3c..ade6c3f 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -2,4 +2,4 @@ `docker build -t vkr-generator .` ## Запуск -`python run_docker.py <путь к файлу с текстом ВКР>` \ No newline at end of file +`python run_docker.py <путь к файлу с текстом ВКР>` diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 8056af5..58f48ae 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -1,33 +1,36 @@ import re from typing import List, Dict + from nltk.tokenize import sent_tokenize, word_tokenize from nltk.corpus import stopwords from transformers import AutoTokenizer, AutoModelForSeq2SeqLM class VkrQuestionGenerator: - """ - Генератор вопросов по тексту ВКР. - Основан на гибридном подходе: NLTK + rut5-base-multitask. - """ - def __init__(self, vkr_text: str, model_path: str): + """Гибридный генератор вопросов по ВКР: NLTK + rut5-base-multitask.""" + + SECTION_PATTERNS: Dict[str, str] = { + "Введение": r"Введение.*?(?=\n[A-ZА-Я][^\n]*\n)", + "Обзор предметной области": r"Обзор предметной области.*?(?=\n[A-ZА-Я][^\n]*\n)", + "Постановка задачи": r"Постановка задачи.*?(?=\n[A-ZА-Я][^\n]*\n)", + "Метод решения": r"Метод решения.*?(?=\n[A-ZА-Я][^\n]*\n)", + "Исследования": r"Исследования.*?(?=\n[A-ZА-Я][^\n]*\n)", + "Заключение": r"Заключение.*?(?=\n[A-ZА-Я][^\n]*\n)", + "Приложения": r"Приложения.*?(?=\n[A-ZА-Я][^\n]*\n)", + } + + def __init__(self, vkr_text: str, model_path: str = "ai-forever/rut5-base-multitask"): self.vkr_text = vkr_text self.sentences = sent_tokenize(vkr_text) self.stopwords = set(stopwords.words("russian")) - # ---- Модель rut5 ---- + # Модель rut5-base-multitask для языкового оформления вопросов self.tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False) self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) - # --------------------------------------------------------- - # --- 1. ЭВРИСТИКА: Извлечение ключевых частей ВКР --- - # --------------------------------------------------------- - def extract_section(self, title: str) -> str: - """ - Универсальный метод извлечения раздела по заголовку. - """ - pattern = rf"{title}.*?(?=\n[A-ZА-Я][^\n]*\n)" + """Извлекает раздел по шаблону заголовка.""" + pattern = self.SECTION_PATTERNS.get(title, rf"{title}.*?(?=\n[A-ZА-Я][^\n]*\n)") m = re.search(pattern, self.vkr_text, re.DOTALL | re.IGNORECASE) return m.group(0) if m else "" @@ -37,77 +40,86 @@ def extract_intro(self) -> str: def extract_conclusion(self) -> str: return self.extract_section("Заключение") - # --------------------------------------------------------- - # --- 2. ЭВРИСТИКА: Поиск ключевых концепций --- - # --------------------------------------------------------- - def extract_keywords(self, text: str) -> List[str]: + """Извлекает ключевые слова из текста.""" tokens = word_tokenize(text.lower()) return [ t for t in tokens if t.isalnum() and t not in self.stopwords and len(t) > 4 ] - # --------------------------------------------------------- - # --- 3. Генерация вопросов через rut5 (режим ask) --- - # --------------------------------------------------------- - def llm_generate_question(self, text_fragment: str) -> str: - """ - Генерация вопроса по фрагменту текста через rut5 ask - """ + """Генерирует формулировку вопроса через rut5 ask.""" prompt = f"ask: {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 + early_stopping=True, ) return self.tokenizer.decode(out[0], skip_special_tokens=True) - # --------------------------------------------------------- - # --- 4. ЭВРИСТИЧЕСКИЕ ШАБЛОНЫ (из документа) --- - # --------------------------------------------------------- - def heuristic_questions(self) -> List[str]: - """ - Генерация вопросов по эвристикам из загруженных PDF. - """ + """Эвристики, завязанные на структуру ВКР.""" intro = self.extract_intro() + overview = self.extract_section("Обзор предметной области") + objectives = self.extract_section("Постановка задачи") + method = self.extract_section("Метод решения") + research = self.extract_section("Исследования") conc = self.extract_conclusion() - keywords = self.extract_keywords(self.vkr_text) + apps = self.extract_section("Приложения") - q = [] + q: List[str] = [] - # --- По связям между разделами --- + # Введение ↔ Заключение if intro and conc: - q.append("Как сформулированные во введении задачи связаны с выводами работы?") - - # --- По выводам --- - if conc: - q.append("На основании каких данных был сделан ключевой вывод в заключении?") - - # --- Общие вопросы (из документа) --- + q.append( + "Как цель и задачи, сформулированные во введении, отражены в итоговых выводах заключения?" + ) + + # Обзор предметной области + if overview: + q.append( + "Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи?" + ) + + # Постановка задачи + if objectives: + q.append( + "В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы?" + ) + + # Метод решения + if method: + q.append( + "Как архитектура и алгоритмы, описанные в разделе «Метод решения», обеспечивают достижение поставленных требований?" + ) + + # Исследования + if research: + q.append( + "Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения?" + ) + + # Приложения + if apps: + q.append( + "Какие дополнительные материалы из приложений необходимы для проверки воспроизводимости результатов?" + ) + + # Обязательные общие вопросы q.extend([ - "Есть ли опенсорс аналоги упомянутых решений?", - "В чем практическая значимость представленного метода?", - "Какие ограничения имеет разработанный подход?", - "Для каких дополнительных задач можно применить полученные результаты?", + "Как практическая значимость работы следует из задач и результатов исследования?", + "Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели?", ]) return q - # --------------------------------------------------------- - # --- 5. Гибридная генерация: LLM + эвристики --- - # --------------------------------------------------------- - - def generate_llm_questions(self, count=5) -> List[str]: - """ - Генерация N вопросов через rut5 по ключевым фрагментам документа. - """ - q = [] - fragments = self.sentences[:40] # первые ~40 предложений для контекста + def generate_llm_questions(self, count: int = 5) -> List[str]: + """Генерирует N вопросов через rut5 по ключевым фрагментам документа.""" + q: List[str] = [] + fragments = self.sentences[:40] step = max(1, len(fragments) // count) @@ -117,7 +129,7 @@ def generate_llm_questions(self, count=5) -> List[str]: llm_q = self.llm_generate_question(frag) if len(llm_q) > 10: q.append(llm_q) - except: + except Exception: # noqa: BLE001 continue if len(q) >= count: @@ -125,18 +137,10 @@ def generate_llm_questions(self, count=5) -> List[str]: return q - # --------------------------------------------------------- - # --- 6. Главный метод --- - # --------------------------------------------------------- - def generate_all(self) -> List[str]: - """ - Генерирует полный набор вопросов: - - эвристические - - модельные (LLM) - """ - result = [] + """Генерирует полный набор вопросов: эвристики + LLM.""" + result: List[str] = [] result.extend(self.heuristic_questions()) - result.extend(["Начало rut5-base-multitask вопросов"]) + result.extend(["--- rut5-base-multitask вопросы ---"]) result.extend(self.generate_llm_questions(count=10)) - return list(dict.fromkeys(result)) # убрать дубли + return list(dict.fromkeys(result)) From d7a57d7c347dae93638fee1f5aba5e2730c60c72 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:03:49 +0300 Subject: [PATCH 08/24] updated questions ranking and added examples --- app/questions_generator/README.md | 92 +++++++++++++++++++++++++++++++ app/questions_generator/run.py | 2 +- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md index ade6c3f..a0fb72a 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -3,3 +3,95 @@ ## Запуск `python run_docker.py <путь к файлу с текстом ВКР>` + +## Пример сгенерированных вопросов по тексту ВКР + +[✔ OK] Как цель и задачи, сформулированные во введении, отражены в итоговых выводах заключения? + - relevance: True + - clarity: True + - difficulty:False + +[✔ OK] Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи? + - relevance: True + - clarity: True + - difficulty:False + +[✖ FAIL] В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы? + - relevance: False + - clarity: True + - difficulty:False + +[✖ FAIL] Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения? + - relevance: True + - clarity: False + - difficulty:False + +[✔ OK] Какие дополнительные материалы из приложений необходимы для проверки воспроизводимости результатов? + - relevance: True + - clarity: True + - difficulty:False + +[✔ OK] Как практическая значимость работы следует из задач и результатов исследования? + - relevance: True + - clarity: True + - difficulty:False + +[✔ OK] Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели? + - relevance: True + - clarity: True + - difficulty:False + +[✖ FAIL] --- rut5-base-multitask вопросы --- + - relevance: False + - clarity: False + - difficulty:False + +[✖ 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/run.py b/app/questions_generator/run.py index a8ba337..36342d5 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -63,7 +63,7 @@ def main(): clr = validator.check_clarity(q) diff = validator.check_difficulty(q) - status = "✔ OK" if (rel and clr and diff) else "✖ FAIL" + status = "✔ OK" if (int(rel) + int(clr) + int(diff) >= 2) else "✖ FAIL" # хотя бы 2 условия выполнены print(f"\n[{status}] {q}") print(f" - relevance: {rel}") From e20a3e0c64d7e42d64a4deaa6951e9a6abc7bf05 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:29:24 +0300 Subject: [PATCH 09/24] docker-compose finally done --- app/questions_generator/Dockerfile | 38 +++++++++---------- app/questions_generator/docker-compose.yml | 24 ++++++++++++ .../init-volumes.sh} | 29 ++++++-------- 3 files changed, 52 insertions(+), 39 deletions(-) create mode 100644 app/questions_generator/docker-compose.yml rename app/questions_generator/{docker-entrypoint.sh => docker/init-volumes.sh} (56%) diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile index 4079bea..3b40b24 100644 --- a/app/questions_generator/Dockerfile +++ b/app/questions_generator/Dockerfile @@ -1,36 +1,32 @@ -FROM python:3.10-slim +FROM python:3.10-slim AS base -# 1. System deps 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/* -# 2. Workdir WORKDIR /app -# 3. Python deps COPY requirements.txt . -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \ - && pip install --no-cache-dir -r requirements.txt \ - && pip install --no-cache-dir "huggingface_hub[cli]" -# NLTK будет качаться в отдельный каталог (volume) -ENV NLTK_DATA=/nltk_data +# можно (и полезно) задать глобально: +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_DEFAULT_TIMEOUT=120 -# 4. Volume'ы: -# - /app/question_generator/rut5-base — модель ruT5 -# - /nltk_data — данные NLTK -VOLUME ["/app/question_generator/rut5-base", "/nltk_data"] +RUN pip install --no-cache-dir torch==2.5.1 +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir "huggingface_hub[cli]" -# 5. Копируем проект -COPY . . +ENV NLTK_DATA=/nltk_data -# 6. Копируем entrypoint-скрипт -COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -RUN chmod +x /usr/local/bin/docker-entrypoint.sh +COPY . . -# 7. Точка входа: сначала — скрипт, затем основной CMD -ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +# ====== runtime image ====== +FROM base AS app CMD ["python", "run.py"] + +# ====== init image ====== +FROM base AS init +COPY docker/init-volumes.sh /usr/local/bin/init-volumes.sh +RUN chmod +x /usr/local/bin/init-volumes.sh +ENTRYPOINT ["/usr/local/bin/init-volumes.sh"] diff --git a/app/questions_generator/docker-compose.yml b/app/questions_generator/docker-compose.yml new file mode 100644 index 0000000..33f7a0a --- /dev/null +++ b/app/questions_generator/docker-compose.yml @@ -0,0 +1,24 @@ +services: + init: + build: + context: . + target: init + volumes: + - rut5_model:/app/question_generator/rut5-base + - nltk_data:/nltk_data + restart: "no" + + app: + build: + context: . + target: app + depends_on: + init: + condition: service_completed_successfully + volumes: + - rut5_model:/app/question_generator/rut5-base + - nltk_data:/nltk_data + +volumes: + rut5_model: + nltk_data: diff --git a/app/questions_generator/docker-entrypoint.sh b/app/questions_generator/docker/init-volumes.sh similarity index 56% rename from app/questions_generator/docker-entrypoint.sh rename to app/questions_generator/docker/init-volumes.sh index c4a5243..2cf47f8 100644 --- a/app/questions_generator/docker-entrypoint.sh +++ b/app/questions_generator/docker/init-volumes.sh @@ -7,12 +7,9 @@ NLTK_DIR="${NLTK_DATA:-/nltk_data}" echo "MODEL_DIR=${MODEL_DIR}" echo "NLTK_DIR=${NLTK_DIR}" -# Гарантируем, что каталоги существуют mkdir -p "$MODEL_DIR" "$NLTK_DIR" -######################################## -# 1. Загрузка модели в volume (один раз) -######################################## +# 1) ruT5 model (один раз) if [ -z "$(ls -A "$MODEL_DIR" 2>/dev/null)" ]; then echo "Model directory is empty. Downloading model to $MODEL_DIR..." huggingface-cli download \ @@ -24,24 +21,20 @@ else echo "Model directory is not empty, skipping download." fi -######################################## -# 2. Загрузка данных NLTK в volume -######################################## +# 2) NLTK data (один раз) if [ -z "$(ls -A "$NLTK_DIR" 2>/dev/null)" ]; then echo "NLTK data directory is empty. Downloading 'punkt' and 'stopwords' to $NLTK_DIR..." - python - < Date: Wed, 24 Dec 2025 19:00:31 +0300 Subject: [PATCH 10/24] interactive mode --- app/questions_generator/Dockerfile | 2 +- app/questions_generator/README.md | 8 ++++---- app/questions_generator/docker-compose.yml | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile index 3b40b24..3c53a21 100644 --- a/app/questions_generator/Dockerfile +++ b/app/questions_generator/Dockerfile @@ -23,7 +23,7 @@ COPY . . # ====== runtime image ====== FROM base AS app -CMD ["python", "run.py"] +CMD ["bash", "-lc", "sleep infinity"] # ====== init image ====== FROM base AS init diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md index a0fb72a..34bf44e 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -1,8 +1,8 @@ -## Сборка -`docker build -t vkr-generator .` +## Запуск (контейнер вечно крутится) +`docker-compose up` - ВАЖНО: Первый раз ОЧЕНЬ ДОЛГО билдится (30-40 минут)!!! -## Запуск -`python run_docker.py <путь к файлу с текстом ВКР>` +## Использование (интерактивное) +`docker compose exec app python run.py /app/vkr_examples/VKR1.docx` - папка `vkr_examples` локальная, лежит рядом с композом ## Пример сгенерированных вопросов по тексту ВКР diff --git a/app/questions_generator/docker-compose.yml b/app/questions_generator/docker-compose.yml index 33f7a0a..27f353b 100644 --- a/app/questions_generator/docker-compose.yml +++ b/app/questions_generator/docker-compose.yml @@ -15,9 +15,12 @@ services: depends_on: init: condition: service_completed_successfully + stdin_open: true + tty: true volumes: - rut5_model:/app/question_generator/rut5-base - nltk_data:/nltk_data + - ./vkr_examples:/app/vkr_examples # монтируется для интерактивного запуска с файлами из этой папки (папка рядом с композом) volumes: rut5_model: From 6ec48774238ff279728741d40a17c2aa6145e7a6 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 24 Dec 2025 19:31:17 +0300 Subject: [PATCH 11/24] logging added --- app/questions_generator/generator.py | 231 +++++++++++++-------- app/questions_generator/run.py | 138 ++++++++++--- app/questions_generator/validator.py | 292 +++++++++------------------ 3 files changed, 351 insertions(+), 310 deletions(-) diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 58f48ae..386c5d3 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -1,4 +1,7 @@ import re +import logging +import time +from contextlib import contextmanager from typing import List, Dict from nltk.tokenize import sent_tokenize, word_tokenize @@ -6,6 +9,17 @@ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM +@contextmanager +def timed(logger: logging.Logger, operation: str, level: int = logging.INFO, **extra): + start = time.perf_counter() + logger.log(level, "START %s %s", operation, (extra if extra else "")) + try: + yield + finally: + elapsed_ms = (time.perf_counter() - start) * 1000.0 + logger.log(level, "END %s | %.2f ms %s", operation, elapsed_ms, (extra if extra else "")) + + class VkrQuestionGenerator: """Гибридный генератор вопросов по ВКР: NLTK + rut5-base-multitask.""" @@ -20,13 +34,28 @@ class VkrQuestionGenerator: } def __init__(self, vkr_text: str, model_path: str = "ai-forever/rut5-base-multitask"): - self.vkr_text = vkr_text - self.sentences = sent_tokenize(vkr_text) - self.stopwords = set(stopwords.words("russian")) + self.logger = logging.getLogger(__name__) + + with timed(self.logger, "generator_init"): + self.vkr_text = vkr_text + + with timed(self.logger, "sent_tokenize"): + self.sentences = sent_tokenize(vkr_text) + + with timed(self.logger, "load_stopwords"): + self.stopwords = set(stopwords.words("russian")) - # Модель rut5-base-multitask для языкового оформления вопросов - self.tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False) - self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) + # Модель rut5-base-multitask для языкового оформления вопросов + with timed(self.logger, "load_tokenizer", model_path=model_path): + self.tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False) + + with timed(self.logger, "load_model", model_path=model_path): + self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) + + self.logger.info( + "Generator ready: sentences=%d stopwords=%d model_path=%s", + len(self.sentences), len(self.stopwords), model_path + ) def extract_section(self, title: str) -> str: """Извлекает раздел по шаблону заголовка.""" @@ -42,105 +71,133 @@ def extract_conclusion(self) -> str: def extract_keywords(self, text: str) -> List[str]: """Извлекает ключевые слова из текста.""" - tokens = word_tokenize(text.lower()) - return [ - t for t in tokens - if t.isalnum() and t not in self.stopwords and len(t) > 4 - ] + with timed(self.logger, "extract_keywords", text_len=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("Keywords extracted: %d", len(result)) + return result def llm_generate_question(self, text_fragment: str) -> str: """Генерирует формулировку вопроса через rut5 ask.""" prompt = f"ask: {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, - ) - return self.tokenizer.decode(out[0], skip_special_tokens=True) + with timed(self.logger, "llm_generate_question", fragment_len=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]: """Эвристики, завязанные на структуру ВКР.""" - intro = self.extract_intro() - overview = self.extract_section("Обзор предметной области") - objectives = self.extract_section("Постановка задачи") - method = self.extract_section("Метод решения") - research = self.extract_section("Исследования") - conc = self.extract_conclusion() - apps = self.extract_section("Приложения") - - q: List[str] = [] - - # Введение ↔ Заключение - if intro and conc: - q.append( - "Как цель и задачи, сформулированные во введении, отражены в итоговых выводах заключения?" - ) - - # Обзор предметной области - if overview: - q.append( - "Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи?" - ) - - # Постановка задачи - if objectives: - q.append( - "В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы?" - ) - - # Метод решения - if method: - q.append( - "Как архитектура и алгоритмы, описанные в разделе «Метод решения», обеспечивают достижение поставленных требований?" - ) - - # Исследования - if research: - q.append( - "Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения?" - ) - - # Приложения - if apps: - q.append( - "Какие дополнительные материалы из приложений необходимы для проверки воспроизводимости результатов?" - ) - - # Обязательные общие вопросы - q.extend([ - "Как практическая значимость работы следует из задач и результатов исследования?", - "Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели?", - ]) - + with timed(self.logger, "heuristic_questions_total"): + intro = self.extract_intro() + overview = self.extract_section("Обзор предметной области") + objectives = self.extract_section("Постановка задачи") + method = self.extract_section("Метод решения") + research = self.extract_section("Исследования") + conc = self.extract_conclusion() + apps = self.extract_section("Приложения") + + q: List[str] = [] + + # Введение ↔ Заключение + if intro and conc: + q.append( + "Как цель и задачи, сформулированные во введении, отражены в итоговых выводах заключения?" + ) + + # Обзор предметной области + if overview: + q.append( + "Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи?" + ) + + # Постановка задачи + if objectives: + q.append( + "В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы?" + ) + + # Метод решения + if method: + q.append( + "Как архитектура и алгоритмы, описанные в разделе «Метод решения», обеспечивают достижение поставленных требований?" + ) + + # Исследования + if research: + q.append( + "Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения?" + ) + + # Приложения + if apps: + q.append( + "Какие дополнительные материалы из приложений необходимы для проверки воспроизводимости результатов?" + ) + + # Обязательные общие вопросы + q.extend([ + "Как практическая значимость работы следует из задач и результатов исследования?", + "Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели?", + ]) + + self.logger.info("Heuristic questions created: %d", len(q)) return q def generate_llm_questions(self, count: int = 5) -> List[str]: """Генерирует N вопросов через rut5 по ключевым фрагментам документа.""" q: List[str] = [] fragments = self.sentences[:40] - step = max(1, len(fragments) // count) - for i in range(0, len(fragments), step): - frag = fragments[i] - try: - llm_q = self.llm_generate_question(frag) - if len(llm_q) > 10: - q.append(llm_q) - except Exception: # noqa: BLE001 - continue + self.logger.info("LLM generation setup: count=%d fragments=%d step=%d", count, len(fragments), step) + + with timed(self.logger, "generate_llm_questions_total", count=count): + for i in range(0, len(fragments), step): + frag = fragments[i] + try: + # Требование: для ИИ — логгировать время каждого вопроса + with timed(self.logger, "llm_question_item", fragment_index=i): + llm_q = self.llm_generate_question(frag) + + if len(llm_q) > 10: + q.append(llm_q) + self.logger.info("LLM question accepted: idx=%d len=%d", len(q), len(llm_q)) + else: + self.logger.info("LLM question rejected (too short): len=%d", len(llm_q)) + + except Exception as e: # noqa: BLE001 + self.logger.exception("LLM generation failed at fragment_index=%d: %s", i, e) + continue - if len(q) >= count: - break + if len(q) >= count: + break + self.logger.info("LLM questions created: %d", len(q)) return q def generate_all(self) -> List[str]: """Генерирует полный набор вопросов: эвристики + LLM.""" - result: List[str] = [] - result.extend(self.heuristic_questions()) - result.extend(["--- rut5-base-multitask вопросы ---"]) - result.extend(self.generate_llm_questions(count=10)) - return list(dict.fromkeys(result)) + with timed(self.logger, "generate_all_total"): + result: List[str] = [] + # Требование: для эвристической генерации можно время создания всех вопросов + with timed(self.logger, "generate_heuristic_block"): + result.extend(self.heuristic_questions()) + + result.extend(["--- rut5-base-multitask вопросы ---"]) + + with timed(self.logger, "generate_llm_block"): + result.extend(self.generate_llm_questions(count=10)) + + deduped = list(dict.fromkeys(result)) + + self.logger.info("generate_all done: raw=%d deduped=%d", len(result), len(deduped)) + return deduped diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py index 36342d5..418d0af 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -1,6 +1,9 @@ import sys import os import argparse +import logging +import time +from contextlib import contextmanager from docx import Document import nltk @@ -9,17 +12,64 @@ from validator import VkrQuestionValidator +LOG_PATH = os.environ.get("VKR_LOG_PATH", "logs/vkr_question_generator.log") + + +def setup_logging() -> None: + os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) + + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + # чтобы не дублировать хендлеры при повторном запуске в том же процессе + if any(isinstance(h, logging.FileHandler) for h in logger.handlers): + return + + fmt = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + fh = logging.FileHandler(LOG_PATH, encoding="utf-8") + fh.setLevel(logging.INFO) + fh.setFormatter(fmt) + + sh = logging.StreamHandler(sys.stdout) + sh.setLevel(logging.INFO) + sh.setFormatter(fmt) + + logger.addHandler(fh) + logger.addHandler(sh) + + +@contextmanager +def timed(logger: logging.Logger, operation: str, level: int = logging.INFO, **extra): + start = time.perf_counter() + logger.log(level, "START %s %s", operation, (extra if extra else "")) + try: + yield + finally: + elapsed_ms = (time.perf_counter() - start) * 1000.0 + logger.log(level, "END %s | %.2f ms %s", operation, elapsed_ms, (extra if extra else "")) + + def load_vkr_text(path: str) -> str: + logger = logging.getLogger(__name__) + if not os.path.exists(path): + logger.error("Файл '%s' не найден.", path) print(f"[ERROR] Файл '{path}' не найден.") sys.exit(1) - document = Document(path) - text = [] - for paragraph in document.paragraphs: - text.append(paragraph.text) + with timed(logger, "parse_docx", path=path): + document = Document(path) + text = [] + for paragraph in document.paragraphs: + text.append(paragraph.text) + result = "\n".join(text) - return "\n".join(text) + logger.info("DOCX parsed: chars=%d, paragraphs=%d", len(result), len(document.paragraphs)) + return result def parse_args() -> argparse.Namespace: @@ -36,39 +86,79 @@ def parse_args() -> argparse.Namespace: def main(): + setup_logging() + logger = logging.getLogger(__name__) + args = parse_args() vkr_path = args.vkr_path - try: - nltk.data.find("tokenizers/punkt_tab/english") - except LookupError: - print("Загрузка необходимых данных NLTK...") - nltk.download("punkt_tab") + logger.info("=== RUN START === vkr_path=%s log_path=%s", vkr_path, LOG_PATH) + + with timed(logger, "nltk_check_download"): + try: + nltk.data.find("tokenizers/punkt_tab/english") + except LookupError: + logger.info("NLTK punkt_tab not found. Downloading...") + print("Загрузка необходимых данных NLTK...") + nltk.download("punkt_tab") print(f"=== Загрузка текста ВКР из '{vkr_path}' ===") text = load_vkr_text(vkr_path) print("=== Инициализация генератора ===") - gen = VkrQuestionGenerator(text, model_path="/app/question_generator/rut5-base") + with timed(logger, "init_generator"): + gen = VkrQuestionGenerator(text, model_path="/app/question_generator/rut5-base") print("=== Инициализация валидатора ===") - validator = VkrQuestionValidator(text) + with timed(logger, "init_validator"): + validator = VkrQuestionValidator(text) print("=== Генерация вопросов ===") - questions = gen.generate_all() + with timed(logger, "generate_all_questions"): + questions = gen.generate_all() - print("\n=== Результаты ===") - for q in questions: - rel = validator.check_relevance(q) - clr = validator.check_clarity(q) - diff = validator.check_difficulty(q) + logger.info("Questions generated: total=%d", len(questions)) - status = "✔ OK" if (int(rel) + int(clr) + int(diff) >= 2) else "✖ FAIL" # хотя бы 2 условия выполнены - - print(f"\n[{status}] {q}") - print(f" - relevance: {rel}") - print(f" - clarity: {clr}") - print(f" - difficulty:{diff}") + print("\n=== Результаты ===") + ok_count = 0 + fail_count = 0 + + with timed(logger, "validate_all_questions", total=len(questions)): + for idx, q in enumerate(questions, start=1): + # маркер-разделитель (ваш текстовый разделитель) + if q.strip().startswith("---"): + logger.info("Separator encountered at %d: %s", idx, q.strip()) + print(f"\n{q}") + continue + + with timed(logger, "validate_question", index=idx): + with timed(logger, "check_relevance", index=idx): + rel = validator.check_relevance(q) + with timed(logger, "check_clarity", index=idx): + clr = validator.check_clarity(q) + with timed(logger, "check_difficulty", index=idx): + diff = validator.check_difficulty(q) + + passed = (int(rel) + int(clr) + int(diff) >= 2) + status = "✔ OK" if passed else "✖ FAIL" + + if passed: + ok_count += 1 + else: + fail_count += 1 + + logger.info( + "Question %d status=%s rel=%s clr=%s diff=%s text=%r", + idx, ("OK" if passed else "FAIL"), rel, clr, diff, q + ) + + print(f"\n[{status}] {q}") + print(f" - relevance: {rel}") + print(f" - clarity: {clr}") + print(f" - difficulty:{diff}") + + logger.info("Validation summary: ok=%d fail=%d total=%d", ok_count, fail_count, len(questions)) + logger.info("=== RUN END ===") print("\n=== Готово ===") diff --git a/app/questions_generator/validator.py b/app/questions_generator/validator.py index c4b8900..463f177 100644 --- a/app/questions_generator/validator.py +++ b/app/questions_generator/validator.py @@ -1,13 +1,25 @@ import re +import logging +import time +from contextlib import contextmanager from typing import List, Dict, Set from collections import Counter -import nltk -from nltk.tokenize import sent_tokenize, word_tokenize +from nltk.tokenize import word_tokenize from nltk.corpus import stopwords -import string from datetime import datetime +@contextmanager +def timed(logger: logging.Logger, operation: str, level: int = logging.INFO, **extra): + start = time.perf_counter() + logger.log(level, "START %s %s", operation, (extra if extra else "")) + try: + yield + finally: + elapsed_ms = (time.perf_counter() - start) * 1000.0 + logger.log(level, "END %s | %.2f ms %s", operation, elapsed_ms, (extra if extra else "")) + + class VkrQuestionValidator: def __init__(self, vkr_text: str): """ @@ -16,9 +28,24 @@ def __init__(self, vkr_text: str): Args: vkr_text: Полный текст ВКР """ - self.vkr_text = vkr_text.lower() - self.stopwords = set(stopwords.words('russian')) - self.keywords = self._extract_keywords() + self.logger = logging.getLogger(__name__) + + with timed(self.logger, "validator_init"): + self.vkr_text = vkr_text.lower() + + with timed(self.logger, "validator_load_stopwords"): + self.stopwords = set(stopwords.words('russian')) + + with timed(self.logger, "validator_extract_keywords_total"): + self.keywords = self._extract_keywords() + + self.logger.info( + "Validator ready: stopwords=%d theme=%d goals=%d methodology=%d", + len(self.stopwords), + len(self.keywords.get("theme", set())), + len(self.keywords.get("goals", set())), + len(self.keywords.get("methodology", set())), + ) def _extract_keywords(self) -> Dict[str, Set[str]]: """ @@ -28,34 +55,35 @@ def _extract_keywords(self) -> Dict[str, Set[str]]: Словарь с категориями ключевых слов """ keywords = { - 'theme': set(), # Тематические слова - 'goals': set(), # Слова, связанные с целями + 'theme': set(), # Тематические слова + 'goals': set(), # Слова, связанные с целями 'methodology': set() # Методологические термины } - # Извлечение ключевых слов из введения - intro_section = self._extract_introduction() - keywords['theme'] = self._tokenize_and_filter(intro_section) + with timed(self.logger, "extract_introduction"): + intro_section = self._extract_introduction() + with timed(self.logger, "tokenize_filter_intro", intro_len=len(intro_section)): + keywords['theme'] = self._tokenize_and_filter(intro_section) - # Извлечение целей из соответствующего раздела - goals_section = self._extract_goals_section() - keywords['goals'] = self._tokenize_and_filter(goals_section) + with timed(self.logger, "extract_goals_section"): + goals_section = self._extract_goals_section() + with timed(self.logger, "tokenize_filter_goals", goals_len=len(goals_section)): + keywords['goals'] = self._tokenize_and_filter(goals_section) - # Извлечение методологических терминов - meth_section = self._extract_methodology_section() - keywords['methodology'] = self._tokenize_and_filter(meth_section) + with timed(self.logger, "extract_methodology_section"): + meth_section = self._extract_methodology_section() + with timed(self.logger, "tokenize_filter_methodology", meth_len=len(meth_section)): + keywords['methodology'] = self._tokenize_and_filter(meth_section) + self.logger.info( + "Keywords extracted: theme=%d goals=%d methodology=%d", + len(keywords["theme"]), len(keywords["goals"]), len(keywords["methodology"]) + ) return keywords def _tokenize_and_filter(self, text: str) -> Set[str]: """ Токенизация и фильтрация текста для получения ключевых слов - - Args: - text: Исходный текст для обработки - - Returns: - Множество отфильтрованных токенов """ tokens = word_tokenize(text.lower()) filtered_tokens = [ @@ -67,110 +95,61 @@ def _tokenize_and_filter(self, text: str) -> Set[str]: return set(filtered_tokens) def _extract_introduction(self) -> str: - """ - Извлечение введения из текста ВКР - - Returns: - Текст введения - """ 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: - """ - Извлечение раздела с целями и задачами - - Returns: - Текст раздела с целями - """ 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: - """ - Извлечение методологического раздела - - Returns: - Текст методологического раздела - """ 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 timed(self.logger, "validator_check_relevance", q_len=len(question)): + score = 0 - Args: - question: Проверяемый вопрос + theme_match = len(set(question.lower().split()) & + set(self.keywords['theme'])) + if theme_match > 0: + score += 1 - Returns: - True если вопрос релевантен, False если нет - """ - score = 0 + actuality_score = self._calculate_actuality_score(question) + score += actuality_score - # Проверка соответствия теме - theme_match = len(set(question.lower().split()) & - set(self.keywords['theme'])) - if theme_match > 0: - score += 1 + goal_match = len(set(question.lower().split()) & + set(self.keywords['goals'])) + if goal_match > 0: + score += 1 - # Проверка актуальности - actuality_score = self._calculate_actuality_score(question) - score += actuality_score + result = score >= 2 - # Проверка связи с целями - goal_match = len(set(question.lower().split()) & - set(self.keywords['goals'])) - if goal_match > 0: - score += 1 - - return score >= 2 + self.logger.info("relevance=%s score=%d q=%r", result, score, question) + return result def _calculate_actuality_score(self, question: str) -> int: - """ - Расчёт актуальности вопроса - - Args: - question: Анализируемый вопрос - - Returns: - Оценка актуальности (0 или 1) - """ 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 timed(self.logger, "validator_check_completeness", total=len(questions_list)): + coverage = { + 'theoretical': self._check_theory_coverage(questions_list), + 'practical': self._check_practice_coverage(questions_list), + 'analysis_levels': self._check_analysis_depth(questions_list) + } + result = all(value >= 0.7 for value in coverage.values()) - Args: - questions_list: Список проверяемых вопросов - - Returns: - True если набор полный, False если нет - """ - coverage = { - 'theoretical': self._check_theory_coverage(questions_list), - 'practical': self._check_practice_coverage(questions_list), - 'analysis_levels': self._check_analysis_depth(questions_list) - } - return all(value >= 0.7 for value in coverage.values()) + self.logger.info("completeness=%s coverage=%s", result, coverage) + return result def _check_theory_coverage(self, questions: List[str]) -> float: - """ - Проверка теоретического охвата вопросами - - Args: - questions: Список вопросов для анализа - - Returns: - Значение от 0 до 1, показывающее степень покрытия - """ theoretical_terms = {'теория', 'модель', 'концепция', 'принцип'} total_questions = len(questions) theory_questions = sum( @@ -180,15 +159,6 @@ def _check_theory_coverage(self, questions: List[str]) -> float: return theory_questions / total_questions if total_questions > 0 else 0 def _check_practice_coverage(self, questions: List[str]) -> float: - """ - Проверка практического охвата вопросами - - Args: - questions: Список вопросов для анализа - - Returns: - Значение от 0 до 1, показывающее степень покрытия - """ practical_terms = {'применение', 'реализация', 'использование', 'результаты'} total_questions = len(questions) practice_questions = sum( @@ -198,15 +168,6 @@ def _check_practice_coverage(self, questions: List[str]) -> float: return practice_questions / total_questions if total_questions > 0 else 0 def _check_analysis_depth(self, questions: List[str]) -> float: - """ - Проверка глубины анализа в вопросах - - Args: - questions: Список вопросов для анализа - - Returns: - Значение от 0 до 1, показывающее глубину анализа - """ depth_indicators = { 'поверхностный': {'что', 'какой'}, 'средний': {'почему', 'как'}, @@ -228,34 +189,19 @@ def _check_analysis_depth(self, questions: List[str]) -> float: return sum(depths) / (len(depths) * 2) if depths else 0 def check_clarity(self, question: str) -> bool: - """ - Проверка ясности формулировки вопроса - - Args: - question: Проверяемый вопрос + with timed(self.logger, "validator_check_clarity", q_len=len(question)): + metrics = { + 'length': self._check_length(question), + 'complexity': self._calculate_complexity(question), + 'ambiguity': self._check_ambiguity(question) + } + result = all(value >= 0.7 for value in metrics.values()) - Returns: - True если формулировка ясная, False если нет - """ - metrics = { - 'length': self._check_length(question), - 'complexity': self._calculate_complexity(question), - 'ambiguity': self._check_ambiguity(question) - } - return all(value >= 0.7 for value in metrics.values()) + self.logger.info("clarity=%s metrics=%s q=%r", result, metrics, question) + return result def _check_length(self, question: str) -> float: - """ - Проверка длины вопроса - - Args: - question: Проверяемый вопрос - - Returns: - Нормализованное значение от 0 до 1 - """ words = len(question.split()) - # Оптимальная длина вопроса считается 7-15 слов if words < 7: return 0.5 * (words / 7) elif words > 15: @@ -263,68 +209,34 @@ def _check_length(self, question: str) -> float: return 1.0 def _calculate_complexity(self, question: str) -> float: - """ - Оценка сложности вопроса - - Args: - question: Анализируемый вопрос - - Returns: - Значение от 0 до 1, показывающее сложность - """ words = question.split() unique_words = set(words) return min(1.0, len(unique_words) / len(words)) def _check_ambiguity(self, question: str) -> float: - """ - Проверка наличия двусмысленностей в вопросе - - Args: - question: Проверяемый вопрос - - Returns: - Значение от 0 до 1, где 1 - нет двусмысленностей - """ 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 timed(self.logger, "validator_check_difficulty", q_len=len(question)): + difficulty_metrics = { + 'abstraction_level': self._assess_abstraction(question), + 'question_type': self._identify_question_type(question), + 'student_level_match': self._match_student_level(question) + } + result = all(value == 'optimal' for value in difficulty_metrics.values()) - Args: - question: Проверяемый вопрос - - Returns: - True если уровень сложности оптимальный, False если нет - """ - difficulty_metrics = { - 'abstraction_level': self._assess_abstraction(question), - 'question_type': self._identify_question_type(question), - 'student_level_match': self._match_student_level(question) - } - return all(value == 'optimal' for value in difficulty_metrics.values()) + self.logger.info("difficulty=%s metrics=%s q=%r", result, difficulty_metrics, question) + return result def _assess_abstraction(self, question: str) -> str: - """ - Оценка уровня абстракции вопроса - - Args: - question: Анализируемый вопрос - - Returns: - 'optimal', 'too_high', 'too_low' - """ abstract_terms = { 'концепция', 'модель', 'теория', 'абстракция', 'парадигма', 'методология' @@ -346,15 +258,6 @@ def _assess_abstraction(self, question: str) -> str: return 'optimal' def _identify_question_type(self, question: str) -> str: - """ - Определение типа вопроса - - Args: - question: Анализируемый вопрос - - Returns: - 'optimal', 'too_simple', 'too_complex' - """ question_types = { 'descriptive': {'описать', 'рассказать', 'характеризовать'}, 'analytical': {'анализировать', 'сравнить', 'оценить'}, @@ -375,15 +278,6 @@ def _identify_question_type(self, question: str) -> str: return 'too_complex' def _match_student_level(self, question: str) -> str: - """ - Проверка соответствия вопроса уровню студента - - Args: - question: Анализируемый вопрос - - Returns: - 'optimal', 'too_hard', 'too_easy' - """ advanced_terms = { 'методология', 'парадигма', 'теоретическая модель', 'эмпирический анализ', 'статистическая обработка' From 0b28da7b4612b2dde78079bcb5413b5e3d5d8554 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:20:46 +0300 Subject: [PATCH 12/24] logging update --- app/questions_generator/README.md | 2 +- app/questions_generator/run.py | 97 ++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 35 deletions(-) diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md index 34bf44e..b07802c 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -2,7 +2,7 @@ `docker-compose up` - ВАЖНО: Первый раз ОЧЕНЬ ДОЛГО билдится (30-40 минут)!!! ## Использование (интерактивное) -`docker compose exec app python run.py /app/vkr_examples/VKR1.docx` - папка `vkr_examples` локальная, лежит рядом с композом +`docker compose exec app python run.py /app/vkr_examples/VKR1.docx --no-overflow-logs` - папка `vkr_examples` локальная, лежит рядом с композом ## Пример сгенерированных вопросов по тексту ВКР diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py index 418d0af..d52d054 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -3,7 +3,7 @@ import argparse import logging import time -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from docx import Document import nltk @@ -53,6 +53,27 @@ def timed(logger: logging.Logger, operation: str, level: int = logging.INFO, **e logger.log(level, "END %s | %.2f ms %s", operation, elapsed_ms, (extra if extra else "")) +@contextmanager +def suppress_console_logs(): + """ + Временно отключает вывод логов в консоль (StreamHandler на stdout/stderr), + при этом FileHandler продолжает писать в файл. + """ + root = logging.getLogger() + saved_levels = [] + + for h in root.handlers: + if isinstance(h, logging.StreamHandler) and getattr(h, "stream", None) in (sys.stdout, sys.stderr): + saved_levels.append((h, h.level)) + h.setLevel(logging.CRITICAL + 1) # выше CRITICAL, чтобы ничего не проходило + + try: + yield + finally: + for h, lvl in saved_levels: + h.setLevel(lvl) + + def load_vkr_text(path: str) -> str: logger = logging.getLogger(__name__) @@ -82,6 +103,11 @@ def parse_args() -> argparse.Namespace: default="vkr_examples/VKR1.docx", help="Путь к .docx файлу с текстом ВКР (по умолчанию: vkr_examples/VKR1.docx)", ) + parser.add_argument( + "--no-overflow-logs", + action="store_true", + help="Отключить вывод логов в консоль во время печати вопросов/результатов (логи в файл сохраняются).", + ) return parser.parse_args() @@ -123,39 +149,42 @@ def main(): ok_count = 0 fail_count = 0 - with timed(logger, "validate_all_questions", total=len(questions)): - for idx, q in enumerate(questions, start=1): - # маркер-разделитель (ваш текстовый разделитель) - if q.strip().startswith("---"): - logger.info("Separator encountered at %d: %s", idx, q.strip()) - print(f"\n{q}") - continue - - with timed(logger, "validate_question", index=idx): - with timed(logger, "check_relevance", index=idx): - rel = validator.check_relevance(q) - with timed(logger, "check_clarity", index=idx): - clr = validator.check_clarity(q) - with timed(logger, "check_difficulty", index=idx): - diff = validator.check_difficulty(q) - - passed = (int(rel) + int(clr) + int(diff) >= 2) - status = "✔ OK" if passed else "✖ FAIL" - - if passed: - ok_count += 1 - else: - fail_count += 1 - - logger.info( - "Question %d status=%s rel=%s clr=%s diff=%s text=%r", - idx, ("OK" if passed else "FAIL"), rel, clr, diff, q - ) - - print(f"\n[{status}] {q}") - print(f" - relevance: {rel}") - print(f" - clarity: {clr}") - print(f" - difficulty:{diff}") + quiet_ctx = suppress_console_logs() if args.no_overflow_logs else nullcontext() + + with quiet_ctx: + with timed(logger, "validate_all_questions", total=len(questions)): + for idx, q in enumerate(questions, start=1): + # маркер-разделитель (ваш текстовый разделитель) + if q.strip().startswith("---"): + logger.info("Separator encountered at %d: %s", idx, q.strip()) + print(f"\n{q}") + continue + + with timed(logger, "validate_question", index=idx): + with timed(logger, "check_relevance", index=idx): + rel = validator.check_relevance(q) + with timed(logger, "check_clarity", index=idx): + clr = validator.check_clarity(q) + with timed(logger, "check_difficulty", index=idx): + diff = validator.check_difficulty(q) + + passed = (int(rel) + int(clr) + int(diff) >= 2) + status = "✔ OK" if passed else "✖ FAIL" + + if passed: + ok_count += 1 + else: + fail_count += 1 + + logger.info( + "Question %d status=%s rel=%s clr=%s diff=%s text=%r", + idx, ("OK" if passed else "FAIL"), rel, clr, diff, q + ) + + print(f"\n[{status}] {q}") + print(f" - relevance: {rel}") + print(f" - clarity: {clr}") + print(f" - difficulty:{diff}") logger.info("Validation summary: ok=%d fail=%d total=%d", ok_count, fail_count, len(questions)) logger.info("=== RUN END ===") From 39f7626ba0ec971890225f39749133f5a263d812 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Mon, 5 Jan 2026 23:54:53 +0300 Subject: [PATCH 13/24] docker fix (builds aprox 40 mins) --- app/questions_generator/Dockerfile | 23 +++++-------------- app/questions_generator/docker-compose.yml | 12 ++++------ .../docker/init-volumes.sh | 23 +------------------ app/questions_generator/requirements.txt | 1 + app/questions_generator/run.py | 1 + 5 files changed, 14 insertions(+), 46 deletions(-) diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile index 3c53a21..a7b8201 100644 --- a/app/questions_generator/Dockerfile +++ b/app/questions_generator/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim AS base +FROM python:3.10-slim RUN apt-get update && apt-get install -y --no-install-recommends \ git wget gcc g++ \ @@ -7,26 +7,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -COPY requirements.txt . - -# можно (и полезно) задать глобально: ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_DEFAULT_TIMEOUT=120 -RUN pip install --no-cache-dir torch==2.5.1 -RUN pip install --no-cache-dir -r requirements.txt -RUN pip install --no-cache-dir "huggingface_hub[cli]" - -ENV NLTK_DATA=/nltk_data +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + "huggingface_hub[cli]" COPY . . -# ====== runtime image ====== -FROM base AS app -CMD ["bash", "-lc", "sleep infinity"] +COPY --chmod=755 docker/init-volumes.sh /usr/local/bin/init-volumes.sh -# ====== init image ====== -FROM base AS init -COPY docker/init-volumes.sh /usr/local/bin/init-volumes.sh -RUN chmod +x /usr/local/bin/init-volumes.sh -ENTRYPOINT ["/usr/local/bin/init-volumes.sh"] +CMD ["bash"] diff --git a/app/questions_generator/docker-compose.yml b/app/questions_generator/docker-compose.yml index 27f353b..276d88d 100644 --- a/app/questions_generator/docker-compose.yml +++ b/app/questions_generator/docker-compose.yml @@ -1,17 +1,14 @@ services: init: - build: - context: . - target: 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: - context: . - target: app + build: . depends_on: init: condition: service_completed_successfully @@ -20,7 +17,8 @@ services: volumes: - rut5_model:/app/question_generator/rut5-base - nltk_data:/nltk_data - - ./vkr_examples:/app/vkr_examples # монтируется для интерактивного запуска с файлами из этой папки (папка рядом с композом) + - ./vkr_examples:/app/vkr_examples + command: ["bash", "-lc", "sleep infinity"] volumes: rut5_model: diff --git a/app/questions_generator/docker/init-volumes.sh b/app/questions_generator/docker/init-volumes.sh index 2cf47f8..e528d44 100644 --- a/app/questions_generator/docker/init-volumes.sh +++ b/app/questions_generator/docker/init-volumes.sh @@ -2,14 +2,11 @@ set -e MODEL_DIR="/app/question_generator/rut5-base" -NLTK_DIR="${NLTK_DATA:-/nltk_data}" echo "MODEL_DIR=${MODEL_DIR}" -echo "NLTK_DIR=${NLTK_DIR}" -mkdir -p "$MODEL_DIR" "$NLTK_DIR" +mkdir -p "$MODEL_DIR" -# 1) ruT5 model (один раз) if [ -z "$(ls -A "$MODEL_DIR" 2>/dev/null)" ]; then echo "Model directory is empty. Downloading model to $MODEL_DIR..." huggingface-cli download \ @@ -20,21 +17,3 @@ if [ -z "$(ls -A "$MODEL_DIR" 2>/dev/null)" ]; then else echo "Model directory is not empty, skipping download." fi - -# 2) NLTK data (один раз) -if [ -z "$(ls -A "$NLTK_DIR" 2>/dev/null)" ]; then - echo "NLTK data directory is empty. Downloading 'punkt' and 'stopwords' to $NLTK_DIR..." - python - <<'PY' -import os -import nltk - -nltk_dir = os.environ.get("NLTK_DATA", "/nltk_data") -nltk.data.path = [nltk_dir] + nltk.data.path - -nltk.download("punkt", download_dir=nltk_dir) -nltk.download("stopwords", download_dir=nltk_dir) -PY - echo "NLTK data downloaded." -else - echo "NLTK data directory is not empty, skipping download." -fi diff --git a/app/questions_generator/requirements.txt b/app/questions_generator/requirements.txt index 191cdc8..0c59711 100644 --- a/app/questions_generator/requirements.txt +++ b/app/questions_generator/requirements.txt @@ -3,3 +3,4 @@ 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 index d52d054..e0a5b83 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -127,6 +127,7 @@ def main(): logger.info("NLTK punkt_tab not found. Downloading...") print("Загрузка необходимых данных NLTK...") nltk.download("punkt_tab") + nltk.download("stopwords") print(f"=== Загрузка текста ВКР из '{vkr_path}' ===") text = load_vkr_text(vkr_path) From bee9a7a3178ccc0153180ab5c0e0b7eba22e3c85 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:39:24 +0300 Subject: [PATCH 14/24] fixed heuristic questions generation --- app/questions_generator/generator.py | 90 ++++++++----------- .../heuristic_questions.csv | 8 ++ 2 files changed, 44 insertions(+), 54 deletions(-) create mode 100644 app/questions_generator/heuristic_questions.csv diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 386c5d3..c4fae49 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -3,6 +3,8 @@ import time from contextlib import contextmanager from typing import List, Dict +import csv +from pathlib import Path from nltk.tokenize import sent_tokenize, word_tokenize from nltk.corpus import stopwords @@ -33,7 +35,11 @@ class VkrQuestionGenerator: "Приложения": r"Приложения.*?(?=\n[A-ZА-Я][^\n]*\n)", } - def __init__(self, vkr_text: str, model_path: str = "ai-forever/rut5-base-multitask"): + def __init__(self, + vkr_text: str, + model_path: str = "ai-forever/rut5-base-multitask", + heuristic_csv_path: str = "heuristic_questions.csv"): + self.logger = logging.getLogger(__name__) with timed(self.logger, "generator_init"): @@ -52,15 +58,27 @@ def __init__(self, vkr_text: str, model_path: str = "ai-forever/rut5-base-multit with timed(self.logger, "load_model", model_path=model_path): self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) + with timed(self.logger, "load_heuristic_questions", path=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="|") + for row in reader: + self.heuristic_templates.append(row) + self.logger.info( "Generator ready: sentences=%d stopwords=%d model_path=%s", len(self.sentences), len(self.stopwords), model_path ) def extract_section(self, title: str) -> str: - """Извлекает раздел по шаблону заголовка.""" - pattern = self.SECTION_PATTERNS.get(title, rf"{title}.*?(?=\n[A-ZА-Я][^\n]*\n)") - m = re.search(pattern, self.vkr_text, re.DOTALL | re.IGNORECASE) + """Извлекает раздел по заголовку (устойчиво к нумерации и регистру).""" + pattern = rf""" + (?im) + ^\s*(\d+(\.\d+)*\.?\s*)?{re.escape(title)}\s*$ + (.*?) + (?=^\s*(\d+(\.\d+)*\.?\s*[А-ЯA-Z]|$\Z)) + """ + m = re.search(pattern, self.vkr_text, re.DOTALL | re.VERBOSE) return m.group(0) if m else "" def extract_intro(self) -> str: @@ -95,59 +113,23 @@ def llm_generate_question(self, text_fragment: str) -> str: return decoded def heuristic_questions(self) -> List[str]: - """Эвристики, завязанные на структуру ВКР.""" + """Эвристики, завязанные на структуру ВКР (загружаются из CSV).""" with timed(self.logger, "heuristic_questions_total"): - intro = self.extract_intro() - overview = self.extract_section("Обзор предметной области") - objectives = self.extract_section("Постановка задачи") - method = self.extract_section("Метод решения") - research = self.extract_section("Исследования") - conc = self.extract_conclusion() - apps = self.extract_section("Приложения") - q: List[str] = [] - # Введение ↔ Заключение - if intro and conc: - q.append( - "Как цель и задачи, сформулированные во введении, отражены в итоговых выводах заключения?" - ) - - # Обзор предметной области - if overview: - q.append( - "Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи?" - ) - - # Постановка задачи - if objectives: - q.append( - "В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы?" - ) - - # Метод решения - if method: - q.append( - "Как архитектура и алгоритмы, описанные в разделе «Метод решения», обеспечивают достижение поставленных требований?" - ) - - # Исследования - if research: - q.append( - "Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения?" - ) - - # Приложения - if apps: - q.append( - "Какие дополнительные материалы из приложений необходимы для проверки воспроизводимости результатов?" - ) - - # Обязательные общие вопросы - q.extend([ - "Как практическая значимость работы следует из задач и результатов исследования?", - "Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели?", - ]) + for item in self.heuristic_templates: + sections = item["section"] + question = item["question"] + + # пустой sections == обязательный общий вопрос + if not sections: + q.append(question) + continue + # for x in sections.split(','): + # a = self.extract_section(x) + # self.logger.info(x, a, question, sections) + if all([self.extract_section(x) for x in sections.split(",")]): # если нет всех нужных секций для вопроса, то не добавляем его + q.append(question) self.logger.info("Heuristic questions created: %d", len(q)) return q diff --git a/app/questions_generator/heuristic_questions.csv b/app/questions_generator/heuristic_questions.csv new file mode 100644 index 0000000..ad180a0 --- /dev/null +++ b/app/questions_generator/heuristic_questions.csv @@ -0,0 +1,8 @@ +section|question +Введение,Заключение|Как цель и задачи, сформулированные во введении, отражены в итоговых выводах заключения? +Обзор предметной области|Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи? +Постановка задачи|В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы? +Метод решения|Как архитектура и алгоритмы, описанные в разделе «Метод решения», обеспечивают достижение поставленных требований? +Исследования|Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения? +|Как практическая значимость работы следует из задач и результатов исследования? +|Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели? From a16784bae5e984fb25a104852eb66c1cb287a2c5 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Sat, 10 Jan 2026 17:58:29 +0300 Subject: [PATCH 15/24] clearing --- app/questions_generator/Dockerfile | 2 +- app/questions_generator/README.md | 17 ++-------------- app/questions_generator/generator.py | 20 +------------------ .../{docker => }/init-volumes.sh | 0 4 files changed, 4 insertions(+), 35 deletions(-) rename app/questions_generator/{docker => }/init-volumes.sh (100%) diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile index a7b8201..8a21aeb 100644 --- a/app/questions_generator/Dockerfile +++ b/app/questions_generator/Dockerfile @@ -16,6 +16,6 @@ RUN pip install --no-cache-dir -r requirements.txt \ COPY . . -COPY --chmod=755 docker/init-volumes.sh /usr/local/bin/init-volumes.sh +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 index b07802c..cfb3767 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -1,5 +1,5 @@ ## Запуск (контейнер вечно крутится) -`docker-compose up` - ВАЖНО: Первый раз ОЧЕНЬ ДОЛГО билдится (30-40 минут)!!! +`docker-compose up` - ВАЖНО: Первый раз ОЧЕНЬ ДОЛГО билдится (30-40 минут) ## Использование (интерактивное) `docker compose exec app python run.py /app/vkr_examples/VKR1.docx --no-overflow-logs` - папка `vkr_examples` локальная, лежит рядом с композом @@ -21,16 +21,6 @@ - clarity: True - difficulty:False -[✖ FAIL] Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения? - - relevance: True - - clarity: False - - difficulty:False - -[✔ OK] Какие дополнительные материалы из приложений необходимы для проверки воспроизводимости результатов? - - relevance: True - - clarity: True - - difficulty:False - [✔ OK] Как практическая значимость работы следует из задач и результатов исследования? - relevance: True - clarity: True @@ -41,10 +31,7 @@ - clarity: True - difficulty:False -[✖ FAIL] --- rut5-base-multitask вопросы --- - - relevance: False - - clarity: False - - difficulty:False +--- rut5-base-multitask вопросы --- [✖ FAIL] Что такое ЛЭТИ? - relevance: False diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index c4fae49..cc85bf8 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -25,16 +25,6 @@ def timed(logger: logging.Logger, operation: str, level: int = logging.INFO, **e class VkrQuestionGenerator: """Гибридный генератор вопросов по ВКР: NLTK + rut5-base-multitask.""" - SECTION_PATTERNS: Dict[str, str] = { - "Введение": r"Введение.*?(?=\n[A-ZА-Я][^\n]*\n)", - "Обзор предметной области": r"Обзор предметной области.*?(?=\n[A-ZА-Я][^\n]*\n)", - "Постановка задачи": r"Постановка задачи.*?(?=\n[A-ZА-Я][^\n]*\n)", - "Метод решения": r"Метод решения.*?(?=\n[A-ZА-Я][^\n]*\n)", - "Исследования": r"Исследования.*?(?=\n[A-ZА-Я][^\n]*\n)", - "Заключение": r"Заключение.*?(?=\n[A-ZА-Я][^\n]*\n)", - "Приложения": r"Приложения.*?(?=\n[A-ZА-Я][^\n]*\n)", - } - def __init__(self, vkr_text: str, model_path: str = "ai-forever/rut5-base-multitask", @@ -81,12 +71,6 @@ def extract_section(self, title: str) -> str: m = re.search(pattern, self.vkr_text, re.DOTALL | re.VERBOSE) return m.group(0) if m else "" - def extract_intro(self) -> str: - return self.extract_section("Введение") - - def extract_conclusion(self) -> str: - return self.extract_section("Заключение") - def extract_keywords(self, text: str) -> List[str]: """Извлекает ключевые слова из текста.""" with timed(self.logger, "extract_keywords", text_len=len(text)): @@ -125,9 +109,7 @@ def heuristic_questions(self) -> List[str]: if not sections: q.append(question) continue - # for x in sections.split(','): - # a = self.extract_section(x) - # self.logger.info(x, a, question, sections) + if all([self.extract_section(x) for x in sections.split(",")]): # если нет всех нужных секций для вопроса, то не добавляем его q.append(question) diff --git a/app/questions_generator/docker/init-volumes.sh b/app/questions_generator/init-volumes.sh similarity index 100% rename from app/questions_generator/docker/init-volumes.sh rename to app/questions_generator/init-volumes.sh From c2df6e4c74c60301a9d281fc6d40a83070928675 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:08:08 +0300 Subject: [PATCH 16/24] created static folder --- .gitignore | 2 +- app/questions_generator/README.md | 2 +- app/questions_generator/docker-compose.yml | 2 +- app/questions_generator/generator.py | 4 ++-- app/questions_generator/run.py | 2 +- app/questions_generator/run_docker.py | 2 +- app/questions_generator/{ => static}/heuristic_questions.csv | 0 7 files changed, 7 insertions(+), 7 deletions(-) rename app/questions_generator/{ => static}/heuristic_questions.csv (100%) diff --git a/.gitignore b/.gitignore index 06067be..d15ce74 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,5 @@ __pycache__ /VERSION.json .env /whisper_asr_model_cache -/app/questions_generator/vkr_examples/ +/app/questions_generator/static/vkr_examples/ /app/questions_generator/rut5-base/ diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md index cfb3767..ad5d787 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -2,7 +2,7 @@ `docker-compose up` - ВАЖНО: Первый раз ОЧЕНЬ ДОЛГО билдится (30-40 минут) ## Использование (интерактивное) -`docker compose exec app python run.py /app/vkr_examples/VKR1.docx --no-overflow-logs` - папка `vkr_examples` локальная, лежит рядом с композом +`docker compose exec app python run.py /app/static/vkr_examples/VKR1.docx --no-overflow-logs` - папка `vkr_examples` локальная ## Пример сгенерированных вопросов по тексту ВКР diff --git a/app/questions_generator/docker-compose.yml b/app/questions_generator/docker-compose.yml index 276d88d..3705ce4 100644 --- a/app/questions_generator/docker-compose.yml +++ b/app/questions_generator/docker-compose.yml @@ -17,7 +17,7 @@ services: volumes: - rut5_model:/app/question_generator/rut5-base - nltk_data:/nltk_data - - ./vkr_examples:/app/vkr_examples + - ./static/vkr_examples:/app/static/vkr_examples command: ["bash", "-lc", "sleep infinity"] volumes: diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index cc85bf8..336495f 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -27,8 +27,8 @@ class VkrQuestionGenerator: def __init__(self, vkr_text: str, - model_path: str = "ai-forever/rut5-base-multitask", - heuristic_csv_path: str = "heuristic_questions.csv"): + model_path: str = "/app/question_generator/rut5-base", + heuristic_csv_path: str = "static/heuristic_questions.csv"): self.logger = logging.getLogger(__name__) diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py index e0a5b83..3228750 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -134,7 +134,7 @@ def main(): print("=== Инициализация генератора ===") with timed(logger, "init_generator"): - gen = VkrQuestionGenerator(text, model_path="/app/question_generator/rut5-base") + gen = VkrQuestionGenerator(text, model_path="/app/question_generator/rut5-base", heuristic_csv_path="static/heuristic_questions.csv") print("=== Инициализация валидатора ===") with timed(logger, "init_validator"): diff --git a/app/questions_generator/run_docker.py b/app/questions_generator/run_docker.py index 30c986e..1b066be 100644 --- a/app/questions_generator/run_docker.py +++ b/app/questions_generator/run_docker.py @@ -21,7 +21,7 @@ def main(): sys.exit(1) # Путь внутри контейнера — фиксированный, один и тот же для всех ОС - container_path = "/app/questions_generator/vkr_examples/vkr.docx" + container_path = "/app/questions_generator/static/vkr_examples/vkr.docx" cmd = [ "docker", "run", "-it", "--rm", diff --git a/app/questions_generator/heuristic_questions.csv b/app/questions_generator/static/heuristic_questions.csv similarity index 100% rename from app/questions_generator/heuristic_questions.csv rename to app/questions_generator/static/heuristic_questions.csv From 666535d860aa8a69f850ae7422bd8cbb28f29c5c Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:50:54 +0300 Subject: [PATCH 17/24] full logs refactor and translation to russian --- app/questions_generator/generator.py | 170 ++++++++++-------- app/questions_generator/init-volumes.sh | 6 +- app/questions_generator/logging_utils.py | 82 +++++++++ app/questions_generator/run.py | 216 +++++++---------------- app/questions_generator/run_docker.py | 35 ++-- app/questions_generator/validator.py | 162 ++++++++--------- 6 files changed, 338 insertions(+), 333 deletions(-) create mode 100644 app/questions_generator/logging_utils.py diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 336495f..3c9fb7a 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -1,91 +1,90 @@ import re import logging -import time -from contextlib import contextmanager -from typing import List, Dict 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 - -@contextmanager -def timed(logger: logging.Logger, operation: str, level: int = logging.INFO, **extra): - start = time.perf_counter() - logger.log(level, "START %s %s", operation, (extra if extra else "")) - try: - yield - finally: - elapsed_ms = (time.perf_counter() - start) * 1000.0 - logger.log(level, "END %s | %.2f ms %s", operation, elapsed_ms, (extra if extra else "")) +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"): - + 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 timed(self.logger, "generator_init"): + with log_timed(self.logger, "инициализация генератора"): self.vkr_text = vkr_text - with timed(self.logger, "sent_tokenize"): + with log_timed(self.logger, "токенизация предложений"): self.sentences = sent_tokenize(vkr_text) - with timed(self.logger, "load_stopwords"): + with log_timed(self.logger, "загрузка стоп-слов"): self.stopwords = set(stopwords.words("russian")) - # Модель rut5-base-multitask для языкового оформления вопросов - with timed(self.logger, "load_tokenizer", model_path=model_path): - self.tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False) + with log_timed(self.logger, "загрузка токенизатора", путь=model_path): + self.tokenizer = AutoTokenizer.from_pretrained( + model_path, use_fast=False + ) - with timed(self.logger, "load_model", model_path=model_path): + with log_timed(self.logger, "загрузка модели", путь=model_path): self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) - with timed(self.logger, "load_heuristic_questions", path=heuristic_csv_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="|") - for row in reader: - self.heuristic_templates.append(row) + self.heuristic_templates.extend(reader) self.logger.info( - "Generator ready: sentences=%d stopwords=%d model_path=%s", - len(self.sentences), len(self.stopwords), model_path + "Генератор готов: предложений=%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)) """ - m = re.search(pattern, self.vkr_text, re.DOTALL | re.VERBOSE) - return m.group(0) if m else "" + 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 timed(self.logger, "extract_keywords", text_len=len(text)): + 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("Keywords extracted: %d", len(result)) + + self.logger.info("Ключевые слова извлечены: %d", len(result)) return result def llm_generate_question(self, text_fragment: str) -> str: - """Генерирует формулировку вопроса через rut5 ask.""" prompt = f"ask: {text_fragment}" - with timed(self.logger, "llm_generate_question", fragment_len=len(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, @@ -94,74 +93,99 @@ def llm_generate_question(self, text_fragment: str) -> str: early_stopping=True, ) decoded = self.tokenizer.decode(out[0], skip_special_tokens=True) + return decoded def heuristic_questions(self) -> List[str]: - """Эвристики, завязанные на структуру ВКР (загружаются из CSV).""" - with timed(self.logger, "heuristic_questions_total"): - q: List[str] = [] + with log_timed(self.logger, "эвристическая генерация вопросов"): + questions: List[str] = [] for item in self.heuristic_templates: sections = item["section"] question = item["question"] - # пустой sections == обязательный общий вопрос if not sections: - q.append(question) + questions.append(question) continue - if all([self.extract_section(x) for x in sections.split(",")]): # если нет всех нужных секций для вопроса, то не добавляем его - q.append(question) + if all(self.extract_section(x) for x in sections.split(",")): + questions.append(question) - self.logger.info("Heuristic questions created: %d", len(q)) - return q + self.logger.info( + "Эвристические вопросы сформированы: %d", + len(questions), + ) + return questions def generate_llm_questions(self, count: int = 5) -> List[str]: - """Генерирует N вопросов через rut5 по ключевым фрагментам документа.""" - q: List[str] = [] + questions: List[str] = [] fragments = self.sentences[:40] step = max(1, len(fragments) // count) - self.logger.info("LLM generation setup: count=%d fragments=%d step=%d", count, len(fragments), step) + self.logger.info( + "Настройка LLM: требуется=%d фрагментов=%d шаг=%d", + count, + len(fragments), + step, + ) - with timed(self.logger, "generate_llm_questions_total", count=count): + with log_timed(self.logger, "LLM генерация всех вопросов", количество=count): for i in range(0, len(fragments), step): - frag = fragments[i] + fragment = fragments[i] try: - # Требование: для ИИ — логгировать время каждого вопроса - with timed(self.logger, "llm_question_item", fragment_index=i): - llm_q = self.llm_generate_question(frag) + with log_timed( + self.logger, + "LLM вопрос", + индекс=i, + ): + llm_q = self.llm_generate_question(fragment) if len(llm_q) > 10: - q.append(llm_q) - self.logger.info("LLM question accepted: idx=%d len=%d", len(q), len(llm_q)) + questions.append(llm_q) + self.logger.info( + "LLM вопрос принят: номер=%d длина=%d", + len(questions), + len(llm_q), + ) else: - self.logger.info("LLM question rejected (too short): len=%d", len(llm_q)) - - except Exception as e: # noqa: BLE001 - self.logger.exception("LLM generation failed at fragment_index=%d: %s", i, e) - continue - - if len(q) >= count: + 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 questions created: %d", len(q)) - return q + self.logger.info( + "LLM вопросы сформированы: %d", + len(questions), + ) + return questions def generate_all(self) -> List[str]: - """Генерирует полный набор вопросов: эвристики + LLM.""" - with timed(self.logger, "generate_all_total"): + with log_timed(self.logger, "полная генерация вопросов"): result: List[str] = [] - # Требование: для эвристической генерации можно время создания всех вопросов - with timed(self.logger, "generate_heuristic_block"): + + with log_timed(self.logger, "эвристический блок"): result.extend(self.heuristic_questions()) - result.extend(["--- rut5-base-multitask вопросы ---"]) + result.append("--- rut5-base-multitask вопросы ---") - with timed(self.logger, "generate_llm_block"): + with log_timed(self.logger, "LLM блок"): result.extend(self.generate_llm_questions(count=10)) deduped = list(dict.fromkeys(result)) - self.logger.info("generate_all done: raw=%d deduped=%d", len(result), len(deduped)) + 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 index e528d44..cd348fd 100644 --- a/app/questions_generator/init-volumes.sh +++ b/app/questions_generator/init-volumes.sh @@ -8,12 +8,12 @@ echo "MODEL_DIR=${MODEL_DIR}" mkdir -p "$MODEL_DIR" if [ -z "$(ls -A "$MODEL_DIR" 2>/dev/null)" ]; then - echo "Model directory is empty. Downloading model to $MODEL_DIR..." + echo "Не видно модельки rut5-base, грузим в папку $MODEL_DIR..." huggingface-cli download \ cointegrated/rut5-base-multitask \ --local-dir "$MODEL_DIR" \ --local-dir-use-symlinks False - echo "Model downloaded." + echo "Загрузили" else - echo "Model directory is not empty, skipping download." + 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/run.py b/app/questions_generator/run.py index 3228750..67e047c 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -1,196 +1,100 @@ -import sys -import os import argparse import logging -import time -from contextlib import contextmanager, nullcontext +import os +import sys +from contextlib import nullcontext -from docx import Document import nltk +from docx import Document from generator import VkrQuestionGenerator from validator import VkrQuestionValidator - - -LOG_PATH = os.environ.get("VKR_LOG_PATH", "logs/vkr_question_generator.log") - - -def setup_logging() -> None: - os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) - - logger = logging.getLogger() - logger.setLevel(logging.INFO) - - # чтобы не дублировать хендлеры при повторном запуске в том же процессе - if any(isinstance(h, logging.FileHandler) for h in logger.handlers): - return - - fmt = logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - fh = logging.FileHandler(LOG_PATH, encoding="utf-8") - fh.setLevel(logging.INFO) - fh.setFormatter(fmt) - - sh = logging.StreamHandler(sys.stdout) - sh.setLevel(logging.INFO) - sh.setFormatter(fmt) - - logger.addHandler(fh) - logger.addHandler(sh) - - -@contextmanager -def timed(logger: logging.Logger, operation: str, level: int = logging.INFO, **extra): - start = time.perf_counter() - logger.log(level, "START %s %s", operation, (extra if extra else "")) - try: - yield - finally: - elapsed_ms = (time.perf_counter() - start) * 1000.0 - logger.log(level, "END %s | %.2f ms %s", operation, elapsed_ms, (extra if extra else "")) - - -@contextmanager -def suppress_console_logs(): - """ - Временно отключает вывод логов в консоль (StreamHandler на stdout/stderr), - при этом FileHandler продолжает писать в файл. - """ - root = logging.getLogger() - saved_levels = [] - - for h in root.handlers: - if isinstance(h, logging.StreamHandler) and getattr(h, "stream", None) in (sys.stdout, sys.stderr): - saved_levels.append((h, h.level)) - h.setLevel(logging.CRITICAL + 1) # выше CRITICAL, чтобы ничего не проходило - - try: - yield - finally: - for h, lvl in saved_levels: - h.setLevel(lvl) +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) - print(f"[ERROR] Файл '{path}' не найден.") + logger.error("Файл не найден: %s", path) sys.exit(1) - with timed(logger, "parse_docx", path=path): - document = Document(path) - text = [] - for paragraph in document.paragraphs: - text.append(paragraph.text) - result = "\n".join(text) - - logger.info("DOCX parsed: chars=%d, paragraphs=%d", len(result), len(document.paragraphs)) - return result - + with log_timed(logger, "чтение DOCX", путь=path): + doc = Document(path) + text = "\n".join(p.text for p in doc.paragraphs) -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Генерация экзаменационных вопросов по тексту ВКР" + logger.info( + "DOCX обработан: символов=%d абзацев=%d", + len(text), + len(doc.paragraphs), ) - parser.add_argument( - "vkr_path", - nargs="?", - default="vkr_examples/VKR1.docx", - help="Путь к .docx файлу с текстом ВКР (по умолчанию: vkr_examples/VKR1.docx)", - ) - parser.add_argument( - "--no-overflow-logs", - action="store_true", - help="Отключить вывод логов в консоль во время печати вопросов/результатов (логи в файл сохраняются).", - ) - return parser.parse_args() + return text def main(): setup_logging() logger = logging.getLogger(__name__) - args = parse_args() - vkr_path = args.vkr_path + parser = argparse.ArgumentParser() + parser.add_argument("vkr_path") + parser.add_argument("--no-overflow-logs", action="store_true") + args = parser.parse_args() - logger.info("=== RUN START === vkr_path=%s log_path=%s", vkr_path, LOG_PATH) + logger.info("Запуск генерации: файл=%s", args.vkr_path) - with timed(logger, "nltk_check_download"): + with log_timed(logger, "проверка NLTK"): try: nltk.data.find("tokenizers/punkt_tab/english") except LookupError: - logger.info("NLTK punkt_tab not found. Downloading...") - print("Загрузка необходимых данных NLTK...") + logger.info("Загрузка данных NLTK") nltk.download("punkt_tab") nltk.download("stopwords") - print(f"=== Загрузка текста ВКР из '{vkr_path}' ===") - text = load_vkr_text(vkr_path) + text = load_vkr_text(args.vkr_path) - print("=== Инициализация генератора ===") - with timed(logger, "init_generator"): - gen = VkrQuestionGenerator(text, model_path="/app/question_generator/rut5-base", heuristic_csv_path="static/heuristic_questions.csv") + with log_timed(logger, "инициализация генератора"): + gen = VkrQuestionGenerator(text) - print("=== Инициализация валидатора ===") - with timed(logger, "init_validator"): + with log_timed(logger, "инициализация валидатора"): validator = VkrQuestionValidator(text) - print("=== Генерация вопросов ===") - with timed(logger, "generate_all_questions"): + with log_timed(logger, "генерация вопросов"): questions = gen.generate_all() - logger.info("Questions generated: total=%d", len(questions)) - - print("\n=== Результаты ===") - ok_count = 0 - fail_count = 0 - - quiet_ctx = suppress_console_logs() if args.no_overflow_logs else nullcontext() - - with quiet_ctx: - with timed(logger, "validate_all_questions", total=len(questions)): - for idx, q in enumerate(questions, start=1): - # маркер-разделитель (ваш текстовый разделитель) - if q.strip().startswith("---"): - logger.info("Separator encountered at %d: %s", idx, q.strip()) - print(f"\n{q}") - continue - - with timed(logger, "validate_question", index=idx): - with timed(logger, "check_relevance", index=idx): - rel = validator.check_relevance(q) - with timed(logger, "check_clarity", index=idx): - clr = validator.check_clarity(q) - with timed(logger, "check_difficulty", index=idx): - diff = validator.check_difficulty(q) - - passed = (int(rel) + int(clr) + int(diff) >= 2) - status = "✔ OK" if passed else "✖ FAIL" - - if passed: - ok_count += 1 - else: - fail_count += 1 - - logger.info( - "Question %d status=%s rel=%s clr=%s diff=%s text=%r", - idx, ("OK" if passed else "FAIL"), rel, clr, diff, q - ) - - print(f"\n[{status}] {q}") - print(f" - relevance: {rel}") - print(f" - clarity: {clr}") - print(f" - difficulty:{diff}") - - logger.info("Validation summary: ok=%d fail=%d total=%d", ok_count, fail_count, len(questions)) - logger.info("=== RUN END ===") - - print("\n=== Готово ===") + 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__": diff --git a/app/questions_generator/run_docker.py b/app/questions_generator/run_docker.py index 1b066be..ca79ab5 100644 --- a/app/questions_generator/run_docker.py +++ b/app/questions_generator/run_docker.py @@ -1,26 +1,26 @@ -import os -import sys import argparse +import logging +import os import subprocess +import sys + +from logging_utils import setup_logging def main(): - parser = argparse.ArgumentParser( - description="Запуск генератора вопросов по ВКР внутри Docker" - ) - parser.add_argument( - "vkr_path", - help="Путь к .docx файлу с текстом ВКР (на хосте)", - ) + 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): - print(f"[ERROR] Файл не найден: {host_path}") + logger.error("Файл не найден: %s", host_path) sys.exit(1) - # Путь внутри контейнера — фиксированный, один и тот же для всех ОС container_path = "/app/questions_generator/static/vkr_examples/vkr.docx" cmd = [ @@ -32,13 +32,16 @@ def main(): "python", "run.py", container_path, ] - print(">> Запускаю команду:") - print(" ".join(cmd)) + logger.info("Запуск Docker команды: %s", " ".join(cmd)) + try: subprocess.run(cmd, check=True) - except subprocess.CalledProcessError as e: - print(f"[ERROR] docker run завершился с ошибкой: {e.returncode}") - sys.exit(e.returncode) + except subprocess.CalledProcessError as exc: + logger.exception( + "Docker завершился с ошибкой, код=%d", + exc.returncode, + ) + sys.exit(exc.returncode) if __name__ == "__main__": diff --git a/app/questions_generator/validator.py b/app/questions_generator/validator.py index 463f177..1f60359 100644 --- a/app/questions_generator/validator.py +++ b/app/questions_generator/validator.py @@ -1,83 +1,63 @@ import re import logging -import time -from contextlib import contextmanager -from typing import List, Dict, Set +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 datetime import datetime - -@contextmanager -def timed(logger: logging.Logger, operation: str, level: int = logging.INFO, **extra): - start = time.perf_counter() - logger.log(level, "START %s %s", operation, (extra if extra else "")) - try: - yield - finally: - elapsed_ms = (time.perf_counter() - start) * 1000.0 - logger.log(level, "END %s | %.2f ms %s", operation, elapsed_ms, (extra if extra else "")) +from logging_utils import log_timed class VkrQuestionValidator: def __init__(self, vkr_text: str): - """ - Инициализация валидатора с текстом ВКР - - Args: - vkr_text: Полный текст ВКР - """ self.logger = logging.getLogger(__name__) - with timed(self.logger, "validator_init"): + with log_timed(self.logger, "инициализация валидатора"): self.vkr_text = vkr_text.lower() - with timed(self.logger, "validator_load_stopwords"): - self.stopwords = set(stopwords.words('russian')) + with log_timed(self.logger, "загрузка стоп-слов"): + self.stopwords = set(stopwords.words("russian")) - with timed(self.logger, "validator_extract_keywords_total"): + with log_timed(self.logger, "извлечение ключевых слов"): self.keywords = self._extract_keywords() self.logger.info( - "Validator ready: stopwords=%d theme=%d goals=%d methodology=%d", + "Валидатор готов: стоп-слов=%d тема=%d цели=%d методология=%d", len(self.stopwords), - len(self.keywords.get("theme", set())), - len(self.keywords.get("goals", set())), - len(self.keywords.get("methodology", set())), + len(self.keywords["theme"]), + len(self.keywords["goals"]), + len(self.keywords["methodology"]), ) def _extract_keywords(self) -> Dict[str, Set[str]]: - """ - Извлечение ключевых слов из текста ВКР - - Returns: - Словарь с категориями ключевых слов - """ keywords = { - 'theme': set(), # Тематические слова - 'goals': set(), # Слова, связанные с целями - 'methodology': set() # Методологические термины + "theme": set(), + "goals": set(), + "methodology": set(), } - with timed(self.logger, "extract_introduction"): - intro_section = self._extract_introduction() - with timed(self.logger, "tokenize_filter_intro", intro_len=len(intro_section)): - keywords['theme'] = self._tokenize_and_filter(intro_section) + with log_timed(self.logger, "извлечение введения"): + intro = self._extract_introduction() + with log_timed(self.logger, "токенизация введения"): + keywords["theme"] = self._tokenize_and_filter(intro) - with timed(self.logger, "extract_goals_section"): - goals_section = self._extract_goals_section() - with timed(self.logger, "tokenize_filter_goals", goals_len=len(goals_section)): - keywords['goals'] = self._tokenize_and_filter(goals_section) + with log_timed(self.logger, "извлечение целей"): + goals = self._extract_goals_section() + with log_timed(self.logger, "токенизация целей"): + keywords["goals"] = self._tokenize_and_filter(goals) - with timed(self.logger, "extract_methodology_section"): - meth_section = self._extract_methodology_section() - with timed(self.logger, "tokenize_filter_methodology", meth_len=len(meth_section)): - keywords['methodology'] = self._tokenize_and_filter(meth_section) + 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( - "Keywords extracted: theme=%d goals=%d methodology=%d", - len(keywords["theme"]), len(keywords["goals"]), len(keywords["methodology"]) + "Ключевые слова извлечены: тема=%d цели=%d методология=%d", + len(keywords["theme"]), + len(keywords["goals"]), + len(keywords["methodology"]), ) return keywords @@ -110,25 +90,19 @@ def _extract_methodology_section(self) -> str: return match.group(0) if match else "" def check_relevance(self, question: str) -> bool: - with timed(self.logger, "validator_check_relevance", q_len=len(question)): + with log_timed(self.logger, "проверка релевантности", длина=len(question)): score = 0 - - theme_match = len(set(question.lower().split()) & - set(self.keywords['theme'])) - if theme_match > 0: - score += 1 - - actuality_score = self._calculate_actuality_score(question) - score += actuality_score - - goal_match = len(set(question.lower().split()) & - set(self.keywords['goals'])) - if goal_match > 0: - score += 1 - + 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("relevance=%s score=%d q=%r", result, score, question) + self.logger.info( + "Релевантность=%s балл=%d вопрос=%r", + result, + score, + question, + ) return result def _calculate_actuality_score(self, question: str) -> int: @@ -138,15 +112,23 @@ def _calculate_actuality_score(self, question: str) -> int: return max(0, min(1, len(year_mentions))) def check_completeness(self, questions_list: List[str]) -> bool: - with timed(self.logger, "validator_check_completeness", total=len(questions_list)): + with log_timed( + self.logger, + "проверка полноты набора вопросов", + всего=len(questions_list), + ): coverage = { - 'theoretical': self._check_theory_coverage(questions_list), - 'practical': self._check_practice_coverage(questions_list), - 'analysis_levels': self._check_analysis_depth(questions_list) + "теория": 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("completeness=%s coverage=%s", result, coverage) + self.logger.info( + "Полнота набора вопросов=%s покрытие=%s", + result, + coverage, + ) return result def _check_theory_coverage(self, questions: List[str]) -> float: @@ -189,15 +171,20 @@ def _check_analysis_depth(self, questions: List[str]) -> float: return sum(depths) / (len(depths) * 2) if depths else 0 def check_clarity(self, question: str) -> bool: - with timed(self.logger, "validator_check_clarity", q_len=len(question)): + with log_timed(self.logger, "проверка ясности", длина=len(question)): metrics = { - 'length': self._check_length(question), - 'complexity': self._calculate_complexity(question), - 'ambiguity': self._check_ambiguity(question) + "length": self._check_length(question), + "complexity": self._calculate_complexity(question), + "ambiguity": self._check_ambiguity(question), } - result = all(value >= 0.7 for value in metrics.values()) + result = all(v >= 0.7 for v in metrics.values()) - self.logger.info("clarity=%s metrics=%s q=%r", result, metrics, question) + self.logger.info( + "Ясность=%s метрики=%s вопрос=%r", + result, + metrics, + question, + ) return result def _check_length(self, question: str) -> float: @@ -225,15 +212,20 @@ def _check_ambiguity(self, question: str) -> float: return max(0.0, ambiguity_score) def check_difficulty(self, question: str) -> bool: - with timed(self.logger, "validator_check_difficulty", q_len=len(question)): - difficulty_metrics = { - 'abstraction_level': self._assess_abstraction(question), - 'question_type': self._identify_question_type(question), - 'student_level_match': self._match_student_level(question) + 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(value == 'optimal' for value in difficulty_metrics.values()) + result = all(v == "optimal" for v in metrics.values()) - self.logger.info("difficulty=%s metrics=%s q=%r", result, difficulty_metrics, question) + self.logger.info( + "Сложность=%s метрики=%s вопрос=%r", + result, + metrics, + question, + ) return result def _assess_abstraction(self, question: str) -> str: From e92b6ac518e85eae38b84a04796d5645425cfa29 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Thu, 15 Jan 2026 01:42:32 +0300 Subject: [PATCH 18/24] stashed new question generator code for future updates --- app/questions_generator/generator.py | 221 +++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 3c9fb7a..59ffd18 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -189,3 +189,224 @@ def generate_all(self) -> List[str]: len(deduped), ) return deduped + + +""" +import re +import logging +import csv +from pathlib import Path +from typing import List, Dict, Iterable + +from nltk.tokenize import sent_tokenize +from transformers import AutoTokenizer, AutoModelForSeq2SeqLM + +from logging_utils import log_timed + + +class VkrQuestionGenerator: + Гибридный генератор вопросов по ВКР: NLTK + rut5-base-multitask. + + SECTION_RE = re.compile( + r"(?im)^\s*(\d+(\.\d+)*\.?\s+)?([А-Я][А-ЯA-Z\s]{3,})\s*$" + ) + + 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 + self.sentences = sent_tokenize(vkr_text) + + self.tokenizer = AutoTokenizer.from_pretrained( + model_path, use_fast=False + ) + self.model = AutoModelForSeq2SeqLM.from_pretrained(model_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("Генератор готов") + + def _split_into_sections(self) -> List[tuple[str, str]]: + Разбивает текст ВКР на логические разделы. + Возвращает список (section_title, section_text). + matches = list(SECTION_RE.finditer(self.vkr_text)) + + if not matches: + return [("ОСНОВНОЙ ТЕКСТ", self.vkr_text)] + + sections = [] + + for i, match in enumerate(matches): + start = match.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(self.vkr_text) + + title = match.group(0).strip() + body = self.vkr_text[start:end].strip() + + if body: + sections.append((title, body)) + + return sections + + def _chunk_section( + self, + section_text: str, + max_tokens: int, + ) -> List[str]: + Делит текст раздела на чанки по лимиту токенов модели. + sentences = sent_tokenize(section_text) + + chunks = [] + current = [] + current_len = 0 + + for sent in sentences: + sent_len = len(self.tokenizer.tokenize(sent)) + + if current_len + sent_len > max_tokens and current: + chunks.append(" ".join(current)) + current = [] + current_len = 0 + + current.append(sent) + current_len += sent_len + + if current: + chunks.append(" ".join(current)) + + return chunks + + def _sections_from_introduction( + self, + sections: List[tuple[str, str]], + ) -> List[tuple[str, str]]: + Оставляет только разделы начиная с 'ВВЕДЕНИЕ' (включая его). + Если введение не найдено — возвращает исходный список. + result = [] + found_intro = False + + for title, text in sections: + normalized = title.upper() + + if "ВВЕДЕНИЕ" in normalized: + found_intro = True + + if found_intro: + result.append((title, text)) + + if not result: + self.logger.warning( + "Раздел 'ВВЕДЕНИЕ' не найден — используется весь текст" + ) + return sections + + return result + + def llm_generate_question(self, text_fragment: str, section: str) -> str: + prompt = ( + f"Раздел: {section}\n" + "Сформулируй содержательный экзаменационный вопрос по следующему тексту:\n\n" + f"{text_fragment}" + ) + + with log_timed(self.logger, "LLM генерация вопроса"): + inputs = self.tokenizer(prompt, return_tensors="pt", truncation=False) + + output = self.model.generate( + **inputs, + max_length=96, + num_beams=5, + early_stopping=True, + no_repeat_ngram_size=3, + ) + + question = self.tokenizer.decode( + output[0], skip_special_tokens=True + ).strip() + + return question + + def generate_llm_questions(self, count: int = 10) -> List[str]: + questions: List[str] = [] + seen: set[str] = set() + + max_tokens = self.tokenizer.model_max_length - 32 + sections = self._split_into_sections() + sections = self._sections_from_introduction(sections) + + self.logger.info( + "LLM генерация: разделов=%d требуется=%d", + len(sections), + count, + ) + + with log_timed(self.logger, "LLM генерация всех вопросов"): + for section_title, section_text in sections: + if len(questions) >= count: + break + + chunks = self._chunk_section(section_text, max_tokens) + + for chunk in chunks: + if len(questions) >= count: + break + + try: + q = self.llm_generate_question(chunk, section_title) + + if ( + len(q) < 15 + or not q.endswith("?") + or q.lower() in seen + ): + continue + + questions.append(q) + seen.add(q.lower()) + + except Exception as exc: # noqa: BLE001 + self.logger.exception( + "Ошибка LLM генерации: section=%s error=%s", + section_title, + exc, + ) + + return questions + + def heuristic_questions(self) -> List[str]: + questions: List[str] = [] + + for item in self.heuristic_templates: + sections = item["section"] + question = item["question"] + + if not sections: + questions.append(question) + continue + + if all(re.search(s, self.vkr_text, re.I) for s in sections.split(",")): + questions.append(question) + + return questions + + def generate_all(self) -> List[str]: + with log_timed(self.logger, "полная генерация вопросов"): + result: List[str] = [] + + result.extend(self.heuristic_questions()) + result.append("--- rut5-base-multitask вопросы ---") + result.extend(self.generate_llm_questions(count=10)) + + deduped = list(dict.fromkeys(result)) + + return deduped +""" From e7e72da4edadf4cdcf74d8d40f3ae8c6f028037e Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Thu, 15 Jan 2026 04:41:32 +0300 Subject: [PATCH 19/24] docker update - llm & stuff and code separated --- app/questions_generator/Dockerfile | 5 +- app/questions_generator/docker-compose.yml | 21 ++++--- app/questions_generator/generator.py | 61 +++++++++---------- .../llm_service/Dockerfile | 20 ++++++ app/questions_generator/llm_service/app.py | 32 ++++++++++ .../{ => llm_service}/init-volumes.sh | 2 +- app/questions_generator/requirements.txt | 5 +- app/questions_generator/run.py | 14 +---- 8 files changed, 101 insertions(+), 59 deletions(-) create mode 100644 app/questions_generator/llm_service/Dockerfile create mode 100644 app/questions_generator/llm_service/app.py rename app/questions_generator/{ => llm_service}/init-volumes.sh (91%) diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile index 8a21aeb..50b0596 100644 --- a/app/questions_generator/Dockerfile +++ b/app/questions_generator/Dockerfile @@ -11,11 +11,10 @@ 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]" +RUN pip install --no-cache-dir -r requirements.txt COPY . . -COPY --chmod=755 init-volumes.sh /usr/local/bin/init-volumes.sh +# COPY --chmod=755 init-volumes.sh /usr/local/bin/init-volumes.sh CMD ["bash"] diff --git a/app/questions_generator/docker-compose.yml b/app/questions_generator/docker-compose.yml index 3705ce4..ccca326 100644 --- a/app/questions_generator/docker-compose.yml +++ b/app/questions_generator/docker-compose.yml @@ -1,21 +1,26 @@ services: - init: - build: . + init-llm: + build: ./llm_service entrypoint: ["/usr/local/bin/init-volumes.sh"] volumes: - - rut5_model:/app/question_generator/rut5-base - - nltk_data:/nltk_data + - rut5_model:/models/rut5 restart: "no" - app: - build: . + llm: + build: ./llm_service depends_on: - init: + init-llm: condition: service_completed_successfully + volumes: + - rut5_model:/models/rut5 + ports: + - "8000:8000" + + app: + build: . 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"] diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 59ffd18..36afaa9 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -6,7 +6,8 @@ from nltk.tokenize import sent_tokenize, word_tokenize from nltk.corpus import stopwords -from transformers import AutoTokenizer, AutoModelForSeq2SeqLM +# from transformers import AutoTokenizer, AutoModelForSeq2SeqLM +import requests from logging_utils import log_timed @@ -17,13 +18,15 @@ class VkrQuestionGenerator: def __init__( self, vkr_text: str, - model_path: str = "/app/question_generator/rut5-base", + # model_path: str = "/app/question_generator/rut5-base", heuristic_csv_path: str = "static/heuristic_questions.csv", + llm_url: str = "http://llm:8000" ): self.logger = logging.getLogger(__name__) with log_timed(self.logger, "инициализация генератора"): self.vkr_text = vkr_text + self.llm_url = llm_url with log_timed(self.logger, "токенизация предложений"): self.sentences = sent_tokenize(vkr_text) @@ -31,13 +34,13 @@ def __init__( 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, "загрузка токенизатора", путь=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, @@ -49,12 +52,12 @@ def __init__( reader = csv.DictReader(f, delimiter="|") self.heuristic_templates.extend(reader) - self.logger.info( - "Генератор готов: предложений=%d стоп-слов=%d модель=%s", - len(self.sentences), - len(self.stopwords), - model_path, - ) + # self.logger.info( + # "Генератор готов: предложений=%d стоп-слов=%d модель=%s", + # len(self.sentences), + # len(self.stopwords), + # model_path, + # ) def extract_section(self, title: str) -> str: pattern = rf""" @@ -80,21 +83,17 @@ def extract_keywords(self, text: str) -> List[str]: 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 + resp = requests.post( + f"{self.llm_url}/generate", + json={ + "prompt": prompt, + "max_length": 96, + "num_beams": 5, + }, + timeout=60, + ) + resp.raise_for_status() + return resp.json()["text"].strip() def heuristic_questions(self) -> List[str]: with log_timed(self.logger, "эвристическая генерация вопросов"): @@ -179,7 +178,7 @@ def generate_all(self) -> List[str]: result.append("--- rut5-base-multitask вопросы ---") with log_timed(self.logger, "LLM блок"): - result.extend(self.generate_llm_questions(count=10)) + result.extend(self.generate_llm_questions(count=5)) deduped = list(dict.fromkeys(result)) diff --git a/app/questions_generator/llm_service/Dockerfile b/app/questions_generator/llm_service/Dockerfile new file mode 100644 index 0000000..887c008 --- /dev/null +++ b/app/questions_generator/llm_service/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.10-slim + +RUN pip install --no-cache-dir \ + torch==2.5.1 \ + transformers \ + sentencepiece \ + fastapi \ + uvicorn \ + huggingface_hub[cli] + +WORKDIR /app + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_DEFAULT_TIMEOUT=120 + +COPY app.py . +COPY init-volumes.sh /usr/local/bin/init-volumes.sh +RUN chmod +x /usr/local/bin/init-volumes.sh + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/questions_generator/llm_service/app.py b/app/questions_generator/llm_service/app.py new file mode 100644 index 0000000..6ef9906 --- /dev/null +++ b/app/questions_generator/llm_service/app.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI +from pydantic import BaseModel +from transformers import AutoTokenizer, AutoModelForSeq2SeqLM +import torch + +app = FastAPI() + +MODEL_PATH = "/models/rut5" +tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, use_fast=False) +model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_PATH) +model.eval() + + +class GenerateRequest(BaseModel): + prompt: str + max_length: int = 96 + num_beams: int = 5 + + +@app.post("/generate") +def generate(req: GenerateRequest): + inputs = tokenizer(req.prompt, return_tensors="pt") + with torch.no_grad(): + output = model.generate( + **inputs, + max_length=req.max_length, + num_beams=req.num_beams, + early_stopping=True, + no_repeat_ngram_size=3, + ) + text = tokenizer.decode(output[0], skip_special_tokens=True) + return {"text": text} diff --git a/app/questions_generator/init-volumes.sh b/app/questions_generator/llm_service/init-volumes.sh similarity index 91% rename from app/questions_generator/init-volumes.sh rename to app/questions_generator/llm_service/init-volumes.sh index cd348fd..21b662e 100644 --- a/app/questions_generator/init-volumes.sh +++ b/app/questions_generator/llm_service/init-volumes.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e -MODEL_DIR="/app/question_generator/rut5-base" +MODEL_DIR="/models/rut5" echo "MODEL_DIR=${MODEL_DIR}" diff --git a/app/questions_generator/requirements.txt b/app/questions_generator/requirements.txt index 0c59711..0e8c108 100644 --- a/app/questions_generator/requirements.txt +++ b/app/questions_generator/requirements.txt @@ -1,6 +1,3 @@ -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 +requests==2.32.3 diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py index 67e047c..e553f85 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -6,19 +6,13 @@ 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, -) +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) @@ -27,11 +21,7 @@ def load_vkr_text(path: str) -> str: doc = Document(path) text = "\n".join(p.text for p in doc.paragraphs) - logger.info( - "DOCX обработан: символов=%d абзацев=%d", - len(text), - len(doc.paragraphs), - ) + logger.info("DOCX обработан: символов=%d абзацев=%d", len(text), len(doc.paragraphs)) return text From 21b960bead5a6cdffad103bbf02035cc44c73759 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Thu, 15 Jan 2026 05:20:13 +0300 Subject: [PATCH 20/24] global question generator refactor --- app/questions_generator/generator.py | 308 +++++---------------- app/questions_generator/llm_service/app.py | 15 + 2 files changed, 80 insertions(+), 243 deletions(-) diff --git a/app/questions_generator/generator.py b/app/questions_generator/generator.py index 36afaa9..a05be45 100644 --- a/app/questions_generator/generator.py +++ b/app/questions_generator/generator.py @@ -2,209 +2,16 @@ 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 -import requests - -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", - llm_url: str = "http://llm:8000" - ): - self.logger = logging.getLogger(__name__) - - with log_timed(self.logger, "инициализация генератора"): - self.vkr_text = vkr_text - self.llm_url = llm_url - - 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}" - - resp = requests.post( - f"{self.llm_url}/generate", - json={ - "prompt": prompt, - "max_length": 96, - "num_beams": 5, - }, - timeout=60, - ) - resp.raise_for_status() - return resp.json()["text"].strip() - - 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=5)) - - deduped = list(dict.fromkeys(result)) - - self.logger.info( - "Генерация завершена: всего=%d уникальных=%d", - len(result), - len(deduped), - ) - return deduped - - -""" -import re -import logging -import csv -from pathlib import Path from typing import List, Dict, Iterable from nltk.tokenize import sent_tokenize -from transformers import AutoTokenizer, AutoModelForSeq2SeqLM +import requests from logging_utils import log_timed class VkrQuestionGenerator: - Гибридный генератор вопросов по ВКР: NLTK + rut5-base-multitask. + """Гибридный генератор вопросов по ВКР: NLTK + rut5-base-multitask.""" SECTION_RE = re.compile( r"(?im)^\s*(\d+(\.\d+)*\.?\s+)?([А-Я][А-ЯA-Z\s]{3,})\s*$" @@ -213,19 +20,15 @@ class VkrQuestionGenerator: def __init__( self, vkr_text: str, - model_path: str = "/app/question_generator/rut5-base", heuristic_csv_path: str = "static/heuristic_questions.csv", + llm_url: str = "http://llm:8000" ): self.logger = logging.getLogger(__name__) with log_timed(self.logger, "инициализация генератора"): self.vkr_text = vkr_text self.sentences = sent_tokenize(vkr_text) - - self.tokenizer = AutoTokenizer.from_pretrained( - model_path, use_fast=False - ) - self.model = AutoModelForSeq2SeqLM.from_pretrained(model_path) + self.llm_url = llm_url self.heuristic_templates: List[Dict[str, str]] = [] with Path(heuristic_csv_path).open(encoding="utf-8") as f: @@ -235,9 +38,9 @@ def __init__( self.logger.info("Генератор готов") def _split_into_sections(self) -> List[tuple[str, str]]: - Разбивает текст ВКР на логические разделы. - Возвращает список (section_title, section_text). - matches = list(SECTION_RE.finditer(self.vkr_text)) + """Разбивает текст ВКР на логические разделы. + Возвращает список (section_title, section_text).""" + matches = list(VkrQuestionGenerator.SECTION_RE.finditer(self.vkr_text)) if not matches: return [("ОСНОВНОЙ ТЕКСТ", self.vkr_text)] @@ -261,7 +64,7 @@ def _chunk_section( section_text: str, max_tokens: int, ) -> List[str]: - Делит текст раздела на чанки по лимиту токенов модели. + """Делит текст раздела на чанки по лимиту токенов модели.""" sentences = sent_tokenize(section_text) chunks = [] @@ -269,7 +72,14 @@ def _chunk_section( current_len = 0 for sent in sentences: - sent_len = len(self.tokenizer.tokenize(sent)) + resp = requests.post( + f"{self.llm_url}/tokenize", + json={"text": sent}, + timeout=10 + ) + resp.raise_for_status() + data = resp.json() + sent_len = data["length"] if current_len + sent_len > max_tokens and current: chunks.append(" ".join(current)) @@ -288,8 +98,8 @@ def _sections_from_introduction( self, sections: List[tuple[str, str]], ) -> List[tuple[str, str]]: - Оставляет только разделы начиная с 'ВВЕДЕНИЕ' (включая его). - Если введение не найдено — возвращает исходный список. + """Оставляет только разделы начиная с 'ВВЕДЕНИЕ' (включая его). + Если введение не найдено — возвращает исходный список.""" result = [] found_intro = False @@ -310,35 +120,30 @@ def _sections_from_introduction( return result - def llm_generate_question(self, text_fragment: str, section: str) -> str: - prompt = ( - f"Раздел: {section}\n" - "Сформулируй содержательный экзаменационный вопрос по следующему тексту:\n\n" - f"{text_fragment}" - ) - - with log_timed(self.logger, "LLM генерация вопроса"): - inputs = self.tokenizer(prompt, return_tensors="pt", truncation=False) - - output = self.model.generate( - **inputs, - max_length=96, - num_beams=5, - early_stopping=True, - no_repeat_ngram_size=3, - ) - - question = self.tokenizer.decode( - output[0], skip_special_tokens=True - ).strip() + def llm_generate_question(self, text_fragment: str) -> str: + prompt = f"ask: {text_fragment}" - return question + resp = requests.post( + f"{self.llm_url}/generate", + json={ + "prompt": prompt, + "max_length": 96, + "num_beams": 5, + }, + timeout=60, + ) + resp.raise_for_status() + return resp.json()["text"].strip() def generate_llm_questions(self, count: int = 10) -> List[str]: questions: List[str] = [] seen: set[str] = set() - max_tokens = self.tokenizer.model_max_length - 32 + resp = requests.get(f"{self.llm_url}/max_tokens", timeout=5) + resp.raise_for_status() + data = resp.json() + max_tokens = data["max_tokens"] - 32 # резервируем 32 токена для safety + sections = self._split_into_sections() sections = self._sections_from_introduction(sections) @@ -348,37 +153,55 @@ def generate_llm_questions(self, count: int = 10) -> List[str]: count, ) - with log_timed(self.logger, "LLM генерация всех вопросов"): - for section_title, section_text in sections: + with log_timed(self.logger, "LLM генерация всех вопросов", количество=count): + for section_index, (section_title, section_text) in enumerate(sections): if len(questions) >= count: break chunks = self._chunk_section(section_text, max_tokens) - for chunk in chunks: + for chunk_index, chunk in enumerate(chunks): if len(questions) >= count: break try: - q = self.llm_generate_question(chunk, section_title) - - if ( - len(q) < 15 - or not q.endswith("?") - or q.lower() in seen + with log_timed( + self.logger, + "LLM вопрос", + индекс=f"{section_index}.{chunk_index}", ): + q = self.llm_generate_question(chunk) + + if len(q) < 15 or not q.endswith("?") or q.lower() in seen: + self.logger.info( + "LLM вопрос отклонён: section='%s', chunk_index=%d, длина=%d", + section_title, + chunk_index, + len(q), + ) continue questions.append(q) seen.add(q.lower()) + self.logger.info( + "LLM вопрос принят: section='%s', номер=%d, длина=%d", + section_title, + len(questions), + len(q), + ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: self.logger.exception( - "Ошибка LLM генерации: section=%s error=%s", + "Ошибка LLM генерации: section='%s', chunk_index=%d, error=%s", section_title, + chunk_index, exc, ) + self.logger.info( + "LLM вопросы сформированы: всего=%d", + len(questions), + ) return questions def heuristic_questions(self) -> List[str]: @@ -408,4 +231,3 @@ def generate_all(self) -> List[str]: deduped = list(dict.fromkeys(result)) return deduped -""" diff --git a/app/questions_generator/llm_service/app.py b/app/questions_generator/llm_service/app.py index 6ef9906..a99ca37 100644 --- a/app/questions_generator/llm_service/app.py +++ b/app/questions_generator/llm_service/app.py @@ -30,3 +30,18 @@ def generate(req: GenerateRequest): ) text = tokenizer.decode(output[0], skip_special_tokens=True) return {"text": text} + + +class TokenizeRequest(BaseModel): + text: str + + +@app.post("/tokenize") +def tokenize(req: TokenizeRequest): + tokens = tokenizer.tokenize(req.text) + return {"tokens": tokens, "length": len(tokens)} + + +@app.get("/max_tokens") +def max_tokens(): + return {"max_tokens": tokenizer.model_max_length} From a89eb4d9beccf9b3ec7a1d9cbac77493a896e21b Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Thu, 15 Jan 2026 05:23:59 +0300 Subject: [PATCH 21/24] added instructions --- app/questions_generator/README.md | 146 ++++++++++++++++-------------- 1 file changed, 78 insertions(+), 68 deletions(-) diff --git a/app/questions_generator/README.md b/app/questions_generator/README.md index ad5d787..c089e7c 100644 --- a/app/questions_generator/README.md +++ b/app/questions_generator/README.md @@ -1,84 +1,94 @@ -## Запуск (контейнер вечно крутится) -`docker-compose up` - ВАЖНО: Первый раз ОЧЕНЬ ДОЛГО билдится (30-40 минут) +## Запуск + +### Сборка +- `docker-compose up init-llm` - загружается модель, устанавливаются зависимости для LLM-модуля + +### Использование +- `docker-compose up llm` - поднимаем контейнер с LLM-модулем +- `docker-compose up app` - поднимаем контейнер с основным приложением +- `docker compose exec app python run.py /app/static/vkr_examples/VKR1.docx --no-overflow-logs` - запускаем генерацию вопросов по файлу ВКР -## Использование (интерактивное) -`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 + - релевантность: True + - ясность: True + - сложность:False [✔ OK] Какие термины и подходы из обзора предметной области легли в основу формальной постановки задачи? - - relevance: True - - clarity: True - - difficulty:False + - релевантность: True + - ясность: True + - сложность:False [✖ FAIL] В каких требованиях к решению, указанных в постановке задачи, находят отражение цели работы? - - relevance: False - - clarity: True - - difficulty:False + - релевантность: False + - ясность: True + - сложность:False + +[✖ FAIL] Какие количественные или качественные свойства решения подтверждены в разделе «Исследования» и как они связаны с задачами введения? + - релевантность: True + - ясность: False + - сложность:False [✔ OK] Как практическая значимость работы следует из задач и результатов исследования? - - relevance: True - - clarity: True - - difficulty:False + - релевантность: True + - ясность: True + - сложность:False [✔ OK] Какие ограничения метода решения указаны в тексте и как они влияют на достижение цели? - - relevance: True - - clarity: True - - difficulty:False + - релевантность: True + - ясность: True + - сложность: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 +[✖ FAIL] Что представляет собой актуальную задачу? + - релевантность: True + - ясность: False + - сложность:False + +[✔ OK] Что позволит пользователю получать информацию о объектах, расположенных на карте? + - релевантность: True + - ясность: True + - сложность:False + +[✔ OK] Что позволяет принимать более обоснованные и оптимальные решения? + - релевантность: True + - ясность: True + - сложность:False + +[✖ FAIL] Что определяет положение точек на эллипсоиде? + - релевантность: True + - ясность: False + - сложность:False + +[✔ OK] Что определяет относительное положение точки на плоскости? + - релевантность: True + - ясность: True + - сложность:False + +[✔ OK] В какой системе координат лежит географическая система координат? + - релевантность: True + - ясность: True + - сложность:False + +[✔ OK] По проблемной ориентации: Универсальные географические решают общие проблемы? + - релевантность: True + - ясность: True + - сложность:False + +[✔ OK] Какие ГИС создаются по масштабу 1: 4 000 000 и меньше? + - релевантность: True + - ясность: True + - сложность:False + +[✖ FAIL] Для чего предназначена специализированная ГИС? + - релевантность: True + - ясность: False + - сложность:False + +[✔ OK] Для сравнения представленных ГИС выбраны следующие критерии? + - релевантность: True + - ясность: True + - сложность:False From dc1cbd4756f4d319286cac6a96abe788e1f58ac0 Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:45:53 +0300 Subject: [PATCH 22/24] docx_parser from document_insight_system prototype (works) & docker fixes --- app/questions_generator/Dockerfile | 4 +- app/questions_generator/docker-compose.yml | 3 +- .../document_parsers/README.md | 85 ++++++ .../document_parsers/converter.py | 27 ++ .../document_parsers/document/__init__.py | 2 + .../document_parsers/document/__main__.py | 20 ++ .../document/chapter/__init__.py | 3 + .../document/chapter/chapter.py | 4 + .../document/chapter/chapter_creator.py | 220 +++++++++++++++ .../chapter/chapter_object/__init__.py | 3 + .../chapter/chapter_object/chapter_object.py | 33 +++ .../chapter/chapter_object/style_info.py | 46 ++++ .../document_parsers/document/document.py | 45 ++++ .../document_parsers/document_uploader.py | 54 ++++ .../docx_uploader/__init__.py | 1 + .../docx_uploader/__main__.py | 21 ++ .../docx_uploader/core_properties.py | 29 ++ .../docx_uploader/docx_uploader.py | 252 ++++++++++++++++++ .../docx_uploader/inline_shape.py | 5 + .../docx_uploader/paragraph.py | 45 ++++ .../document_parsers/docx_uploader/style.py | 146 ++++++++++ .../document_parsers/docx_uploader/table.py | 14 + .../document_parsers/pdf_document/__init__.py | 1 + .../document_parsers/pdf_document/__main__.py | 21 ++ .../pdf_document/pdf_document_manager.py | 98 +++++++ .../llm_service/init-volumes.sh | 5 +- app/questions_generator/requirements.txt | 3 + app/questions_generator/run.py | 11 +- app/questions_generator/run_docker.py | 48 ---- 29 files changed, 1194 insertions(+), 55 deletions(-) create mode 100644 app/questions_generator/document_parsers/README.md create mode 100644 app/questions_generator/document_parsers/converter.py create mode 100644 app/questions_generator/document_parsers/document/__init__.py create mode 100644 app/questions_generator/document_parsers/document/__main__.py create mode 100644 app/questions_generator/document_parsers/document/chapter/__init__.py create mode 100644 app/questions_generator/document_parsers/document/chapter/chapter.py create mode 100644 app/questions_generator/document_parsers/document/chapter/chapter_creator.py create mode 100644 app/questions_generator/document_parsers/document/chapter/chapter_object/__init__.py create mode 100644 app/questions_generator/document_parsers/document/chapter/chapter_object/chapter_object.py create mode 100644 app/questions_generator/document_parsers/document/chapter/chapter_object/style_info.py create mode 100644 app/questions_generator/document_parsers/document/document.py create mode 100644 app/questions_generator/document_parsers/document_uploader.py create mode 100644 app/questions_generator/document_parsers/docx_uploader/__init__.py create mode 100644 app/questions_generator/document_parsers/docx_uploader/__main__.py create mode 100644 app/questions_generator/document_parsers/docx_uploader/core_properties.py create mode 100644 app/questions_generator/document_parsers/docx_uploader/docx_uploader.py create mode 100644 app/questions_generator/document_parsers/docx_uploader/inline_shape.py create mode 100644 app/questions_generator/document_parsers/docx_uploader/paragraph.py create mode 100644 app/questions_generator/document_parsers/docx_uploader/style.py create mode 100644 app/questions_generator/document_parsers/docx_uploader/table.py create mode 100644 app/questions_generator/document_parsers/pdf_document/__init__.py create mode 100644 app/questions_generator/document_parsers/pdf_document/__main__.py create mode 100644 app/questions_generator/document_parsers/pdf_document/pdf_document_manager.py delete mode 100644 app/questions_generator/run_docker.py diff --git a/app/questions_generator/Dockerfile b/app/questions_generator/Dockerfile index 50b0596..606f081 100644 --- a/app/questions_generator/Dockerfile +++ b/app/questions_generator/Dockerfile @@ -3,6 +3,8 @@ FROM python:3.10-slim RUN apt-get update && apt-get install -y --no-install-recommends \ git wget gcc g++ \ libprotobuf-dev protobuf-compiler \ + libreoffice-core \ + libreoffice-writer \ && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -15,6 +17,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -# COPY --chmod=755 init-volumes.sh /usr/local/bin/init-volumes.sh - CMD ["bash"] diff --git a/app/questions_generator/docker-compose.yml b/app/questions_generator/docker-compose.yml index ccca326..2e34daa 100644 --- a/app/questions_generator/docker-compose.yml +++ b/app/questions_generator/docker-compose.yml @@ -1,7 +1,8 @@ services: init-llm: build: ./llm_service - entrypoint: ["/usr/local/bin/init-volumes.sh"] + entrypoint: [ "/bin/sh", "-c" ] + command: [ "/usr/local/bin/init-volumes.sh" ] volumes: - rut5_model:/models/rut5 restart: "no" diff --git a/app/questions_generator/document_parsers/README.md b/app/questions_generator/document_parsers/README.md new file mode 100644 index 0000000..3ebca35 --- /dev/null +++ b/app/questions_generator/document_parsers/README.md @@ -0,0 +1,85 @@ +# Файлы взяты 26.01.2026 с https://github.com/moevm/document_insight_system/tree/master/app/main/reports + +### Staff +python -m document_parsers.document --help +python -m document_parsers.docx_uploader docx_parser --help + +(open() видит так файл, а docx.Document - нет) +python -m document_parsers.docx_uploader docx_parser --file static/vkr_examples/VKR1.docx + + + +# Запуск и тестирование + +Пререквизиты: `argparse`, `python-docx`, `docx2python`, `re`, `subprocess`, `markdown`. Для парсинга `.doc`-файлов потребуется +LibreOffice. + +Здесь и далее считается, что корневая директория репозитория добавлена в `PYTHONPATH`. + +Код проверки текстовых документов разбит по python-пакетам: + +## `docx_uploader` + +Proof-of-concept парсинг файлов `.docx` с выводом структуры +файла в текстовом виде в stdout. + +Запуск: `python3 -m app.main.mse22.docx_uploader [--help|-h] docx_parser --file ` + +Конкретные примеры: + +`python3 -m app.main.mse22.docx_uploader docx_parser --file ~/my/beatiful/file.docx` +– парсинг файла `~/my/beatiful/file.docx`; + +`python3 -m app.main.mse22.docx_uploader --help` +– вызов краткой справки; + +`python3 -m app.main.mse22.docx_uploader docx_parser --file ~/my/beatiful/file.docx > /dev/null && echo $?` +– проверка безошибочной работы пакета на файле `~/my/beatiful/file.docx` без +вывода содержимого файла. + +## `doc` + +Перевод файлов `.doc`, `.odt` в `.docx` с помощью сторонней программы (LibreOffice) с целью дальнейшего парсинга. + +`python3 -m app.main.mse22.converter_to_docx convert --filename ` + +Пример: `python3 -m app.main.mse22.converter_to_docx convert --filename ~/my/beatiful/file.doc` + +## `document` + +Парсинг файлов с созданием вспомогательных структур, которые будут +использоваться для проверки документов, с печатью результата в stdout. + +Запуск: `python3 -m app.main.mse22.document [-h|--help] --filename --type ` + +Тип файла: + +- LR - Лабораторная работа +- FWQ - Выпускная квалификационная работа + +Конкретные примеры: + +`python3 -m app.main.mse22.document --help` +– вызов краткой справки; + +`python3 -m app.main.mse22.document --filename ~/my/beatiful/file.docx --type FWQ` +– парсинг файла `~/my/beatiful/file.docx`; + +`python3 -m app.main.mse22.document --filename ~/my/beatiful/file.docx --type FWQ > /dev/null && echo $?` +– проверка безошибочной работы пакета на файле `~/my/beatiful/file.docx` без +вывода содержимого файла. + +## `PDF` + +Получаем текст по страницам из файла с помощью конвертации файла в pdf. + +```bash +$ python3 -m app.main.mse22.pdf_document text_from_pages --filename path_to_file +``` +## `MD` + +Парсинг файлов `.md` с выводом структуры файла в текстовом виде в stdout. + +```bash +$ python3 -m app.main.reports.md_uploader md_parser --mdfile path_to_md_file +``` diff --git a/app/questions_generator/document_parsers/converter.py b/app/questions_generator/document_parsers/converter.py new file mode 100644 index 0000000..e677206 --- /dev/null +++ b/app/questions_generator/document_parsers/converter.py @@ -0,0 +1,27 @@ +import os +import subprocess +from os.path import dirname + + +def run_process(cmd: str): return subprocess.run(cmd.split(' ')) + + +def convert_to(filepath, target_format='pdf'): + new_filename, outdir = None, dirname(filepath) + convert_cmd = { + 'pdf': f"soffice --headless --convert-to pdf --outdir {outdir} {filepath}", + 'docx': f"soffice --headless --convert-to docx --outdir {outdir} {filepath}", + 'pptx': f"soffice --headless --convert-to pptx --outdir {outdir} {filepath}", + }[target_format] + + if run_process(convert_cmd).returncode == 0: + # success conversion + new_filename = "{}.{}".format(filepath.rsplit('.', 1)[0], target_format) + + return new_filename + + +def open_file(filepath, remove=False): + file = open(filepath, 'rb') + if remove: os.remove(filepath) + return file diff --git a/app/questions_generator/document_parsers/document/__init__.py b/app/questions_generator/document_parsers/document/__init__.py new file mode 100644 index 0000000..1cfd2c2 --- /dev/null +++ b/app/questions_generator/document_parsers/document/__init__.py @@ -0,0 +1,2 @@ +from .chapter import * +from .document import Document diff --git a/app/questions_generator/document_parsers/document/__main__.py b/app/questions_generator/document_parsers/document/__main__.py new file mode 100644 index 0000000..fe10ac8 --- /dev/null +++ b/app/questions_generator/document_parsers/document/__main__.py @@ -0,0 +1,20 @@ +import argparse + +from .document import main as document_main + + +def parse_args(): + parser = argparse.ArgumentParser(description="File name") + parser.add_argument("--filename", type=str, required=True, help="path to .docx file") + parser.add_argument("--type", type=str, required=True, help="LR or FWQ") + parser.set_defaults(func=document_main) + return parser.parse_args() + + +def main(): + args = parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/app/questions_generator/document_parsers/document/chapter/__init__.py b/app/questions_generator/document_parsers/document/chapter/__init__.py new file mode 100644 index 0000000..a4a3d37 --- /dev/null +++ b/app/questions_generator/document_parsers/document/chapter/__init__.py @@ -0,0 +1,3 @@ +from .chapter import Chapter +from .chapter_creator import ChapterCreator +from .chapter_object import * diff --git a/app/questions_generator/document_parsers/document/chapter/chapter.py b/app/questions_generator/document_parsers/document/chapter/chapter.py new file mode 100644 index 0000000..01125a8 --- /dev/null +++ b/app/questions_generator/document_parsers/document/chapter/chapter.py @@ -0,0 +1,4 @@ +class Chapter: + def __init__(self, title, objects): + self.header = title + self.pageObjects = objects diff --git a/app/questions_generator/document_parsers/document/chapter/chapter_creator.py b/app/questions_generator/document_parsers/document/chapter/chapter_creator.py new file mode 100644 index 0000000..f2b6744 --- /dev/null +++ b/app/questions_generator/document_parsers/document/chapter/chapter_creator.py @@ -0,0 +1,220 @@ +import re + +import docx +from docx2python import docx2python + +from .chapter import Chapter +from .chapter_object import ChapterObjectImage, ChapterObjectTable, ChapterObjectHeader + + +class ChapterCreator: + @staticmethod + def make_chapter_object(docx_docx2python, docx_docx, i, j, paragraph_index): + image_pattern = r'\-{4}media/image\d+\.\D+\-{4}' + if re.search(image_pattern, docx_docx2python.body[i][0][0][j]): + return ChapterObjectImage('image', docx_docx.paragraphs[paragraph_index]) + else: + return ChapterObjectHeader('paragraph', docx_docx.paragraphs[paragraph_index]) + + @staticmethod + def make_content(doc, body_index, paragraph_index): + new_chapters = [] + index = 0 + paragraph_index += 1 + flag = False + while len(doc.body[body_index][0][0]) >= paragraph_index + 1 \ + and doc.body[body_index][0][0][paragraph_index] == ' ': + paragraph_index += 1 + if len(doc.body[body_index][0][0]) == paragraph_index + 1: + body_index += 1 + paragraph_index = 0 + for elem in doc.body[body_index]: + s = '' + for item in elem: + s += item[0] + s += '+' + if re.search(r'(ЗАКЛЮЧЕНИЕ|Заключение|заключение)', s): + flag = True + break + elif re.search(r'(ВВЕДЕНИЕ|Введение|введение)', s) \ + or re.search(r'(ОПРЕДЕЛЕНИЯ, ОБОЗНАЧЕНИЯ И СОКРАЩЕНИЯ|Определения, обозначения и сокращения|' + r'определения, обозначения и сокращения)', s) \ + or len(s) == len(elem): + continue + else: + if s.split('+')[1].strip(): + new_chapters.append(f"{s.split('+')[0]} {s.split('+')[1]}".strip()) + else: + for elem in doc.body[body_index][0][0]: + if re.search(r'(ЗАКЛЮЧЕНИЕ|Заключение|заключение)', elem): + flag = True + break + if not flag: + return False, None, None + for i in range(paragraph_index, len(doc.body[body_index][0][0])): + if re.search(r'(ВВЕДЕНИЕ|Введение|введение)', doc.body[body_index][0][0][i]) \ + or re.search(r'(ОПРЕДЕЛЕНИЯ, ОБОЗНАЧЕНИЯ И СОКРАЩЕНИЯ|Определения, обозначения и сокращения|' + r'определения, обозначения и сокращения)', doc.body[body_index][0][0][i]) \ + or doc.body[body_index][0][0][i] == '': + continue + elif re.search(r'(ЗАКЛЮЧЕНИЕ|Заключение|заключение)', doc.body[body_index][0][0][i]): + index = i + 1 + break + else: + new_chapters.append( + doc.body[body_index][0][0][i] + [re.search( + r'[\w. ]{2,}', + doc.body[body_index][0][0][i]).start():re.search(r'[\w. ]{2,}', + doc.body[body_index][0][0][i]).end()]) + if flag: + return True, new_chapters, index + else: + return False, None, None + + def create_chapter_objects(self, path, file_type): + docx_docx2python = docx2python(path) + indices, errors = self.make_indices(docx_docx2python, file_type) + docx_docx = docx.Document(path) + + table_index = 0 + paragraph_index = 0 + page_objects = [] + current_page_object = [] + for i in range(0, len(indices)): + chapter = docx_docx.paragraphs[paragraph_index].text if i != 0 else 'Титульный лист' + for j in range(indices[i][0][0], indices[i][1][0] + 1): + if len(docx_docx2python.body[j]) > 1: + current_page_object.append(ChapterObjectTable('table', docx_docx.tables[table_index])) + table_index += 1 + + else: + if indices[i][0][0] == indices[i][1][0]: + for _ in range(indices[i][0][1], indices[i][1][1] + 1): + current_page_object.append(self.make_chapter_object(docx_docx2python, docx_docx, j, _, + paragraph_index)) + paragraph_index += 1 + + elif j == indices[i][0][0] and j != indices[i][1][0]: + for _ in range(indices[i][0][1], len(docx_docx2python.body[j][0][0])): + current_page_object.append(self.make_chapter_object(docx_docx2python, docx_docx, j, _, + paragraph_index)) + paragraph_index += 1 + + elif j != indices[i][0][0] and j != indices[i][1][0]: + for _ in range(0, len(docx_docx2python.body[j][0][0])): + current_page_object.append(self.make_chapter_object(docx_docx2python, docx_docx, j, _, + paragraph_index)) + paragraph_index += 1 + + elif j == indices[i][1][0]: + for _ in range(0, indices[i][1][1] + 1): + current_page_object.append(self.make_chapter_object(docx_docx2python, docx_docx, j, _, + paragraph_index)) + paragraph_index += 1 + + page_objects.append(Chapter(chapter, current_page_object)) + current_page_object = [] + + return page_objects, errors + + def make_indices(self, parsed_doc, file_type, chapter_names=None): + # 'LR': + # chapters = ['Цель работы', 'Основные теоретические положения', + # 'Выполнение работы', 'Тестирование', 'Выводы'] + # else: + # chapters = ['ЗАДАНИЕ НА ВЫПУСКНУЮ КВАЛИФИКАЦИОННУЮ РАБОТУ', + # 'КАЛЕНДАРНЫЙ ПЛАН ВЫПОЛНЕНИЯ ВЫПУСКНОЙ КВАЛИФИКАЦИОННОЙ РАБОТЫ', 'РЕФЕРАТ', 'ABSTRACT', + # 'СОДЕРЖАНИЕ', 'ОПРЕДЕЛЕНИЯ, ОБОЗНАЧЕНИЯ И СОКРАЩЕНИЯ', 'ВВЕДЕНИЕ', 'ЗАКЛЮЧЕНИЕ', + # 'СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ'] + i_start = [0, 0] + doc_chapters = [] # + errors = [] + cur_index = 0 + cur_chapter = 0 + application_pattern = r'(Приложение|ПРИЛОЖЕНИЕ|приложение) [А-ЯЁа-яё]' + application_find_index = 0 + find_index = 0 + while cur_chapter != len(chapter_names): + + if not parsed_doc.body: + # пустой документ + errors = chapter_names.copy() + break + + if cur_index == len(parsed_doc.body): + # ??? + if chapter_names[cur_chapter] != 'определения, обозначения и сокращения': + errors.append(chapter_names[cur_chapter]) + cur_chapter += 1 + cur_index = doc_chapters[-1][0][0] if doc_chapters else 0 + if cur_chapter == len(chapter_names): + break + + if len(parsed_doc.body[cur_index]) > 1: # table + cur_index += 1 + elif chapter_names[cur_chapter] in parsed_doc.body[cur_index][0][0][find_index:] \ + or chapter_names[cur_chapter].lower() in parsed_doc.body[cur_index][0][0][find_index:] \ + and file_type != 'LR' \ + or chapter_names[cur_chapter].capitalize() in parsed_doc.body[cur_index][0][0][find_index:]: + if chapter_names[cur_chapter] in parsed_doc.body[cur_index][0][0][find_index:]: + find_chapter = chapter_names[cur_chapter] + elif chapter_names[cur_chapter].lower() in parsed_doc.body[cur_index][0][0][find_index:]: + find_chapter = chapter_names[cur_chapter].lower() + else: + find_chapter = chapter_names[cur_chapter].capitalize() + if chapter_names[cur_chapter] == 'СОДЕРЖАНИЕ': + is_correct, new_chapters, index = self.make_content(parsed_doc, cur_index, + parsed_doc.body[cur_index][0][0] + .index(find_chapter)) + if is_correct: + chapter_names = chapter_names[:cur_chapter + 3] + new_chapters + chapter_names[cur_chapter + 3:] + + if not doc_chapters: + doc_chapters.append([ + i_start, + [cur_index, parsed_doc.body[cur_index][0][0].index(find_chapter) - 1] + ]) + else: + doc_chapters.append([ + [doc_chapters[-1][1][0], doc_chapters[-1][1][1] + 1], + [cur_index, parsed_doc.body[cur_index][0][0].index(find_chapter) - 1] + ]) + + find_index = index + i_start = [doc_chapters[-1][1][0], doc_chapters[-1][1][1] + 1] + cur_chapter += 1 + continue + + tmp_i_end = [0, 0] + tmp_i_start = i_start.copy() + if parsed_doc.body[cur_index][0][0].index(find_chapter, find_index) == 0 and cur_index > 0: + tmp_i_end[0], tmp_i_end[1] = cur_index - 1, len(parsed_doc.body[cur_index - 1][0][0]) - 1 + i_start[0], i_start[1] = tmp_i_end[0] + 1, 0 + else: + tmp_i_end[0], tmp_i_end[1] = cur_index, parsed_doc.body[cur_index][0][0].index(find_chapter, + find_index) - 1 + i_start[0], i_start[1] = tmp_i_end[0], tmp_i_end[1] + 1 + doc_chapters.append([tmp_i_start, tmp_i_end]) + cur_chapter += 1 + else: + cur_index += 1 + + for i in range(cur_index, len(parsed_doc.body)): + if len(parsed_doc.body[i][0][0]) > 1: + for j in range(application_find_index, len(parsed_doc.body[i][0][0])): + if re.search(application_pattern, parsed_doc.body[i][0][0][j]): + if not doc_chapters: + doc_chapters.append([[0, 0], [i, j - 1]]) + else: + doc_chapters.append([[doc_chapters[-1][1][0], doc_chapters[-1][1][1] + 1], + [i, j - 1]]) + application_find_index = j - 1 + cur_index += 1 + if not doc_chapters: + if parsed_doc.body: # empty file + doc_chapters.append([[0, 0], [len(parsed_doc.body) - 1, len(parsed_doc.body[-1][0][0]) - 1]]) + else: + doc_chapters.append([[doc_chapters[-1][1][0], doc_chapters[-1][1][1] + 1], + [len(parsed_doc.body) - 1, len(parsed_doc.body[-1][0][0]) - 1]]) + return doc_chapters, errors diff --git a/app/questions_generator/document_parsers/document/chapter/chapter_object/__init__.py b/app/questions_generator/document_parsers/document/chapter/chapter_object/__init__.py new file mode 100644 index 0000000..a1bcfeb --- /dev/null +++ b/app/questions_generator/document_parsers/document/chapter/chapter_object/__init__.py @@ -0,0 +1,3 @@ +from .chapter_object import ChapterObject, ChapterObjectImage, ChapterObjectList, ChapterObjectHeader, \ + ChapterObjectTable +from .style_info import StyleInfo diff --git a/app/questions_generator/document_parsers/document/chapter/chapter_object/chapter_object.py b/app/questions_generator/document_parsers/document/chapter/chapter_object/chapter_object.py new file mode 100644 index 0000000..a131d58 --- /dev/null +++ b/app/questions_generator/document_parsers/document/chapter/chapter_object/chapter_object.py @@ -0,0 +1,33 @@ +from .style_info import StyleInfo + + +class ChapterObject: + def __init__(self, object_type, data): + self.type = object_type + self.data = data + + self.style_info = StyleInfo(data.style) + self.table = None + + +class ChapterObjectHeader(ChapterObject): + def __init__(self, object_type, data): + super().__init__(object_type, data) + self.text = data.text + + +class ChapterObjectImage(ChapterObject): + def __init__(self, object_type, data): + super().__init__(object_type, data) + + +class ChapterObjectTable(ChapterObject): + def __init__(self, object_type, table): + super().__init__(object_type, table) + self.data_matrix = [[c.paragraphs for c in row.cells] for row in table.rows] + + +class ChapterObjectList(ChapterObject): + def __init__(self, object_type, data, list_of_paragraphs=None): + super().__init__(object_type, data) + self.data_list = list_of_paragraphs diff --git a/app/questions_generator/document_parsers/document/chapter/chapter_object/style_info.py b/app/questions_generator/document_parsers/document/chapter/chapter_object/style_info.py new file mode 100644 index 0000000..91845a2 --- /dev/null +++ b/app/questions_generator/document_parsers/document/chapter/chapter_object/style_info.py @@ -0,0 +1,46 @@ +from docx.shared import Cm + + +class StyleInfo: + def __init__(self, docx_paragraph_style): + if docx_paragraph_style is not None: + font = docx_paragraph_style.font + self.font_name = font.name + if font.size is not None: + self.font_size = font.size.pt + else: + self.font_size = None + self.bold = font.bold + self.italic = font.italic + self.all_caps = font.all_caps + formatting = docx_paragraph_style.paragraph_format + self.alignment = formatting.alignment + if formatting.first_line_indent is not None: + self.first_line_indent = Cm(formatting.first_line_indent.cm) + self.first_line_indent = round(self.first_line_indent / 1000) * 1000 + else: + self.first_line_indent = None + if formatting.line_spacing is not None: + self.line_spacing = Cm(formatting.line_spacing) + self.line_spacing = round(self.line_spacing / 1000) * 1000 + else: + self.line_spacing = None + + def __str__(self): + return ("{0} font, {1} pt\nBold: {2}\nItalic: {3}\nAll caps: {4}\nAlignment:" + + "{5}\nFirst line indent: {6}\nLine spacing: {7}") \ + .format(self.font_name, + self.__pretty_print(self.font_size), + self.bold, + self.italic, + self.all_caps, + self.alignment, + self.__pretty_print(self.first_line_indent), + self.__pretty_print(self.line_spacing) + ) + + @staticmethod + def __pretty_print(prop): + if prop is None: + return "" + return prop diff --git a/app/questions_generator/document_parsers/document/document.py b/app/questions_generator/document_parsers/document/document.py new file mode 100644 index 0000000..cc4a8b1 --- /dev/null +++ b/app/questions_generator/document_parsers/document/document.py @@ -0,0 +1,45 @@ +import docx + +from .chapter import ChapterCreator, StyleInfo + + +class Document: + def __init__(self, docx_document, filename, chapter_names): + core_props = docx_document.core_properties + self.info = DocumentInfo(core_props.author, core_props.created, core_props.modified) + self.chapters, self.errors = ChapterCreator().create_chapter_objects(filename, chapter_names) + + def __str__(self): + if not self.errors: + s = "The document contains all the basic sections." + else: + s = "There are no sections in the document: " + for i in self.errors: + s += i + ", " + return s + "\ndocument.Document object:\nPages:\n{0}\nInfo: {1}".format(self.chapters, self.info) + + +class DocumentInfo: + def __init__(self, author, datetime_created, datetime_modified): + self.author = author + self.datetime_created = datetime_created + self.datetime_modified = datetime_modified + + def __str__(self): + return "document.DocumentInfo object:\nAuthor: {0}\nCreated: {1}\nModified: {2}" \ + .format(self.author, self.datetime_created, self.datetime_modified) + + +def main(args): + if args.type == 'LR' or args.type == 'FWQ': + docx_document = docx.Document(args.filename) + parsed_document = Document(docx_document, args.filename, args.type) + print(parsed_document) + print() + for page in parsed_document.chapters: + print(page.header) + for object in page.pageObjects: + if object.type != 'table': + print(object.data.text, StyleInfo(object.data.style), '', sep='\n') + else: + print('Wrong file type!') diff --git a/app/questions_generator/document_parsers/document_uploader.py b/app/questions_generator/document_parsers/document_uploader.py new file mode 100644 index 0000000..c522a32 --- /dev/null +++ b/app/questions_generator/document_parsers/document_uploader.py @@ -0,0 +1,54 @@ +from abc import ABC, abstractmethod + + +class DocumentUploader(ABC): + + def __init__(self): + self.chapters = [] + self.paragraphs = [] + self.tables = [] + self.styled_paragraphs = [] + self.pdf_file = None + self.literature_header = [] + self.literature_page = 0 + self.first_lines = [] + self.page_count = 0 + + @abstractmethod + def upload(self): + pass + + @abstractmethod + def parse(self): + pass + + @abstractmethod + def parse_effective_styles(self): + pass + + @abstractmethod + def page_counter(self): + pass + + @abstractmethod + def make_chapters(self, work_type): + pass + + @abstractmethod + def find_header_page(self, work_type): + pass + + @abstractmethod + def find_literature_vkr(self, work_type): + pass + + @abstractmethod + def find_literature_page(self, work_type): + pass + + @abstractmethod + def show_chapters(self, work_type): + pass + + def get_main_headers(self): + pass diff --git a/app/questions_generator/document_parsers/docx_uploader/__init__.py b/app/questions_generator/document_parsers/docx_uploader/__init__.py new file mode 100644 index 0000000..60ae5a9 --- /dev/null +++ b/app/questions_generator/document_parsers/docx_uploader/__init__.py @@ -0,0 +1 @@ +from .docx_uploader import DocxUploader diff --git a/app/questions_generator/document_parsers/docx_uploader/__main__.py b/app/questions_generator/document_parsers/docx_uploader/__main__.py new file mode 100644 index 0000000..51bbf1a --- /dev/null +++ b/app/questions_generator/document_parsers/docx_uploader/__main__.py @@ -0,0 +1,21 @@ +import argparse + +from .docx_uploader import main as docx_uploader_main + + +def parse_args(): + parser = argparse.ArgumentParser(description='File Uploaders') + subparsers = parser.add_subparsers() + docx_parser = subparsers.add_parser('docx_parser', help='Upload file [docx_uploader]') + docx_parser.add_argument('--file', type=str, required=True, help='path to docx_uploader file') + docx_parser.set_defaults(func=docx_uploader_main) + return parser.parse_args() + + +def main(): + args = parse_args() + args.func(args) + + +if __name__ == '__main__': + main() diff --git a/app/questions_generator/document_parsers/docx_uploader/core_properties.py b/app/questions_generator/document_parsers/docx_uploader/core_properties.py new file mode 100644 index 0000000..92ae4d6 --- /dev/null +++ b/app/questions_generator/document_parsers/docx_uploader/core_properties.py @@ -0,0 +1,29 @@ +import pandas + + +class CoreProperties: + def __init__(self, doc): + self.author = doc.core_properties.author + self.category = doc.core_properties.category + self.comments = doc.core_properties.comments + self.content_status = doc.core_properties.content_status + self.created = doc.core_properties.created + self.identifier = doc.core_properties.identifier + self.keywords = doc.core_properties.keywords + self.language = doc.core_properties.language + self.last_modified_by = doc.core_properties.last_modified_by + self.last_printed = doc.core_properties.last_printed + self.modified = doc.core_properties.modified + self.revision = doc.core_properties.revision + self.subject = doc.core_properties.subject + self.title = doc.core_properties.title + self.version = doc.core_properties.version + + def to_string(self): + df = pandas.DataFrame({'Values': [self.author, self.category, self.comments, self.content_status, self.created, + self.identifier, self.keywords, self.language, self.last_modified_by, + self.last_printed, self.modified, self.revision, self.subject, self.title, + self.version]}) + df.index = ['AUTHOR', 'CATEGORY', 'COMMENTS', 'CONTENT STATUS', 'CREATED', 'IDENTIFIED', 'KEYWORDS', 'LANGUAGE', + 'LAST MODIFIED BY', 'LAST PRINTED', 'MODIFIED', 'REVISION', 'SUBJECT', 'TITLE', 'VERSION'] + return df.to_string() diff --git a/app/questions_generator/document_parsers/docx_uploader/docx_uploader.py b/app/questions_generator/document_parsers/docx_uploader/docx_uploader.py new file mode 100644 index 0000000..ac30dee --- /dev/null +++ b/app/questions_generator/document_parsers/docx_uploader/docx_uploader.py @@ -0,0 +1,252 @@ +import re +from functools import reduce + +import docx + +from .core_properties import CoreProperties +from .inline_shape import InlineShape +from .paragraph import Paragraph +from .style import Style +from .table import Table, Cell +from ..pdf_document.pdf_document_manager import PdfDocumentManager +from ..document_uploader import DocumentUploader + + +class DocxUploader(DocumentUploader): + def __init__(self): + super().__init__() + self.inline_shapes = [] + self.core_properties = None + self.headers = [] + self.headers_main = '' + self.file = None + self.special_paragraph_indices = {} + self.headers_page = 0 + self.page_count = 0 + + def upload(self, file, pdf_filepath=''): + self.file = docx.Document(file) + self.pdf_file = PdfDocumentManager(file, pdf_filepath) + + def parse(self): + self.core_properties = CoreProperties(self.file) + for i in range(len(self.file.inline_shapes)): + self.inline_shapes.append(InlineShape(self.file.inline_shapes[i])) + self.paragraphs = self.__make_paragraphs(self.file.paragraphs) + self.parse_effective_styles() + self.tables = self.__make_table(self.file.tables) + + def __make_paragraphs(self, paragraphs): + tmp_paragraphs = [] + for i in range(len(paragraphs)): + if len(paragraphs[i].text.strip()): + tmp_paragraphs.append(Paragraph(paragraphs[i])) + return tmp_paragraphs + + def make_chapters(self, work_type): + if not self.chapters: + tmp_chapters = [] + if work_type == 'VKR': + # find headers + header_ind = -1 + par_num = 0 + head_par_ind = -1 + for par_ind in range(len(self.styled_paragraphs)): + head_par_ind += 1 + style_name = self.paragraphs[par_ind].paragraph_style_name.lower() + if style_name.find("heading") >= 0: + header_ind += 1 + par_num = 0 + tmp_chapters.append({"style": style_name, "text": self.styled_paragraphs[par_ind]["text"].strip(), + "styled_text": self.styled_paragraphs[par_ind], "number": head_par_ind, + "child": []}) + elif header_ind >= 0: + par_num += 1 + tmp_chapters[header_ind]["child"].append( + {"style": style_name, "text": self.styled_paragraphs[par_ind]["text"], + "styled_text": self.styled_paragraphs[par_ind], "number": head_par_ind}) + self.chapters = tmp_chapters + return self.chapters + + def make_headers(self, work_type): + if not self.headers: + if work_type == 'VKR': + # find first pages + headers = [ + {"name": "Титульный лист", "marker": False, "key": "санкт-петербургский государственный", + "main_character": True, "page": 0}, + {"name": "Задание на выпускную квалификационную работу", "marker": False, "key": "задание", + "main_character": True, "page": 0}, + {"name": "Календарный план", "marker": False, "key": "календарный план", "main_character": True, + "page": 0}, + {"name": "Реферат", "marker": False, "key": "реферат", "main_character": False, "page": 0}, + {"name": "Abstract", "marker": False, "key": "abstract", "main_character": False, "page": 0}, + {"name": "Cодержание", "marker": False, "key": "содержание", "main_character": False, "page": 0}] + for page in range(1, self.page_count if self.page_counter() < 2 * len(headers) else 2 * len(headers)): + page_text = (self.pdf_file.get_text_on_page()[page].split("\n")[0]).lower() + for i in range(len(headers)): + if not headers[i]["marker"]: + if page_text.find(headers[i]["key"]) >= 0: + headers[i]["marker"] = True + headers[i]["page"] = page + break + self.headers = headers + return self.headers + + def get_main_headers(self, work_type): #this method helps to avoid mistake in "needed_headers_check" (because of structure checks for md) + if not self.headers_main: + if work_type == 'VKR': + self.headers_main = self.make_headers(work_type)[1]['name'] + return self.headers_main + + def __make_table(self, tables): + for i in range(len(tables)): + table = [] + for j in range(len(tables[i].rows)): + row = [] + for k in range(len(tables[i].rows[j].cells)): + tmp_paragraphs = self.__make_paragraphs(tables[i].rows[j].cells[k].paragraphs) + row.append(Cell(tables[i].rows[j].cells[k], tmp_paragraphs)) + table.append(row) + self.tables.append(Table(tables[i], table)) + return tables + + def find_header_page(self, work_type): + if not self.headers_page: + if work_type != 'VKR': + self.headers_page = 1 + return self.headers_page + for header in self.make_headers(work_type): + if header["name"].find('Cодержание') >= 0: + if not header["page"]: + self.headers_page = 1 + else: + self.headers_page = header["page"] + break + return self.headers_page + + def find_literature_page(self, work_type): + if not self.literature_page: + for k, v in self.pdf_file.text_on_page.items(): + line = v[:40] if len(v) > 21 else v + if re.search('список[ \t]*(использованных|использованной|)[ \t]*(источников|литературы)', line.strip().lower()): + break + self.literature_page += 1 + self.literature_page += 1 + return self.literature_page + + def find_literature_vkr(self, work_type): + if not self.literature_header: + for header in self.make_chapters(work_type): + header_text = header["text"].lower() + if header_text.find('список использованных источников') >= 0: + self.literature_header = header + return self.literature_header + + def build_vkr_hierarchy(self, styles): + indices = self.get_paragraph_indices_by_style(styles) + tagged_indices = [{"index": 0, "level": 0}, {"index": len(self.styled_paragraphs), "level": 0}] + for j in range(len(indices)): + tagged_indices.extend(list(map(lambda index: {"index": index, "level": j + 1, + "text": self.styled_paragraphs[index]["text"]}, indices[j]))) + tagged_indices.sort(key=lambda dct: dct["index"]) + return tagged_indices + + def build_vkr_hierarchy_style(self, styles): + indices = self.get_paragraph_indices_by_style(styles) + tagged_indices = [{"index": 0, "level": 0}, {"index": len(self.styled_paragraphs), "level": 0}] + for j in range(len(indices)): + tagged_indices.extend(list(map(lambda index: {"index": index, "level": j + 1, + "styled_text": self.styled_paragraphs[index], + "style": self.paragraphs[index].paragraph_style_name.lower()}, + indices[j]))) + tagged_indices.sort(key=lambda dct: dct["index"]) + return tagged_indices + + # Parses styles once; subsequent calls have no effect, since the file itself shouldn't change + def parse_effective_styles(self): + if self.styled_paragraphs: + return + for par in filter(lambda p: len(p.text.strip()) > 0, self.file.paragraphs): + paragraph = {"text": par.text, "runs": []} + for run in filter(lambda r: len(r.text.strip()) > 0, par.runs): + paragraph["runs"].append({"text": run.text, "style": Style(run, par)}) + self.styled_paragraphs.append(paragraph) + + def unify_multiline_entities(self, first_line_regex_str): + pattern = re.compile(first_line_regex_str) + pars_to_delete = [] + skip_flag = False + for i in range(len(self.styled_paragraphs) - 1): + if skip_flag: + skip_flag = False + continue + par = self.styled_paragraphs[i] + next_par = self.styled_paragraphs[i + 1] + if pattern.match(par["text"]): + skip_flag = True + par["text"] += ("\n" + next_par["text"]) + par["runs"].extend(next_par["runs"]) + pars_to_delete.append(next_par) + continue + for par in pars_to_delete: + self.styled_paragraphs.remove(par) + + def get_paragraph_indices_by_style(self, style_list): + result = [] + for template_style in style_list: + matched_pars = [] + for i in range(len(self.styled_paragraphs)): + par = self.styled_paragraphs[i] + if reduce(lambda prev, run: prev and run["style"].matches(template_style), par["runs"], True): + matched_pars.append(i) + result.append(matched_pars) + return result + + def page_counter(self): + if not self.page_count: + for k, v in self.pdf_file.text_on_page.items(): + line = v[:20] if len(v) > 21 else v + if re.search('ПРИЛОЖЕНИЕ [А-Я]', line.strip()): + break + self.page_count += 1 + line = '' + lines = v.split("\n") + for i in range(len(lines)): + if i > 1: + break + if i > 0: + line += " " + line += lines[i].strip() + self.first_lines.append(line.lower()) + return self.page_count + + def upload_from_cli(self, file): + self.upload(file=file) + + def print_info(self): + print(self.core_properties.to_string()) + for i in range(len(self.paragraphs)): + print(self.paragraphs[i].to_string()) + + def __str__(self): + return self.core_properties.to_string() + '\n' + '\n'.join( + [self.paragraphs[i].to_string() for i in range(len(self.paragraphs))]) + + def show_chapters(self, work_type): + chapters_str = "
" + for header in self.make_chapters(work_type): + if header["style"] == 'heading 2': + chapters_str += header["text"] + "
" + else: + chapters_str += "    " + header["text"] + "
" + return chapters_str + + +def main(args): + file = args.file + uploader = DocxUploader() + uploader.upload_from_cli(file=file) + uploader.parse() + uploader.print_info() + uploader.parse_effective_styles() diff --git a/app/questions_generator/document_parsers/docx_uploader/inline_shape.py b/app/questions_generator/document_parsers/docx_uploader/inline_shape.py new file mode 100644 index 0000000..74a9194 --- /dev/null +++ b/app/questions_generator/document_parsers/docx_uploader/inline_shape.py @@ -0,0 +1,5 @@ +class InlineShape: + def __init__(self, inline_shape): + self.type = inline_shape.type + self.width = inline_shape.width + self.height = inline_shape.height diff --git a/app/questions_generator/document_parsers/docx_uploader/paragraph.py b/app/questions_generator/document_parsers/docx_uploader/paragraph.py new file mode 100644 index 0000000..78ee16b --- /dev/null +++ b/app/questions_generator/document_parsers/docx_uploader/paragraph.py @@ -0,0 +1,45 @@ +import pandas + + +class Paragraph: + + def __init__(self, paragraph): + self.paragraph_text = paragraph.text + self.paragraph_style_name = paragraph.style.name + self.paragraph_alignment = paragraph.paragraph_format.alignment + self.paragraph_left_indent = paragraph.paragraph_format.left_indent + self.paragraph_right_indent = paragraph.paragraph_format.right_indent + self.paragraph_first_line_indent = paragraph.paragraph_format.first_line_indent + self.paragraph_space_after = paragraph.paragraph_format.space_after + self.paragraph_space_before = paragraph.paragraph_format.space_before + self.paragraph_line_spacing = paragraph.paragraph_format.line_spacing + self.paragraph_line_spacing_rule = paragraph.paragraph_format.line_spacing_rule + self.paragraph_keep_together = paragraph.paragraph_format.keep_together + self.paragraph_keep_with_next = paragraph.paragraph_format.keep_with_next + self.paragraph_page_break_before = paragraph.paragraph_format.page_break_before + self.paragraph_widow_control = paragraph.paragraph_format.widow_control + self.modify() + + def to_string(self): + df = pandas.DataFrame({'Values': [self.paragraph_text, self.paragraph_alignment, self.paragraph_left_indent, + self.paragraph_right_indent, self.paragraph_first_line_indent, + self.paragraph_space_after, self.paragraph_space_before, + self.paragraph_line_spacing, self.paragraph_line_spacing_rule, + self.paragraph_keep_together, self.paragraph_keep_with_next, + self.paragraph_page_break_before, self.paragraph_widow_control]}) + df.index = ['TEXT', 'ALIGNMENT', 'LEFT_INDENT', 'RIGHT_INDENT', 'FIRST_LINE_INDENT', 'SPACE_AFTER', + 'SPACE_BEFORE', 'LINE_SPACING', 'LINE_SPACING_RULE', 'KEEP_TOGETHER', 'KEEP_WITH_NEXT', + 'PAGE_BREAK_BEFORE', 'WIDOW_CONTROL'] + return df.to_string() + + def modify(self): + if self.paragraph_left_indent is not None: + self.paragraph_left_indent = self.paragraph_left_indent.cm + if self.paragraph_right_indent is not None: + self.paragraph_right_indent = self.paragraph_right_indent.cm + if self.paragraph_first_line_indent is not None: + self.paragraph_first_line_indent = self.paragraph_first_line_indent.cm + if self.paragraph_space_after is not None: + self.paragraph_space_after = self.paragraph_space_after.pt + if self.paragraph_space_before is not None: + self.paragraph_space_before = self.paragraph_space_before.pt diff --git a/app/questions_generator/document_parsers/docx_uploader/style.py b/app/questions_generator/document_parsers/docx_uploader/style.py new file mode 100644 index 0000000..641ca03 --- /dev/null +++ b/app/questions_generator/document_parsers/docx_uploader/style.py @@ -0,0 +1,146 @@ +class Style: + _friendly_property_names = { + "font_name": "Имя шрифта", + "font_size_pt": "Размер шрифта, пт", + "bold": "Полужирное начертание", + "italic": "Курсивное начертание", + "all_caps": "Все заглавные", + "alignment": "Выравнивание", + "line_spacing": "Межстрочный интервал", + "first_line_indent_cm": "Красная строка, см", + "space_after_pt": "Отступ перед абзацем, пт", + "space_before_pt": "Отступ после абзаца, пт" + } + + def __init__(self, run=None, par=None): + self.font_name = None + self.font_size_pt = None + self.bold = None + self.italic = None + self.all_caps = None + self.alignment = None + self.line_spacing = None + # self.line_spacing_rule = None + self.first_line_indent_cm = None + self.space_after_pt = None + self.space_before_pt = None + if run is not None and par is not None: + for attribute_name in dir(self): + if attribute_name.startswith("set_") and callable(getattr(self, attribute_name)): + getattr(self, attribute_name)(run, par) + + @staticmethod + def get_style_attribute(style, attr_names): + nested_attr = style + for attr_name in attr_names: + if nested_attr is None: + return None + nested_attr = getattr(nested_attr, attr_name) + return nested_attr + + @staticmethod + def resolve_style_property(style, attr_names): + cur_style = style + while cur_style is not None: + result = Style.get_style_attribute(cur_style, attr_names) + if result is not None: + return result + cur_style = cur_style.base_style + return None + + @staticmethod + def resolve_style_hierarchy(run, par, attr_names): + if run is not None: + run_result = Style.get_style_attribute(run, attr_names) + if run_result is not None: + return run_result + run_style_result = Style.resolve_style_property(run.style, attr_names) + if run_style_result is not None: + return run_style_result + return Style.resolve_style_property(par.style, attr_names) + + def set_font_name(self, run, par): + self.font_name = Style.resolve_style_hierarchy(run, par, ["font", "name"]) + # As a last resort you can try parsing XML directly: https://github.com/python-openxml/python-docx/issues/383 + + def set_font_size_pt(self, run, par): + self.font_size_pt = Style.resolve_style_hierarchy(run, par, ["font", "size", "pt"]) + if self.font_size_pt is not None: + self.font_size_pt = round(self.font_size_pt * 100) / 100 + + def set_bold(self, run, par): + self.bold = Style.resolve_style_hierarchy(run, par, ["font", "bold"]) + if self.bold is None: + self.bold = False + + def set_italic(self, run, par): + self.italic = Style.resolve_style_hierarchy(run, par, ["font", "italic"]) + if self.italic is None: + self.italic = False + + def set_all_caps(self, run, par): + if run.text.upper() == run.text: + self.all_caps = True + return + self.all_caps = Style.resolve_style_hierarchy(run, par, ["font", "all_caps"]) + if self.all_caps is None: + self.all_caps = False + + def set_alignment(self, run, par): + self.alignment = par.alignment + if self.alignment is None: + self.alignment = Style.resolve_style_hierarchy(None, par, ["paragraph_format", "alignment"]) + + '''def set_line_spacing_rule(self, run, par): + self.line_spacing_rule = Style.resolve_style_hierarchy(None, par, ["paragraph_format", "line_spacing_rule"]) + if self.line_spacing_rule is None: + self.line_spacing_rule = WD_LINE_SPACING.SINGLE''' + + def set_line_spacing(self, run, par): + self.line_spacing = Style.resolve_style_hierarchy(None, par, ["paragraph_format", "line_spacing"]) + if self.line_spacing is not None: + self.line_spacing = round(self.line_spacing * 100) / 100 + + def set_first_line_indent_cm(self, run, par): + self.first_line_indent_cm = Style.resolve_style_hierarchy(None, par, + ["paragraph_format", "first_line_indent", "cm"]) + if self.first_line_indent_cm is not None: + self.first_line_indent_cm = round(self.first_line_indent_cm * 100) / 100 + + def set_space_after_pt(self, run, par): + self.space_after_pt = Style.resolve_style_hierarchy(None, par, ["paragraph_format", "space_after", "pt"]) + if self.space_after_pt is None: + self.space_after_pt = 0.0 + else: + self.space_after_pt = round(self.space_after_pt * 100) / 100 + + def set_space_before_pt(self, run, par): + self.space_before_pt = Style.resolve_style_hierarchy(None, par, ["paragraph_format", "space_before", "pt"]) + if self.space_before_pt is None: + self.space_before_pt = 0.0 + else: + self.space_before_pt = round(self.space_before_pt * 100) / 100 + + # a.matches(b) != b.matches(a) + # None in template_style == "any"; None in self == "not found" + def matches(self, template_style, error_list=None): + flag = True + for property_name in dir(self): + if callable(getattr(self, property_name)): + continue + if property_name[0] == "_": + continue + if getattr(template_style, property_name) is None: + continue + if getattr(self, property_name) != getattr(template_style, property_name): + if error_list is None: + return False + else: + flag = False + if getattr(self, property_name): + error_list.append("{0}: ожидалось \"{1}\", фактически \"{2}\"".format( + Style._friendly_property_names[property_name], getattr(template_style, property_name), + # "по умолчанию" if getattr(self, property_name) is None else getattr(self, property_name) + getattr(self, property_name) + )) + return flag diff --git a/app/questions_generator/document_parsers/docx_uploader/table.py b/app/questions_generator/document_parsers/docx_uploader/table.py new file mode 100644 index 0000000..fee7cd0 --- /dev/null +++ b/app/questions_generator/document_parsers/docx_uploader/table.py @@ -0,0 +1,14 @@ +class Cell: + def __init__(self, cell, paragraphs): + self.ceil_paragraphs = paragraphs + self.cell_text = cell.text + self.cell_vertical_alignment = cell.vertical_alignment + self.cell_width = cell.width + + +class Table: + def __init__(self, table, cells): + self.table_cells = cells + self.table_alignment = table.alignment + self.table_autofit = table.autofit + self.table_direction = table.table_direction diff --git a/app/questions_generator/document_parsers/pdf_document/__init__.py b/app/questions_generator/document_parsers/pdf_document/__init__.py new file mode 100644 index 0000000..b1eb35a --- /dev/null +++ b/app/questions_generator/document_parsers/pdf_document/__init__.py @@ -0,0 +1 @@ +from .pdf_document_manager import PdfDocumentManager diff --git a/app/questions_generator/document_parsers/pdf_document/__main__.py b/app/questions_generator/document_parsers/pdf_document/__main__.py new file mode 100644 index 0000000..cae18f8 --- /dev/null +++ b/app/questions_generator/document_parsers/pdf_document/__main__.py @@ -0,0 +1,21 @@ +import argparse + +from .pdf_document_manager import main as pdf_document_main + + +def parse_args(): + parser = argparse.ArgumentParser(description="File parsers") + subparsers = parser.add_subparsers(description="Concrete file formats") + odt_loading_parser = subparsers.add_parser("text_from_pages", help="PDF Document") + odt_loading_parser.add_argument("--filename", type=str, required=True, help="path to file") + odt_loading_parser.set_defaults(func=pdf_document_main) + return parser.parse_args() + + +def main(): + args = parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/app/questions_generator/document_parsers/pdf_document/pdf_document_manager.py b/app/questions_generator/document_parsers/pdf_document/pdf_document_manager.py new file mode 100644 index 0000000..8ad469f --- /dev/null +++ b/app/questions_generator/document_parsers/pdf_document/pdf_document_manager.py @@ -0,0 +1,98 @@ + +# import pdfplumber +import pymupdf + + +from ..converter import convert_to + +class PdfDocumentManager: + def __init__(self, path_to_file, pdf_filepath): + if not pdf_filepath: + # self.pdf_file = pdfplumber.open(convert_to(path_to_file, target_format='pdf')) + self.pdf_file = pymupdf.open(convert_to(path_to_file, target_format='pdf')) + else: + # self.pdf_file = pdfplumber.open(pdf_filepath) + self.pdf_file = pymupdf.open(pdf_filepath) + self.pages = [self.pdf_file.load_page(page_num) for page_num in range(self.pdf_file.page_count)] + self.page_count_all = self.pdf_file.page_count + # self.page_count = len(self.pages) + # self.pages = self.pdf_file.pages + self.text_on_page = self.get_text_on_page() + # self.bboxes = [] + # self.only_text_on_page = {} + + def get_text_on_page(self): + return {page_num + 1: page.get_text() for page_num, page in enumerate(self.pages)} + + # def get_text_on_page(self): + # return {page + 1: self.pages[page].extract_text() for page in range(self.page_count_all)} + + def get_image_num(self): + return len(self.pdf_file.get_page_images(0)) + + def page_images(self, page_without_pril): + total_height = 0 + for page_num in range(page_without_pril): + page = self.pdf_file[page_num] + images = self.pdf_file.get_page_images(page_num) + for image in images: + image_coord = page.get_image_bbox(image[7], transform=0) # might be [1.0, 1.0, -1.0, -1.0] + image_height = image_coord[3] - image_coord[1] + if image_height > 0: + total_height += image_height + return total_height + + def page_height(self, page_without_pril): + page = self.pdf_file[0] # get first page as a sample + page_rect = page.rect + height, top_margin = page_rect.height, page_rect.y0 + bottom_margin = height - page_rect.y1 + available_space = (height - top_margin - bottom_margin)*page_without_pril + + return available_space + + def page_rows_text(self, page_num): + page = self.pdf_file.load_page(page_num) + text_blocks = page.get_text("blocks") + return text_blocks + + # def get_only_text_on_page(self): + # if not self.only_text_on_page: + # only_text_on_page = {} + # for page in range(self.page_count): + # p = self.pages[page] + # print(p.curves + p.edges) + # ts = { + # "vertical_strategy": "explicit", + # "horizontal_strategy": "explicit", + # "explicit_vertical_lines": self.curves_to_edges(p.curves + p.edges), + # "explicit_horizontal_lines": self.curves_to_edges(p.curves + p.edges), + # "intersection_y_tolerance": 10, + # } + # self.bboxes = [table.bbox for table in p.find_tables(table_settings=ts)] + # only_text_on_page.update({page + 1: p.filter(self.not_within_bboxes).extract_text()}) + # self.only_text_on_page = only_text_on_page + # return self.only_text_on_page + # + # def curves_to_edges(self, cs): + # """See https://github.com/jsvine/pdfplumber/issues/127""" + # edges = [] + # for c in cs: + # edges.append(pdfplumber.utils.rect_to_edges(c)) + # return edges + # + # def not_within_bboxes(self, obj): + # """Check if the object is in any of the table's bbox.""" + # def obj_in_bbox(_bbox): + # """See https://github.com/jsvine/pdfplumber/blob/stable/pdfplumber/table.py#L404""" + # v_mid = (obj["top"] + obj["bottom"]) / 2 + # h_mid = (obj["x0"] + obj["x1"]) / 2 + # x0, top, x1, bottom = _bbox + # return (h_mid >= x0) and (h_mid < x1) and (v_mid >= top) and (v_mid < bottom) + # return not any(obj_in_bbox(__bbox) for __bbox in self.bboxes) + + +def main(args): + pdf_document_manager = PdfDocumentManager(args.filename) + for k, v in pdf_document_manager.text_on_page.items(): + print(f"Страница №{k}" + '\n' + f"Текст: {v}", end='\n\n') diff --git a/app/questions_generator/llm_service/init-volumes.sh b/app/questions_generator/llm_service/init-volumes.sh index 21b662e..777ef1b 100644 --- a/app/questions_generator/llm_service/init-volumes.sh +++ b/app/questions_generator/llm_service/init-volumes.sh @@ -9,10 +9,9 @@ mkdir -p "$MODEL_DIR" if [ -z "$(ls -A "$MODEL_DIR" 2>/dev/null)" ]; then echo "Не видно модельки rut5-base, грузим в папку $MODEL_DIR..." - huggingface-cli download \ + hf download \ cointegrated/rut5-base-multitask \ - --local-dir "$MODEL_DIR" \ - --local-dir-use-symlinks False + --local-dir "$MODEL_DIR" echo "Загрузили" else echo "В директории модельки что-то есть, не будем ещё раз загружать" diff --git a/app/questions_generator/requirements.txt b/app/questions_generator/requirements.txt index 0e8c108..90cab3c 100644 --- a/app/questions_generator/requirements.txt +++ b/app/questions_generator/requirements.txt @@ -1,3 +1,6 @@ nltk==3.9.2 python-docx==1.2.0 requests==2.32.3 +pandas==2.3.3 +python-docx==1.2.0 +pymupdf==1.26.0 diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py index e553f85..6d82c32 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -9,6 +9,7 @@ from generator import VkrQuestionGenerator from validator import VkrQuestionValidator from logging_utils import setup_logging, log_timed, suppress_console_logs +from document_parsers.docx_uploader import docx_uploader def load_vkr_text(path: str) -> str: @@ -87,5 +88,13 @@ def main(): print(f" - сложность:{diff}") +def main2(): + uploader = docx_uploader.DocxUploader() + uploader.upload("/app/static/vkr_examples/VKR1.docx") + uploader.parse() + uploader.print_info() + uploader.parse_effective_styles() + + if __name__ == "__main__": - main() + main2() diff --git a/app/questions_generator/run_docker.py b/app/questions_generator/run_docker.py deleted file mode 100644 index ca79ab5..0000000 --- a/app/questions_generator/run_docker.py +++ /dev/null @@ -1,48 +0,0 @@ -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() From 9adab6060c5c7cab3600b84fbc26ef93c735feec Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:37:49 +0300 Subject: [PATCH 23/24] testing paragraphs max nesting (founded max depth - 1) --- app/questions_generator/run.py | 35 ++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py index 6d82c32..5193b89 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -88,12 +88,43 @@ def main(): print(f" - сложность:{diff}") +def recursive_collect_chapter(chapter, depth=0, indent=' '): + """ + Рекурсивно собирает текст главы и всех её детей (включая вложенных), + а также строит строковое представление структуры с указанием глубины, + текста и количества детей на каждом уровне. + + :param chapter: Словарь главы {'text': str, 'child': list of dicts} + :param depth: Текущая глубина рекурсии (начинается с 0) + :param indent: Строка для отступа (по умолчанию два пробела) + :return: (collected_text: str, structure: str) + """ + # Собираем текст текущей главы + text = chapter['text'] + + # Строим строку структуры для текущего уровня + structure = indent * depth + f'Depth {depth}: "{chapter["text"]}" (children: {len(chapter.get("child", []))})' + + # Рекурсивно обрабатываем детей + for child in chapter.get('child', []): + child_text, child_structure = recursive_collect_chapter(child, depth + 1, indent) + text += '\n' + child_text + structure += '\n' + child_structure + + return text, structure + + def main2(): uploader = docx_uploader.DocxUploader() uploader.upload("/app/static/vkr_examples/VKR1.docx") uploader.parse() - uploader.print_info() - uploader.parse_effective_styles() + chapters = uploader.make_chapters('VKR') + for i, ch in enumerate(chapters): + text, structure = recursive_collect_chapter(ch) + print(f'\nГлава {i + 1} текст:') + print(text) + print(f'Глава {i + 1} структура:') + print(structure) if __name__ == "__main__": From 7db5fafca6bcd980f316d1587673d8ea5f9f19fb Mon Sep 17 00:00:00 2001 From: kiyro7 <92889789+kiyro7@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:55:09 +0300 Subject: [PATCH 24/24] first prototype of chapters detection (works) --- app/questions_generator/run.py | 46 ++++++++++++++++------------------ 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/app/questions_generator/run.py b/app/questions_generator/run.py index 5193b89..d8bcd73 100644 --- a/app/questions_generator/run.py +++ b/app/questions_generator/run.py @@ -88,30 +88,29 @@ def main(): print(f" - сложность:{diff}") -def recursive_collect_chapter(chapter, depth=0, indent=' '): +def get_full_chapter_text(chapter): """ - Рекурсивно собирает текст главы и всех её детей (включая вложенных), - а также строит строковое представление структуры с указанием глубины, - текста и количества детей на каждом уровне. - - :param chapter: Словарь главы {'text': str, 'child': list of dicts} - :param depth: Текущая глубина рекурсии (начинается с 0) - :param indent: Строка для отступа (по умолчанию два пробела) - :return: (collected_text: str, structure: str) + Собирает полный текст главы + всех её прямых детей (подразделов/параграфов). + + Возвращает строку с текстом, разделённым переносами строк. """ - # Собираем текст текущей главы - text = chapter['text'] + lines = [chapter["text"].strip()] + + for child in chapter.get("child", []): + child_text = child["text"].strip() + if child_text: # пропускаем пустые + lines.append(child_text) - # Строим строку структуры для текущего уровня - structure = indent * depth + f'Depth {depth}: "{chapter["text"]}" (children: {len(chapter.get("child", []))})' + return "\n".join(lines) - # Рекурсивно обрабатываем детей - for child in chapter.get('child', []): - child_text, child_structure = recursive_collect_chapter(child, depth + 1, indent) - text += '\n' + child_text - structure += '\n' + child_structure - return text, structure +def make_chapters(chapters): + out = [] + for item in chapters: + full_text = get_full_chapter_text(item) + if not (full_text.strip() == (item.get("text") or "").strip()): # если заголовок совпадает с полным текстом, значит это просто глобальный заголовок и, неожиданно, внутри него самого нет текста + out.append(full_text) + return out def main2(): @@ -119,12 +118,9 @@ def main2(): uploader.upload("/app/static/vkr_examples/VKR1.docx") uploader.parse() chapters = uploader.make_chapters('VKR') - for i, ch in enumerate(chapters): - text, structure = recursive_collect_chapter(ch) - print(f'\nГлава {i + 1} текст:') - print(text) - print(f'Глава {i + 1} структура:') - print(structure) + chapters = make_chapters(chapters) + for item in chapters: + print(item, end="\n\n\n\n\n") if __name__ == "__main__":