mirror of
https://github.com/intari/search2_chatgpt.git
synced 2025-09-01 10:09:36 +00:00
немногожко тестов
This commit is contained in:
parent
957362f0a8
commit
b17fd050f3
14 changed files with 1020 additions and 123 deletions
|
@ -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 его использует
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
116
backend/app.py
116
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})
|
||||
# Конфигурация
|
||||
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()
|
||||
return {"results": results.get("hits", [])}
|
||||
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}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
fastapi
|
||||
uvicorn
|
||||
uvicorn[standard] # Включает поддержку websockets и др., [standard] рекомендован uvicorn
|
||||
requests
|
||||
pdfminer.six
|
||||
ebooklib
|
||||
beautifulsoup4
|
||||
python-dotenv
|
||||
# Зависимости для тестов
|
||||
pytest
|
||||
httpx # Для FastAPI TestClient
|
||||
|
|
1
backend/tests/init.py
Normal file
1
backend/tests/init.py
Normal file
|
@ -0,0 +1 @@
|
|||
# Пустой файл, нужен чтобы Python распознал папку как пакет
|
108
backend/tests/test_app.py
Normal file
108
backend/tests/test_app.py
Normal 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 и создания временного файла)
|
174
backend/tests/test_indexer.py
Normal file
174
backend/tests/test_indexer.py
Normal 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
|
|
@ -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):
|
||||
def is_likely_utf8(filename: str) -> bool:
|
||||
"""
|
||||
Проверяет, можно ли успешно декодировать файл как UTF-8.
|
||||
Возвращает True, если файл успешно декодирован или если возникла ошибка чтения файла.
|
||||
Возвращает False, если произошла ошибка UnicodeDecodeError.
|
||||
"""
|
||||
try:
|
||||
with open(filename, "rb") as f:
|
||||
raw_data = f.read()
|
||||
result = chardet.detect(raw_data)
|
||||
return result["encoding"]
|
||||
# Читаем весь файл. Для очень больших файлов можно ограничить чтение.
|
||||
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:
|
||||
# Ограничимся проверкой текстовых файлов по расширению
|
||||
if file.lower().endswith(('.txt', '.md', '.html', '.css', '.js', '.json', '.xml', '.csv')):
|
||||
filepath = os.path.join(root, file)
|
||||
encoding = detect_encoding(filepath)
|
||||
if encoding and "utf-16" in encoding.lower():
|
||||
print(f"❌ {filepath} - {encoding}")
|
||||
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("✅ Готово!")
|
||||
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("✅ Проверка завершена!")
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
103
fix_encoding.py
103
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:
|
||||
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()
|
||||
with codecs.open(filename, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f"✅ {filename} теперь в UTF-8!")
|
||||
|
||||
def fix_all_files(directory):
|
||||
# Перезаписываем файл в 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') # Добавь нужные
|
||||
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
# Проверяем расширение файла
|
||||
if file.lower().endswith(text_extensions):
|
||||
filepath = os.path.join(root, file)
|
||||
convert_to_utf8(filepath)
|
||||
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("✅ Исправление кодировки завершено!")
|
||||
|
|
|
@ -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;"]
|
||||
|
|
|
@ -2,25 +2,82 @@
|
|||
<html lang="ru">
|
||||
<head>
|
||||
<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>
|
||||
<body>
|
||||
<input id="query" type="text" placeholder="Введите запрос">
|
||||
<h1>Поиск по документам</h1>
|
||||
<input id="query" type="text" placeholder="Введите поисковый запрос" onkeyup="handleKey(event)">
|
||||
<button onclick="search()">Искать</button>
|
||||
<div id="status"></div>
|
||||
<ul id="results"></ul>
|
||||
|
||||
<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() {
|
||||
let query = document.getElementById("query").value;
|
||||
let res = await fetch(`/search?q=` + query);
|
||||
let data = await res.json();
|
||||
let list = document.getElementById("results");
|
||||
list.innerHTML = "";
|
||||
let query = searchInput.value.trim();
|
||||
if (!query) {
|
||||
resultsList.innerHTML = "";
|
||||
statusDiv.textContent = "Введите запрос для поиска.";
|
||||
return;
|
||||
}
|
||||
|
||||
resultsList.innerHTML = ""; // Очищаем предыдущие результаты
|
||||
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 => {
|
||||
let item = document.createElement("li");
|
||||
item.innerHTML = `<a href="/files/${doc.id}">${doc.id}</a>: ${doc.content.substring(0, 200)}...`;
|
||||
list.appendChild(item);
|
||||
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>
|
||||
</body>
|
||||
|
|
|
@ -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;
|
||||
# }
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue