diff --git a/scripts/run_lint.sh b/scripts/run_lint.sh index 48685a4..b823717 100755 --- a/scripts/run_lint.sh +++ b/scripts/run_lint.sh @@ -24,8 +24,9 @@ docker run --rm \ echo '▶ ruff (services/ scripts/)' ruff check services/ scripts/ - echo '▶ mypy (чистая логика L1/L2 + ГОСТ)' + echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг)' ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ ) ( cd services/worker-gost && mypy --config-file /repo/mypy.ini app/formatters/ ) + ( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py ) " echo "✅ Линт (ruff + mypy) пройден" diff --git a/services/worker-gpu/app/scoring.py b/services/worker-gpu/app/scoring.py new file mode 100644 index 0000000..a182191 --- /dev/null +++ b/services/worker-gpu/app/scoring.py @@ -0,0 +1,59 @@ +"""Чистая доменная логика скоринга плагиата — без Celery/БД/сети. + +Объединяет совпадения всех уровней (1 winnowing, 2 minhash, 3+4 semantic+llm), +дедуплицирует их и считает итоговый процент схожести, который видит студент. +Вынесено из Celery-задачи, чтобы логику можно было тестировать изолированно. +""" + +from typing import Any + + +def aggregate_results( + level1_matches: list[dict[str, Any]], + level2_matches: list[dict[str, Any]], + semantic_matches: list[dict[str, Any]], + total_fragments: int, +) -> dict[str, Any]: + """Свести совпадения уровней в итог проверки. + + Дедупликация — по паре (source_title, position_start): одно и то же совпадение + источника в одной позиции не дублируется. Итоговый процент — доля УНИКАЛЬНЫХ + помеченных позиций от всех фрагментов (фрагмент, совпавший с несколькими + источниками, не раздувает процент выше 100). + + Args: + level1_matches: совпадения уровня 1 (Winnowing, точные) + level2_matches: совпадения уровня 2 (MinHash, нечёткие) + semantic_matches: совпадения уровней 3-4 (FAISS + LLM-парафраз) + total_fragments: всего проверенных фрагментов документа + + Returns: + dict с полями overall_similarity, matches, total_fragments, + flagged_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) + unique_matches.append(m) + + flagged_positions = {m.get("position_start") for m in unique_matches} + flagged_frags = len(flagged_positions) + overall = (flagged_frags / total_fragments * 100) if total_fragments > 0 else 0.0 + overall = min(overall, 100.0) + + return { + "overall_similarity": round(overall, 2), + "matches": unique_matches, + "total_fragments": total_fragments, + "flagged_fragments": flagged_frags, + "by_method": { + "exact": len(level1_matches), + "fuzzy": len(level2_matches), + "semantic_llm": len(semantic_matches), + }, + } diff --git a/services/worker-gpu/app/tasks/plagiarism.py b/services/worker-gpu/app/tasks/plagiarism.py index 14b33a8..8cddab4 100644 --- a/services/worker-gpu/app/tasks/plagiarism.py +++ b/services/worker-gpu/app/tasks/plagiarism.py @@ -141,36 +141,12 @@ def check_plagiarism( if (i + 1) % 10 == 0: logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}") - # Объединить все совпадения и дедуплицировать по source_title - all_matches = level1_matches + level2_matches + semantic_matches - seen_sources: set[str] = set() - unique_matches = [] - for m in all_matches: - key = f"{m.get('source_title', '')}:{m.get('position_start', '')}" - if key not in seen_sources: - seen_sources.add(key) - unique_matches.append(m) + # Свести совпадения уровней в итог (дедуп + процент) — чистая логика в app.scoring + from app.scoring import aggregate_results - # Вычислить общий процент схожести по ДОЛЕ помеченных фрагментов документа. - # Считаем уникальные позиции (фрагмент, совпавший с несколькими источниками, - # не должен раздувать процент выше 100). - total_frags = len(fragments) - flagged_positions = {m.get("position_start") for m in unique_matches} - flagged_frags = len(flagged_positions) - overall_similarity = (flagged_frags / total_frags * 100) if total_frags > 0 else 0.0 - overall_similarity = min(overall_similarity, 100.0) - - result = { - "overall_similarity": round(overall_similarity, 2), - "matches": unique_matches, - "total_fragments": total_frags, - "flagged_fragments": flagged_frags, - "by_method": { - "exact": len(level1_matches), - "fuzzy": len(level2_matches), - "semantic_llm": len(semantic_matches), - }, - } + result = aggregate_results( + level1_matches, level2_matches, semantic_matches, len(fragments) + ) # Сохранить результат with db_session() as session: @@ -183,7 +159,8 @@ def check_plagiarism( logger.info( f"Проверка плагиата завершена для задачи {task_id!r}. " - f"Схожесть: {overall_similarity:.1f}%, совпадений: {flagged_frags}" + f"Схожесть: {result['overall_similarity']:.1f}%, " + f"совпадений: {result['flagged_fragments']}" ) # Уведомить пользователя diff --git a/services/worker-gpu/tests/test_scoring.py b/services/worker-gpu/tests/test_scoring.py new file mode 100644 index 0000000..5351b7d --- /dev/null +++ b/services/worker-gpu/tests/test_scoring.py @@ -0,0 +1,69 @@ +"""Юнит-тесты скоринга плагиата (итоговый процент, который видит студент).""" + +from app.scoring import aggregate_results + + +def _m(title: str, pos: int) -> dict: + return {"source_title": title, "position_start": pos} + + +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