Compare commits

..

6 Commits

Author SHA1 Message Date
jze9
b471ec767a feat: две доработки в духе коммерческих систем — цитаты и авто-корпус
Some checks failed
Deploy / test (push) Failing after 4m15s
Deploy / deploy (push) Has been skipped
Ответ на "неужели больше нет" / "мы ведь можем это исправить": закрывает
две дыры относительно Антиплагиат.ру/Turnitin, о которых договорились.

1. Различение цитаты и голого плагиата (app.scoring.is_cited, worker-gpu):
   эвристика — фрагмент считается процитированным, если обрамлён кавычками
   («…», "…") либо сразу за ним (в пределах ~150 симв.) идёт скобочная ссылка
   с годом: [Иванов, 2023], (Smith, 2020) — совпадает и с нашим же форматом
   ГОСТ 7.0.5. aggregate_results теперь принимает full_text, размечает
   match["cited"] и считает uncited_similarity (доля БЕЗ похожих на цитаты —
   ближе к тому, что коммерческие системы называют "% некорректных
   заимствований") отдельно от overall_similarity (как было, для совместимости).
   Фронтенд: бейдж "Цитата" на совпадении + строка с разбивкой в отчёте
   (аддитивные опциональные поля в типах — старые задачи не ломаются).

2. Автопополнение корпуса проверенными работами (как у коммерческих систем —
   так ловится списывание у предыдущих потоков). Раньше загруженная на проверку
   работа складывалась в StagedWork и ждала РУЧНОГО одобрения админом — де-факто
   не пополняла базу для сравнения. Теперь index.auto_approve_submission
   (диспатчится из gpu.check_plagiarism ПОСЛЕ сохранения результата — чтобы
   работа не сматчилась сама с собой) добавляет её в documents автоматически,
   под настройкой AUTO_APPROVE_SUBMISSIONS (default True). Ручное
   approve/reject в админке остаётся рабочим (идемпотентно — auto-approve
   пропускает уже не-pending записи), пригодится при AUTO_APPROVE=False.
   Конвертация StagedWork→doc_data вынесена в чистый app/staging.py (без
   Celery/SQLAlchemy/MinIO) — тестируется изолированно, идентична ручному
   пути в admin.py (POST /admin/staging/{id}/approve).

Тестов добавлено 16 (scoring 8→18, новый staging.py — 6). Оба mypy-гейта
расширены (scoring.py, staging.py). Тестов всего: 112 (было 82 в последнем
подсчёте README — таблица давно отставала, заодно поправил на актуальные цифры
по всем сервисам, включая забытый в прошлый раз Qdrant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 15:09:43 +05:00
jze9
92a1ce0f57 feat(ingestion): скрипт бэкфилла full_text для CyberLeninka из OCR
Оставался незакоммиченным с предыдущего шага (докачка full-text). Переспрашивает
59 уже засеянных cyberleninka-тем из parse_sources, матчит raw-статьи с уже
залитыми documents по ext_id, и там где minio_key ещё не проставлен — пересчитывает
Winnowing-отпечатки из OCR full_text (не короткой annotation) и сохраняет текст в
MinIO. Идемпотентно (пропускает уже обработанные). Не трогает faiss_id/эмбеддинги.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 15:09:18 +05:00
jze9
2aac40ed3e feat(parsers): новый источник PubMed Central (PMC) — реальный полный текст
Отвечает на "неужели больше нет библиотек" — добавлен четвёртый источник.
PMC Open Access Subset (NCBI E-utilities) — крупнейший биомедицинский открытый
архив, бесплатный API без обязательного ключа (NCBI_API_KEY — опционально,
поднимает лимит 3→10 запросов/сек). В отличие от остальных источников отдаёт
РЕАЛЬНЫЙ полный текст статьи (bodyJATS XML) прямо в ответе efetch — 17-53К
символов на статью в проверке вживую, не аннотацию и не 700-символьный OCR-
фрагмент. Никаких новых зависимостей — xml.etree.ElementTree (stdlib) + httpx,
по образцу arxiv.py.

- scripts/parsers/pmc.py: esearch (open access[filter]) → efetch (батчи по 20,
  JATS XML) → плоские словари → unified schema. Год-фильтр, свой User-Agent.
- Зарегистрирован в index.run_parser (services/worker-indexer/app/tasks/index.py)
  и в CLI run_parser.py — доступен как source_type="pmc" наравне с остальными.
- 7 юнит-тестов на реальных JATS XML-фрагментах (без сети): извлечение полей,
  фильтр contrib-type=author (не editor), пустой/битый XML, батч из 2 статей.

Рассмотрены и НЕ добавлены: Semantic Scholar (общий rate-limit исчерпан без
API-ключа, ключ — самостоятельная регистрация юзера) и CORE.ac.uk (обязателен
ключ). Google Scholar/ResearchGate — намеренно не трогаем, скрейпинг нарушает ToS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 14:47:15 +05:00
jze9
cc87a11f07 feat(parsers): CyberLeninka full_text из OCR-фрагментов поиска (без доп. запросов)
Поисковый API КиберЛенинки отдаёт поле "ocr" — список OCR-фрагментов текста
статьи (начало + фрагмент с ключевыми словами), прямо в ответе search. Раньше
transform() игнорировал его (full_text всегда None), и Winnowing-отпечатки (L1)
считались только по короткой annotation (~150 симв.) либо не считались вовсе,
если аннотации не было.

Теперь ocr (список) склеивается, чистится (<b>/сущности) и идёт в full_text —
add_document уже умеет: `text = full_text or abstract`. Даёт заметно более точные
L1-отпечатки для ВСЕХ будущих CyberLeninka-документов бесплатно — 0 доп. HTTP-
запросов, работает и там, где annotation вообще пустая (проверено вживую).

2 новых теста (ocr→full_text, отсутствие ocr→None). Тестов в файле: 7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 14:37:12 +05:00
jze9
92e23b5209 feat(ingestion): расширение охвата корпуса — 64 новые дисциплины
Продолжение seed_ru_sources.py: все 30 базовых RU-тем + 26 EN-тем (OpenAlex) +
4 категории arXiv уже отработаны (done). Этот скрипт добавляет НОВОЕ поверх —
углубление RU (специализированные подотрасли права/экономики/IT, 29 тем) и
расширение EN на области, которых не было (право, лингвистика, история,
искусство, науки о Земле и т.д., 20 тем) + 15 новых тем arXiv. Идемпотентно
(префиксы ru2:/oa3:/arxiv2: не пересекаются с уже существующими).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 13:53:01 +05:00
jze9
ea5c3a962c docs: визуальная схема системы (Mermaid) — docs/DIAGRAM.md
Диаграмма компонентов и потоков данных (frontend/API/очереди/воркеры/общая
инфра/опциональные Qdrant и Prometheus-Grafana) + sequence-диаграмма конвейера
проверки плагиата L1-L4. Дополняет текстовое ARCHITECTURE.md, на который
ссылается. ARCHITECTURE.md теперь ссылается на DIAGRAM.md и INGESTION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 13:47:45 +05:00
20 changed files with 1068 additions and 22 deletions

View File

@@ -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 |
## Лицензия

View File

@@ -1,8 +1,9 @@
# Архитектура — Академический помощник
Каноничное описание системы. Обновляется вместе с кодом; при расхождении верить
коду, а не этому файлу. Смежные документы: [DR-HA.md](DR-HA.md) (отказоустойчивость),
[../README.md](../README.md) (быстрый старт и команды).
коду, а не этому файлу. Визуальная схема — [DIAGRAM.md](DIAGRAM.md). Смежные
документы: [DR-HA.md](DR-HA.md) (отказоустойчивость), [INGESTION.md](INGESTION.md)
(наполнение корпуса), [../README.md](../README.md) (быстрый старт и команды).
## 1. Назначение

108
docs/DIAGRAM.md Normal file
View File

@@ -0,0 +1,108 @@
# Схема системы
Визуальная схема к [ARCHITECTURE.md](ARCHITECTURE.md) (там — полное текстовое описание).
## Компоненты и потоки данных
```mermaid
flowchart TB
subgraph client["Клиент"]
FE["Frontend (React SPA)<br/>CT 102, nginx"]
end
subgraph app["App-хост 1.32"]
API["API (FastAPI)<br/>JWT · rate-limit · /metrics"]
MQ[("RabbitMQ .82<br/>брокер Celery")]
subgraph workers["Celery-воркеры"]
IDX["worker-indexer<br/>L1 Winnowing · L2 MinHash<br/>PDF/DOCX · парсеры"]
GPU["worker-gpu<br/>L3 семантика · L4 LLM<br/>эмбеддинги"]
GOST["worker-gost<br/>ГОСТ 7.1 / 7.0.5"]
NOTIFY["worker-notifier<br/>SMTP email"]
end
ES[("Elasticsearch<br/>BM25, local")]
VEC{{"Векторный бэкенд<br/>VECTOR_BACKEND"}}
FAISS["FAISS<br/>(файл, дефолт)"]
QDR["Qdrant<br/>(опционально)"]
end
subgraph shared["Общая инфраструктура"]
PG[("PostgreSQL 1.38<br/>users/tasks/documents/<br/>fingerprints/...")]
REDIS[("Redis 1.35<br/>кэш · rate-limit · LSH-индекс")]
MINIO[("MinIO 1.21<br/>документы · full-text · бэкапы")]
OLLAMA["Ollama .163<br/>qwen2.5:7b"]
end
subgraph obs["Опционально (профили compose)"]
PROM["Prometheus"]
GRAF["Grafana"]
end
FE -->|"HTTPS /api, /ws"| API
API -->|"send_task"| MQ
API <-->|"users/tasks"| PG
API <-->|"session/rate-limit"| REDIS
MQ -->|"queue.index"| IDX
MQ -->|"queue.gpu"| GPU
MQ -->|"queue.gost"| GOST
MQ -->|"queue.notify"| NOTIFY
IDX <-->|"documents/fingerprints"| PG
IDX <-->|"MinHash LSH (antiplag_lsh)"| REDIS
IDX <-->|"PDF/full-text"| MINIO
IDX --> ES
IDX -->|"gpu.embed_documents"| MQ
GPU <-->|"documents"| PG
GPU --> VEC
VEC --> FAISS
VEC -.->|"переключение флагом"| QDR
GPU -->|"L4 парафраз"| OLLAMA
GPU --> ES
GOST <-->|"documents"| PG
NOTIFY <-->|"tasks"| PG
NOTIFY -->|"email"| SMTP(["jze9mail.ru"])
API -->|"/metrics"| PROM
PROM --> GRAF
style VEC fill:#00000000,stroke-dasharray: 4 3
style obs fill:#00000000,stroke-dasharray: 4 3
```
## Конвейер проверки плагиата (4 уровня)
```mermaid
sequenceDiagram
participant U as Студент
participant API as API
participant IDX as worker-indexer
participant GPU as worker-gpu
participant LLM as Ollama
U->>API: upload файла
API->>API: сохранить в MinIO, создать Task, commit
API->>IDX: index.extract_and_check
IDX->>IDX: извлечь текст → фрагменты
IDX->>IDX: L1 Winnowing (fingerprints)
IDX->>IDX: L2 MinHash LSH (Redis)
IDX->>GPU: gpu.check_plagiarism (частичные совпадения)
loop по подозрительным фрагментам
GPU->>GPU: L3 семантический поиск (FAISS/Qdrant)
GPU->>LLM: L4 check_paraphrase
LLM-->>GPU: is_paraphrase, confidence
end
GPU->>GPU: app.scoring.aggregate_results (итоговый %)
GPU->>API: результат → Task.result
GPU-->>U: notify.send_task_done → email/WebSocket
```
## Легенда
- Сплошные стрелки — прямые вызовы/запросы; пунктир у `VEC` — переключение бэкенда
по настройке `VECTOR_BACKEND`, не одновременная работа обоих.
- `obs` (Prometheus/Grafana) и `Qdrant` — опциональны, поднимаются под
`docker compose --profile qdrant|observability`, по умолчанию выключены.

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Бэкфилл full_text для уже залитых документов CyberLeninka из OCR-фрагментов.
Парсер (scripts/parsers/cyberleninka.py) раньше игнорировал поле "ocr" в ответе
поиска (список OCR-фрагментов текста статьи) — full_text всегда был None, и
Winnowing-отпечатки (L1) считались по короткой аннотации. Это исправлено для
НОВЫХ документов; этот скрипт досчитывает уже существующие.
Алгоритм: для каждой ранее засеянной cyberleninka-темы (parse_sources) —
переспросить search API (тот же query/limit — переспрос идемпотентен и не бьёт
по документу дважды), сматчить raw-статьи с уже существующими documents по
ext_id, и там где full_text ещё не сохранён (minio_key IS NULL):
1. пересчитать fingerprints по full_text (заменить провизорные из annotation);
2. сохранить full_text в MinIO (тот же путь, что enrich_full_text: corpus/{id}.txt);
3. проставить documents.minio_key.
Не трогает faiss_id/эмбеддинги (embed_documents использует title+abstract, не
full_text — пересчитывать нечего). Идемпотентно: документы с уже проставленным
minio_key пропускаются.
Запуск:
python scripts/backfill_cyberleninka_fulltext.py # dry-run (посчитать)
python scripts/backfill_cyberleninka_fulltext.py --apply # применить
"""
import argparse
import io
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "parsers"))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent
/ "services" / "worker-indexer" / "app" / "algorithms"))
def _load_env() -> None:
env = Path(__file__).resolve().parent.parent / ".env"
if not env.exists():
return
for line in env.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
def _pg_dsn() -> dict:
return {
"host": os.environ["POSTGRES_HOST"], "port": int(os.environ.get("POSTGRES_PORT", "5432")),
"dbname": os.environ["POSTGRES_DB"], "user": os.environ["POSTGRES_USER"],
"password": os.environ["POSTGRES_PASSWORD"],
}
MAX_FP = 20000
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
_load_env()
import psycopg2
from cyberleninka import CyberLeninkaParser
from minio import Minio
from winnowing import winnow
conn = psycopg2.connect(**_pg_dsn())
minio = Minio(
os.environ["MINIO_ENDPOINT"],
access_key=os.environ["MINIO_ACCESS_KEY"], secret_key=os.environ["MINIO_SECRET_KEY"],
secure=False,
)
bucket = os.environ.get("MINIO_BUCKET_DOCS", "documents")
with conn.cursor() as cur:
cur.execute(
"SELECT id, query, \"limit\" FROM parse_sources "
"WHERE source_type='cyberleninka' AND last_status='done' ORDER BY id"
)
topics = cur.fetchall()
print(f"Тем CyberLeninka к обработке: {len(topics)}")
parser = CyberLeninkaParser()
matched = updated = no_ocr = not_found = already_done = 0
for _sid, query, limit in topics:
raws = list(parser.fetch(query=query or "", limit=limit))
for raw in raws:
d = parser.transform(raw)
if not (d and d.get("ext_id")):
continue
with conn.cursor() as cur:
cur.execute(
"SELECT id, minio_key FROM documents WHERE ext_id=%s AND source='cyberleninka'",
(d["ext_id"],),
)
row = cur.fetchone()
if not row:
not_found += 1
continue
doc_id, minio_key = row
matched += 1
if minio_key:
already_done += 1
continue
if not d.get("full_text"):
no_ocr += 1
continue
if args.apply:
text = d["full_text"]
key = f"corpus/{doc_id}.txt"
data = text.encode("utf-8")
minio.put_object(bucket, key, io.BytesIO(data), length=len(data),
content_type="text/plain; charset=utf-8")
hashes = list(winnow(text))[:MAX_FP]
with conn.cursor() as cur:
cur.execute("DELETE FROM fingerprints WHERE doc_id=%s", (doc_id,))
if hashes:
from psycopg2.extras import execute_values
execute_values(
cur, "INSERT INTO fingerprints (doc_id,hash_value,position) VALUES %s",
[(doc_id, h, i) for i, h in enumerate(hashes)],
)
cur.execute("UPDATE documents SET minio_key=%s WHERE id=%s", (key, doc_id))
conn.commit()
updated += 1
print(f" {(query or '')[:32]:32} raw={len(raws):4} | обновлено всего={updated}", flush=True)
print(f"\nИТОГ: сматчено={matched} обновлено={updated} "
f"уже_было={already_done} без_ocr={no_ocr} не_найдено={not_found}"
f"{' [DRY-RUN — ничего не записано, добавьте --apply]' if not args.apply else ''}")
conn.close()
if __name__ == "__main__":
main()

View File

@@ -137,6 +137,13 @@ class CyberLeninkaParser(BaseParser):
link = raw.get("link", "")
url = f"{BASE_URL}{link}" if link.startswith("/") else link or None
# OCR-фрагменты текста статьи — приходят прямо в ответе поиска (список
# кусков, обычно начало статьи + фрагмент с ключевыми словами), без
# доп. запроса. Не полный текст всей статьи, но заметно богаче аннотации —
# используем как full_text для более точных Winnowing-отпечатков (L1).
ocr = raw.get("ocr")
full_text = _clean(" ".join(ocr)) if isinstance(ocr, list) and ocr else None
return {
"source": self.source_name,
"ext_id": ext_id,
@@ -151,7 +158,7 @@ class CyberLeninkaParser(BaseParser):
"pages": raw.get("pages") or None,
"abstract": _clean(raw.get("annotation")) or None,
"url": url,
"full_text": None,
"full_text": full_text,
}
def fetch_article_details(self, url: str) -> dict[str, Any]:

215
scripts/parsers/pmc.py Normal file
View File

@@ -0,0 +1,215 @@
"""Парсер PubMed Central (PMC) — NCBI E-utilities.
PMC Open Access Subset — крупнейший биомедицинский открытый архив с реальным
полным текстом статей (не только аннотацией). API бесплатный, ключ не нужен
(рекомендуется для повышения лимита с 3 до 10 запросов/сек — NCBI_API_KEY).
Документация: https://www.ncbi.nlm.nih.gov/books/NBK25501/
Два запроса на пачку: esearch (ID) → efetch (полные JATS XML статьи, откуда
разом достаём метаданные + abstract + body — тело статьи, реальный полный
текст, а не аннотация или OCR-фрагмент).
"""
import logging
import os
import time
import xml.etree.ElementTree as ET
from typing import Any
import httpx
from base import BaseParser
logger = logging.getLogger(__name__)
EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
BATCH_SIZE = 20
# 3 запроса/сек без ключа, 10/сек с ключом (NCBI_API_KEY в окружении)
RATE_LIMIT_DELAY = 0.12 if os.environ.get("NCBI_API_KEY") else 0.35
class PMCParser(BaseParser):
"""Парсер PubMed Central через NCBI E-utilities."""
source_name = "pmc"
def __init__(self) -> None:
super().__init__()
self.api_key = os.environ.get("NCBI_API_KEY")
self.client = httpx.Client(
headers={"User-Agent": "AcademicHelper/1.0 (noreply@jze9.ru)"},
timeout=30.0,
)
def _params(self, **extra: Any) -> dict[str, Any]:
p = dict(extra)
if self.api_key:
p["api_key"] = self.api_key
return p
def fetch(
self,
query: str = "",
limit: int = 500,
year_from: int | None = None,
year_to: int | None = None,
) -> list[dict[str, Any]]:
"""
Получить статьи из PMC Open Access Subset.
Args:
query: Поисковый запрос
limit: Максимальное количество документов
year_from: Год публикации от
year_to: Год публикации до
Returns:
Список сырых словарей (уже с извлечёнными метаданными + full_text)
"""
term = f"{query} AND open access[filter]" if query else "open access[filter]"
if year_from or year_to:
lo = year_from or 1900
hi = year_to or 3000
term += f' AND ("{lo}"[PDAT] : "{hi}"[PDAT])'
ids = self._search_ids(term, limit)
if not ids:
return []
results: list[dict[str, Any]] = []
for i in range(0, len(ids), BATCH_SIZE):
batch = ids[i : i + BATCH_SIZE]
try:
response = self.client.get(
f"{EUTILS}/efetch.fcgi",
params=self._params(db="pmc", id=",".join(batch), rettype="full", retmode="xml"),
)
response.raise_for_status()
results.extend(self._parse_articles(response.text))
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"PMC efetch HTTP ошибка: {e.response.status_code}")
if e.response.status_code == 429:
time.sleep(5)
continue
except Exception as e:
logger.error(f"Ошибка efetch PMC (батч {i}): {e}")
continue
return results[:limit]
def _search_ids(self, term: str, limit: int) -> list[str]:
"""Собрать PMC ID постранично через esearch."""
ids: list[str] = []
retstart = 0
page = min(200, limit)
while len(ids) < limit:
try:
response = self.client.get(
f"{EUTILS}/esearch.fcgi",
params=self._params(
db="pmc", term=term, retstart=retstart,
retmax=min(page, limit - len(ids)), retmode="json",
),
)
response.raise_for_status()
page_ids = response.json().get("esearchresult", {}).get("idlist", [])
if not page_ids:
break
ids.extend(page_ids)
retstart += len(page_ids)
time.sleep(RATE_LIMIT_DELAY)
except httpx.HTTPStatusError as e:
logger.error(f"PMC esearch HTTP ошибка: {e.response.status_code}")
break
except Exception as e:
logger.error(f"Ошибка esearch PMC: {e}")
break
return ids[:limit]
def _parse_articles(self, xml_text: str) -> list[dict[str, Any]]:
"""Разобрать JATS XML (пачка статей из efetch) в плоские словари."""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as e:
logger.error(f"Ошибка парсинга XML PMC: {e}")
return []
return [self._parse_article(art) for art in root.findall("article")]
@staticmethod
def _text(el: ET.Element | None) -> str | None:
"""Склеить весь текст элемента, включая вложенные теги форматирования."""
return "".join(el.itertext()).strip() if el is not None else None
def _parse_article(self, article: ET.Element) -> dict[str, Any]:
"""Извлечь метаданные + abstract + полный текст из одной <article>."""
am = article.find(".//article-meta")
if am is None:
return {}
pmcaid = doi = None
for aid in am.findall("article-id"):
if aid.get("pub-id-type") == "pmcaid":
pmcaid = aid.text
elif aid.get("pub-id-type") == "doi":
doi = aid.text
authors = []
for c in am.findall(".//contrib-group/contrib[@contrib-type='author']"):
surname = c.find(".//surname")
given = c.find(".//given-names")
if surname is not None and surname.text:
authors.append({
"last_name": surname.text.strip(),
"first_name": (given.text or "").strip() if given is not None else "",
})
year = None
for y in am.findall(".//pub-date/year"):
if y.text and y.text.isdigit():
year = int(y.text)
break
body = article.find("body")
full_text = None
if body is not None:
paragraphs = [self._text(p) for p in body.findall(".//p")]
full_text = " ".join(p for p in paragraphs if p) or None
return {
"id": pmcaid,
"doi": doi,
"title": self._text(am.find(".//title-group/article-title")),
"journal": self._text(article.find(".//journal-meta/journal-title-group/journal-title")),
"authors": authors,
"year": year,
"abstract": self._text(am.find(".//abstract")),
"full_text": full_text,
}
def transform(self, raw: dict[str, Any]) -> dict[str, Any]:
"""Преобразовать статью PMC в унифицированный формат."""
ext_id = raw.get("id")
if not ext_id:
return {}
authors = self.normalize_authors(raw.get("authors", []))
return {
"source": self.source_name,
"ext_id": f"pmc:{ext_id}",
"doi": raw.get("doi"),
"title": (raw.get("title") or "").strip() or None,
"authors": authors,
"year": raw.get("year"),
"lang": "en", # PMC — практически полностью англоязычный корпус
"journal": raw.get("journal"),
"volume": None,
"issue": None,
"pages": None,
"abstract": raw.get("abstract"),
"url": f"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC{ext_id}/",
"full_text": raw.get("full_text"),
}

View File

@@ -51,3 +51,14 @@ def test_transform_handles_string_authors():
def test_transform_empty_without_id_or_link():
assert CyberLeninkaParser().transform({"name": "x"}) == {}
def test_transform_uses_ocr_as_full_text():
raw = dict(SAMPLE, ocr=["Первый <b>фрагмент</b> статьи.", "Второй &amp; фрагмент."])
t = CyberLeninkaParser().transform(raw)
assert t["full_text"] == "Первый фрагмент статьи. Второй & фрагмент."
def test_transform_full_text_none_without_ocr():
assert CyberLeninkaParser().transform(SAMPLE)["full_text"] is None
assert CyberLeninkaParser().transform(dict(SAMPLE, ocr=[]))["full_text"] is None

View File

@@ -0,0 +1,88 @@
"""Юнит-тесты парсера PubMed Central (PMC) — чистая логика (без сети).
_parse_article работает с реальными xml.etree.ElementTree узлами (JATS XML из
efetch), поэтому тесты строят минимальные JATS-фрагменты, а не мокают HTTP.
"""
import xml.etree.ElementTree as ET
from pmc import PMCParser
ARTICLE_XML = """
<article>
<front>
<journal-meta>
<journal-title-group><journal-title>Journal of Testing</journal-title></journal-title-group>
</journal-meta>
<article-meta>
<article-id pub-id-type="pmcaid">1234567</article-id>
<article-id pub-id-type="doi">10.1000/test.123</article-id>
<title-group><article-title>A <italic>Study</italic> of Testing</article-title></title-group>
<contrib-group>
<contrib contrib-type="author">
<name><surname>Ivanov</surname><given-names>Ivan</given-names></name>
</contrib>
<contrib contrib-type="editor">
<name><surname>NotAnAuthor</surname><given-names>X</given-names></name>
</contrib>
</contrib-group>
<pub-date pub-type="epub"><year>2024</year></pub-date>
<abstract><p>This is the <bold>abstract</bold> text.</p></abstract>
</article-meta>
</front>
<body>
<p>First paragraph of the body.</p>
<p>Second paragraph with <xref>a ref</xref> inline.</p>
</body>
</article>
"""
def _article() -> ET.Element:
return ET.fromstring(ARTICLE_XML)
def test_parse_article_extracts_all_fields():
raw = PMCParser()._parse_article(_article())
assert raw["id"] == "1234567"
assert raw["doi"] == "10.1000/test.123"
assert raw["title"] == "A Study of Testing" # вложенный <italic> склеен
assert raw["journal"] == "Journal of Testing"
assert raw["year"] == 2024
assert raw["abstract"] == "This is the abstract text."
assert raw["full_text"] == "First paragraph of the body. Second paragraph with a ref inline."
def test_parse_article_only_includes_authors_not_editors():
raw = PMCParser()._parse_article(_article())
assert raw["authors"] == [{"last_name": "Ivanov", "first_name": "Ivan"}]
def test_parse_article_without_article_meta_is_empty():
assert PMCParser()._parse_article(ET.fromstring("<article/>")) == {}
def test_transform_maps_to_unified_schema():
raw = PMCParser()._parse_article(_article())
t = PMCParser().transform(raw)
assert t["source"] == "pmc"
assert t["ext_id"] == "pmc:1234567"
assert t["lang"] == "en"
assert t["url"] == "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/"
assert t["authors"][0]["last_name"] == "Ivanov"
assert t["full_text"].startswith("First paragraph")
def test_transform_empty_without_id():
assert PMCParser().transform({"title": "x"}) == {}
def test_parse_articles_batch():
xml = f"<pmc-articleset>{ARTICLE_XML}{ARTICLE_XML}</pmc-articleset>"
parsed = PMCParser()._parse_articles(xml)
assert len(parsed) == 2
assert all(p["id"] == "1234567" for p in parsed)
def test_parse_articles_malformed_xml_returns_empty():
assert PMCParser()._parse_articles("<not valid xml") == []

View File

@@ -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 )
"

View File

@@ -63,18 +63,33 @@ def run_arxiv(args: argparse.Namespace, output_dir: Path) -> None:
)
def run_pmc(args: argparse.Namespace, output_dir: Path) -> None:
from pmc import PMCParser
parser = PMCParser()
parser.run(
output_dir=output_dir,
query=args.query,
limit=args.limit,
year_from=args.year_from,
year_to=args.year_to,
)
def run_all(args: argparse.Namespace, output_dir: Path) -> None:
"""Запустить все парсеры последовательно."""
logger.info("Запуск всех парсеров...")
run_openalex(args, output_dir)
run_cyberleninka(args, output_dir)
run_arxiv(args, output_dir)
run_pmc(args, output_dir)
PARSERS = {
"openalex": run_openalex,
"cyberleninka": run_cyberleninka,
"arxiv": run_arxiv,
"pmc": run_pmc,
"all": run_all,
}

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Расширение корпуса вширь — новые дисциплины, которых ещё нет в parse_sources.
Продолжение scripts/seed_ru_sources.py: та заливка покрыла 30 базовых русских
дисциплин + был уже собран seed-корпус из ~26 английских тем (OpenAlex) и 4
категорий arXiv. Этот скрипт добавляет НОВЫЕ темы поверх уже сделанного —
углубление RU (специализированные подотрасли права/экономики/IT) и расширение EN
(область права/лингвистики/искусства/наук о Земле и т.д., которых ещё не было),
плюс новые категории arXiv.
Идемпотентно (по name, с префиксами ru2:/oa3:/arxiv2: — не пересекаются с уже
существующими ru:/corpus:/oa2:/arxiv:). Dry-run по умолчанию.
Запуск:
python scripts/seed_broad_corpus.py # план
python scripts/seed_broad_corpus.py --apply # записать в БД
"""
import argparse
import os
from pathlib import Path
# (name_suffix, source_type, query, lang, limit)
RU_DEEP = [ # углубление: специализированные RU-темы, которых не было в первой волне
"семейное право", "административное право", "уголовный процесс",
"гражданский процесс", "международное право", "предпринимательское право",
"налоговое право", "земельное право", "экологическое право",
"информационная безопасность", "искусственный интеллект", "нейронные сети",
"веб-разработка", "архитектура зданий", "дизайн", "музыкальное искусство",
"физическая культура", "спортивная медицина", "туризм", "логистика",
"инновационный менеджмент", "антикризисное управление", "аудит",
"страхование", "банковское дело", "инвестиции",
"внешнеэкономическая деятельность", "региональная экономика", "демография",
]
EN_OPENALEX_NEW = [ # области, которых не было в первой EN-волне
"law", "political science", "linguistics", "literature studies", "history",
"philosophy", "anthropology", "geography", "agriculture",
"business management", "finance", "architecture", "astronomy", "geology",
"pharmacology", "nursing", "veterinary science", "art history",
"music theory", "religious studies",
]
EN_ARXIV_NEW = [ # темы поиска arXiv, не пересекаются с уже сделанными 4
"natural language processing", "reinforcement learning", "quantum physics",
"particle physics", "condensed matter physics", "number theory",
"statistics methodology", "signal processing", "distributed systems",
"software engineering", "bioinformatics", "econometrics", "optimization",
"graph theory", "information theory",
]
def _load_env() -> None:
env = Path(__file__).resolve().parent.parent / ".env"
if not env.exists():
return
for line in env.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
os.environ.setdefault(key.strip(), val.strip())
def _pg_dsn() -> dict:
return {
"host": os.environ.get("POSTGRES_HOST", "localhost"),
"port": int(os.environ.get("POSTGRES_PORT", "5432")),
"dbname": os.environ.get("POSTGRES_DB", "antiplagiator"),
"user": os.environ.get("POSTGRES_USER", "antiplagiator"),
"password": os.environ.get("POSTGRES_PASSWORD", ""),
}
def _build_rows(ru_limit: int, en_limit: int, arxiv_limit: int) -> list[tuple]:
rows = []
rows += [(f"ru2:{t}", "cyberleninka", t, "ru", ru_limit) for t in RU_DEEP]
rows += [(f"oa3:{t}", "openalex", t, None, en_limit) for t in EN_OPENALEX_NEW]
rows += [(f"arxiv2:{t}", "arxiv", t, None, arxiv_limit) for t in EN_ARXIV_NEW]
return rows
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true", help="реально записать в БД (иначе dry-run)")
ap.add_argument("--ru-limit", type=int, default=500)
ap.add_argument("--en-limit", type=int, default=1000)
ap.add_argument("--arxiv-limit", type=int, default=800)
args = ap.parse_args()
_load_env()
rows = _build_rows(args.ru_limit, args.en_limit, args.arxiv_limit)
total_potential = sum(r[4] for r in rows)
print(f"Новых источников: {len(rows)} "
f"(RU углубление {len(RU_DEEP)}, EN OpenAlex {len(EN_OPENALEX_NEW)}, "
f"arXiv {len(EN_ARXIV_NEW)})")
print(f"Потенциальный объём (верхняя граница): ~{total_potential} документов")
if not args.apply:
print("\n[dry-run] запустите с --apply, чтобы записать в parse_sources.")
for name, stype, q, _lang, lim in rows[:10]:
print(f" {name:32} [{stype:12}] limit={lim} → {q!r}")
print(f" ... и ещё {len(rows) - 10}")
return
import psycopg2
conn = psycopg2.connect(**_pg_dsn())
conn.autocommit = True
created, skipped, ids = 0, 0, []
with conn.cursor() as cur:
for name, stype, query, lang, limit in rows:
cur.execute("SELECT id FROM parse_sources WHERE name = %s", (name,))
if cur.fetchone():
skipped += 1
continue
cur.execute(
'INSERT INTO parse_sources '
'(source_type, name, query, lang, "limit", enabled, last_status, docs_added) '
"VALUES (%s, %s, %s, %s, %s, TRUE, 'idle', 0) RETURNING id",
(stype, name, query, lang, limit),
)
ids.append(cur.fetchone()[0])
created += 1
conn.close()
print(f"\nСоздано: {created}, пропущено (уже были): {skipped}")
print(f"ID новых источников: {ids}")
if __name__ == "__main__":
main()

View File

@@ -66,6 +66,12 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {
<p className="text-sm text-gray-600 mt-1">
Проверено фрагментов: {data.total_fragments} · Выявлено совпадений: {data.flagged_fragments}
</p>
{!!data.cited_fragments && (
<p className="text-sm text-gray-500 mt-0.5">
из них корректно процитировано: {data.cited_fragments} · без указания источника:{' '}
{data.uncited_similarity?.toFixed(1)}%
</p>
)}
</div>
</div>
@@ -125,6 +131,14 @@ export function PlagiarismReport({ data }: PlagiarismReportProps) {
<span className="text-xs text-gray-400 px-2 py-0.5 bg-white rounded-full border border-gray-200">
{METHOD_LABELS[match.method] || match.method}
</span>
{match.cited && (
<span
className="text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-700"
title="Похоже на корректно оформленную цитату (кавычки или ссылка с годом рядом)"
>
Цитата
</span>
)}
</div>
{/* Фрагмент */}
<div className="px-4 py-3">

View File

@@ -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;

View File

@@ -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),

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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,
@@ -501,6 +552,14 @@ def run_parser(source_id: int) -> dict[str, Any]:
"limit": cfg["limit"],
"year_from": cfg.get("year_from"),
}
elif stype == "pmc":
from pmc import PMCParser as P
fetch_kwargs = {
"query": cfg.get("query") or "",
"limit": cfg["limit"],
"year_from": cfg.get("year_from"),
"year_to": cfg.get("year_to"),
}
else:
raise ValueError(f"неизвестный тип источника: {stype}")

View File

@@ -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"