From 25e55a3f7dafe9060cf197a6a03f1051286018ba Mon Sep 17 00:00:00 2001 From: jze9 Date: Thu, 23 Jul 2026 19:57:05 +0500 Subject: [PATCH] =?UTF-8?q?fix(detection):=20=D0=BF=D0=BE=D1=84=D1=80?= =?UTF-8?q?=D0=B0=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F=201=20+=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=BE=D0=B5=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D1=82=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две связанные проблемы, ломавшие качество детекции: 1. Уровень 1 (Winnowing) репортил весь документ одним совпадением на позиции 0..длина — в отчёте нельзя было понять, ГДЕ плагиат. Теперь winnow'им каждый фрагмент и находим источник + позицию для каждого, так отчёт показывает "символы A-B скопированы из источника X". 2. MAX_FINGERPRINTS_PER_DOC=500 обрезал отпечатки статьи (у типичной статьи их ~3600), причём произвольную выборку. Из-за этого скопированный фрагмент почти не разделял отпечатки с источником (проверено: хранилось ~14%, фрагмент находил 17% своих хэшей → не срабатывало). Winnowing рассчитан на ПОЛНОЕ хранение; поднял лимит до 20000. Также overall_similarity считается по числу уникальных помеченных позиций, а не совпадений — фрагмент, совпавший с несколькими источниками, больше не раздувает процент выше 100. Проверено end-to-end: документ с дословной вставкой из статьи корпуса → вставка локализована (chars 1027-2385 = 100%, источник атрибутирован); чисто оригинальный текст → 0% (нет ложных срабатываний). Co-Authored-By: Claude Opus 4.8 --- services/worker-gpu/app/tasks/plagiarism.py | 8 ++- services/worker-indexer/app/config.py | 10 ++- services/worker-indexer/app/tasks/index.py | 72 ++++++++++++--------- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/services/worker-gpu/app/tasks/plagiarism.py b/services/worker-gpu/app/tasks/plagiarism.py index 4044ab7..75dd1fe 100644 --- a/services/worker-gpu/app/tasks/plagiarism.py +++ b/services/worker-gpu/app/tasks/plagiarism.py @@ -152,10 +152,14 @@ def check_plagiarism( seen_sources.add(key) unique_matches.append(m) - # Вычислить общий процент схожести + # Вычислить общий процент схожести по ДОЛЕ помеченных фрагментов документа. + # Считаем уникальные позиции (фрагмент, совпавший с несколькими источниками, + # не должен раздувать процент выше 100). total_frags = len(fragments) - flagged_frags = len(unique_matches) + flagged_positions = {m.get("position_start") for m in unique_matches} + flagged_frags = len(flagged_positions) overall_similarity = (flagged_frags / total_frags * 100) if total_frags > 0 else 0.0 + overall_similarity = min(overall_similarity, 100.0) result = { "overall_similarity": round(overall_similarity, 2), diff --git a/services/worker-indexer/app/config.py b/services/worker-indexer/app/config.py index 0eaf5c0..f23a09e 100644 --- a/services/worker-indexer/app/config.py +++ b/services/worker-indexer/app/config.py @@ -39,7 +39,15 @@ class Settings(BaseSettings): # Настройки обработки текста FRAGMENT_WINDOW_WORDS: int = 200 # Размер окна для фрагментов FRAGMENT_OVERLAP_WORDS: int = 50 # Перекрытие фрагментов - MAX_FINGERPRINTS_PER_DOC: int = 500 # Максимум хэшей Winnowing на документ + # Максимум хэшей Winnowing на документ. Winnowing рассчитан на ПОЛНОЕ + # хранение отпечатков: обрезка теряет гарантию, что скопированный фрагмент + # разделит отпечатки с источником (при 500 хранилось ~14% отпечатков статьи, + # и локализованный поиск фрагментов почти не срабатывал). Держим высоким; + # цена — размер таблицы fingerprints (~3-4К строк на статью). + MAX_FINGERPRINTS_PER_DOC: int = 20000 + # Порог уровня 1: % отпечатков фрагмента, найденных в источнике, чтобы + # пометить фрагмент как скопированный + EXACT_FRAGMENT_THRESHOLD: float = 40.0 # Скачивание полного текста статей (PDF по URL источника) FETCH_FULL_TEXT: bool = False # Включить обогащение полным текстом при заливке корпуса diff --git a/services/worker-indexer/app/tasks/index.py b/services/worker-indexer/app/tasks/index.py index 29d8db2..5d6b143 100644 --- a/services/worker-indexer/app/tasks/index.py +++ b/services/worker-indexer/app/tasks/index.py @@ -139,50 +139,64 @@ def extract_and_check( ) logger.info(f"Фрагментов создано: {len(fragments)}") - # ──── Уровень 1: Winnowing fingerprints ──────────────────────────────── + # ──── Уровень 1: Winnowing fingerprints (пофрагментно, с локализацией) ─── + # Winnow'им КАЖДЫЙ фрагмент отдельно и ищем, с каким источником и на + # какой позиции он совпадает. Так в отчёте видно не «документ похож на X», + # а «фрагмент на символах A–B скопирован из источника X». level1_matches: list[dict] = [] - doc_fingerprint = winnow(text) + from app.models import Document, Fingerprint - if doc_fingerprint: - from app.models import Document, Fingerprint + with db_session() as session: + doc_cache: dict[int, Document] = {} - with db_session() as session: - hashes = list(doc_fingerprint)[: settings.MAX_FINGERPRINTS_PER_DOC] + for frag in fragments: + frag_fp = winnow(frag["text"]) + if not frag_fp: + continue + frag_hashes = list(frag_fp) - # Найти совпадения в базе fingerprints - matching_docs = session.execute( + # Источник, разделяющий больше всего отпечатков с этим фрагментом + row = session.execute( select( Fingerprint.doc_id, - func.count(Fingerprint.id).label("match_count"), + func.count(Fingerprint.id).label("cnt"), ) - .where(Fingerprint.hash_value.in_(hashes)) + .where(Fingerprint.hash_value.in_(frag_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() + .limit(1) + ).first() - for doc_id, match_count in matching_docs: - similarity = match_count / len(hashes) * 100 - if similarity < 20: - continue + if row is None: + continue + doc_id, cnt = row + # Доля отпечатков фрагмента, найденных в источнике + similarity = cnt / len(frag_hashes) * 100 + if similarity < settings.EXACT_FRAGMENT_THRESHOLD: + continue + + doc = doc_cache.get(doc_id) + if doc is None: doc = session.get(Document, doc_id) - if not doc: + if doc is None: continue + doc_cache[doc_id] = doc - 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, - }) + level1_matches.append({ + "fragment": frag["text"][:300], + "position_start": frag["start"], + "position_end": frag["end"], + "similarity": round(min(similarity, 100.0), 1), + "method": "exact", + "source_title": doc.title, + "source_url": doc.url, + "source_db": doc.source, + }) - logger.info(f"Уровень 1 (Winnowing): {len(level1_matches)} совпадений") + logger.info( + f"Уровень 1 (Winnowing): {len(level1_matches)} совпадений-фрагментов" + ) # ──── Уровень 2: MinHash LSH ──────────────────────────────────────────── level2_matches: list[dict] = []