Files
anti-plagiarism/services/worker-gpu/tests/test_scoring.py
jze9 53d0de3d71
All checks were successful
Deploy / test (push) Successful in 4m15s
Deploy / deploy (push) Successful in 37s
feat(plagiarism): показывать тематически близкие источники, а не только нарушения
Кандидаты уровня 3 (FAISS), которые прошли порог семантической схожести,
но LLM не подтвердила заимствование, раньше молча отбрасывались. Теперь
это отдельный блок "recommendations" в отчёте — не плагиат, но источники,
полезные для раскрытия темы.
2026-08-25 14:16:09 +05:00

195 lines
7.9 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"]
# ─── aggregate_results: recommendations ──────────────────────────────────────
def _cand(title: str, similarity: float, url: str | None = None) -> dict:
return {
"fragment": "фрагмент",
"position_start": 0,
"position_end": 8,
"similarity": similarity,
"source_title": title,
"source_url": url,
"source_db": "openalex",
}
def test_no_related_candidates_means_empty_recommendations():
out = aggregate_results([], [], [], total_fragments=10)
assert out["recommendations"] == []
def test_related_candidates_become_recommendations_sorted_by_similarity():
out = aggregate_results(
[], [], [], total_fragments=10,
related_candidates=[_cand("Low", 60.0), _cand("High", 90.0)],
)
titles = [r["source_title"] for r in out["recommendations"]]
assert titles == ["High", "Low"]
def test_recommendations_dedup_keeps_best_per_source():
out = aggregate_results(
[], [], [], total_fragments=10,
related_candidates=[_cand("A", 60.0, "url-a"), _cand("A", 85.0, "url-a")],
)
assert len(out["recommendations"]) == 1
assert out["recommendations"][0]["similarity"] == 85.0
def test_recommendations_capped_at_ten():
candidates = [_cand(f"S{i}", float(i), f"url-{i}") for i in range(15)]
out = aggregate_results([], [], [], total_fragments=10, related_candidates=candidates)
assert len(out["recommendations"]) == 10
def test_source_already_flagged_as_match_excluded_from_recommendations():
semantic = [{**_cand("A", 90.0, "url-a"), "method": "semantic+llm"}]
out = aggregate_results(
[], [], semantic, total_fragments=10,
related_candidates=[_cand("A", 60.0, "url-a"), _cand("B", 70.0, "url-b")],
)
titles = [r["source_title"] for r in out["recommendations"]]
assert titles == ["B"]