diff --git a/.gitignore b/.gitignore index d6ebb25..6580af5 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,4 @@ services/**/app/models/**/*.py[cod] # Temp tmp/ temp/ +CREDENTIALS.md diff --git a/services/frontend/src/components/PlagiarismReport.tsx b/services/frontend/src/components/PlagiarismReport.tsx index e5a92ae..85f734d 100644 --- a/services/frontend/src/components/PlagiarismReport.tsx +++ b/services/frontend/src/components/PlagiarismReport.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, CheckCircle, Info } from 'lucide-react'; +import { AlertTriangle, CheckCircle, Info, Lightbulb } from 'lucide-react'; import { clsx } from 'clsx'; import type { PlagiarismResultData } from '../types'; @@ -173,6 +173,49 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {

Совпадений не обнаружено

)} + + {/* Рекомендации по теме — тематически близкие работы, не заимствование */} + {!!data.recommendations?.length && ( +
+

+ + Похожие по теме работы ({data.recommendations.length}) +

+

+ Не заимствование — семантический поиск нашёл близкие по смыслу источники. + Могут пригодиться для раскрытия темы и списка литературы. +

+
+ {data.recommendations.map((rec, i) => ( +
+
+ + {rec.source_title} + + + {rec.similarity.toFixed(0)}% по смыслу + +
+
+

+ «{rec.fragment}» +

+ {rec.source_url && ( + + Открыть источник → + + )} +
+
+ ))} +
+
+ )} ); } diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts index 9e445b1..6d59d86 100644 --- a/services/frontend/src/types/index.ts +++ b/services/frontend/src/types/index.ts @@ -62,6 +62,16 @@ export interface PlagiarismMatch { 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 { overall_similarity: number; // Доля БЕЗ похожих на корректную цитату фрагментов — «настоящий» плагиат. @@ -77,6 +87,10 @@ export interface PlagiarismResultData { fuzzy: number; semantic_llm: number; }; + // Тематически близкие работы, которые LLM не подтвердила как заимствование — + // не плагиат, но кандидаты для раскрытия/развития темы. Опционально: старые + // задачи в БД посчитаны без этого поля. + recommendations?: PlagiarismRecommendation[]; } // ─── Библиография ───────────────────────────────────────────────────────────── diff --git a/services/worker-gpu/app/scoring.py b/services/worker-gpu/app/scoring.py index 69bf1e3..5199d8e 100644 --- a/services/worker-gpu/app/scoring.py +++ b/services/worker-gpu/app/scoring.py @@ -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, } diff --git a/services/worker-gpu/app/tasks/plagiarism.py b/services/worker-gpu/app/tasks/plagiarism.py index a24bf4b..7132f99 100644 --- a/services/worker-gpu/app/tasks/plagiarism.py +++ b/services/worker-gpu/app/tasks/plagiarism.py @@ -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, ) # Сохранить результат diff --git a/services/worker-gpu/tests/test_scoring.py b/services/worker-gpu/tests/test_scoring.py index dac11d8..bb07af2 100644 --- a/services/worker-gpu/tests/test_scoring.py +++ b/services/worker-gpu/tests/test_scoring.py @@ -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"]