Compare commits

..

14 Commits

Author SHA1 Message Date
jze9
6099ef621f fix(parsers): ленивый импорт bs4 в CyberLeninka + добавить в зависимости
Some checks failed
Deploy / test (push) Failing after 2m35s
Deploy / deploy (push) Has been skipped
Прод-воркер падал на index.run_parser с 'No module named bs4': парсер импортил
beautifulsoup4 на верхнем уровне, а в образе worker-indexer его нет. Но bs4 нужен
только для fetch_article_details (детали статьи), а заливке (fetch+transform) — нет.

- импорт bs4 сделан ленивым (внутри fetch_article_details) → заливка работает даже
  без bs4 в образе;
- beautifulsoup4 добавлен в worker-indexer/requirements.txt (для деталей статьи).

Это была вторая причина, почему CyberLeninka никогда не наполняла базу (первая —
GET вместо POST, 405). Проверено вживую: fetch без bs4 в пути работает.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:10:55 +05:00
jze9
9d005486df fix(parsers): починить CyberLeninka + подготовить русскую заливку корпуса
Корпус на 99.4% английский, 0 русских источников — при том что сервис для русских
студентов. Корень: парсер CyberLeninka был сломан (слал GET на /api/search → HTTP
405) и ни разу не наполнял базу.

- cyberleninka.py: GET→POST с JSON-телом (mode=articles); authors теперь из списка
  (API отдаёт список, не строку); чистка <b>-подсветки и HTML-сущностей (&quot;).
  Проверено вживую: 5/5 студенческих тем возвращают реальные русские статьи.
- Юнит-тесты парсера (scripts/parsers/tests/, 5 шт.) + обвязка; run_tests.sh обобщён
  на пути → парсеры теперь в тест-гейте CI. Всего тестов: 87.
- scripts/seed_ru_sources.py: сидер parse_sources по 30 студенческим дисциплинам
  (dry-run по умолчанию, --apply для записи). НЕ запускает заливку — готовит задания.
- docs/INGESTION.md: runbook (текущее состояние, шаги запуска, проверка, масштаб).

Прод-путь index.run_parser уже поддерживает cyberleninka и openalex(lang=ru).
Заливку не запускал — это отдельный go (ресурсоёмко: GPU-эмбеддинги, рост БД).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:02:16 +05:00
jze9
e6c44f30dd docs: полное описание проекта — docs/ARCHITECTURE.md
Каноничная документация всей системы: назначение, схема, 6 сервисов и их зоны
ответственности, модель данных (все таблицы), Celery-очереди/задачи, ключевые
потоки (проверка плагиата, поиск, библиография, наполнение корпуса), 4 уровня
детекции, векторный бэкенд, инфра-топология (узлы/адреса), конфигурация, гейты
качества, наблюдаемость/эксплуатация, безопасность, раскладка репозитория.

README ссылается на ARCHITECTURE.md и DR-HA.md. Данные сверены с кодом (модели,
TaskType/TaskStatus, источники, тарифы, маршрутизация задач).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:45:23 +05:00
jze9
d32a07d0a8 feat(ops): DR-тест восстановления бэкапа PG + runbook HA/DR
Закрыта слепая зона «бэкапы есть, но восстановление не проверялось».

- scripts/ops/pg_restore_verify.sh — берёт последний дамп из MinIO backups/pg/,
  restore в ЭФЕМЕРНЫЙ postgres:16, проверяет ключевые таблицы (users/documents/
  tasks). Прод не трогает, всё в одноразовом контейнере. Под cron раз в неделю.
- docs/DR-HA.md — runbook: бэкапы, проверка восстановления, потоковая репликация
  PG, Redis-реплика+Sentinel, таблица SPOF со статусом митигаций.

Провижн реплик PG/Redis — на Proxmox (нужен новый LXC), это работа на железе, не
в репозитории; runbook даёт конкретные шаги. Векторный SPOF уже снимается Qdrant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:40:03 +05:00
jze9
d2d99d8231 feat(obs): наблюдаемость Prometheus + Grafana под профилем observability
Раньше видимости в прод не было — только email-монитор (костыль). Добавлено
без нагрузки по умолчанию (профиль не поднимается, пока не попросят):

- API: /metrics через prometheus-fastapi-instrumentator (кол-во/латентность
  запросов по хендлерам);
- Prometheus (infra/prometheus/prometheus.yml) скрейпит API и Flower — из Flower
  приходят метрики Celery (задачи, время, воркеры) вообще без доп. кода;
- Grafana с автопровижном источника Prometheus (infra/grafana/provisioning),
  пароль через GRAFANA_ADMIN_PASSWORD;
- docker-compose.prod.yml: сервисы prometheus/grafana под profiles:[observability]
  + volumes; .env.example и README пополнены.

Запуск: docker compose --profile observability up -d prometheus grafana.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:37:47 +05:00
jze9
012b17304f feat(gpu): Qdrant как векторный бэкенд под флагом — снимает SPOF FAISS
FAISS-индекс — файловый синглтон в RAM одного воркера (save на каждую запись,
без блокировок, без HA, не горизонтален). Добавлен альтернативный бэкенд Qdrant
с тем же classmethod-интерфейсом, выбор через VECTOR_BACKEND — аддитивно и
безопасно: дефолт остаётся faiss, ничего не ломается, пока не переключат.

- app/qdrant_manager.py — search/add_vectors/save(no-op)/total_vectors поверх
  qdrant-client (коллекция Cosine, id точки = doc_id, upsert идемпотентен);
- app/vector_store.py — get_backend() по настройке; задачи search/plagiarism
  переведены на него (больше не импортируют FAISSManager напрямую);
- app/migrate_faiss_to_qdrant.py — перелив существующих векторов (reconstruct
  из IndexIDMap2 → upsert), идемпотентно;
- docker-compose.prod.yml — сервис qdrant под профилем `qdrant` (по умолчанию не
  поднимается, ресурсов не ест) + volume; README — раздел про переключение.

Тесты (9) гоняют QdrantManager против ВСТРОЕННОГО Qdrant (qdrant-client :memory:,
не моки) + диспетчеризацию бэкенда. Всего тестов: 82 (indexer 24, gost 24, gpu 34).
Плюсы Qdrant активируются только после явного переключения + миграции.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:34:43 +05:00
jze9
37ee18ac55 docs(readme): секция «Тестирование и качество» + фикс устаревших фактов
Добавлена секция про CI-гейты (ruff+mypy, 73 юнит-теста, make-команды,
таблица покрытия по слоям L1-L4/скоринг/фрагменты/ГОСТ/библиография).

Заодно поправлены устаревшие технические утверждения, разошедшиеся с кодом:
- FAISS-GPU (IVFFlat) → FAISS-CPU (IndexIDMap2 · IndexFlatIP) — реальный индекс;
- Ollama llama3:8b → qwen2.5:7b; убран RTX 2060 (LLM на отдельном GPU-сервере);
- уточнено описание 4 уровней проверки под фактическую реализацию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:22:45 +05:00
jze9
ccc3521e74 refactor(gost): вынести сборку списка литературы в app.bibliography + 7 тестов
Сортировка (кириллица→латиница), нумерация и выбор форматтера жили внутри
Celery-задачи с БД и не тестировались — хотя это порядок и вид готового списка
литературы, который видит студент. Вынес в чистый app.bibliography.build_bibliography:

- нумерация сквозная с 1; total = число записей;
- сортировка по фамилии первого автора, кириллица раньше латиницы;
- стиль 7.1 → полное описание (format_full), иначе 7.0.5 → краткая ссылка;
- doc_id сохраняется в каждой записи; пустой список → total 0.

ORM→dict конверсия осталась в задаче (она из БД), поведение сохранено 1:1.
Добавлен в mypy-гейт. Тестов всего: 73 (indexer 24, gost 24, gpu 25).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:20:01 +05:00
jze9
b2d2061a83 refactor(indexer): вынести разбиение на фрагменты в app.fragments + 5 тестов
_split_into_fragments жила в тяжёлой Celery-задаче (celery/sqlalchemy/minio) и
не тестировалась, хотя именно она определяет, ЧТО проверяется на плагиат.
Вынес в чистый app.fragments.split_into_fragments — тестируется изолированно:

- пустой/пробельный текст → [];
- текст короче 20 слов → отбрасывается порогом длины;
- короткий текст → один фрагмент на весь объём, start=0;
- скользящее окно: перекрытие ровно (window-step) слов, хвост==голова соседа;
- start/end — корректные символьные офсеты в исходный текст.

Поведение сохранено 1:1. Добавлен в mypy-гейт. Тестов всего: 66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:17:33 +05:00
jze9
4c15f11efa refactor(gpu): вынести скоринг плагиата в чистый app.scoring + 8 тестов
Итоговый процент схожести (тот, что видит студент) и дедупликация совпадений
жили внутри Celery-задачи check_plagiarism и не тестировались. Вынес в чистую
функцию app.scoring.aggregate_results (без Celery/БД/сети) — правильная «высота»
(доменная логика отдельно от оркестрации) и теперь покрыто тестами:

- процент = доля УНИКАЛЬНЫХ помеченных позиций от всех фрагментов;
- одна позиция с несколькими источниками считается один раз (не раздувает %);
- точный дубль (source_title:pos) дедуплицируется;
- деление на ноль фрагментов; кап на 100%; округление до 2 знаков; by_method.

Поведение сохранено 1:1 (лог задачи переведён на result[...]). scoring.py добавлен
в mypy-гейт. Тестов всего: 61 (indexer 19, gost 17, gpu 25).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:13:27 +05:00
jze9
426796a9a7 test(gpu): покрыть L4 OllamaClient (парсинг парафраза, 12 тестов)
Сеть замокана (monkeypatch httpx.post/get). Проверяется устойчивость слоя LLM:
- нормализация ответа: is_paraphrase→bool, confidence→float, reason→str;
- кривой/невалидный JSON от модели → безопасные дефолты, не падение;
- отсутствие ключа "response" → пустой объект → безопасные значения;
- таймаут/ConnectError → «LLM недоступна»; прочие ошибки перехвачены;
- summarize: strip ответа и фолбэк в аннотацию/заголовок при ошибке;
- is_available: 200 → True, исключение → False.

Тестов всего: 53 (indexer 19, gost 17, gpu 17). httpx добавлен в requirements-test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:10:34 +05:00
jze9
e79f254af6 chore(lint): добавить mypy в CI-гейт — старт градуальной типизации
Ruff уже стоял; теперь рядом mypy как проверка типов. Прагматичный конфиг
mypy.ini (ловит реальные несовпадения типов/обращения к None/неверные аргументы,
но не требует аннотаций везде и не шумит на сторонних либах) — чтобы гейт был
зелёным и расширяемым.

Область на старте — только чистая логика, которая уже типобезопасна:
worker-indexer/app/algorithms (L1 winnowing, L2 minhash) и
worker-gost/app/formatters (ГОСТ 7.1 / 7.0.5). Запуск per-service, чтобы
резолвился локальный пакет app. faiss_manager вне области пока — требует
Optional-рефактора _index; на следующий заход.

run_lint.sh теперь гоняет ruff + mypy в одном контейнере; шаг CI переименован.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 20:07:44 +05:00
jze9
2daaa8c8a4 chore(lint): ruff-гейт в CI + фиксы (0 находок) — блокирует кривой деплой
Второй CI-гейт после тестов: ruff как статический анализатор всего Python-кода
(services + scripts). Раньше ни линта, ни проверки типов в CI не было вовсе.

Конфиг ruff.toml: правила E/F/W/I/UP/B/SIM/C4, line-length 100. Осознанно
выключены E501 (длину держит форматтер; длинные RU-комментарии — норма),
B008 (Depends()/Query() в дефолтах — идиома FastAPI, не баг) и UP042
((str, Enum)→StrEnum меняет __str__/сериализацию — не трогаем).

Починено под ноль находок:
- B904 (11): raise ... from exc / from None — читаемые цепочки исключений в
  Celery-ретраях и HTTPException, ошибки обработки не маскируют исходные.
- SIM105 (5): try/except/pass → contextlib.suppress (faiss remove_ids, lsh.remove,
  сброс кэша, ws-disconnect, парс года).
- C416/SIM108/B905/F841/UP035/UP017/F401/I001: dict(rows), тернарник, zip strict,
  мёртвая переменная, устаревшие импорты, timezone.utc→UTC, чистка/сортировка.

Обвязка: scripts/run_lint.sh (ruff в изолированном python:3.11-slim), шаг «Линт»
в job test перед юнит-тестами (падаем раньше). make lint / make lint-fix.
Все 41 юнит-тест по-прежнему зелёные, изменённые файлы компилируются.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 17:20:24 +05:00
jze9
ecc10a4413 test: юнит-суит L1/L2/L3 + ГОСТ (41 тест) и гейт в CI перед деплоем
Первый настоящий автоматический тест-суит проекта — раньше регрессии ловились
руками. Покрыта чистая логика детекции и форматирования (без БД/Redis/GPU/Ollama):

- worker-indexer: L1 Winnowing (точные совпадения, идемпотентность отпечатка,
  диапазон signed int64) и L2 MinHash LSH (шинглы, Jaccard, near-duplicate +
  upsert через in-memory-фолбэк).
- worker-gpu: L3 FAISS — возврат doc_id из PostgreSQL (IndexIDMap2),
  идемпотентность add_vectors (remove-before-add, без дублей), self-match ≈ 1,
  ранжирование. Прямо стережёт баги, из-за которых индекс переписывался.
- worker-gost: ГОСТ 7.1-2003 и 7.0.5-2008 — авторы (≤3 / 4+ «и др.»/et al.),
  статья/книга/web, DOI, порядок сортировки кириллица→латиница, стр. в ссылке.

Обвязка: per-service pytest.ini/conftest/requirements-test. scripts/run_tests.sh
гоняет тесты в изолированных python:3.11-slim контейнерах (не засоряя хост),
через Tsinghua-зеркало. CI: job `test` теперь гейтит `deploy` (needs: test) —
падение тестов блокирует прод-деплой. make test / make test-one SVC=...

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 17:11:08 +05:00
84 changed files with 2199 additions and 242 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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
View 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
View 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).

View 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

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

View 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

View File

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

View 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__)))

View 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>), декодировать сущности (&quot;), обрезать."""
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]]:
""" """
Разбить строку авторов КиберЛенинки на список. Разбить строку авторов КиберЛенинки на список.

View File

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

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -0,0 +1,4 @@
# Зависимости для юнит-тестов парсеров (чистый transform, без сети).
pytest==8.2.0
httpx==0.27.0
beautifulsoup4==4.12.3

View File

@@ -0,0 +1,53 @@
"""Юнит-тесты парсера CyberLeninka — чистая логика transform (без сети).
Стерегут исправления реальных багов: JSON-API отдаёт authors списком, а name/
annotation — с HTML-подсветкой (<b>) и сущностями (&quot;).
"""
from cyberleninka import CyberLeninkaParser, _authors_from_list, _clean
# Форма ответа POST /api/search КиберЛенинки
SAMPLE = {
"name": "СОДЕРЖАНИЕ <b>ЭКОНОМИЧЕСКОГО</b> <b>АНАЛИЗА</b> &quot;X&quot;",
"annotation": "В статье <b>рассматривается</b> вопрос",
"authors": ["Вишникина А. Д.", "Клопова А. А."],
"journal": "Экономика и социум",
"year": 2024,
"link": "/article/n/soderzhanie",
}
def test_clean_strips_tags_and_entities():
assert _clean('<b>Тест</b> &quot;X&quot;') == 'Тест "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 "&quot;" 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
View 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) пройден"

View File

@@ -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
View 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
View 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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. Создаём задачу и диспатчим ─────────────────────────────────────────

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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),
}

View File

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

View File

@@ -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 "яяя" # В конец

View File

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

View File

@@ -0,0 +1,6 @@
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -0,0 +1,2 @@
# Зависимости для юнит-тестов worker-gost (ГОСТ-форматтеры — чистая логика).
pytest==8.2.0

View 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}

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

View 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)

View File

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

View File

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

View File

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

View File

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

View 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()

View File

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

View 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

View 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),
},
}

View File

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

View File

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

View 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

View File

@@ -0,0 +1,6 @@
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View 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:-режим → тесты бэкенда без сервера

View File

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

View 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)

View 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

View 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 персистит сам — вызов не должен падать

View 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

View 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

View File

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

View File

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

View File

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

View 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

View File

@@ -38,22 +38,21 @@ 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()
# Скачиваем с ограничением размера # Скачиваем с ограничением размера
buf = bytearray() buf = bytearray()
for chunk in resp.iter_bytes(): for chunk in resp.iter_bytes():
buf += chunk buf += chunk
if len(buf) > settings.FULL_TEXT_MAX_BYTES: if len(buf) > settings.FULL_TEXT_MAX_BYTES:
logger.info( logger.info(
f"full-text превысил лимит {settings.FULL_TEXT_MAX_BYTES} байт, " f"full-text превысил лимит {settings.FULL_TEXT_MAX_BYTES} байт, "
f"обрезаю: {url}" f"обрезаю: {url}"
) )
break break
data = bytes(buf) data = bytes(buf)
except Exception as e: except Exception as e:
logger.info(f"full-text: скачать не удалось {url!r}: {type(e).__name__}: {str(e)[:120]}") logger.info(f"full-text: скачать не удалось {url!r}: {type(e).__name__}: {str(e)[:120]}")
return None return None

View File

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

View File

@@ -0,0 +1,6 @@
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View 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

View File

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

View 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-го слова окна

View 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()

View 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

View File

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

View File

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