feat(ops): замер качества детекции — первые честные цифры
All checks were successful
Deploy / deploy (push) Successful in 4s
Deploy / test (push) Successful in 2m53s

О качестве проверки мы до сих пор знали только «механизм жив»: находит
подброшенный фрагмент. Продукт при этом продаёт процент заимствований, за
который никто не ручался — неизвестно было ни сколько списываний система
пропускает, ни как часто обвиняет невиновных.

Бенчмарк делает из документов корпуса «студенческие работы» четырёх видов и
гоняет их через настоящий путь L1. Первый замер на проде:

  дословно        100% найдено
  лёгкий рерайт   100% найдено
  сильный рерайт    0% — это работа L3/L4, не L1
  оригинал          0% ложных обвинений

То есть основа работает как задумано. Показательно другое: из 20 взятых
документов в замер попали 8 — у остальных нет полного текста нужной длины.
Узкое место не алгоритм, а глубина корпуса.

Запускать после изменения порогов и параметров winnowing — иначе непонятно,
улучшение сделано или ухудшение.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-09-05 20:45:59 +05:00
parent fed4b54cfc
commit a76e46c561
2 changed files with 198 additions and 0 deletions

View File

@@ -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 (доменная логика) + тесты — блокируют деплой.

View File

@@ -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()