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:
jze9
2026-05-24 19:42:39 +05:00
commit 7758315632
120 changed files with 9500 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
FROM nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04
# Установить Python 3.11
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 \
python3.11-dev \
python3-pip \
python3.11-distutils \
gcc \
g++ \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Сделать python3.11 дефолтным
RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 && \
update-alternatives --install /usr/bin/python python python3.11 1 && \
python3.11 -m pip install --upgrade pip
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Директория для FAISS индекса (монтируется как volume)
RUN mkdir -p /data/index
CMD ["celery", "-A", "app.celery_app", "worker", "-Q", "queue.gpu", "-c", "1", "-n", "gpu@%h", "--loglevel=info"]

View File

View 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__)

View 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()

View 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

View 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

View 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

View 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("Модель выгружена из памяти")

View 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

View 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)}

View 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)

View File

@@ -0,0 +1,13 @@
celery==5.4.0
redis==5.0.4
sqlalchemy==2.0.30
psycopg2-binary==2.9.9
sentence-transformers==3.0.0
faiss-gpu==1.7.4
torch==2.3.0
numpy==1.26.4
httpx==0.27.0
minio==7.2.7
datasketch==1.6.5
pydantic-settings==2.2.1
elasticsearch==8.13.0