feat(plagiarism): показывать тематически близкие источники, а не только нарушения
Кандидаты уровня 3 (FAISS), которые прошли порог семантической схожести, но LLM не подтвердила заимствование, раньше молча отбрасывались. Теперь это отдельный блок "recommendations" в отчёте — не плагиат, но источники, полезные для раскрытия темы.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -105,3 +105,4 @@ services/**/app/models/**/*.py[cod]
|
|||||||
# Temp
|
# Temp
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
CREDENTIALS.md
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AlertTriangle, CheckCircle, Info } from 'lucide-react';
|
import { AlertTriangle, CheckCircle, Info, Lightbulb } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { PlagiarismResultData } from '../types';
|
import type { PlagiarismResultData } from '../types';
|
||||||
|
|
||||||
@@ -173,6 +173,49 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {
|
|||||||
<p className="text-base font-medium">Совпадений не обнаружено</p>
|
<p className="text-base font-medium">Совпадений не обнаружено</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Рекомендации по теме — тематически близкие работы, не заимствование */}
|
||||||
|
{!!data.recommendations?.length && (
|
||||||
|
<div>
|
||||||
|
<h3 className="flex items-center gap-1.5 text-base font-semibold text-gray-900 mb-1">
|
||||||
|
<Lightbulb className="w-4 h-4 text-blue-500" />
|
||||||
|
Похожие по теме работы ({data.recommendations.length})
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Не заимствование — семантический поиск нашёл близкие по смыслу источники.
|
||||||
|
Могут пригодиться для раскрытия темы и списка литературы.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.recommendations.map((rec, i) => (
|
||||||
|
<div key={i} className="border border-blue-100 bg-blue-50/40 rounded-xl overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-blue-100">
|
||||||
|
<span className="text-sm font-medium text-gray-700 flex-1 truncate">
|
||||||
|
{rec.source_title}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-700">
|
||||||
|
{rec.similarity.toFixed(0)}% по смыслу
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-gray-700 italic leading-relaxed line-clamp-2">
|
||||||
|
«{rec.fragment}»
|
||||||
|
</p>
|
||||||
|
{rec.source_url && (
|
||||||
|
<a
|
||||||
|
href={rec.source_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-brand-600 hover:underline mt-1.5 inline-block"
|
||||||
|
>
|
||||||
|
Открыть источник →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ export interface PlagiarismMatch {
|
|||||||
cited?: boolean;
|
cited?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlagiarismRecommendation {
|
||||||
|
fragment: string;
|
||||||
|
position_start: number;
|
||||||
|
position_end: number;
|
||||||
|
similarity: number;
|
||||||
|
source_title: string;
|
||||||
|
source_url: string | null;
|
||||||
|
source_db: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlagiarismResultData {
|
export interface PlagiarismResultData {
|
||||||
overall_similarity: number;
|
overall_similarity: number;
|
||||||
// Доля БЕЗ похожих на корректную цитату фрагментов — «настоящий» плагиат.
|
// Доля БЕЗ похожих на корректную цитату фрагментов — «настоящий» плагиат.
|
||||||
@@ -77,6 +87,10 @@ export interface PlagiarismResultData {
|
|||||||
fuzzy: number;
|
fuzzy: number;
|
||||||
semantic_llm: number;
|
semantic_llm: number;
|
||||||
};
|
};
|
||||||
|
// Тематически близкие работы, которые LLM не подтвердила как заимствование —
|
||||||
|
// не плагиат, но кандидаты для раскрытия/развития темы. Опционально: старые
|
||||||
|
// задачи в БД посчитаны без этого поля.
|
||||||
|
recommendations?: PlagiarismRecommendation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Библиография ─────────────────────────────────────────────────────────────
|
// ─── Библиография ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ def aggregate_results(
|
|||||||
semantic_matches: list[dict[str, Any]],
|
semantic_matches: list[dict[str, Any]],
|
||||||
total_fragments: int,
|
total_fragments: int,
|
||||||
full_text: str = "",
|
full_text: str = "",
|
||||||
|
related_candidates: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Свести совпадения уровней в итог проверки.
|
"""Свести совпадения уровней в итог проверки.
|
||||||
|
|
||||||
@@ -77,11 +78,15 @@ def aggregate_results(
|
|||||||
total_fragments: всего проверенных фрагментов документа
|
total_fragments: всего проверенных фрагментов документа
|
||||||
full_text: полный текст проверяемого документа — нужен для is_cited;
|
full_text: полный текст проверяемого документа — нужен для is_cited;
|
||||||
пустая строка → ни один фрагмент не размечается как цитата
|
пустая строка → ни один фрагмент не размечается как цитата
|
||||||
|
related_candidates: кандидаты уровня 3 (FAISS), которые прошли порог
|
||||||
|
семантической схожести, но LLM не подтвердила парафраз/плагиат —
|
||||||
|
не заимствование, но тематически близкая работа. Используются
|
||||||
|
для "recommendations", а не для процента схожести.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict с полями overall_similarity, uncited_similarity, matches (с полем
|
dict с полями overall_similarity, uncited_similarity, matches (с полем
|
||||||
"cited" в каждом), total_fragments, flagged_fragments, cited_fragments,
|
"cited" в каждом), total_fragments, flagged_fragments, cited_fragments,
|
||||||
uncited_fragments, by_method.
|
uncited_fragments, by_method, recommendations.
|
||||||
"""
|
"""
|
||||||
all_matches = level1_matches + level2_matches + semantic_matches
|
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
|
pct = (len(positions) / total_fragments * 100) if total_fragments > 0 else 0.0
|
||||||
return round(min(pct, 100.0), 2)
|
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 {
|
return {
|
||||||
"overall_similarity": _pct(flagged_positions),
|
"overall_similarity": _pct(flagged_positions),
|
||||||
"uncited_similarity": _pct(uncited_positions),
|
"uncited_similarity": _pct(uncited_positions),
|
||||||
@@ -114,4 +131,5 @@ def aggregate_results(
|
|||||||
"fuzzy": len(level2_matches),
|
"fuzzy": len(level2_matches),
|
||||||
"semantic_llm": len(semantic_matches),
|
"semantic_llm": len(semantic_matches),
|
||||||
},
|
},
|
||||||
|
"recommendations": recommendations,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ def check_plagiarism(
|
|||||||
ollama = OllamaClient()
|
ollama = OllamaClient()
|
||||||
vector_store = get_backend()
|
vector_store = get_backend()
|
||||||
semantic_matches: list[dict] = []
|
semantic_matches: list[dict] = []
|
||||||
|
related_candidates: list[dict] = []
|
||||||
|
|
||||||
for i, fragment in enumerate(fragments):
|
for i, fragment in enumerate(fragments):
|
||||||
frag_text = fragment.get("text", "")
|
frag_text = fragment.get("text", "")
|
||||||
@@ -125,19 +126,29 @@ def check_plagiarism(
|
|||||||
if source_text:
|
if source_text:
|
||||||
llm_result = ollama.check_paraphrase(source_text, frag_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:
|
if llm_result.get("is_paraphrase") and llm_result.get("confidence", 0.0) >= LLM_CONFIDENCE_THRESHOLD:
|
||||||
semantic_matches.append({
|
semantic_matches.append({
|
||||||
"fragment": frag_text[:300],
|
**candidate,
|
||||||
"position_start": fragment.get("start", 0),
|
|
||||||
"position_end": fragment.get("end", len(frag_text)),
|
|
||||||
"similarity": round(score * 100, 1),
|
|
||||||
"method": "semantic+llm",
|
"method": "semantic+llm",
|
||||||
"confidence": llm_result["confidence"],
|
"confidence": llm_result["confidence"],
|
||||||
"reason": llm_result.get("reason", ""),
|
"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:
|
if (i + 1) % 10 == 0:
|
||||||
logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}")
|
logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}")
|
||||||
@@ -146,7 +157,12 @@ def check_plagiarism(
|
|||||||
from app.scoring import aggregate_results
|
from app.scoring import aggregate_results
|
||||||
|
|
||||||
result = 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 не передан
|
out = aggregate_results([_m("A", 0)], [], [], total_fragments=2) # full_text не передан
|
||||||
assert out["matches"][0]["cited"] is False
|
assert out["matches"][0]["cited"] is False
|
||||||
assert out["overall_similarity"] == out["uncited_similarity"]
|
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