diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 658ee53..1e73725 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,28 @@ { - "name": "Search Dev", - "dockerComposeFile": "docker-compose.yml", - "service": "backend", - "workspaceFolder": "/app", - "extensions": ["ms-python.python"], - "remoteUser": "root" + "name": "Document Search Dev", + "dockerComposeFile": "../docker-compose.yml", // Путь относительно .devcontainer папки + "service": "backend", // Сервис, к которому подключается VS Code + "workspaceFolder": "/app", // Рабочая папка внутри контейнера backend + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", // Поддержка Python + "ms-python.vscode-pylance", // IntelliSense + "ms-python.flake8", // Линтер (или ruff) + "ms-python.mypy" // Проверка типов + // Можно добавить другие полезные расширения + ], + "settings": { + "python.pythonPath": "/usr/local/bin/python", + "python.linting.flake8Enabled": true, + "python.linting.pylintEnabled": false, + "python.formatting.provider": "black", // Или autopep8 + "python.analysis.typeCheckingMode": "basic" // Включаем MyPy + } + } + }, + // Запуск от не-root пользователя (если базовый образ python это позволяет) + // "remoteUser": "vscode" // Или другое имя пользователя, созданного в Dockerfile + "remoteUser": "root" // Оставляем root для простоты, т.к. базовый образ python его использует } + diff --git a/backend/Dockerfile b/backend/Dockerfile index 8f32eaa..46e8c42 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,10 +1,20 @@ +# backend/Dockerfile + FROM python:3.11 WORKDIR /app +# Копируем сначала requirements, чтобы кэшировать слой установки зависимостей COPY requirements.txt . +# Устанавливаем зависимости приложения и тестов +# Используем --no-cache-dir для уменьшения размера образа RUN pip install --no-cache-dir -r requirements.txt +# Копируем остальной код приложения и тесты COPY . . +# Команда по умолчанию для запуска приложения CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] + +# Пример команды для запуска тестов (можно использовать при CI/CD или вручную) +# RUN pytest diff --git a/backend/app.py b/backend/app.py index ada9a0b..2d4d191 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,24 +1,116 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Query, Depends from fastapi.responses import FileResponse import requests import os +from typing import List, Dict, Any, Optional from dotenv import load_dotenv +import logging +# Загрузка переменных окружения (например, из .env) load_dotenv() -FILES_DIR = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage") -SEARCH_ENGINE = "http://meilisearch:7700" -app = FastAPI() +# Настройка логирования +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) -@app.get("/search") -def search(q: str): - response = requests.get(f"{SEARCH_ENGINE}/indexes/documents/search", params={"q": q}) - results = response.json() - return {"results": results.get("hits", [])} +# Конфигурация +FILES_DIR: str = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage") # Путь монтирования в Docker +SEARCH_ENGINE_URL: str = os.getenv("MEILI_URL", "http://meilisearch:7700") +MEILI_API_KEY: Optional[str] = os.getenv("MEILI_MASTER_KEY") # Используйте Master Key или Search API Key +INDEX_NAME: str = "documents" + +app = FastAPI( + title="Document Search API", + description="API для поиска по локальным документам и их получения", + version="0.2.0", +) + +# Зависимость для получения HTTP-клиента (лучше использовать один клиент) +# В реальном приложении можно использовать httpx.AsyncClient +# Для простоты пока оставляем requests +def get_search_session() -> requests.Session: + """Создает сессию requests с заголовками для Meilisearch.""" + session = requests.Session() + headers = {} + if MEILI_API_KEY: + headers["Authorization"] = f"Bearer {MEILI_API_KEY}" + session.headers.update(headers) + return session + +@app.get("/search", response_model=Dict[str, List[Dict[str, Any]]], summary="Поиск документов") +async def search( + q: str = Query(..., description="Поисковый запрос"), + limit: int = Query(20, ge=1, le=100, description="Максимальное количество результатов"), + session: requests.Session = Depends(get_search_session) +) -> Dict[str, List[Dict[str, Any]]]: + """ + Выполняет поиск документов в индексе Meilisearch. + """ + search_url = f"{SEARCH_ENGINE_URL}/indexes/{INDEX_NAME}/search" + params = {"q": q, "limit": limit, "attributesToHighlight": ["content"]} # Запрашиваем подсветку + try: + response = session.post(search_url, json=params) # Meilisearch рекомендует POST для поиска с параметрами + response.raise_for_status() # Вызовет исключение для кодов 4xx/5xx + results = response.json() + logger.info(f"Поиск по запросу '{q}' вернул {len(results.get('hits', []))} результатов") + # Возвращаем только нужные поля, включая _formatted для подсветки + hits = [] + for hit in results.get("hits", []): + # Убираем полный content, если он большой, оставляем только id и _formatted + formatted_hit = hit.get("_formatted", {"id": hit.get("id", "N/A"), "content": "..."}) + formatted_hit["id"] = hit.get("id", "N/A") # Убедимся, что id всегда есть + hits.append(formatted_hit) + + return {"results": hits} + + except requests.exceptions.RequestException as e: + logger.error(f"Ошибка при обращении к Meilisearch ({search_url}): {e}") + raise HTTPException(status_code=503, detail="Сервис поиска временно недоступен") + except Exception as e: + logger.error(f"Неожиданная ошибка при поиске: {e}") + raise HTTPException(status_code=500, detail="Внутренняя ошибка сервера при поиске") + + +@app.get("/files/{filename}", summary="Получение файла документа") +async def get_file(filename: str) -> FileResponse: + """ + Возвращает файл по его имени. + Используется для скачивания файлов, найденных через поиск. + """ + if not filename or ".." in filename or "/" in filename: + logger.warning(f"Попытка доступа к некорректному имени файла: {filename}") + raise HTTPException(status_code=400, detail="Некорректное имя файла") -@app.get("/files/{filename}") -def get_file(filename: str): file_path = os.path.join(FILES_DIR, filename) - if os.path.exists(file_path): + # Проверка безопасности: убеждаемся, что путь действительно внутри FILES_DIR + if not os.path.abspath(file_path).startswith(os.path.abspath(FILES_DIR)): + logger.error(f"Попытка доступа за пределы разрешенной директории: {file_path}") + raise HTTPException(status_code=403, detail="Доступ запрещен") + + if os.path.exists(file_path) and os.path.isfile(file_path): + logger.info(f"Отдаем файл: {filename}") + # media_type можно определять более точно, если нужно return FileResponse(file_path, filename=filename) - return {"error": "Файл не найден"} + else: + logger.warning(f"Запрошенный файл не найден: {filename} (путь {file_path})") + raise HTTPException(status_code=404, detail="Файл не найден") + +# Можно добавить эндпоинт для статуса системы, проверки подключения к MeiliSearch и т.д. +@app.get("/health", summary="Проверка состояния сервиса") +async def health_check(session: requests.Session = Depends(get_search_session)) -> Dict[str, str]: + """Проверяет доступность бэкенда и Meilisearch.""" + meili_status = "недоступен" + try: + health_url = f"{SEARCH_ENGINE_URL}/health" + response = session.get(health_url) + response.raise_for_status() + if response.json().get("status") == "available": + meili_status = "доступен" + except requests.exceptions.RequestException: + pass # Статус останется "недоступен" + except Exception as e: + logger.error(f"Неожиданная ошибка при проверке здоровья Meilisearch: {e}") + + + return {"status": "ok", "meilisearch_status": meili_status} + diff --git a/backend/indexer.py b/backend/indexer.py index 64d1116..1e341a8 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -1,47 +1,297 @@ import os import requests -from pdfminer.high_level import extract_text -from ebooklib import epub +import time +import logging +from pathlib import Path +from typing import Optional, List, Dict, Any, Tuple, Set +from pdfminer.high_level import extract_text as pdf_extract_text +from pdfminer.pdfparser import PDFSyntaxError +from ebooklib import epub, ITEM_DOCUMENT from bs4 import BeautifulSoup from dotenv import load_dotenv +# Загрузка переменных окружения load_dotenv() -FILES_DIR = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage") -SEARCH_ENGINE = "http://meilisearch:7700" -INDEX_NAME = "documents" -def extract_text_from_pdf(pdf_path): - return extract_text(pdf_path) +# Настройка логирования +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) -def extract_text_from_epub(epub_path): - book = epub.read_epub(epub_path) - text = [] - for item in book.get_items(): - if item.get_type() == 9: +# Конфигурация +FILES_DIR: str = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage") +SEARCH_ENGINE_URL: str = os.getenv("MEILI_URL", "http://meilisearch:7700") +MEILI_API_KEY: Optional[str] = os.getenv("MEILI_MASTER_KEY") # Используйте Master Key или Index API Key +INDEX_NAME: str = "documents" +BATCH_SIZE: int = 100 # Количество документов для отправки в Meilisearch за раз + +# --- Функции извлечения текста --- + +def extract_text_from_txt(file_path: Path) -> str: + """Извлекает текст из TXT файла, пробуя разные кодировки.""" + encodings_to_try = ['utf-8', 'cp1251', 'latin-1'] + for encoding in encodings_to_try: + try: + return file_path.read_text(encoding=encoding) + except UnicodeDecodeError: + continue + except Exception as e: + raise IOError(f"Не удалось прочитать TXT файл {file_path} даже после попыток смены кодировки.") from e + # Если ни одна кодировка не подошла + logger.warning(f"Не удалось определить кодировку для TXT файла: {file_path.name}. Пропускаем.") + raise ValueError(f"Unknown encoding for {file_path.name}") + + +def extract_text_from_pdf(file_path: Path) -> str: + """Извлекает текст из PDF файла.""" + try: + return pdf_extract_text(str(file_path)) + except PDFSyntaxError as e: + raise ValueError(f"Ошибка синтаксиса PDF: {file_path.name}") from e + except Exception as e: + # Ловим другие возможные ошибки pdfminer + raise IOError(f"Не удалось обработать PDF файл {file_path.name}") from e + +def extract_text_from_epub(file_path: Path) -> str: + """Извлекает текст из EPUB файла.""" + try: + book = epub.read_epub(str(file_path)) + text_parts: List[str] = [] + for item in book.get_items_of_type(ITEM_DOCUMENT): soup = BeautifulSoup(item.content, "html.parser") - text.append(soup.get_text()) - return "\n".join(text) + # Удаляем скрипты и стили, чтобы не индексировать их содержимое + for script_or_style in soup(["script", "style"]): + script_or_style.decompose() + # Получаем текст, разделяя блоки параграфами для лучшей читаемости + # Используем strip=True для удаления лишних пробелов по краям + block_text = soup.get_text(separator='\n', strip=True) + if block_text: + text_parts.append(block_text) + return "\n\n".join(text_parts) # Разделяем контент разных HTML-файлов двойным переносом строки + except KeyError as e: + # Иногда возникает при проблемах с оглавлением или структурой epub + raise ValueError(f"Ошибка структуры EPUB файла: {file_path.name}, KeyError: {e}") from e + except Exception as e: + raise IOError(f"Не удалось обработать EPUB файл {file_path.name}") from e -def index_files(): - docs = [] - for filename in os.listdir(FILES_DIR): - file_path = os.path.join(FILES_DIR, filename) +# --- Функции взаимодействия с Meilisearch --- - if filename.endswith(".txt"): - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - elif filename.endswith(".pdf"): +def get_meili_client() -> requests.Session: + """Создает и настраивает HTTP клиент для Meilisearch.""" + session = requests.Session() + headers = {} + if MEILI_API_KEY: + headers['Authorization'] = f'Bearer {MEILI_API_KEY}' + session.headers.update(headers) + return session + +def get_indexed_files(client: requests.Session) -> Dict[str, float]: + """Получает список ID и время модификации проиндексированных файлов из Meilisearch.""" + indexed_files: Dict[str, float] = {} + offset = 0 + limit = 1000 # Получаем по 1000 за раз + url = f"{SEARCH_ENGINE_URL}/indexes/{INDEX_NAME}/documents" + params = {"limit": limit, "fields": "id,file_mtime"} + + while True: + try: + params["offset"] = offset + response = client.get(url, params=params) + response.raise_for_status() + data = response.json() + results = data.get("results", []) + if not results: + break # Больше нет документов + + for doc in results: + # Убедимся, что file_mtime существует и является числом + mtime = doc.get("file_mtime") + if isinstance(mtime, (int, float)): + indexed_files[doc['id']] = float(mtime) + else: + # Если времени модификации нет, считаем, что файл нужно переиндексировать + indexed_files[doc['id']] = 0.0 + + offset += len(results) + + # Защита от бесконечного цикла, если API вернет некорректные данные + if len(results) < limit: + break + + except requests.exceptions.HTTPError as e: + # Если индекс не найден (404), это нормально при первом запуске + if e.response.status_code == 404: + logger.info(f"Индекс '{INDEX_NAME}' не найден. Будет создан при первой индексации.") + return {} # Возвращаем пустой словарь + else: + logger.error(f"Ошибка получения документов из Meilisearch: {e}") + raise # Передаем ошибку дальше, т.к. не можем продолжить + except requests.exceptions.RequestException as e: + logger.error(f"Ошибка соединения с Meilisearch ({url}): {e}") + raise + logger.info(f"Найдено {len(indexed_files)} документов в индексе '{INDEX_NAME}'.") + return indexed_files + +def update_meili_index(client: requests.Session, documents: List[Dict[str, Any]]) -> None: + """Отправляет пакет документов в Meilisearch для добавления/обновления.""" + if not documents: + return + url = f"{SEARCH_ENGINE_URL}/indexes/{INDEX_NAME}/documents" + try: + # Отправляем частями (батчами) + for i in range(0, len(documents), BATCH_SIZE): + batch = documents[i:i + BATCH_SIZE] + response = client.post(url, json=batch) + response.raise_for_status() + task_info = response.json() + logger.info(f"Отправлено {len(batch)} документов на индексацию. Task UID: {task_info.get('taskUid', 'N/A')}") + # В продакшене можно добавить мониторинг статуса задачи Meilisearch + time.sleep(0.1) # Небольшая пауза между батчами + + except requests.exceptions.RequestException as e: + logger.error(f"Ошибка при отправке документов в Meilisearch: {e}") + # Можно добавить логику повторной попытки или сохранения неудавшихся батчей + +def delete_from_meili_index(client: requests.Session, file_ids: List[str]) -> None: + """Удаляет документы из Meilisearch по списку ID.""" + if not file_ids: + return + url = f"{SEARCH_ENGINE_URL}/indexes/{INDEX_NAME}/documents/delete-batch" + try: + # Удаляем частями (батчами) + for i in range(0, len(file_ids), BATCH_SIZE): + batch_ids = file_ids[i:i + BATCH_SIZE] + response = client.post(url, json=batch_ids) + response.raise_for_status() + task_info = response.json() + logger.info(f"Отправлено {len(batch_ids)} ID на удаление. Task UID: {task_info.get('taskUid', 'N/A')}") + time.sleep(0.1) # Небольшая пауза + + except requests.exceptions.RequestException as e: + logger.error(f"Ошибка при удалении документов из Meilisearch: {e}") + +# --- Основная логика индексации --- + +def process_file(file_path: Path) -> Optional[Dict[str, Any]]: + """Обрабатывает один файл: извлекает текст и формирует документ для Meilisearch.""" + filename = file_path.name + file_mtime = os.path.getmtime(str(file_path)) + content = None + file_ext = file_path.suffix.lower() + + try: + logger.debug(f"Обработка файла: {filename}") + if file_ext == ".txt": + content = extract_text_from_txt(file_path) + elif file_ext == ".pdf": content = extract_text_from_pdf(file_path) - elif filename.endswith(".epub"): + elif file_ext == ".epub": content = extract_text_from_epub(file_path) else: - continue + logger.debug(f"Неподдерживаемый формат файла: {filename}. Пропускаем.") + return None # Неподдерживаемый формат - docs.append({"id": filename, "content": content}) + if content is None or not content.strip(): + logger.warning(f"Не удалось извлечь текст или текст пуст: {filename}") + return None + + # Формируем документ для Meilisearch + document = { + "id": filename, # Используем имя файла как уникальный ID + "content": content.strip(), + "file_mtime": file_mtime, # Сохраняем время модификации + "indexed_at": time.time() # Время последней индексации + } + return document + + except (ValueError, IOError, Exception) as e: + # Ловим ошибки чтения, парсинга или другие проблемы с файлом + logger.error(f"❌ Ошибка обработки файла {filename}: {e}") + return None # Пропускаем этот файл + +def scan_and_index_files() -> None: + """Сканирует директорию, сравнивает с индексом и обновляет Meilisearch.""" + logger.info(f"🚀 Запуск сканирования директории: {FILES_DIR}") + target_dir = Path(FILES_DIR) + if not target_dir.is_dir(): + logger.error(f"Директория не найдена: {FILES_DIR}") + return + + client = get_meili_client() + + # 1. Получаем состояние индекса + try: + indexed_files_mtimes: Dict[str, float] = get_indexed_files(client) + except Exception as e: + logger.error(f"Не удалось получить состояние индекса. Прерывание: {e}") + return + + # 2. Сканируем локальные файлы + local_files_mtimes: Dict[str, float] = {} + files_to_process: List[Path] = [] + processed_extensions = {".txt", ".pdf", ".epub"} + + for item in target_dir.rglob('*'): # Рекурсивно обходим все файлы + if item.is_file() and item.suffix.lower() in processed_extensions: + try: + local_files_mtimes[item.name] = item.stat().st_mtime + files_to_process.append(item) + except FileNotFoundError: + logger.warning(f"Файл был удален во время сканирования: {item.name}") + continue # Пропускаем, если файл исчез между листингом и stat() + + logger.info(f"Найдено {len(local_files_mtimes)} поддерживаемых файлов локально.") + + # 3. Определяем изменения + local_filenames: Set[str] = set(local_files_mtimes.keys()) + indexed_filenames: Set[str] = set(indexed_files_mtimes.keys()) + + files_to_add: Set[str] = local_filenames - indexed_filenames + files_to_delete: Set[str] = indexed_filenames - local_filenames + files_to_check_for_update: Set[str] = local_filenames.intersection(indexed_filenames) + + files_to_update: Set[str] = { + fname for fname in files_to_check_for_update + if local_files_mtimes[fname] > indexed_files_mtimes.get(fname, 0.0) # Сравниваем время модификации + } + + logger.info(f"К добавлению: {len(files_to_add)}, к обновлению: {len(files_to_update)}, к удалению: {len(files_to_delete)}") + + # 4. Обрабатываем и отправляем добавления/обновления + docs_for_meili: List[Dict[str, Any]] = [] + files_requiring_processing: Set[str] = files_to_add.union(files_to_update) + + processed_count = 0 + skipped_count = 0 + error_count = 0 + + for file_path in files_to_process: + if file_path.name in files_requiring_processing: + processed_count +=1 + document = process_file(file_path) + if document: + docs_for_meili.append(document) + else: + error_count += 1 # Ошибка или не удалось извлечь текст + else: + skipped_count +=1 # Файл не изменился + + logger.info(f"Обработано файлов: {processed_count} (пропущено без изменений: {skipped_count}, ошибки: {error_count})") + + if docs_for_meili: + logger.info(f"Отправка {len(docs_for_meili)} документов в Meilisearch...") + update_meili_index(client, docs_for_meili) + else: + logger.info("Нет новых или обновленных файлов для индексации.") + + # 5. Удаляем устаревшие документы + if files_to_delete: + logger.info(f"Удаление {len(files_to_delete)} устаревших документов из Meilisearch...") + delete_from_meili_index(client, list(files_to_delete)) + else: + logger.info("Нет файлов для удаления из индекса.") + + logger.info("✅ Индексация завершена.") - if docs: - requests.post(f"{SEARCH_ENGINE}/indexes/{INDEX_NAME}/documents", json=docs) - print(f"Индексировано {len(docs)} файлов!") if __name__ == "__main__": - index_files() + scan_and_index_files() diff --git a/backend/requirements.txt b/backend/requirements.txt index da7e72c..a777467 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,10 @@ fastapi -uvicorn +uvicorn[standard] # Включает поддержку websockets и др., [standard] рекомендован uvicorn requests pdfminer.six ebooklib beautifulsoup4 python-dotenv +# Зависимости для тестов +pytest +httpx # Для FastAPI TestClient diff --git a/backend/tests/init.py b/backend/tests/init.py new file mode 100644 index 0000000..8982750 --- /dev/null +++ b/backend/tests/init.py @@ -0,0 +1 @@ +# Пустой файл, нужен чтобы Python распознал папку как пакет diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py new file mode 100644 index 0000000..4deb7bc --- /dev/null +++ b/backend/tests/test_app.py @@ -0,0 +1,108 @@ +import pytest +from fastapi.testclient import TestClient +from httpx import Response # Используем Response из httpx, так как TestClient его возвращает +from unittest.mock import patch, MagicMock + +# Важно: Импортируем 'app' из модуля, где он создан +from backend.app import app + +# Фикстура для создания тестового клиента +@pytest.fixture(scope="module") +def client() -> TestClient: + return TestClient(app) + +# Фикстура для мокирования сессии requests +@pytest.fixture +def mock_search_session(): + with patch("backend.app.requests.Session") as mock_session_cls: + mock_session_instance = MagicMock() + # Настраиваем мок для POST запроса на /search + mock_response_search = MagicMock(spec=Response) + mock_response_search.status_code = 200 + mock_response_search.json.return_value = { + "hits": [ + {"id": "test.txt", "_formatted": {"id": "test.txt", "content": "Это тест"}}, + {"id": "another.pdf", "_formatted": {"id": "another.pdf", "content": "Еще один тестовый файл"}} + ], + "query": "тест", + "processingTimeMs": 10, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 2 + } + # Настраиваем мок для GET запроса на /health + mock_response_health = MagicMock(spec=Response) + mock_response_health.status_code = 200 + mock_response_health.json.return_value = {"status": "available"} + + # Используем side_effect для разных ответов на разные URL + def side_effect(*args, **kwargs): + url = args[0] # Первый аргумент - URL + if 'search' in url: + if 'json' in kwargs and kwargs['json'].get('q') == "ошибка": # Имитируем ошибку Meili + mock_err_resp = MagicMock(spec=Response) + mock_err_resp.status_code = 500 + mock_err_resp.raise_for_status.side_effect = requests.exceptions.HTTPError("Meili Error") + return mock_err_resp + return mock_response_search + elif 'health' in url: + return mock_response_health + else: # Поведение по умолчанию + default_resp = MagicMock(spec=Response) + default_resp.status_code = 404 + return default_resp + + # Назначаем side_effect для разных методов + mock_session_instance.post.side_effect = side_effect + mock_session_instance.get.side_effect = side_effect + + mock_session_cls.return_value = mock_session_instance + yield mock_session_instance + +def test_search_success(client: TestClient, mock_search_session: MagicMock): + """Тестирует успешный поиск.""" + response = client.get("/search?q=тест") + assert response.status_code == 200 + data = response.json() + assert "results" in data + assert len(data["results"]) == 2 + assert data["results"][0]["id"] == "test.txt" + assert "тест" in data["results"][0]["content"] + # Проверяем, что был вызван POST к MeiliSearch (т.к. app.py использует POST) + mock_search_session.post.assert_called_once() + + +def test_search_empty_query(client: TestClient): + """Тестирует поиск с пустым запросом (FastAPI вернет 422).""" + response = client.get("/search?q=") + assert response.status_code == 422 # Ошибка валидации FastAPI + +def test_search_meili_error(client: TestClient, mock_search_session: MagicMock): + """Тестирует обработку ошибки от Meilisearch.""" + response = client.get("/search?q=ошибка") # Используем запрос, который вызовет ошибку в моке + assert response.status_code == 503 # Service Unavailable + assert response.json()["detail"] == "Сервис поиска временно недоступен" + +def test_get_file_not_found(client: TestClient): + """Тестирует запрос несуществующего файла.""" + # Мы не мокируем os.path.exists, поэтому он вернет False + response = client.get("/files/nonexistent.txt") + assert response.status_code == 404 + assert response.json()["detail"] == "Файл не найден" + +def test_get_file_invalid_name(client: TestClient): + """Тестирует запрос файла с некорректным именем.""" + response = client.get("/files/../secret.txt") + assert response.status_code == 400 # Bad Request (изменен в app.py) + +# Пример теста для /health (уже частично покрыт в mock_search_session) +def test_health_check(client: TestClient, mock_search_session: MagicMock): + """Тестирует эндпоинт /health.""" + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["meilisearch_status"] == "доступен" + mock_search_session.get.assert_called_once_with(f"{app.SEARCH_ENGINE_URL}/health") + +# TODO: Добавить тест для успешного получения файла (требует мокирования os.path и создания временного файла) diff --git a/backend/tests/test_indexer.py b/backend/tests/test_indexer.py new file mode 100644 index 0000000..8d9faa4 --- /dev/null +++ b/backend/tests/test_indexer.py @@ -0,0 +1,174 @@ +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open +from backend import indexer # Импортируем модуль для тестирования + +# --- Тесты для функций извлечения текста --- + +def test_extract_text_from_txt_success(): + """Тестирует успешное чтение UTF-8 TXT файла.""" + mock_content = "Привет, мир!" + # Используем mock_open для имитации чтения файла + m = mock_open(read_data=mock_content.encode('utf-8')) + with patch('pathlib.Path.read_text', m): + result = indexer.extract_text_from_txt(Path("dummy.txt")) + assert result == mock_content + # Проверяем, что была попытка чтения с utf-8 + m.assert_called_once_with(encoding='utf-8') + +def test_extract_text_from_txt_cp1251(): + """Тестирует чтение TXT файла в CP1251 после неудачи с UTF-8.""" + mock_content_cp1251 = "Тест CP1251".encode('cp1251') + # Имитируем ошибку при чтении UTF-8 и успешное чтение CP1251 + m = MagicMock() + m.side_effect = [UnicodeDecodeError('utf-8', b'', 0, 1, 'reason'), mock_content_cp1251.decode('cp1251')] + # Важно: Мокаем read_text у экземпляра Path, а не сам метод класса + with patch('pathlib.Path.read_text', m): + result = indexer.extract_text_from_txt(Path("dummy.txt")) + assert result == "Тест CP1251" + assert m.call_count == 2 # Были вызовы для utf-8 и cp1251 + assert m.call_args_list[0][1]['encoding'] == 'utf-8' + assert m.call_args_list[1][1]['encoding'] == 'cp1251' + + +def test_extract_text_from_txt_unknown_encoding(): + """Тестирует случай, когда ни одна кодировка не подходит.""" + m = MagicMock() + m.side_effect = UnicodeDecodeError('dummy', b'', 0, 1, 'reason') + with patch('pathlib.Path.read_text', m), pytest.raises(ValueError, match="Unknown encoding"): + indexer.extract_text_from_txt(Path("dummy.txt")) + assert m.call_count == 3 # Попытки utf-8, cp1251, latin-1 + + +@patch('backend.indexer.pdf_extract_text', return_value="PDF content here") +def test_extract_text_from_pdf_success(mock_pdf_extract): + """Тестирует успешное извлечение текста из PDF.""" + result = indexer.extract_text_from_pdf(Path("dummy.pdf")) + assert result == "PDF content here" + mock_pdf_extract.assert_called_once_with("dummy.pdf") + + +@patch('backend.indexer.pdf_extract_text', side_effect=indexer.PDFSyntaxError("Bad PDF")) +def test_extract_text_from_pdf_syntax_error(mock_pdf_extract): + """Тестирует обработку PDFSyntaxError.""" + with pytest.raises(ValueError, match="Ошибка синтаксиса PDF"): + indexer.extract_text_from_pdf(Path("dummy.pdf")) + + +@patch('backend.indexer.epub.read_epub') +def test_extract_text_from_epub_success(mock_read_epub): + """Тестирует успешное извлечение текста из EPUB.""" + # Создаем моки для epub объектов + mock_item1 = MagicMock() + mock_item1.get_type.return_value = indexer.ITEM_DOCUMENT + mock_item1.content = b"

First paragraph.

" + + mock_item2 = MagicMock() + mock_item2.get_type.return_value = indexer.ITEM_DOCUMENT + mock_item2.content = b"

Second paragraph.

" + + mock_book = MagicMock() + mock_book.get_items_of_type.return_value = [mock_item1, mock_item2] + mock_read_epub.return_value = mock_book + + result = indexer.extract_text_from_epub(Path("dummy.epub")) + assert result == "First paragraph.\n\nSecond paragraph." # Скрипт должен быть удален, параграфы разделены + mock_read_epub.assert_called_once_with("dummy.epub") + mock_book.get_items_of_type.assert_called_once_with(indexer.ITEM_DOCUMENT) + + +@patch('backend.indexer.epub.read_epub', side_effect=Exception("EPUB Read Error")) +def test_extract_text_from_epub_error(mock_read_epub): + """Тестирует обработку общей ошибки при чтении EPUB.""" + with pytest.raises(IOError, match="Не удалось обработать EPUB файл"): + indexer.extract_text_from_epub(Path("dummy.epub")) + +# --- Тесты для process_file --- + +@patch('backend.indexer.os.path.getmtime', return_value=12345.67) +@patch('backend.indexer.extract_text_from_txt', return_value="Текстовый контент") +def test_process_file_txt_success(mock_extract, mock_getmtime): + """Тестирует успешную обработку TXT файла.""" + # Используем mock_open чтобы Path("file.txt").suffix сработал + m_open = mock_open() + with patch('pathlib.Path.open', m_open): + p = Path("file.txt") + result = indexer.process_file(p) + + assert result is not None + assert result["id"] == "file.txt" + assert result["content"] == "Текстовый контент" + assert result["file_mtime"] == 12345.67 + assert "indexed_at" in result + mock_extract.assert_called_once_with(p) + mock_getmtime.assert_called_once_with("file.txt") + + +@patch('backend.indexer.os.path.getmtime', return_value=12345.67) +@patch('backend.indexer.extract_text_from_pdf', side_effect=IOError("PDF read failed")) +def test_process_file_pdf_error(mock_extract, mock_getmtime): + """Тестирует обработку ошибки при извлечении текста из PDF.""" + m_open = mock_open() + with patch('pathlib.Path.open', m_open): + p = Path("broken.pdf") + result = indexer.process_file(p) + + assert result is None # Ожидаем None при ошибке + mock_extract.assert_called_once_with(p) + + +def test_process_file_unsupported_extension(): + """Тестирует обработку файла с неподдерживаемым расширением.""" + m_open = mock_open() + with patch('pathlib.Path.open', m_open): + p = Path("image.jpg") + result = indexer.process_file(p) + assert result is None + +# --- Тесты для основной логики (scan_and_index_files) --- +# Эти тесты сложнее, так как требуют мокирования os.walk, get_indexed_files, update/delete и т.д. +# Пример одного сценария: + +@patch('backend.indexer.Path.is_dir', return_value=True) +@patch('backend.indexer.Path.rglob') +@patch('backend.indexer.get_meili_client') +@patch('backend.indexer.get_indexed_files') +@patch('backend.indexer.update_meili_index') +@patch('backend.indexer.delete_from_meili_index') +@patch('backend.indexer.process_file') +def test_scan_and_index_new_file( + mock_process_file, mock_delete, mock_update, mock_get_indexed, mock_client, mock_rglob, mock_is_dir +): + """Тестирует сценарий добавления нового файла.""" + # Настройка моков + mock_get_indexed.return_value = {} # Индекс пуст + + # Имитация файла на диске + new_file_path = MagicMock(spec=Path) + new_file_path.name = "new.txt" + new_file_path.is_file.return_value = True + new_file_path.suffix = ".txt" + new_file_path.stat.return_value.st_mtime = 100.0 + mock_rglob.return_value = [new_file_path] # rglob находит один файл + + # Имитация успешной обработки файла + mock_process_file.return_value = {"id": "new.txt", "content": "new file", "file_mtime": 100.0, "indexed_at": 101.0} + + # Запуск функции + indexer.scan_and_index_files() + + # Проверки + mock_get_indexed.assert_called_once() # Проверили индекс + mock_process_file.assert_called_once_with(new_file_path) # Обработали файл + mock_update.assert_called_once() # Вызвали обновление + update_args, _ = mock_update.call_args + assert len(update_args[1]) == 1 # Обновляем один документ + assert update_args[1][0]["id"] == "new.txt" + mock_delete.assert_not_called() # Ничего не удаляли + +# TODO: Добавить больше тестов для scan_and_index_files: +# - Обновление существующего файла (mtime изменился) +# - Удаление файла (есть в индексе, нет локально) +# - Файл не изменился (пропуск) +# - Ошибка при обработке файла +# - Ошибка при взаимодействии с Meilisearch diff --git a/check_encoding.py b/check_encoding.py index 38db325..a0a25eb 100644 --- a/check_encoding.py +++ b/check_encoding.py @@ -1,22 +1,84 @@ +# File: check_encoding.py import os -import chardet +import argparse +import logging -DIRECTORY = "./" # Замени на путь к папке с файлами +# Настройка логирования +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -def detect_encoding(filename): - with open(filename, "rb") as f: - raw_data = f.read() - result = chardet.detect(raw_data) - return result["encoding"] +def is_likely_utf8(filename: str) -> bool: + """ + Проверяет, можно ли успешно декодировать файл как UTF-8. + Возвращает True, если файл успешно декодирован или если возникла ошибка чтения файла. + Возвращает False, если произошла ошибка UnicodeDecodeError. + """ + try: + with open(filename, "rb") as f: + # Читаем весь файл. Для очень больших файлов можно ограничить чтение. + f.read().decode('utf-8') + return True + except UnicodeDecodeError: + # Это точно не UTF-8 + return False + except FileNotFoundError: + logging.error(f"Файл не найден при проверке UTF-8: {filename}") + # Не можем быть уверены, но для целей проверки считаем 'проблемным' + return False + except Exception as e: + logging.error(f"Не удалось прочитать файл {filename} для проверки UTF-8: {e}") + # Не можем быть уверены, но для целей проверки считаем 'проблемным' + return False + +def check_all_files_not_utf8(directory: str) -> None: + """Проверяет все файлы в директории, сообщая о тех, что не в UTF-8.""" + found_non_utf8 = False + checked_files = 0 + problematic_files = [] -def check_all_files(directory): for root, _, files in os.walk(directory): for file in files: - filepath = os.path.join(root, file) - encoding = detect_encoding(filepath) - if encoding and "utf-16" in encoding.lower(): - print(f"❌ {filepath} - {encoding}") + # Ограничимся проверкой текстовых файлов по расширению + if file.lower().endswith(('.txt', '.md', '.html', '.css', '.js', '.json', '.xml', '.csv')): + filepath = os.path.join(root, file) + checked_files += 1 + if not is_likely_utf8(filepath): + problematic_files.append(filepath) + found_non_utf8 = True -print("🔍 Поиск файлов с UTF-16...") -check_all_files(DIRECTORY) -print("✅ Готово!") \ No newline at end of file + logging.info(f"Проверено файлов с текстовыми расширениями: {checked_files}") + if found_non_utf8: + logging.warning(f"Найдены файлы ({len(problematic_files)}), которые не удалось прочитать как UTF-8:") + # Попробуем угадать кодировку для проблемных файлов, если chardet доступен + try: + import chardet + logging.info("Попытка определить кодировку для проблемных файлов (требуется chardet)...") + for filepath in problematic_files: + try: + with open(filepath, "rb") as f: + raw_data = f.read(8192) # Читаем начало файла для chardet + if not raw_data: continue # Пропускаем пустые + result = chardet.detect(raw_data) + encoding = result["encoding"] or "N/A" + confidence = result["confidence"] or 0.0 + logging.warning(f" - {filepath} (предположительно {encoding}, уверенность {confidence:.2f})") + except Exception as e: + logging.warning(f" - {filepath} (не удалось определить кодировку: {e})") + + except ImportError: + logging.warning("Модуль 'chardet' не установлен. Установите его (`pip install chardet`), чтобы попытаться определить кодировку автоматически.") + for filepath in problematic_files: + logging.warning(f" - {filepath}") + else: + logging.info("Все проверенные файлы успешно читаются как UTF-8.") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Проверка текстовых файлов на соответствие кодировке UTF-8.") + parser.add_argument("directory", type=str, help="Путь к директории для проверки.") + args = parser.parse_args() + + if not os.path.isdir(args.directory): + logging.error(f"Указанный путь не является директорией: {args.directory}") + else: + logging.info(f"🔍 Поиск файлов не в UTF-8 в директории: {args.directory}...") + check_all_files_not_utf8(args.directory) + logging.info("✅ Проверка завершена!") diff --git a/docker-compose.yml b/docker-compose.yml index e71b5a5..eb8877c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: ports: - "7700:7700" environment: + # ВАЖНО: Для продакшена установите MEILI_MASTER_KEY! + # Например: MEILI_MASTER_KEY: 'your_strong_master_key' - MEILI_NO_ANALYTICS=true restart: unless-stopped volumes: @@ -17,24 +19,19 @@ services: container_name: backend ports: - "8000:8000" - env_file: .env + env_file: .env # Файл с переменными окружения (включая MEILI_API_KEY, если используется) volumes: - - ${LOCAL_STORAGE_PATH}:/mnt/storage + # LOCAL_STORAGE_PATH должен быть определен в .env + - ${LOCAL_STORAGE_PATH}:/mnt/storage:ro # Монтируем только для чтения depends_on: - meilisearch - deploy: - resources: - reservations: - devices: - - driver: cifs - count: 1 - capabilities: [gpu] + # Убрана секция deploy с резервированием GPU frontend: build: ./frontend container_name: frontend ports: - - "8080:80" + - "8080:80" # Можно изменить на другой порт, если 8080 занят depends_on: - backend @@ -42,17 +39,21 @@ services: image: nginx:latest container_name: nginx ports: - - "80:80" + - "80:80" # Основной порт доступа к системе volumes: - - ${LOCAL_STORAGE_PATH}:/usr/share/nginx/html/files + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + # Файлы больше не раздаются напрямую через Nginx из LOCAL_STORAGE_PATH + # Фронтенд будет получать их через бэкенд /files/{filename} depends_on: - backend - frontend volumes: meili_data: - smb_share: - driver_opts: - type: cifs - o: username=${SMB_USER},password=${SMB_PASSWORD},vers=3.0 - device: ${SMB_STORAGE_PATH} + # Если SMB используется, убедитесь, что LOCAL_STORAGE_PATH в .env указывает на /mnt/smb_share + # smb_share: + # driver_opts: + # type: cifs + # o: username=${SMB_USER},password=${SMB_PASSWORD},vers=3.0,uid=1000,gid=1000 # Добавьте uid/gid если нужно + # device: ${SMB_STORAGE_PATH} + diff --git a/fix_encoding.py b/fix_encoding.py index b052169..774e0f4 100644 --- a/fix_encoding.py +++ b/fix_encoding.py @@ -1,29 +1,92 @@ +# File: fix_encoding.py import os -import chardet -import codecs +import codecs # Используем codecs для явного указания кодировок при чтении/записи +import argparse +import logging -DIRECTORY = "./local_files" # Путь к папке с файлами +# Настройка логирования +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -def convert_to_utf8(filename): - with open(filename, "rb") as f: - raw_data = f.read() - result = chardet.detect(raw_data) - encoding = result["encoding"] - - if encoding and "utf-16" in encoding.lower(): - print(f"🔄 Конвертирую {filename} ({encoding}) в UTF-8...") - with codecs.open(filename, "r", encoding=encoding) as f: - content = f.read() - with codecs.open(filename, "w", encoding="utf-8") as f: - f.write(content) - print(f"✅ {filename} теперь в UTF-8!") +def ensure_utf8_encoding(filename: str, source_encoding: str = 'cp1251') -> bool: + """ + Проверяет кодировку файла. Если не UTF-8, пытается конвертировать из source_encoding. + Возвращает True, если была произведена конвертация, False иначе. + """ + is_converted = False + try: + # 1. Пробуем прочитать как UTF-8, чтобы не трогать уже корректные файлы + with codecs.open(filename, "r", encoding='utf-8') as f: + f.read() # Просто читаем, чтобы проверить декодирование + logging.debug(f"Файл {filename} уже в UTF-8.") + return False # Конвертация не требуется + except UnicodeDecodeError: + # Файл точно не UTF-8, пробуем конвертировать из предполагаемой source_encoding + logging.info(f"Файл {filename} не в UTF-8. Попытка конвертации из {source_encoding}...") + try: + # Читаем с исходной кодировкой + with codecs.open(filename, "r", encoding=source_encoding) as f: + content = f.read() + + # Перезаписываем файл в UTF-8 + # Важно: Используем 'w' режим, который перезапишет файл + with codecs.open(filename, "w", encoding='utf-8') as f: + f.write(content) + + logging.info(f"✅ {filename} успешно конвертирован из {source_encoding} в UTF-8!") + is_converted = True + except UnicodeDecodeError: + # Не удалось прочитать даже как source_encoding + logging.warning(f"⚠️ Не удалось прочитать {filename} как {source_encoding} (после неудачи с UTF-8). Файл не изменен.") + except Exception as e: + logging.error(f"❌ Ошибка конвертации файла {filename} из {source_encoding}: {e}. Файл не изменен.") + except FileNotFoundError: + logging.error(f"Файл не найден при попытке конвертации: {filename}") + except Exception as e: + # Ловим другие возможные ошибки чтения на этапе проверки UTF-8 + logging.error(f"Не удалось прочитать файл {filename} для проверки/конвертации: {e}") + + return is_converted + + +def fix_all_files(directory: str, source_encoding: str = 'cp1251') -> None: + """ + Обходит директорию и конвертирует текстовые файлы из source_encoding в UTF-8, + если они еще не в UTF-8. + """ + converted_count = 0 + processed_count = 0 + # Список расширений текстовых файлов для обработки + text_extensions = ('.txt', '.md', '.html', '.htm', '.css', '.js', '.json', '.xml', '.csv', '.log', '.srt') # Добавь нужные -def fix_all_files(directory): for root, _, files in os.walk(directory): for file in files: - filepath = os.path.join(root, file) - convert_to_utf8(filepath) + # Проверяем расширение файла + if file.lower().endswith(text_extensions): + filepath = os.path.join(root, file) + processed_count += 1 + if ensure_utf8_encoding(filepath, source_encoding): + converted_count += 1 + else: + logging.debug(f"Пропуск файла с нетекстовым расширением: {file}") -print("🔍 Исправление кодировки...") -fix_all_files(DIRECTORY) -print("✅ Готово!") + logging.info(f"Проверено текстовых файлов: {processed_count}. Конвертировано в UTF-8: {converted_count}.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Конвертация текстовых файлов из указанной кодировки (по умолч. cp1251) в UTF-8.") + parser.add_argument("directory", type=str, help="Путь к директории для конвертации.") + parser.add_argument( + "--source-encoding", + type=str, + default="cp1251", + help="Предполагаемая исходная кодировка файлов, не являющихся UTF-8 (по умолчанию: cp1251).", + ) + args = parser.parse_args() + + if not os.path.isdir(args.directory): + logging.error(f"Указанный путь не является директорией: {args.directory}") + else: + logging.info(f"🛠️ Запуск исправления кодировки в директории: {args.directory}...") + logging.info(f"Предполагаемая исходная кодировка для конвертации: {args.source_encoding}") + fix_all_files(args.directory, args.source_encoding) + logging.info("✅ Исправление кодировки завершено!") diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 279735e..b62445f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,2 +1,17 @@ -FROM nginx:latest +# Используем официальный образ Nginx +FROM nginx:stable-alpine + +# Удаляем стандартную конфигурацию Nginx +RUN rm /etc/nginx/conf.d/default.conf + +# Копируем нашу конфигурацию (если она есть в папке frontend, иначе она берется из ./nginx) +# COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Копируем статичные файлы фронтенда COPY index.html /usr/share/nginx/html/index.html + +# Открываем порт 80 +EXPOSE 80 + +# Команда для запуска Nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html index 2990561..003c77f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,25 +2,82 @@ - Поиск + Поиск по документам + - +

Поиск по документам

+ +
diff --git a/nginx/default.conf b/nginx/default.conf index 4a02305..68ca5b1 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -1,17 +1,58 @@ server { listen 80; + server_name localhost; # Или ваш домен + # Корень для статики фронтенда (HTML, CSS, JS) location / { + # Указываем путь, куда Nginx копирует файлы из frontend/Dockerfile root /usr/share/nginx/html; - index index.html; - } - - location /files/ { - root /usr/share/nginx/html; - autoindex on; + try_files $uri $uri/ /index.html; # Обслуживать index.html для SPA-подобного поведения } + # Проксирование запросов поиска на бэкенд location /search { - proxy_pass http://backend:8000; + proxy_pass http://backend:8000; # Имя сервиса из docker-compose + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } + + # Проксирование запросов на получение файлов на бэкенд + location /files/ { + proxy_pass http://backend:8000; # Перенаправляем на корень бэкенда + # FastAPI сам разберет /files/{filename} + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Опционально: увеличить таймауты для больших файлов + # proxy_connect_timeout 600; + # proxy_send_timeout 600; + # proxy_read_timeout 600; + # send_timeout 600; + } + + # Опционально: Проксирование health-check эндпоинта + location /health { + proxy_pass http://backend:8000/health; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # Чтобы только внутренняя сеть могла проверять health (если нужно) + # allow 172.16.0.0/12; # Пример диапазона Docker сети + # deny all; + } + + # Отключить логи доступа для статики (опционально) + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + # Обработка ошибок (опционально) + # error_page 500 502 503 504 /50x.html; + # location = /50x.html { + # root /usr/share/nginx/html; + # } }