немногожко тестов

This commit is contained in:
Dmitriy Kazimirov 2025-03-30 00:32:00 +06:00
parent 957362f0a8
commit b17fd050f3
14 changed files with 1020 additions and 123 deletions

View file

@ -1,8 +1,28 @@
{ {
"name": "Search Dev", "name": "Document Search Dev",
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "../docker-compose.yml", // Путь относительно .devcontainer папки
"service": "backend", "service": "backend", // Сервис, к которому подключается VS Code
"workspaceFolder": "/app", "workspaceFolder": "/app", // Рабочая папка внутри контейнера backend
"extensions": ["ms-python.python"], "customizations": {
"remoteUser": "root" "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 его использует
} }

View file

@ -1,10 +1,20 @@
# backend/Dockerfile
FROM python:3.11 FROM python:3.11
WORKDIR /app WORKDIR /app
# Копируем сначала requirements, чтобы кэшировать слой установки зависимостей
COPY requirements.txt . COPY requirements.txt .
# Устанавливаем зависимости приложения и тестов
# Используем --no-cache-dir для уменьшения размера образа
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Копируем остальной код приложения и тесты
COPY . . COPY . .
# Команда по умолчанию для запуска приложения
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
# Пример команды для запуска тестов (можно использовать при CI/CD или вручную)
# RUN pytest

View file

@ -1,24 +1,116 @@
from fastapi import FastAPI from fastapi import FastAPI, HTTPException, Query, Depends
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import requests import requests
import os import os
from typing import List, Dict, Any, Optional
from dotenv import load_dotenv from dotenv import load_dotenv
import logging
# Загрузка переменных окружения (например, из .env)
load_dotenv() 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): FILES_DIR: str = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage") # Путь монтирования в Docker
response = requests.get(f"{SEARCH_ENGINE}/indexes/documents/search", params={"q": q}) SEARCH_ENGINE_URL: str = os.getenv("MEILI_URL", "http://meilisearch:7700")
results = response.json() MEILI_API_KEY: Optional[str] = os.getenv("MEILI_MASTER_KEY") # Используйте Master Key или Search API Key
return {"results": results.get("hits", [])} 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) 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 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}

View file

@ -1,47 +1,297 @@
import os import os
import requests import requests
from pdfminer.high_level import extract_text import time
from ebooklib import epub 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 bs4 import BeautifulSoup
from dotenv import load_dotenv from dotenv import load_dotenv
# Загрузка переменных окружения
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) FILES_DIR: str = os.getenv("LOCAL_STORAGE_PATH", "/mnt/storage")
text = [] SEARCH_ENGINE_URL: str = os.getenv("MEILI_URL", "http://meilisearch:7700")
for item in book.get_items(): MEILI_API_KEY: Optional[str] = os.getenv("MEILI_MASTER_KEY") # Используйте Master Key или Index API Key
if item.get_type() == 9: 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") 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(): # --- Функции взаимодействия с Meilisearch ---
docs = []
for filename in os.listdir(FILES_DIR):
file_path = os.path.join(FILES_DIR, filename)
if filename.endswith(".txt"): def get_meili_client() -> requests.Session:
with open(file_path, "r", encoding="utf-8") as f: """Создает и настраивает HTTP клиент для Meilisearch."""
content = f.read() session = requests.Session()
elif filename.endswith(".pdf"): 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) content = extract_text_from_pdf(file_path)
elif filename.endswith(".epub"): elif file_ext == ".epub":
content = extract_text_from_epub(file_path) content = extract_text_from_epub(file_path)
else: 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__": if __name__ == "__main__":
index_files() scan_and_index_files()

View file

@ -1,7 +1,10 @@
fastapi fastapi
uvicorn uvicorn[standard] # Включает поддержку websockets и др., [standard] рекомендован uvicorn
requests requests
pdfminer.six pdfminer.six
ebooklib ebooklib
beautifulsoup4 beautifulsoup4
python-dotenv python-dotenv
# Зависимости для тестов
pytest
httpx # Для FastAPI TestClient

1
backend/tests/init.py Normal file
View file

@ -0,0 +1 @@
# Пустой файл, нужен чтобы Python распознал папку как пакет

108
backend/tests/test_app.py Normal file
View file

@ -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": "Это <em>тест</em>"}},
{"id": "another.pdf", "_formatted": {"id": "another.pdf", "content": "Еще один <em>тест</em>овый файл"}}
],
"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 "<em>тест</em>" 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 и создания временного файла)

View file

@ -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"<html><body><p>First paragraph.</p></body></html>"
mock_item2 = MagicMock()
mock_item2.get_type.return_value = indexer.ITEM_DOCUMENT
mock_item2.content = b"<html><body><p>Second paragraph.</p><script>alert('hi');</script></body></html>"
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

View file

@ -1,22 +1,84 @@
# File: check_encoding.py
import os 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): def is_likely_utf8(filename: str) -> bool:
with open(filename, "rb") as f: """
raw_data = f.read() Проверяет, можно ли успешно декодировать файл как UTF-8.
result = chardet.detect(raw_data) Возвращает True, если файл успешно декодирован или если возникла ошибка чтения файла.
return result["encoding"] Возвращает 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 root, _, files in os.walk(directory):
for file in files: for file in files:
filepath = os.path.join(root, file) # Ограничимся проверкой текстовых файлов по расширению
encoding = detect_encoding(filepath) if file.lower().endswith(('.txt', '.md', '.html', '.css', '.js', '.json', '.xml', '.csv')):
if encoding and "utf-16" in encoding.lower(): filepath = os.path.join(root, file)
print(f"{filepath} - {encoding}") checked_files += 1
if not is_likely_utf8(filepath):
problematic_files.append(filepath)
found_non_utf8 = True
print("🔍 Поиск файлов с UTF-16...") logging.info(f"Проверено файлов с текстовыми расширениями: {checked_files}")
check_all_files(DIRECTORY) if found_non_utf8:
print("✅ Готово!") 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("✅ Проверка завершена!")

View file

@ -7,6 +7,8 @@ services:
ports: ports:
- "7700:7700" - "7700:7700"
environment: environment:
# ВАЖНО: Для продакшена установите MEILI_MASTER_KEY!
# Например: MEILI_MASTER_KEY: 'your_strong_master_key'
- MEILI_NO_ANALYTICS=true - MEILI_NO_ANALYTICS=true
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@ -17,24 +19,19 @@ services:
container_name: backend container_name: backend
ports: ports:
- "8000:8000" - "8000:8000"
env_file: .env env_file: .env # Файл с переменными окружения (включая MEILI_API_KEY, если используется)
volumes: volumes:
- ${LOCAL_STORAGE_PATH}:/mnt/storage # LOCAL_STORAGE_PATH должен быть определен в .env
- ${LOCAL_STORAGE_PATH}:/mnt/storage:ro # Монтируем только для чтения
depends_on: depends_on:
- meilisearch - meilisearch
deploy: # Убрана секция deploy с резервированием GPU
resources:
reservations:
devices:
- driver: cifs
count: 1
capabilities: [gpu]
frontend: frontend:
build: ./frontend build: ./frontend
container_name: frontend container_name: frontend
ports: ports:
- "8080:80" - "8080:80" # Можно изменить на другой порт, если 8080 занят
depends_on: depends_on:
- backend - backend
@ -42,17 +39,21 @@ services:
image: nginx:latest image: nginx:latest
container_name: nginx container_name: nginx
ports: ports:
- "80:80" - "80:80" # Основной порт доступа к системе
volumes: 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: depends_on:
- backend - backend
- frontend - frontend
volumes: volumes:
meili_data: meili_data:
smb_share: # Если SMB используется, убедитесь, что LOCAL_STORAGE_PATH в .env указывает на /mnt/smb_share
driver_opts: # smb_share:
type: cifs # driver_opts:
o: username=${SMB_USER},password=${SMB_PASSWORD},vers=3.0 # type: cifs
device: ${SMB_STORAGE_PATH} # o: username=${SMB_USER},password=${SMB_PASSWORD},vers=3.0,uid=1000,gid=1000 # Добавьте uid/gid если нужно
# device: ${SMB_STORAGE_PATH}

View file

@ -1,29 +1,92 @@
# File: fix_encoding.py
import os import os
import chardet import codecs # Используем codecs для явного указания кодировок при чтении/записи
import 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): def ensure_utf8_encoding(filename: str, source_encoding: str = 'cp1251') -> bool:
with open(filename, "rb") as f: """
raw_data = f.read() Проверяет кодировку файла. Если не UTF-8, пытается конвертировать из source_encoding.
result = chardet.detect(raw_data) Возвращает True, если была произведена конвертация, False иначе.
encoding = result["encoding"] """
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()
if encoding and "utf-16" in encoding.lower(): # Перезаписываем файл в UTF-8
print(f"🔄 Конвертирую {filename} ({encoding}) в UTF-8...") # Важно: Используем 'w' режим, который перезапишет файл
with codecs.open(filename, "r", encoding=encoding) as f: with codecs.open(filename, "w", encoding='utf-8') as f:
content = f.read() f.write(content)
with codecs.open(filename, "w", encoding="utf-8") as f:
f.write(content) logging.info(f"{filename} успешно конвертирован из {source_encoding} в UTF-8!")
print(f"{filename} теперь в 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 root, _, files in os.walk(directory):
for file in files: 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("🔍 Исправление кодировки...") logging.info(f"Проверено текстовых файлов: {processed_count}. Конвертировано в UTF-8: {converted_count}.")
fix_all_files(DIRECTORY)
print("✅ Готово!")
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("✅ Исправление кодировки завершено!")

View file

@ -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 COPY index.html /usr/share/nginx/html/index.html
# Открываем порт 80
EXPOSE 80
# Команда для запуска Nginx
CMD ["nginx", "-g", "daemon off;"]

View file

@ -2,25 +2,82 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Поиск</title> <title>Поиск по документам</title>
<style>
body { font-family: sans-serif; line-height: 1.6; padding: 20px; }
#query { padding: 10px; width: 300px; margin-right: 5px; }
button { padding: 10px; cursor: pointer; }
#results { list-style: none; padding: 0; margin-top: 20px; }
#results li { border: 1px solid #ddd; margin-bottom: 15px; padding: 15px; border-radius: 4px; background-color: #f9f9f9; }
#results li a { font-weight: bold; color: #007bff; text-decoration: none; }
#results li a:hover { text-decoration: underline; }
.snippet { margin-top: 8px; color: #555; font-size: 0.9em; }
.snippet em { font-weight: bold; background-color: yellow; } /* Подсветка */
#status { margin-top: 15px; font-style: italic; color: #888; }
</style>
</head> </head>
<body> <body>
<input id="query" type="text" placeholder="Введите запрос"> <h1>Поиск по документам</h1>
<input id="query" type="text" placeholder="Введите поисковый запрос" onkeyup="handleKey(event)">
<button onclick="search()">Искать</button> <button onclick="search()">Искать</button>
<div id="status"></div>
<ul id="results"></ul> <ul id="results"></ul>
<script> <script>
const searchInput = document.getElementById("query");
const resultsList = document.getElementById("results");
const statusDiv = document.getElementById("status");
function handleKey(event) {
// Запускаем поиск по нажатию Enter
if (event.key === "Enter") {
search();
}
}
async function search() { async function search() {
let query = document.getElementById("query").value; let query = searchInput.value.trim();
let res = await fetch(`/search?q=` + query); if (!query) {
let data = await res.json(); resultsList.innerHTML = "";
let list = document.getElementById("results"); statusDiv.textContent = "Введите запрос для поиска.";
list.innerHTML = ""; return;
data.results.forEach(doc => { }
let item = document.createElement("li");
item.innerHTML = `<a href="/files/${doc.id}">${doc.id}</a>: ${doc.content.substring(0, 200)}...`; resultsList.innerHTML = ""; // Очищаем предыдущие результаты
list.appendChild(item); statusDiv.textContent = "Идет поиск..."; // Показываем статус
});
try {
// Используем относительный путь, т.к. Nginx проксирует /search
const response = await fetch(`/search?q=${encodeURIComponent(query)}&limit=50`); // Запрашиваем до 50 результатов
if (!response.ok) {
throw new Error(`Ошибка сервера: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.results && data.results.length > 0) {
statusDiv.textContent = `Найдено результатов: ${data.results.length}`;
data.results.forEach(doc => {
const item = document.createElement("li");
// Ссылка ведет на эндпоинт бэкенда для скачивания файла
// Используем doc.id (имя файла)
const fileLink = `<a href="/files/${encodeURIComponent(doc.id)}" target="_blank">${doc.id}</a>`;
// Отображаем подсвеченный фрагмент (_formatted.content)
const snippetHTML = doc.content ? `<div class="snippet">${doc.content}</div>` : '<div class="snippet">(нет превью)</div>';
item.innerHTML = `${fileLink}${snippetHTML}`;
resultsList.appendChild(item);
});
} else {
statusDiv.textContent = "Ничего не найдено.";
}
} catch (error) {
console.error("Ошибка поиска:", error);
statusDiv.textContent = `Ошибка: ${error.message}. Попробуйте еще раз позже.`;
resultsList.innerHTML = ""; // Очищаем на случай ошибки
}
} }
</script> </script>
</body> </body>

View file

@ -1,17 +1,58 @@
server { server {
listen 80; listen 80;
server_name localhost; # Или ваш домен
# Корень для статики фронтенда (HTML, CSS, JS)
location / { location / {
# Указываем путь, куда Nginx копирует файлы из frontend/Dockerfile
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; try_files $uri $uri/ /index.html; # Обслуживать index.html для SPA-подобного поведения
}
location /files/ {
root /usr/share/nginx/html;
autoindex on;
} }
# Проксирование запросов поиска на бэкенд
location /search { 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;
# }
} }