Files
anti-plagiarism/services/worker-gpu/app/scoring.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

118 lines
6.3 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/БД/сети.
Объединяет совпадения всех уровней (1 winnowing, 2 minhash, 3+4 semantic+llm),
дедуплицирует их, размечает корректно процитированные фрагменты и считает
итоговые проценты, которые видит студент. Вынесено из Celery-задачи, чтобы
логику можно было тестировать изолированно.
"""
import re
from typing import Any
# Кавычки (открывающая/закрывающая) — рус./англ. варианты
_OPEN_QUOTES = '«"„'
_CLOSE_QUOTES = '»"“”'
# Скобочная конструкция с 4-значным годом внутри — [Иванов, 2023], (Smith, 2020),
# [Иванов и др., 2023]. Совпадает и с нашим же форматом ГОСТ 7.0.5.
_CITATION_RE = re.compile(r"[\[(][^\[\]()]{0,80}(?:19|20)\d{2}[^\[\]()]{0,20}[\])]")
# Насколько далеко после фрагмента искать ссылку на источник
_CITATION_LOOKAHEAD = 150
def is_cited(full_text: str, position_start: int, position_end: int) -> bool:
"""Эвристика: похож ли фрагмент на корректно процитированный, а не на голый плагиат.
Фрагмент считается процитированным, если:
- обрамлён кавычками («…», "…") сразу по границам, ИЛИ
- сразу за ним (в пределах ~150 символов) идёт скобочная ссылка с годом —
[Иванов, 2023], (Smith, 2020) и т.п.
Эвристика, не 100%-но точная — сигнал для сортировки/отчёта, не финальный
вердикт. Для фрагментов без надёжной позиции в тексте (например, уровень 2
MinHash — совпадение на уровне всего документа) возвращает False: их нельзя
локализовать, чтобы проверить окружение.
Args:
full_text: полный текст проверяемого документа
position_start: начало фрагмента (символ)
position_end: конец фрагмента (символ)
Returns:
True, если похоже на корректную цитату
"""
if not full_text or position_start < 0 or position_end > len(full_text):
return False
before = full_text[max(0, position_start - 3) : position_start]
after = full_text[position_end : position_end + 3]
if any(c in before for c in _OPEN_QUOTES) and any(c in after for c in _CLOSE_QUOTES):
return True
tail = full_text[position_end : position_end + _CITATION_LOOKAHEAD]
return bool(_CITATION_RE.search(tail))
def aggregate_results(
level1_matches: list[dict[str, Any]],
level2_matches: list[dict[str, Any]],
semantic_matches: list[dict[str, Any]],
total_fragments: int,
full_text: str = "",
) -> dict[str, Any]:
"""Свести совпадения уровней в итог проверки.
Дедупликация — по паре (source_title, position_start): одно и то же совпадение
источника в одной позиции не дублируется. `overall_similarity` — доля УНИКАЛЬНЫХ
помеченных позиций от всех фрагментов (фрагмент, совпавший с несколькими
источниками, не раздувает процент выше 100). `uncited_similarity` — та же доля,
но БЕЗ фрагментов, похожих на корректную цитату (см. is_cited) — это ближе к
тому, что коммерческие системы называют «% некорректных заимствований».
Args:
level1_matches: совпадения уровня 1 (Winnowing, точные)
level2_matches: совпадения уровня 2 (MinHash, нечёткие)
semantic_matches: совпадения уровней 3-4 (FAISS + LLM-парафраз)
total_fragments: всего проверенных фрагментов документа
full_text: полный текст проверяемого документа — нужен для is_cited;
пустая строка → ни один фрагмент не размечается как цитата
Returns:
dict с полями overall_similarity, uncited_similarity, matches (с полем
"cited" в каждом), total_fragments, flagged_fragments, cited_fragments,
uncited_fragments, by_method.
"""
all_matches = level1_matches + level2_matches + semantic_matches
seen: set[str] = set()
unique_matches: list[dict[str, Any]] = []
for m in all_matches:
key = f"{m.get('source_title', '')}:{m.get('position_start', '')}"
if key not in seen:
seen.add(key)
m = {**m, "cited": is_cited(full_text, m.get("position_start", -1), m.get("position_end", -1))}
unique_matches.append(m)
flagged_positions = {m.get("position_start") for m in unique_matches}
uncited_positions = {m.get("position_start") for m in unique_matches if not m["cited"]}
def _pct(positions: set) -> float:
pct = (len(positions) / total_fragments * 100) if total_fragments > 0 else 0.0
return round(min(pct, 100.0), 2)
return {
"overall_similarity": _pct(flagged_positions),
"uncited_similarity": _pct(uncited_positions),
"matches": unique_matches,
"total_fragments": total_fragments,
"flagged_fragments": len(flagged_positions),
"cited_fragments": len(flagged_positions) - len(uncited_positions),
"uncited_fragments": len(uncited_positions),
"by_method": {
"exact": len(level1_matches),
"fuzzy": len(level2_matches),
"semantic_llm": len(semantic_matches),
},
}