Files
anti-plagiarism/services/worker-gpu/tests/test_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

142 lines
6.0 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.
"""Юнит-тесты скоринга плагиата (итоговый процент, который видит студент)."""
from app.scoring import aggregate_results, is_cited
def _m(title: str, pos: int) -> dict:
return {"source_title": title, "position_start": pos}
def _mp(title: str, start: int, end: int) -> dict:
return {"source_title": title, "position_start": start, "position_end": end}
def test_empty_input_is_zero():
out = aggregate_results([], [], [], total_fragments=10)
assert out["overall_similarity"] == 0.0
assert out["flagged_fragments"] == 0
assert out["matches"] == []
assert out["by_method"] == {"exact": 0, "fuzzy": 0, "semantic_llm": 0}
def test_zero_fragments_does_not_divide_by_zero():
out = aggregate_results([_m("A", 0)], [], [], total_fragments=0)
assert out["overall_similarity"] == 0.0
def test_percentage_is_share_of_flagged_positions():
# 2 из 4 фрагментов помечены → 50%
level1 = [_m("A", 0), _m("B", 5)]
out = aggregate_results(level1, [], [], total_fragments=4)
assert out["overall_similarity"] == 50.0
assert out["flagged_fragments"] == 2
def test_same_position_multiple_sources_counts_once():
# Одна позиция совпала с двумя разными источниками — доля не раздувается
level1 = [_m("A", 0)]
semantic = [_m("B", 0)] # та же позиция 0, другой источник
out = aggregate_results(level1, [], semantic, total_fragments=2)
assert out["flagged_fragments"] == 1
assert out["overall_similarity"] == 50.0
# оба совпадения сохранены в matches (дедуп только по паре title:pos)
assert len(out["matches"]) == 2
def test_exact_duplicate_match_is_deduplicated():
# Один и тот же источник в одной позиции пришёл дважды — остаётся один
dup = _m("A", 3)
out = aggregate_results([dict(dup)], [dict(dup)], [], total_fragments=5)
assert len(out["matches"]) == 1
def test_similarity_capped_at_100():
# Больше уникальных позиций, чем фрагментов (пограничный случай) → не выше 100%
matches = [_m(f"S{i}", i) for i in range(5)]
out = aggregate_results(matches, [], [], total_fragments=3)
assert out["overall_similarity"] == 100.0
def test_by_method_counts_raw_matches_per_level():
out = aggregate_results(
[_m("A", 0), _m("B", 1)], # exact = 2
[_m("C", 2)], # fuzzy = 1
[_m("D", 3), _m("E", 4), _m("F", 5)], # semantic_llm = 3
total_fragments=10,
)
assert out["by_method"] == {"exact": 2, "fuzzy": 1, "semantic_llm": 3}
def test_percentage_rounded_to_two_decimals():
# 1 из 3 → 33.333... → 33.33
out = aggregate_results([_m("A", 0)], [], [], total_fragments=3)
assert out["overall_similarity"] == 33.33
# ─── is_cited ───────────────────────────────────────────────────────────────
def test_is_cited_quoted_fragment():
text = 'Автор пишет: «дословная цитата» и развивает мысль.'
start = text.index("дословная")
end = start + len("дословная цитата")
assert is_cited(text, start, end) is True
def test_is_cited_bracket_citation_with_year():
text = "Текст фрагмента без кавычек [Иванов, 2023] продолжение."
end = text.index(" [Иванов")
assert is_cited(text, 0, end) is True
def test_is_cited_apa_style_citation():
text = "Some borrowed sentence here (Smith, 2020) continues."
end = text.index(" (Smith")
assert is_cited(text, 0, end) is True
def test_is_cited_bare_plagiarism_is_false():
text = "Просто скопированный текст без всякого оформления далее."
assert is_cited(text, 0, 10) is False
def test_is_cited_bracket_without_year_is_false():
text = "Текст фрагмента [см. приложение] продолжение."
end = text.index(" [см")
assert is_cited(text, 0, end) is False
def test_is_cited_empty_full_text_is_false():
assert is_cited("", 0, 5) is False
def test_is_cited_out_of_range_position_is_false():
assert is_cited("короткий текст", 0, 999) is False
# ─── aggregate_results: cited/uncited split ──────────────────────────────────
def test_cited_match_excluded_from_uncited_similarity():
text = 'Вот «процитированный фрагмент» и текст дальше.'
start = text.index("процитированный")
end = start + len("процитированный фрагмент")
out = aggregate_results([_mp("A", start, end)], [], [], total_fragments=4, full_text=text)
assert out["matches"][0]["cited"] is True
assert out["overall_similarity"] == 25.0 # цитата всё ещё учтена в общем %
assert out["uncited_similarity"] == 0.0 # но не в "некорректных" заимствованиях
assert out["cited_fragments"] == 1
assert out["uncited_fragments"] == 0
def test_uncited_match_counts_in_both_percentages():
text = "Скопированный без указания источника текст фрагмента дальше."
out = aggregate_results([_mp("A", 0, 20)], [], [], total_fragments=4, full_text=text)
assert out["matches"][0]["cited"] is False
assert out["overall_similarity"] == out["uncited_similarity"] == 25.0
assert out["uncited_fragments"] == 1
def test_no_full_text_means_nothing_marked_cited():
out = aggregate_results([_m("A", 0)], [], [], total_fragments=2) # full_text не передан
assert out["matches"][0]["cited"] is False
assert out["overall_similarity"] == out["uncited_similarity"]