diff --git a/README.md b/README.md
index 2f67b57..415f3a4 100644
--- a/README.md
+++ b/README.md
@@ -208,8 +208,9 @@ docker compose -f docker-compose.prod.yml --profile observability up -d promethe
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
-2. **Юнит-тесты** — `pytest` по сервисам: 73 теста на ядро детекции и форматирования,
- без внешней инфры (БД/Redis/GPU/Ollama замоканы либо не нужны).
+2. **Юнит-тесты** — `pytest` по сервисам: 112 тестов на ядро детекции, скоринга,
+ парсеров и форматирования, без внешней инфры (БД/Redis/GPU/Ollama замоканы
+ либо не нужны).
```bash
make lint # ruff + mypy в изолированном контейнере
@@ -229,11 +230,14 @@ make test-one SVC=worker-gost # тесты одного сервиса
| L1 — точные совпадения | `worker-indexer/app/algorithms/winnowing.py` | 13 |
| L2 — нечёткие (MinHash LSH) | `worker-indexer/app/algorithms/minhash.py` | 6 |
| Разбиение на фрагменты | `worker-indexer/app/fragments.py` | 5 |
-| L3 — семантический индекс | `worker-gpu/app/faiss_manager.py` | 5 |
+| Автопополнение корпуса | `worker-indexer/app/staging.py` | 6 |
+| L3 — семантический индекс (FAISS) | `worker-gpu/app/faiss_manager.py` | 5 |
+| L3 — векторный бэкенд (Qdrant + выбор) | `worker-gpu/app/qdrant_manager.py`, `vector_store.py` | 9 |
| L4 — LLM-парафраз | `worker-gpu/app/ollama_client.py` | 12 |
-| Итоговый % плагиата | `worker-gpu/app/scoring.py` | 8 |
+| Итоговый % плагиата + цитаты | `worker-gpu/app/scoring.py` | 18 |
| ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 |
| Список литературы | `worker-gost/app/bibliography.py` | 7 |
+| Парсеры источников (CyberLeninka, PMC) | `scripts/parsers/` | 14 |
## Лицензия
diff --git a/scripts/run_lint.sh b/scripts/run_lint.sh
index 7d97705..56af604 100755
--- a/scripts/run_lint.sh
+++ b/scripts/run_lint.sh
@@ -25,7 +25,7 @@ docker run --rm \
ruff check services/ scripts/
echo '▶ mypy (чистая логика L1/L2 + ГОСТ + скоринг)'
- ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py )
+ ( cd services/worker-indexer && mypy --config-file /repo/mypy.ini app/algorithms/ app/fragments.py app/staging.py )
( cd services/worker-gost && mypy --config-file /repo/mypy.ini app/formatters/ app/bibliography.py )
( cd services/worker-gpu && mypy --config-file /repo/mypy.ini app/scoring.py )
"
diff --git a/services/frontend/src/components/PlagiarismReport.tsx b/services/frontend/src/components/PlagiarismReport.tsx
index 4d26275..e5a92ae 100644
--- a/services/frontend/src/components/PlagiarismReport.tsx
+++ b/services/frontend/src/components/PlagiarismReport.tsx
@@ -66,6 +66,12 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {
Проверено фрагментов: {data.total_fragments} · Выявлено совпадений: {data.flagged_fragments}
+ {!!data.cited_fragments && (
+
+ из них корректно процитировано: {data.cited_fragments} · без указания источника:{' '}
+ {data.uncited_similarity?.toFixed(1)}%
+
+ )}
@@ -125,6 +131,14 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {
{METHOD_LABELS[match.method] || match.method}
+ {match.cited && (
+
+ Цитата
+
+ )}
{/* Фрагмент */}
diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts
index fbffb46..9e445b1 100644
--- a/services/frontend/src/types/index.ts
+++ b/services/frontend/src/types/index.ts
@@ -56,13 +56,22 @@ export interface PlagiarismMatch {
source_title: string;
source_url: string | null;
source_db: string;
+ // Похоже на корректную цитату (кавычки/ссылка с годом рядом) — эвристика,
+ // см. app.scoring.is_cited. Опционально: старые задачи в БД посчитаны без
+ // этого поля.
+ cited?: boolean;
}
export interface PlagiarismResultData {
overall_similarity: number;
+ // Доля БЕЗ похожих на корректную цитату фрагментов — «настоящий» плагиат.
+ // Опционально по той же причине, что и cited.
+ uncited_similarity?: number;
matches: PlagiarismMatch[];
total_fragments: number;
flagged_fragments: number;
+ cited_fragments?: number;
+ uncited_fragments?: number;
by_method?: {
exact: number;
fuzzy: number;
diff --git a/services/worker-gpu/app/scoring.py b/services/worker-gpu/app/scoring.py
index a182191..69bf1e3 100644
--- a/services/worker-gpu/app/scoring.py
+++ b/services/worker-gpu/app/scoring.py
@@ -1,35 +1,87 @@
"""Чистая доменная логика скоринга плагиата — без Celery/БД/сети.
Объединяет совпадения всех уровней (1 winnowing, 2 minhash, 3+4 semantic+llm),
-дедуплицирует их и считает итоговый процент схожести, который видит студент.
-Вынесено из Celery-задачи, чтобы логику можно было тестировать изолированно.
+дедуплицирует их, размечает корректно процитированные фрагменты и считает
+итоговые проценты, которые видит студент. Вынесено из Celery-задачи, чтобы
+логику можно было тестировать изолированно.
"""
+import re
from typing import Any
+# Кавычки (открывающая/закрывающая) — рус./англ. варианты
+_OPEN_QUOTES = '«"„'
+_CLOSE_QUOTES = '»"“”'
+
+# Скобочная конструкция с 4-значным годом внутри — [Иванов, 2023], (Smith, 2020),
+# [Иванов и др., 2023]. Совпадает и с нашим же форматом ГОСТ 7.0.5.
+_CITATION_RE = re.compile(r"[\[(][^\[\]()]{0,80}(?:19|20)\d{2}[^\[\]()]{0,20}[\])]")
+
+# Насколько далеко после фрагмента искать ссылку на источник
+_CITATION_LOOKAHEAD = 150
+
+
+def is_cited(full_text: str, position_start: int, position_end: int) -> bool:
+ """Эвристика: похож ли фрагмент на корректно процитированный, а не на голый плагиат.
+
+ Фрагмент считается процитированным, если:
+ - обрамлён кавычками («…», "…") сразу по границам, ИЛИ
+ - сразу за ним (в пределах ~150 символов) идёт скобочная ссылка с годом —
+ [Иванов, 2023], (Smith, 2020) и т.п.
+
+ Эвристика, не 100%-но точная — сигнал для сортировки/отчёта, не финальный
+ вердикт. Для фрагментов без надёжной позиции в тексте (например, уровень 2
+ MinHash — совпадение на уровне всего документа) возвращает False: их нельзя
+ локализовать, чтобы проверить окружение.
+
+ Args:
+ full_text: полный текст проверяемого документа
+ position_start: начало фрагмента (символ)
+ position_end: конец фрагмента (символ)
+
+ Returns:
+ True, если похоже на корректную цитату
+ """
+ if not full_text or position_start < 0 or position_end > len(full_text):
+ return False
+
+ before = full_text[max(0, position_start - 3) : position_start]
+ after = full_text[position_end : position_end + 3]
+ if any(c in before for c in _OPEN_QUOTES) and any(c in after for c in _CLOSE_QUOTES):
+ return True
+
+ tail = full_text[position_end : position_end + _CITATION_LOOKAHEAD]
+ return bool(_CITATION_RE.search(tail))
+
def aggregate_results(
level1_matches: list[dict[str, Any]],
level2_matches: list[dict[str, Any]],
semantic_matches: list[dict[str, Any]],
total_fragments: int,
+ full_text: str = "",
) -> dict[str, Any]:
"""Свести совпадения уровней в итог проверки.
Дедупликация — по паре (source_title, position_start): одно и то же совпадение
- источника в одной позиции не дублируется. Итоговый процент — доля УНИКАЛЬНЫХ
+ источника в одной позиции не дублируется. `overall_similarity` — доля УНИКАЛЬНЫХ
помеченных позиций от всех фрагментов (фрагмент, совпавший с несколькими
- источниками, не раздувает процент выше 100).
+ источниками, не раздувает процент выше 100). `uncited_similarity` — та же доля,
+ но БЕЗ фрагментов, похожих на корректную цитату (см. is_cited) — это ближе к
+ тому, что коммерческие системы называют «% некорректных заимствований».
Args:
level1_matches: совпадения уровня 1 (Winnowing, точные)
level2_matches: совпадения уровня 2 (MinHash, нечёткие)
semantic_matches: совпадения уровней 3-4 (FAISS + LLM-парафраз)
total_fragments: всего проверенных фрагментов документа
+ full_text: полный текст проверяемого документа — нужен для is_cited;
+ пустая строка → ни один фрагмент не размечается как цитата
Returns:
- dict с полями overall_similarity, matches, total_fragments,
- flagged_fragments, by_method.
+ dict с полями overall_similarity, uncited_similarity, matches (с полем
+ "cited" в каждом), total_fragments, flagged_fragments, cited_fragments,
+ uncited_fragments, by_method.
"""
all_matches = level1_matches + level2_matches + semantic_matches
@@ -39,18 +91,24 @@ def aggregate_results(
key = f"{m.get('source_title', '')}:{m.get('position_start', '')}"
if key not in seen:
seen.add(key)
+ m = {**m, "cited": is_cited(full_text, m.get("position_start", -1), m.get("position_end", -1))}
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)
+ uncited_positions = {m.get("position_start") for m in unique_matches if not m["cited"]}
+
+ def _pct(positions: set) -> float:
+ pct = (len(positions) / total_fragments * 100) if total_fragments > 0 else 0.0
+ return round(min(pct, 100.0), 2)
return {
- "overall_similarity": round(overall, 2),
+ "overall_similarity": _pct(flagged_positions),
+ "uncited_similarity": _pct(uncited_positions),
"matches": unique_matches,
"total_fragments": total_fragments,
- "flagged_fragments": flagged_frags,
+ "flagged_fragments": len(flagged_positions),
+ "cited_fragments": len(flagged_positions) - len(uncited_positions),
+ "uncited_fragments": len(uncited_positions),
"by_method": {
"exact": len(level1_matches),
"fuzzy": len(level2_matches),
diff --git a/services/worker-gpu/app/tasks/plagiarism.py b/services/worker-gpu/app/tasks/plagiarism.py
index b23c2ab..a24bf4b 100644
--- a/services/worker-gpu/app/tasks/plagiarism.py
+++ b/services/worker-gpu/app/tasks/plagiarism.py
@@ -146,7 +146,7 @@ def check_plagiarism(
from app.scoring import aggregate_results
result = aggregate_results(
- level1_matches, level2_matches, semantic_matches, len(fragments)
+ level1_matches, level2_matches, semantic_matches, len(fragments), full_text=text
)
# Сохранить результат
@@ -171,6 +171,16 @@ def check_plagiarism(
queue="queue.notify",
)
+ # Пополнить базу для сравнения (как у коммерческих систем — каждая
+ # проверенная работа сама становится источником для будущих проверок).
+ # Диспатчим ПОСЛЕ сохранения результата, чтобы работа не сматчилась
+ # сама с собой в только что посчитанном отчёте.
+ celery_app.send_task(
+ "index.auto_approve_submission",
+ args=[task_id],
+ queue="queue.index",
+ )
+
return result
except Exception as exc:
diff --git a/services/worker-gpu/tests/test_scoring.py b/services/worker-gpu/tests/test_scoring.py
index 5351b7d..dac11d8 100644
--- a/services/worker-gpu/tests/test_scoring.py
+++ b/services/worker-gpu/tests/test_scoring.py
@@ -1,12 +1,16 @@
"""Юнит-тесты скоринга плагиата (итоговый процент, который видит студент)."""
-from app.scoring import aggregate_results
+from app.scoring import aggregate_results, is_cited
def _m(title: str, pos: int) -> dict:
return {"source_title": title, "position_start": pos}
+def _mp(title: str, start: int, end: int) -> dict:
+ return {"source_title": title, "position_start": start, "position_end": end}
+
+
def test_empty_input_is_zero():
out = aggregate_results([], [], [], total_fragments=10)
assert out["overall_similarity"] == 0.0
@@ -67,3 +71,71 @@ def test_percentage_rounded_to_two_decimals():
# 1 из 3 → 33.333... → 33.33
out = aggregate_results([_m("A", 0)], [], [], total_fragments=3)
assert out["overall_similarity"] == 33.33
+
+
+# ─── is_cited ───────────────────────────────────────────────────────────────
+
+def test_is_cited_quoted_fragment():
+ text = 'Автор пишет: «дословная цитата» и развивает мысль.'
+ start = text.index("дословная")
+ end = start + len("дословная цитата")
+ assert is_cited(text, start, end) is True
+
+
+def test_is_cited_bracket_citation_with_year():
+ text = "Текст фрагмента без кавычек [Иванов, 2023] продолжение."
+ end = text.index(" [Иванов")
+ assert is_cited(text, 0, end) is True
+
+
+def test_is_cited_apa_style_citation():
+ text = "Some borrowed sentence here (Smith, 2020) continues."
+ end = text.index(" (Smith")
+ assert is_cited(text, 0, end) is True
+
+
+def test_is_cited_bare_plagiarism_is_false():
+ text = "Просто скопированный текст без всякого оформления далее."
+ assert is_cited(text, 0, 10) is False
+
+
+def test_is_cited_bracket_without_year_is_false():
+ text = "Текст фрагмента [см. приложение] продолжение."
+ end = text.index(" [см")
+ assert is_cited(text, 0, end) is False
+
+
+def test_is_cited_empty_full_text_is_false():
+ assert is_cited("", 0, 5) is False
+
+
+def test_is_cited_out_of_range_position_is_false():
+ assert is_cited("короткий текст", 0, 999) is False
+
+
+# ─── aggregate_results: cited/uncited split ──────────────────────────────────
+
+def test_cited_match_excluded_from_uncited_similarity():
+ text = 'Вот «процитированный фрагмент» и текст дальше.'
+ start = text.index("процитированный")
+ end = start + len("процитированный фрагмент")
+ out = aggregate_results([_mp("A", start, end)], [], [], total_fragments=4, full_text=text)
+ assert out["matches"][0]["cited"] is True
+ assert out["overall_similarity"] == 25.0 # цитата всё ещё учтена в общем %
+ assert out["uncited_similarity"] == 0.0 # но не в "некорректных" заимствованиях
+ assert out["cited_fragments"] == 1
+ assert out["uncited_fragments"] == 0
+
+
+def test_uncited_match_counts_in_both_percentages():
+ text = "Скопированный без указания источника текст фрагмента дальше."
+ out = aggregate_results([_mp("A", 0, 20)], [], [], total_fragments=4, full_text=text)
+ assert out["matches"][0]["cited"] is False
+ assert out["overall_similarity"] == out["uncited_similarity"] == 25.0
+ assert out["uncited_fragments"] == 1
+
+
+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"]
diff --git a/services/worker-indexer/app/config.py b/services/worker-indexer/app/config.py
index 4f6f4c5..dce4e51 100644
--- a/services/worker-indexer/app/config.py
+++ b/services/worker-indexer/app/config.py
@@ -59,6 +59,13 @@ class Settings(BaseSettings):
FULL_TEXT_MIN_CHARS: int = 500 # Минимум символов, иначе считаем извлечение неудачным
EMBED_BATCH_SIZE: int = 64 # Размер пачки документов для диспатча эмбеддингов
+ # Автоматически добавлять проверенные работы студентов в базу для сравнения
+ # (как в коммерческих системах — Антиплагиат.ру/Turnitin ловят списывание у
+ # предыдущих потоков именно так). Без этого каждая работа лежит в StagedWork
+ # и ждёт ручного одобрения админом — сейчас это дефолт для роста корпуса;
+ # выключить, если нужна модерация перед публикацией.
+ AUTO_APPROVE_SUBMISSIONS: bool = True
+
# App
ENVIRONMENT: str = "development"
DEBUG: bool = False
diff --git a/services/worker-indexer/app/staging.py b/services/worker-indexer/app/staging.py
new file mode 100644
index 0000000..7e9ff08
--- /dev/null
+++ b/services/worker-indexer/app/staging.py
@@ -0,0 +1,40 @@
+"""Преобразование StagedWork → doc_data для add_document — чистая логика.
+
+Формат идентичен ручному одобрению в админке (POST /admin/staging/{id}/approve),
+чтобы автоматический путь (auto_approve_submission) и ручной давали одинаковый
+результат. Без зависимости от SQLAlchemy/Celery/MinIO — принимает любой объект с
+нужными атрибутами (StagedWork ORM или эквивалент), тестируется изолированно.
+"""
+
+from typing import Any, Protocol
+
+
+class StagedWorkLike(Protocol):
+ id: int
+ title: str | None
+ filename: str | None
+ authors: list | None
+ year: int | None
+ lang: str | None
+
+
+def staged_work_to_doc_data(sw: StagedWorkLike, full_text: str) -> dict[str, Any]:
+ """Собрать doc_data для index.add_document из записи StagedWork.
+
+ Args:
+ sw: StagedWork (или объект с теми же атрибутами)
+ full_text: извлечённый текст работы (уже прочитан из MinIO)
+
+ Returns:
+ dict в формате, который ожидает add_document (source, ext_id, title, ...)
+ """
+ return {
+ "source": "user_submission",
+ "ext_id": f"staged:{sw.id}",
+ "title": sw.title or sw.filename or f"Работа #{sw.id}",
+ "authors": sw.authors or [],
+ "year": sw.year,
+ "lang": sw.lang,
+ "abstract": full_text[:2000],
+ "full_text": full_text,
+ }
diff --git a/services/worker-indexer/app/tasks/index.py b/services/worker-indexer/app/tasks/index.py
index 4518e58..f710112 100644
--- a/services/worker-indexer/app/tasks/index.py
+++ b/services/worker-indexer/app/tasks/index.py
@@ -1,7 +1,7 @@
"""Celery задачи индексации документов и проверки плагиата (уровни 1-2)."""
import io
-from datetime import UTC
+from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -17,6 +17,7 @@ from app.db import db_session, get_minio, update_task_status
from app.extractors.docx import extract_text_from_docx, extract_text_from_txt
from app.extractors.pdf import extract_text_from_pdf
from app.fragments import split_into_fragments
+from app.staging import staged_work_to_doc_data
logger = get_task_logger(__name__)
@@ -390,6 +391,56 @@ def enrich_full_text(self, doc_id: int, url: str) -> dict[str, Any]:
return {"status": "ok", "doc_id": doc_id, "chars": len(text), "fingerprints": len(hashes)}
+@celery_app.task(
+ name="index.auto_approve_submission",
+ bind=True,
+ max_retries=2,
+ default_retry_delay=60,
+)
+def auto_approve_submission(self, task_id: str) -> dict[str, Any]:
+ """Автоматически добавить проверенную работу студента в базу для сравнения.
+
+ Вызывается из gpu.check_plagiarism ПОСЛЕ сохранения результата проверки —
+ так работа не сматчится сама с собой. Идемпотентно: пропускает, если
+ StagedWork не 'pending' (уже одобрена/отклонена вручную или повторный вызов)
+ или если AUTO_APPROVE_SUBMISSIONS выключен настройкой — тогда работа так и
+ остаётся ждать ручного решения в админке.
+ """
+ from app.models import StagedWork
+
+ if not settings.AUTO_APPROVE_SUBMISSIONS:
+ return {"status": "skipped", "reason": "auto-approve выключен настройкой"}
+
+ with db_session() as session:
+ sw = session.execute(
+ select(StagedWork).where(StagedWork.task_id == task_id)
+ ).scalar_one_or_none()
+ if sw is None:
+ return {"status": "skipped", "reason": "StagedWork не найден"}
+ if sw.status != "pending":
+ return {"status": "skipped", "reason": f"уже {sw.status}"}
+
+ full_text = ""
+ if sw.text_key:
+ try:
+ minio = get_minio()
+ obj = minio.get_object(settings.MINIO_BUCKET_STAGING, sw.text_key)
+ full_text = obj.read().decode("utf-8", errors="replace")
+ except Exception as e:
+ logger.warning(f"auto_approve_submission: не прочитан текст {task_id!r}: {e}")
+ return {"status": "error", "reason": str(e)}
+
+ doc_data = staged_work_to_doc_data(sw, full_text)
+ sw.status = "approved"
+ sw.reviewed_by = None # None = одобрено автоматически, не человеком
+ sw.reviewed_at = datetime.now(UTC)
+ session.commit()
+
+ result = add_document(doc_data, dispatch_embed=True)
+ logger.info(f"auto_approve_submission: задача {task_id!r} → {result}")
+ return result
+
+
def _stage_work(
task_id: str,
minio_key: str,
diff --git a/services/worker-indexer/tests/test_staging.py b/services/worker-indexer/tests/test_staging.py
new file mode 100644
index 0000000..e1a8071
--- /dev/null
+++ b/services/worker-indexer/tests/test_staging.py
@@ -0,0 +1,51 @@
+"""Юнит-тесты преобразования StagedWork → doc_data (автопополнение корпуса)."""
+
+from dataclasses import dataclass
+
+from app.staging import staged_work_to_doc_data
+
+
+@dataclass
+class _SW:
+ id: int
+ title: str | None = None
+ filename: str | None = None
+ authors: list | None = None
+ year: int | None = None
+ lang: str | None = None
+
+
+def test_ext_id_is_prefixed_with_staged():
+ d = staged_work_to_doc_data(_SW(id=42), "текст")
+ assert d["ext_id"] == "staged:42"
+
+
+def test_source_is_user_submission():
+ d = staged_work_to_doc_data(_SW(id=1), "текст")
+ assert d["source"] == "user_submission"
+
+
+def test_title_falls_back_to_filename_then_placeholder():
+ assert staged_work_to_doc_data(_SW(id=1, title="Заголовок"), "т")["title"] == "Заголовок"
+ assert staged_work_to_doc_data(_SW(id=1, filename="work.pdf"), "т")["title"] == "work.pdf"
+ assert staged_work_to_doc_data(_SW(id=7), "т")["title"] == "Работа #7"
+
+
+def test_authors_defaults_to_empty_list():
+ assert staged_work_to_doc_data(_SW(id=1), "т")["authors"] == []
+ assert staged_work_to_doc_data(_SW(id=1, authors=[{"last_name": "X"}]), "т")["authors"] == [
+ {"last_name": "X"}
+ ]
+
+
+def test_full_text_preserved_abstract_truncated_to_2000():
+ long_text = "с" * 3000
+ d = staged_work_to_doc_data(_SW(id=1), long_text)
+ assert d["full_text"] == long_text
+ assert len(d["abstract"]) == 2000
+
+
+def test_year_and_lang_passed_through():
+ d = staged_work_to_doc_data(_SW(id=1, year=2024, lang="ru"), "т")
+ assert d["year"] == 2024
+ assert d["lang"] == "ru"