# -*- coding: utf-8 -*- """ QA Engine для инженерной документации. Диагностическая версия: считает документы, чанки, батчи эмбеддингов и подробно показывает причины, почему индекс не сохранился. """ from __future__ import annotations import math import json import os import pickle import re import tempfile from collections.abc import Mapping from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any, Optional import logging import numpy as np import requests from core.table_extractor import extract_tables_from_results, tables_to_dicts from core.prompts import get_system_prompt from core.answer_formatter import compact_evidence, evidence_key, format_document_answer try: from dotenv import load_dotenv load_dotenv() except ImportError: load_dotenv = None try: from sklearn.feature_extraction.text import TfidfVectorizer except ImportError: TfidfVectorizer = None try: from sentence_transformers import SentenceTransformer except ImportError: SentenceTransformer = None logger = logging.getLogger(__name__) DOCUMENT_REFERENCE_RE = re.compile( r"(? dict[str, Any]: """Словарь для совместимого обмена данными с оркестратором.""" return { "doc_name": self.doc_name, "chunk_id": self.chunk_id, "text": self.text, "score": self.score, "semantic_score": self.semantic_score, "lexical_score": self.lexical_score, "filepath": self.filepath, "metadata": dict(self.metadata), } @classmethod def from_value(cls, value: SearchResult | Mapping[str, Any]) -> SearchResult: """Принимает публичный SearchResult или его словарное представление.""" if isinstance(value, cls): return value if not isinstance(value, Mapping): raise TypeError("Результат поиска должен быть SearchResult или словарём") return cls( doc_name=str(value.get("doc_name", "")), chunk_id=int(value.get("chunk_id", 0) or 0), text=str(value.get("text", "") or ""), score=float(value.get("score", 0) or 0), semantic_score=float(value.get("semantic_score", 0) or 0), lexical_score=float(value.get("lexical_score", 0) or 0), filepath=str(value.get("filepath", "")), metadata=dict(value.get("metadata", {}) or {}), ) @dataclass(slots=True) class QAResponse: """Ответ на вопрос.""" question: str answer: str sources: list[SearchResult] = field(default_factory=list) provider: str = "none" used_llm: bool = False context: str = "" class QASystem: """Система вопросов-ответов по документам.""" def __init__( self, use_llm: bool = False, llm_provider: str = "ollama", model_name: Optional[str] = None, top_k: int = 5, min_score: float = 0.15, ollama_base_url: str = "http://localhost:11434", ollama_model: str = "llama3.1:8b", embedding_model_name: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", use_embeddings: bool = True, semantic_weight: float = 0.7, lexical_weight: float = 0.3, embedding_batch_size: int = 32, ): self.use_llm = use_llm self.llm_provider = (llm_provider or "ollama").strip().lower() self.top_k = max(1, int(top_k)) self.min_score = max(0.0, min(1.0, float(min_score))) self.ollama_base_url = ollama_base_url.rstrip("/") self.ollama_model = model_name if model_name and self.llm_provider in {"ollama", "mixed"} else ollama_model self.embedding_model_name = embedding_model_name self.use_embeddings = use_embeddings self.semantic_weight = semantic_weight self.lexical_weight = lexical_weight self.embedding_batch_size = max(1, int(embedding_batch_size)) self.documents: list[dict[str, Any]] = [] self.chunks: list[dict[str, Any]] = [] self.embedding_model: Any = None self.chunk_embeddings: Any = None self.vectorizer: Any = None self.tfidf_matrix: Any = None self.is_ready = False self.llm_available = False self.last_llm_error = "" self.last_index_diagnostics: dict[str, Any] = {} self.last_save_diagnostics: dict[str, Any] = {} self.last_load_diagnostics: dict[str, Any] = {} if TfidfVectorizer is None: print("⚠️ scikit-learn не установлен") if self.use_embeddings: self._try_load_embedding_model() if self.use_llm: self._validate_llm_config() def _reset_index_diagnostics(self) -> None: self.last_index_diagnostics = { "documents_total": 0, "documents_with_chunks": 0, "doc_type_counts": {}, "chunks_total": 0, "chunks_nonempty": 0, "chunks_skipped_empty": 0, "embeddings_enabled": self.use_embeddings, "embedding_model_loaded": self.embedding_model is not None, "embedding_batches_started": False, "embedding_batch_size": self.embedding_batch_size, "embedding_batches_total": 0, "embedding_texts_total": 0, "tfidf_enabled": TfidfVectorizer is not None, "tfidf_built": False, "success": False, "error": "", } def _try_load_embedding_model(self) -> None: if SentenceTransformer is None: print("⚠️ sentence-transformers не установлен") self.embedding_model = None return try: print(f"📥 Загружаю embedding model: {self.embedding_model_name}") self.embedding_model = SentenceTransformer(self.embedding_model_name) print("✅ Embedding model загружена") except Exception: logger.exception("Не удалось загрузить embedding model %s", self.embedding_model_name) self.embedding_model = None def is_ollama_alive(self) -> bool: try: response = requests.get(f"{self.ollama_base_url}/api/tags", timeout=2) return response.status_code == 200 except requests.RequestException: return False def is_ollama_available(self) -> bool: """Проверяет не только сервер, но и наличие настроенной модели.""" try: response = requests.get(f"{self.ollama_base_url}/api/tags", timeout=2) response.raise_for_status() data = response.json() names = { str(item.get("name") or item.get("model") or "") for item in data.get("models", []) if isinstance(item, dict) } wanted = self.ollama_model if ":" in self.ollama_model else self.ollama_model + ":latest" available = self.ollama_model in names or wanted in names self.last_llm_error = "" if available else f"В Ollama не установлена модель {self.ollama_model}" self.llm_available = available return available except (requests.RequestException, ValueError, TypeError, AttributeError) as exc: self.last_llm_error = f"Ollama недоступна: {type(exc).__name__}: {exc}" self.llm_available = False return False def _validate_llm_config(self) -> None: self.llm_available = False if not self.use_llm: print("ℹ️ LLM отключён") return if self.llm_provider == "ollama": self.llm_available = self.is_ollama_available() print( f"{'✅' if self.llm_available else '⚠️'} " f"Ollama {'доступен' if self.llm_available else 'недоступен'} " f"(base_url={self.ollama_base_url}, model={self.ollama_model})" ) elif self.llm_provider == "mixed": self.llm_available = self.is_ollama_available() print( f"{'✅' if self.llm_available else '⚠️'} Mixed LLM provider, " f"available={self.llm_available}" ) elif self.llm_provider == "none": self.llm_available = False print("ℹ️ LLM provider=none") else: self.llm_available = False print(f"⚠️ Неизвестный LLM provider: {self.llm_provider}") def _select_provider(self) -> str: if not self.use_llm: return "none" if self.llm_provider in {"ollama", "mixed"}: return "ollama" if self.is_ollama_available() else "none" return "none" def get_selected_provider(self) -> str: """Возвращает фактически доступный провайдер (Ollama либо none).""" return self._select_provider() def build_index(self, parsed_docs: list[dict[str, Any]]) -> bool: """Строит индекс по документам с подробной диагностикой батчей.""" self._reset_index_diagnostics() self.is_ready = False self.vectorizer = None self.tfidf_matrix = None self.chunk_embeddings = None try: self.documents = parsed_docs or [] self.chunks = [] self.last_index_diagnostics["documents_total"] = len(self.documents) doc_type_counts: dict[str, int] = {} docs_with_chunks = 0 skipped_empty = 0 for doc in self.documents: doc_name = doc.get("doc_name", "") file_path = doc.get("filepath", doc.get("file_path", "")) file_type = doc.get("filetype", doc.get("file_type", "unknown")) doc_metadata = doc.get("metadata", {}) or {} doc_chunks = doc.get("chunks", []) or [] doc_type_counts[file_type] = doc_type_counts.get(file_type, 0) + 1 if doc_chunks: docs_with_chunks += 1 for chunk in doc_chunks: text = (chunk.get("text") or "").strip() if not text: skipped_empty += 1 continue self.chunks.append( { "doc_name": chunk.get("doc_name", doc_name), "chunk_id": chunk.get("chunk_id", 0), "text": text, "filepath": file_path, "filetype": file_type, "metadata": { **doc_metadata, **(chunk.get("metadata", {}) or {}), }, } ) self.last_index_diagnostics["documents_with_chunks"] = docs_with_chunks self.last_index_diagnostics["doc_type_counts"] = doc_type_counts self.last_index_diagnostics["chunks_total"] = len(self.chunks) self.last_index_diagnostics["chunks_nonempty"] = len(self.chunks) self.last_index_diagnostics["chunks_skipped_empty"] = skipped_empty print("📊 INDEX BUILD DIAGNOSTICS") print(f"📄 Документов получено: {len(self.documents)}") print(f"📄 Документов с чанками: {docs_with_chunks}") print(f"🧩 Всего непустых чанков: {len(self.chunks)}") print(f"🗑️ Пустых чанков пропущено: {skipped_empty}") print(f"🗂️ Типы документов: {doc_type_counts}") if not self.chunks: print("❌ После сборки нет ни одного непустого чанка") self.is_ready = False self.last_index_diagnostics["error"] = "no_nonempty_chunks" return False texts = [c["text"] for c in self.chunks] # TF-IDF if TfidfVectorizer is not None: # Символьные n-граммы находят русские формы «вентиляция»/ # «вентиляции» без дополнительной морфологической библиотеки. vectorizer = TfidfVectorizer( max_features=50000, analyzer="char_wb", ngram_range=(3, 5), min_df=1, sublinear_tf=True, ) self.tfidf_matrix = vectorizer.fit_transform(texts) self.vectorizer = vectorizer self.last_index_diagnostics["tfidf_built"] = True print(f"✅ TF-IDF построен: shape={self.tfidf_matrix.shape}") else: self.vectorizer = None self.tfidf_matrix = None # ЭМБЕДДИНГИ С БАТЧАМИ if self.embedding_model is not None and texts: total_texts = len(texts) batch_size = self.embedding_batch_size total_batches = math.ceil(total_texts / batch_size) self.last_index_diagnostics["embedding_batches_started"] = True self.last_index_diagnostics["embedding_batches_total"] = total_batches self.last_index_diagnostics["embedding_texts_total"] = total_texts print("🚀 ЗАПУСК БАТЧЕЙ ЭМБЕДДИНГОВ") print(f" model={self.embedding_model_name}") print(f" texts={total_texts}") print(f" batch_size={batch_size}") print(f" total_batches={total_batches}") all_embeddings: list[np.ndarray] = [] for batch_idx in range(total_batches): start = batch_idx * batch_size end = min(start + batch_size, total_texts) batch_texts = texts[start:end] print( f" 📦 Batch {batch_idx + 1}/{total_batches}: " f"items={len(batch_texts)} range=[{start}:{end}]" ) try: batch_embeddings = np.asarray(self.embedding_model.encode( batch_texts, normalize_embeddings=True, show_progress_bar=False, ), dtype=np.float32) if batch_embeddings.ndim != 2 or len(batch_embeddings) != len(batch_texts): raise ValueError("Модель вернула неверную размерность эмбеддингов") if not np.isfinite(batch_embeddings).all(): raise ValueError("Модель вернула NaN/inf в эмбеддингах") all_embeddings.append(batch_embeddings) except Exception as exc: logger.exception("Ошибка эмбеддингов; сохраняется лексический поиск") self.last_index_diagnostics["embedding_error"] = str(exc) all_embeddings.clear() break # Объединяем все батчи self.chunk_embeddings = np.vstack(all_embeddings) if all_embeddings else None # type: ignore print( f"{'✅ ЭМБЕДДИНГИ ПОСТРОЕНЫ' if self.chunk_embeddings is not None else '⚠️ ЭМБЕДДИНГИ НЕДОСТУПНЫ'}: " f"shape={None if self.chunk_embeddings is None else self.chunk_embeddings.shape}" ) else: self.chunk_embeddings = None if not self.use_embeddings: print("ℹ️ Эмбеддинги отключены настройкой use_embeddings=False") elif self.embedding_model is None: print("⚠️ Эмбеддинги не построены: embedding model не загружена") self.is_ready = self.tfidf_matrix is not None or self.chunk_embeddings is not None self.last_index_diagnostics["success"] = self.is_ready if not self.is_ready: self.last_index_diagnostics["error"] = "no_search_index" return False print("✅ Индекс успешно построен") return True except Exception as e: self.is_ready = False self.last_index_diagnostics["error"] = str(e) print(f"❌ build_index failed: {e}") import traceback traceback.print_exc() return False def index_documents(self, directory: str | Path) -> bool: try: from core.parser import DocumentParser directory = Path(directory) print(f"📂 index_documents: directory={directory}") parser = DocumentParser(chunk_size=1200, chunk_overlap=200) docs = parser.parse_directory(directory, recursive=True) print(f"📄 parser.parse_directory returned documents={len(docs) if docs else 0}") return self.build_index(docs) except ImportError as e: print(f"❌ Не удалось импортировать parser: {e}") return False except Exception as e: print(f"❌ Ошибка индексации: {e}") import traceback traceback.print_exc() return False def save_index(self, index_path: str | Path) -> bool: """Сохраняет индекс на диск с проверкой.""" self.last_save_diagnostics = { "path": str(index_path), "documents_total": len(self.documents), "chunks_total": len(self.chunks), "has_embeddings": self.chunk_embeddings is not None, "has_tfidf": self.tfidf_matrix is not None, "success": False, "file_exists_after_save": False, "file_size_bytes": 0, "error": "", } temporary_path: Path | None = None try: index_path = Path(index_path) index_path.parent.mkdir(parents=True, exist_ok=True) if not self.documents: print("⚠️ save_index: documents пустой") if not self.chunks: print("⚠️ save_index: chunks пустой") data = { "documents": self.documents, "chunks": self.chunks, "top_k": self.top_k, "min_score": self.min_score, "embedding_model_name": self.embedding_model_name, "use_embeddings": self.use_embeddings, "semantic_weight": self.semantic_weight, "lexical_weight": self.lexical_weight, "chunk_embeddings": self.chunk_embeddings, "vectorizer": self.vectorizer, "tfidf_matrix": self.tfidf_matrix, "ollama_model": self.ollama_model, "llm_provider": self.llm_provider, "diagnostics": self.last_index_diagnostics, } print("💾 SAVE INDEX DIAGNOSTICS:") print(f" path={index_path}") print(f" documents={len(self.documents)}") print(f" chunks={len(self.chunks)}") print(f" embeddings_present={self.chunk_embeddings is not None}") if self.chunk_embeddings is not None: print(f" embeddings_shape={self.chunk_embeddings.shape}") print(f" tfidf_present={self.tfidf_matrix is not None}") # Только полностью записанный файл заменяет рабочий индекс. # Временный файл находится на том же диске для атомарного replace. with tempfile.NamedTemporaryFile( mode="wb", dir=index_path.parent, prefix=f".{index_path.name}.", suffix=".tmp", delete=False, ) as f: temporary_path = Path(f.name) pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL) f.flush() os.fsync(f.fileno()) temporary_path.replace(index_path) temporary_path = None file_exists = index_path.exists() file_size = index_path.stat().st_size if file_exists else 0 self.last_save_diagnostics["file_exists_after_save"] = file_exists self.last_save_diagnostics["file_size_bytes"] = file_size self.last_save_diagnostics["success"] = file_exists and file_size > 0 if not file_exists: print(f"❌ Индекс не сохранился: файл не создан: {index_path}") return False if file_size <= 0: print(f"❌ Индекс не сохранился корректно: пустой файл: {index_path}") return False print(f"✅ Индекс сохранён: {index_path} ({file_size} bytes)") return True except Exception as e: self.last_save_diagnostics["error"] = str(e) logger.exception("Ошибка сохранения индекса %s", index_path) return False finally: if temporary_path is not None: try: temporary_path.unlink(missing_ok=True) except OSError: logger.warning("Не удалось удалить временный файл индекса %s", temporary_path, exc_info=True) def load_index(self, index_path: str | Path) -> bool: """Загружает индекс с диска.""" self.last_load_diagnostics = { "path": str(index_path), "success": False, "file_exists_before_load": False, "file_size_bytes": 0, "error": "", } try: index_path = Path(index_path) self.last_load_diagnostics["file_exists_before_load"] = index_path.exists() if not index_path.exists(): print(f"⚠️ load_index: файл не найден: {index_path}") self.last_load_diagnostics["error"] = "file_not_found" return False self.last_load_diagnostics["file_size_bytes"] = index_path.stat().st_size print( f"📥 Загружаю индекс: {index_path} " f"({self.last_load_diagnostics['file_size_bytes']} bytes)" ) with open(index_path, "rb") as f: data = pickle.load(f) if not isinstance(data, dict): raise ValueError("Неверный формат файла индекса") documents = data.get("documents", []) chunks = data.get("chunks", []) if not isinstance(documents, list) or not isinstance(chunks, list): raise ValueError("documents/chunks должны быть списками") if not chunks or any(not isinstance(chunk, dict) for chunk in chunks): raise ValueError("В индексе отсутствуют корректные фрагменты") vectorizer = data.get("vectorizer") tfidf_matrix = data.get("tfidf_matrix") if vectorizer is None or not callable(getattr(vectorizer, "transform", None)): tfidf_matrix = None if tfidf_matrix is not None and tfidf_matrix.shape[0] != len(chunks): raise ValueError("Число строк TF-IDF не совпадает с числом фрагментов") embeddings = data.get("chunk_embeddings") stored_model = data.get("embedding_model_name") if embeddings is not None: embeddings = np.asarray(embeddings, dtype=np.float32) if ( not self.use_embeddings or stored_model != self.embedding_model_name or embeddings.ndim != 2 or len(embeddings) != len(chunks) or not np.isfinite(embeddings).all() ): logger.warning("Эмбеддинги индекса несовместимы с текущими настройками; используется TF-IDF") embeddings = None if tfidf_matrix is None and (embeddings is None or self.embedding_model is None): raise ValueError("В индексе нет доступных поисковых данных; перестройте индекс") # Настройки текущего запуска и уже загруженную модель нельзя # заменять настройками из старого pickle. self.documents, self.chunks = documents, chunks self.vectorizer, self.tfidf_matrix = vectorizer, tfidf_matrix self.chunk_embeddings = embeddings self.last_index_diagnostics = data.get("diagnostics", {}) self.is_ready = bool(self.chunks) self.last_load_diagnostics["success"] = self.is_ready print("✅ Индекс загружен") print(f" documents={len(self.documents)}") print(f" chunks={len(self.chunks)}") print(f" embeddings_present={self.chunk_embeddings is not None}") if self.chunk_embeddings is not None: print(f" embeddings_shape={self.chunk_embeddings.shape}") print(f" tfidf_present={self.tfidf_matrix is not None}") return self.is_ready except Exception as e: self.last_load_diagnostics["error"] = str(e) logger.exception("Ошибка загрузки индекса %s", index_path) return False def get_diagnostics(self) -> dict[str, Any]: """Возвращает полную диагностику состояния.""" return { "is_ready": self.is_ready, "documents_total": len(self.documents), "chunks_total": len(self.chunks), "has_embeddings": self.chunk_embeddings is not None, "embeddings_shape": str(self.chunk_embeddings.shape) if self.chunk_embeddings is not None else None, "has_tfidf": self.tfidf_matrix is not None, "selected_provider": self.get_selected_provider(), "last_llm_error": self.last_llm_error, "last_index_diagnostics": self.last_index_diagnostics, "last_save_diagnostics": self.last_save_diagnostics, "last_load_diagnostics": self.last_load_diagnostics, } def search( self, query: str, top_k: Optional[int] = None, ) -> list[SearchResult]: """Поиск по индексу (гибридный).""" if not self.is_ready or not self.chunks: return [] query = (query or "").strip() if not query: return [] document_refs = self._document_references(query) allowed = np.ones(len(self.chunks), dtype=bool) if document_refs: allowed = np.asarray([ self._matches_requested_document(chunk.get("doc_name", ""), document_refs) for chunk in self.chunks ]) if not allowed.any(): return [] # Номер СП обозначает фильтр документа, а не тему ответа. Иначе # библиография с этим номером вытесняет содержательный нужный пункт. content_query = DOCUMENT_REFERENCE_RE.sub(" ", query) content_query = re.sub(r"\b(?:по|согласно)\s*$", " ", content_query, flags=re.IGNORECASE).strip() lexical_query = content_query or query top_k = self.top_k if top_k is None else max(0, int(top_k)) if top_k == 0: return [] lexical_scores = np.zeros(len(self.chunks), dtype=np.float32) # type: ignore semantic_scores = np.zeros(len(self.chunks), dtype=np.float32) # type: ignore lexical_available = False semantic_available = False # Лексический поиск (TF-IDF) if self.vectorizer is not None and self.tfidf_matrix is not None: try: query_vec = self.vectorizer.transform([lexical_query]) sim = (self.tfidf_matrix @ query_vec.T).toarray().ravel() lexical_scores = sim.astype(np.float32) # type: ignore lexical_available = bool(query_vec.nnz) except Exception: logger.exception("Ошибка лексического поиска") # Семантический поиск (эмбеддинги) if ( self.use_embeddings and self.embedding_model is not None and self.chunk_embeddings is not None and len(self.chunk_embeddings) == len(self.chunks) ): try: q_emb = self.embedding_model.encode( [lexical_query], normalize_embeddings=True, show_progress_bar=False, ) q_emb = np.asarray(q_emb, dtype=np.float32)[0] # type: ignore semantic_scores = self.chunk_embeddings @ q_emb # type: ignore if not np.isfinite(semantic_scores).all(): raise ValueError("Некорректные оценки семантического поиска") semantic_available = True except Exception: logger.exception("Ошибка семантического поиска; используется TF-IDF") # Гибридная комбинация if semantic_available and lexical_available: semantic_weight = max(0.0, self.semantic_weight) lexical_weight = max(0.0, self.lexical_weight) total_weight = semantic_weight + lexical_weight scores = ( (semantic_weight * semantic_scores + lexical_weight * lexical_scores) / total_weight if total_weight else (semantic_scores + lexical_scores) / 2 ) elif semantic_available: scores = semantic_scores elif lexical_available: scores = lexical_scores else: return [] clause_refs = self._requested_clauses(query) definition_term = self._definition_term(query) normative_query = not definition_term and self._requires_exact_evidence(query) if clause_refs: clause_matches = np.asarray([ any(self._has_clause_start(chunk.get("text", ""), clause) for clause in clause_refs) for chunk in self.chunks ]) allowed &= clause_matches if not allowed.any(): return [] for index, chunk in enumerate(self.chunks): if not allowed[index]: scores[index] = -np.inf continue text = chunk.get("text", "") # Точный номер пункта и заголовок определения — сильные # детерминированные признаки, независимые от модели эмбеддингов. if clause_refs and any(self._has_clause_start(text, clause) for clause in clause_refs): scores[index] = max(scores[index], 0.85 + min(max(float(scores[index]), 0), 1) * 0.1) if definition_term and self._has_definition(text, definition_term): scores[index] = max(scores[index], 0.8 + min(max(float(scores[index]), 0), 1) * 0.1) if normative_query and not clause_refs: evidence = self._normative_excerpt(text, query) if evidence and self._topic_support_score(evidence, query) >= 0.9: scores[index] = max(scores[index], 0.85 + min(max(float(scores[index]), 0), 1) * 0.1) ranked_ids = np.argsort(scores)[::-1][:top_k] # type: ignore results: list[SearchResult] = [] for idx in ranked_ids: score = float(scores[idx]) if not np.isfinite(score) or score <= 0 or score < self.min_score: continue chunk = self.chunks[idx] results.append( SearchResult( doc_name=chunk.get("doc_name", ""), chunk_id=int(chunk.get("chunk_id", 0)), text=chunk.get("text", ""), score=score, semantic_score=float(semantic_scores[idx]) if len(semantic_scores) else 0.0, lexical_score=float(lexical_scores[idx]) if len(lexical_scores) else 0.0, filepath=chunk.get("filepath", ""), metadata=chunk.get("metadata", {}) or {}, ) ) return results @staticmethod def _document_references(text: str) -> list[tuple[str, str]]: """Извлекает обозначения документов, включая сокращённое «СП 60».""" return [(re.sub(r"\s+", "", kind).lower(), number) for kind, number in DOCUMENT_REFERENCE_RE.findall(text or "")] @classmethod def _matches_requested_document(cls, name: str, references: list[tuple[str, str]]) -> bool: for kind, number in cls._document_references(name): if any(kind == requested_kind and (number == requested_number or number.startswith(requested_number + ".")) for requested_kind, requested_number in references): return True return False @staticmethod def _requested_clauses(query: str) -> list[str]: return re.findall(r"(?:\bпункт(?:а|е|ы|ов)?|\bп\.)\s*(\d+(?:\.\d+)+)(?!\d)", query, flags=re.IGNORECASE) @staticmethod def _has_clause_start(text: str, clause: str) -> bool: return bool(re.search(r"(?:^|\n)\s*" + re.escape(clause) + r"(?:\s|[)]|$)", text)) @staticmethod def _definition_term(question: str) -> str: match = re.search( r"(?:что\s+(?:такое|означает|значит)|определение(?:\s+термина)?|^термин|^понятие)\s+(.+)", question, flags=re.IGNORECASE, ) if not match: return "" term = DOCUMENT_REFERENCE_RE.sub("", match.group(1)) # Условия поиска и второе предложение не входят в название термина. term = re.split(r"[?!.;\n]|\s+(?:по|согласно|из|в)\s+", term, maxsplit=1, flags=re.IGNORECASE)[0] term = re.sub(r"\s+(?:по|согласно|из|в)\s*$", "", term, flags=re.IGNORECASE) return term.strip(" \t?!.«»\"'").lower() @classmethod def _has_definition(cls, text: str, term: str) -> bool: return bool(cls._definition_excerpt(text, term)) @staticmethod def _evidence_units(text: str) -> list[str]: """Сохраняет условия числовой нормы вместе с её пунктом.""" # Не разрезаем пункт на отдельные предложения: это теряет исключения. numbered = bool(re.search(r"(?:^|\n)\s*\d+(?:\.\d+)+\s+", text or "")) boundary = r"\n(?=\s*\d+(?:\.\d+)+\s+)" if numbered else r"\n\s*\n" return [part.strip() for part in re.split(boundary, text or "") if part.strip()] @classmethod def _definition_excerpt(cls, text: str, term: str) -> str: """Прямое определение с заголовком; упоминание/ссылка недостаточны.""" if not term: return "" pattern = re.compile( r"(?:^|\n|(?<=[.!?])\s+)\s*(?:\d+(?:\.\d+)*\s+)?" + re.escape(term) + r"\s*(?::|[—–]|\s-\s|\bэто\s)\s*(\S.+)", re.IGNORECASE, ) for unit in cls._evidence_units(text): match = pattern.search(unit) if not match: continue prefix = unit[:match.start()] if re.search(r"библиограф|нормативные\s+ссылки", prefix, re.IGNORECASE): continue value = match.group(1) if re.match(r"(?:см\.\s*|по\s+|согласно\s+)?(?:ГОСТ|СП|СНиП|\[\d+\])", value, re.IGNORECASE): continue return unit[match.start():].strip() return "" @classmethod def _requires_exact_evidence(cls, question: str) -> bool: """ Строгая выдержка нужна для: - определений; - конкретного пункта; - явно запрошенной нормы/требования. Само указание СП/ГОСТ является только фильтром документа. """ return bool( cls._definition_term(question) or cls._requested_clauses(question) or re.search( r"норм[аыуе]|норматив|" r"требован|требует|" r"допуска|допустим|" r"должн|следует|" r"минимальн|максимальн|" r"не\s+(?:менее|более)", question, re.IGNORECASE, ) ) @classmethod def _topic_support_score(cls, text: str, question: str) -> float: query = DOCUMENT_REFERENCE_RE.sub("", question) query = re.sub(r"(?:пункт\w*|п\.)\s*\d+(?:\.\d+)*", "", query, flags=re.IGNORECASE) generic = ("како", "каку", "каки", "кака", "определ", "такое", "означ", "значит", "термин", "поняти", "соглас", "треб", "норм", "допус", "долж", "следу", "найти", "минималь", "максималь", "обеспеч", "предусматр", "явля", "составля") words = cls._query_terms(query) - {"при", "или", "либо", "более", "менее", "пункт", "такой"} stems = {word[:max(4, min(7, len(word) - 2))] for word in words if not word.isdigit() and not word.startswith(generic)} if not stems: return float(bool(cls._requested_clauses(question))) lower = text.lower().replace("ё", "е") hits = sum(stem.replace("ё", "е") in lower for stem in stems) return hits / len(stems) @classmethod def _has_topic_support(cls, text: str, question: str) -> bool: return cls._topic_support_score(text, question) >= 0.5 @classmethod def _normative_excerpt(cls, text: str, question: str) -> str: clauses = cls._requested_clauses(question) candidates = [] for unit in cls._evidence_units(text): if re.match(r"(?:\[\d+\]\s*)?(?:ГОСТ|СП|СНиП)\s*\d", unit, re.IGNORECASE): continue if re.search(r"^(?:\d+\s+)?(?:Библиография|Нормативные ссылки)\b", unit, re.IGNORECASE): continue if clauses: if any(cls._has_clause_start(unit, clause) for clause in clauses): return unit continue if not cls._has_topic_support(unit, question): continue if not re.search(r"должн|требует|следует|необходим|допуска|не\s+(?:менее|более)|" r"принима[ею]|предусматр|устанавлива|запрещ", unit, re.IGNORECASE): continue candidates.append(unit) if not candidates: return "" # Только выбор, без генерации или пересказа числовых ограничений. return max(candidates, key=lambda unit: cls._topic_support_score(unit, question)) def answer( self, question: str, top_k: Optional[int] = None, ) -> dict[str, Any]: """Отвечает на вопрос.""" results = self.search(question, top_k=top_k) return self.answer_from_results(question, results) def answer_from_results( self, question: str, results: list[SearchResult | Mapping[str, Any]], ) -> dict[str, Any]: """Отвечает по уже отобранным фрагментам без повторного поиска. Оформление принадлежит одному formatter. Модель может выбрать только проверяемые выдержки: номер ссылки сам по себе не подтверждает вывод. """ normalized_results = self._normalize_search_results(results) # Оркестратор расширяет запрос и может принести фрагменты из других # документов. Повторно применяем ограничения исходного вопроса. document_refs = self._document_references(question) if document_refs: normalized_results = [item for item in normalized_results if self._matches_requested_document(item.doc_name, document_refs)] clause_refs = self._requested_clauses(question) if clause_refs: normalized_results = [item for item in normalized_results if any(self._has_clause_start(item.text, clause) for clause in clause_refs)] definition_term = self._definition_term(question) exact_evidence = self._requires_exact_evidence(question) if exact_evidence: supported = [] for item in normalized_results: excerpt = (self._definition_excerpt(item.text, definition_term) if definition_term else self._normative_excerpt(item.text, question)) if excerpt: supported.append(replace(item, text=excerpt)) normalized_results = supported if not definition_term: # Семантически близкая норма для другого режима (охлаждение / # радиационный обогрев) не должна вытеснять более точное условие. normalized_results.sort(key=lambda item: self._topic_support_score(item.text, question), reverse=True) else: normalized_results = [item for item in normalized_results if self._has_topic_support(item.text, question)] if not normalized_results: return { "answer": format_document_answer([], insufficient=True), "answer_formatted": True, "sources": [], "tables": [], "formulas": [], "provider": "none", "used_llm": False, "context": "", "confidence": 0.0, "needs_clarification": True, "questions": ["Какой документ или раздел нужно проверить?"], "grounded": True, "evidence_mode": "insufficient", } # Ограничение контекста удерживает запрос в памяти локальной модели. # Копии не изменяют исходные результаты поиска и сам индекс. selected_results = [ replace(item, text=(item.text if exact_evidence else self._context_excerpt(item.text, question))) for item in normalized_results ] # Повторный отбор нужен после выделения выдержки: разные перекрывающиеся # чанки могут содержать один и тот же пункт. Номера чанков не доказательства. selected_results = self._deduplicate_context(selected_results)[:max(1, min(self.top_k, 6))] context = self._build_context(selected_results) sources = self._build_sources(selected_results) try: tables = tables_to_dicts(extract_tables_from_results(selected_results, min_rows=1)) except (AttributeError, KeyError, TypeError, ValueError): logger.exception("Не удалось извлечь таблицы из найденных фрагментов") tables = [] payload = { "answer_formatted": True, "sources": sources, "tables": tables, "formulas": self._extract_formulas_from_results(selected_results), "context": context, "confidence": round(max(0.0, min(1.0, max(item.score for item in selected_results))), 3), "needs_clarification": False, "questions": [], "grounded": True, "evidence_mode": "exact_excerpt" if exact_evidence else "retrieved_context", } self.last_llm_error = "" if exact_evidence: primary = selected_results[:1] return {**payload, "sources": self._build_sources(primary), "answer": format_document_answer(self._formatter_excerpts(primary)), "provider": "none", "used_llm": False} provider = self._select_provider() if provider != "none": prompt = self._build_prompt(question, context) llm_answer = self._ask_ollama(prompt) evidence = self._validated_model_evidence(llm_answer, selected_results) if llm_answer else None if evidence == []: return {**payload, "answer": format_document_answer([], insufficient=True), "sources": [], "tables": [], "formulas": [], "confidence": 0.0, "needs_clarification": True, "evidence_mode": "insufficient", "provider": provider, "used_llm": True} if evidence: return { **payload, "answer": format_document_answer(self._formatter_excerpts(evidence)), "sources": self._build_sources(evidence), "provider": provider, "used_llm": True, } if llm_answer: logger.warning( "Ответ %s отклонён: выдержки не подтверждены переданным контекстом", provider, ) self.last_llm_error = "Ответ модели не содержит проверяемых выдержек из найденных фрагментов" fallback_answer = self._generate_extract_answer(selected_results, question=question) return { **payload, "answer": fallback_answer, "sources": self._build_sources(selected_results[:1]), "provider": "none", "used_llm": False, "llm_error": self.last_llm_error, } @staticmethod def _normalize_search_results( results: list[SearchResult | Mapping[str, Any]] | None, ) -> list[SearchResult]: normalized: list[SearchResult] = [] seen: set[str] = set() for value in results or []: try: item = SearchResult.from_value(value) except (TypeError, ValueError, OverflowError): logger.warning("Пропущен некорректный результат поиска", exc_info=True) continue key = evidence_key(item.text) if item.text.strip() and np.isfinite(item.score) and key not in seen: normalized.append(item) seen.add(key) return normalized @classmethod def _deduplicate_context(cls, results: list[SearchResult]) -> list[SearchResult]: """Убирает повторные абзацы/пункты, сохраняя разные условия и числа.""" selected = [] seen: set[str] = set() for item in results: units = [] for unit in cls._evidence_units(compact_evidence(item.text)): key = evidence_key(unit) if key and key not in seen: units.append(unit) seen.add(key) if units: selected.append(replace(item, text="\n\n".join(units))) return selected @staticmethod def _formatter_excerpts(results: list[SearchResult]) -> list[dict[str, Any]]: return [{**item.to_dict(), "reference_id": index} for index, item in enumerate(results, start=1)] @classmethod def _validated_model_evidence( cls, answer: str, results: list[SearchResult], ) -> list[SearchResult] | None: """Разрешает только полные исходные абзацы/пункты, без пересказа. Даже дословная часть пункта может потерять отрицание или исключение. Поэтому проверяем целую единицу доказательства, а не подстроку/ссылку. None означает ошибку протокола, [] — явное отсутствие подтверждения. """ try: raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", answer.strip(), flags=re.IGNORECASE) data = json.loads(raw) if not isinstance(data, dict): return None if data == {"insufficient": True}: return [] if set(data) != {"evidence"} or not isinstance(data["evidence"], list) or not data["evidence"]: return None validated = [] seen: set[str] = set() for entry in data["evidence"]: if not isinstance(entry, dict) or set(entry) != {"source_id", "quote"}: return None reference = entry["source_id"] if type(reference) is not int or not 1 <= reference <= len(results) or not isinstance(entry["quote"], str): return None source = results[reference - 1] # Нормализуем только пробельные символы; регистр/числа/отрицания # модели не исправляем. Возвращаем оригинал, не текст модели. quote = re.sub(r"\s+", " ", entry["quote"]).strip() original = next((unit for unit in cls._evidence_units(source.text) if re.sub(r"\s+", " ", unit).strip() == quote), None) if not original or "…" in original: return None key = evidence_key(original) if key not in seen: validated.append(replace(source, text=original)) seen.add(key) return validated[:3] except (ValueError, TypeError): return None @classmethod def _context_excerpt(cls, text: str, question: str) -> str: """Сохраняет до 1600 символов вокруг предложения по теме вопроса.""" if len(text) <= 1600: return text excerpt = cls._select_evidence_excerpt(text, question) position = text.find(excerpt.rstrip("…")) start = max(0, position - 250) if position >= 0 else 0 if start: line_start = text.rfind("\n", max(0, start - 150), start) if line_start >= 0: start = line_start + 1 end = min(len(text), start + 1580) return ("…\n" if start else "") + text[start:end] + ("\n…" if end < len(text) else "") def find_definition(self, term: str) -> dict[str, Any]: """Ищет определение термина.""" query = f"определение {term}" results = self.search(query, top_k=5) for item in results: sentence = self._definition_excerpt(item.text, term) if sentence: return {"found": True, "definition": sentence, "source": item.doc_name} return {"found": False, "definition": "", "source": ""} @staticmethod def _build_context(results: list[SearchResult]) -> str: """Собирает контекст.""" parts = [] for i, result in enumerate(results[:6], start=1): metadata = result.metadata or {} location = "" if metadata.get("page"): location = f"; страница {metadata['page']}" elif metadata.get("page_numbers"): location = f"; страницы {metadata['page_numbers']}" if metadata.get("table_title"): location += f"; {metadata['table_title']}" parts.append( f"[Источник {i}] {result.doc_name[:200]}\n" f"Фрагмент #{result.chunk_id}{location[:150]}\n" f"{result.text[:1600]}" ) return "\n\n".join(parts) @staticmethod def _build_sources(results: list[SearchResult]) -> list[dict[str, Any]]: """Собирает источники с теми же номерами, что и в контексте LLM.""" sources: list[dict[str, Any]] = [] for reference_id, item in enumerate(results, start=1): metadata = dict(item.metadata or {}) source = { "reference_id": reference_id, "doc_name": item.doc_name, "chunk_id": item.chunk_id, "score": item.score, "filepath": item.filepath, } for key in ("page", "page_numbers", "sheet", "table_title", "location"): if metadata.get(key) not in (None, "", []): source[key] = metadata[key] sources.append(source) return sources @staticmethod def _extract_formulas_from_results(results: list[SearchResult]) -> list[dict[str, Any]]: """Извлекает формулы.""" formulas = [] seen = set() for result in results[:3]: matches = re.findall(r"[A-Za-zА-Яа-я0-9_]+\s*=\s*[^=\n]{3,120}", result.text) for match in matches[:3]: if match not in seen: seen.add(match) formulas.append({ "raw": match, "variables": re.findall(r"[A-Za-zА-Яа-я_]+", match), "source": result.doc_name, }) return formulas @staticmethod def _best_sentence_for_term(text: str, term: str) -> str: sentences = re.split(r"(?<=[.!?])\s+", text) term_lower = term.lower() best = "" best_score = -1.0 for sentence in sentences: lower = sentence.lower() if term_lower not in lower: continue # чем короче — тем лучше; чем раньше — тем лучше length_penalty = 1.0 / (1 + len(sentence) / 200.0) position_bonus = 1.0 if lower.find(term_lower) < 100 else 0.7 score = length_penalty * position_bonus if score > best_score: best_score = score best = sentence.strip() return best def _ask_ollama(self, prompt: str) -> Optional[str]: """Запрос к Ollama.""" try: response = requests.post( f"{self.ollama_base_url}/api/generate", json={ "model": self.ollama_model, "prompt": prompt, "stream": False, "format": "json", "options": {"temperature": 0.0, "num_predict": 700, "num_ctx": 8192}, }, timeout=120, ) response.raise_for_status() data = response.json() answer = data.get("response") if not isinstance(answer, str): raise ValueError("Ollama вернула ответ без текстового поля response") self.last_llm_error = "" return answer.strip() or None except (requests.RequestException, ValueError, TypeError, AttributeError) as e: self.last_llm_error = f"Ollama: {type(e).__name__}: {e}" logger.exception("Ошибка запроса к Ollama") return None @staticmethod def _build_prompt(question: str, context: str) -> str: """Формирует промпт.""" return f""" {get_system_prompt()} НАЧАЛО КОНТЕКСТА {context} КОНЕЦ КОНТЕКСТА Вопрос: {question[:3000]} """.strip() @staticmethod def _has_valid_citations(answer: str, source_count: int) -> bool: """Только синтаксис ссылок, не проверка содержательной обоснованности.""" references = [ int(value) for value in re.findall(r"\[Источник\s+(\d+)\]", answer, flags=re.IGNORECASE) ] return bool(references) and all(1 <= value <= source_count for value in references) @staticmethod def _query_terms(question: str) -> set[str]: stopwords = { "какой", "какая", "какие", "как", "что", "это", "для", "про", "нужно", "нужен", "нужна", "пожалуйста", "покажи", "найди", "расскажи", "документ", "документа", "базе", "знаний", } return { token.lower() for token in re.findall(r"[A-Za-zА-Яа-яЁё0-9_]{3,}", question or "") if token.lower() not in stopwords } @classmethod def _select_evidence_excerpt(cls, text: str, question: str) -> str: """Выбирает короткое дословное доказательство вместо сырого чанка.""" clean = re.sub(r"[ \t]+", " ", text or "").strip() if not clean: return "" terms = cls._query_terms(question) clauses = cls._requested_clauses(question) definition = cls._definition_term(question) candidates = re.split(r"(?<=[.!?])\s+|\n+", clean) scored: list[tuple[int, int, str]] = [] for position, candidate in enumerate(candidates): candidate = candidate.strip() if len(candidate) < 15 or re.match(r"^\[(?:СТРАНИЦА|ТАБЛИЦА|ИСТОЧНИК)\b", candidate, flags=re.IGNORECASE): continue lower = candidate.lower() hits = sum(1 for term in terms if term in lower) if any(cls._has_clause_start(candidate, clause) for clause in clauses): hits += 100 if definition and cls._has_definition(candidate, definition): hits += 90 scored.append((hits, -position, candidate)) if scored: scored.sort(reverse=True) excerpt = scored[0][2] position = -scored[0][1] # Не теряем условие/исключение сразу после выбранного предложения. if position + 1 < len(candidates): following = candidates[position + 1].strip() if re.match(r"^(?:При|Если|Кроме|Для этого|В этом случае|В противном случае)\b", following): if len(excerpt) + len(following) < 750: excerpt += " " + following else: excerpt = clean if len(excerpt) > 750: excerpt = excerpt[:750].rsplit(" ", 1)[0].rstrip() + "…" return excerpt @classmethod def _generate_extract_answer( cls, results: list[SearchResult], question: str = "", ) -> str: """Тот же formatter для fallback; без служебных предупреждений об LLM.""" if not results: return format_document_answer([], insufficient=True) primary = results[0] units = cls._evidence_units(primary.text) relevant = max(units, key=lambda unit: cls._topic_support_score(unit, question)) return format_document_answer(cls._formatter_excerpts([replace(primary, text=relevant)])) def _run_self_tests() -> None: """Офлайн-проверки контракта, поиска, контекста и восстановления индекса.""" import tempfile qa = QASystem(use_llm=False, use_embeddings=False, min_score=0.05) docs = [{"doc_name": "Вентиляция.docx", "chunks": [{ "chunk_id": 1, "text": "Вентиляция обеспечивает организованный воздухообмен в помещениях.", "metadata": {"page": 4}, }]}] assert qa.build_index(docs) found = qa.search("Как устроена вентиляция?") assert found and found[0].doc_name == "Вентиляция.docx" assert qa.search("вентиляции")[0].doc_name == "Вентиляция.docx" lexical_score = found[0].score qa.chunk_embeddings = np.ones((1, 3), dtype=np.float32) assert abs(qa.search("Как устроена вентиляция?")[0].score - lexical_score) < 1e-6 answer = qa.answer_from_results("вентиляция", [found[0].to_dict()]) assert "[Источник 1]" in answer["answer"] and answer["sources"][0]["page"] == 4 assert not answer["used_llm"] and answer["provider"] == "none" assert qa.answer_from_results("нет данных", [])["needs_clarification"] assert not qa._has_valid_citations("Нет источников", 1) assert not qa._has_valid_citations("Норма [Источник 2]", 1) assert qa._has_valid_citations("Факт [Источник 1]", 1) assert qa._definition_term("Что такое рабочая зона по СП 60.13330? Найди определение.") == "рабочая зона" assert qa._has_definition("3.1 вентиляция: Организованный воздухообмен.", "вентиляция") assert not qa._has_definition("ГОСТ 12.1.005 Воздух рабочей зоны.", "рабочая зона") assert qa._has_topic_support("Вентиляция обеспечивает воздухообмен.", "требования к вентиляции") assert not qa._has_topic_support("Вентиляция обеспечивает воздухообмен.", "орбита Нептуна") context = qa._build_context([replace(found[0], text="а" * 30000)] * 10) assert len(context) < 12000 and "страница 4" in context assert "НАЧАЛО КОНТЕКСТА" in qa._build_prompt("вопрос", context) with tempfile.TemporaryDirectory() as temp_dir: index_path = Path(temp_dir) / "index.pkl" assert qa.save_index(index_path) target = QASystem(use_llm=False, use_embeddings=False, top_k=2, min_score=0.02, ollama_model="local-test-model", embedding_model_name="another-model") assert target.load_index(index_path) assert target.top_k == 2 and target.min_score == 0.02 assert target.ollama_model == "local-test-model" assert target.embedding_model_name == "another-model" and target.chunk_embeddings is None assert target.search("вентиляции") if __name__ == "__main__": _run_self_tests() print("qa_engine: self-tests passed") # ИСПРАВЛЕНО: синтаксис; контракт SearchResult/AgentLoop; только Ollama с проверкой модели; устойчивый TF-IDF/semantic поиск; фильтры СП/пунктов и определения; атомарный индекс без замены настроек; ограниченный контекст, ссылки и точные выдержки; офлайн-тесты.