Итоговый процент схожести (тот, что видит студент) и дедупликация совпадений жили внутри Celery-задачи check_plagiarism и не тестировались. Вынес в чистую функцию app.scoring.aggregate_results (без Celery/БД/сети) — правильная «высота» (доменная логика отдельно от оркестрации) и теперь покрыто тестами: - процент = доля УНИКАЛЬНЫХ помеченных позиций от всех фрагментов; - одна позиция с несколькими источниками считается один раз (не раздувает %); - точный дубль (source_title:pos) дедуплицируется; - деление на ноль фрагментов; кап на 100%; округление до 2 знаков; by_method. Поведение сохранено 1:1 (лог задачи переведён на result[...]). scoring.py добавлен в mypy-гейт. Тестов всего: 61 (indexer 19, gost 17, gpu 25). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
60 lines
2.7 KiB
Python
60 lines
2.7 KiB
Python
"""Чистая доменная логика скоринга плагиата — без 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),
|
||
},
|
||
}
|