Add project and deployment instruction (docs/DEPLOYMENT.md)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ars
2026-02-19 18:12:09 +00:00
commit 53c572ef46
94 changed files with 9200 additions and 0 deletions

34
worker/.env.example Normal file
View 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
View 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
)

View 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
View 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
View 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
View 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
View 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 функции