"""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}