Первый прогон нового конвейера показал слабое место: листинг бакета шёл с начала, и заливка часами перемалывала уже существующие статьи как дубли — из 300 полученных 300 оказались дублями. S3 умеет start-after, и стартовый ключ выводится прямо из базы: ext_id вида `pmc:PMC10000000` соответствует ключу `PMC10000000.1`, бакет отдаётся лексикографически. Теперь при отсутствии сохранённого токена (первый прогон после ручных заливок) листинг начинается после максимального залитого. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Академический помощник
Микросервисная система антиплагиата и поиска академических источников.
Студент вводит тему → система ищет источники → проверяет плагиат → форматирует ГОСТ-библиографию. Всё асинхронно: студент закрыл браузер, получил email когда готово.
📐 Полное описание системы — docs/ARCHITECTURE.md. Отказоустойчивость и восстановление — docs/DR-HA.md.
Архитектура
┌─────────────────┐
│ Frontend │ React + Vite + TypeScript
│ (React SPA) │
└────────┬────────┘
│ HTTPS
┌────────▼────────┐
│ API Gateway │ FastAPI, порт 8000
│ (FastAPI) │ JWT, Rate Limit, WebSocket
└──┬─────────┬───┘
│ RabbitMQ│ Celery задачи
┌────────────▼──┐ ┌──▼──────────────┐
│ worker-gpu │ │ worker-indexer │
│ (FAISS, CPU) │ │ (PDF/DOCX parse) │
│ Sem. search │ │ Winnowing/MinHash │
│ LLM paraphrase│ └──────────────────┘
└────────────────┘
│
┌─────────▼──────────┐ ┌────────────────────┐
│ worker-notifier │ │ worker-gost │
│ (SMTP email) │ │ (ГОСТ 7.1/7.0.5) │
└────────────────────┘ └────────────────────┘
Инфраструктура:
PostgreSQL 16 · Redis 7 · RabbitMQ 3 · Elasticsearch 8
MinIO (S3) · Ollama с bge-m3 на отдельном сервере эмбеддингов (192.168.1.40)
LLM-парафраз (L4) — Ollama или OpenRouter, переключается LLM_BACKEND
Быстрый старт
# 1. Клонировать репозиторий
git clone https://github.com/jze9/anti-plagiarism.git
cd anti-plagiarism
# 2. Создать файл конфигурации
cp .env.example .env
# Отредактировать .env — сменить пароли и ключи!
# 3. Запустить в режиме разработки
make dev
# 4. Применить миграции базы данных
make migrate-dev
# 5. Создать ES индекс
make es-init
# 6. Открыть браузер
# Frontend: http://localhost:5173
# API docs: http://localhost:8000/api/docs
# RabbitMQ: http://localhost:15672 (guest/guest)
# Flower: http://localhost:5555
# MinIO: http://localhost:9001
Команды
make dev # Запуск в dev режиме (hot reload)
make build # Сборка Docker образов
make up # Запуск в продакшн режиме
make down # Остановить все сервисы
make migrate # Применить Alembic миграции
make logs # Логи всех сервисов
make shell-api # Shell в контейнере API
make shell-gpu # Shell в контейнере GPU воркера
make lint # ruff + mypy в контейнере (тот же гейт, что в CI)
make test # юнит-тесты всех сервисов в контейнерах
make clean # Удалить контейнеры и volumes
Стек технологий
| Компонент | Технологии |
|---|---|
| API Gateway | FastAPI 0.111, Python 3.11, SQLAlchemy 2.0, Alembic |
| GPU Worker | FAISS-CPU (IndexIDMap2 · IndexFlatIP), эмбеддинги bge-m3/1024d (Ollama/sentence-transformers/облако — EMBED_BACKEND), LLM-парафраз через Ollama или OpenRouter (LLM_BACKEND) |
| Indexer | PyMuPDF, python-docx, Winnowing, MinHash LSH |
| Очереди | RabbitMQ (брокер) + Celery 5 (воркеры) + Redis (результаты) |
| База данных | PostgreSQL 16 |
| Поиск | Elasticsearch 8 (BM25) + FAISS (cosine, IndexFlatIP) |
| Хранилище | MinIO (S3-совместимый) |
| Frontend | React 18, Vite, TypeScript, TailwindCSS, Zustand, React Query v5 |
Проверка плагиата (4 уровня)
- Winnowing — точные/частичные совпадения по fingerprint'ам (xxHash + скользящее окно)
- MinHash LSH — нечёткие совпадения (шинглы + Jaccard, индекс в общем Redis)
- FAISS cosine — семантическое сходство (порог 0.75; эмбеддинги bge-m3/1024d, IndexFlatIP на нормированных векторах)
- LLM-анализ парафраза (порог confidence 0.7) — бэкенд переключается
LLM_BACKEND: локальная Ollama или облачный OpenRouter/DeepSeek (геоблокировка РФ обходится через SOCKS5-проксиsingbox-proxy, см.infra/singbox/)
Проверенные работы сами пополняют корпус (source=user_submission) — это даёт эффект
как у коммерческих систем (база растёт от каждой проверки), но такие документы
исключены из сравнения на всех уровнях L1-L3 (AUTO_APPROVE_SUBMISSIONS,
по умолчанию выключено), иначе работа матчилась бы сама с собой на 100%.
Тарифные планы
| Тариф | Цена | Поиск/день | Изложений/мес | Плагиат/мес | Одновременно |
|---|---|---|---|---|---|
| Бесплатный | 0₽ | 10 | 3 | 1 | 1 |
| Студенческий | 199₽ | безлимит | 30 | 10 | 2 |
| Премиум | 499₽ | безлимит | безлимит | 50 | 5 |
| Научный | 999₽ | безлимит | безлимит | безлимит | 10 |
Парсеры источников
# Запарсить OpenAlex
python scripts/run_parser.py openalex \
--query "машинное обучение" \
--limit 10000 \
--output /data/processed
# КиберЛенинка
python scripts/run_parser.py cyberleninka \
--query "нейронные сети" \
--limit 1000
# arXiv
python scripts/run_parser.py arxiv \
--query "deep learning" \
--categories cs.AI cs.LG \
--limit 5000
# PubMed Central (PMC) — англоязычные научные статьи открытого доступа
python scripts/run_parser.py pmc \
--query "public health" \
--limit 1000
Массовое расширение корпуса по дисциплинам (не единичный запрос) — scripts/seed_broad_corpus.py
и scripts/seed_ru_sources.py заводят десятки parse_sources записей сразу, дальше их
разбирает index.run_parser через очередь. Подробности и текущий охват — docs/INGESTION.md.
Переменные окружения
Смотри .env.example для полного списка переменных.
Обязательно смените SECRET_KEY, POSTGRES_PASSWORD, MINIO_SECRET_KEY.
Три docker-compose файла
| Файл | Назначение |
|---|---|
docker-compose.prod.yml |
Реальный прод. app-сервисы + локальный Elasticsearch. Postgres/Redis/RabbitMQ/MinIO/Ollama — уже существующие общие серверы, адреса в .env. Файл описывает и сервис nginx (собранный фронтенд + TLS), но в текущей топологии он не запускается — фронтенд и TLS реально раздаёт хостовой nginx на отдельном CT 102 (см. «Продакшн деплой» ниже), а api публикует 8000:8000 наружу именно под него. |
docker-compose.test.yml |
Повседневная разработка — hot reload против той же общей инфры, что и прод (make test-up). |
docker-compose.selfhosted.yml.example |
Не используется. Полностью автономный вариант (свои Postgres/Redis/RabbitMQ/ES/MinIO/Ollama + GPU passthrough) — на случай отдельного выделенного сервера в будущем. |
Продакшн деплой
Автоматический: push в main → Gitea Actions (.gitea/workflows/deploy.yml) → гейт
test (lint + юнит-тесты) → deploy → scripts/deploy.sh на app-хосте. Умная
пересборка — образ пересобирается только у сервисов, чей код изменился с прошлого
деплоя (маркер SHA в .last_deploy_sha); правка docker-compose.prod.yml триггерит
полную пересборку всех пяти бэкенд-сервисов.
.env на проде НЕ редактируется руками. Первым шагом deploy.sh логинится в
self-hosted Infisical (Machine Identity, Universal Auth) и генерирует .env заново
из окружения prod на каждом запуске — ручные правки файла на сервере переживут
максимум до следующего деплоя. Менять секреты/конфиг — через Infisical
(https://infisical.jze9.ru, проект academ), не через .env напрямую. Если
Infisical недоступен или вернул подозрительно мало ключей, деплой падает раньше
синка кода и не трогает рабочий .env.
Фронтенд собирается отдельно (docker run node:20-slim → npm run build) и
заливается по scp/pct push на CT 102, где раздаётся хостовым (не
докеризованным) nginx с TLS через certbot — см. /etc/nginx/sites-available/academic
на CT 102. Это отдельная машина от app-хоста, деплоится только когда меняется
services/frontend/.
Ручной запуск деплоя (например, после смены секрета в Infisical без изменений кода) —
workflow_dispatch в Gitea Actions, или локально: PVE_PASSWORD=... INFISICAL_CLIENT_ID=... INFISICAL_CLIENT_SECRET=... bash scripts/deploy.sh из полного чекаута репозитория.
Векторный бэкенд (FAISS / Qdrant)
Семантический индекс (уровень 3) спрятан за app.vector_store.get_backend() и
переключается настройкой VECTOR_BACKEND — без изменения кода:
faiss(по умолчанию) — файловыйIndexIDMap2(IndexFlatIP)в RAM воркера. Просто, но это единая точка отказа и без конкурентной записи.qdrant— сетевой сервис: снимает SPOF, допускает конкурентный upsert из нескольких воркеров, переживает рестарт, масштабируется горизонтально.
Переключение на Qdrant (аддитивно, ничего не ломает до шага 2):
# 1. Поднять Qdrant (профиль qdrant в docker-compose.prod.yml)
docker compose -f docker-compose.prod.yml --profile qdrant up -d qdrant
# 2. В .env выставить VECTOR_BACKEND=qdrant (QDRANT_URL по умолчанию http://qdrant:6333)
# 3. Перелить существующие векторы FAISS → Qdrant (идемпотентно)
docker compose -f docker-compose.prod.yml exec worker-gpu python -m app.migrate_faiss_to_qdrant
# 4. Перезапустить GPU-воркер
docker compose -f docker-compose.prod.yml up -d worker-gpu
Наблюдаемость (Prometheus + Grafana)
Опционально (профиль observability, по умолчанию не поднимается):
docker compose -f docker-compose.prod.yml --profile observability up -d prometheus grafana
- API отдаёт HTTP-метрики на
/metrics(кол-во и латентность запросов по хендлерам). - Prometheus (
infra/prometheus/prometheus.yml) скрейпит API и Flower — из Flower приходят метрики Celery (задачи, время выполнения, воркеры) без доп. кода. - Grafana с автоподключённым источником Prometheus (
infra/grafana/provisioning/); пароль admin —GRAFANA_ADMIN_PASSWORDв Infisical (prod). Если не задан — падает на дефолтadmin, поэтому перед включением профиляobservabilityв проде убедиться, что значение в Infisical реально установлено (не пустое).
Тестирование и качество кода
Перед деплоем CI (.gitea/workflows/deploy.yml, job test) прогоняет два гейта,
и deploy стартует, только если оба зелёные — кривой код в прод не уезжает:
- Линт —
ruff(весь Python) +mypy(чистая доменная логика). Конфиги:ruff.toml,mypy.ini. - Юнит-тесты —
pytestпо сервисам: 150 тестов на ядро детекции, скоринга, парсеров, форматирования, OAuth и прогресса заливки, без внешней инфры (БД/Redis/GPU/Ollama замоканы либо не нужны).
make lint # ruff + mypy в изолированном контейнере
make lint-fix # авто-исправления ruff
make test # все юнит-тесты в контейнерах
make test-one SVC=worker-gost # тесты одного сервиса
Всё гоняется в python:3.11-slim (не засоряя хост) через
scripts/run_lint.sh и scripts/run_tests.sh.
Что покрыто. Чистая логика вынесена из Celery-задач в отдельные тестируемые модули (доменная логика отдельно от оркестрации):
| Слой | Модуль | Тестов |
|---|---|---|
| L1 — точные совпадения | worker-indexer/app/algorithms/winnowing.py |
13 |
| L2 — нечёткие (MinHash LSH) | worker-indexer/app/algorithms/minhash.py |
6 |
| Разбиение на фрагменты | worker-indexer/app/fragments.py |
5 |
| Автопополнение корпуса | worker-indexer/app/staging.py |
6 |
| Извлечение текста из PDF | worker-indexer/app/extractors/pdf.py |
4 |
| 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 |
18 |
| ГОСТ 7.1 / 7.0.5 | worker-gost/app/formatters/ |
17 |
| Список литературы | worker-gost/app/bibliography.py |
7 |
| Парсеры источников (CyberLeninka, PMC, прогресс-колбэк) | scripts/parsers/ |
19 |
| OAuth-ссылки (Google/Яндекс) | api/app/core/oauth.py |
6 |
| Прогресс заливки (счётчики, бюджет) | worker-indexer/app/progress.py |
7 |
| Шкала загрузки и тайминги прогонов | api/app/core/progress.py, schemas/admin.py |
11 |
Лицензия
MIT