feat: initial microservices project structure
Services: - api: FastAPI gateway with JWT auth, async endpoints, WebSocket - worker-gpu: CUDA sentence-transformers, FAISS IVFFlat, Ollama LLM - worker-indexer: Winnowing+MinHash plagiarism detection, PDF/DOCX extraction - worker-notifier: SMTP email notifications - worker-gost: GOST 7.1-2003 and GOST R 7.0.5-2008 formatting Infrastructure: - docker-compose.yml (production) + docker-compose.dev.yml (hot reload) - Nginx reverse proxy + WebSocket support - PostgreSQL 16 with Alembic migrations - Elasticsearch 8 with Russian/English analyzers - MinIO, RabbitMQ, Redis, Ollama Frontend: - React 18 + Vite + TypeScript + TailwindCSS + Zustand + React Query v5 - 9 pages: Home, Search, Cabinet, Task, Check, Bibliography, Pricing, Login, Register Scripts: - Parser stubs: OpenAlex, КиберЛенинка, arXiv (Phase 0 - to be filled) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
services/worker-gpu/app/__init__.py
Normal file
0
services/worker-gpu/app/__init__.py
Normal file
38
services/worker-gpu/app/celery_app.py
Normal file
38
services/worker-gpu/app/celery_app.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Celery приложение GPU воркера."""
|
||||
|
||||
from celery import Celery
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from app.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"worker_gpu",
|
||||
broker=settings.RABBITMQ_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
include=["app.tasks.search", "app.tasks.plagiarism"],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
result_serializer="json",
|
||||
accept_content=["json"],
|
||||
timezone="Europe/Moscow",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
# Маршрутизация
|
||||
task_routes={
|
||||
"gpu.*": {"queue": "queue.gpu"},
|
||||
"index.*": {"queue": "queue.index"},
|
||||
"notify.*": {"queue": "queue.notify"},
|
||||
"gost.*": {"queue": "queue.gost"},
|
||||
},
|
||||
# Надёжность
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
# GPU воркер — только 1 процесс
|
||||
worker_concurrency=1,
|
||||
# Результаты хранить 24 часа
|
||||
result_expires=86400,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
64
services/worker-gpu/app/config.py
Normal file
64
services/worker-gpu/app/config.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Конфигурация GPU воркера."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Настройки GPU воркера."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_HOST: str = "postgres"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_DB: str = "antiplagiator"
|
||||
POSTGRES_USER: str = "antiplagiator"
|
||||
POSTGRES_PASSWORD: str = "changeme"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
|
||||
# RabbitMQ
|
||||
RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/"
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: str = "minio:9000"
|
||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||
MINIO_SECRET_KEY: str = "changeme"
|
||||
MINIO_BUCKET_DOCS: str = "documents"
|
||||
|
||||
# Elasticsearch
|
||||
ELASTICSEARCH_URL: str = "http://elasticsearch:9200"
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL: str = "http://ollama:11434"
|
||||
|
||||
# FAISS / ML
|
||||
FAISS_INDEX_PATH: str = "/data/index/faiss.index"
|
||||
FAISS_ID_MAP_PATH: str = "/data/index/faiss_id_map.json"
|
||||
EMBED_MODEL: str = "paraphrase-multilingual-mpnet-base-v2"
|
||||
EMBED_DEVICE: str = "cuda"
|
||||
EMBED_BATCH_SIZE: int = 64
|
||||
EMBED_DIM: int = 768 # Размерность вектора paraphrase-multilingual-mpnet-base-v2
|
||||
FAISS_NLIST: int = 1024 # Количество кластеров для IVFFlat
|
||||
FAISS_NPROBE: int = 64 # Количество кластеров для поиска
|
||||
|
||||
# App
|
||||
APP_URL: str = "https://academic.jze9.ru"
|
||||
ENVIRONMENT: str = "development"
|
||||
DEBUG: bool = False
|
||||
|
||||
@property
|
||||
def database_url_sync(self) -> str:
|
||||
"""Синхронный URL для SQLAlchemy (psycopg2)."""
|
||||
return (
|
||||
f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
50
services/worker-gpu/app/db.py
Normal file
50
services/worker-gpu/app/db.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Синхронное подключение к PostgreSQL для Celery воркеров."""
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
import redis as redis_lib
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Синхронный движок для воркеров (psycopg2)
|
||||
engine = create_engine(
|
||||
settings.database_url_sync,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db_session() -> Generator[Session, None, None]:
|
||||
"""Контекстный менеджер для сессии БД."""
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
# Redis клиент для кэширования результатов поиска
|
||||
_redis_client: redis_lib.Redis | None = None
|
||||
|
||||
|
||||
def get_redis() -> redis_lib.Redis:
|
||||
"""Получить или создать Redis клиент."""
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
151
services/worker-gpu/app/es_client.py
Normal file
151
services/worker-gpu/app/es_client.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Elasticsearch клиент для полнотекстового поиска (BM25)."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from elasticsearch import Elasticsearch, exceptions as es_exceptions
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_es_client: Elasticsearch | None = None
|
||||
|
||||
INDEX_NAME = "documents"
|
||||
|
||||
|
||||
def get_es_client() -> Elasticsearch:
|
||||
"""Получить или создать синглтон ES клиент."""
|
||||
global _es_client
|
||||
if _es_client is None:
|
||||
_es_client = Elasticsearch(
|
||||
settings.ELASTICSEARCH_URL,
|
||||
retry_on_timeout=True,
|
||||
max_retries=3,
|
||||
request_timeout=30,
|
||||
)
|
||||
return _es_client
|
||||
|
||||
|
||||
def search_fulltext(
|
||||
query: str,
|
||||
lang: str | None = None,
|
||||
year_from: int | None = None,
|
||||
year_to: int | None = None,
|
||||
category: str | None = None,
|
||||
size: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Полнотекстовый BM25 поиск в Elasticsearch.
|
||||
|
||||
Args:
|
||||
query: Поисковый запрос
|
||||
lang: Фильтр языка (ru, en, None = все)
|
||||
year_from: Фильтр года публикации (от)
|
||||
year_to: Фильтр года публикации (до)
|
||||
category: Фильтр тематической категории
|
||||
size: Максимальное количество результатов
|
||||
|
||||
Returns:
|
||||
Список словарей с полями doc_id и score
|
||||
"""
|
||||
es = get_es_client()
|
||||
|
||||
# Составить bool запрос
|
||||
must_clauses: list[dict] = [
|
||||
{
|
||||
"multi_match": {
|
||||
"query": query,
|
||||
"fields": ["title^3", "abstract^2", "authors"],
|
||||
"type": "best_fields",
|
||||
"operator": "or",
|
||||
"minimum_should_match": "30%",
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
filter_clauses: list[dict] = []
|
||||
|
||||
if lang:
|
||||
filter_clauses.append({"term": {"lang": lang}})
|
||||
|
||||
if year_from or year_to:
|
||||
range_filter: dict = {"range": {"year": {}}}
|
||||
if year_from:
|
||||
range_filter["range"]["year"]["gte"] = year_from
|
||||
if year_to:
|
||||
range_filter["range"]["year"]["lte"] = year_to
|
||||
filter_clauses.append(range_filter)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": must_clauses,
|
||||
"filter": filter_clauses,
|
||||
}
|
||||
},
|
||||
"size": size,
|
||||
"_source": ["doc_id"],
|
||||
}
|
||||
|
||||
try:
|
||||
response = es.search(index=INDEX_NAME, body=body)
|
||||
hits = response["hits"]["hits"]
|
||||
return [
|
||||
{
|
||||
"doc_id": hit["_source"]["doc_id"],
|
||||
"es_score": hit["_score"],
|
||||
"es_id": hit["_id"],
|
||||
}
|
||||
for hit in hits
|
||||
]
|
||||
except es_exceptions.ConnectionError as e:
|
||||
logger.error(f"Elasticsearch недоступен: {e}")
|
||||
return []
|
||||
except es_exceptions.NotFoundError:
|
||||
logger.warning(f"Индекс {INDEX_NAME!r} не найден в Elasticsearch")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка поиска в Elasticsearch: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def index_document(doc_id: int, doc_data: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Индексировать документ в Elasticsearch.
|
||||
|
||||
Args:
|
||||
doc_id: ID документа в PostgreSQL
|
||||
doc_data: Словарь с метаданными документа
|
||||
|
||||
Returns:
|
||||
True при успехе, False при ошибке
|
||||
"""
|
||||
es = get_es_client()
|
||||
|
||||
doc = {
|
||||
"doc_id": doc_id,
|
||||
"source": doc_data.get("source"),
|
||||
"title": doc_data.get("title", ""),
|
||||
"abstract": doc_data.get("abstract", ""),
|
||||
"authors": " ".join(
|
||||
f"{a.get('last_name', '')} {a.get('first_name', '')}"
|
||||
for a in doc_data.get("authors", [])
|
||||
),
|
||||
"year": doc_data.get("year"),
|
||||
"lang": doc_data.get("lang"),
|
||||
"journal": doc_data.get("journal"),
|
||||
"doi": doc_data.get("doi"),
|
||||
"url": doc_data.get("url"),
|
||||
}
|
||||
|
||||
try:
|
||||
es.index(
|
||||
index=INDEX_NAME,
|
||||
id=str(doc_id),
|
||||
document=doc,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка индексации документа {doc_id} в ES: {e}")
|
||||
return False
|
||||
210
services/worker-gpu/app/faiss_manager.py
Normal file
210
services/worker-gpu/app/faiss_manager.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Singleton менеджер FAISS GPU индекса.
|
||||
|
||||
Использует IVFFlat (а не HNSW — не поддерживается на GPU).
|
||||
Поддерживает graceful degradation на CPU если GPU недоступна.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FAISSManager:
|
||||
"""Singleton для управления FAISS индексом на GPU."""
|
||||
|
||||
_index = None
|
||||
_id_map: dict[int, int] = {} # faiss_internal_id -> doc_id (PostgreSQL)
|
||||
_reverse_map: dict[int, int] = {} # doc_id -> faiss_internal_id
|
||||
_use_gpu: bool = False
|
||||
_is_trained: bool = False
|
||||
|
||||
@classmethod
|
||||
def load_or_create(cls) -> None:
|
||||
"""
|
||||
Загрузить индекс с диска или создать новый.
|
||||
|
||||
Пытается перенести индекс на GPU, при ошибке остаётся на CPU.
|
||||
"""
|
||||
import faiss
|
||||
|
||||
index_path = settings.FAISS_INDEX_PATH
|
||||
id_map_path = settings.FAISS_ID_MAP_PATH
|
||||
|
||||
# Загрузить ID маппинг
|
||||
if os.path.exists(id_map_path):
|
||||
with open(id_map_path, "r") as f:
|
||||
raw_map = json.load(f)
|
||||
cls._id_map = {int(k): int(v) for k, v in raw_map.items()}
|
||||
cls._reverse_map = {v: k for k, v in cls._id_map.items()}
|
||||
logger.info(f"ID маппинг загружен: {len(cls._id_map)} записей")
|
||||
|
||||
if os.path.exists(index_path):
|
||||
# Загрузить существующий индекс
|
||||
logger.info(f"Загрузка FAISS индекса из {index_path}")
|
||||
cpu_index = faiss.read_index(index_path)
|
||||
cls._is_trained = cpu_index.is_trained
|
||||
else:
|
||||
# Создать новый IVFFlat индекс
|
||||
logger.info("Создание нового FAISS IVFFlat индекса...")
|
||||
quantizer = faiss.IndexFlatIP(settings.EMBED_DIM)
|
||||
cpu_index = faiss.IndexIVFFlat(
|
||||
quantizer,
|
||||
settings.EMBED_DIM,
|
||||
settings.FAISS_NLIST,
|
||||
faiss.METRIC_INNER_PRODUCT,
|
||||
)
|
||||
# IVFFlat требует обучения перед использованием
|
||||
cls._is_trained = False
|
||||
|
||||
# Попытка перенести на GPU
|
||||
try:
|
||||
res = faiss.StandardGpuResources()
|
||||
cls._index = faiss.index_cpu_to_gpu(res, 0, cpu_index)
|
||||
cls._use_gpu = True
|
||||
logger.info("FAISS индекс размещён на GPU")
|
||||
except Exception as e:
|
||||
logger.warning(f"GPU недоступна: {e}. Используем CPU FAISS.")
|
||||
cls._index = cpu_index
|
||||
cls._use_gpu = False
|
||||
|
||||
if cls._is_trained:
|
||||
cls._index.nprobe = settings.FAISS_NPROBE
|
||||
|
||||
@classmethod
|
||||
def search(cls, query_vector: np.ndarray, k: int = 20) -> list[tuple[int, float]]:
|
||||
"""
|
||||
Поиск k ближайших векторов.
|
||||
|
||||
Args:
|
||||
query_vector: Нормализованный вектор запроса, форма (768,)
|
||||
k: Количество результатов
|
||||
|
||||
Returns:
|
||||
Список кортежей (doc_id, cosine_score), отсортированных по убыванию score
|
||||
"""
|
||||
if cls._index is None or not cls._is_trained:
|
||||
logger.warning("FAISS индекс не инициализирован или не обучен, пропускаем поиск")
|
||||
return []
|
||||
|
||||
try:
|
||||
query = query_vector.reshape(1, -1).astype(np.float32)
|
||||
distances, indices = cls._index.search(query, k)
|
||||
|
||||
results = []
|
||||
for idx, dist in zip(indices[0], distances[0]):
|
||||
if idx == -1:
|
||||
continue
|
||||
doc_id = cls._id_map.get(int(idx))
|
||||
if doc_id is not None:
|
||||
results.append((doc_id, float(dist)))
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка поиска FAISS: {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def add_vectors(cls, vectors: np.ndarray, doc_ids: list[int]) -> None:
|
||||
"""
|
||||
Добавить векторы в индекс.
|
||||
|
||||
Если индекс не обучен и накопилось достаточно векторов — обучить его.
|
||||
|
||||
Args:
|
||||
vectors: numpy массив формы (N, 768)
|
||||
doc_ids: Список doc_id из PostgreSQL
|
||||
"""
|
||||
import faiss
|
||||
|
||||
if cls._index is None:
|
||||
cls.load_or_create()
|
||||
|
||||
vectors = vectors.astype(np.float32)
|
||||
|
||||
if not cls._is_trained:
|
||||
# Для IVFFlat нужно минимум nlist * 39 обучающих примеров
|
||||
min_train = settings.FAISS_NLIST * 39
|
||||
current_n = cls._index.ntotal if cls._index is not None else 0
|
||||
|
||||
if current_n + len(vectors) >= min_train:
|
||||
logger.info(f"Обучение IVFFlat индекса на {current_n + len(vectors)} векторах...")
|
||||
cls._index.train(vectors)
|
||||
cls._is_trained = True
|
||||
cls._index.nprobe = settings.FAISS_NPROBE
|
||||
logger.info("Обучение завершено")
|
||||
else:
|
||||
logger.info(
|
||||
f"Недостаточно векторов для обучения IVFFlat "
|
||||
f"({current_n + len(vectors)} < {min_train}). "
|
||||
"Используйте FlatIP до накопления достаточного количества документов."
|
||||
)
|
||||
# Временный flat индекс для малого количества документов
|
||||
if not hasattr(cls, '_flat_index') or cls._flat_index is None:
|
||||
cls._flat_index = faiss.IndexFlatIP(settings.EMBED_DIM)
|
||||
|
||||
# Добавить в flat индекс
|
||||
start_id = cls._flat_index.ntotal
|
||||
cls._flat_index.add(vectors)
|
||||
for i, doc_id in enumerate(doc_ids):
|
||||
internal_id = start_id + i
|
||||
cls._id_map[internal_id] = doc_id
|
||||
cls._reverse_map[doc_id] = internal_id
|
||||
cls._save_id_map()
|
||||
return
|
||||
|
||||
if cls._is_trained:
|
||||
start_id = cls._index.ntotal
|
||||
cls._index.add(vectors)
|
||||
for i, doc_id in enumerate(doc_ids):
|
||||
internal_id = start_id + i
|
||||
cls._id_map[internal_id] = doc_id
|
||||
cls._reverse_map[doc_id] = internal_id
|
||||
|
||||
cls._save_id_map()
|
||||
logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}")
|
||||
|
||||
@classmethod
|
||||
def save(cls) -> None:
|
||||
"""Сохранить индекс на диск (CPU версия)."""
|
||||
if cls._index is None:
|
||||
return
|
||||
|
||||
import faiss
|
||||
|
||||
index_path = Path(settings.FAISS_INDEX_PATH)
|
||||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Перенести на CPU перед сохранением
|
||||
if cls._use_gpu:
|
||||
cpu_index = faiss.index_gpu_to_cpu(cls._index)
|
||||
else:
|
||||
cpu_index = cls._index
|
||||
|
||||
faiss.write_index(cpu_index, str(index_path))
|
||||
cls._save_id_map()
|
||||
logger.info(f"FAISS индекс сохранён: {index_path} ({cpu_index.ntotal} векторов)")
|
||||
|
||||
@classmethod
|
||||
def _save_id_map(cls) -> None:
|
||||
"""Сохранить маппинг faiss_id -> doc_id на диск."""
|
||||
id_map_path = Path(settings.FAISS_ID_MAP_PATH)
|
||||
id_map_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(id_map_path, "w") as f:
|
||||
json.dump({str(k): v for k, v in cls._id_map.items()}, f)
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def total_vectors(cls) -> int:
|
||||
"""Количество векторов в индексе."""
|
||||
if cls._index is None:
|
||||
return 0
|
||||
return cls._index.ntotal
|
||||
81
services/worker-gpu/app/model_manager.py
Normal file
81
services/worker-gpu/app/model_manager.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Singleton менеджер sentence-transformers модели.
|
||||
|
||||
Модель загружается один раз при первом обращении и кэшируется в памяти GPU.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelManager:
|
||||
"""Singleton для управления эмбеддинг-моделью."""
|
||||
|
||||
_instance = None
|
||||
_model = None
|
||||
_device: str = settings.EMBED_DEVICE
|
||||
|
||||
@classmethod
|
||||
def get_model(cls):
|
||||
"""Получить или загрузить модель sentence-transformers."""
|
||||
if cls._model is None:
|
||||
# Импорт здесь, чтобы не блокировать импорт модуля если torch не установлен
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
logger.info(
|
||||
f"Загрузка модели {settings.EMBED_MODEL!r} на устройство {settings.EMBED_DEVICE!r}..."
|
||||
)
|
||||
try:
|
||||
cls._model = SentenceTransformer(
|
||||
settings.EMBED_MODEL,
|
||||
device=settings.EMBED_DEVICE,
|
||||
)
|
||||
logger.info(
|
||||
f"Модель загружена. Размерность вектора: {cls._model.get_sentence_embedding_dimension()}"
|
||||
)
|
||||
except Exception as e:
|
||||
# Graceful degradation на CPU если CUDA недоступна
|
||||
logger.warning(f"Не удалось загрузить модель на CUDA: {e}. Переключение на CPU.")
|
||||
cls._device = "cpu"
|
||||
cls._model = SentenceTransformer(settings.EMBED_MODEL, device="cpu")
|
||||
|
||||
return cls._model
|
||||
|
||||
@classmethod
|
||||
def encode(cls, texts: list[str]) -> np.ndarray:
|
||||
"""
|
||||
Закодировать тексты в векторы.
|
||||
|
||||
Args:
|
||||
texts: Список текстов для кодирования
|
||||
|
||||
Returns:
|
||||
numpy массив формы (len(texts), 768), нормализованный для cosine similarity
|
||||
"""
|
||||
if not texts:
|
||||
return np.array([]).reshape(0, settings.EMBED_DIM)
|
||||
|
||||
model = cls.get_model()
|
||||
vectors = model.encode(
|
||||
texts,
|
||||
batch_size=settings.EMBED_BATCH_SIZE,
|
||||
normalize_embeddings=True, # Нормализация для cosine через inner product
|
||||
show_progress_bar=len(texts) > 100,
|
||||
convert_to_numpy=True,
|
||||
)
|
||||
return vectors.astype(np.float32)
|
||||
|
||||
@classmethod
|
||||
def encode_single(cls, text: str) -> np.ndarray:
|
||||
"""Закодировать один текст. Удобный метод."""
|
||||
return cls.encode([text])[0]
|
||||
|
||||
@classmethod
|
||||
def unload(cls) -> None:
|
||||
"""Выгрузить модель из памяти (для тестов/диагностики)."""
|
||||
cls._model = None
|
||||
logger.info("Модель выгружена из памяти")
|
||||
138
services/worker-gpu/app/ollama_client.py
Normal file
138
services/worker-gpu/app/ollama_client.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Клиент для Ollama HTTP API — LLM анализ парафраза и суммаризация."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""HTTP клиент для Ollama LLM."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.OLLAMA_URL
|
||||
self.model = "llama3:8b"
|
||||
self.timeout = 60.0 # секунд
|
||||
|
||||
def check_paraphrase(self, text_a: str, text_b: str) -> dict:
|
||||
"""
|
||||
Проверить является ли text_b парафразом text_a с помощью LLM.
|
||||
|
||||
Args:
|
||||
text_a: Исходный текст из базы данных (до 500 символов)
|
||||
text_b: Проверяемый фрагмент (до 500 символов)
|
||||
|
||||
Returns:
|
||||
dict с полями:
|
||||
- is_paraphrase: bool
|
||||
- confidence: float 0.0-1.0
|
||||
- reason: str — краткое объяснение
|
||||
"""
|
||||
prompt = f"""Ты эксперт по академическому плагиату. Определи, является ли Текст B парафразом Текста A.
|
||||
|
||||
Текст A (источник): {text_a[:500]}
|
||||
|
||||
Текст B (проверяемый): {text_b[:500]}
|
||||
|
||||
Критерии парафраза: передача тех же идей другими словами, перефразировка без ссылки на источник.
|
||||
|
||||
Ответь ТОЛЬКО валидным JSON без пояснений и markdown:
|
||||
{{"is_paraphrase": true/false, "confidence": 0.0-1.0, "reason": "краткое объяснение на русском"}}"""
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json={
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": 0.1, # Детерминированный вывод
|
||||
"num_predict": 200,
|
||||
},
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
llm_response = result.get("response", "{}")
|
||||
|
||||
# Парсим JSON из ответа
|
||||
parsed = json.loads(llm_response)
|
||||
return {
|
||||
"is_paraphrase": bool(parsed.get("is_paraphrase", False)),
|
||||
"confidence": float(parsed.get("confidence", 0.0)),
|
||||
"reason": str(parsed.get("reason", "")),
|
||||
}
|
||||
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
||||
logger.warning(f"Ollama недоступна: {e}")
|
||||
return {"is_paraphrase": False, "confidence": 0.0, "reason": "LLM недоступна"}
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning(f"Ошибка парсинга ответа Ollama: {e}")
|
||||
return {"is_paraphrase": False, "confidence": 0.0, "reason": "Ошибка парсинга ответа"}
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при обращении к Ollama: {e}")
|
||||
return {"is_paraphrase": False, "confidence": 0.0, "reason": str(e)}
|
||||
|
||||
def summarize(self, title: str, abstract: str, lang: str = "ru") -> str:
|
||||
"""
|
||||
Сгенерировать краткое академическое изложение статьи.
|
||||
|
||||
Args:
|
||||
title: Название статьи
|
||||
abstract: Аннотация статьи
|
||||
lang: Язык ответа (ru/en)
|
||||
|
||||
Returns:
|
||||
Краткое изложение на указанном языке
|
||||
"""
|
||||
lang_instruction = "на русском языке" if lang == "ru" else "in English"
|
||||
|
||||
prompt = f"""Сделай краткое академическое изложение {lang_instruction}.
|
||||
|
||||
Название: {title}
|
||||
Аннотация: {abstract[:1000]}
|
||||
|
||||
Изложение должно:
|
||||
- Содержать 3-5 предложений
|
||||
- Передавать основную идею и результаты
|
||||
- Быть написано академическим стилем
|
||||
- НЕ копировать текст дословно
|
||||
|
||||
Ответ:"""
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json={
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3,
|
||||
"num_predict": 300,
|
||||
},
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("response", "").strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка суммаризации через Ollama: {e}")
|
||||
return abstract[:500] if abstract else title
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Проверить доступность Ollama сервера."""
|
||||
try:
|
||||
response = httpx.get(f"{self.base_url}/api/tags", timeout=5.0)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
0
services/worker-gpu/app/tasks/__init__.py
Normal file
0
services/worker-gpu/app/tasks/__init__.py
Normal file
262
services/worker-gpu/app/tasks/plagiarism.py
Normal file
262
services/worker-gpu/app/tasks/plagiarism.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Celery задачи проверки плагиата (уровни 3 и 4) и построения эмбеддингов."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.db import db_session
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
# Пороговые значения
|
||||
FAISS_SIMILARITY_THRESHOLD = 0.75 # Минимальный cosine score для подозрительного совпадения
|
||||
LLM_CONFIDENCE_THRESHOLD = 0.7 # Минимальная уверенность LLM
|
||||
|
||||
|
||||
def _get_doc_text(doc_id: int) -> str | None:
|
||||
"""Получить текст документа из базы данных."""
|
||||
from app.models import Document
|
||||
with db_session() as session:
|
||||
doc = session.get(Document, doc_id)
|
||||
if doc is None:
|
||||
return None
|
||||
return doc.abstract or ""
|
||||
|
||||
|
||||
def _get_doc_meta(doc_id: int) -> dict | None:
|
||||
"""Получить метаданные документа из базы данных."""
|
||||
from app.models import Document
|
||||
with db_session() as session:
|
||||
doc = session.get(Document, doc_id)
|
||||
if doc is None:
|
||||
return None
|
||||
return {
|
||||
"id": doc.id,
|
||||
"title": doc.title,
|
||||
"url": doc.url,
|
||||
"source": doc.source,
|
||||
"authors": doc.authors,
|
||||
"year": doc.year,
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="gpu.check_plagiarism",
|
||||
bind=True,
|
||||
max_retries=2,
|
||||
default_retry_delay=120,
|
||||
)
|
||||
def check_plagiarism(
|
||||
self,
|
||||
task_id: str,
|
||||
text: str,
|
||||
fragments: list[dict],
|
||||
level1_matches: list[dict] | None = None,
|
||||
level2_matches: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Проверка плагиата уровни 3 (FAISS семантика) и 4 (Ollama LLM).
|
||||
|
||||
Принимает результаты уровней 1 и 2 от worker-indexer и дополняет их.
|
||||
|
||||
Args:
|
||||
task_id: ID задачи в PostgreSQL
|
||||
text: Полный текст документа
|
||||
fragments: Список фрагментов для проверки:
|
||||
[{"text": str, "start": int, "end": int}, ...]
|
||||
level1_matches: Совпадения уровня 1 (Winnowing)
|
||||
level2_matches: Совпадения уровня 2 (MinHash)
|
||||
|
||||
Returns:
|
||||
dict с результатами проверки
|
||||
"""
|
||||
from app.models import Task
|
||||
|
||||
logger.info(
|
||||
f"Проверка плагиата (ур. 3-4) для задачи {task_id!r}. "
|
||||
f"Фрагментов: {len(fragments)}"
|
||||
)
|
||||
|
||||
level1_matches = level1_matches or []
|
||||
level2_matches = level2_matches or []
|
||||
|
||||
# Обновить статус
|
||||
with db_session() as session:
|
||||
task = session.get(Task, task_id)
|
||||
if task is None:
|
||||
logger.error(f"Задача {task_id!r} не найдена")
|
||||
return {}
|
||||
task.status = "processing"
|
||||
session.commit()
|
||||
|
||||
try:
|
||||
from app.faiss_manager import FAISSManager
|
||||
from app.model_manager import ModelManager
|
||||
from app.ollama_client import OllamaClient
|
||||
|
||||
ollama = OllamaClient()
|
||||
semantic_matches: list[dict] = []
|
||||
|
||||
for i, fragment in enumerate(fragments):
|
||||
frag_text = fragment.get("text", "")
|
||||
if len(frag_text.split()) < 10:
|
||||
# Пропустить слишком короткие фрагменты
|
||||
continue
|
||||
|
||||
# Уровень 3: Семантический поиск через FAISS
|
||||
frag_vec = ModelManager.encode_single(frag_text)
|
||||
faiss_results = FAISSManager.search(frag_vec, k=10)
|
||||
|
||||
for doc_id, score in faiss_results:
|
||||
if score < FAISS_SIMILARITY_THRESHOLD:
|
||||
continue
|
||||
|
||||
# Уровень 4: LLM анализ парафраза для подозрительных совпадений
|
||||
source_text = _get_doc_text(doc_id) or ""
|
||||
doc_meta = _get_doc_meta(doc_id)
|
||||
|
||||
if not doc_meta:
|
||||
continue
|
||||
|
||||
llm_result = {"is_paraphrase": False, "confidence": 0.0, "reason": ""}
|
||||
if source_text:
|
||||
llm_result = ollama.check_paraphrase(source_text, frag_text)
|
||||
|
||||
if llm_result.get("is_paraphrase") and llm_result.get("confidence", 0.0) >= LLM_CONFIDENCE_THRESHOLD:
|
||||
semantic_matches.append({
|
||||
"fragment": frag_text[:300],
|
||||
"position_start": fragment.get("start", 0),
|
||||
"position_end": fragment.get("end", len(frag_text)),
|
||||
"similarity": round(score * 100, 1),
|
||||
"method": "semantic+llm",
|
||||
"confidence": llm_result["confidence"],
|
||||
"reason": llm_result.get("reason", ""),
|
||||
"source_title": doc_meta["title"],
|
||||
"source_url": doc_meta["url"],
|
||||
"source_db": doc_meta["source"],
|
||||
})
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}")
|
||||
|
||||
# Объединить все совпадения и дедуплицировать по source_title
|
||||
all_matches = level1_matches + level2_matches + semantic_matches
|
||||
seen_sources: set[str] = set()
|
||||
unique_matches = []
|
||||
for m in all_matches:
|
||||
key = f"{m.get('source_title', '')}:{m.get('position_start', '')}"
|
||||
if key not in seen_sources:
|
||||
seen_sources.add(key)
|
||||
unique_matches.append(m)
|
||||
|
||||
# Вычислить общий процент схожести
|
||||
total_frags = len(fragments)
|
||||
flagged_frags = len(unique_matches)
|
||||
overall_similarity = (flagged_frags / total_frags * 100) if total_frags > 0 else 0.0
|
||||
|
||||
result = {
|
||||
"overall_similarity": round(overall_similarity, 2),
|
||||
"matches": unique_matches,
|
||||
"total_fragments": total_frags,
|
||||
"flagged_fragments": flagged_frags,
|
||||
"by_method": {
|
||||
"exact": len(level1_matches),
|
||||
"fuzzy": len(level2_matches),
|
||||
"semantic_llm": len(semantic_matches),
|
||||
},
|
||||
}
|
||||
|
||||
# Сохранить результат
|
||||
with db_session() as session:
|
||||
task = session.get(Task, task_id)
|
||||
if task:
|
||||
task.result = result
|
||||
task.status = "done"
|
||||
task.queue_position = None
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Проверка плагиата завершена для задачи {task_id!r}. "
|
||||
f"Схожесть: {overall_similarity:.1f}%, совпадений: {flagged_frags}"
|
||||
)
|
||||
|
||||
# Уведомить пользователя
|
||||
celery_app.send_task(
|
||||
"notify.send_task_done",
|
||||
args=[task_id],
|
||||
queue="queue.notify",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Ошибка проверки плагиата для задачи {task_id!r}: {exc}", exc_info=True)
|
||||
|
||||
try:
|
||||
with db_session() as session:
|
||||
task = session.get(Task, task_id)
|
||||
if task:
|
||||
task.status = "failed"
|
||||
task.error = str(exc)
|
||||
session.commit()
|
||||
except Exception as db_exc:
|
||||
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
|
||||
|
||||
raise self.retry(exc=exc, countdown=120)
|
||||
|
||||
|
||||
@celery_app.task(name="gpu.embed_documents")
|
||||
def embed_documents(doc_ids: list[int]) -> dict[str, Any]:
|
||||
"""
|
||||
Построить эмбеддинги для документов и добавить их в FAISS индекс.
|
||||
|
||||
Вызывается воркером-индексером после добавления новых документов.
|
||||
|
||||
Args:
|
||||
doc_ids: Список ID документов в PostgreSQL
|
||||
"""
|
||||
if not doc_ids:
|
||||
return {"status": "ok", "embedded": 0}
|
||||
|
||||
from app.models import Document
|
||||
from app.faiss_manager import FAISSManager
|
||||
from app.model_manager import ModelManager
|
||||
from sqlalchemy import select
|
||||
|
||||
logger.info(f"Построение эмбеддингов для {len(doc_ids)} документов...")
|
||||
|
||||
with db_session() as session:
|
||||
docs = session.execute(
|
||||
select(Document).where(Document.id.in_(doc_ids))
|
||||
).scalars().all()
|
||||
|
||||
if not docs:
|
||||
logger.warning(f"Документы не найдены: {doc_ids}")
|
||||
return {"status": "not_found", "embedded": 0}
|
||||
|
||||
# Формируем тексты: title + abstract
|
||||
texts = [
|
||||
f"{d.title}. {d.abstract or ''}".strip()
|
||||
for d in docs
|
||||
]
|
||||
ids = [d.id for d in docs]
|
||||
|
||||
import numpy as np
|
||||
vectors = ModelManager.encode(texts)
|
||||
|
||||
FAISSManager.add_vectors(vectors, ids)
|
||||
FAISSManager.save()
|
||||
|
||||
# Обновить faiss_id в PostgreSQL
|
||||
with db_session() as session:
|
||||
for doc_id in ids:
|
||||
doc = session.get(Document, doc_id)
|
||||
if doc and doc_id in FAISSManager._reverse_map:
|
||||
doc.faiss_id = FAISSManager._reverse_map[doc_id]
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Встроено и проиндексировано {len(ids)} документов")
|
||||
return {"status": "ok", "embedded": len(ids)}
|
||||
281
services/worker-gpu/app/tasks/search.py
Normal file
281
services/worker-gpu/app/tasks/search.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Celery задача семантического поиска источников."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.db import db_session, get_redis
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
# Время жизни кэша поиска (1 час)
|
||||
SEARCH_CACHE_TTL = 3600
|
||||
|
||||
# Минимальный cosine score для включения в результаты
|
||||
FAISS_MIN_SCORE = 0.3
|
||||
|
||||
# Вес BM25 и семантического скора при ранжировании
|
||||
WEIGHT_SEMANTIC = 0.6
|
||||
WEIGHT_BM25 = 0.4
|
||||
|
||||
|
||||
def _get_cache_key(query: str, lang: str | None, year_from: int | None, year_to: int | None) -> str:
|
||||
"""Получить ключ кэша для поискового запроса."""
|
||||
key_str = f"{query}:{lang}:{year_from}:{year_to}"
|
||||
return f"search:{hashlib.md5(key_str.encode()).hexdigest()}"
|
||||
|
||||
|
||||
def _merge_and_rank(
|
||||
faiss_results: list[tuple[int, float]],
|
||||
es_results: list[dict],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Объединить и ранжировать результаты FAISS и Elasticsearch.
|
||||
|
||||
Нормализует оба скора в диапазон [0, 1] и взвешенно суммирует.
|
||||
|
||||
Args:
|
||||
faiss_results: [(doc_id, cosine_score), ...] из FAISS
|
||||
es_results: [{"doc_id": int, "es_score": float}, ...] из ES
|
||||
|
||||
Returns:
|
||||
Список словарей с doc_id и combined_score, отсортированный по убыванию
|
||||
"""
|
||||
scores: dict[int, dict[str, float]] = {}
|
||||
|
||||
# Нормализация FAISS скоров
|
||||
if faiss_results:
|
||||
max_faiss = max(s for _, s in faiss_results) or 1.0
|
||||
for doc_id, score in faiss_results:
|
||||
scores[doc_id] = {"faiss": score / max_faiss, "es": 0.0}
|
||||
|
||||
# Нормализация BM25 скоров
|
||||
if es_results:
|
||||
max_es = max(r["es_score"] for r in es_results) or 1.0
|
||||
for r in es_results:
|
||||
doc_id = r["doc_id"]
|
||||
norm_score = r["es_score"] / max_es
|
||||
if doc_id in scores:
|
||||
scores[doc_id]["es"] = norm_score
|
||||
else:
|
||||
scores[doc_id] = {"faiss": 0.0, "es": norm_score}
|
||||
|
||||
# Комбинированный скор
|
||||
ranked = []
|
||||
for doc_id, s in scores.items():
|
||||
combined = WEIGHT_SEMANTIC * s["faiss"] + WEIGHT_BM25 * s["es"]
|
||||
ranked.append({
|
||||
"doc_id": doc_id,
|
||||
"combined_score": round(combined, 4),
|
||||
"faiss_score": round(s["faiss"], 4),
|
||||
"es_score": round(s["es"], 4),
|
||||
})
|
||||
|
||||
ranked.sort(key=lambda x: x["combined_score"], reverse=True)
|
||||
return ranked
|
||||
|
||||
|
||||
def _enrich_from_db(ranked: list[dict], session) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Обогатить результаты метаданными из PostgreSQL и добавить ГОСТ-цитату.
|
||||
|
||||
Args:
|
||||
ranked: Список с doc_id и скорами
|
||||
session: SQLAlchemy сессия
|
||||
|
||||
Returns:
|
||||
Список источников с полными метаданными
|
||||
"""
|
||||
from app.models import Document
|
||||
|
||||
if not ranked:
|
||||
return []
|
||||
|
||||
doc_ids = [r["doc_id"] for r in ranked]
|
||||
docs = session.execute(
|
||||
select(Document).where(Document.id.in_(doc_ids))
|
||||
).scalars().all()
|
||||
|
||||
doc_map = {d.id: d for d in docs}
|
||||
|
||||
results = []
|
||||
for r in ranked:
|
||||
doc = doc_map.get(r["doc_id"])
|
||||
if not doc:
|
||||
continue
|
||||
|
||||
# Простая ГОСТ-цитата (полная форматирует worker-gost)
|
||||
gost_citation = _format_gost_simple(doc)
|
||||
|
||||
results.append({
|
||||
"id": doc.id,
|
||||
"title": doc.title,
|
||||
"authors": doc.authors,
|
||||
"year": doc.year,
|
||||
"journal": doc.journal,
|
||||
"abstract": doc.abstract[:300] if doc.abstract else None,
|
||||
"url": doc.url,
|
||||
"doi": doc.doi,
|
||||
"relevance_score": r["combined_score"],
|
||||
"gost_citation": gost_citation,
|
||||
"source_db": doc.source,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _format_gost_simple(doc) -> str:
|
||||
"""Простое ГОСТ-форматирование (без полной логики worker-gost)."""
|
||||
authors = doc.authors or []
|
||||
author_str = ""
|
||||
if authors:
|
||||
first = authors[0]
|
||||
last_name = first.get("last_name", "")
|
||||
initials = first.get("initials", "")
|
||||
if len(authors) == 1:
|
||||
author_str = f"{last_name} {initials}"
|
||||
elif len(authors) <= 3:
|
||||
parts = [f"{a.get('last_name', '')} {a.get('initials', '')}" for a in authors]
|
||||
author_str = ", ".join(parts)
|
||||
else:
|
||||
author_str = f"{last_name} {initials} и др."
|
||||
|
||||
title = doc.title or ""
|
||||
year = str(doc.year) if doc.year else "б. г."
|
||||
journal = doc.journal or ""
|
||||
pages = doc.pages or ""
|
||||
|
||||
if journal:
|
||||
citation = f"{author_str}. {title} // {journal}. — {year}."
|
||||
if pages:
|
||||
citation += f" — С. {pages}."
|
||||
else:
|
||||
citation = f"{author_str}. {title}. — {year}."
|
||||
|
||||
return citation.strip()
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="gpu.search_semantic",
|
||||
bind=True,
|
||||
max_retries=3,
|
||||
default_retry_delay=60,
|
||||
)
|
||||
def search_semantic(
|
||||
self,
|
||||
task_id: str,
|
||||
query: str,
|
||||
lang: str | None = None,
|
||||
year_from: int | None = None,
|
||||
year_to: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Семантический поиск источников.
|
||||
|
||||
Алгоритм:
|
||||
1. Нормализация запроса
|
||||
2. Проверка Redis кэша
|
||||
3. Параллельно: FAISS GPU cosine + Elasticsearch BM25
|
||||
4. Merge + дедупликация + ранжирование
|
||||
5. Обогащение метаданными из PostgreSQL
|
||||
6. Сохранение результата в task.result
|
||||
7. Диспатч notify.send_task_done
|
||||
|
||||
Args:
|
||||
task_id: ID задачи в PostgreSQL
|
||||
query: Поисковый запрос
|
||||
lang: Фильтр языка
|
||||
year_from: Фильтр года (от)
|
||||
year_to: Фильтр года (до)
|
||||
"""
|
||||
from app.models import Task
|
||||
|
||||
logger.info(f"Начало поиска для задачи {task_id!r}, запрос: {query[:50]!r}")
|
||||
|
||||
# Обновить статус на processing
|
||||
with db_session() as session:
|
||||
task = session.get(Task, task_id)
|
||||
if task is None:
|
||||
logger.error(f"Задача {task_id!r} не найдена в БД")
|
||||
return {}
|
||||
task.status = "processing"
|
||||
session.commit()
|
||||
|
||||
try:
|
||||
redis = get_redis()
|
||||
cache_key = _get_cache_key(query, lang, year_from, year_to)
|
||||
|
||||
# Проверить кэш
|
||||
cached = redis.get(cache_key)
|
||||
if cached:
|
||||
logger.info(f"Результат поиска получен из кэша: {cache_key}")
|
||||
results = json.loads(cached)
|
||||
else:
|
||||
# Нормализация запроса
|
||||
query_normalized = query.strip()
|
||||
|
||||
# Закодировать запрос в вектор
|
||||
from app.model_manager import ModelManager
|
||||
query_vec = ModelManager.encode_single(query_normalized)
|
||||
|
||||
# FAISS семантический поиск
|
||||
from app.faiss_manager import FAISSManager
|
||||
faiss_results = FAISSManager.search(query_vec, k=50)
|
||||
logger.info(f"FAISS: найдено {len(faiss_results)} результатов")
|
||||
|
||||
# Elasticsearch BM25 поиск
|
||||
from app.es_client import search_fulltext
|
||||
es_results = search_fulltext(query_normalized, lang=lang, year_from=year_from, year_to=year_to)
|
||||
logger.info(f"ES BM25: найдено {len(es_results)} результатов")
|
||||
|
||||
# Объединить и ранжировать
|
||||
ranked = _merge_and_rank(faiss_results, es_results)
|
||||
|
||||
# Обогатить метаданными
|
||||
with db_session() as session:
|
||||
results = _enrich_from_db(ranked[:30], session) # Топ-30 результатов
|
||||
|
||||
# Кэшировать на 1 час
|
||||
redis.setex(cache_key, SEARCH_CACHE_TTL, json.dumps(results, ensure_ascii=False, default=str))
|
||||
|
||||
# Сохранить результат в задачу
|
||||
with db_session() as session:
|
||||
task = session.get(Task, task_id)
|
||||
if task:
|
||||
task.result = {"sources": results, "total": len(results), "query": query}
|
||||
task.status = "done"
|
||||
task.queue_position = None
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Задача {task_id!r} выполнена. Найдено {len(results)} источников.")
|
||||
|
||||
# Уведомить пользователя
|
||||
celery_app.send_task(
|
||||
"notify.send_task_done",
|
||||
args=[task_id],
|
||||
queue="queue.notify",
|
||||
)
|
||||
|
||||
return {"task_id": task_id, "total": len(results)}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Ошибка при выполнении поиска для задачи {task_id!r}: {exc}", exc_info=True)
|
||||
|
||||
# Обновить статус задачи на failed
|
||||
try:
|
||||
with db_session() as session:
|
||||
task = session.get(Task, task_id)
|
||||
if task:
|
||||
task.status = "failed"
|
||||
task.error = str(exc)
|
||||
session.commit()
|
||||
except Exception as db_exc:
|
||||
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
|
||||
|
||||
# Повторить попытку
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
Reference in New Issue
Block a user