From a76e46c561a3fcb30d5b07215d6be31f29d855ba Mon Sep 17 00:00:00 2001 From: jze9 Date: Sat, 5 Sep 2026 20:45:59 +0500 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=86=D0=B8=D0=B8=20=E2=80=94=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D1=8B=D0=B5=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=86=D0=B8=D1=84=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit О качестве проверки мы до сих пор знали только «механизм жив»: находит подброшенный фрагмент. Продукт при этом продаёт процент заимствований, за который никто не ручался — неизвестно было ни сколько списываний система пропускает, ни как часто обвиняет невиновных. Бенчмарк делает из документов корпуса «студенческие работы» четырёх видов и гоняет их через настоящий путь L1. Первый замер на проде: дословно 100% найдено лёгкий рерайт 100% найдено сильный рерайт 0% — это работа L3/L4, не L1 оригинал 0% ложных обвинений То есть основа работает как задумано. Показательно другое: из 20 взятых документов в замер попали 8 — у остальных нет полного текста нужной длины. Узкое место не алгоритм, а глубина корпуса. Запускать после изменения порогов и параметров winnowing — иначе непонятно, улучшение сделано или ухудшение. Co-Authored-By: Claude Opus 5 --- docs/ARCHITECTURE.md | 18 +++ scripts/ops/detection_benchmark.py | 180 +++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 scripts/ops/detection_benchmark.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 41e7eaa..e5a4aba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -214,6 +214,24 @@ Identity (Universal Auth) и генерирует `.env` заново (`infisica ## 11. Качество и тесты +**Замер качества детекции** — [`scripts/ops/detection_benchmark.py`](../scripts/ops/detection_benchmark.py). +Делает из документов корпуса «студенческие работы» (дословная копия, лёгкий и +сильный рерайт, плюс заведомо оригинальный текст) и прогоняет через настоящий +путь L1. Первый замер, 05.09.2026: + +| Случай | Доля найденных | +|--------|---------------:| +| дословно | 100% | +| лёгкий рерайт (выброшено каждое 10-е слово) | 100% | +| сильный рерайт (каждое 3-е слово) | 0% — задача L3/L4, не L1 | +| оригинальный текст | 0% ложных обвинений | + +Запускать после изменения порогов (`EXACT_FRAGMENT_THRESHOLD`), параметров +winnowing и плотности отпечатков: без этих чисел непонятно, улучшение сделано +или ухудшение. Ограничение замера: в выборку попадают только документы с +реальной глубиной (>800 отпечатков) — по документам с одной аннотацией мерить +нечего, и это само по себе показатель состояния корпуса. + - **113 юнит-тестов** (pytest, per-service) на чистую логику L1-L4/скоринг/фрагменты/ ГОСТ/библиография; инфра замокана или не нужна. Запуск: `make test`. - **Гейты CI**: ruff (весь Python) + mypy (доменная логика) + тесты — блокируют деплой. diff --git a/scripts/ops/detection_benchmark.py b/scripts/ops/detection_benchmark.py new file mode 100644 index 0000000..ff67505 --- /dev/null +++ b/scripts/ops/detection_benchmark.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Замер качества детекции на реальном корпусе: что находим и что выдумываем. + +Зачем: до сих пор о качестве проверки мы знали только «механизм жив» — находит +подброшенный фрагмент. Этого мало. Продукт продаёт процент заимствований, и +нужно знать две вещи: сколько списываний система пропускает (полнота) и как +часто обвиняет невиновных (точность). Без этих чисел непонятно, что улучшать — +пороги, глубину корпуса или алгоритм. + +Как устроен замер. Берём документы из самого корпуса и делаем из них +«студенческие работы» четырёх видов: + + дословно — фрагмент скопирован как есть (обязан находиться); + лёгкий рерайт — выброшено каждое 10-е слово (обязан находиться); + сильный рерайт— выброшено каждое 3-е слово (L1 не обязан; это работа L3/L4); + оригинал — текст, которого в корпусе нет (обязан НЕ находиться). + +Считается путь L1 — тот же, что в index.extract_and_check: фрагментация, +winnowing по каждому фрагменту, поиск по отпечаткам с порогом +EXACT_FRAGMENT_THRESHOLD. Уровни L3/L4 сюда не входят намеренно: они требуют +GPU-воркера и на порядок медленнее, а мерить нужно в первую очередь основу. + +Запуск (в контейнере worker-indexer): + cd /home/user/anti-plagiarism + docker compose -f docker-compose.prod.yml exec -T worker-indexer python - \\ + < scripts/ops/detection_benchmark.py + ... python - --docs 40 < scripts/ops/detection_benchmark.py + +Ничего не пишет в базу — только читает. +""" + +import argparse +import random + + +def make_cases(text: str, words_taken: int) -> dict[str, str]: + """Сделать из текста источника четыре «студенческие работы».""" + words = text.split() + start = len(words) // 3 + chunk = words[start : start + words_taken] + + return { + "дословно": " ".join(chunk), + "лёгкий рерайт": " ".join(w for i, w in enumerate(chunk) if i % 10), + "сильный рерайт": " ".join(w for i, w in enumerate(chunk) if i % 3), + } + + +def original_text(rnd: random.Random, words_taken: int) -> str: + """Текст, которого заведомо нет в корпусе — проверка на ложные обвинения.""" + topics = [ + "вчера вечером я варил гречку с грибами и вспоминал поездку на озеро", + "мой сосед купил подержанный велосипед и красит его в оранжевый цвет", + "бабушка печёт пироги с капустой по субботам и зовёт всех соседей", + "кот запрыгнул на подоконник и уронил горшок с геранью на пол", + "в субботу мы чинили забор и обсуждали цены на доски и гвозди", + ] + out: list[str] = [] + while len(out) < words_taken: + out.extend(rnd.choice(topics).split()) + return " ".join(out[:words_taken]) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--docs", type=int, default=25, help="сколько документов взять для замера") + ap.add_argument("--words", type=int, default=400, help="сколько слов «списывать»") + ap.add_argument("--seed", type=int, default=20260905, help="зерно случайности (для повторяемости)") + args = ap.parse_args() + + from app.algorithms.winnowing import winnow + from app.config import settings + from app.db import db_session, get_minio + from app.fragments import split_into_fragments + from app.models import Document, Fingerprint + from sqlalchemy import func, select + from sqlalchemy import text as sql_text + + rnd = random.Random(args.seed) + + # Берём документы с реальной глубиной: по аннотации в 30 отпечатков мерить + # нечего, и такой замер сказал бы больше о корпусе, чем об алгоритме + with db_session() as s: + rows = s.execute(sql_text(""" + SELECT d.id, d.source, d.minio_key, d.abstract + FROM documents d + JOIN (SELECT doc_id, count(*) c FROM fingerprints + GROUP BY doc_id HAVING count(*) > 800) f ON f.doc_id = d.id + WHERE d.source <> 'user_submission' + ORDER BY random() LIMIT :n + """), {"n": args.docs}).all() + + print(f"документов в замере: {len(rows)}, «списываем» по {args.words} слов") + print(f"порог срабатывания фрагмента: {settings.EXACT_FRAGMENT_THRESHOLD}%\n") + + def find_source(work_text: str) -> set[int]: + """Прогнать «работу» через L1 и вернуть найденные документы-источники.""" + found: set[int] = set() + frags = split_into_fragments( + work_text, + window=settings.FRAGMENT_WINDOW_WORDS, + overlap=settings.FRAGMENT_OVERLAP_WORDS, + ) + with db_session() as s: + for frag in frags: + fp = winnow(frag["text"]) + if not fp: + continue + hit = s.execute( + select(Fingerprint.doc_id, func.count(Fingerprint.id).label("c")) + .join(Document, Document.id == Fingerprint.doc_id) + .where( + Fingerprint.hash_value.in_(list(fp)), + Document.source != "user_submission", + ) + .group_by(Fingerprint.doc_id) + .order_by(func.count(Fingerprint.id).desc()) + .limit(1) + ).first() + if hit and hit[1] / len(fp) * 100 >= settings.EXACT_FRAGMENT_THRESHOLD: + found.add(hit[0]) + return found + + stats: dict[str, dict[str, int]] = {} + minio = get_minio() + + for doc_id, _source, minio_key, abstract in rows: + # Полный текст, если он сохранён; иначе то, что есть в базе + body = abstract or "" + if minio_key: + try: + obj = minio.get_object(settings.MINIO_BUCKET_DOCS, minio_key) + body = obj.read().decode("utf-8", errors="replace") + obj.close() + obj.release_conn() + except Exception: + pass + if len(body.split()) < args.words * 2: + continue + + for case_name, work in make_cases(body, args.words).items(): + found = find_source(work) + bucket = stats.setdefault(case_name, {"всего": 0, "нашли верно": 0, "не нашли": 0}) + bucket["всего"] += 1 + if doc_id in found: + bucket["нашли верно"] += 1 + else: + bucket["не нашли"] += 1 + + # Контроль на ложные обвинения + found = find_source(original_text(rnd, args.words)) + bucket = stats.setdefault("оригинал", {"всего": 0, "ложно обвинён": 0, "чисто": 0}) + bucket["всего"] += 1 + if found: + bucket["ложно обвинён"] += 1 + else: + bucket["чисто"] += 1 + + print(f"{'случай':16} {'всего':>6} {'найден':>8} {'пропущен':>10} доля найденных") + for case in ("дословно", "лёгкий рерайт", "сильный рерайт"): + b = stats.get(case) + if not b: + continue + share = b["нашли верно"] / b["всего"] * 100 if b["всего"] else 0 + print(f"{case:16} {b['всего']:>6} {b['нашли верно']:>8} {b['не нашли']:>10} {share:5.1f}%") + + b = stats.get("оригинал") + if b: + fp_rate = b["ложно обвинён"] / b["всего"] * 100 if b["всего"] else 0 + print(f"\nоригинальные тексты: {b['всего']}, ложных обвинений {b['ложно обвинён']} " + f"({fp_rate:.1f}%)") + + print("\nЧитать так: «дословно» и «лёгкий рерайт» — обязанность L1, доля должна быть " + "близка к 100%. «Сильный рерайт» L1 брать не обязан, это работа L3/L4. " + "Ложные обвинения должны быть нулевыми.") + + +if __name__ == "__main__": + main()