Add project and deployment instruction (docs/DEPLOYMENT.md)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
34
worker/.env.example
Normal file
34
worker/.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# === NEXTCLOUD ===
|
||||
DOMAIN_NEXTCLOUD=https://next.iieasy.ru
|
||||
NC_USER=your_nextcloud_username
|
||||
NC_APP_PASSWORD=your_app_password
|
||||
|
||||
# Пути для сканирования (разделенные запятыми)
|
||||
# {username} будет заменен на имя пользователя Nextcloud
|
||||
NC_SCAN_PATHS=/home/{username}/Documents,/home/{username}/Files,/Shared/Documents
|
||||
|
||||
# === OPEN WEBUI API ===
|
||||
DOMAIN_OPENWEBUI=https://odo.iieasy.ru
|
||||
OPENWEBUI_API_KEY=your_api_key_here
|
||||
OPENWEBUI_TIMEOUT=300
|
||||
|
||||
# === AUTHENTIK (опционально, для маппинга пользователей) ===
|
||||
DOMAIN_AUTHENTIK=https://auth.iieasy.ru
|
||||
AUTHENTIK_API_TOKEN=your_authentik_api_token
|
||||
|
||||
# Маппинг пользователей Nextcloud -> Authentik (опционально)
|
||||
# Формат: NC_USER_{nextcloud_username}={authentik_user_id}
|
||||
# NC_USER_john=user.12345678-1234-1234-1234-123456789012
|
||||
|
||||
# === НАСТРОЙКИ ВОРКЕРА ===
|
||||
# Интервал синхронизации в секундах (по умолчанию 300 = 5 минут)
|
||||
SYNC_INTERVAL=300
|
||||
|
||||
# Максимальный размер файла для обработки в байтах (по умолчанию 100MB)
|
||||
MAX_FILE_SIZE=104857600
|
||||
|
||||
# Путь к БД состояния синхронизации
|
||||
DB_PATH=sync_state.db
|
||||
|
||||
# Уровень логирования (DEBUG, INFO, WARNING, ERROR)
|
||||
LOG_LEVEL=INFO
|
||||
132
worker/config.py
Normal file
132
worker/config.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Конфигурация для воркера синхронизации Nextcloud -> Qdrant
|
||||
Типизация и валидация настроек
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загрузка переменных окружения: сначала worker/.env, затем корневой .env
|
||||
load_dotenv()
|
||||
root_env = Path(__file__).resolve().parent.parent / ".env"
|
||||
load_dotenv(root_env)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NextcloudConfig:
|
||||
"""Конфигурация подключения к Nextcloud"""
|
||||
url: str
|
||||
username: str
|
||||
password: str
|
||||
scan_paths: List[str] # Пути для сканирования
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenWebUIConfig:
|
||||
"""Конфигурация подключения к Open WebUI API"""
|
||||
api_url: str
|
||||
api_key: str
|
||||
timeout: int = 300 # Таймаут для больших файлов (секунды)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthentikConfig:
|
||||
"""Конфигурация для маппинга пользователей Authentik"""
|
||||
api_url: Optional[str] = None
|
||||
api_token: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerConfig:
|
||||
"""Основная конфигурация воркера"""
|
||||
nextcloud: NextcloudConfig
|
||||
openwebui: OpenWebUIConfig
|
||||
authentik: AuthentikConfig
|
||||
sync_interval: int = 300 # Интервал синхронизации (секунды)
|
||||
max_file_size: int = 100 * 1024 * 1024 # Максимальный размер файла (100MB)
|
||||
db_path: str = "sync_state.db" # Путь к SQLite БД для отслеживания состояния
|
||||
log_level: str = "INFO"
|
||||
|
||||
|
||||
def load_config() -> WorkerConfig:
|
||||
"""
|
||||
Загрузка и валидация конфигурации из переменных окружения
|
||||
|
||||
Returns:
|
||||
WorkerConfig: Валидированная конфигурация
|
||||
|
||||
Raises:
|
||||
ValueError: Если обязательные переменные не установлены
|
||||
"""
|
||||
# Nextcloud конфигурация
|
||||
nc_url = os.getenv("DOMAIN_NEXTCLOUD", "").rstrip("/")
|
||||
if not nc_url:
|
||||
raise ValueError("DOMAIN_NEXTCLOUD не установлен в .env")
|
||||
|
||||
nc_user = os.getenv("NC_USER")
|
||||
if not nc_user:
|
||||
raise ValueError("NC_USER не установлен в .env")
|
||||
|
||||
nc_password = os.getenv("NC_APP_PASSWORD")
|
||||
if not nc_password:
|
||||
raise ValueError("NC_APP_PASSWORD не установлен в .env")
|
||||
|
||||
# Пути для сканирования (можно настроить через переменные окружения)
|
||||
scan_paths = os.getenv(
|
||||
"NC_SCAN_PATHS",
|
||||
"/home/{username}/Documents,/home/{username}/Files,/Shared/Documents"
|
||||
).split(",")
|
||||
|
||||
nextcloud_config = NextcloudConfig(
|
||||
url=nc_url,
|
||||
username=nc_user,
|
||||
password=nc_password,
|
||||
scan_paths=[path.strip() for path in scan_paths]
|
||||
)
|
||||
|
||||
# Open WebUI конфигурация
|
||||
webui_url = os.getenv("DOMAIN_OPENWEBUI", "").rstrip("/")
|
||||
if not webui_url:
|
||||
raise ValueError("DOMAIN_OPENWEBUI не установлен в .env")
|
||||
|
||||
webui_api_key = os.getenv("OPENWEBUI_API_KEY")
|
||||
if not webui_api_key or webui_api_key == "твой_api_ключ_от_openwebui":
|
||||
raise ValueError(
|
||||
"OPENWEBUI_API_KEY не установлен или имеет значение по умолчанию. "
|
||||
"Создайте API ключ в Open WebUI -> Settings -> Account -> API Keys"
|
||||
)
|
||||
|
||||
timeout = int(os.getenv("OPENWEBUI_TIMEOUT", "300"))
|
||||
|
||||
openwebui_config = OpenWebUIConfig(
|
||||
api_url=f"{webui_url}/api/v1",
|
||||
api_key=webui_api_key,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# Authentik конфигурация (опционально)
|
||||
authentik_url = os.getenv("DOMAIN_AUTHENTIK", "").rstrip("/")
|
||||
authentik_token = os.getenv("AUTHENTIK_API_TOKEN")
|
||||
|
||||
authentik_config = AuthentikConfig(
|
||||
api_url=authentik_url if authentik_url else None,
|
||||
api_token=authentik_token
|
||||
)
|
||||
|
||||
# Общие настройки воркера
|
||||
sync_interval = int(os.getenv("SYNC_INTERVAL", "300"))
|
||||
max_file_size = int(os.getenv("MAX_FILE_SIZE", str(100 * 1024 * 1024)))
|
||||
db_path = os.getenv("DB_PATH", "sync_state.db")
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
return WorkerConfig(
|
||||
nextcloud=nextcloud_config,
|
||||
openwebui=openwebui_config,
|
||||
authentik=authentik_config,
|
||||
sync_interval=sync_interval,
|
||||
max_file_size=max_file_size,
|
||||
db_path=db_path,
|
||||
log_level=log_level
|
||||
)
|
||||
243
worker/document_processor.py
Normal file
243
worker/document_processor.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Обработка документов различных форматов
|
||||
Поддержка PDF, DOCX, текстовых файлов с обработкой больших файлов (>100MB)
|
||||
"""
|
||||
import logging
|
||||
import io
|
||||
from typing import Optional, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentProcessor:
|
||||
"""Обработчик документов различных форматов"""
|
||||
|
||||
def __init__(self, max_file_size: int = 100 * 1024 * 1024):
|
||||
"""
|
||||
Инициализация процессора документов
|
||||
|
||||
Args:
|
||||
max_file_size: Максимальный размер файла для прямой обработки (байты)
|
||||
"""
|
||||
self.max_file_size = max_file_size
|
||||
|
||||
def process_file(
|
||||
self,
|
||||
file_content: bytes,
|
||||
filename: str,
|
||||
mime_type: Optional[str] = None
|
||||
) -> Tuple[str, bool]:
|
||||
"""
|
||||
Обработка файла и извлечение текста
|
||||
|
||||
Args:
|
||||
file_content: Содержимое файла
|
||||
filename: Имя файла (для определения типа)
|
||||
mime_type: MIME тип файла (опционально)
|
||||
|
||||
Returns:
|
||||
Кортеж (текст, is_large_file) где is_large_file указывает,
|
||||
что файл был обработан потоково из-за большого размера
|
||||
"""
|
||||
file_size = len(file_content)
|
||||
is_large_file = file_size > self.max_file_size
|
||||
|
||||
# Определение типа файла
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
|
||||
try:
|
||||
if file_ext == '.pdf' or mime_type == 'application/pdf':
|
||||
return self._process_pdf(file_content, is_large_file), is_large_file
|
||||
|
||||
elif file_ext in ['.docx', '.doc'] or mime_type in ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/msword']:
|
||||
return self._process_docx(file_content), False
|
||||
|
||||
elif file_ext in ['.txt', '.md', '.markdown'] or mime_type in ['text/plain', 'text/markdown']:
|
||||
return self._process_text(file_content), False
|
||||
|
||||
elif file_ext == '.csv' or mime_type == 'text/csv':
|
||||
return self._process_csv(file_content), False
|
||||
|
||||
else:
|
||||
logger.warning(f"Неподдерживаемый формат файла: {filename} (тип: {mime_type})")
|
||||
# Пытаемся обработать как текст
|
||||
try:
|
||||
return self._process_text(file_content), False
|
||||
except Exception:
|
||||
raise ValueError(f"Не удалось обработать файл {filename}: неподдерживаемый формат")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке файла {filename}: {e}")
|
||||
raise
|
||||
|
||||
def _process_pdf(self, content: bytes, is_large: bool) -> str:
|
||||
"""
|
||||
Обработка PDF файла
|
||||
|
||||
Args:
|
||||
content: Содержимое PDF
|
||||
is_large: Флаг большого файла (для потоковой обработки)
|
||||
|
||||
Returns:
|
||||
Извлеченный текст
|
||||
"""
|
||||
try:
|
||||
import pypdf
|
||||
|
||||
pdf_file = io.BytesIO(content)
|
||||
pdf_reader = pypdf.PdfReader(pdf_file)
|
||||
|
||||
text_parts = []
|
||||
total_pages = len(pdf_reader.pages)
|
||||
|
||||
logger.info(f"Обработка PDF: {total_pages} страниц")
|
||||
|
||||
# Для больших файлов обрабатываем страницы порциями
|
||||
if is_large:
|
||||
# Ограничиваем количество страниц для очень больших файлов
|
||||
max_pages = min(total_pages, 1000) # Максимум 1000 страниц
|
||||
logger.warning(f"Большой PDF файл. Обрабатываются первые {max_pages} из {total_pages} страниц")
|
||||
else:
|
||||
max_pages = total_pages
|
||||
|
||||
for page_num in range(max_pages):
|
||||
try:
|
||||
page = pdf_reader.pages[page_num]
|
||||
text = page.extract_text()
|
||||
if text.strip():
|
||||
text_parts.append(f"--- Страница {page_num + 1} ---\n{text}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка при обработке страницы {page_num + 1}: {e}")
|
||||
continue
|
||||
|
||||
result = "\n\n".join(text_parts)
|
||||
|
||||
if not result.strip():
|
||||
raise ValueError("Не удалось извлечь текст из PDF")
|
||||
|
||||
return result
|
||||
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Библиотека pypdf не установлена. Установите: pip install pypdf"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке PDF: {e}")
|
||||
raise
|
||||
|
||||
def _process_docx(self, content: bytes) -> str:
|
||||
"""
|
||||
Обработка DOCX файла
|
||||
|
||||
Args:
|
||||
content: Содержимое DOCX
|
||||
|
||||
Returns:
|
||||
Извлеченный текст
|
||||
"""
|
||||
try:
|
||||
import docx
|
||||
|
||||
doc_file = io.BytesIO(content)
|
||||
doc = docx.Document(doc_file)
|
||||
|
||||
text_parts = []
|
||||
|
||||
# Извлечение текста из параграфов
|
||||
for paragraph in doc.paragraphs:
|
||||
if paragraph.text.strip():
|
||||
text_parts.append(paragraph.text)
|
||||
|
||||
# Извлечение текста из таблиц
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
row_text = " | ".join(cell.text.strip() for cell in row.cells)
|
||||
if row_text.strip():
|
||||
text_parts.append(row_text)
|
||||
|
||||
result = "\n\n".join(text_parts)
|
||||
|
||||
if not result.strip():
|
||||
raise ValueError("Не удалось извлечь текст из DOCX")
|
||||
|
||||
return result
|
||||
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Библиотека python-docx не установлена. Установите: pip install python-docx"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке DOCX: {e}")
|
||||
raise
|
||||
|
||||
def _process_text(self, content: bytes) -> str:
|
||||
"""
|
||||
Обработка текстового файла
|
||||
|
||||
Args:
|
||||
content: Содержимое файла
|
||||
|
||||
Returns:
|
||||
Текст с правильной кодировкой
|
||||
"""
|
||||
# Попытка различных кодировок
|
||||
encodings = ['utf-8', 'utf-8-sig', 'cp1251', 'latin-1']
|
||||
|
||||
for encoding in encodings:
|
||||
try:
|
||||
return content.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
# Если ничего не помогло, используем errors='replace'
|
||||
return content.decode('utf-8', errors='replace')
|
||||
|
||||
def _process_csv(self, content: bytes) -> str:
|
||||
"""
|
||||
Обработка CSV файла (конвертация в читаемый текст)
|
||||
|
||||
Args:
|
||||
content: Содержимое CSV
|
||||
|
||||
Returns:
|
||||
Текстовая версия CSV
|
||||
"""
|
||||
import csv
|
||||
|
||||
text_content = self._process_text(content)
|
||||
csv_reader = csv.reader(io.StringIO(text_content))
|
||||
|
||||
rows = []
|
||||
for row_num, row in enumerate(csv_reader, 1):
|
||||
rows.append(f"Строка {row_num}: {' | '.join(row)}")
|
||||
|
||||
return "\n".join(rows)
|
||||
|
||||
def is_supported_format(self, filename: str, mime_type: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Проверка поддержки формата файла
|
||||
|
||||
Args:
|
||||
filename: Имя файла
|
||||
mime_type: MIME тип
|
||||
|
||||
Returns:
|
||||
True если формат поддерживается
|
||||
"""
|
||||
file_ext = Path(filename).suffix.lower()
|
||||
supported_extensions = {'.pdf', '.docx', '.doc', '.txt', '.md', '.markdown', '.csv'}
|
||||
|
||||
if file_ext in supported_extensions:
|
||||
return True
|
||||
|
||||
supported_mimes = {
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/csv'
|
||||
}
|
||||
|
||||
return mime_type in supported_mimes if mime_type else False
|
||||
249
worker/nextcloud_client.py
Normal file
249
worker/nextcloud_client.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
WebDAV клиент для работы с Nextcloud
|
||||
Сканирование директорий, загрузка файлов, получение метаданных
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from pathlib import Path
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from requests.exceptions import RequestException, Timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
"""Клиент для работы с Nextcloud через WebDAV API"""
|
||||
|
||||
def __init__(self, url: str, username: str, password: str):
|
||||
"""
|
||||
Инициализация WebDAV клиента
|
||||
|
||||
Args:
|
||||
url: URL Nextcloud (например, https://next.iieasy.ru)
|
||||
username: Имя пользователя Nextcloud
|
||||
password: App Password (не основной пароль!)
|
||||
"""
|
||||
self.base_url = url.rstrip("/")
|
||||
self.webdav_url = f"{self.base_url}/remote.php/dav/files/{username}"
|
||||
self.auth = HTTPBasicAuth(username, password)
|
||||
self.session = requests.Session()
|
||||
self.session.auth = self.auth
|
||||
self.session.headers.update({
|
||||
"User-Agent": "iiEasy-Nextcloud-Sync/1.0"
|
||||
})
|
||||
|
||||
def list_directory(self, path: str, depth: int = 1) -> List[Dict]:
|
||||
"""
|
||||
Получение списка файлов и директорий через PROPFIND
|
||||
|
||||
Args:
|
||||
path: Путь к директории (относительно WebDAV root)
|
||||
depth: Глубина рекурсии (0 - только указанный ресурс, 1 - + дочерние)
|
||||
|
||||
Returns:
|
||||
Список словарей с информацией о файлах/директориях
|
||||
|
||||
Raises:
|
||||
RequestException: При ошибках сетевого запроса
|
||||
"""
|
||||
url = f"{self.webdav_url}/{path.lstrip('/')}"
|
||||
|
||||
try:
|
||||
# PROPFIND запрос для получения списка ресурсов
|
||||
headers = {
|
||||
"Depth": str(depth),
|
||||
"Content-Type": "application/xml"
|
||||
}
|
||||
|
||||
body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<oc:fileid/>
|
||||
<nc:has-preview/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
response = self.session.request(
|
||||
"PROPFIND",
|
||||
url,
|
||||
headers=headers,
|
||||
data=body,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Парсинг XML ответа (упрощенная версия)
|
||||
# В production лучше использовать xml.etree.ElementTree
|
||||
files = []
|
||||
# Здесь должен быть парсинг XML, но для простоты используем альтернативный метод
|
||||
return self._parse_propfind_response(response.text)
|
||||
|
||||
except Timeout:
|
||||
logger.error(f"Таймаут при получении списка файлов: {path}")
|
||||
raise
|
||||
except RequestException as e:
|
||||
logger.error(f"Ошибка при получении списка файлов {path}: {e}")
|
||||
raise
|
||||
|
||||
def _parse_propfind_response(self, xml_content: str) -> List[Dict]:
|
||||
"""
|
||||
Упрощенный парсинг PROPFIND ответа
|
||||
В production лучше использовать xml.etree.ElementTree или lxml
|
||||
"""
|
||||
import re
|
||||
files = []
|
||||
|
||||
# Простой regex парсинг (для production использовать XML парсер)
|
||||
# Ищем href и getlastmodified
|
||||
href_pattern = r'<d:href>(.*?)</d:href>'
|
||||
modified_pattern = r'<d:getlastmodified>(.*?)</d:getlastmodified>'
|
||||
size_pattern = r'<d:getcontentlength>(.*?)</d:getcontentlength>'
|
||||
type_pattern = r'<d:getcontenttype>(.*?)</d:getcontenttype>'
|
||||
|
||||
hrefs = re.findall(href_pattern, xml_content)
|
||||
modifieds = re.findall(modified_pattern, xml_content)
|
||||
sizes = re.findall(size_pattern, xml_content)
|
||||
types = re.findall(type_pattern, xml_content)
|
||||
|
||||
for i, href in enumerate(hrefs):
|
||||
# Убираем префикс /remote.php/dav/files/username
|
||||
clean_href = href.replace(f"/remote.php/dav/files/{self.auth.username}", "")
|
||||
if clean_href == "":
|
||||
continue
|
||||
|
||||
files.append({
|
||||
"path": clean_href,
|
||||
"modified": modifieds[i] if i < len(modifieds) else None,
|
||||
"size": int(sizes[i]) if i < len(sizes) and sizes[i] else 0,
|
||||
"type": types[i] if i < len(types) else "application/octet-stream",
|
||||
"is_directory": i < len(types) and types[i] == "httpd/unix-directory"
|
||||
})
|
||||
|
||||
return files
|
||||
|
||||
def download_file(self, path: str) -> bytes:
|
||||
"""
|
||||
Загрузка файла из Nextcloud
|
||||
|
||||
Args:
|
||||
path: Путь к файлу (относительно WebDAV root)
|
||||
|
||||
Returns:
|
||||
Содержимое файла в виде bytes
|
||||
|
||||
Raises:
|
||||
RequestException: При ошибках загрузки
|
||||
"""
|
||||
url = f"{self.webdav_url}/{path.lstrip('/')}"
|
||||
|
||||
try:
|
||||
response = self.session.get(url, timeout=300, stream=True)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
except Timeout:
|
||||
logger.error(f"Таймаут при загрузке файла: {path}")
|
||||
raise
|
||||
except RequestException as e:
|
||||
logger.error(f"Ошибка при загрузке файла {path}: {e}")
|
||||
raise
|
||||
|
||||
def get_file_metadata(self, path: str) -> Dict:
|
||||
"""
|
||||
Получение метаданных файла (размер, дата изменения)
|
||||
|
||||
Args:
|
||||
path: Путь к файлу
|
||||
|
||||
Returns:
|
||||
Словарь с метаданными
|
||||
"""
|
||||
try:
|
||||
url = f"{self.webdav_url}/{path.lstrip('/')}"
|
||||
response = self.session.head(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
return {
|
||||
"size": int(response.headers.get("Content-Length", 0)),
|
||||
"modified": response.headers.get("Last-Modified"),
|
||||
"etag": response.headers.get("ETag", "").strip('"')
|
||||
}
|
||||
except RequestException as e:
|
||||
logger.warning(f"Не удалось получить метаданные для {path}: {e}")
|
||||
return {
|
||||
"size": 0,
|
||||
"modified": None,
|
||||
"etag": ""
|
||||
}
|
||||
|
||||
def scan_directory_recursive(self, base_path: str, max_depth: int = 10) -> List[Dict]:
|
||||
"""
|
||||
Рекурсивное сканирование директории
|
||||
|
||||
Args:
|
||||
base_path: Базовый путь для сканирования
|
||||
max_depth: Максимальная глубина рекурсии
|
||||
|
||||
Returns:
|
||||
Список всех файлов с их метаданными
|
||||
"""
|
||||
all_files = []
|
||||
|
||||
def scan_recursive(current_path: str, depth: int = 0):
|
||||
if depth > max_depth:
|
||||
return
|
||||
|
||||
try:
|
||||
items = self.list_directory(current_path, depth=1)
|
||||
|
||||
for item in items:
|
||||
item_path = item["path"]
|
||||
|
||||
# Пропускаем сам каталог
|
||||
if item_path == current_path:
|
||||
continue
|
||||
|
||||
if item.get("is_directory"):
|
||||
# Рекурсивный обход поддиректорий
|
||||
scan_recursive(item_path, depth + 1)
|
||||
else:
|
||||
# Добавляем файл в список
|
||||
all_files.append({
|
||||
"path": item_path,
|
||||
"size": item.get("size", 0),
|
||||
"modified": item.get("modified"),
|
||||
"type": item.get("type", "application/octet-stream")
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сканировании {current_path}: {e}")
|
||||
|
||||
scan_recursive(base_path)
|
||||
return all_files
|
||||
|
||||
def extract_username_from_path(self, path: str) -> Optional[str]:
|
||||
"""
|
||||
Извлечение имени пользователя из пути Nextcloud
|
||||
Например: /home/username/Documents -> username
|
||||
|
||||
Args:
|
||||
path: Путь к файлу/директории
|
||||
|
||||
Returns:
|
||||
Имя пользователя или None
|
||||
"""
|
||||
# Формат путей: /home/{username}/...
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) >= 2 and parts[0] == "home":
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""Закрытие сессии"""
|
||||
self.session.close()
|
||||
448
worker/nextcloud_sync.py
Executable file
448
worker/nextcloud_sync.py
Executable file
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Воркер синхронизации Nextcloud -> Qdrant через Open WebUI API
|
||||
Background процесс для автоматической синхронизации документов
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import sqlite3
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# Импорт локальных модулей
|
||||
from config import load_config, WorkerConfig
|
||||
from nextcloud_client import NextcloudClient
|
||||
from openwebui_client import OpenWebUIClient
|
||||
from document_processor import DocumentProcessor
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('sync.log'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncStateDB:
|
||||
"""Управление состоянием синхронизации через SQLite"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""
|
||||
Инициализация БД состояния
|
||||
|
||||
Args:
|
||||
db_path: Путь к файлу БД
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
"""Инициализация схемы БД"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS synced_files (
|
||||
file_path TEXT PRIMARY KEY,
|
||||
file_hash TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
last_modified TEXT,
|
||||
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id TEXT,
|
||||
document_id TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_file_hash ON synced_files(file_hash)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_file_hash(self, file_path: str, file_content: bytes) -> str:
|
||||
"""Вычисление хеша файла"""
|
||||
return hashlib.sha256(file_content).hexdigest()
|
||||
|
||||
def is_file_synced(self, file_path: str, file_hash: str) -> bool:
|
||||
"""
|
||||
Проверка, синхронизирован ли файл
|
||||
|
||||
Args:
|
||||
file_path: Путь к файлу
|
||||
file_hash: Хеш содержимого файла
|
||||
|
||||
Returns:
|
||||
True если файл уже синхронизирован с таким хешем
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT file_hash FROM synced_files
|
||||
WHERE file_path = ? AND file_hash = ?
|
||||
""", (file_path, file_hash))
|
||||
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
return result is not None
|
||||
|
||||
def mark_file_synced(
|
||||
self,
|
||||
file_path: str,
|
||||
file_hash: str,
|
||||
file_size: int,
|
||||
last_modified: Optional[str],
|
||||
user_id: Optional[str],
|
||||
document_id: Optional[str]
|
||||
):
|
||||
"""Отметка файла как синхронизированного"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO synced_files
|
||||
(file_path, file_hash, file_size, last_modified, synced_at, user_id, document_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
file_path,
|
||||
file_hash,
|
||||
file_size,
|
||||
last_modified,
|
||||
datetime.now().isoformat(),
|
||||
user_id,
|
||||
document_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_sync_stats(self) -> Dict:
|
||||
"""Получение статистики синхронизации"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM synced_files")
|
||||
total_files = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT SUM(file_size) FROM synced_files")
|
||||
total_size = cursor.fetchone()[0] or 0
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"total_files": total_files,
|
||||
"total_size": total_size
|
||||
}
|
||||
|
||||
|
||||
class UserMapper:
|
||||
"""Маппинг пользователей Nextcloud -> Authentik"""
|
||||
|
||||
def __init__(self, config: WorkerConfig):
|
||||
"""
|
||||
Инициализация маппера пользователей
|
||||
|
||||
Args:
|
||||
config: Конфигурация воркера
|
||||
"""
|
||||
self.config = config
|
||||
# Кеш маппинга (в production можно использовать БД или Authentik API)
|
||||
self._cache: Dict[str, str] = {}
|
||||
|
||||
def map_nextcloud_user_to_authentik(self, nc_username: str) -> Optional[str]:
|
||||
"""
|
||||
Маппинг имени пользователя Nextcloud в user_id Authentik
|
||||
|
||||
Args:
|
||||
nc_username: Имя пользователя в Nextcloud
|
||||
|
||||
Returns:
|
||||
user_id Authentik или None если маппинг не найден
|
||||
"""
|
||||
# Проверка кеша
|
||||
if nc_username in self._cache:
|
||||
return self._cache[nc_username]
|
||||
|
||||
# В production здесь должен быть запрос к Authentik API
|
||||
# или использование общей БД пользователей
|
||||
# Пока используем простое предположение: username совпадает
|
||||
# или можно настроить через переменные окружения
|
||||
|
||||
# Попытка получить из переменных окружения (формат: NC_USER_username=AUTHENTIK_USER_ID)
|
||||
env_key = f"NC_USER_{nc_username}"
|
||||
authentik_user_id = None
|
||||
|
||||
import os
|
||||
if env_key in os.environ:
|
||||
authentik_user_id = os.environ[env_key]
|
||||
else:
|
||||
# По умолчанию предполагаем, что username совпадает
|
||||
# В production это должно быть через Authentik API
|
||||
authentik_user_id = nc_username
|
||||
|
||||
self._cache[nc_username] = authentik_user_id
|
||||
return authentik_user_id
|
||||
|
||||
def get_user_groups(self, nc_username: str) -> List[str]:
|
||||
"""
|
||||
Получение списка групп доступа для пользователя
|
||||
|
||||
Args:
|
||||
nc_username: Имя пользователя Nextcloud
|
||||
|
||||
Returns:
|
||||
Список групп доступа
|
||||
"""
|
||||
# В production получать из Authentik API или Nextcloud групп
|
||||
# Пока возвращаем пустой список (только личные файлы)
|
||||
return []
|
||||
|
||||
|
||||
class NextcloudSyncWorker:
|
||||
"""Основной класс воркера синхронизации"""
|
||||
|
||||
def __init__(self, config: WorkerConfig):
|
||||
"""
|
||||
Инициализация воркера
|
||||
|
||||
Args:
|
||||
config: Конфигурация воркера
|
||||
"""
|
||||
self.config = config
|
||||
self.nc_client = NextcloudClient(
|
||||
config.nextcloud.url,
|
||||
config.nextcloud.username,
|
||||
config.nextcloud.password
|
||||
)
|
||||
self.webui_client = OpenWebUIClient(
|
||||
config.openwebui.api_url,
|
||||
config.openwebui.api_key,
|
||||
config.openwebui.timeout
|
||||
)
|
||||
self.processor = DocumentProcessor(config.max_file_size)
|
||||
self.state_db = SyncStateDB(config.db_path)
|
||||
self.user_mapper = UserMapper(config)
|
||||
|
||||
def sync_path(self, path_template: str) -> int:
|
||||
"""
|
||||
Синхронизация указанного пути
|
||||
|
||||
Args:
|
||||
path_template: Шаблон пути (может содержать {username})
|
||||
|
||||
Returns:
|
||||
Количество синхронизированных файлов
|
||||
"""
|
||||
synced_count = 0
|
||||
|
||||
# Замена {username} в шаблоне пути
|
||||
if "{username}" in path_template:
|
||||
# Сканируем все домашние директории
|
||||
# В production можно получить список пользователей из Nextcloud API
|
||||
username = self.config.nextcloud.username
|
||||
path = path_template.replace("{username}", username)
|
||||
else:
|
||||
path = path_template
|
||||
|
||||
try:
|
||||
logger.info(f"Сканирование пути: {path}")
|
||||
files = self.nc_client.scan_directory_recursive(path)
|
||||
logger.info(f"Найдено файлов: {len(files)}")
|
||||
|
||||
for file_info in files:
|
||||
try:
|
||||
if self._sync_file(file_info, path_template):
|
||||
synced_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации файла {file_info['path']}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сканировании пути {path}: {e}")
|
||||
|
||||
return synced_count
|
||||
|
||||
def _sync_file(self, file_info: Dict, path_template: str) -> bool:
|
||||
"""
|
||||
Синхронизация одного файла
|
||||
|
||||
Args:
|
||||
file_info: Информация о файле
|
||||
path_template: Шаблон пути (для определения владельца)
|
||||
|
||||
Returns:
|
||||
True если файл был синхронизирован
|
||||
"""
|
||||
file_path = file_info["path"]
|
||||
file_size = file_info.get("size", 0)
|
||||
|
||||
# Пропускаем слишком большие файлы
|
||||
if file_size > self.config.max_file_size * 2: # Двойной лимит для безопасности
|
||||
logger.warning(f"Файл {file_path} слишком большой ({file_size} bytes), пропуск")
|
||||
return False
|
||||
|
||||
# Проверка поддержки формата
|
||||
if not self.processor.is_supported_format(file_path, file_info.get("type")):
|
||||
logger.debug(f"Неподдерживаемый формат: {file_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Загрузка файла
|
||||
file_content = self.nc_client.download_file(file_path)
|
||||
file_hash = self.state_db.get_file_hash(file_path, file_content)
|
||||
|
||||
# Проверка, не синхронизирован ли уже файл
|
||||
if self.state_db.is_file_synced(file_path, file_hash):
|
||||
logger.debug(f"Файл уже синхронизирован: {file_path}")
|
||||
return False
|
||||
|
||||
# Обработка файла
|
||||
text_content, is_large = self.processor.process_file(
|
||||
file_content,
|
||||
Path(file_path).name,
|
||||
file_info.get("type")
|
||||
)
|
||||
|
||||
# Определение владельца файла
|
||||
username = self.nc_client.extract_username_from_path(file_path)
|
||||
if not username:
|
||||
# Для общих папок используем None или специальную группу
|
||||
username = None
|
||||
|
||||
user_id = self.user_mapper.map_nextcloud_user_to_authentik(username) if username else None
|
||||
access_groups = self.user_mapper.get_user_groups(username) if username else []
|
||||
|
||||
# Метаданные для API
|
||||
metadata = {
|
||||
"source": "nextcloud",
|
||||
"path": file_path,
|
||||
"original_size": file_size,
|
||||
"is_large_file": is_large
|
||||
}
|
||||
|
||||
# Загрузка в Open WebUI
|
||||
if is_large or len(text_content.encode('utf-8')) > self.config.max_file_size:
|
||||
# Для больших файлов загружаем как текст
|
||||
result = self.webui_client.upload_document_with_text(
|
||||
text_content=text_content,
|
||||
filename=Path(file_path).name,
|
||||
user_id=user_id,
|
||||
access_groups=access_groups,
|
||||
metadata=metadata
|
||||
)
|
||||
else:
|
||||
# Для обычных файлов загружаем оригинал
|
||||
result = self.webui_client.upload_document(
|
||||
file_content=file_content,
|
||||
filename=Path(file_path).name,
|
||||
user_id=user_id,
|
||||
access_groups=access_groups,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# Сохранение состояния
|
||||
document_id = result.get("id") if isinstance(result, dict) else None
|
||||
self.state_db.mark_file_synced(
|
||||
file_path=file_path,
|
||||
file_hash=file_hash,
|
||||
file_size=file_size,
|
||||
last_modified=file_info.get("modified"),
|
||||
user_id=user_id,
|
||||
document_id=document_id
|
||||
)
|
||||
|
||||
logger.info(f"Файл синхронизирован: {file_path} -> документ ID: {document_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации файла {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def run_once(self):
|
||||
"""Однократный запуск синхронизации"""
|
||||
logger.info("=== Начало синхронизации ===")
|
||||
|
||||
total_synced = 0
|
||||
|
||||
for path_template in self.config.nextcloud.scan_paths:
|
||||
try:
|
||||
synced = self.sync_path(path_template)
|
||||
total_synced += synced
|
||||
logger.info(f"Синхронизировано файлов из {path_template}: {synced}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации пути {path_template}: {e}")
|
||||
|
||||
stats = self.state_db.get_sync_stats()
|
||||
logger.info(f"=== Синхронизация завершена ===")
|
||||
logger.info(f"Всего синхронизировано в этом цикле: {total_synced}")
|
||||
logger.info(f"Всего файлов в БД: {stats['total_files']}")
|
||||
logger.info(f"Общий размер: {stats['total_size'] / 1024 / 1024:.2f} MB")
|
||||
|
||||
def run_daemon(self):
|
||||
"""Запуск воркера в режиме daemon (бесконечный цикл)"""
|
||||
logger.info("Запуск воркера в режиме daemon")
|
||||
logger.info(f"Интервал синхронизации: {self.config.sync_interval} секунд")
|
||||
|
||||
try:
|
||||
while True:
|
||||
self.run_once()
|
||||
logger.info(f"Ожидание {self.config.sync_interval} секунд до следующей синхронизации...")
|
||||
time.sleep(self.config.sync_interval)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Получен сигнал остановки, завершение работы...")
|
||||
finally:
|
||||
self.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
"""Очистка ресурсов"""
|
||||
logger.info("Очистка ресурсов...")
|
||||
self.nc_client.close()
|
||||
self.webui_client.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Точка входа"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Воркер синхронизации Nextcloud -> Qdrant")
|
||||
parser.add_argument(
|
||||
"--once",
|
||||
action="store_true",
|
||||
help="Запустить синхронизацию один раз и выйти"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--daemon",
|
||||
action="store_true",
|
||||
help="Запустить в режиме daemon (бесконечный цикл)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
worker = NextcloudSyncWorker(config)
|
||||
|
||||
if args.once:
|
||||
worker.run_once()
|
||||
elif args.daemon:
|
||||
worker.run_daemon()
|
||||
else:
|
||||
# По умолчанию запускаем один раз
|
||||
worker.run_once()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
179
worker/openwebui_client.py
Normal file
179
worker/openwebui_client.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
HTTP клиент для загрузки документов в Open WebUI API
|
||||
Поддержка метаданных user_id и access_groups для изоляции доступа
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Optional, List
|
||||
import requests
|
||||
from requests.exceptions import RequestException, Timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenWebUIClient:
|
||||
"""Клиент для работы с Open WebUI API"""
|
||||
|
||||
def __init__(self, api_url: str, api_key: str, timeout: int = 300):
|
||||
"""
|
||||
Инициализация клиента Open WebUI API
|
||||
|
||||
Args:
|
||||
api_url: Базовый URL API (например, https://odo.iieasy.ru/api/v1)
|
||||
api_key: API ключ из Open WebUI Settings -> Account -> API Keys
|
||||
timeout: Таймаут для запросов (секунды)
|
||||
"""
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "iiEasy-Nextcloud-Sync/1.0"
|
||||
})
|
||||
|
||||
def upload_document(
|
||||
self,
|
||||
file_content: bytes,
|
||||
filename: str,
|
||||
user_id: Optional[str] = None,
|
||||
access_groups: Optional[List[str]] = None,
|
||||
metadata: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Загрузка документа в Open WebUI через API
|
||||
|
||||
Args:
|
||||
file_content: Содержимое файла в виде bytes
|
||||
filename: Имя файла
|
||||
user_id: ID пользователя Authentik для изоляции доступа
|
||||
access_groups: Список групп доступа (для совместного доступа)
|
||||
metadata: Дополнительные метаданные (source, path и т.д.)
|
||||
|
||||
Returns:
|
||||
Ответ API в виде словаря
|
||||
|
||||
Raises:
|
||||
RequestException: При ошибках загрузки
|
||||
"""
|
||||
url = f"{self.api_url}/documents"
|
||||
|
||||
# Подготовка данных для multipart/form-data
|
||||
files = {
|
||||
"file": (filename, file_content)
|
||||
}
|
||||
|
||||
# Метаданные передаются через form-data или JSON
|
||||
data = {}
|
||||
|
||||
if user_id:
|
||||
data["user_id"] = user_id
|
||||
|
||||
if access_groups:
|
||||
# Если API поддерживает список групп, передаем как JSON строку или отдельные поля
|
||||
data["access_groups"] = ",".join(access_groups) if isinstance(access_groups, list) else access_groups
|
||||
|
||||
if metadata:
|
||||
# Добавляем дополнительные метаданные
|
||||
for key, value in metadata.items():
|
||||
if key not in data:
|
||||
data[key] = str(value)
|
||||
|
||||
try:
|
||||
logger.info(f"Загрузка документа {filename} (размер: {len(file_content)} bytes)")
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json() if response.content else {}
|
||||
logger.info(f"Документ {filename} успешно загружен. ID: {result.get('id', 'unknown')}")
|
||||
|
||||
return result
|
||||
|
||||
except Timeout:
|
||||
logger.error(f"Таймаут при загрузке документа {filename}")
|
||||
raise
|
||||
except RequestException as e:
|
||||
logger.error(f"Ошибка при загрузке документа {filename}: {e}")
|
||||
if hasattr(e.response, 'text'):
|
||||
logger.error(f"Ответ сервера: {e.response.text}")
|
||||
raise
|
||||
|
||||
def upload_document_with_text(
|
||||
self,
|
||||
text_content: str,
|
||||
filename: str,
|
||||
user_id: Optional[str] = None,
|
||||
access_groups: Optional[List[str]] = None,
|
||||
metadata: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Загрузка текстового документа (например, извлеченного из PDF)
|
||||
|
||||
Args:
|
||||
text_content: Текст документа
|
||||
filename: Имя файла (будет изменено на .txt если нужно)
|
||||
user_id: ID пользователя Authentik
|
||||
access_groups: Список групп доступа
|
||||
metadata: Дополнительные метаданные
|
||||
|
||||
Returns:
|
||||
Ответ API
|
||||
"""
|
||||
# Убеждаемся, что файл имеет расширение .txt
|
||||
if not filename.endswith('.txt'):
|
||||
filename = filename.rsplit('.', 1)[0] + '.txt'
|
||||
|
||||
file_content = text_content.encode('utf-8')
|
||||
return self.upload_document(
|
||||
file_content=file_content,
|
||||
filename=filename,
|
||||
user_id=user_id,
|
||||
access_groups=access_groups,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def check_document_exists(self, document_id: str) -> bool:
|
||||
"""
|
||||
Проверка существования документа по ID
|
||||
|
||||
Args:
|
||||
document_id: ID документа
|
||||
|
||||
Returns:
|
||||
True если документ существует
|
||||
"""
|
||||
try:
|
||||
url = f"{self.api_url}/documents/{document_id}"
|
||||
response = self.session.get(url, timeout=30)
|
||||
return response.status_code == 200
|
||||
except RequestException:
|
||||
return False
|
||||
|
||||
def delete_document(self, document_id: str) -> bool:
|
||||
"""
|
||||
Удаление документа по ID
|
||||
|
||||
Args:
|
||||
document_id: ID документа
|
||||
|
||||
Returns:
|
||||
True если удаление успешно
|
||||
"""
|
||||
try:
|
||||
url = f"{self.api_url}/documents/{document_id}"
|
||||
response = self.session.delete(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except RequestException as e:
|
||||
logger.error(f"Ошибка при удалении документа {document_id}: {e}")
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
"""Закрытие сессии"""
|
||||
self.session.close()
|
||||
17
worker/requirements.txt
Normal file
17
worker/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
# Зависимости для воркера синхронизации Nextcloud -> Qdrant
|
||||
|
||||
# HTTP клиенты
|
||||
requests>=2.31.0
|
||||
|
||||
# Обработка переменных окружения
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Обработка PDF
|
||||
pypdf>=3.17.0
|
||||
|
||||
# Обработка DOCX
|
||||
python-docx>=1.1.0
|
||||
|
||||
# WebDAV клиент (альтернатива requests для WebDAV)
|
||||
# Используем requests напрямую, но можно добавить:
|
||||
# easywebdav>=1.2.0 # Опционально, если нужны специфичные WebDAV функции
|
||||
Reference in New Issue
Block a user