Тесты на queued_s/duration_s фиксируют ровно ту путаницу, из-за которой метрика и разъехалась: ожидание в очереди и время работы — разные величины, а у прогонов до миграции 006 длительности просто нет (вместо неё раньше показывалось время в очереди). Панель отладки теперь показывает, сколько идущий прогон уже работает — по этому и виден застрявший, а не только по отсутствию heartbeat. README: фактические числа тестов (150, проверено прогоном run_tests.sh). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
17 KiB
Markdown
273 lines
17 KiB
Markdown
# Академический помощник
|
||
|
||
Микросервисная система антиплагиата и поиска академических источников.
|
||
|
||
Студент вводит тему → система ищет источники → проверяет плагиат → форматирует ГОСТ-библиографию.
|
||
Всё асинхронно: студент закрыл браузер, получил email когда готово.
|
||
|
||
> 📐 Полное описание системы — [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||
> Отказоустойчивость и восстановление — [docs/DR-HA.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 │
|
||
│ (CUDA/FAISS) │ │ (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 (qwen2.5:7b) · отдельный GPU-сервер
|
||
```
|
||
|
||
## Быстрый старт
|
||
|
||
```bash
|
||
# 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
|
||
```
|
||
|
||
## Команды
|
||
|
||
```bash
|
||
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 уровня)
|
||
|
||
1. **Winnowing** — точные/частичные совпадения по fingerprint'ам (xxHash + скользящее окно)
|
||
2. **MinHash LSH** — нечёткие совпадения (шинглы + Jaccard, индекс в общем Redis)
|
||
3. **FAISS cosine** — семантическое сходство (порог 0.75; эмбеддинги bge-m3/1024d, IndexFlatIP на нормированных векторах)
|
||
4. **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 |
|
||
|
||
## Парсеры источников
|
||
|
||
```bash
|
||
# Запарсить 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](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):
|
||
|
||
```bash
|
||
# 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`, по умолчанию не поднимается):
|
||
|
||
```bash
|
||
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` стартует, только если оба зелёные — кривой код в прод не уезжает:
|
||
|
||
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
|
||
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
|
||
2. **Юнит-тесты** — `pytest` по сервисам: 150 тестов на ядро детекции, скоринга,
|
||
парсеров, форматирования, OAuth и прогресса заливки, без внешней инфры
|
||
(БД/Redis/GPU/Ollama замоканы либо не нужны).
|
||
|
||
```bash
|
||
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_lint.sh) и [`scripts/run_tests.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
|