feat(plagiarism): показывать тематически близкие источники, а не только нарушения
Кандидаты уровня 3 (FAISS), которые прошли порог семантической схожести, но LLM не подтвердила заимствование, раньше молча отбрасывались. Теперь это отдельный блок "recommendations" в отчёте — не плагиат, но источники, полезные для раскрытия темы.
This commit is contained in:
@@ -60,6 +60,7 @@ def aggregate_results(
|
||||
semantic_matches: list[dict[str, Any]],
|
||||
total_fragments: int,
|
||||
full_text: str = "",
|
||||
related_candidates: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Свести совпадения уровней в итог проверки.
|
||||
|
||||
@@ -77,11 +78,15 @@ def aggregate_results(
|
||||
total_fragments: всего проверенных фрагментов документа
|
||||
full_text: полный текст проверяемого документа — нужен для is_cited;
|
||||
пустая строка → ни один фрагмент не размечается как цитата
|
||||
related_candidates: кандидаты уровня 3 (FAISS), которые прошли порог
|
||||
семантической схожести, но LLM не подтвердила парафраз/плагиат —
|
||||
не заимствование, но тематически близкая работа. Используются
|
||||
для "recommendations", а не для процента схожести.
|
||||
|
||||
Returns:
|
||||
dict с полями overall_similarity, uncited_similarity, matches (с полем
|
||||
"cited" в каждом), total_fragments, flagged_fragments, cited_fragments,
|
||||
uncited_fragments, by_method.
|
||||
uncited_fragments, by_method, recommendations.
|
||||
"""
|
||||
all_matches = level1_matches + level2_matches + semantic_matches
|
||||
|
||||
@@ -101,6 +106,18 @@ def aggregate_results(
|
||||
pct = (len(positions) / total_fragments * 100) if total_fragments > 0 else 0.0
|
||||
return round(min(pct, 100.0), 2)
|
||||
|
||||
# Рекомендации: лучший кандидат на источник (без уже засчитанных как совпадение),
|
||||
# отсортированы по убыванию схожести, не более 10 — чтобы не захламлять отчёт.
|
||||
flagged_sources = {m.get("source_url") or m.get("source_title") for m in semantic_matches}
|
||||
best_by_source: dict[str, dict[str, Any]] = {}
|
||||
for c in related_candidates or []:
|
||||
key = c.get("source_url") or c.get("source_title", "")
|
||||
if not key or key in flagged_sources:
|
||||
continue
|
||||
if key not in best_by_source or c.get("similarity", 0) > best_by_source[key].get("similarity", 0):
|
||||
best_by_source[key] = c
|
||||
recommendations = sorted(best_by_source.values(), key=lambda c: c.get("similarity", 0), reverse=True)[:10]
|
||||
|
||||
return {
|
||||
"overall_similarity": _pct(flagged_positions),
|
||||
"uncited_similarity": _pct(uncited_positions),
|
||||
@@ -114,4 +131,5 @@ def aggregate_results(
|
||||
"fuzzy": len(level2_matches),
|
||||
"semantic_llm": len(semantic_matches),
|
||||
},
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ def check_plagiarism(
|
||||
ollama = OllamaClient()
|
||||
vector_store = get_backend()
|
||||
semantic_matches: list[dict] = []
|
||||
related_candidates: list[dict] = []
|
||||
|
||||
for i, fragment in enumerate(fragments):
|
||||
frag_text = fragment.get("text", "")
|
||||
@@ -125,19 +126,29 @@ def check_plagiarism(
|
||||
if source_text:
|
||||
llm_result = ollama.check_paraphrase(source_text, frag_text)
|
||||
|
||||
candidate = {
|
||||
"fragment": frag_text[:300],
|
||||
"position_start": fragment.get("start", 0),
|
||||
"position_end": fragment.get("end", len(frag_text)),
|
||||
"similarity": round(score * 100, 1),
|
||||
"source_title": doc_meta["title"],
|
||||
"source_url": doc_meta["url"],
|
||||
"source_db": doc_meta["source"],
|
||||
}
|
||||
|
||||
if llm_result.get("is_paraphrase") and llm_result.get("confidence", 0.0) >= LLM_CONFIDENCE_THRESHOLD:
|
||||
semantic_matches.append({
|
||||
"fragment": frag_text[:300],
|
||||
"position_start": fragment.get("start", 0),
|
||||
"position_end": fragment.get("end", len(frag_text)),
|
||||
"similarity": round(score * 100, 1),
|
||||
**candidate,
|
||||
"method": "semantic+llm",
|
||||
"confidence": llm_result["confidence"],
|
||||
"reason": llm_result.get("reason", ""),
|
||||
"source_title": doc_meta["title"],
|
||||
"source_url": doc_meta["url"],
|
||||
"source_db": doc_meta["source"],
|
||||
})
|
||||
else:
|
||||
# Похоже по смыслу, но LLM не подтвердила заимствование —
|
||||
# не плагиат, но тематически близкая работа: кандидат в
|
||||
# рекомендации "источники для раскрытия темы", а не в отчёт
|
||||
# о нарушениях.
|
||||
related_candidates.append(candidate)
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}")
|
||||
@@ -146,7 +157,12 @@ def check_plagiarism(
|
||||
from app.scoring import aggregate_results
|
||||
|
||||
result = aggregate_results(
|
||||
level1_matches, level2_matches, semantic_matches, len(fragments), full_text=text
|
||||
level1_matches,
|
||||
level2_matches,
|
||||
semantic_matches,
|
||||
len(fragments),
|
||||
full_text=text,
|
||||
related_candidates=related_candidates,
|
||||
)
|
||||
|
||||
# Сохранить результат
|
||||
|
||||
@@ -139,3 +139,56 @@ 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"]
|
||||
|
||||
Reference in New Issue
Block a user