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-indexer/app/__init__.py
Normal file
0
services/worker-indexer/app/__init__.py
Normal file
0
services/worker-indexer/app/algorithms/__init__.py
Normal file
0
services/worker-indexer/app/algorithms/__init__.py
Normal file
118
services/worker-indexer/app/algorithms/minhash.py
Normal file
118
services/worker-indexer/app/algorithms/minhash.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""MinHash LSH для нечёткого поиска похожих документов.
|
||||
|
||||
Позволяет быстро находить документы с похожим содержимым
|
||||
без точного сравнения всех пар.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from datasketch import MinHash, MinHashLSH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Параметры MinHash LSH
|
||||
LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты
|
||||
LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память)
|
||||
|
||||
# Глобальный LSH индекс (in-memory)
|
||||
_lsh: MinHashLSH | None = None
|
||||
|
||||
|
||||
def get_lsh() -> MinHashLSH:
|
||||
"""Получить или создать глобальный LSH индекс."""
|
||||
global _lsh
|
||||
if _lsh is None:
|
||||
_lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM)
|
||||
logger.info("MinHash LSH индекс создан")
|
||||
return _lsh
|
||||
|
||||
|
||||
def get_shingles(text: str, k: int = 3) -> set[str]:
|
||||
"""
|
||||
Получить k-слоговые шинглы из текста.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
k: Размер шингла (количество слов)
|
||||
|
||||
Returns:
|
||||
Множество шинглов
|
||||
"""
|
||||
words = text.lower().split()
|
||||
if len(words) < k:
|
||||
return {" ".join(words)} if words else set()
|
||||
return {" ".join(words[i : i + k]) for i in range(len(words) - k + 1)}
|
||||
|
||||
|
||||
def text_to_minhash(text: str) -> MinHash:
|
||||
"""
|
||||
Создать MinHash подпись для текста.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
|
||||
Returns:
|
||||
MinHash объект
|
||||
"""
|
||||
m = MinHash(num_perm=LSH_NUM_PERM)
|
||||
for shingle in get_shingles(text):
|
||||
m.update(shingle.encode("utf-8"))
|
||||
return m
|
||||
|
||||
|
||||
def add_to_lsh(doc_key: str, text: str) -> None:
|
||||
"""
|
||||
Добавить документ в LSH индекс.
|
||||
|
||||
Args:
|
||||
doc_key: Уникальный ключ документа (например, "doc:{id}")
|
||||
text: Текст документа
|
||||
"""
|
||||
lsh = get_lsh()
|
||||
m = text_to_minhash(text)
|
||||
try:
|
||||
lsh.insert(doc_key, m)
|
||||
except ValueError:
|
||||
# Документ уже в индексе — игнорируем
|
||||
pass
|
||||
|
||||
|
||||
def find_similar(text: str) -> list[str]:
|
||||
"""
|
||||
Найти похожие документы в LSH индексе.
|
||||
|
||||
Args:
|
||||
text: Текст для поиска похожих
|
||||
|
||||
Returns:
|
||||
Список ключей похожих документов
|
||||
"""
|
||||
lsh = get_lsh()
|
||||
m = text_to_minhash(text)
|
||||
try:
|
||||
return lsh.query(m)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка запроса к MinHash LSH: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def compute_jaccard_minhash(text_a: str, text_b: str) -> float:
|
||||
"""
|
||||
Оценить схожесть двух текстов через MinHash Jaccard.
|
||||
|
||||
Args:
|
||||
text_a: Первый текст
|
||||
text_b: Второй текст
|
||||
|
||||
Returns:
|
||||
Оценка схожести Жаккара через MinHash
|
||||
"""
|
||||
m_a = text_to_minhash(text_a)
|
||||
m_b = text_to_minhash(text_b)
|
||||
return m_a.jaccard(m_b)
|
||||
|
||||
|
||||
def reset_lsh() -> None:
|
||||
"""Сбросить LSH индекс (для тестов)."""
|
||||
global _lsh
|
||||
_lsh = None
|
||||
120
services/worker-indexer/app/algorithms/winnowing.py
Normal file
120
services/worker-indexer/app/algorithms/winnowing.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Алгоритм Winnowing для fingerprinting текстов.
|
||||
|
||||
Winnowing — алгоритм выбора минимального хэша в скользящем окне.
|
||||
Используется для нахождения точных и частичных совпадений текстов.
|
||||
"""
|
||||
|
||||
import xxhash
|
||||
|
||||
|
||||
def get_ngrams(tokens: list[str], k: int = 5) -> list[str]:
|
||||
"""
|
||||
Сформировать n-граммы из токенов.
|
||||
|
||||
Args:
|
||||
tokens: Список слов
|
||||
k: Размер n-граммы
|
||||
|
||||
Returns:
|
||||
Список n-грамм в виде строк
|
||||
"""
|
||||
if len(tokens) < k:
|
||||
return []
|
||||
return [" ".join(tokens[i : i + k]) for i in range(len(tokens) - k + 1)]
|
||||
|
||||
|
||||
def hash_ngram(ngram: str) -> int:
|
||||
"""
|
||||
Хэшировать n-грамму через xxHash (быстро, детерминированно).
|
||||
|
||||
Args:
|
||||
ngram: n-грамма для хэширования
|
||||
|
||||
Returns:
|
||||
64-битный хэш
|
||||
"""
|
||||
return xxhash.xxh64(ngram.encode("utf-8")).intdigest()
|
||||
|
||||
|
||||
def winnow(text: str, k: int = 5, window: int = 4) -> set[int]:
|
||||
"""
|
||||
Алгоритм Winnowing для построения fingerprint документа.
|
||||
|
||||
Шаги:
|
||||
1. Токенизация (lowercase)
|
||||
2. Построение k-грамм
|
||||
3. Хэширование k-грамм
|
||||
4. Скользящее окно — выбор минимального хэша в каждой позиции
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
k: Размер k-граммы (n-gram)
|
||||
window: Размер скользящего окна
|
||||
|
||||
Returns:
|
||||
Множество отобранных хэшей (fingerprint)
|
||||
"""
|
||||
tokens = text.lower().split()
|
||||
|
||||
if len(tokens) < k:
|
||||
return set()
|
||||
|
||||
ngrams = get_ngrams(tokens, k)
|
||||
hashes = [hash_ngram(ng) for ng in ngrams]
|
||||
|
||||
if not hashes:
|
||||
return set()
|
||||
|
||||
# Скользящее окно — выбираем минимальный хэш в каждом окне
|
||||
fingerprint: set[int] = set()
|
||||
prev_min_idx = -1
|
||||
|
||||
for i in range(len(hashes) - window + 1):
|
||||
window_hashes = hashes[i : i + window]
|
||||
min_val = min(window_hashes)
|
||||
min_idx = i + window_hashes.index(min_val)
|
||||
|
||||
# Добавляем только если это новый минимум или позиция изменилась
|
||||
if min_idx != prev_min_idx:
|
||||
fingerprint.add(min_val)
|
||||
prev_min_idx = min_idx
|
||||
|
||||
return fingerprint
|
||||
|
||||
|
||||
def jaccard_similarity(fp_a: set[int], fp_b: set[int]) -> float:
|
||||
"""
|
||||
Коэффициент Жаккара для двух fingerprint'ов.
|
||||
|
||||
Args:
|
||||
fp_a: Fingerprint документа A
|
||||
fp_b: Fingerprint документа B
|
||||
|
||||
Returns:
|
||||
Коэффициент сходства от 0.0 до 1.0
|
||||
"""
|
||||
if not fp_a or not fp_b:
|
||||
return 0.0
|
||||
|
||||
intersection = len(fp_a & fp_b)
|
||||
union = len(fp_a | fp_b)
|
||||
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def compute_similarity(text_a: str, text_b: str, k: int = 5, window: int = 4) -> float:
|
||||
"""
|
||||
Вычислить схожесть двух текстов через Winnowing.
|
||||
|
||||
Args:
|
||||
text_a: Первый текст
|
||||
text_b: Второй текст
|
||||
k: Размер k-граммы
|
||||
window: Размер окна Winnowing
|
||||
|
||||
Returns:
|
||||
Схожесть от 0.0 до 1.0
|
||||
"""
|
||||
fp_a = winnow(text_a, k=k, window=window)
|
||||
fp_b = winnow(text_b, k=k, window=window)
|
||||
return jaccard_similarity(fp_a, fp_b)
|
||||
30
services/worker-indexer/app/celery_app.py
Normal file
30
services/worker-indexer/app/celery_app.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Celery приложение индексер-воркера."""
|
||||
|
||||
from celery import Celery
|
||||
|
||||
from app.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"worker_indexer",
|
||||
broker=settings.RABBITMQ_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
include=["app.tasks.index"],
|
||||
)
|
||||
|
||||
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,
|
||||
result_expires=86400,
|
||||
)
|
||||
55
services/worker-indexer/app/config.py
Normal file
55
services/worker-indexer/app/config.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Конфигурация индексер-воркера."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Настройки индексер-воркера."""
|
||||
|
||||
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"
|
||||
|
||||
# Настройки обработки текста
|
||||
FRAGMENT_WINDOW_WORDS: int = 200 # Размер окна для фрагментов
|
||||
FRAGMENT_OVERLAP_WORDS: int = 50 # Перекрытие фрагментов
|
||||
MAX_FINGERPRINTS_PER_DOC: int = 500 # Максимум хэшей Winnowing на документ
|
||||
|
||||
# App
|
||||
ENVIRONMENT: str = "development"
|
||||
DEBUG: bool = False
|
||||
|
||||
@property
|
||||
def database_url_sync(self) -> str:
|
||||
"""Синхронный URL для SQLAlchemy."""
|
||||
return (
|
||||
f"postgresql+psycopg2://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
67
services/worker-indexer/app/db.py
Normal file
67
services/worker-indexer/app/db.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Синхронное подключение к PostgreSQL и MinIO для индексер-воркера."""
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
from minio import Minio
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Синхронный движок SQLAlchemy
|
||||
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()
|
||||
|
||||
|
||||
_minio_client: Minio | None = None
|
||||
|
||||
|
||||
def get_minio() -> Minio:
|
||||
"""Получить или создать MinIO клиент."""
|
||||
global _minio_client
|
||||
if _minio_client is None:
|
||||
_minio_client = Minio(
|
||||
settings.MINIO_ENDPOINT,
|
||||
access_key=settings.MINIO_ACCESS_KEY,
|
||||
secret_key=settings.MINIO_SECRET_KEY,
|
||||
secure=False,
|
||||
)
|
||||
return _minio_client
|
||||
|
||||
|
||||
def update_task_status(task_id: str, status: str, error: str | None = None) -> None:
|
||||
"""Обновить статус задачи в БД."""
|
||||
from app.models import Task
|
||||
|
||||
with db_session() as session:
|
||||
task = session.get(Task, task_id)
|
||||
if task:
|
||||
task.status = status
|
||||
if error:
|
||||
task.error = error
|
||||
session.commit()
|
||||
0
services/worker-indexer/app/extractors/__init__.py
Normal file
0
services/worker-indexer/app/extractors/__init__.py
Normal file
69
services/worker-indexer/app/extractors/docx.py
Normal file
69
services/worker-indexer/app/extractors/docx.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Извлечение текста из DOCX файлов через python-docx."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_text_from_docx(data: bytes) -> str:
|
||||
"""
|
||||
Извлечь текст из DOCX файла.
|
||||
|
||||
Обрабатывает параграфы, таблицы и заголовки.
|
||||
|
||||
Args:
|
||||
data: Байты DOCX файла
|
||||
|
||||
Returns:
|
||||
Извлечённый текст
|
||||
|
||||
Raises:
|
||||
ValueError: Если не удалось открыть DOCX
|
||||
"""
|
||||
from docx import Document as DocxDocument
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
try:
|
||||
doc = DocxDocument(io.BytesIO(data))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Не удалось открыть DOCX: {e}") from e
|
||||
|
||||
texts: list[str] = []
|
||||
|
||||
# Основной текст из параграфов
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
if text:
|
||||
texts.append(text)
|
||||
|
||||
# Текст из таблиц
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
cell_text = cell.text.strip()
|
||||
if cell_text:
|
||||
texts.append(cell_text)
|
||||
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def extract_text_from_txt(data: bytes) -> str:
|
||||
"""
|
||||
Извлечь текст из TXT файла с определением кодировки.
|
||||
|
||||
Args:
|
||||
data: Байты TXT файла
|
||||
|
||||
Returns:
|
||||
Текст файла
|
||||
"""
|
||||
# Пробуем UTF-8, затем cp1251 (Windows-1251 для русских текстов)
|
||||
for encoding in ("utf-8", "cp1251", "latin-1"):
|
||||
try:
|
||||
return data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
# Последний вариант — игнорировать ошибки
|
||||
return data.decode("utf-8", errors="ignore")
|
||||
80
services/worker-indexer/app/extractors/pdf.py
Normal file
80
services/worker-indexer/app/extractors/pdf.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Извлечение текста из PDF файлов через PyMuPDF."""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_text_from_pdf(data: bytes) -> str:
|
||||
"""
|
||||
Извлечь текст из PDF файла.
|
||||
|
||||
Обрабатывает многостраничные документы.
|
||||
Удаляет лишние переносы строк и пробелы.
|
||||
|
||||
Args:
|
||||
data: Байты PDF файла
|
||||
|
||||
Returns:
|
||||
Извлечённый текст
|
||||
|
||||
Raises:
|
||||
ValueError: Если не удалось открыть PDF
|
||||
"""
|
||||
import fitz # PyMuPDF
|
||||
|
||||
try:
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Не удалось открыть PDF: {e}") from e
|
||||
|
||||
texts: list[str] = []
|
||||
|
||||
for page_num, page in enumerate(doc):
|
||||
try:
|
||||
page_text = page.get_text("text")
|
||||
if page_text.strip():
|
||||
texts.append(page_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка извлечения текста со страницы {page_num}: {e}")
|
||||
continue
|
||||
|
||||
doc.close()
|
||||
|
||||
full_text = "\n".join(texts)
|
||||
|
||||
# Нормализация: убрать множественные переносы строк
|
||||
import re
|
||||
full_text = re.sub(r"\n{3,}", "\n\n", full_text)
|
||||
full_text = re.sub(r"[ \t]+", " ", full_text)
|
||||
|
||||
return full_text.strip()
|
||||
|
||||
|
||||
def extract_metadata_from_pdf(data: bytes) -> dict:
|
||||
"""
|
||||
Извлечь метаданные из PDF (title, author, subject и т.д.).
|
||||
|
||||
Args:
|
||||
data: Байты PDF файла
|
||||
|
||||
Returns:
|
||||
Словарь метаданных
|
||||
"""
|
||||
import fitz
|
||||
|
||||
try:
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
metadata = doc.metadata or {}
|
||||
doc.close()
|
||||
return {
|
||||
"title": metadata.get("title", ""),
|
||||
"author": metadata.get("author", ""),
|
||||
"subject": metadata.get("subject", ""),
|
||||
"keywords": metadata.get("keywords", ""),
|
||||
"creator": metadata.get("creator", ""),
|
||||
"pages": doc.page_count if hasattr(doc, "page_count") else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка извлечения метаданных PDF: {e}")
|
||||
return {}
|
||||
0
services/worker-indexer/app/tasks/__init__.py
Normal file
0
services/worker-indexer/app/tasks/__init__.py
Normal file
329
services/worker-indexer/app/tasks/index.py
Normal file
329
services/worker-indexer/app/tasks/index.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""Celery задачи индексации документов и проверки плагиата (уровни 1-2)."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.algorithms.minhash import add_to_lsh, find_similar
|
||||
from app.algorithms.winnowing import winnow
|
||||
from app.celery_app import celery_app
|
||||
from app.config import settings
|
||||
from app.db import db_session, get_minio, update_task_status
|
||||
from app.extractors.docx import extract_text_from_docx, extract_text_from_txt
|
||||
from app.extractors.pdf import extract_text_from_pdf
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def _split_into_fragments(
|
||||
text: str,
|
||||
window: int = 200,
|
||||
overlap: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Разбить текст на фрагменты для проверки плагиата.
|
||||
|
||||
Использует скользящее окно с перекрытием.
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
window: Размер окна в словах
|
||||
overlap: Перекрытие между фрагментами в словах
|
||||
|
||||
Returns:
|
||||
Список словарей {"text": str, "start": int, "end": int}
|
||||
"""
|
||||
words = text.split()
|
||||
if not words:
|
||||
return []
|
||||
|
||||
fragments = []
|
||||
step = window - overlap
|
||||
char_positions = []
|
||||
|
||||
# Вычислить позиции символов для каждого слова
|
||||
pos = 0
|
||||
for word in words:
|
||||
char_positions.append(pos)
|
||||
pos += len(word) + 1 # +1 для пробела
|
||||
|
||||
for i in range(0, max(1, len(words) - window + 1), step):
|
||||
chunk_words = words[i : i + window]
|
||||
if len(chunk_words) < 20: # Пропустить слишком короткие фрагменты
|
||||
continue
|
||||
|
||||
start_char = char_positions[i]
|
||||
end_idx = min(i + window - 1, len(words) - 1)
|
||||
end_char = char_positions[end_idx] + len(words[end_idx])
|
||||
|
||||
fragments.append({
|
||||
"text": " ".join(chunk_words),
|
||||
"start": start_char,
|
||||
"end": end_char,
|
||||
})
|
||||
|
||||
return fragments
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="index.extract_and_check",
|
||||
bind=True,
|
||||
max_retries=3,
|
||||
default_retry_delay=60,
|
||||
)
|
||||
def extract_and_check(
|
||||
self,
|
||||
task_id: str,
|
||||
minio_key: str,
|
||||
filename: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Извлечь текст из документа и проверить плагиат (уровни 1-2).
|
||||
|
||||
Алгоритм:
|
||||
1. Скачать файл из MinIO
|
||||
2. Извлечь текст (PDF/DOCX/TXT)
|
||||
3. Разбить на фрагменты по 200 слов с перекрытием 50 слов
|
||||
4. Уровень 1: Winnowing против базы fingerprints в PostgreSQL
|
||||
5. Уровень 2: MinHash LSH нечёткий поиск
|
||||
6. Диспатч gpu.check_plagiarism для уровней 3 и 4
|
||||
|
||||
Args:
|
||||
task_id: ID задачи в PostgreSQL
|
||||
minio_key: Ключ объекта в MinIO
|
||||
filename: Оригинальное имя файла
|
||||
"""
|
||||
logger.info(f"Извлечение текста для задачи {task_id!r}, файл: {filename!r}")
|
||||
|
||||
update_task_status(task_id, "processing")
|
||||
|
||||
try:
|
||||
# Скачать из MinIO
|
||||
minio = get_minio()
|
||||
response = minio.get_object(settings.MINIO_BUCKET_DOCS, minio_key)
|
||||
file_data = response.read()
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
logger.info(f"Файл скачан из MinIO: {minio_key} ({len(file_data)} байт)")
|
||||
|
||||
# Извлечь текст в зависимости от формата
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext == ".pdf":
|
||||
text = extract_text_from_pdf(file_data)
|
||||
elif ext == ".docx":
|
||||
text = extract_text_from_docx(file_data)
|
||||
else:
|
||||
text = extract_text_from_txt(file_data)
|
||||
|
||||
if not text.strip():
|
||||
raise ValueError("Не удалось извлечь текст из документа")
|
||||
|
||||
logger.info(f"Текст извлечён: {len(text)} символов, {len(text.split())} слов")
|
||||
|
||||
# Разбить на фрагменты
|
||||
fragments = _split_into_fragments(
|
||||
text,
|
||||
window=settings.FRAGMENT_WINDOW_WORDS,
|
||||
overlap=settings.FRAGMENT_OVERLAP_WORDS,
|
||||
)
|
||||
logger.info(f"Фрагментов создано: {len(fragments)}")
|
||||
|
||||
# ──── Уровень 1: Winnowing fingerprints ────────────────────────────────
|
||||
level1_matches: list[dict] = []
|
||||
doc_fingerprint = winnow(text)
|
||||
|
||||
if doc_fingerprint:
|
||||
from app.models import Document, Fingerprint
|
||||
|
||||
with db_session() as session:
|
||||
hashes = list(doc_fingerprint)[: settings.MAX_FINGERPRINTS_PER_DOC]
|
||||
|
||||
# Найти совпадения в базе fingerprints
|
||||
matching_docs = session.execute(
|
||||
select(
|
||||
Fingerprint.doc_id,
|
||||
func.count(Fingerprint.id).label("match_count"),
|
||||
)
|
||||
.where(Fingerprint.hash_value.in_(hashes))
|
||||
.group_by(Fingerprint.doc_id)
|
||||
.having(func.count(Fingerprint.id) > len(hashes) * 0.1)
|
||||
.order_by(func.count(Fingerprint.id).desc())
|
||||
.limit(20)
|
||||
).all()
|
||||
|
||||
for doc_id, match_count in matching_docs:
|
||||
similarity = match_count / len(hashes) * 100
|
||||
if similarity < 20:
|
||||
continue
|
||||
|
||||
doc = session.get(Document, doc_id)
|
||||
if not doc:
|
||||
continue
|
||||
|
||||
level1_matches.append({
|
||||
"fragment": text[:200],
|
||||
"position_start": 0,
|
||||
"position_end": len(text),
|
||||
"similarity": round(similarity, 1),
|
||||
"method": "exact",
|
||||
"source_title": doc.title,
|
||||
"source_url": doc.url,
|
||||
"source_db": doc.source,
|
||||
})
|
||||
|
||||
logger.info(f"Уровень 1 (Winnowing): {len(level1_matches)} совпадений")
|
||||
|
||||
# ──── Уровень 2: MinHash LSH ────────────────────────────────────────────
|
||||
level2_matches: list[dict] = []
|
||||
similar_keys = find_similar(text)
|
||||
|
||||
if similar_keys:
|
||||
from app.models import Document
|
||||
|
||||
with db_session() as session:
|
||||
for key in similar_keys[:10]:
|
||||
# Ключ формата "doc:{id}"
|
||||
try:
|
||||
doc_id = int(key.split(":")[-1])
|
||||
doc = session.get(Document, doc_id)
|
||||
if not doc:
|
||||
continue
|
||||
|
||||
level2_matches.append({
|
||||
"fragment": text[:200],
|
||||
"position_start": 0,
|
||||
"position_end": len(text),
|
||||
"similarity": 60.0, # MinHash даёт только факт похожести
|
||||
"method": "fuzzy",
|
||||
"source_title": doc.title,
|
||||
"source_url": doc.url,
|
||||
"source_db": doc.source,
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
logger.info(f"Уровень 2 (MinHash): {len(level2_matches)} совпадений")
|
||||
|
||||
# ──── Диспатч GPU задачи (уровни 3-4) ─────────────────────────────────
|
||||
# Передаём только текстовые данные (JSON-сериализуемые)
|
||||
celery_app.send_task(
|
||||
"gpu.check_plagiarism",
|
||||
args=[task_id, text, fragments],
|
||||
kwargs={
|
||||
"level1_matches": level1_matches,
|
||||
"level2_matches": level2_matches,
|
||||
},
|
||||
queue="queue.gpu",
|
||||
)
|
||||
|
||||
logger.info(f"GPU задача отправлена для задачи {task_id!r}")
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"fragments": len(fragments),
|
||||
"level1": len(level1_matches),
|
||||
"level2": len(level2_matches),
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Ошибка при обработке задачи {task_id!r}: {exc}", exc_info=True)
|
||||
update_task_status(task_id, "failed", str(exc))
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
@celery_app.task(name="index.add_document")
|
||||
def add_document(doc_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Добавить документ из внешнего источника в систему.
|
||||
|
||||
Алгоритм:
|
||||
1. Дедупликация по ext_id
|
||||
2. Сохранить метаданные в PostgreSQL
|
||||
3. Индексировать в Elasticsearch
|
||||
4. Вычислить Winnowing fingerprints
|
||||
5. Добавить в MinHash LSH
|
||||
6. Диспатч gpu.embed_documents для FAISS эмбеддингов
|
||||
|
||||
Args:
|
||||
doc_data: Словарь с метаданными документа
|
||||
|
||||
Returns:
|
||||
dict со статусом операции и doc_id
|
||||
"""
|
||||
from app.models import Document, Fingerprint
|
||||
|
||||
ext_id = doc_data.get("ext_id")
|
||||
if not ext_id:
|
||||
return {"status": "error", "reason": "ext_id обязателен"}
|
||||
|
||||
with db_session() as session:
|
||||
# Проверить дублирование
|
||||
existing = session.execute(
|
||||
select(Document).where(Document.ext_id == ext_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
return {"status": "duplicate", "doc_id": existing.id}
|
||||
|
||||
# Создать документ
|
||||
allowed_fields = {c.key for c in Document.__table__.columns}
|
||||
doc_kwargs = {k: v for k, v in doc_data.items() if k in allowed_fields}
|
||||
|
||||
doc = Document(**doc_kwargs)
|
||||
session.add(doc)
|
||||
session.flush()
|
||||
doc_id = doc.id
|
||||
|
||||
# Вычислить fingerprints
|
||||
text = doc_data.get("full_text") or doc_data.get("abstract", "") or ""
|
||||
if text:
|
||||
fp = winnow(text)
|
||||
fingerprints_to_add = list(fp)[: settings.MAX_FINGERPRINTS_PER_DOC]
|
||||
for i, hash_val in enumerate(fingerprints_to_add):
|
||||
session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i))
|
||||
|
||||
# Добавить в MinHash LSH (in-memory)
|
||||
add_to_lsh(f"doc:{doc_id}", text)
|
||||
|
||||
session.commit()
|
||||
|
||||
logger.info(f"Документ {doc_id} добавлен в PostgreSQL: {doc_data.get('title', '')[:50]!r}")
|
||||
|
||||
# Индексация в Elasticsearch
|
||||
try:
|
||||
from elasticsearch import Elasticsearch
|
||||
from app.config import settings as cfg
|
||||
|
||||
es = Elasticsearch(cfg.ELASTICSEARCH_URL)
|
||||
es_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"),
|
||||
}
|
||||
es.index(index="documents", id=str(doc_id), document=es_doc)
|
||||
logger.info(f"Документ {doc_id} проиндексирован в Elasticsearch")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка индексации в ES для документа {doc_id}: {e}")
|
||||
|
||||
# Диспатч FAISS эмбеддингов
|
||||
celery_app.send_task(
|
||||
"gpu.embed_documents",
|
||||
args=[[doc_id]],
|
||||
queue="queue.gpu",
|
||||
)
|
||||
|
||||
return {"status": "indexed", "doc_id": doc_id}
|
||||
Reference in New Issue
Block a user