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)}% по смыслу
+
+
+
+
+ ))}
+
+
+ )}
);
}
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"]