Files
anti-plagiarism/services/worker-gpu/app/tasks/plagiarism.py
jze9 b471ec767a
Some checks failed
Deploy / test (push) Failing after 4m15s
Deploy / deploy (push) Has been skipped
feat: две доработки в духе коммерческих систем — цитаты и авто-корпус
Ответ на "неужели больше нет" / "мы ведь можем это исправить": закрывает
две дыры относительно Антиплагиат.ру/Turnitin, о которых договорились.

1. Различение цитаты и голого плагиата (app.scoring.is_cited, worker-gpu):
   эвристика — фрагмент считается процитированным, если обрамлён кавычками
   («…», "…") либо сразу за ним (в пределах ~150 симв.) идёт скобочная ссылка
   с годом: [Иванов, 2023], (Smith, 2020) — совпадает и с нашим же форматом
   ГОСТ 7.0.5. aggregate_results теперь принимает full_text, размечает
   match["cited"] и считает uncited_similarity (доля БЕЗ похожих на цитаты —
   ближе к тому, что коммерческие системы называют "% некорректных
   заимствований") отдельно от overall_similarity (как было, для совместимости).
   Фронтенд: бейдж "Цитата" на совпадении + строка с разбивкой в отчёте
   (аддитивные опциональные поля в типах — старые задачи не ломаются).

2. Автопополнение корпуса проверенными работами (как у коммерческих систем —
   так ловится списывание у предыдущих потоков). Раньше загруженная на проверку
   работа складывалась в StagedWork и ждала РУЧНОГО одобрения админом — де-факто
   не пополняла базу для сравнения. Теперь index.auto_approve_submission
   (диспатчится из gpu.check_plagiarism ПОСЛЕ сохранения результата — чтобы
   работа не сматчилась сама с собой) добавляет её в documents автоматически,
   под настройкой AUTO_APPROVE_SUBMISSIONS (default True). Ручное
   approve/reject в админке остаётся рабочим (идемпотентно — auto-approve
   пропускает уже не-pending записи), пригодится при AUTO_APPROVE=False.
   Конвертация StagedWork→doc_data вынесена в чистый app/staging.py (без
   Celery/SQLAlchemy/MinIO) — тестируется изолированно, идентична ручному
   пути в admin.py (POST /admin/staging/{id}/approve).

Тестов добавлено 16 (scoring 8→18, новый staging.py — 6). Оба mypy-гейта
расширены (scoring.py, staging.py). Тестов всего: 112 (было 82 в последнем
подсчёте README — таблица давно отставала, заодно поправил на актуальные цифры
по всем сервисам, включая забытый в прошлый раз Qdrant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 15:09:43 +05:00

253 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Celery задачи проверки плагиата (уровни 3 и 4) и построения эмбеддингов."""
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.model_manager import ModelManager
from app.ollama_client import OllamaClient
from app.vector_store import get_backend
ollama = OllamaClient()
vector_store = get_backend()
semantic_matches: list[dict] = []
for i, fragment in enumerate(fragments):
frag_text = fragment.get("text", "")
if len(frag_text.split()) < 10:
# Пропустить слишком короткие фрагменты
continue
# Уровень 3: Семантический поиск (FAISS или Qdrant по VECTOR_BACKEND)
frag_vec = ModelManager.encode_single(frag_text)
faiss_results = vector_store.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)}")
# Свести совпадения уровней в итог (дедуп + процент) — чистая логика в app.scoring
from app.scoring import aggregate_results
result = aggregate_results(
level1_matches, level2_matches, semantic_matches, len(fragments), full_text=text
)
# Сохранить результат
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"Схожесть: {result['overall_similarity']:.1f}%, "
f"совпадений: {result['flagged_fragments']}"
)
# Уведомить пользователя
celery_app.send_task(
"notify.send_task_done",
args=[task_id],
queue="queue.notify",
)
# Пополнить базу для сравнения (как у коммерческих систем — каждая
# проверенная работа сама становится источником для будущих проверок).
# Диспатчим ПОСЛЕ сохранения результата, чтобы работа не сматчилась
# сама с собой в только что посчитанном отчёте.
celery_app.send_task(
"index.auto_approve_submission",
args=[task_id],
queue="queue.index",
)
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) from exc
@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.model_manager import ModelManager
from app.models import Document
from app.vector_store import get_backend
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]
vectors = ModelManager.encode(texts)
store = get_backend()
store.add_vectors(vectors, ids)
store.save()
# Обновить faiss_id в PostgreSQL (для IDMap2 faiss_id == doc_id)
with db_session() as session:
for doc_id in ids:
doc = session.get(Document, doc_id)
if doc:
doc.faiss_id = doc_id
session.commit()
logger.info(f"Встроено и проиндексировано {len(ids)} документов")
return {"status": "ok", "embedded": len(ids)}