Compare commits
14 Commits
a2d3625f2a
...
6099ef621f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6099ef621f | ||
|
|
9d005486df | ||
|
|
e6c44f30dd | ||
|
|
d32a07d0a8 | ||
|
|
d2d99d8231 | ||
|
|
012b17304f | ||
|
|
37ee18ac55 | ||
|
|
ccc3521e74 | ||
|
|
b2d2061a83 | ||
|
|
4c15f11efa | ||
|
|
426796a9a7 | ||
|
|
e79f254af6 | ||
|
|
2daaa8c8a4 | ||
|
|
ecc10a4413 |
@@ -39,3 +39,11 @@ SMTP_TLS_VERIFY=true
|
|||||||
# App
|
# App
|
||||||
APP_URL=https://academic.jze9.ru
|
APP_URL=https://academic.jze9.ru
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Векторный бэкенд (опционально): faiss (по умолчанию) или qdrant. См. README.
|
||||||
|
VECTOR_BACKEND=faiss
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
QDRANT_COLLECTION=documents
|
||||||
|
|
||||||
|
# Наблюдаемость (профиль observability, опционально)
|
||||||
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
|
|||||||
@@ -13,7 +13,35 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: deploy
|
||||||
|
steps:
|
||||||
|
- name: Клонировать репозиторий (depth 1 — для тестов история не нужна)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SRC="$(mktemp -d)"
|
||||||
|
echo "SRC=$SRC" >> "$GITHUB_ENV"
|
||||||
|
git clone --branch main --depth 1 \
|
||||||
|
https://gitea.jze9.ru/jze9/anti-plagiarism.git "$SRC"
|
||||||
|
|
||||||
|
- name: Линт (ruff + mypy) — быстрый гейт, падаем раньше тестов
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$SRC"
|
||||||
|
bash scripts/run_lint.sh
|
||||||
|
|
||||||
|
- name: Юнит-тесты (L1 winnowing, L2 minhash, L3 FAISS, ГОСТ) в контейнерах
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$SRC"
|
||||||
|
bash scripts/run_tests.sh
|
||||||
|
|
||||||
|
- name: Убрать временный чекаут
|
||||||
|
if: always()
|
||||||
|
run: rm -rf "${SRC:-/tmp/none}"
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
|
needs: test # деплой только если юнит-тесты прошли
|
||||||
runs-on: deploy
|
runs-on: deploy
|
||||||
steps:
|
steps:
|
||||||
- name: Клонировать репозиторий (полностью — нужен git-лог для diff)
|
- name: Клонировать репозиторий (полностью — нужен git-лог для diff)
|
||||||
|
|||||||
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean test-up test-down test-logs test-ps
|
.PHONY: dev build migrate logs shell-api shell-gpu lint lint-fix test test-one down ps restart clean test-up test-down test-logs test-ps
|
||||||
|
|
||||||
# ─── Переменные ────────────────────────────────────────────────────────────────
|
# ─── Переменные ────────────────────────────────────────────────────────────────
|
||||||
# Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
|
# Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
|
||||||
@@ -100,18 +100,25 @@ shell-redis:
|
|||||||
@echo "Redis общий (192.168.1.19) — подключайся напрямую: redis-cli -h 192.168.1.19 -n 3"
|
@echo "Redis общий (192.168.1.19) — подключайся напрямую: redis-cli -h 192.168.1.19 -n 3"
|
||||||
|
|
||||||
# ─── Линтинг и тесты ──────────────────────────────────────────────────────────
|
# ─── Линтинг и тесты ──────────────────────────────────────────────────────────
|
||||||
|
# Ruff-линт всего Python-кода в изолированном контейнере (конфиг — ruff.toml).
|
||||||
|
# Тот же скрипт гоняет CI как гейт перед деплоем. Автофиксы: make lint-fix.
|
||||||
lint:
|
lint:
|
||||||
$(COMPOSE_PROD) exec api ruff check . --fix
|
bash scripts/run_lint.sh
|
||||||
$(COMPOSE_PROD) exec api mypy app/
|
|
||||||
|
lint-fix:
|
||||||
|
ruff check services/ scripts/ --fix
|
||||||
|
|
||||||
lint-frontend:
|
lint-frontend:
|
||||||
cd services/frontend && npm run lint
|
cd services/frontend && npm run lint
|
||||||
|
|
||||||
|
# Юнит-тесты чистой логики (L1/L2/L3 + ГОСТ) в изолированных контейнерах —
|
||||||
|
# без БД/Redis/GPU. Тот же скрипт гоняет CI как гейт перед деплоем.
|
||||||
test:
|
test:
|
||||||
$(COMPOSE_PROD) exec api pytest tests/ -v
|
bash scripts/run_tests.sh
|
||||||
|
|
||||||
test-cov:
|
# Тесты одного сервиса, напр.: make test-one SVC=worker-gost
|
||||||
$(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html
|
test-one:
|
||||||
|
bash scripts/run_tests.sh $(SVC)
|
||||||
|
|
||||||
# ─── Утилиты ──────────────────────────────────────────────────────────────────
|
# ─── Утилиты ──────────────────────────────────────────────────────────────────
|
||||||
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —
|
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -5,6 +5,9 @@
|
|||||||
Студент вводит тему → система ищет источники → проверяет плагиат → форматирует ГОСТ-библиографию.
|
Студент вводит тему → система ищет источники → проверяет плагиат → форматирует ГОСТ-библиографию.
|
||||||
Всё асинхронно: студент закрыл браузер, получил email когда готово.
|
Всё асинхронно: студент закрыл браузер, получил email когда готово.
|
||||||
|
|
||||||
|
> 📐 Полное описание системы — [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||||
|
> Отказоустойчивость и восстановление — [docs/DR-HA.md](docs/DR-HA.md).
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -32,7 +35,7 @@
|
|||||||
|
|
||||||
Инфраструктура:
|
Инфраструктура:
|
||||||
PostgreSQL 16 · Redis 7 · RabbitMQ 3 · Elasticsearch 8
|
PostgreSQL 16 · Redis 7 · RabbitMQ 3 · Elasticsearch 8
|
||||||
MinIO (4TB) · Ollama (llama3:8b) · NVIDIA RTX 2060
|
MinIO (S3) · Ollama (qwen2.5:7b) · отдельный GPU-сервер
|
||||||
```
|
```
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
@@ -74,8 +77,8 @@ make migrate # Применить Alembic миграции
|
|||||||
make logs # Логи всех сервисов
|
make logs # Логи всех сервисов
|
||||||
make shell-api # Shell в контейнере API
|
make shell-api # Shell в контейнере API
|
||||||
make shell-gpu # Shell в контейнере GPU воркера
|
make shell-gpu # Shell в контейнере GPU воркера
|
||||||
make lint # Запустить линтер
|
make lint # ruff + mypy в контейнере (тот же гейт, что в CI)
|
||||||
make test # Запустить тесты
|
make test # юнит-тесты всех сервисов в контейнерах
|
||||||
make clean # Удалить контейнеры и volumes
|
make clean # Удалить контейнеры и volumes
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,20 +87,20 @@ make clean # Удалить контейнеры и volumes
|
|||||||
| Компонент | Технологии |
|
| Компонент | Технологии |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| API Gateway | FastAPI 0.111, Python 3.11, SQLAlchemy 2.0, Alembic |
|
| API Gateway | FastAPI 0.111, Python 3.11, SQLAlchemy 2.0, Alembic |
|
||||||
| GPU Worker | sentence-transformers, FAISS-GPU (IVFFlat), Ollama (llama3:8b) |
|
| GPU Worker | sentence-transformers, FAISS-CPU (IndexIDMap2 · IndexFlatIP), Ollama (qwen2.5:7b) |
|
||||||
| Indexer | PyMuPDF, python-docx, Winnowing, MinHash LSH |
|
| Indexer | PyMuPDF, python-docx, Winnowing, MinHash LSH |
|
||||||
| Очереди | RabbitMQ (брокер) + Celery 5 (воркеры) + Redis (результаты) |
|
| Очереди | RabbitMQ (брокер) + Celery 5 (воркеры) + Redis (результаты) |
|
||||||
| База данных | PostgreSQL 16 |
|
| База данных | PostgreSQL 16 |
|
||||||
| Поиск | Elasticsearch 8 (BM25) + FAISS GPU (cosine) |
|
| Поиск | Elasticsearch 8 (BM25) + FAISS (cosine, IndexFlatIP) |
|
||||||
| Хранилище | MinIO (S3-совместимый) |
|
| Хранилище | MinIO (S3-совместимый) |
|
||||||
| Frontend | React 18, Vite, TypeScript, TailwindCSS, Zustand, React Query v5 |
|
| Frontend | React 18, Vite, TypeScript, TailwindCSS, Zustand, React Query v5 |
|
||||||
|
|
||||||
## Проверка плагиата (4 уровня)
|
## Проверка плагиата (4 уровня)
|
||||||
|
|
||||||
1. **Winnowing + MinHash** (~мс) — точное совпадение по fingerprints
|
1. **Winnowing** — точные/частичные совпадения по fingerprint'ам (xxHash + скользящее окно)
|
||||||
2. **n-граммы + Jaccard** (~сек) — нечёткое совпадение
|
2. **MinHash LSH** — нечёткие совпадения (шинглы + Jaccard, индекс в общем Redis)
|
||||||
3. **FAISS GPU cosine** (~мс) — семантическое сходство (порог 0.75)
|
3. **FAISS cosine** — семантическое сходство (порог 0.75; IndexFlatIP на нормированных эмбеддингах)
|
||||||
4. **Ollama Llama3** (~2сек) — LLM анализ парафраза (confidence > 0.7)
|
4. **Ollama qwen2.5:7b** — LLM-анализ парафраза (порог confidence 0.7)
|
||||||
|
|
||||||
## Тарифные планы
|
## Тарифные планы
|
||||||
|
|
||||||
@@ -159,6 +162,79 @@ make migrate
|
|||||||
|
|
||||||
Nginx работает внутри контейнера (`docker-compose.prod.yml`, сервис `nginx`, Dockerfile в `infra/nginx/Dockerfile`) — собирает `services/frontend` в статику и отдаёт её вместе с проксированием `/api/`, `/ws/` на `api:8000` по конфигу `infra/nginx/nginx.conf`. Контейнер монтирует `/etc/letsencrypt` с хоста как read-only — сертификат обновляется на хосте (`certbot renew`), контейнер просто читает его.
|
Nginx работает внутри контейнера (`docker-compose.prod.yml`, сервис `nginx`, Dockerfile в `infra/nginx/Dockerfile`) — собирает `services/frontend` в статику и отдаёт её вместе с проксированием `/api/`, `/ws/` на `api:8000` по конфигу `infra/nginx/nginx.conf`. Контейнер монтирует `/etc/letsencrypt` с хоста как read-only — сертификат обновляется на хосте (`certbot renew`), контейнер просто читает его.
|
||||||
|
|
||||||
|
## Векторный бэкенд (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` в `.env` (по умолчанию `admin`).
|
||||||
|
|
||||||
|
## Тестирование и качество кода
|
||||||
|
|
||||||
|
Перед деплоем CI (`.gitea/workflows/deploy.yml`, job `test`) прогоняет два гейта,
|
||||||
|
и `deploy` стартует, только если оба зелёные — кривой код в прод не уезжает:
|
||||||
|
|
||||||
|
1. **Линт** — `ruff` (весь Python) + `mypy` (чистая доменная логика).
|
||||||
|
Конфиги: [`ruff.toml`](ruff.toml), [`mypy.ini`](mypy.ini).
|
||||||
|
2. **Юнит-тесты** — `pytest` по сервисам: 73 теста на ядро детекции и форматирования,
|
||||||
|
без внешней инфры (БД/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 |
|
||||||
|
| L3 — семантический индекс | `worker-gpu/app/faiss_manager.py` | 5 |
|
||||||
|
| L4 — LLM-парафраз | `worker-gpu/app/ollama_client.py` | 12 |
|
||||||
|
| Итоговый % плагиата | `worker-gpu/app/scoring.py` | 8 |
|
||||||
|
| ГОСТ 7.1 / 7.0.5 | `worker-gost/app/formatters/` | 17 |
|
||||||
|
| Список литературы | `worker-gost/app/bibliography.py` | 7 |
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
es_prod_data:
|
es_prod_data:
|
||||||
faiss_index_prod:
|
faiss_index_prod:
|
||||||
|
qdrant_prod_data:
|
||||||
|
prometheus_data:
|
||||||
|
grafana_data:
|
||||||
|
|
||||||
x-app-env: &app-env
|
x-app-env: &app-env
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -49,6 +52,51 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
|
# Qdrant — альтернативный векторный бэкенд (снимает SPOF файлового FAISS).
|
||||||
|
# Опционален: поднимается только с профилем и активируется VECTOR_BACKEND=qdrant.
|
||||||
|
# docker compose -f docker-compose.prod.yml --profile qdrant up -d qdrant
|
||||||
|
# docker compose -f docker-compose.prod.yml exec worker-gpu python -m app.migrate_faiss_to_qdrant
|
||||||
|
# Тег сервера при необходимости поднять до версии клиента (qdrant-client 1.19).
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:v1.12.4
|
||||||
|
container_name: antiplagiator-qdrant
|
||||||
|
profiles: ["qdrant"]
|
||||||
|
volumes:
|
||||||
|
- qdrant_prod_data:/qdrant/storage
|
||||||
|
networks:
|
||||||
|
- antiplagiator
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ─── Наблюдаемость (опционально; профиль observability) ────────────────────
|
||||||
|
# docker compose -f docker-compose.prod.yml --profile observability up -d prometheus grafana
|
||||||
|
# Prometheus скрейпит api:/metrics и flower:/metrics (метрики Celery).
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.54.1
|
||||||
|
container_name: antiplagiator-prometheus
|
||||||
|
profiles: ["observability"]
|
||||||
|
volumes:
|
||||||
|
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
networks:
|
||||||
|
- antiplagiator
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.2.0
|
||||||
|
container_name: antiplagiator-grafana
|
||||||
|
profiles: ["observability"]
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
volumes:
|
||||||
|
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
networks:
|
||||||
|
- antiplagiator
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
# ─── Приложение ────────────────────────────────────────────────────────────
|
# ─── Приложение ────────────────────────────────────────────────────────────
|
||||||
# RabbitMQ вынесен на отдельный сервер 192.168.20.82 (см. RABBITMQ_URL в .env)
|
# RabbitMQ вынесен на отдельный сервер 192.168.20.82 (см. RABBITMQ_URL в .env)
|
||||||
api:
|
api:
|
||||||
|
|||||||
190
docs/ARCHITECTURE.md
Normal file
190
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Архитектура — Академический помощник
|
||||||
|
|
||||||
|
Каноничное описание системы. Обновляется вместе с кодом; при расхождении верить
|
||||||
|
коду, а не этому файлу. Смежные документы: [DR-HA.md](DR-HA.md) (отказоустойчивость),
|
||||||
|
[../README.md](../README.md) (быстрый старт и команды).
|
||||||
|
|
||||||
|
## 1. Назначение
|
||||||
|
|
||||||
|
Веб-сервис для студентов: ввёл тему → система находит **открытые** академические
|
||||||
|
источники, делает изложения, форматирует список литературы по ГОСТ и проверяет текст
|
||||||
|
на плагиат. Всё **асинхронно**: пользователь отправляет задачу, закрывает браузер и
|
||||||
|
получает результат на email (или в реальном времени через WebSocket, если вкладка открыта).
|
||||||
|
|
||||||
|
## 2. Общая схема
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ Frontend (React SPA) │ CT 102 → nginx
|
||||||
|
│ Home/Search/Check/Cabinet/ │
|
||||||
|
│ Bibliography/Settings/Admin │
|
||||||
|
└──────────────┬──────────────┘
|
||||||
|
│ HTTPS (/api, /ws)
|
||||||
|
┌──────────────▼──────────────┐
|
||||||
|
│ API Gateway (FastAPI) │ 1.32:8000
|
||||||
|
│ JWT · rate-limit · WebSocket │
|
||||||
|
│ /metrics (Prometheus) │
|
||||||
|
└───┬───────────────────────┬──┘
|
||||||
|
│ publish (RabbitMQ) │ read/write
|
||||||
|
│ │
|
||||||
|
┌────────────────────┼───────────────────┐ │
|
||||||
|
▼ ▼ ▼ │
|
||||||
|
queue.index queue.gpu queue.gost
|
||||||
|
┌───────────┐ ┌────────────┐ ┌───────────┐
|
||||||
|
│ worker- │ │ worker-gpu │ │ worker- │
|
||||||
|
│ indexer │ │ FAISS/Qdr. │ │ gost │
|
||||||
|
│ L1 Winnow │ │ L3 семант. │ │ ГОСТ 7.1/ │
|
||||||
|
│ L2 MinHash│ │ L4 LLM │ │ 7.0.5 │
|
||||||
|
│ PDF/DOCX │ │ эмбеддинги │ │ │
|
||||||
|
└─────┬─────┘ └──────┬─────┘ └─────┬─────┘
|
||||||
|
│ │ │
|
||||||
|
└─────────┬─────────┴───────┬──────────┘
|
||||||
|
▼ ▼
|
||||||
|
queue.notify общие данные
|
||||||
|
┌────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ worker- │ │ PostgreSQL 1.38 · Redis 1.35 │
|
||||||
|
│ notifier │ │ RabbitMQ .82 · MinIO 1.21 │
|
||||||
|
│ SMTP email │ │ Elasticsearch (local 1.32) │
|
||||||
|
└────────────┘ │ Ollama .163 (qwen2.5:7b) │
|
||||||
|
│ [opt] Qdrant · Prometheus/Graf.│
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Сервисы
|
||||||
|
|
||||||
|
| Сервис | Технологии | Ответственность |
|
||||||
|
|--------|-----------|-----------------|
|
||||||
|
| **api** | FastAPI, SQLAlchemy async (asyncpg), Redis, Celery-producer | HTTP/WS API, auth (JWT), rate-limits, диспетч задач в очереди, админ-панель |
|
||||||
|
| **worker-indexer** | Celery, PyMuPDF, python-docx, xxhash, datasketch | Извлечение текста (PDF/DOCX/TXT), фрагментация, **L1 Winnowing**, **L2 MinHash LSH**, парсинг источников, обогащение full-text |
|
||||||
|
| **worker-gpu** | Celery, sentence-transformers, FAISS/Qdrant, httpx→Ollama | Эмбеддинги, **L3** семантический поиск, **L4** LLM-анализ парафраза, семантический поиск источников |
|
||||||
|
| **worker-gost** | Celery | Список литературы по **ГОСТ 7.1-2003 / Р 7.0.5-2008** |
|
||||||
|
| **worker-notifier** | Celery, smtplib | Email: письма-результаты и верификация (jze9mail.ru) |
|
||||||
|
| **frontend** | React 18, Vite, TS, Tailwind, Zustand, React Query | SPA: 11 публичных страниц + админ-панель. Собирается в статику, отдаётся nginx |
|
||||||
|
|
||||||
|
## 4. Модель данных (PostgreSQL)
|
||||||
|
|
||||||
|
- **users** — `email`, `hashed_password` (bcrypt), `name`, `is_verified`, `is_admin`,
|
||||||
|
`plan` (free/student/premium), `verification_token`.
|
||||||
|
- **tasks** — `id` (UUID), `public_id` (внешний), `user_id`, `type` (`TaskType`:
|
||||||
|
search/plagiarism/summarize/gost), `status` (`TaskStatus`:
|
||||||
|
queued→processing→done/failed), `celery_task_id`, `input_data` (JSON),
|
||||||
|
`result` (JSON), `error`, `queue_position`.
|
||||||
|
- **documents** — корпус источников: `source` (openalex/arxiv/cyberleninka/
|
||||||
|
user_submission), `ext_id`, `doi`, `title`, `authors` (JSON), `year`, `lang`,
|
||||||
|
`journal/volume/issue/pages`, `abstract`, `url`, `minio_key` (full-text в MinIO),
|
||||||
|
`faiss_id`.
|
||||||
|
- **fingerprints** — `doc_id`, `hash_value` (BIGINT, Winnowing), `position` — для L1.
|
||||||
|
- **usage_logs** — `user_id`, `action` — учёт лимитов по тарифу.
|
||||||
|
- **parse_sources** — задания парсеров (админка): тип, query, годы, лимит, статус.
|
||||||
|
- **staged_works** — пользовательские загрузки на модерацию перед добавлением в корпус.
|
||||||
|
- **admin_sessions** — одноразовые коды входа в админку.
|
||||||
|
|
||||||
|
## 5. Асинхронный конвейер (Celery + RabbitMQ)
|
||||||
|
|
||||||
|
Брокер — RabbitMQ, backend результатов — Redis. Маршрутизация по префиксу задачи:
|
||||||
|
|
||||||
|
| Очередь | Задачи | Воркер |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| `queue.index` | `index.extract_and_check`, `index.add_document`, `index.run_parser`, `index.enrich_full_text` | worker-indexer |
|
||||||
|
| `queue.gpu` | `gpu.check_plagiarism`, `gpu.embed_documents`, `gpu.search_semantic` | worker-gpu |
|
||||||
|
| `queue.gost` | `gost.format_bibliography` | worker-gost |
|
||||||
|
| `queue.notify` | `notify.send_task_done`, `notify.send_verification` | worker-notifier |
|
||||||
|
|
||||||
|
Важно: API **коммитит задачу в БД до** `send_task` (иначе гонка dispatch-before-commit).
|
||||||
|
|
||||||
|
## 6. Ключевые потоки
|
||||||
|
|
||||||
|
**Проверка плагиата.** upload (api, файл→MinIO, Task) → `index.extract_and_check`
|
||||||
|
(извлечь текст → фрагментация → **L1 Winnowing** по fingerprints → **L2 MinHash LSH**
|
||||||
|
в Redis) → передаёт частичные совпадения в `gpu.check_plagiarism` (**L3** FAISS/Qdrant
|
||||||
|
семантика по фрагментам → для подозрительных **L4** Ollama-парафраз) →
|
||||||
|
`app.scoring.aggregate_results` (итоговый %) → результат в Task → `notify.send_task_done`.
|
||||||
|
|
||||||
|
**Поиск источников.** api → `gpu.search_semantic`: эмбеддинг запроса → векторный поиск
|
||||||
|
(FAISS/Qdrant) + Elasticsearch BM25 → объединение → результат.
|
||||||
|
|
||||||
|
**Список литературы.** api → `gost.format_bibliography`: документы из БД →
|
||||||
|
`app.bibliography.build_bibliography` (сортировка кириллица→латиница, нумерация,
|
||||||
|
формат 7.1/7.0.5) → результат.
|
||||||
|
|
||||||
|
**Наполнение корпуса.** админка/CLI → `index.run_parser` (OpenAlex/arXiv/КиберЛенинка,
|
||||||
|
фильтр `is_oa`) → `index.add_document` (дедуп по `ext_id`, fingerprints, MinHash,
|
||||||
|
эмбеддинг) → `index.enrich_full_text` (скачать OA-PDF → MinIO → переиндексация).
|
||||||
|
|
||||||
|
## 7. Детекция плагиата — 4 уровня
|
||||||
|
|
||||||
|
1. **L1 Winnowing** (`worker-indexer/app/algorithms/winnowing.py`) — точные/частичные
|
||||||
|
совпадения: k-граммы → xxHash → минимум в скользящем окне → fingerprint; Jaccard.
|
||||||
|
2. **L2 MinHash LSH** (`.../minhash.py`) — нечёткие совпадения: шинглы → MinHash (128
|
||||||
|
перм.) → LSH-индекс в **общем Redis** (префикс `antiplag_lsh`, upsert, graceful-фолбэк
|
||||||
|
в память).
|
||||||
|
3. **L3 семантика** (`worker-gpu`) — эмбеддинги `paraphrase-multilingual-mpnet-base-v2`
|
||||||
|
(768d, нормированы) → cosine в FAISS `IndexIDMap2(IndexFlatIP)` **или** Qdrant
|
||||||
|
(`VECTOR_BACKEND`); порог 0.75.
|
||||||
|
4. **L4 LLM-парафраз** — Ollama `qwen2.5:7b` оценивает пары «источник↔фрагмент» для
|
||||||
|
подозрительных из L3; порог confidence 0.7.
|
||||||
|
|
||||||
|
Итог: `scoring.aggregate_results` — доля уникальных помеченных позиций (не выше 100%).
|
||||||
|
|
||||||
|
## 8. Векторный бэкенд
|
||||||
|
|
||||||
|
Абстрагирован за `worker-gpu/app/vector_store.get_backend()`; `VECTOR_BACKEND=faiss`
|
||||||
|
(файловый синглтон, дефолт) или `qdrant` (сервис, снимает SPOF/конкурентную запись).
|
||||||
|
Переключение и миграция — см. README, раздел «Векторный бэкенд».
|
||||||
|
|
||||||
|
## 9. Инфраструктура и топология
|
||||||
|
|
||||||
|
| Компонент | Узел | Примечание |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| api + воркеры + Elasticsearch | 1.32 (app-хост) | docker-compose.prod.yml |
|
||||||
|
| Frontend (nginx, статика + TLS) | CT 102 | деплоится отдельно |
|
||||||
|
| PostgreSQL 16 | 1.38 (выделенный LXC) | UTF8; бэкап→MinIO |
|
||||||
|
| Redis 7 | 1.35 (выделенный LXC) | кэш, rate-limits, LSH |
|
||||||
|
| RabbitMQ | 192.168.20.82 | брокер Celery |
|
||||||
|
| MinIO (S3) | 1.21 | документы, full-text, бэкапы |
|
||||||
|
| Ollama (qwen2.5:7b) | 192.168.20.163 | GPU-сервер |
|
||||||
|
| Qdrant / Prometheus / Grafana | 1.32 | опционально, под compose-профилями |
|
||||||
|
|
||||||
|
**Деплой** — Gitea Actions по push в `main`: гейт `test` (ruff+mypy → 82 юнит-теста),
|
||||||
|
затем `deploy` (`needs: test`) через `scripts/deploy.sh` (умная пересборка изменённых
|
||||||
|
сервисов). PG/Redis не в compose — общая инфра берётся из `.env`.
|
||||||
|
|
||||||
|
## 10. Конфигурация
|
||||||
|
|
||||||
|
Всё через `.env` (пример — `.env.example`). Обязательно менять: `SECRET_KEY`,
|
||||||
|
`POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`. Опции: `VECTOR_BACKEND`, `QDRANT_URL`,
|
||||||
|
`GRAFANA_ADMIN_PASSWORD`. `.env` не в git и исключён из деплой-rsync (не откатывается).
|
||||||
|
|
||||||
|
## 11. Качество и тесты
|
||||||
|
|
||||||
|
- **82 юнит-теста** (pytest, per-service) на чистую логику L1-L4/скоринг/фрагменты/
|
||||||
|
ГОСТ/библиография; инфра замокана или не нужна. Запуск: `make test`.
|
||||||
|
- **Гейты CI**: ruff (весь Python) + mypy (доменная логика) + тесты — блокируют деплой.
|
||||||
|
`make lint`. Хермет-раннеры в `python:3.11-slim`.
|
||||||
|
- Чистая доменная логика вынесена из Celery-задач в тестируемые модули
|
||||||
|
(`scoring.py`, `fragments.py`, `bibliography.py`). Подробнее — README «Тестирование».
|
||||||
|
|
||||||
|
## 12. Наблюдаемость и эксплуатация
|
||||||
|
|
||||||
|
- **Мониторинг**: `antiplag_monitor.py` (cron 5 мин, 7 сервисов, email-алерт при смене
|
||||||
|
статуса). Опционально — Prometheus+Grafana (профиль `observability`, метрики API + Flower).
|
||||||
|
- **Бэкапы**: `pg_dump→gzip→MinIO`, cron 03:00, ротация 14. Проверка восстановления —
|
||||||
|
`scripts/ops/pg_restore_verify.sh`. HA/DR — [DR-HA.md](DR-HA.md).
|
||||||
|
|
||||||
|
## 13. Безопасность
|
||||||
|
|
||||||
|
JWT-аутентификация, bcrypt-хэши паролей, email-верификация, отдельный вход в админку
|
||||||
|
(одноразовые коды, `admin_sessions`). Rate-limits по тарифу — в Redis. CORS — явные
|
||||||
|
origins. Пользователь видит только свои задачи (ownership проверяется, в т.ч. на WebSocket).
|
||||||
|
|
||||||
|
## 14. Раскладка репозитория
|
||||||
|
|
||||||
|
```
|
||||||
|
services/ api, worker-{gpu,indexer,notifier,gost}, frontend
|
||||||
|
scripts/ parsers/ (OpenAlex/arXiv/КиберЛенинка), ops/, deploy.sh,
|
||||||
|
run_tests.sh, run_lint.sh
|
||||||
|
infra/ nginx/, prometheus/, grafana/
|
||||||
|
docs/ ARCHITECTURE.md (этот файл), DR-HA.md
|
||||||
|
.gitea/workflows/ deploy.yml (гейт test → deploy)
|
||||||
|
ruff.toml · mypy.ini · Makefile · docker-compose.prod.yml
|
||||||
|
```
|
||||||
55
docs/DR-HA.md
Normal file
55
docs/DR-HA.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Отказоустойчивость и восстановление (HA / DR)
|
||||||
|
|
||||||
|
Актуальная топология: PostgreSQL на выделенном LXC **1.38**, Redis на **1.35**,
|
||||||
|
RabbitMQ на **192.168.20.82**, app+ES на **1.32**. Это не docker-compose, поэтому
|
||||||
|
HA для БД/кэша делается на уровне Proxmox/LXC, а не в этом репозитории.
|
||||||
|
|
||||||
|
## 1. Бэкапы (сделано)
|
||||||
|
|
||||||
|
`pg_dump → gzip → MinIO backups/pg/`, cron ежедневно 03:00 на 1.38, ротация 14 дней.
|
||||||
|
|
||||||
|
## 2. Проверка восстановимости (сделано — было слепой зоной)
|
||||||
|
|
||||||
|
Бэкап без проверенного восстановления = отсутствие бэкапа. Скрипт
|
||||||
|
[`scripts/ops/pg_restore_verify.sh`](../scripts/ops/pg_restore_verify.sh) берёт
|
||||||
|
последний дамп из MinIO, restore в **эфемерный** `postgres:16` и проверяет ключевые
|
||||||
|
таблицы. Прод не трогает.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/ops/pg_restore_verify.sh # креды из .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Рекомендация: cron раз в неделю на 1.32, алерт (как в antiplag_monitor) при ненулевом коде.
|
||||||
|
|
||||||
|
## 3. HA PostgreSQL — потоковая репликация (требует новый LXC)
|
||||||
|
|
||||||
|
Провижн реплики — задача на Proxmox (нужен ещё один LXC, напр. 1.39). Шаги:
|
||||||
|
|
||||||
|
1. **Primary (1.38)** `postgresql.conf`: `wal_level=replica`, `max_wal_senders=5`,
|
||||||
|
`wal_keep_size=1GB`; `pg_hba.conf`: строка `replication` для IP реплики.
|
||||||
|
2. **Replica**: `pg_basebackup -h 1.38 -U replicator -D $PGDATA -R` (создаёт
|
||||||
|
`standby.signal` + `primary_conninfo`), затем старт — догоняет primary по WAL.
|
||||||
|
3. **Failover**: ручной `pg_ctl promote` на реплике + переключение `POSTGRES_HOST`
|
||||||
|
в `.env` (или автоматизация через Patroni + etcd — если нужен авто-failover).
|
||||||
|
4. Проверка лага: `SELECT * FROM pg_stat_replication` на primary.
|
||||||
|
|
||||||
|
## 4. HA Redis — реплика + Sentinel (требует новый LXC)
|
||||||
|
|
||||||
|
Redis у нас — кэш/rate-limits/LSH-индекс (префикс `antiplag_lsh`). Потеря = деградация,
|
||||||
|
не потеря данных задач (они в PG). Если нужна устойчивость:
|
||||||
|
|
||||||
|
1. Второй Redis (реплика): `replicaof 1.35 6379` + тот же `requirepass`.
|
||||||
|
2. **3× Sentinel** (на app-хостах): `sentinel monitor antiplag 1.35 6379 2`,
|
||||||
|
авто-переключение мастера.
|
||||||
|
3. Клиенты (Celery/кэш) → на Sentinel-aware подключение (`redis.sentinel`),
|
||||||
|
либо оставить прямое подключение + ручной перевод `REDIS_URL` при аварии.
|
||||||
|
|
||||||
|
## 5. Единые точки отказа — статус
|
||||||
|
|
||||||
|
| Компонент | SPOF | Митигация |
|
||||||
|
|-----------|:----:|-----------|
|
||||||
|
| PostgreSQL 1.38 | да | бэкап+restore-тест ✅; реплика — §3 (нужен LXC) |
|
||||||
|
| Redis 1.35 | да | graceful-фолбэк LSH в память ✅; реплика+Sentinel — §4 |
|
||||||
|
| Векторный индекс | было | `VECTOR_BACKEND=qdrant` снимает (см. README) ✅ |
|
||||||
|
| RabbitMQ .82 | да | мониторинг ловит падение ✅; кластер — по потребности |
|
||||||
|
| app/ES 1.32 | да | воркеры горизонтальны; ES single-node (для BM25 не критично) |
|
||||||
52
docs/INGESTION.md
Normal file
52
docs/INGESTION.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Наполнение корпуса — runbook
|
||||||
|
|
||||||
|
## Текущее состояние (на 2026-08-12)
|
||||||
|
|
||||||
|
- ~**42K документов**, из них **99.4% английские, 0 русских** (см. `documents`).
|
||||||
|
- Заливка **встала 2026-08-07**. Корпус — seed из ~30 английских тем OpenAlex/arXiv.
|
||||||
|
- Для сервиса под русских студентов это главный дефект: русские работы проверять
|
||||||
|
не с чем.
|
||||||
|
|
||||||
|
## Что подготовлено
|
||||||
|
|
||||||
|
- **Парсер CyberLeninka починен** (`scripts/parsers/cyberleninka.py`): раньше слал GET
|
||||||
|
на `/api/search` → HTTP 405; теперь POST с JSON-телом (`mode=articles`), authors из
|
||||||
|
списка, чистка `<b>`/HTML-сущностей. Проверено вживую (5/5 тем) + юнит-тесты
|
||||||
|
(`scripts/parsers/tests/`), в гейте CI.
|
||||||
|
- **Прод-путь готов**: `index.run_parser(source_id)` уже умеет `cyberleninka` и
|
||||||
|
`openalex` c `lang=ru`.
|
||||||
|
- **Сидер источников**: `scripts/seed_ru_sources.py` — 30 студенческих дисциплин.
|
||||||
|
|
||||||
|
## Запуск русской заливки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. (на app-хосте / в контейнере worker-indexer, где есть psycopg2 и прод-.env)
|
||||||
|
# Посмотреть план:
|
||||||
|
python scripts/seed_ru_sources.py
|
||||||
|
# Создать источники в parse_sources (лимит на дисциплину):
|
||||||
|
python scripts/seed_ru_sources.py --apply --limit 500
|
||||||
|
|
||||||
|
# 2. Проверить пару источников на темпе/качестве, затем запустить заливку:
|
||||||
|
# • Админ-панель → «Источники» → «Запустить», ЛИБО
|
||||||
|
# • Celery: index.run_parser.delay(source_id) по каждому id
|
||||||
|
```
|
||||||
|
|
||||||
|
Заливка сама: fetch (rate-limit 1 req/s) → `add_document` (дедуп по `ext_id`,
|
||||||
|
fingerprints L1, MinHash L2) → батч-эмбеддинги `gpu.embed_documents` (L3). ~30 тем ×
|
||||||
|
500 ≈ 15K русских документов на первый заход.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT lang, count(*) FROM documents GROUP BY lang ORDER BY 2 DESC; -- должен появиться ru
|
||||||
|
SELECT source, count(*) FROM documents WHERE source='cyberleninka'; -- > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Масштаб (следующий уровень)
|
||||||
|
|
||||||
|
- Больше тем + выше `--limit`; добавить OpenAlex `lang=ru` (качество ниже — англ.
|
||||||
|
заголовки с меткой ru).
|
||||||
|
- Для миллионов — **bulk** (снапшот OpenAlex на S3), а не постраничный API.
|
||||||
|
- На масштабе обязателен `VECTOR_BACKEND=qdrant` (FAISS flat не тянет), а таблица
|
||||||
|
`fingerprints` (уже ~29M строк на 42K доков) потребует партиционирования. См.
|
||||||
|
[ARCHITECTURE.md](ARCHITECTURE.md) и [DR-HA.md](DR-HA.md).
|
||||||
10
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
10
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Автопровижн источника данных Grafana → Prometheus (профиль observability).
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
20
infra/prometheus/prometheus.yml
Normal file
20
infra/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Prometheus scrape-конфиг (профиль observability в docker-compose.prod.yml).
|
||||||
|
# Скрейпит HTTP-метрики API и Celery-метрики из Flower (Flower 2.0 отдаёт их сам).
|
||||||
|
global:
|
||||||
|
scrape_interval: 30s
|
||||||
|
evaluation_interval: 30s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9090"]
|
||||||
|
|
||||||
|
- job_name: api
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["api:8000"]
|
||||||
|
|
||||||
|
- job_name: flower # метрики Celery: задачи, время выполнения, воркеры
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["flower:5555"]
|
||||||
14
mypy.ini
Normal file
14
mypy.ini
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Прагматичная конфигурация mypy (старт градуальной типизации).
|
||||||
|
# Ловит реальные ошибки типов (несовпадения, обращения к None, неверные
|
||||||
|
# аргументы), но НЕ требует аннотаций везде и не шумит на нетипизированных
|
||||||
|
# сторонних либах — чтобы гейт был зелёным и его можно было расширять по мере
|
||||||
|
# типизации кода. Область проверки задаётся в scripts/run_lint.sh (пока только
|
||||||
|
# чистая логика L1/L2 + ГОСТ-форматтеры).
|
||||||
|
[mypy]
|
||||||
|
python_version = 3.11
|
||||||
|
ignore_missing_imports = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
warn_return_any = false
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
26
ruff.toml
Normal file
26
ruff.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Линтер/форматтер для всех Python-сервисов и скриптов.
|
||||||
|
# Гоняется как гейт в CI (scripts/run_lint.sh) — падение блокирует деплой.
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"F", # pyflakes (неиспользуемое, неопределённое)
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"I", # isort (порядок импортов)
|
||||||
|
"UP", # pyupgrade (устаревшие конструкции)
|
||||||
|
"B", # flake8-bugbear (частые баги)
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501", # длину строк держит форматтер, а не линтер; длинные RU-комментарии — норма
|
||||||
|
"B008", # FastAPI-идиома: Depends()/Query() в значениях по умолчанию — не баг
|
||||||
|
"UP042", # (str, Enum) → StrEnum меняет __str__ (сериализацию) — не трогаем намеренно
|
||||||
|
]
|
||||||
|
|
||||||
|
[lint.per-file-ignores]
|
||||||
|
# В тестах допускаем импорт-после-кода (importorskip) и «неотсортированные» блоки
|
||||||
|
"**/tests/*" = ["E402"]
|
||||||
|
"**/conftest.py" = ["E402"]
|
||||||
71
scripts/ops/pg_restore_verify.sh
Executable file
71
scripts/ops/pg_restore_verify.sh
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# DR-тест: проверка ВОССТАНОВИМОСТИ бэкапа PostgreSQL.
|
||||||
|
#
|
||||||
|
# Берёт последний дамп из MinIO (backups/pg/), поднимает ЭФЕМЕРНЫЙ контейнер
|
||||||
|
# postgres:16, восстанавливает в него дамп и проверяет, что ключевые таблицы не
|
||||||
|
# пусты. Прод не трогается — всё в одноразовом контейнере/сети, удаляются на выходе.
|
||||||
|
#
|
||||||
|
# Зачем: бэкапы делаются (cron на 1.38), но восстановление раньше никто не проверял —
|
||||||
|
# «непроверенный бэкап = отсутствие бэкапа». Запускать по cron раз в неделю.
|
||||||
|
#
|
||||||
|
# Требуется docker + доступ до MinIO. Креды берутся из .env (или окружения):
|
||||||
|
# MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY,
|
||||||
|
# MINIO_BUCKET_BACKUPS (=backups), POSTGRES_DB, POSTGRES_USER.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
if [ -f "$ROOT/.env" ]; then set -a; . "$ROOT/.env"; set +a; fi
|
||||||
|
|
||||||
|
: "${MINIO_ENDPOINT:?нужен MINIO_ENDPOINT}"
|
||||||
|
: "${MINIO_ACCESS_KEY:?нужен MINIO_ACCESS_KEY}"
|
||||||
|
: "${MINIO_SECRET_KEY:?нужен MINIO_SECRET_KEY}"
|
||||||
|
BUCKET="${MINIO_BUCKET_BACKUPS:-backups}"
|
||||||
|
PREFIX="${PG_BACKUP_PREFIX:-pg/}"
|
||||||
|
PGDB="${POSTGRES_DB:-antiplagiator}"
|
||||||
|
PGUSER="${POSTGRES_USER:-antiplagiator}"
|
||||||
|
|
||||||
|
SUFFIX="$$-$RANDOM"
|
||||||
|
PG="drtest-pg-$SUFFIX"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
MC_HOST="http://${MINIO_ACCESS_KEY}:${MINIO_SECRET_KEY}@${MINIO_ENDPOINT}"
|
||||||
|
|
||||||
|
cleanup() { docker rm -f "$PG" >/dev/null 2>&1 || true; rm -rf "$WORK"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "▶ Поиск последнего дампа в minio://$BUCKET/$PREFIX"
|
||||||
|
LATEST="$(docker run --rm -e MC_HOST_dr="$MC_HOST" minio/mc:latest \
|
||||||
|
sh -c "mc ls --recursive dr/$BUCKET/$PREFIX | awk '{print \$NF}' | grep -E '\.(sql\.)?gz$' | sort | tail -1")"
|
||||||
|
[ -n "$LATEST" ] || { echo "❌ дампы не найдены"; exit 1; }
|
||||||
|
FNAME="$(basename "$LATEST")"
|
||||||
|
echo " последний: $FNAME"
|
||||||
|
|
||||||
|
echo "▶ Скачивание дампа"
|
||||||
|
docker run --rm -e MC_HOST_dr="$MC_HOST" -v "$WORK":/w minio/mc:latest \
|
||||||
|
sh -c "mc cp 'dr/$BUCKET/$PREFIX$FNAME' /w/dump.gz"
|
||||||
|
|
||||||
|
echo "▶ Эфемерный postgres:16"
|
||||||
|
docker run -d --name "$PG" \
|
||||||
|
-e POSTGRES_PASSWORD=drtest -e POSTGRES_DB="$PGDB" -e POSTGRES_USER="$PGUSER" \
|
||||||
|
postgres:16 >/dev/null
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
docker exec "$PG" pg_isready -U "$PGUSER" -d "$PGDB" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "▶ Восстановление"
|
||||||
|
gunzip -c "$WORK/dump.gz" | docker exec -i "$PG" psql -v ON_ERROR_STOP=0 -U "$PGUSER" -d "$PGDB" >/dev/null 2>&1
|
||||||
|
|
||||||
|
echo "▶ Проверка ключевых таблиц"
|
||||||
|
rc=0
|
||||||
|
for tbl in users documents tasks; do
|
||||||
|
n="$(docker exec "$PG" psql -U "$PGUSER" -d "$PGDB" -tAc "SELECT count(*) FROM $tbl" 2>/dev/null | tr -d '[:space:]' || echo ERR)"
|
||||||
|
echo " $tbl: ${n:-ERR}"
|
||||||
|
{ [ -z "$n" ] || [ "$n" = "ERR" ]; } && rc=1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
echo "✅ Бэкап восстанавливается, ключевые таблицы на месте ($FNAME)"
|
||||||
|
else
|
||||||
|
echo "❌ Восстановление не прошло проверку — разобраться СРОЧНО"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -6,13 +6,12 @@ API: https://info.arxiv.org/help/api/index.html
|
|||||||
Использует Atom XML API.
|
Использует Atom XML API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from base import BaseParser
|
from base import BaseParser
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -203,9 +202,6 @@ class ArxivParser(BaseParser):
|
|||||||
# Определить язык (arXiv — преимущественно английский)
|
# Определить язык (arXiv — преимущественно английский)
|
||||||
lang = "en"
|
lang = "en"
|
||||||
|
|
||||||
# Категории как JSON
|
|
||||||
categories = raw.get("categories", [])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"source": self.source_name,
|
"source": self.source_name,
|
||||||
"ext_id": f"arxiv:{ext_id}",
|
"ext_id": f"arxiv:{ext_id}",
|
||||||
|
|||||||
6
scripts/parsers/conftest.py
Normal file
6
scripts/parsers/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет каталог парсеров в sys.path (импорты вида `from base import ...`)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -3,18 +3,19 @@
|
|||||||
КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей.
|
КиберЛенинка — крупнейший русскоязычный открытый репозиторий научных статей.
|
||||||
Сайт: https://cyberleninka.ru
|
Сайт: https://cyberleninka.ru
|
||||||
|
|
||||||
Используем парсинг HTML страниц со строгим rate limiting (1 req/sec),
|
Поиск — через JSON-API `POST /api/search` (`{"mode":"articles", "q", "size", "from"}`)
|
||||||
а не недокументированное API.
|
со строгим rate limiting (1 req/sec). Ответ подсвечивает совпадения тегами <b>…</b>
|
||||||
|
в name/annotation — чистим при трансформации.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import logging
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from base import BaseParser
|
from base import BaseParser
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -63,13 +64,16 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
|
|
||||||
while len(results) < limit:
|
while len(results) < limit:
|
||||||
try:
|
try:
|
||||||
params: dict[str, Any] = {
|
# /api/search КиберЛенинки — это POST с JSON-телом (GET даёт 405).
|
||||||
|
# mode=articles обязателен, иначе поиск не по статьям.
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"mode": "articles",
|
||||||
"q": query,
|
"q": query,
|
||||||
"size": min(10, limit - len(results)),
|
"size": min(10, limit - len(results)),
|
||||||
"from": page * 10,
|
"from": page * 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.get(SEARCH_URL, params=params)
|
response = self.client.post(SEARCH_URL, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -114,18 +118,20 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
if not ext_id:
|
if not ext_id:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Авторы (строка вида "Иванов И.И., Петров П.П.")
|
# Авторы: API отдаёт список строк "Фамилия И. О." (иногда — одну строку)
|
||||||
authors_str = raw.get("authors", "") or ""
|
raw_authors = raw.get("authors") or []
|
||||||
authors = _parse_cyberleninka_authors(authors_str)
|
authors = (
|
||||||
|
_parse_cyberleninka_authors(raw_authors)
|
||||||
|
if isinstance(raw_authors, str)
|
||||||
|
else _authors_from_list(raw_authors)
|
||||||
|
)
|
||||||
|
|
||||||
# Год
|
# Год
|
||||||
year_raw = raw.get("year")
|
year_raw = raw.get("year")
|
||||||
year = None
|
year = None
|
||||||
if year_raw:
|
if year_raw:
|
||||||
try:
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
year = int(str(year_raw)[:4])
|
year = int(str(year_raw)[:4])
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# URL
|
# URL
|
||||||
link = raw.get("link", "")
|
link = raw.get("link", "")
|
||||||
@@ -135,7 +141,7 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
"source": self.source_name,
|
"source": self.source_name,
|
||||||
"ext_id": ext_id,
|
"ext_id": ext_id,
|
||||||
"doi": raw.get("doi") or None,
|
"doi": raw.get("doi") or None,
|
||||||
"title": (raw.get("name") or "").strip() or None,
|
"title": _clean(raw.get("name")) or None,
|
||||||
"authors": authors,
|
"authors": authors,
|
||||||
"year": year,
|
"year": year,
|
||||||
"lang": "ru", # КиберЛенинка — только русскоязычные
|
"lang": "ru", # КиберЛенинка — только русскоязычные
|
||||||
@@ -143,7 +149,7 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
"volume": raw.get("volume") or None,
|
"volume": raw.get("volume") or None,
|
||||||
"issue": raw.get("number") or None,
|
"issue": raw.get("number") or None,
|
||||||
"pages": raw.get("pages") or None,
|
"pages": raw.get("pages") or None,
|
||||||
"abstract": (raw.get("annotation") or "").strip() or None,
|
"abstract": _clean(raw.get("annotation")) or None,
|
||||||
"url": url,
|
"url": url,
|
||||||
"full_text": None,
|
"full_text": None,
|
||||||
}
|
}
|
||||||
@@ -165,6 +171,9 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Ленивый импорт: заливке (fetch/transform) bs4 не нужен, только деталям статьи
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
meta = {}
|
meta = {}
|
||||||
|
|
||||||
@@ -191,6 +200,26 @@ class CyberLeninkaParser(BaseParser):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(text: str | None) -> str:
|
||||||
|
"""Убрать HTML-теги подсветки (<b>…</b>), декодировать сущности ("), обрезать."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return html.unescape(re.sub(r"<[^>]+>", "", str(text))).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _authors_from_list(items: list) -> list[dict[str, str]]:
|
||||||
|
"""Авторы из списка строк 'Фамилия И. О.' → [{last_name, initials}, ...]."""
|
||||||
|
authors: list[dict[str, str]] = []
|
||||||
|
for item in items:
|
||||||
|
name = _clean(str(item))
|
||||||
|
words = name.split()
|
||||||
|
if not words:
|
||||||
|
continue
|
||||||
|
initials = " ".join(words[1:]) if len(words) >= 2 else ""
|
||||||
|
authors.append({"last_name": words[0], "initials": initials})
|
||||||
|
return authors
|
||||||
|
|
||||||
|
|
||||||
def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]:
|
def _parse_cyberleninka_authors(authors_str: str) -> list[dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Разбить строку авторов КиберЛенинки на список.
|
Разбить строку авторов КиберЛенинки на список.
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ API: https://docs.openalex.org/
|
|||||||
- Идемпотентность: проверка по ext_id перед добавлением
|
- Идемпотентность: проверка по ext_id перед добавлением
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Generator
|
import time
|
||||||
|
from collections.abc import Generator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from base import BaseParser
|
from base import BaseParser
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
3
scripts/parsers/pytest.ini
Normal file
3
scripts/parsers/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
4
scripts/parsers/requirements-test.txt
Normal file
4
scripts/parsers/requirements-test.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Зависимости для юнит-тестов парсеров (чистый transform, без сети).
|
||||||
|
pytest==8.2.0
|
||||||
|
httpx==0.27.0
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
53
scripts/parsers/tests/test_cyberleninka.py
Normal file
53
scripts/parsers/tests/test_cyberleninka.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Юнит-тесты парсера CyberLeninka — чистая логика transform (без сети).
|
||||||
|
|
||||||
|
Стерегут исправления реальных багов: JSON-API отдаёт authors списком, а name/
|
||||||
|
annotation — с HTML-подсветкой (<b>) и сущностями (").
|
||||||
|
"""
|
||||||
|
|
||||||
|
from cyberleninka import CyberLeninkaParser, _authors_from_list, _clean
|
||||||
|
|
||||||
|
# Форма ответа POST /api/search КиберЛенинки
|
||||||
|
SAMPLE = {
|
||||||
|
"name": "СОДЕРЖАНИЕ <b>ЭКОНОМИЧЕСКОГО</b> <b>АНАЛИЗА</b> "X"",
|
||||||
|
"annotation": "В статье <b>рассматривается</b> вопрос",
|
||||||
|
"authors": ["Вишникина А. Д.", "Клопова А. А."],
|
||||||
|
"journal": "Экономика и социум",
|
||||||
|
"year": 2024,
|
||||||
|
"link": "/article/n/soderzhanie",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_strips_tags_and_entities():
|
||||||
|
assert _clean('<b>Тест</b> "X"') == 'Тест "X"'
|
||||||
|
assert _clean(None) == ""
|
||||||
|
assert _clean(" чисто ") == "чисто"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_from_list():
|
||||||
|
a = _authors_from_list(["Вишникина А. Д.", "Иванов И."])
|
||||||
|
assert a[0] == {"last_name": "Вишникина", "initials": "А. Д."}
|
||||||
|
assert a[1] == {"last_name": "Иванов", "initials": "И."}
|
||||||
|
assert _authors_from_list([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_cleans_and_maps_fields():
|
||||||
|
t = CyberLeninkaParser().transform(SAMPLE)
|
||||||
|
assert "<b>" not in t["title"] and """ not in t["title"]
|
||||||
|
assert t["title"].startswith("СОДЕРЖАНИЕ ЭКОНОМИЧЕСКОГО АНАЛИЗА")
|
||||||
|
assert t["lang"] == "ru"
|
||||||
|
assert t["year"] == 2024
|
||||||
|
assert t["journal"] == "Экономика и социум"
|
||||||
|
assert t["url"] == "https://cyberleninka.ru/article/n/soderzhanie"
|
||||||
|
assert t["source"] == "cyberleninka"
|
||||||
|
assert t["authors"][0]["last_name"] == "Вишникина"
|
||||||
|
assert "<b>" not in t["abstract"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_handles_string_authors():
|
||||||
|
raw = dict(SAMPLE, authors="Иванов И.И., Петров П.П.")
|
||||||
|
t = CyberLeninkaParser().transform(raw)
|
||||||
|
assert len(t["authors"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_empty_without_id_or_link():
|
||||||
|
assert CyberLeninkaParser().transform({"name": "x"}) == {}
|
||||||
32
scripts/run_lint.sh
Executable file
32
scripts/run_lint.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Статический анализ Python-кода в изолированном контейнере — гейт CI перед деплоем.
|
||||||
|
# 1) ruff — линт всего кода (services + scripts), конфиг ruff.toml
|
||||||
|
# 2) mypy — проверка типов (пока только чистая логика L1/L2 + ГОСТ), конфиг mypy.ini
|
||||||
|
# область растёт по мере типизации кода; запуск per-service, чтобы
|
||||||
|
# резолвился локальный пакет `app`.
|
||||||
|
#
|
||||||
|
# PIP_INDEX_URL переопределяется при необходимости (по умолчанию Tsinghua-зеркало).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}"
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
RUFF_VERSION="0.16.2"
|
||||||
|
MYPY_VERSION="1.13.0"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$ROOT":/repo -w /repo \
|
||||||
|
-e PIP_INDEX_URL="$MIRROR" \
|
||||||
|
-e PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
python:3.11-slim bash -c "
|
||||||
|
set -e
|
||||||
|
pip install --no-cache-dir --timeout 60 -q ruff==$RUFF_VERSION mypy==$MYPY_VERSION
|
||||||
|
|
||||||
|
echo '▶ ruff (services/ scripts/)'
|
||||||
|
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-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 )
|
||||||
|
"
|
||||||
|
echo "✅ Линт (ruff + mypy) пройден"
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
70
scripts/run_tests.sh
Executable file
70
scripts/run_tests.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Прогон юнит-тестов всех сервисов в изолированных python:3.11-slim контейнерах.
|
||||||
|
#
|
||||||
|
# Тесты бьют по ЧИСТОЙ логике детекции и форматирования и не требуют внешней
|
||||||
|
# инфраструктуры (БД/Redis/RabbitMQ/GPU/Ollama) — поэтому гоняются одинаково на
|
||||||
|
# машине разработчика и в CI. Запуск в контейнере не засоряет хост зависимостями.
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# scripts/run_tests.sh # все сервисы
|
||||||
|
# scripts/run_tests.sh worker-gost # только один сервис
|
||||||
|
#
|
||||||
|
# PIP_INDEX_URL можно переопределить (по умолчанию — Tsinghua-зеркало, т.к.
|
||||||
|
# pypi.org из LAN недоступен).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}"
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
IMAGE="python:3.11-slim"
|
||||||
|
|
||||||
|
# относительный-путь:apt-пакеты (нужны faiss-cpu → libgomp1)
|
||||||
|
SERVICES=(
|
||||||
|
"services/worker-indexer:"
|
||||||
|
"services/worker-gost:"
|
||||||
|
"services/worker-gpu:libgomp1"
|
||||||
|
"scripts/parsers:"
|
||||||
|
)
|
||||||
|
|
||||||
|
run_service() {
|
||||||
|
local dir_rel="$1" apt_pkgs="$2"
|
||||||
|
local dir="$ROOT/$dir_rel"
|
||||||
|
local name="${dir_rel##*/}"
|
||||||
|
if [[ ! -f "$dir/requirements-test.txt" ]]; then
|
||||||
|
echo "⚠ $name: нет requirements-test.txt — пропуск"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "──────────────────────────────────────────────"
|
||||||
|
echo "▶ Тесты: $name"
|
||||||
|
echo "──────────────────────────────────────────────"
|
||||||
|
docker run --rm \
|
||||||
|
-v "$dir":/app -w /app \
|
||||||
|
-e PIP_INDEX_URL="$MIRROR" \
|
||||||
|
-e PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
"$IMAGE" bash -c "
|
||||||
|
set -e
|
||||||
|
${apt_pkgs:+apt-get update -qq && apt-get install -y -qq --no-install-recommends $apt_pkgs >/dev/null && rm -rf /var/lib/apt/lists/*}
|
||||||
|
pip install --no-cache-dir --timeout 60 -q -r requirements-test.txt
|
||||||
|
python -m pytest
|
||||||
|
"
|
||||||
|
}
|
||||||
|
|
||||||
|
FILTER="${1:-}"
|
||||||
|
failed=0
|
||||||
|
for entry in "${SERVICES[@]}"; do
|
||||||
|
dir_rel="${entry%%:*}"
|
||||||
|
apt="${entry#*:}"
|
||||||
|
name="${dir_rel##*/}"
|
||||||
|
[[ -n "$FILTER" && "$FILTER" != "$name" ]] && continue
|
||||||
|
if ! run_service "$dir_rel" "$apt"; then
|
||||||
|
failed=1
|
||||||
|
echo "✗ $name: тесты упали"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "══════════════════════════════════════════════"
|
||||||
|
if [[ $failed -eq 0 ]]; then
|
||||||
|
echo "✅ Все юнит-тесты прошли"
|
||||||
|
else
|
||||||
|
echo "❌ Есть упавшие тесты"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
121
scripts/seed_ru_sources.py
Normal file
121
scripts/seed_ru_sources.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Подготовка русскоязычной заливки корпуса — сидинг parse_sources (CyberLeninka).
|
||||||
|
|
||||||
|
Зачем: корпус на ~99% английский (0 русских источников), хотя сервис — для русских
|
||||||
|
студентов. Этот скрипт создаёт в таблице `parse_sources` набор заданий CyberLeninka
|
||||||
|
по основным студенческим дисциплинам. Он НЕ запускает заливку — только готовит
|
||||||
|
задания; их затем триггерят (кнопка «Запустить» в админке или Celery-задача
|
||||||
|
`index.run_parser(source_id)`).
|
||||||
|
|
||||||
|
Идемпотентно: источник с таким `name` повторно не создаётся.
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
# dry-run — показать план, ничего не писать:
|
||||||
|
python scripts/seed_ru_sources.py
|
||||||
|
# создать источники в БД (лимит на каждую дисциплину):
|
||||||
|
python scripts/seed_ru_sources.py --apply --limit 500
|
||||||
|
|
||||||
|
Креды PG берутся из .env (POSTGRES_HOST/PORT/DB/USER/PASSWORD) или окружения.
|
||||||
|
ВНИМАНИЕ: локальный .env может указывать на старый PG — для прода используйте
|
||||||
|
POSTGRES_HOST=192.168.1.38 (см. вывод по наполненности базы).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Студенческие дисциплины → поисковый запрос в CyberLeninka (русскоязычный).
|
||||||
|
DISCIPLINES = [
|
||||||
|
"экономический анализ", "менеджмент организации", "бухгалтерский учёт",
|
||||||
|
"финансы и кредит", "маркетинг", "гражданское право", "уголовное право",
|
||||||
|
"конституционное право", "трудовое право", "педагогика", "психология личности",
|
||||||
|
"социология", "философия науки", "история России", "политология",
|
||||||
|
"информационные технологии", "программирование", "базы данных",
|
||||||
|
"математический анализ", "физика", "химия", "биология", "медицина",
|
||||||
|
"экология", "лингвистика", "литературоведение", "государственное управление",
|
||||||
|
"международные отношения", "журналистика", "культурология",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env() -> None:
|
||||||
|
"""Подтянуть переменные из .env репозитория, если файл есть."""
|
||||||
|
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 main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--apply", action="store_true", help="реально записать в БД (иначе dry-run)")
|
||||||
|
ap.add_argument("--limit", type=int, default=500, help="лимит документов на дисциплину")
|
||||||
|
ap.add_argument("--source-type", default="cyberleninka", help="тип парсера (по умолчанию cyberleninka)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
_load_env()
|
||||||
|
rows = [(f"ru:{d}", args.source_type, d) for d in DISCIPLINES]
|
||||||
|
|
||||||
|
print(f"Дисциплин: {len(rows)} | тип: {args.source_type} | лимит/шт: {args.limit}")
|
||||||
|
print(f"Потенциальный объём: ~{len(rows) * args.limit} документов")
|
||||||
|
if not args.apply:
|
||||||
|
print("\n[dry-run] будут созданы источники (name → query):")
|
||||||
|
for name, _, q in rows:
|
||||||
|
print(f" {name:38} → {q!r}")
|
||||||
|
print("\nЗапустите с --apply, чтобы записать в parse_sources.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Нужен psycopg2 (есть в образах воркеров). Запускайте на app-хосте/в контейнере.")
|
||||||
|
|
||||||
|
dsn = _pg_dsn()
|
||||||
|
print(f"\nПодключение к PG {dsn['host']}:{dsn['port']}/{dsn['dbname']} ...")
|
||||||
|
conn = psycopg2.connect(**dsn)
|
||||||
|
conn.autocommit = True
|
||||||
|
created, skipped, ids = 0, 0, []
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for name, stype, query in rows:
|
||||||
|
cur.execute("SELECT id FROM parse_sources WHERE name = %s", (name,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO parse_sources '
|
||||||
|
'(source_type, name, query, lang, "limit", enabled, last_status, docs_added) '
|
||||||
|
"VALUES (%s, %s, %s, 'ru', %s, TRUE, 'idle', 0) RETURNING id",
|
||||||
|
(stype, name, query, args.limit),
|
||||||
|
)
|
||||||
|
ids.append(cur.fetchone()[0])
|
||||||
|
created += 1
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"Создано: {created}, пропущено (уже были): {skipped}")
|
||||||
|
if ids:
|
||||||
|
print(f"ID новых источников: {ids}")
|
||||||
|
print("\nЗапуск заливки (после проверки источников):")
|
||||||
|
print(" • Админка → раздел «Источники» → «Запустить» у каждого, ЛИБО")
|
||||||
|
print(" • Celery: for i in ids: index.run_parser.delay(i)")
|
||||||
|
print("Рекомендация: сначала запустить 1-2 для проверки темпа/качества, потом остальные.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,14 +4,15 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from alembic import context
|
import app.models # noqa: F401 — регистрирует все модели
|
||||||
|
|
||||||
|
# Импорт всех моделей для автоопределения изменений
|
||||||
|
from app.database import Base
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
# Импорт всех моделей для автоопределения изменений
|
from alembic import context
|
||||||
from app.database import Base
|
|
||||||
import app.models # noqa: F401 — регистрирует все модели
|
|
||||||
|
|
||||||
# Alembic Config
|
# Alembic Config
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ Create Date: 2024-01-01 00:00:00.000000
|
|||||||
Создаёт таблицы: users, tasks, documents, fingerprints, usage_logs
|
Создаёт таблицы: users, tasks, documents, fingerprints, usage_logs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
# revision identifiers
|
# revision identifiers
|
||||||
revision = "001"
|
revision = "001"
|
||||||
down_revision = None
|
down_revision = None
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ Create Date: 2026-05-24
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
revision = "002"
|
revision = "002"
|
||||||
down_revision = "001"
|
down_revision = "001"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ Revises: 002
|
|||||||
Create Date: 2026-05-30
|
Create Date: 2026-05-30
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
revision = "003"
|
revision = "003"
|
||||||
down_revision = "002"
|
down_revision = "002"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
дополнительно проверяют секретный код сессии (verify_admin_code).
|
дополнительно проверяют секретный код сессии (verify_admin_code).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
@@ -150,11 +151,11 @@ async def stats(db: AsyncSession = Depends(get_db)) -> AdminStats:
|
|||||||
users_total = (await db.execute(select(func.count()).select_from(User))).scalar_one()
|
users_total = (await db.execute(select(func.count()).select_from(User))).scalar_one()
|
||||||
|
|
||||||
rows = (await db.execute(select(Task.status, func.count()).group_by(Task.status))).all()
|
rows = (await db.execute(select(Task.status, func.count()).group_by(Task.status))).all()
|
||||||
tasks_by_status = {s: c for s, c in rows}
|
tasks_by_status = dict(rows)
|
||||||
|
|
||||||
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
|
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
|
||||||
rows = (await db.execute(select(Document.source, func.count()).group_by(Document.source))).all()
|
rows = (await db.execute(select(Document.source, func.count()).group_by(Document.source))).all()
|
||||||
docs_by_source = {s: c for s, c in rows}
|
docs_by_source = dict(rows)
|
||||||
|
|
||||||
staging_pending = (
|
staging_pending = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -257,10 +258,8 @@ async def update_user(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
# Сбросить кэш пользователя
|
# Сбросить кэш пользователя
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
await get_redis().delete(f"user:cache:{user_id}")
|
await get_redis().delete(f"user:cache:{user_id}")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return AdminUserResponse.model_validate(user)
|
return AdminUserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
@@ -533,7 +532,7 @@ async def approve_staging(
|
|||||||
obj = get_minio().get_object("staging", sw.text_key)
|
obj = get_minio().get_object("staging", sw.text_key)
|
||||||
full_text = obj.read().decode("utf-8", errors="replace")
|
full_text = obj.read().decode("utf-8", errors="replace")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Не удалось прочитать текст: {e}")
|
raise HTTPException(status_code=500, detail=f"Не удалось прочитать текст: {e}") from e
|
||||||
|
|
||||||
# Добавить в базу документов через существующую задачу индексатора
|
# Добавить в базу документов через существующую задачу индексатора
|
||||||
doc_data = {
|
doc_data = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Роутер аутентификации: регистрация, вход, верификация email."""
|
"""Роутер аутентификации: регистрация, вход, верификация email."""
|
||||||
|
|
||||||
import secrets
|
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -224,7 +224,7 @@ async def resend_verification(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Не удалось отправить письмо, попробуйте позже",
|
detail="Не удалось отправить письмо, попробуйте позже",
|
||||||
)
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK)
|
@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ async def upload_for_plagiarism_check(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Ошибка сохранения файла. Попробуйте позже.",
|
detail="Ошибка сохранения файла. Попробуйте позже.",
|
||||||
)
|
) from e
|
||||||
|
|
||||||
# ── 5. Создаём задачу и диспатчим ─────────────────────────────────────────
|
# ── 5. Создаём задачу и диспатчим ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Роутер для получения отчётов о выполненных задачах."""
|
"""Роутер для получения отчётов о выполненных задачах."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Авторизация админ-панели: проверка роли is_admin и секретного кода сессии."""
|
"""Авторизация админ-панели: проверка роли is_admin и секретного кода сессии."""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Path, status
|
from fastapi import Depends, HTTPException, Path, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Redis гарантирует, что между командами внутри
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app.core.redis_client import get_redis
|
from app.core.redis_client import get_redis
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ return 1
|
|||||||
|
|
||||||
|
|
||||||
def _period_suffix(period: str) -> str:
|
def _period_suffix(period: str) -> str:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(UTC)
|
||||||
return now.strftime("%Y-%m-%d") if period == "day" else now.strftime("%Y-%m")
|
return now.strftime("%Y-%m-%d") if period == "day" else now.strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Query, WebSocket, status
|
from fastapi import Depends, HTTPException, Query, WebSocket, status
|
||||||
@@ -35,7 +35,7 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|||||||
|
|
||||||
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
||||||
payload = data.copy()
|
payload = data.copy()
|
||||||
expire = datetime.now(timezone.utc) + (
|
expire = datetime.now(UTC) + (
|
||||||
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
)
|
)
|
||||||
payload["exp"] = expire
|
payload["exp"] = expire
|
||||||
@@ -58,7 +58,7 @@ def _decode_token(token: str) -> int:
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Невалидный или просроченный токен",
|
detail="Невалидный или просроченный токен",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
) from None
|
||||||
|
|
||||||
|
|
||||||
async def _load_user(user_id: int, db: AsyncSession):
|
async def _load_user(user_id: int, db: AsyncSession):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""WebSocket менеджер для real-time обновлений статуса задач."""
|
"""WebSocket менеджер для real-time обновлений статуса задач."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -27,10 +28,8 @@ class ConnectionManager:
|
|||||||
def disconnect(self, task_id: str, websocket: WebSocket) -> None:
|
def disconnect(self, task_id: str, websocket: WebSocket) -> None:
|
||||||
"""Удалить соединение из реестра."""
|
"""Удалить соединение из реестра."""
|
||||||
if task_id in self._connections:
|
if task_id in self._connections:
|
||||||
try:
|
with contextlib.suppress(ValueError):
|
||||||
self._connections[task_id].remove(websocket)
|
self._connections[task_id].remove(websocket)
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if not self._connections[task_id]:
|
if not self._connections[task_id]:
|
||||||
del self._connections[task_id]
|
del self._connections[task_id]
|
||||||
logger.info(f"WebSocket отключён от задачи {task_id!r}")
|
logger.info(f"WebSocket отключён от задачи {task_id!r}")
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Точка входа FastAPI приложения — Академический помощник."""
|
"""Точка входа FastAPI приложения — Академический помощник."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends
|
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
|
|
||||||
from app.api import admin, auth, documents, reports, search, tasks
|
from app.api import admin, auth, documents, reports, search, tasks
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -80,6 +81,11 @@ app.include_router(documents.router, prefix="/api")
|
|||||||
app.include_router(reports.router, prefix="/api")
|
app.include_router(reports.router, prefix="/api")
|
||||||
app.include_router(admin.router, prefix="/api")
|
app.include_router(admin.router, prefix="/api")
|
||||||
|
|
||||||
|
# ─── Метрики Prometheus ───────────────────────────────────────────────────────
|
||||||
|
# HTTP-метрики (кол-во/латентность запросов по хендлерам) на /metrics.
|
||||||
|
# Prometheus скрейпит их (см. infra/prometheus/prometheus.yml, профиль observability).
|
||||||
|
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
# ─── WebSocket ────────────────────────────────────────────────────────────────
|
# ─── WebSocket ────────────────────────────────────────────────────────────────
|
||||||
@app.websocket("/ws/tasks/{public_id}")
|
@app.websocket("/ws/tasks/{public_id}")
|
||||||
@@ -98,6 +104,7 @@ async def websocket_task_updates(
|
|||||||
ownership проверяется до установки соединения.
|
ownership проверяется до установки соединения.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models.task import Task
|
from app.models.task import Task
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import func, String
|
from sqlalchemy import String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.models.task import Task
|
|
||||||
from app.models.document import UsageLog
|
from app.models.document import UsageLog
|
||||||
|
from app.models.task import Task
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ minio==7.2.7
|
|||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
|
prometheus-fastapi-instrumentator==7.0.0 # /metrics для Prometheus (профиль observability)
|
||||||
|
|||||||
42
services/worker-gost/app/bibliography.py
Normal file
42
services/worker-gost/app/bibliography.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Сборка списка литературы по ГОСТ — чистая логика (без Celery/БД).
|
||||||
|
|
||||||
|
Сортирует источники (кириллица → латиница), нумерует и форматирует каждый по
|
||||||
|
выбранному стилю. Вынесено из Celery-задачи для изолированного тестирования
|
||||||
|
порядка и нумерации — того, что студент видит в готовом списке литературы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.formatters.gost_7_0_5 import GOST705Formatter
|
||||||
|
from app.formatters.gost_7_1 import GOST71Formatter, _get_sort_key
|
||||||
|
|
||||||
|
|
||||||
|
def build_bibliography(docs: list[dict[str, Any]], style: str = "7.1") -> dict[str, Any]:
|
||||||
|
"""Собрать нумерованный список литературы по ГОСТ.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
docs: словари документов (title/authors/year/...); каждый должен иметь "id"
|
||||||
|
style: "7.1" (полное описание) или иначе — "7.0.5" (краткая ссылка)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с полями:
|
||||||
|
bibliography: list of {"number", "citation", "doc_id"}
|
||||||
|
style: применённый стиль
|
||||||
|
total: число записей
|
||||||
|
"""
|
||||||
|
cite: Callable[[dict[str, Any]], str] = (
|
||||||
|
GOST71Formatter().format_full if style == "7.1" else GOST705Formatter().format_short
|
||||||
|
)
|
||||||
|
|
||||||
|
sorted_docs = sorted(docs, key=_get_sort_key)
|
||||||
|
bibliography = [
|
||||||
|
{"number": number, "citation": cite(doc), "doc_id": doc["id"]}
|
||||||
|
for number, doc in enumerate(sorted_docs, 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bibliography": bibliography,
|
||||||
|
"style": style,
|
||||||
|
"total": len(bibliography),
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Подключение к PostgreSQL для gost-воркера."""
|
"""Подключение к PostgreSQL для gost-воркера."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
- Место публикации через "/"
|
- Место публикации через "/"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def _format_author(author: dict) -> str:
|
def _format_author(author: dict) -> str:
|
||||||
@@ -245,10 +244,7 @@ def _get_sort_key(doc: dict) -> str:
|
|||||||
Строка для сортировки
|
Строка для сортировки
|
||||||
"""
|
"""
|
||||||
authors = doc.get("authors", [])
|
authors = doc.get("authors", [])
|
||||||
if authors:
|
last_name = authors[0].get("last_name", "") if authors else doc.get("title", "")
|
||||||
last_name = authors[0].get("last_name", "")
|
|
||||||
else:
|
|
||||||
last_name = doc.get("title", "")
|
|
||||||
|
|
||||||
if not last_name:
|
if not last_name:
|
||||||
return "яяя" # В конец
|
return "яяя" # В конец
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"""Celery задача форматирования библиографии по ГОСТ."""
|
"""Celery задача форматирования библиографии по ГОСТ."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.bibliography import build_bibliography
|
||||||
from app.celery_app import celery_app
|
from app.celery_app import celery_app
|
||||||
from app.db import db_session
|
from app.db import db_session
|
||||||
from app.formatters.gost_7_0_5 import GOST705Formatter
|
|
||||||
from app.formatters.gost_7_1 import GOST71Formatter, _get_sort_key
|
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
@@ -60,9 +58,6 @@ def format_bibliography(
|
|||||||
if not docs:
|
if not docs:
|
||||||
raise ValueError(f"Документы не найдены: {doc_ids}")
|
raise ValueError(f"Документы не найдены: {doc_ids}")
|
||||||
|
|
||||||
# Выбрать форматтер
|
|
||||||
formatter = GOST71Formatter() if style == "7.1" else GOST705Formatter()
|
|
||||||
|
|
||||||
# Преобразовать ORM объекты в словари для форматирования
|
# Преобразовать ORM объекты в словари для форматирования
|
||||||
docs_dicts = []
|
docs_dicts = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
@@ -81,27 +76,8 @@ def format_bibliography(
|
|||||||
"source": doc.source,
|
"source": doc.source,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Сортировать: кириллица (рус. авторы) → латиница (иностр.)
|
# Сортировка + нумерация + форматирование — чистая логика в app.bibliography
|
||||||
sorted_docs = sorted(docs_dicts, key=lambda d: _get_sort_key(d))
|
result = build_bibliography(docs_dicts, style)
|
||||||
|
|
||||||
bibliography = []
|
|
||||||
for i, doc_dict in enumerate(sorted_docs, 1):
|
|
||||||
if style == "7.1":
|
|
||||||
citation = formatter.format_full(doc_dict)
|
|
||||||
else:
|
|
||||||
citation = formatter.format_short(doc_dict)
|
|
||||||
|
|
||||||
bibliography.append({
|
|
||||||
"number": i,
|
|
||||||
"citation": citation,
|
|
||||||
"doc_id": doc_dict["id"],
|
|
||||||
})
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"bibliography": bibliography,
|
|
||||||
"style": style,
|
|
||||||
"total": len(bibliography),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Сохранить результат
|
# Сохранить результат
|
||||||
task = session.get(Task, task_id)
|
task = session.get(Task, task_id)
|
||||||
@@ -113,7 +89,7 @@ def format_bibliography(
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Библиография сформирована для задачи {task_id!r}: "
|
f"Библиография сформирована для задачи {task_id!r}: "
|
||||||
f"{len(bibliography)} записей по ГОСТ {style}"
|
f"{result['total']} записей по ГОСТ {style}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Уведомить пользователя
|
# Уведомить пользователя
|
||||||
@@ -139,4 +115,4 @@ def format_bibliography(
|
|||||||
except Exception as db_exc:
|
except Exception as db_exc:
|
||||||
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
|
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
|
||||||
|
|
||||||
raise self.retry(exc=exc, countdown=30)
|
raise self.retry(exc=exc, countdown=30) from exc
|
||||||
|
|||||||
6
services/worker-gost/conftest.py
Normal file
6
services/worker-gost/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
3
services/worker-gost/pytest.ini
Normal file
3
services/worker-gost/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
2
services/worker-gost/requirements-test.txt
Normal file
2
services/worker-gost/requirements-test.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Зависимости для юнит-тестов worker-gost (ГОСТ-форматтеры — чистая логика).
|
||||||
|
pytest==8.2.0
|
||||||
59
services/worker-gost/tests/test_bibliography.py
Normal file
59
services/worker-gost/tests/test_bibliography.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Юнит-тесты сборки списка литературы по ГОСТ (порядок и нумерация)."""
|
||||||
|
|
||||||
|
from app.bibliography import build_bibliography
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(doc_id: int, last_name: str, year: int = 2020) -> dict:
|
||||||
|
return {
|
||||||
|
"id": doc_id,
|
||||||
|
"title": "Некоторое название работы",
|
||||||
|
"authors": [{"last_name": last_name}],
|
||||||
|
"year": year,
|
||||||
|
"journal": "Вестник",
|
||||||
|
"lang": "ru",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list():
|
||||||
|
out = build_bibliography([], style="7.1")
|
||||||
|
assert out == {"bibliography": [], "style": "7.1", "total": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_numbering_is_sequential_from_one():
|
||||||
|
docs = [_doc(10, "Борисов"), _doc(20, "Александров"), _doc(30, "Васильев")]
|
||||||
|
out = build_bibliography(docs, style="7.1")
|
||||||
|
assert [b["number"] for b in out["bibliography"]] == [1, 2, 3]
|
||||||
|
assert out["total"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_alphabetically_within_cyrillic():
|
||||||
|
docs = [_doc(1, "Яковлев"), _doc(2, "Абрамов"), _doc(3, "Миронов")]
|
||||||
|
out = build_bibliography(docs, style="7.1")
|
||||||
|
order = [b["doc_id"] for b in out["bibliography"]]
|
||||||
|
assert order == [2, 3, 1] # Абрамов, Миронов, Яковлев
|
||||||
|
|
||||||
|
|
||||||
|
def test_cyrillic_sorted_before_latin():
|
||||||
|
docs = [_doc(1, "Adams"), _doc(2, "Яковлев")]
|
||||||
|
out = build_bibliography(docs, style="7.1")
|
||||||
|
# русский источник идёт первым, иностранный — после
|
||||||
|
assert [b["doc_id"] for b in out["bibliography"]] == [2, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_7_1_uses_full_citation():
|
||||||
|
out = build_bibliography([_doc(1, "Иванов")], style="7.1")
|
||||||
|
citation = out["bibliography"][0]["citation"]
|
||||||
|
assert "//" in citation # полное описание статьи содержит разделитель журнала
|
||||||
|
assert out["style"] == "7.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_7_0_5_uses_short_citation():
|
||||||
|
out = build_bibliography([_doc(1, "Иванов", year=2021)], style="7.0.5")
|
||||||
|
citation = out["bibliography"][0]["citation"]
|
||||||
|
assert citation == "[Иванов, 2021]" # краткая ссылка
|
||||||
|
assert out["style"] == "7.0.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_doc_id_preserved_in_each_entry():
|
||||||
|
out = build_bibliography([_doc(42, "Иванов"), _doc(7, "Абрамов")], style="7.1")
|
||||||
|
assert {b["doc_id"] for b in out["bibliography"]} == {42, 7}
|
||||||
32
services/worker-gost/tests/test_gost_7_0_5.py
Normal file
32
services/worker-gost/tests/test_gost_7_0_5.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Юнит-тесты кратких библиографических ссылок по ГОСТ Р 7.0.5-2008."""
|
||||||
|
|
||||||
|
from app.formatters.gost_7_0_5 import GOST705Formatter, format_short
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_author_and_year():
|
||||||
|
assert format_short({"authors": [{"last_name": "Иванов"}], "year": 2023}) == "[Иванов, 2023]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_without_author_uses_title():
|
||||||
|
out = format_short({"title": "Большая книга про всё сразу", "year": 2020})
|
||||||
|
assert out == "[Большая книга про..., 2020]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_no_author_no_title():
|
||||||
|
assert format_short({"year": 2019}) == "[2019]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_missing_year_defaults():
|
||||||
|
assert format_short({"authors": [{"last_name": "Петров"}]}) == "[Петров, б. г.]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_with_page():
|
||||||
|
f = GOST705Formatter()
|
||||||
|
doc = {"authors": [{"last_name": "Иванов"}], "year": 2023}
|
||||||
|
assert f.format_inline(doc, page="15") == "[Иванов, 2023, с. 15]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_without_page():
|
||||||
|
f = GOST705Formatter()
|
||||||
|
doc = {"authors": [{"last_name": "Иванов"}], "year": 2023}
|
||||||
|
assert f.format_inline(doc) == "[Иванов, 2023]"
|
||||||
80
services/worker-gost/tests/test_gost_7_1.py
Normal file
80
services/worker-gost/tests/test_gost_7_1.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Юнит-тесты полного библиографического описания по ГОСТ 7.1-2003."""
|
||||||
|
|
||||||
|
from app.formatters.gost_7_1 import (
|
||||||
|
GOST71Formatter,
|
||||||
|
_format_author,
|
||||||
|
_format_authors,
|
||||||
|
_get_sort_key,
|
||||||
|
format_full,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_with_initials():
|
||||||
|
assert _format_author({"last_name": "Иванов", "initials": "И. И."}) == "Иванов И. И."
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_initials_get_trailing_dot():
|
||||||
|
assert _format_author({"last_name": "Иванов", "initials": "И. И"}) == "Иванов И. И."
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_from_first_name():
|
||||||
|
assert (
|
||||||
|
_format_author({"last_name": "Петров", "first_name": "Пётр Петрович"})
|
||||||
|
== "Петров П.П."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_last_name_only():
|
||||||
|
assert _format_author({"last_name": "Сидоров"}) == "Сидоров"
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_empty():
|
||||||
|
assert _format_author({}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_up_to_three_all_listed():
|
||||||
|
authors = [{"last_name": "А"}, {"last_name": "Б"}, {"last_name": "В"}]
|
||||||
|
assert _format_authors(authors) == "А, Б, В"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_four_plus_truncated_ru():
|
||||||
|
authors = [{"last_name": n} for n in ("А", "Б", "В", "Г")]
|
||||||
|
assert _format_authors(authors, lang="ru") == "А, Б, В, и др."
|
||||||
|
|
||||||
|
|
||||||
|
def test_authors_four_plus_truncated_en():
|
||||||
|
authors = [{"last_name": n} for n in ("A", "B", "C", "D")]
|
||||||
|
assert _format_authors(authors, lang="en") == "A, B, C, et al."
|
||||||
|
|
||||||
|
|
||||||
|
def test_article_has_journal_year_and_doi():
|
||||||
|
doc = {
|
||||||
|
"title": "Заголовок статьи",
|
||||||
|
"journal": "Вестник науки",
|
||||||
|
"year": 2023,
|
||||||
|
"volume": "5",
|
||||||
|
"issue": "2",
|
||||||
|
"pages": "10-20",
|
||||||
|
"doi": "10.1234/abc",
|
||||||
|
"authors": [{"last_name": "Иванов", "initials": "И.И."}],
|
||||||
|
}
|
||||||
|
out = format_full(doc)
|
||||||
|
assert out.startswith("Иванов И.И.. Заголовок статьи")
|
||||||
|
assert "// Вестник науки" in out
|
||||||
|
assert "— 2023" in out
|
||||||
|
assert "Т. 5" in out and "№ 2" in out
|
||||||
|
assert "С. 10-20" in out
|
||||||
|
assert out.endswith("DOI: 10.1234/abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_resource_marked_and_has_url():
|
||||||
|
doc = {"title": "Портал", "url": "https://example.org", "year": 2024}
|
||||||
|
out = GOST71Formatter().format_full(doc)
|
||||||
|
assert "[Электронный ресурс]" in out
|
||||||
|
assert "URL: https://example.org" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_key_cyrillic_before_latin():
|
||||||
|
ru = _get_sort_key({"authors": [{"last_name": "Яковлев"}]})
|
||||||
|
en = _get_sort_key({"authors": [{"last_name": "Adams"}]})
|
||||||
|
assert ru < en # кириллица (префикс 0) сортируется раньше латиницы (префикс 1)
|
||||||
@@ -48,6 +48,12 @@ class Settings(BaseSettings):
|
|||||||
FAISS_NLIST: int = 1024 # Количество кластеров для IVFFlat
|
FAISS_NLIST: int = 1024 # Количество кластеров для IVFFlat
|
||||||
FAISS_NPROBE: int = 64 # Количество кластеров для поиска
|
FAISS_NPROBE: int = 64 # Количество кластеров для поиска
|
||||||
|
|
||||||
|
# Векторный бэкенд: "faiss" (файловый синглтон, дефолт) или "qdrant" (сервис,
|
||||||
|
# снимает SPOF и конкурентную запись). Переключается без изменения кода.
|
||||||
|
VECTOR_BACKEND: str = "faiss"
|
||||||
|
QDRANT_URL: str = "http://qdrant:6333"
|
||||||
|
QDRANT_COLLECTION: str = "documents"
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_URL: str = "https://academic.jze9.ru"
|
APP_URL: str = "https://academic.jze9.ru"
|
||||||
ENVIRONMENT: str = "development"
|
ENVIRONMENT: str = "development"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Синхронное подключение к PostgreSQL для Celery воркеров."""
|
"""Синхронное подключение к PostgreSQL для Celery воркеров."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
import redis as redis_lib
|
import redis as redis_lib
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from elasticsearch import Elasticsearch, exceptions as es_exceptions
|
from elasticsearch import Elasticsearch
|
||||||
|
from elasticsearch import exceptions as es_exceptions
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
миллионов документов) полный перебор по FlatIP по скорости приемлем.
|
миллионов документов) полный перебор по FlatIP по скорости приемлем.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -88,7 +89,7 @@ class FAISSManager:
|
|||||||
distances, ids = cls._index.search(query, min(k, cls._index.ntotal))
|
distances, ids = cls._index.search(query, min(k, cls._index.ntotal))
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for idx, dist in zip(ids[0], distances[0]):
|
for idx, dist in zip(ids[0], distances[0], strict=False):
|
||||||
if idx == -1:
|
if idx == -1:
|
||||||
continue
|
continue
|
||||||
# Для IDMap2 idx — это уже doc_id из PostgreSQL
|
# Для IDMap2 idx — это уже doc_id из PostgreSQL
|
||||||
@@ -120,10 +121,8 @@ class FAISSManager:
|
|||||||
ids = np.asarray(doc_ids, dtype=np.int64)
|
ids = np.asarray(doc_ids, dtype=np.int64)
|
||||||
|
|
||||||
# Удалить существующие id, чтобы повторный эмбеддинг не создавал дубли
|
# Удалить существующие id, чтобы повторный эмбеддинг не создавал дубли
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
cls._index.remove_ids(ids)
|
cls._index.remove_ids(ids)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
cls._index.add_with_ids(vectors, ids)
|
cls._index.add_with_ids(vectors, ids)
|
||||||
logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}")
|
logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}")
|
||||||
|
|||||||
47
services/worker-gpu/app/migrate_faiss_to_qdrant.py
Normal file
47
services/worker-gpu/app/migrate_faiss_to_qdrant.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Одноразовая миграция векторов из файлового FAISS в Qdrant.
|
||||||
|
|
||||||
|
Запуск (после старта qdrant и выставления VECTOR_BACKEND=qdrant в .env):
|
||||||
|
docker compose -f docker-compose.prod.yml --profile qdrant up -d qdrant
|
||||||
|
docker compose -f docker-compose.prod.yml exec worker-gpu python -m app.migrate_faiss_to_qdrant
|
||||||
|
|
||||||
|
Идемпотентна: повторный запуск перезапишет те же точки по doc_id, дублей не будет.
|
||||||
|
После миграции стоит сверить: QdrantManager.total_vectors() == count(documents).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("migrate_faiss_to_qdrant")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(batch_size: int = 1000) -> int:
|
||||||
|
"""Перелить все векторы из FAISS-индекса в Qdrant. Возвращает число векторов."""
|
||||||
|
import faiss
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.qdrant_manager import QdrantManager
|
||||||
|
|
||||||
|
index = faiss.read_index(settings.FAISS_INDEX_PATH)
|
||||||
|
if not hasattr(index, "id_map"):
|
||||||
|
raise SystemExit("FAISS-индекс без id_map (несовместимый тип) — миграция невозможна")
|
||||||
|
|
||||||
|
ids = faiss.vector_to_array(index.id_map).astype(np.int64)
|
||||||
|
total = int(len(ids))
|
||||||
|
logger.info("FAISS: %d векторов к миграции в Qdrant", total)
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
for start in range(0, total, batch_size):
|
||||||
|
chunk = ids[start : start + batch_size]
|
||||||
|
vecs = np.vstack([index.reconstruct(int(i)) for i in chunk]).astype(np.float32)
|
||||||
|
QdrantManager.add_vectors(vecs, [int(i) for i in chunk])
|
||||||
|
migrated += len(chunk)
|
||||||
|
logger.info("Мигрировано %d/%d", migrated, total)
|
||||||
|
|
||||||
|
logger.info("Готово. Точек в Qdrant: %d", QdrantManager.total_vectors())
|
||||||
|
return migrated
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
"""Базовые модели данных для GPU воркера (минимальный набор для работы с БД)."""
|
"""Базовые модели данных для GPU воркера (минимальный набор для работы с БД)."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, BigInteger, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import JSON, ForeignKey, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
|
|||||||
104
services/worker-gpu/app/qdrant_manager.py
Normal file
104
services/worker-gpu/app/qdrant_manager.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Менеджер векторного индекса на Qdrant — альтернатива FAISSManager.
|
||||||
|
|
||||||
|
Тот же classmethod-интерфейс (search / add_vectors / save / total_vectors), но
|
||||||
|
вместо файлового синглтона в RAM одного воркера — сетевой сервис Qdrant. Это:
|
||||||
|
- снимает единую точку отказа (индекс живёт отдельно от воркера);
|
||||||
|
- допускает конкурентную запись из нескольких воркеров (upsert по doc_id);
|
||||||
|
- масштабируется горизонтально и переживает рестарт воркера без перезагрузки.
|
||||||
|
|
||||||
|
Дистанция — Cosine; эмбеддинги нормированы, id точки = doc_id из PostgreSQL,
|
||||||
|
поэтому поиск сразу возвращает doc_id. Включается через VECTOR_BACKEND=qdrant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QdrantManager:
|
||||||
|
"""Синглтон-обёртка над коллекцией Qdrant (интерфейс как у FAISSManager)."""
|
||||||
|
|
||||||
|
COLLECTION = settings.QDRANT_COLLECTION
|
||||||
|
|
||||||
|
_client = None
|
||||||
|
_ready = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_client(cls):
|
||||||
|
"""Ленивое подключение к Qdrant (или заранее внедрённый клиент в тестах)."""
|
||||||
|
if cls._client is None:
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
|
||||||
|
cls._client = QdrantClient(url=settings.QDRANT_URL, timeout=30)
|
||||||
|
return cls._client
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _ensure(cls):
|
||||||
|
"""Гарантировать существование коллекции нужной размерности/дистанции."""
|
||||||
|
client = cls._get_client()
|
||||||
|
if cls._ready:
|
||||||
|
return client
|
||||||
|
|
||||||
|
from qdrant_client.models import Distance, VectorParams
|
||||||
|
|
||||||
|
if not client.collection_exists(cls.COLLECTION):
|
||||||
|
client.create_collection(
|
||||||
|
collection_name=cls.COLLECTION,
|
||||||
|
vectors_config=VectorParams(
|
||||||
|
size=settings.EMBED_DIM, distance=Distance.COSINE
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info(f"Создана коллекция Qdrant {cls.COLLECTION!r} (dim={settings.EMBED_DIM})")
|
||||||
|
cls._ready = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, query_vector: np.ndarray, k: int = 20) -> list[tuple[int, float]]:
|
||||||
|
"""Поиск k ближайших векторов. Возвращает [(doc_id, cosine_score), ...]."""
|
||||||
|
client = cls._ensure()
|
||||||
|
vec = np.asarray(query_vector, dtype=np.float32).reshape(-1).tolist()
|
||||||
|
try:
|
||||||
|
res = client.query_points(collection_name=cls.COLLECTION, query=vec, limit=k)
|
||||||
|
return [(int(p.id), float(p.score)) for p in res.points]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка поиска Qdrant: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_vectors(cls, vectors: np.ndarray, doc_ids: list[int]) -> None:
|
||||||
|
"""Upsert векторов по doc_id (идемпотентно: повторная запись перезаписывает)."""
|
||||||
|
if len(doc_ids) == 0:
|
||||||
|
return
|
||||||
|
client = cls._ensure()
|
||||||
|
|
||||||
|
from qdrant_client.models import PointStruct
|
||||||
|
|
||||||
|
vectors = np.asarray(vectors, dtype=np.float32)
|
||||||
|
points = [
|
||||||
|
PointStruct(id=int(doc_id), vector=vectors[i].tolist())
|
||||||
|
for i, doc_id in enumerate(doc_ids)
|
||||||
|
]
|
||||||
|
client.upsert(collection_name=cls.COLLECTION, points=points)
|
||||||
|
logger.info(f"Upsert {len(points)} векторов в Qdrant. Всего: {cls.total_vectors()}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save(cls) -> None:
|
||||||
|
"""No-op: Qdrant персистит данные на своей стороне."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def total_vectors(cls) -> int:
|
||||||
|
"""Количество точек в коллекции."""
|
||||||
|
try:
|
||||||
|
return cls._ensure().count(collection_name=cls.COLLECTION).count
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _reset(cls) -> None:
|
||||||
|
"""Сбросить клиент и флаг готовности (для тестов)."""
|
||||||
|
cls._client = None
|
||||||
|
cls._ready = False
|
||||||
59
services/worker-gpu/app/scoring.py
Normal file
59
services/worker-gpu/app/scoring.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Чистая доменная логика скоринга плагиата — без Celery/БД/сети.
|
||||||
|
|
||||||
|
Объединяет совпадения всех уровней (1 winnowing, 2 minhash, 3+4 semantic+llm),
|
||||||
|
дедуплицирует их и считает итоговый процент схожести, который видит студент.
|
||||||
|
Вынесено из Celery-задачи, чтобы логику можно было тестировать изолированно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_results(
|
||||||
|
level1_matches: list[dict[str, Any]],
|
||||||
|
level2_matches: list[dict[str, Any]],
|
||||||
|
semantic_matches: list[dict[str, Any]],
|
||||||
|
total_fragments: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Свести совпадения уровней в итог проверки.
|
||||||
|
|
||||||
|
Дедупликация — по паре (source_title, position_start): одно и то же совпадение
|
||||||
|
источника в одной позиции не дублируется. Итоговый процент — доля УНИКАЛЬНЫХ
|
||||||
|
помеченных позиций от всех фрагментов (фрагмент, совпавший с несколькими
|
||||||
|
источниками, не раздувает процент выше 100).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level1_matches: совпадения уровня 1 (Winnowing, точные)
|
||||||
|
level2_matches: совпадения уровня 2 (MinHash, нечёткие)
|
||||||
|
semantic_matches: совпадения уровней 3-4 (FAISS + LLM-парафраз)
|
||||||
|
total_fragments: всего проверенных фрагментов документа
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с полями overall_similarity, matches, total_fragments,
|
||||||
|
flagged_fragments, by_method.
|
||||||
|
"""
|
||||||
|
all_matches = level1_matches + level2_matches + semantic_matches
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique_matches: list[dict[str, Any]] = []
|
||||||
|
for m in all_matches:
|
||||||
|
key = f"{m.get('source_title', '')}:{m.get('position_start', '')}"
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
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)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"overall_similarity": round(overall, 2),
|
||||||
|
"matches": unique_matches,
|
||||||
|
"total_fragments": total_fragments,
|
||||||
|
"flagged_fragments": flagged_frags,
|
||||||
|
"by_method": {
|
||||||
|
"exact": len(level1_matches),
|
||||||
|
"fuzzy": len(level2_matches),
|
||||||
|
"semantic_llm": len(semantic_matches),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Celery задачи проверки плагиата (уровни 3 и 4) и построения эмбеддингов."""
|
"""Celery задачи проверки плагиата (уровни 3 и 4) и построения эмбеддингов."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
@@ -93,11 +92,12 @@ def check_plagiarism(
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.faiss_manager import FAISSManager
|
|
||||||
from app.model_manager import ModelManager
|
from app.model_manager import ModelManager
|
||||||
from app.ollama_client import OllamaClient
|
from app.ollama_client import OllamaClient
|
||||||
|
from app.vector_store import get_backend
|
||||||
|
|
||||||
ollama = OllamaClient()
|
ollama = OllamaClient()
|
||||||
|
vector_store = get_backend()
|
||||||
semantic_matches: list[dict] = []
|
semantic_matches: list[dict] = []
|
||||||
|
|
||||||
for i, fragment in enumerate(fragments):
|
for i, fragment in enumerate(fragments):
|
||||||
@@ -106,9 +106,9 @@ def check_plagiarism(
|
|||||||
# Пропустить слишком короткие фрагменты
|
# Пропустить слишком короткие фрагменты
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Уровень 3: Семантический поиск через FAISS
|
# Уровень 3: Семантический поиск (FAISS или Qdrant по VECTOR_BACKEND)
|
||||||
frag_vec = ModelManager.encode_single(frag_text)
|
frag_vec = ModelManager.encode_single(frag_text)
|
||||||
faiss_results = FAISSManager.search(frag_vec, k=10)
|
faiss_results = vector_store.search(frag_vec, k=10)
|
||||||
|
|
||||||
for doc_id, score in faiss_results:
|
for doc_id, score in faiss_results:
|
||||||
if score < FAISS_SIMILARITY_THRESHOLD:
|
if score < FAISS_SIMILARITY_THRESHOLD:
|
||||||
@@ -142,36 +142,12 @@ def check_plagiarism(
|
|||||||
if (i + 1) % 10 == 0:
|
if (i + 1) % 10 == 0:
|
||||||
logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}")
|
logger.info(f"Проверено фрагментов: {i + 1}/{len(fragments)}")
|
||||||
|
|
||||||
# Объединить все совпадения и дедуплицировать по source_title
|
# Свести совпадения уровней в итог (дедуп + процент) — чистая логика в app.scoring
|
||||||
all_matches = level1_matches + level2_matches + semantic_matches
|
from app.scoring import aggregate_results
|
||||||
seen_sources: set[str] = set()
|
|
||||||
unique_matches = []
|
|
||||||
for m in all_matches:
|
|
||||||
key = f"{m.get('source_title', '')}:{m.get('position_start', '')}"
|
|
||||||
if key not in seen_sources:
|
|
||||||
seen_sources.add(key)
|
|
||||||
unique_matches.append(m)
|
|
||||||
|
|
||||||
# Вычислить общий процент схожести по ДОЛЕ помеченных фрагментов документа.
|
result = aggregate_results(
|
||||||
# Считаем уникальные позиции (фрагмент, совпавший с несколькими источниками,
|
level1_matches, level2_matches, semantic_matches, len(fragments)
|
||||||
# не должен раздувать процент выше 100).
|
)
|
||||||
total_frags = len(fragments)
|
|
||||||
flagged_positions = {m.get("position_start") for m in unique_matches}
|
|
||||||
flagged_frags = len(flagged_positions)
|
|
||||||
overall_similarity = (flagged_frags / total_frags * 100) if total_frags > 0 else 0.0
|
|
||||||
overall_similarity = min(overall_similarity, 100.0)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"overall_similarity": round(overall_similarity, 2),
|
|
||||||
"matches": unique_matches,
|
|
||||||
"total_fragments": total_frags,
|
|
||||||
"flagged_fragments": flagged_frags,
|
|
||||||
"by_method": {
|
|
||||||
"exact": len(level1_matches),
|
|
||||||
"fuzzy": len(level2_matches),
|
|
||||||
"semantic_llm": len(semantic_matches),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Сохранить результат
|
# Сохранить результат
|
||||||
with db_session() as session:
|
with db_session() as session:
|
||||||
@@ -184,7 +160,8 @@ def check_plagiarism(
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Проверка плагиата завершена для задачи {task_id!r}. "
|
f"Проверка плагиата завершена для задачи {task_id!r}. "
|
||||||
f"Схожесть: {overall_similarity:.1f}%, совпадений: {flagged_frags}"
|
f"Схожесть: {result['overall_similarity']:.1f}%, "
|
||||||
|
f"совпадений: {result['flagged_fragments']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Уведомить пользователя
|
# Уведомить пользователя
|
||||||
@@ -209,7 +186,7 @@ def check_plagiarism(
|
|||||||
except Exception as db_exc:
|
except Exception as db_exc:
|
||||||
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
|
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
|
||||||
|
|
||||||
raise self.retry(exc=exc, countdown=120)
|
raise self.retry(exc=exc, countdown=120) from exc
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="gpu.embed_documents")
|
@celery_app.task(name="gpu.embed_documents")
|
||||||
@@ -225,10 +202,9 @@ def embed_documents(doc_ids: list[int]) -> dict[str, Any]:
|
|||||||
if not doc_ids:
|
if not doc_ids:
|
||||||
return {"status": "ok", "embedded": 0}
|
return {"status": "ok", "embedded": 0}
|
||||||
|
|
||||||
from app.models import Document
|
|
||||||
from app.faiss_manager import FAISSManager
|
|
||||||
from app.model_manager import ModelManager
|
from app.model_manager import ModelManager
|
||||||
from sqlalchemy import select
|
from app.models import Document
|
||||||
|
from app.vector_store import get_backend
|
||||||
|
|
||||||
logger.info(f"Построение эмбеддингов для {len(doc_ids)} документов...")
|
logger.info(f"Построение эмбеддингов для {len(doc_ids)} документов...")
|
||||||
|
|
||||||
@@ -248,11 +224,11 @@ def embed_documents(doc_ids: list[int]) -> dict[str, Any]:
|
|||||||
]
|
]
|
||||||
ids = [d.id for d in docs]
|
ids = [d.id for d in docs]
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
vectors = ModelManager.encode(texts)
|
vectors = ModelManager.encode(texts)
|
||||||
|
|
||||||
FAISSManager.add_vectors(vectors, ids)
|
store = get_backend()
|
||||||
FAISSManager.save()
|
store.add_vectors(vectors, ids)
|
||||||
|
store.save()
|
||||||
|
|
||||||
# Обновить faiss_id в PostgreSQL (для IDMap2 faiss_id == doc_id)
|
# Обновить faiss_id в PostgreSQL (для IDMap2 faiss_id == doc_id)
|
||||||
with db_session() as session:
|
with db_session() as session:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
@@ -223,10 +222,10 @@ def search_semantic(
|
|||||||
from app.model_manager import ModelManager
|
from app.model_manager import ModelManager
|
||||||
query_vec = ModelManager.encode_single(query_normalized)
|
query_vec = ModelManager.encode_single(query_normalized)
|
||||||
|
|
||||||
# FAISS семантический поиск
|
# Семантический поиск (FAISS или Qdrant по VECTOR_BACKEND)
|
||||||
from app.faiss_manager import FAISSManager
|
from app.vector_store import get_backend
|
||||||
faiss_results = FAISSManager.search(query_vec, k=50)
|
faiss_results = get_backend().search(query_vec, k=50)
|
||||||
logger.info(f"FAISS: найдено {len(faiss_results)} результатов")
|
logger.info(f"Векторный поиск: найдено {len(faiss_results)} результатов")
|
||||||
|
|
||||||
# Elasticsearch BM25 поиск
|
# Elasticsearch BM25 поиск
|
||||||
from app.es_client import search_fulltext
|
from app.es_client import search_fulltext
|
||||||
@@ -278,4 +277,4 @@ def search_semantic(
|
|||||||
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
|
logger.error(f"Не удалось обновить статус задачи: {db_exc}")
|
||||||
|
|
||||||
# Повторить попытку
|
# Повторить попытку
|
||||||
raise self.retry(exc=exc, countdown=60)
|
raise self.retry(exc=exc, countdown=60) from exc
|
||||||
|
|||||||
25
services/worker-gpu/app/vector_store.py
Normal file
25
services/worker-gpu/app/vector_store.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Выбор бэкенда векторного индекса по настройке VECTOR_BACKEND.
|
||||||
|
|
||||||
|
Единая точка входа: задачи (search / plagiarism) зовут get_backend().search(...) /
|
||||||
|
.add_vectors(...) / .save(...), не зная, какой бэкенд под капотом — FAISS (файловый
|
||||||
|
синглтон в RAM воркера, дефолт) или Qdrant (сетевой сервис, снимает SPOF и допускает
|
||||||
|
конкурентную запись). Оба класса имеют одинаковый classmethod-интерфейс.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend():
|
||||||
|
"""Вернуть класс активного векторного бэкенда согласно settings.VECTOR_BACKEND."""
|
||||||
|
if settings.VECTOR_BACKEND == "qdrant":
|
||||||
|
from app.qdrant_manager import QdrantManager
|
||||||
|
|
||||||
|
return QdrantManager
|
||||||
|
|
||||||
|
from app.faiss_manager import FAISSManager
|
||||||
|
|
||||||
|
return FAISSManager
|
||||||
6
services/worker-gpu/conftest.py
Normal file
6
services/worker-gpu/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
3
services/worker-gpu/pytest.ini
Normal file
3
services/worker-gpu/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
9
services/worker-gpu/requirements-test.txt
Normal file
9
services/worker-gpu/requirements-test.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Зависимости для юнит-тестов worker-gpu (FAISS-индекс L3 + OllamaClient L4).
|
||||||
|
# torch/sentence-transformers НЕ нужны — тестируется только логика индекса и
|
||||||
|
# парсинг ответа LLM (сеть в тестах замокана).
|
||||||
|
pytest==8.2.0
|
||||||
|
faiss-cpu==1.8.0
|
||||||
|
numpy==1.26.4
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
httpx==0.27.0
|
||||||
|
qdrant-client==1.19.0 # local :memory:-режим → тесты бэкенда без сервера
|
||||||
@@ -4,6 +4,7 @@ sqlalchemy==2.0.30
|
|||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
sentence-transformers==3.0.0
|
sentence-transformers==3.0.0
|
||||||
faiss-cpu==1.8.0 # faiss-gpu нет в pip для py3.11; индексы в коде CPU-типа, GPU занят эмбеддингами (torch) и LLM (Ollama)
|
faiss-cpu==1.8.0 # faiss-gpu нет в pip для py3.11; индексы в коде CPU-типа, GPU занят эмбеддингами (torch) и LLM (Ollama)
|
||||||
|
qdrant-client==1.19.0 # альтернативный векторный бэкенд (VECTOR_BACKEND=qdrant), снимает SPOF FAISS
|
||||||
torch==2.3.0
|
torch==2.3.0
|
||||||
numpy==1.26.4
|
numpy==1.26.4
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
|||||||
88
services/worker-gpu/tests/test_faiss_manager.py
Normal file
88
services/worker-gpu/tests/test_faiss_manager.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Юнит-тесты FAISSManager (уровень 3 — семантический индекс).
|
||||||
|
|
||||||
|
Проверяют то, что было сломано в старой реализации и переписано:
|
||||||
|
- возврат doc_id из PostgreSQL (IndexIDMap2), а не позиционного номера;
|
||||||
|
- идемпотентность add_vectors по doc_id (remove-before-add, без дублей);
|
||||||
|
- корректность self-match (косинус ≈ 1) и ранжирования.
|
||||||
|
|
||||||
|
Работают целиком в памяти — диск (FAISS_INDEX_PATH) не трогается.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("faiss")
|
||||||
|
|
||||||
|
from app.config import settings # noqa: E402
|
||||||
|
from app.faiss_manager import FAISSManager # noqa: E402
|
||||||
|
|
||||||
|
DIM = settings.EMBED_DIM
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(vecs: np.ndarray) -> np.ndarray:
|
||||||
|
"""Нормировать строки к единичной длине (для косинуса через inner product)."""
|
||||||
|
vecs = np.asarray(vecs, dtype=np.float32)
|
||||||
|
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
|
||||||
|
norms[norms == 0] = 1.0
|
||||||
|
return vecs / norms
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_index():
|
||||||
|
"""Свежий пустой индекс в памяти на каждый тест; диск не читаем и не пишем."""
|
||||||
|
FAISSManager._index = FAISSManager._new_index()
|
||||||
|
yield
|
||||||
|
FAISSManager._index = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_search_returns_nothing():
|
||||||
|
assert FAISSManager.search(np.zeros(DIM, dtype=np.float32)) == []
|
||||||
|
assert FAISSManager.total_vectors() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_self_search_returns_postgres_doc_id():
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
vecs = _unit(rng.standard_normal((3, DIM)))
|
||||||
|
FAISSManager.add_vectors(vecs, [101, 202, 303])
|
||||||
|
assert FAISSManager.total_vectors() == 3
|
||||||
|
|
||||||
|
results = FAISSManager.search(vecs[1], k=1)
|
||||||
|
assert results, "поиск ничего не вернул"
|
||||||
|
top_id, score = results[0]
|
||||||
|
assert top_id == 202 # IndexIDMap2 возвращает doc_id, а не позицию
|
||||||
|
assert score == pytest.approx(1.0, abs=1e-4) # self-match: косинус ≈ 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_is_idempotent_by_doc_id():
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
FAISSManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [1, 2])
|
||||||
|
|
||||||
|
# Повторный эмбеддинг тех же id не должен плодить дубли (remove-before-add)
|
||||||
|
updated = _unit(rng.standard_normal((2, DIM)))
|
||||||
|
FAISSManager.add_vectors(updated, [1, 2])
|
||||||
|
assert FAISSManager.total_vectors() == 2
|
||||||
|
|
||||||
|
# ...и поиск возвращает ОБНОВЛЁННЫЙ вектор
|
||||||
|
top_id, score = FAISSManager.search(updated[0], k=1)[0]
|
||||||
|
assert top_id == 1
|
||||||
|
assert score == pytest.approx(1.0, abs=1e-4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_ranks_nearest_first():
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
v = _unit(rng.standard_normal((1, DIM)))[0]
|
||||||
|
near = _unit((v + 0.01 * rng.standard_normal(DIM))[None, :])[0]
|
||||||
|
far = _unit(rng.standard_normal((1, DIM)))[0]
|
||||||
|
FAISSManager.add_vectors(np.stack([near, far]), [10, 20])
|
||||||
|
|
||||||
|
results = FAISSManager.search(v, k=2)
|
||||||
|
assert [doc_id for doc_id, _ in results][0] == 10 # ближайший — near
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_respects_k_and_index_size():
|
||||||
|
rng = np.random.default_rng(1)
|
||||||
|
FAISSManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [5, 6])
|
||||||
|
# k больше числа векторов не должно приводить к падению или id == -1
|
||||||
|
results = FAISSManager.search(_unit(rng.standard_normal((1, DIM)))[0], k=100)
|
||||||
|
assert len(results) == 2
|
||||||
|
assert all(doc_id in (5, 6) for doc_id, _ in results)
|
||||||
129
services/worker-gpu/tests/test_ollama_client.py
Normal file
129
services/worker-gpu/tests/test_ollama_client.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Юнит-тесты OllamaClient (уровень 4 — LLM-анализ парафраза).
|
||||||
|
|
||||||
|
Сеть замокана: проверяется устойчивость парсинга ответа модели и корректные
|
||||||
|
фолбэки при недоступности/ошибках — кривой вывод LLM не должен ронять проверку.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from app import ollama_client as oc
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, payload: dict):
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def _returns(payload: dict):
|
||||||
|
def _fake(*args, **kwargs):
|
||||||
|
return _FakeResponse(payload)
|
||||||
|
return _fake
|
||||||
|
|
||||||
|
|
||||||
|
def _raises(exc: Exception):
|
||||||
|
def _fake(*args, **kwargs):
|
||||||
|
raise exc
|
||||||
|
return _fake
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return oc.OllamaClient()
|
||||||
|
|
||||||
|
|
||||||
|
def test_paraphrase_parses_and_normalizes(monkeypatch, client):
|
||||||
|
payload = {"response": json.dumps(
|
||||||
|
{"is_paraphrase": True, "confidence": 0.87, "reason": "те же идеи"}
|
||||||
|
)}
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _returns(payload))
|
||||||
|
assert client.check_paraphrase("оригинал текста", "перефраз того же") == {
|
||||||
|
"is_paraphrase": True,
|
||||||
|
"confidence": 0.87,
|
||||||
|
"reason": "те же идеи",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paraphrase_coerces_types(monkeypatch, client):
|
||||||
|
# confidence пришёл как int, reason отсутствует
|
||||||
|
payload = {"response": json.dumps({"is_paraphrase": False, "confidence": 1})}
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _returns(payload))
|
||||||
|
out = client.check_paraphrase("a", "b")
|
||||||
|
assert out["is_paraphrase"] is False
|
||||||
|
assert isinstance(out["confidence"], float) and out["confidence"] == 1.0
|
||||||
|
assert out["reason"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_paraphrase_malformed_json_is_safe(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _returns({"response": "не JSON, а болтовня"}))
|
||||||
|
out = client.check_paraphrase("a", "b")
|
||||||
|
assert out["is_paraphrase"] is False
|
||||||
|
assert out["confidence"] == 0.0
|
||||||
|
assert "парс" in out["reason"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_paraphrase_missing_response_key_defaults(monkeypatch, client):
|
||||||
|
# нет ключа "response" → дефолт "{}" → пустой объект → безопасные значения
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _returns({}))
|
||||||
|
assert client.check_paraphrase("a", "b") == {
|
||||||
|
"is_paraphrase": False,
|
||||||
|
"confidence": 0.0,
|
||||||
|
"reason": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_paraphrase_timeout_returns_unavailable(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _raises(httpx.TimeoutException("timeout")))
|
||||||
|
out = client.check_paraphrase("a", "b")
|
||||||
|
assert out["is_paraphrase"] is False
|
||||||
|
assert out["reason"] == "LLM недоступна"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paraphrase_connect_error_returns_unavailable(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _raises(httpx.ConnectError("no route")))
|
||||||
|
assert client.check_paraphrase("a", "b")["reason"] == "LLM недоступна"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paraphrase_unexpected_error_is_caught(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _raises(ValueError("boom")))
|
||||||
|
out = client.check_paraphrase("a", "b")
|
||||||
|
assert out["is_paraphrase"] is False
|
||||||
|
assert out["confidence"] == 0.0
|
||||||
|
assert out["reason"] == "boom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_returns_stripped_response(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _returns({"response": " Краткое изложение. "}))
|
||||||
|
assert client.summarize("Заголовок", "Аннотация") == "Краткое изложение."
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_falls_back_to_abstract_on_error(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _raises(RuntimeError("down")))
|
||||||
|
assert client.summarize("Заголовок", "Аннотация про исследование") == (
|
||||||
|
"Аннотация про исследование"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_falls_back_to_title_when_no_abstract(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(oc.httpx, "post", _raises(RuntimeError("down")))
|
||||||
|
assert client.summarize("Только заголовок", "") == "Только заголовок"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_available_true_on_200(monkeypatch, client):
|
||||||
|
class _R:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
monkeypatch.setattr(oc.httpx, "get", lambda *a, **k: _R())
|
||||||
|
assert client.is_available() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_available_false_on_exception(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(oc.httpx, "get", _raises(httpx.ConnectError("x")))
|
||||||
|
assert client.is_available() is False
|
||||||
83
services/worker-gpu/tests/test_qdrant_manager.py
Normal file
83
services/worker-gpu/tests/test_qdrant_manager.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Юнит-тесты QdrantManager против встроенного Qdrant (:memory:) — реальный бэкенд.
|
||||||
|
|
||||||
|
Не моки: qdrant-client в local-режиме поднимает in-process Qdrant, поэтому
|
||||||
|
проверяется настоящее поведение коллекции (косинус, upsert, count).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("qdrant_client")
|
||||||
|
|
||||||
|
from app.config import settings # noqa: E402
|
||||||
|
from app.qdrant_manager import QdrantManager # noqa: E402
|
||||||
|
from qdrant_client import QdrantClient # noqa: E402
|
||||||
|
|
||||||
|
DIM = settings.EMBED_DIM
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(vecs: np.ndarray) -> np.ndarray:
|
||||||
|
vecs = np.asarray(vecs, dtype=np.float32)
|
||||||
|
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
|
||||||
|
norms[norms == 0] = 1.0
|
||||||
|
return vecs / norms
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def in_memory_backend():
|
||||||
|
"""Свежий встроенный Qdrant на каждый тест."""
|
||||||
|
QdrantManager._reset()
|
||||||
|
QdrantManager._client = QdrantClient(location=":memory:")
|
||||||
|
yield
|
||||||
|
QdrantManager._reset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_search_returns_nothing():
|
||||||
|
assert QdrantManager.search(np.zeros(DIM, dtype=np.float32)) == []
|
||||||
|
assert QdrantManager.total_vectors() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_empty_is_noop():
|
||||||
|
QdrantManager.add_vectors(np.zeros((0, DIM), dtype=np.float32), [])
|
||||||
|
assert QdrantManager.total_vectors() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_self_search_returns_postgres_doc_id():
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
vecs = _unit(rng.standard_normal((3, DIM)))
|
||||||
|
QdrantManager.add_vectors(vecs, [101, 202, 303])
|
||||||
|
assert QdrantManager.total_vectors() == 3
|
||||||
|
|
||||||
|
res = QdrantManager.search(vecs[1], k=1)
|
||||||
|
assert res, "поиск ничего не вернул"
|
||||||
|
top_id, score = res[0]
|
||||||
|
assert top_id == 202 # id точки == doc_id из PostgreSQL
|
||||||
|
assert score == pytest.approx(1.0, abs=1e-3) # self-match: косинус ≈ 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_is_idempotent_by_doc_id():
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
QdrantManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [1, 2])
|
||||||
|
|
||||||
|
updated = _unit(rng.standard_normal((2, DIM)))
|
||||||
|
QdrantManager.add_vectors(updated, [1, 2]) # те же id → перезапись, не дубли
|
||||||
|
assert QdrantManager.total_vectors() == 2
|
||||||
|
|
||||||
|
top_id, score = QdrantManager.search(updated[0], k=1)[0]
|
||||||
|
assert top_id == 1
|
||||||
|
assert score == pytest.approx(1.0, abs=1e-3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_ranks_nearest_first():
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
v = _unit(rng.standard_normal((1, DIM)))[0]
|
||||||
|
near = _unit((v + 0.01 * rng.standard_normal(DIM))[None, :])[0]
|
||||||
|
far = _unit(rng.standard_normal((1, DIM)))[0]
|
||||||
|
QdrantManager.add_vectors(np.stack([near, far]), [10, 20])
|
||||||
|
|
||||||
|
res = QdrantManager.search(v, k=2)
|
||||||
|
assert res[0][0] == 10 # ближайший — near
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_is_noop():
|
||||||
|
QdrantManager.save() # Qdrant персистит сам — вызов не должен падать
|
||||||
69
services/worker-gpu/tests/test_scoring.py
Normal file
69
services/worker-gpu/tests/test_scoring.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Юнит-тесты скоринга плагиата (итоговый процент, который видит студент)."""
|
||||||
|
|
||||||
|
from app.scoring import aggregate_results
|
||||||
|
|
||||||
|
|
||||||
|
def _m(title: str, pos: int) -> dict:
|
||||||
|
return {"source_title": title, "position_start": pos}
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_input_is_zero():
|
||||||
|
out = aggregate_results([], [], [], total_fragments=10)
|
||||||
|
assert out["overall_similarity"] == 0.0
|
||||||
|
assert out["flagged_fragments"] == 0
|
||||||
|
assert out["matches"] == []
|
||||||
|
assert out["by_method"] == {"exact": 0, "fuzzy": 0, "semantic_llm": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_fragments_does_not_divide_by_zero():
|
||||||
|
out = aggregate_results([_m("A", 0)], [], [], total_fragments=0)
|
||||||
|
assert out["overall_similarity"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_percentage_is_share_of_flagged_positions():
|
||||||
|
# 2 из 4 фрагментов помечены → 50%
|
||||||
|
level1 = [_m("A", 0), _m("B", 5)]
|
||||||
|
out = aggregate_results(level1, [], [], total_fragments=4)
|
||||||
|
assert out["overall_similarity"] == 50.0
|
||||||
|
assert out["flagged_fragments"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_position_multiple_sources_counts_once():
|
||||||
|
# Одна позиция совпала с двумя разными источниками — доля не раздувается
|
||||||
|
level1 = [_m("A", 0)]
|
||||||
|
semantic = [_m("B", 0)] # та же позиция 0, другой источник
|
||||||
|
out = aggregate_results(level1, [], semantic, total_fragments=2)
|
||||||
|
assert out["flagged_fragments"] == 1
|
||||||
|
assert out["overall_similarity"] == 50.0
|
||||||
|
# оба совпадения сохранены в matches (дедуп только по паре title:pos)
|
||||||
|
assert len(out["matches"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_duplicate_match_is_deduplicated():
|
||||||
|
# Один и тот же источник в одной позиции пришёл дважды — остаётся один
|
||||||
|
dup = _m("A", 3)
|
||||||
|
out = aggregate_results([dict(dup)], [dict(dup)], [], total_fragments=5)
|
||||||
|
assert len(out["matches"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_similarity_capped_at_100():
|
||||||
|
# Больше уникальных позиций, чем фрагментов (пограничный случай) → не выше 100%
|
||||||
|
matches = [_m(f"S{i}", i) for i in range(5)]
|
||||||
|
out = aggregate_results(matches, [], [], total_fragments=3)
|
||||||
|
assert out["overall_similarity"] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_method_counts_raw_matches_per_level():
|
||||||
|
out = aggregate_results(
|
||||||
|
[_m("A", 0), _m("B", 1)], # exact = 2
|
||||||
|
[_m("C", 2)], # fuzzy = 1
|
||||||
|
[_m("D", 3), _m("E", 4), _m("F", 5)], # semantic_llm = 3
|
||||||
|
total_fragments=10,
|
||||||
|
)
|
||||||
|
assert out["by_method"] == {"exact": 2, "fuzzy": 1, "semantic_llm": 3}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
25
services/worker-gpu/tests/test_vector_store.py
Normal file
25
services/worker-gpu/tests/test_vector_store.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Тест выбора векторного бэкенда по настройке VECTOR_BACKEND."""
|
||||||
|
|
||||||
|
from app import vector_store
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_backend_is_faiss(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "VECTOR_BACKEND", "faiss")
|
||||||
|
from app.faiss_manager import FAISSManager
|
||||||
|
|
||||||
|
assert vector_store.get_backend() is FAISSManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_qdrant_backend_selected(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "VECTOR_BACKEND", "qdrant")
|
||||||
|
from app.qdrant_manager import QdrantManager
|
||||||
|
|
||||||
|
assert vector_store.get_backend() is QdrantManager
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_backend_falls_back_to_faiss(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "VECTOR_BACKEND", "что-то не то")
|
||||||
|
from app.faiss_manager import FAISSManager
|
||||||
|
|
||||||
|
assert vector_store.get_backend() is FAISSManager
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
локальный и теряется при рестарте), чтобы воркер не падал целиком.
|
локальный и теряется при рестарте), чтобы воркер не падал целиком.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -118,10 +119,8 @@ def add_to_lsh(doc_key: str, text: str) -> None:
|
|||||||
lsh = get_lsh()
|
lsh = get_lsh()
|
||||||
m = text_to_minhash(text)
|
m = text_to_minhash(text)
|
||||||
try:
|
try:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
lsh.remove(doc_key) # снять прежнюю версию, если была
|
lsh.remove(doc_key) # снять прежнюю версию, если была (ключа могло не быть)
|
||||||
except Exception:
|
|
||||||
pass # ключа не было — это норма
|
|
||||||
lsh.insert(doc_key, m)
|
lsh.insert(doc_key, m)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MinHash LSH: не удалось добавить {doc_key!r}: {e}")
|
logger.warning(f"MinHash LSH: не удалось добавить {doc_key!r}: {e}")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Синхронное подключение к PostgreSQL и MinIO для индексер-воркера."""
|
"""Синхронное подключение к PostgreSQL и MinIO для индексер-воркера."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ def extract_text_from_docx(data: bytes) -> str:
|
|||||||
ValueError: Если не удалось открыть DOCX
|
ValueError: Если не удалось открыть DOCX
|
||||||
"""
|
"""
|
||||||
from docx import Document as DocxDocument
|
from docx import Document as DocxDocument
|
||||||
from docx.oxml.ns import qn
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = DocxDocument(io.BytesIO(data))
|
doc = DocxDocument(io.BytesIO(data))
|
||||||
|
|||||||
56
services/worker-indexer/app/fragments.py
Normal file
56
services/worker-indexer/app/fragments.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Разбиение текста документа на фрагменты для проверки плагиата — чистая логика.
|
||||||
|
|
||||||
|
Скользящее окно по словам с перекрытием. Вынесено из Celery-задачи, чтобы
|
||||||
|
границы фрагментов (от них зависит, что именно проверяется на плагиат) можно
|
||||||
|
было тестировать изолированно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def split_into_fragments(
|
||||||
|
text: str,
|
||||||
|
window: int = 200,
|
||||||
|
overlap: int = 50,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Разбить текст на фрагменты скользящим окном с перекрытием.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Исходный текст
|
||||||
|
window: Размер окна в словах
|
||||||
|
overlap: Перекрытие между соседними фрагментами в словах
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список словарей {"text": str, "start": int, "end": int}, где start/end —
|
||||||
|
позиции в символах. Фрагменты короче 20 слов отбрасываются.
|
||||||
|
"""
|
||||||
|
words = text.split()
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
|
||||||
|
fragments: list[dict[str, Any]] = []
|
||||||
|
step = window - overlap
|
||||||
|
|
||||||
|
# Позиция первого символа каждого слова (слова разделены одним пробелом)
|
||||||
|
char_positions = []
|
||||||
|
pos = 0
|
||||||
|
for word in words:
|
||||||
|
char_positions.append(pos)
|
||||||
|
pos += len(word) + 1 # +1 для пробела
|
||||||
|
|
||||||
|
for i in range(0, max(1, len(words) - window + 1), step):
|
||||||
|
chunk_words = words[i : i + window]
|
||||||
|
if len(chunk_words) < 20: # слишком короткий фрагмент — пропустить
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_char = char_positions[i]
|
||||||
|
end_idx = min(i + window - 1, len(words) - 1)
|
||||||
|
end_char = char_positions[end_idx] + len(words[end_idx])
|
||||||
|
|
||||||
|
fragments.append({
|
||||||
|
"text": " ".join(chunk_words),
|
||||||
|
"start": start_char,
|
||||||
|
"end": end_char,
|
||||||
|
})
|
||||||
|
|
||||||
|
return fragments
|
||||||
@@ -38,8 +38,7 @@ def fetch_full_text(url: str) -> str | None:
|
|||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
timeout=settings.FULL_TEXT_TIMEOUT,
|
timeout=settings.FULL_TEXT_TIMEOUT,
|
||||||
headers=_HEADERS,
|
headers=_HEADERS,
|
||||||
) as client:
|
) as client, client.stream("GET", url) as resp:
|
||||||
with client.stream("GET", url) as resp:
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
ctype = resp.headers.get("content-type", "").lower()
|
ctype = resp.headers.get("content-type", "").lower()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Celery задачи индексации документов и проверки плагиата (уровни 1-2)."""
|
"""Celery задачи индексации документов и проверки плагиата (уровни 1-2)."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
from datetime import UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -16,60 +16,11 @@ from app.config import settings
|
|||||||
from app.db import db_session, get_minio, update_task_status
|
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.docx import extract_text_from_docx, extract_text_from_txt
|
||||||
from app.extractors.pdf import extract_text_from_pdf
|
from app.extractors.pdf import extract_text_from_pdf
|
||||||
|
from app.fragments import split_into_fragments
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _split_into_fragments(
|
|
||||||
text: str,
|
|
||||||
window: int = 200,
|
|
||||||
overlap: int = 50,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Разбить текст на фрагменты для проверки плагиата.
|
|
||||||
|
|
||||||
Использует скользящее окно с перекрытием.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Исходный текст
|
|
||||||
window: Размер окна в словах
|
|
||||||
overlap: Перекрытие между фрагментами в словах
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Список словарей {"text": str, "start": int, "end": int}
|
|
||||||
"""
|
|
||||||
words = text.split()
|
|
||||||
if not words:
|
|
||||||
return []
|
|
||||||
|
|
||||||
fragments = []
|
|
||||||
step = window - overlap
|
|
||||||
char_positions = []
|
|
||||||
|
|
||||||
# Вычислить позиции символов для каждого слова
|
|
||||||
pos = 0
|
|
||||||
for word in words:
|
|
||||||
char_positions.append(pos)
|
|
||||||
pos += len(word) + 1 # +1 для пробела
|
|
||||||
|
|
||||||
for i in range(0, max(1, len(words) - window + 1), step):
|
|
||||||
chunk_words = words[i : i + window]
|
|
||||||
if len(chunk_words) < 20: # Пропустить слишком короткие фрагменты
|
|
||||||
continue
|
|
||||||
|
|
||||||
start_char = char_positions[i]
|
|
||||||
end_idx = min(i + window - 1, len(words) - 1)
|
|
||||||
end_char = char_positions[end_idx] + len(words[end_idx])
|
|
||||||
|
|
||||||
fragments.append({
|
|
||||||
"text": " ".join(chunk_words),
|
|
||||||
"start": start_char,
|
|
||||||
"end": end_char,
|
|
||||||
})
|
|
||||||
|
|
||||||
return fragments
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(
|
@celery_app.task(
|
||||||
name="index.extract_and_check",
|
name="index.extract_and_check",
|
||||||
bind=True,
|
bind=True,
|
||||||
@@ -133,7 +84,7 @@ def extract_and_check(
|
|||||||
_stage_work(task_id, minio_key, filename, text, word_count)
|
_stage_work(task_id, minio_key, filename, text, word_count)
|
||||||
|
|
||||||
# Разбить на фрагменты
|
# Разбить на фрагменты
|
||||||
fragments = _split_into_fragments(
|
fragments = split_into_fragments(
|
||||||
text,
|
text,
|
||||||
window=settings.FRAGMENT_WINDOW_WORDS,
|
window=settings.FRAGMENT_WINDOW_WORDS,
|
||||||
overlap=settings.FRAGMENT_OVERLAP_WORDS,
|
overlap=settings.FRAGMENT_OVERLAP_WORDS,
|
||||||
@@ -253,7 +204,7 @@ def extract_and_check(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Ошибка при обработке задачи {task_id!r}: {exc}", exc_info=True)
|
logger.error(f"Ошибка при обработке задачи {task_id!r}: {exc}", exc_info=True)
|
||||||
update_task_status(task_id, "failed", str(exc))
|
update_task_status(task_id, "failed", str(exc))
|
||||||
raise self.retry(exc=exc, countdown=60)
|
raise self.retry(exc=exc, countdown=60) from exc
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="index.add_document")
|
@celery_app.task(name="index.add_document")
|
||||||
@@ -330,6 +281,7 @@ def add_document(doc_data: dict[str, Any], dispatch_embed: bool = True) -> dict[
|
|||||||
# Индексация в Elasticsearch
|
# Индексация в Elasticsearch
|
||||||
try:
|
try:
|
||||||
from elasticsearch import Elasticsearch
|
from elasticsearch import Elasticsearch
|
||||||
|
|
||||||
from app.config import settings as cfg
|
from app.config import settings as cfg
|
||||||
|
|
||||||
es = Elasticsearch(cfg.ELASTICSEARCH_URL)
|
es = Elasticsearch(cfg.ELASTICSEARCH_URL)
|
||||||
@@ -409,7 +361,7 @@ def enrich_full_text(self, doc_id: int, url: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"enrich_full_text: не удалось сохранить текст в MinIO для {doc_id}: {exc}")
|
logger.error(f"enrich_full_text: не удалось сохранить текст в MinIO для {doc_id}: {exc}")
|
||||||
raise self.retry(exc=exc, countdown=120)
|
raise self.retry(exc=exc, countdown=120) from exc
|
||||||
|
|
||||||
# Пересчитать fingerprints по полному тексту
|
# Пересчитать fingerprints по полному тексту
|
||||||
doc_fp = winnow(text)
|
doc_fp = winnow(text)
|
||||||
@@ -501,7 +453,7 @@ def run_parser(source_id: int) -> dict[str, Any]:
|
|||||||
задачу add_document для каждого полученного документа.
|
задачу add_document для каждого полученного документа.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
from app.models import ParseSource
|
from app.models import ParseSource
|
||||||
|
|
||||||
@@ -594,7 +546,7 @@ def run_parser(source_id: int) -> dict[str, Any]:
|
|||||||
src.last_status = "error" if error_msg else "done"
|
src.last_status = "error" if error_msg else "done"
|
||||||
src.last_error = error_msg
|
src.last_error = error_msg
|
||||||
src.docs_added = (src.docs_added or 0) + added
|
src.docs_added = (src.docs_added or 0) + added
|
||||||
src.last_run_at = datetime.now(timezone.utc)
|
src.last_run_at = datetime.now(UTC)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
return {"status": "error" if error_msg else "done", "added": added, "error": error_msg}
|
return {"status": "error" if error_msg else "done", "added": added, "error": error_msg}
|
||||||
|
|||||||
6
services/worker-indexer/conftest.py
Normal file
6
services/worker-indexer/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
3
services/worker-indexer/pytest.ini
Normal file
3
services/worker-indexer/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -q
|
||||||
8
services/worker-indexer/requirements-test.txt
Normal file
8
services/worker-indexer/requirements-test.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Зависимости для юнит-тестов worker-indexer (чистая логика L1/L2, без инфры).
|
||||||
|
# Redis намеренно НЕ ставим — тогда MinHash LSH детерминированно уходит
|
||||||
|
# в in-memory-фолбэк, что и нужно тестам.
|
||||||
|
pytest==8.2.0
|
||||||
|
xxhash==3.4.1
|
||||||
|
datasketch==1.6.5
|
||||||
|
numpy==1.26.4
|
||||||
|
pydantic-settings==2.2.1
|
||||||
@@ -9,5 +9,6 @@ python-docx==1.1.0
|
|||||||
datasketch==1.6.5
|
datasketch==1.6.5
|
||||||
xxhash==3.4.1
|
xxhash==3.4.1
|
||||||
langdetect==1.0.9
|
langdetect==1.0.9
|
||||||
|
beautifulsoup4==4.12.3 # парсер CyberLeninka: детали статьи (fetch_article_details)
|
||||||
pydantic-settings==2.2.1
|
pydantic-settings==2.2.1
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
|||||||
43
services/worker-indexer/tests/test_fragments.py
Normal file
43
services/worker-indexer/tests/test_fragments.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Юнит-тесты разбиения текста на фрагменты (определяет, что проверяется)."""
|
||||||
|
|
||||||
|
from app.fragments import split_into_fragments
|
||||||
|
|
||||||
|
|
||||||
|
def _text(n: int) -> str:
|
||||||
|
"""n слов одинаковой длины: 'w000 w001 ... ' (по 4 символа + пробел)."""
|
||||||
|
return " ".join(f"w{i:03d}" for i in range(n))
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_text_returns_empty():
|
||||||
|
assert split_into_fragments("") == []
|
||||||
|
assert split_into_fragments(" ") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_shorter_than_min_words_is_dropped():
|
||||||
|
# < 20 слов → единственный кандидат отбрасывается порогом длины
|
||||||
|
assert split_into_fragments(_text(19), window=200, overlap=50) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_text_single_fragment_covers_all():
|
||||||
|
frags = split_into_fragments(_text(30), window=200, overlap=50)
|
||||||
|
assert len(frags) == 1
|
||||||
|
assert frags[0]["start"] == 0
|
||||||
|
assert frags[0]["text"] == _text(30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_overlap():
|
||||||
|
frags = split_into_fragments(_text(50), window=25, overlap=5)
|
||||||
|
# step = window - overlap = 20 → окна начинаются со слов 0 и 20
|
||||||
|
assert len(frags) == 2
|
||||||
|
words0 = frags[0]["text"].split()
|
||||||
|
words1 = frags[1]["text"].split()
|
||||||
|
assert len(words0) == 25 and len(words1) == 25
|
||||||
|
# перекрытие ровно 5 слов: хвост первого == голова второго
|
||||||
|
assert words0[-5:] == words1[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_char_positions_are_offsets_into_source_text():
|
||||||
|
text = _text(30)
|
||||||
|
f = split_into_fragments(text, window=25, overlap=5)[0]
|
||||||
|
assert text[f["start"]:].startswith("w000") # start — символьный офсет начала
|
||||||
|
assert text[: f["end"]].endswith("w024") # end — конец 25-го слова окна
|
||||||
62
services/worker-indexer/tests/test_minhash.py
Normal file
62
services/worker-indexer/tests/test_minhash.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Юнит-тесты MinHash LSH (уровень 2 — нечёткие совпадения).
|
||||||
|
|
||||||
|
Redis в тестовом окружении недоступен → get_lsh() детерминированно падает в
|
||||||
|
in-memory-фолбэк, что и проверяется здесь.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.algorithms import minhash
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_shingles_basic():
|
||||||
|
assert minhash.get_shingles("один два три четыре", k=3) == {
|
||||||
|
"один два три",
|
||||||
|
"два три четыре",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_shingles_short_text():
|
||||||
|
assert minhash.get_shingles("одно", k=3) == {"одно"}
|
||||||
|
assert minhash.get_shingles("", k=3) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_text_jaccard_is_one():
|
||||||
|
t = "нейронные сети глубокого обучения распознают образы на изображениях точно"
|
||||||
|
assert minhash.compute_jaccard_minhash(t, t) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_disjoint_text_jaccard_near_zero():
|
||||||
|
a = "квантовая физика элементарных частиц и теория поля"
|
||||||
|
b = "кулинарные рецепты домашней выпечки пшеничного хлеба"
|
||||||
|
assert minhash.compute_jaccard_minhash(a, b) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_lsh_finds_near_duplicate_and_ignores_unrelated():
|
||||||
|
minhash.reset_lsh()
|
||||||
|
try:
|
||||||
|
base = (
|
||||||
|
"нейронные сети глубокого обучения применяются для распознавания "
|
||||||
|
"образов на цифровых изображениях и в задачах компьютерного зрения"
|
||||||
|
)
|
||||||
|
minhash.add_to_lsh("doc:1", base)
|
||||||
|
|
||||||
|
near = base.replace("изображениях", "фотографиях")
|
||||||
|
assert "doc:1" in minhash.find_similar(near)
|
||||||
|
|
||||||
|
unrelated = (
|
||||||
|
"экономический анализ рынка недвижимости в крупных городах страны "
|
||||||
|
"за последние несколько отчётных финансовых кварталов подряд"
|
||||||
|
)
|
||||||
|
assert "doc:1" not in minhash.find_similar(unrelated)
|
||||||
|
finally:
|
||||||
|
minhash.reset_lsh()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lsh_upsert_does_not_duplicate():
|
||||||
|
minhash.reset_lsh()
|
||||||
|
try:
|
||||||
|
text = "обработка естественного языка методами машинного обучения и статистики"
|
||||||
|
minhash.add_to_lsh("doc:9", text)
|
||||||
|
minhash.add_to_lsh("doc:9", text) # повторно тот же ключ — upsert, не дубль
|
||||||
|
assert minhash.find_similar(text).count("doc:9") == 1
|
||||||
|
finally:
|
||||||
|
minhash.reset_lsh()
|
||||||
79
services/worker-indexer/tests/test_winnowing.py
Normal file
79
services/worker-indexer/tests/test_winnowing.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Юнит-тесты алгоритма Winnowing (уровень 1 — точные/частичные совпадения)."""
|
||||||
|
|
||||||
|
from app.algorithms.winnowing import (
|
||||||
|
compute_similarity,
|
||||||
|
get_ngrams,
|
||||||
|
hash_ngram,
|
||||||
|
jaccard_similarity,
|
||||||
|
winnow,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Достаточно длинный текст, чтобы окно Winnowing реально отработало
|
||||||
|
LONG = (
|
||||||
|
"машинное обучение позволяет извлекать закономерности из больших объёмов "
|
||||||
|
"данных без явного программирования каждого правила вручную аналитиком"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ngrams_basic():
|
||||||
|
assert get_ngrams(["a", "b", "c", "d"], k=2) == ["a b", "b c", "c d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ngrams_too_short_is_empty():
|
||||||
|
assert get_ngrams(["a", "b"], k=5) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_ngram_is_deterministic():
|
||||||
|
assert hash_ngram("одна и та же строка") == hash_ngram("одна и та же строка")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_ngram_fits_signed_int64():
|
||||||
|
# Хэш обязан умещаться в PostgreSQL BIGINT (signed 64-bit)
|
||||||
|
for s in ("a", "тест", "one two three four five", "𝔘𝔫𝔦𝔠𝔬𝔡𝔢"):
|
||||||
|
h = hash_ngram(s)
|
||||||
|
assert -(2**63) <= h <= 2**63 - 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_winnow_short_text_is_empty():
|
||||||
|
assert winnow("три слова тут", k=5) == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_winnow_identical_text_identical_fingerprint():
|
||||||
|
assert winnow(LONG) == winnow(LONG)
|
||||||
|
|
||||||
|
|
||||||
|
def test_winnow_is_case_insensitive():
|
||||||
|
assert winnow(LONG) == winnow(LONG.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def test_jaccard_identical_is_one():
|
||||||
|
fp = winnow(LONG)
|
||||||
|
assert fp # непустой отпечаток
|
||||||
|
assert jaccard_similarity(fp, fp) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_jaccard_disjoint_is_zero():
|
||||||
|
assert jaccard_similarity({1, 2, 3}, {4, 5, 6}) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_jaccard_empty_is_zero():
|
||||||
|
assert jaccard_similarity(set(), {1, 2}) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_similarity_identical_documents_is_one():
|
||||||
|
# Ключевая гарантия L1: копия документа детектится как 100% совпадение
|
||||||
|
assert compute_similarity(LONG, LONG) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_similarity_unrelated_documents_is_low():
|
||||||
|
other = (
|
||||||
|
"рецепт домашнего хлеба на закваске требует терпения муки воды соли "
|
||||||
|
"и тёплого места для медленного подъёма теста в течение ночи"
|
||||||
|
)
|
||||||
|
assert compute_similarity(LONG, other) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_similarity_partial_overlap_is_between():
|
||||||
|
modified = LONG + " добавлен ещё один совершенно новый хвост предложения здесь"
|
||||||
|
sim = compute_similarity(LONG, modified)
|
||||||
|
assert 0.0 < sim < 1.0
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Подключение к PostgreSQL для notifier-воркера."""
|
"""Подключение к PostgreSQL для notifier-воркера."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Celery задачи отправки email уведомлений."""
|
"""Celery задачи отправки email уведомлений."""
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
|
|
||||||
@@ -67,7 +66,7 @@ def send_task_done(self, task_id: str) -> dict:
|
|||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Ошибка отправки уведомления для задачи {task_id!r}: {exc}", exc_info=True)
|
logger.error(f"Ошибка отправки уведомления для задачи {task_id!r}: {exc}", exc_info=True)
|
||||||
raise self.retry(exc=exc, countdown=30)
|
raise self.retry(exc=exc, countdown=30) from exc
|
||||||
|
|
||||||
|
|
||||||
def _build_summary(task) -> str:
|
def _build_summary(task) -> str:
|
||||||
@@ -144,4 +143,4 @@ def send_verification(self, user_email: str, user_name: str, token: str) -> dict
|
|||||||
return {"status": "sent"}
|
return {"status": "sent"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Ошибка отправки верификации на {user_email!r}: {exc}", exc_info=True)
|
logger.error(f"Ошибка отправки верификации на {user_email!r}: {exc}", exc_info=True)
|
||||||
raise self.retry(exc=exc, countdown=60)
|
raise self.retry(exc=exc, countdown=60) from exc
|
||||||
|
|||||||
Reference in New Issue
Block a user