Compare commits

...

12 Commits

Author SHA1 Message Date
jze9
8dc51a4f65 ci: пересобирать только изменённые сервисы + полный клон для diff
All checks were successful
Deploy / deploy (push) Successful in 3s
Первый прогон вслепую пересобирал все 5 бэкенд-образов, включая worker-gpu
(torch/faiss ~2 ГБ) — на медленном канале это ~1-2 часа впустую, хотя коммит
менял только CI-файлы. Теперь deploy.sh по git-diff с прошлого деплоя
(маркер .last_deploy_sha) собирает лишь сервисы с изменённым кодом; фронт
пересобирается только при правках services/frontend; правка compose = полная
пересборка. Workflow клонирует репозиторий полностью (нужен лог для diff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:34:30 +05:00
jze9
34fa495fb1 ci: авто-деплой в прод через Gitea Actions (push в main)
Some checks failed
Deploy / deploy (push) Failing after 8m6s
Self-hosted act_runner в host-режиме на app-хосте 1.32 (label "deploy").
При пуше в main workflow клонирует коммит и запускает scripts/deploy.sh:
синхронизация кода → пересборка/перезапуск бэкенд-сервисов → миграции →
сборка фронтенда → выкладка статики на CT 102 (reverse-proxy) с бэкапом.

Доступ к Proxmox-хосту для CT 102 — через секрет PVE_PASSWORD.
Больше ручных rsync/rebuild — деплой по git push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:23:04 +05:00
jze9
b0e1eadcbb feat(indexer): MinHash LSH (уровень 2) в общем Redis вместо памяти
Раньше LSH-индекс жил in-memory в процессе воркера: терялся при рестарте и
не шарился между воркерами — уровень 2 в проде фактически не работал.

Теперь индекс хранится в общем Redis (тот же REDIS_URL) через нативный
storage_config datasketch:
- переживает рестарт, общий для всех воркеров-индексеров;
- все ключи под префиксом antiplag_lsh — изолированы от кэша/rate-limits и
  чужих данных в общей БД; никаких FLUSH (и ACL-юзер их не умеет);
- add_to_lsh теперь upsert (remove+insert) — full-text версия документа
  корректно заменяет провизорную по аннотации (был латентный баг: skip-on-
  duplicate оставлял абстрактную версию);
- graceful-фолбэк в in-memory, если Redis недоступен, чтобы воркер не падал.

Проверено на боевом Redis через изолированный тестовый префикс: query
находит похожие, все ключи в неймспейсе, точечная чистка вернула БД к
исходному состоянию (чужие данные не затронуты).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:09:51 +05:00
jze9
f1c1530cea fix(frontend): убрать неиспользуемый импорт React в Settings
tsc падал с TS6133 ('React' is declared but never read) — проект на
automatic JSX runtime, дефолтный импорт React не нужен. Из-за этого не
собирался прод-фронт. Проверено: npm run build проходит.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:40:49 +05:00
jze9
5cbc1e127b feat(profile): редактирование персональных данных и смена пароля
Бэкенд:
- PATCH /auth/me — изменение имени и/или email. Смена email проверяет
  уникальность, сбрасывает is_verified и отправляет новое письмо
  подтверждения. Пользователь перезагружается из БД (объект из Redis-кэша
  не привязан к сессии), кэш инвалидируется после изменения.
- POST /auth/change-password — смена пароля с подтверждением текущего;
  отклоняет неверный текущий и совпадение нового со старым.

Фронтенд:
- Страница /settings: карточка персональных данных (имя, email со статусом
  подтверждения и предупреждением о повторной верификации) и карточка смены
  пароля с проверкой совпадения.
- Ссылка «Настройки» в карточке профиля кабинета, методы API-клиента.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:22:05 +05:00
jze9
3af79e9d03 fix(auth): сбрасывать Redis-кэш пользователя после подтверждения email
get_current_user кэширует пользователя в Redis на 5 минут. verify_email
не сбрасывал этот кэш после user.is_verified = True — если пользователь
до подтверждения хотя бы раз дёрнул защищённый эндпоинт (закешировался
как неверифицированный), он получал "Необходимо подтвердить email адрес"
ещё до 5 минут после реального подтверждения по ссылке из письма.
2026-07-27 12:22:37 +05:00
jze9
673e72a15a feat(infra): переход прод/test на общую инфраструктуру вместо self-hosted
Postgres/Redis/RabbitMQ/MinIO/Ollama теперь общие серверы сети (адреса в .env),
локально в докере остаётся только Elasticsearch + app-сервисы. Старый
docker-compose.yml (полностью автономный стек) сохранён как
docker-compose.selfhosted.yml.example на случай отдельного GPU-сервера в
будущем. Добавлен docker-compose.prod.yml (app + свой nginx с TLS,
собирающий фронтенд в статику). Makefile переписан под три режима
(dev/test-up/prod). Мелкая чистка неиспользуемых импортов во фронтенде.
2026-07-27 12:22:36 +05:00
jze9
25e55a3f7d fix(detection): пофрагментная локализация уровня 1 + полное хранение отпечатков
Две связанные проблемы, ломавшие качество детекции:

1. Уровень 1 (Winnowing) репортил весь документ одним совпадением на позиции
   0..длина — в отчёте нельзя было понять, ГДЕ плагиат. Теперь winnow'им
   каждый фрагмент и находим источник + позицию для каждого, так отчёт
   показывает "символы A-B скопированы из источника X".

2. MAX_FINGERPRINTS_PER_DOC=500 обрезал отпечатки статьи (у типичной статьи
   их ~3600), причём произвольную выборку. Из-за этого скопированный фрагмент
   почти не разделял отпечатки с источником (проверено: хранилось ~14%,
   фрагмент находил 17% своих хэшей → не срабатывало). Winnowing рассчитан на
   ПОЛНОЕ хранение; поднял лимит до 20000.

Также overall_similarity считается по числу уникальных помеченных позиций,
а не совпадений — фрагмент, совпавший с несколькими источниками, больше не
раздувает процент выше 100.

Проверено end-to-end: документ с дословной вставкой из статьи корпуса →
вставка локализована (chars 1027-2385 = 100%, источник атрибутирован);
чисто оригинальный текст → 0% (нет ложных срабатываний).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:57:05 +05:00
jze9
ef2723e1d3 feat(indexer): скачивание полного текста статей + батчинг эмбеддингов
Раньше корпус состоял только из метаданных: парсеры отдают full_text=None,
fingerprints/эмбеддинги считались из аннотаций, MinIO статьями не наполнялся
вовсе. Проверка плагиата шла против абстрактов, а не тел статей.

Добавлено:
- app/fulltext.py: best-effort скачивание PDF по url источника (стрим с
  лимитом размера, детект PDF по content-type/magic), извлечение текста
  через PyMuPDF.
- index.enrich_full_text: новая задача — качает полный текст, кладёт в MinIO
  (documents/corpus/{id}.txt), пересчитывает fingerprints по полному тексту,
  обновляет MinHash. Диспатчится из add_document при FETCH_FULL_TEXT=true.
- openalex: предпочитаем прямую ссылку на PDF (best_oa_location.pdf_url)
  вместо лендинга — покрытие full-text выросло с 17% до 33% на выборке.
- run_parser батчит эмбеддинги (EMBED_BATCH_SIZE) вместо диспатча по одному
  документу: worker-gpu кодирует пачку разом и реже переписывает FAISS-индекс.

Покрытие ~33% (прямые OA-PDF: arxiv/usenix/springer/techscience и т.п.);
для остального остаётся фолбэк на аннотацию. Управляется FETCH_FULL_TEXT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:42:02 +05:00
jze9
38b000703a fix(gpu): рабочий FAISS-индекс вместо необучаемого IVFFlat
Семантический поиск (уровень 3) не работал вообще: индекс IndexIVFFlat с
nlist=1024 требует ~40000 векторов для обучения, а до обучения векторы
складывались во временный in-memory _flat_index, который:
  - не сохранялся на диск (save() писал только пустой _index) → терялся при
    рестарте;
  - не участвовал в поиске (search() искал только в необученном _index и
    сразу возвращал []).
Итог: в FAISS всегда было 0 векторов, семантика возвращала пусто.

Заменено на IndexIDMap2(IndexFlatIP): без обучения, работает с первого
вектора, doc_id хранится внутри индекса, корректно персистится. На
нормализованных векторах inner product = cosine, порог 0.75 сохраняет смысл.
Добавлена идемпотентность add_vectors (remove_ids перед add).

Проверено: 66 документов → ntotal=66, поиск возвращает релевантные
результаты со score 0.72-0.78, round-trip save/load сохраняет векторы.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:28:34 +05:00
jze9
9ca2bd1742 fix(notifier): маппинг input_data в модели Task для уведомлений о завершении
send_task_done падал с AttributeError: минимальная модель Task в notifier
не мапила колонку input_data (она есть в БД и в полной модели API), а
_build_summary читал task.input_data для search/plagiarism. Задача уходила
в бесконечные ретраи. Добавлен маппинг колонки + защита от NULL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:16:13 +05:00
jze9
c1cf1ddd2f feat(auth): подтверждение email + актуализация SMTP/Ollama конфигов
- Резенд письма верификации (/auth/resend-verification), модалка на
  фронте с поллингом статуса, страница /verify-email/:token
- SMTP переведён на собственный Postfix (mail.jze9mail.ru, STARTTLS,
  SMTP_TLS_VERIFY) вместо Yandex-заглушки в дефолтах и .env.example
- OLLAMA_URL и модель в worker-gpu синхронизированы с новым GPU-хостом
  (llama3:8b -> qwen2.5:7b, которой раньше не было на сервере)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 18:37:22 +05:00
49 changed files with 1467 additions and 281 deletions

View File

@@ -28,12 +28,13 @@ OLLAMA_URL=http://ollama:11434
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32 SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
ACCESS_TOKEN_EXPIRE_MINUTES=10080 ACCESS_TOKEN_EXPIRE_MINUTES=10080
# SMTP (Yandex) # SMTP (собственный Postfix+Dovecot, mail.jze9mail.ru, STARTTLS)
SMTP_HOST=smtp.yandex.ru SMTP_HOST=mail.jze9mail.ru
SMTP_PORT=465 SMTP_PORT=587
SMTP_USER=noreply@jze9.ru SMTP_USER=noreply
SMTP_PASSWORD=changeme SMTP_PASSWORD=changeme
SMTP_FROM=noreply@jze9.ru SMTP_FROM=noreply@jze9mail.ru
SMTP_TLS_VERIFY=true
# App # App
APP_URL=https://academic.jze9.ru APP_URL=https://academic.jze9.ru

View File

@@ -0,0 +1,37 @@
name: Deploy
# Авто-деплой в прод при пуше в main.
# Джоба выполняется на self-hosted runner'е в host-режиме прямо на app-хосте
# 1.32 (label "deploy"), поэтому имеет доступ к его docker и сети до Proxmox.
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
runs-on: deploy
steps:
- name: Клонировать репозиторий (полностью — нужен git-лог для diff)
run: |
set -euo pipefail
SRC="$(mktemp -d)"
echo "SRC=$SRC" >> "$GITHUB_ENV"
git clone --branch main \
https://gitea.jze9.ru/jze9/anti-plagiarism.git "$SRC"
- name: Деплой (бэкенд 1.32 + фронтенд CT 102)
env:
PVE_PASSWORD: ${{ secrets.PVE_PASSWORD }}
run: |
set -euo pipefail
cd "$SRC"
bash scripts/deploy.sh
- name: Убрать временный чекаут
if: always()
run: rm -rf "${SRC:-/tmp/none}"

View File

@@ -1,11 +1,18 @@
.PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean .PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean test-up test-down test-logs test-ps
# ─── Переменные ──────────────────────────────────────────────────────────────── # ─── Переменные ────────────────────────────────────────────────────────────────
COMPOSE = docker compose # Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
COMPOSE_DEV = docker compose -f docker-compose.yml -f docker-compose.dev.yml # (postgres/redis/rabbitmq/minio/ollama) уже существует и берётся из .env.
# Dev (полностью локальный sandbox, свои postgres/redis/rabbitmq/minio/ollama +
# hot reload) — поверх self-hosted примера, используется редко.
# Test (то, что реально гоняется на машине разработчика день в день) — hot
# reload против той же общей инфры, что и прод.
COMPOSE_PROD = docker compose -f docker-compose.prod.yml
COMPOSE_DEV = docker compose -f docker-compose.selfhosted.yml.example -f docker-compose.dev.yml
COMPOSE_TEST = docker compose -f docker-compose.test.yml
PROJECT = anti-plagiarism PROJECT = anti-plagiarism
# ─── Разработка ──────────────────────────────────────────────────────────────── # ─── Разработка (полностью локальный sandbox, редко используется) ─────────────
dev: dev:
@cp -n .env.example .env 2>/dev/null || true @cp -n .env.example .env 2>/dev/null || true
$(COMPOSE_DEV) up --build $(COMPOSE_DEV) up --build
@@ -14,99 +21,112 @@ dev-d:
@cp -n .env.example .env 2>/dev/null || true @cp -n .env.example .env 2>/dev/null || true
$(COMPOSE_DEV) up --build -d $(COMPOSE_DEV) up --build -d
# ─── Test (hot reload против общей инфры — повседневная разработка) ──────────
test-up:
$(COMPOSE_TEST) up --build -d
test-down:
$(COMPOSE_TEST) down
test-logs:
$(COMPOSE_TEST) logs -f
test-ps:
$(COMPOSE_TEST) ps
# ─── Продакшн ───────────────────────────────────────────────────────────────── # ─── Продакшн ─────────────────────────────────────────────────────────────────
build: build:
$(COMPOSE) build $(COMPOSE_PROD) build
up: up:
$(COMPOSE) up -d $(COMPOSE_PROD) up -d
down: down:
$(COMPOSE) down $(COMPOSE_PROD) down
restart: restart:
$(COMPOSE) restart $(COMPOSE_PROD) restart
ps: ps:
$(COMPOSE) ps $(COMPOSE_PROD) ps
# ─── Миграции ───────────────────────────────────────────────────────────────── # ─── Миграции ─────────────────────────────────────────────────────────────────
migrate: migrate:
$(COMPOSE) exec api alembic upgrade head $(COMPOSE_PROD) exec api alembic upgrade head
migrate-dev: migrate-dev:
$(COMPOSE_DEV) exec api alembic upgrade head $(COMPOSE_DEV) exec api alembic upgrade head
makemigration: makemigration:
@read -p "Migration name: " name; \ @read -p "Migration name: " name; \
$(COMPOSE) exec api alembic revision --autogenerate -m "$$name" $(COMPOSE_PROD) exec api alembic revision --autogenerate -m "$$name"
downgrade: downgrade:
$(COMPOSE) exec api alembic downgrade -1 $(COMPOSE_PROD) exec api alembic downgrade -1
# ─── Логи ───────────────────────────────────────────────────────────────────── # ─── Логи ─────────────────────────────────────────────────────────────────────
logs: logs:
$(COMPOSE) logs -f $(COMPOSE_PROD) logs -f
logs-api: logs-api:
$(COMPOSE) logs -f api $(COMPOSE_PROD) logs -f api
logs-gpu: logs-gpu:
$(COMPOSE) logs -f worker-gpu $(COMPOSE_PROD) logs -f worker-gpu
logs-indexer: logs-indexer:
$(COMPOSE) logs -f worker-indexer $(COMPOSE_PROD) logs -f worker-indexer
logs-notifier: logs-notifier:
$(COMPOSE) logs -f worker-notifier $(COMPOSE_PROD) logs -f worker-notifier
logs-gost: logs-gost:
$(COMPOSE) logs -f worker-gost $(COMPOSE_PROD) logs -f worker-gost
# ─── Шеллы ──────────────────────────────────────────────────────────────────── # ─── Шеллы ────────────────────────────────────────────────────────────────────
shell-api: shell-api:
$(COMPOSE) exec api bash $(COMPOSE_PROD) exec api bash
shell-gpu: shell-gpu:
$(COMPOSE) exec worker-gpu bash $(COMPOSE_PROD) exec worker-gpu bash
shell-indexer: shell-indexer:
$(COMPOSE) exec worker-indexer bash $(COMPOSE_PROD) exec worker-indexer bash
shell-db: shell-db:
$(COMPOSE) exec postgres psql -U antiplagiator antiplagiator @echo "Postgres общий (192.168.1.11) — подключайся напрямую: psql -h 192.168.1.11 -U antiplagiator antiplagiator"
shell-redis: shell-redis:
$(COMPOSE) exec redis redis-cli @echo "Redis общий (192.168.1.19) — подключайся напрямую: redis-cli -h 192.168.1.19 -n 3"
# ─── Линтинг и тесты ────────────────────────────────────────────────────────── # ─── Линтинг и тесты ──────────────────────────────────────────────────────────
lint: lint:
$(COMPOSE) exec api ruff check . --fix $(COMPOSE_PROD) exec api ruff check . --fix
$(COMPOSE) exec api mypy app/ $(COMPOSE_PROD) exec api mypy app/
lint-frontend: lint-frontend:
cd services/frontend && npm run lint cd services/frontend && npm run lint
test: test:
$(COMPOSE) exec api pytest tests/ -v $(COMPOSE_PROD) exec api pytest tests/ -v
test-cov: test-cov:
$(COMPOSE) exec api pytest tests/ -v --cov=app --cov-report=html $(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html
# ─── Утилиты ────────────────────────────────────────────────────────────────── # ─── Утилиты ──────────────────────────────────────────────────────────────────
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —
# общие postgres/redis/rabbitmq/minio не в Compose, их не затронет.
clean: clean:
$(COMPOSE) down -v --remove-orphans $(COMPOSE_PROD) down -v --remove-orphans
docker system prune -f docker system prune -f
# Запустить парсер (пример: make parse PARSER=openalex ARGS="--limit 1000") # Запустить парсер (пример: make parse PARSER=openalex ARGS="--limit 1000")
parse: parse:
$(COMPOSE) exec api python /scripts/run_parser.py $(PARSER) $(ARGS) $(COMPOSE_PROD) exec api python /scripts/run_parser.py $(PARSER) $(ARGS)
# Создать MinIO бакеты # Создать MinIO бакеты (запускать один раз на общем MinIO 192.168.1.21, не локально)
minio-init: minio-init:
$(COMPOSE) exec minio mc alias set local http://localhost:9000 minioadmin changeme @echo "MinIO общий (192.168.1.21) — бакеты documents/backups создаются на нём, см. .env MINIO_*"
$(COMPOSE) exec minio mc mb local/documents || true
$(COMPOSE) exec minio mc mb local/backups || true
# Создать ES индекс # Создать ES индекс
es-init: es-init:
@@ -115,13 +135,14 @@ es-init:
# Статистика Celery # Статистика Celery
flower: flower:
@echo "Flower доступен по адресу http://localhost:5555" @echo "Flower доступен по адресу http://localhost:5555"
@$(COMPOSE) ps flower @$(COMPOSE_PROD) ps flower
help: help:
@echo "Академический помощник — команды make:" @echo "Академический помощник — команды make:"
@echo "" @echo ""
@echo " make dev — запустить в режиме разработки (hot reload)" @echo " make test-up — запустить тестовый стек (hot reload, общая инфра)"
@echo " make buildсобрать Docker образы" @echo " make dev запустить полностью локальный sandbox (редко нужно)"
@echo " make build — собрать прод-образы"
@echo " make up — запустить в продакшн режиме" @echo " make up — запустить в продакшн режиме"
@echo " make down — остановить все сервисы" @echo " make down — остановить все сервисы"
@echo " make migrate — применить миграции Alembic" @echo " make migrate — применить миграции Alembic"
@@ -129,4 +150,4 @@ help:
@echo " make shell-api — открыть shell в контейнере api" @echo " make shell-api — открыть shell в контейнере api"
@echo " make lint — запустить линтер" @echo " make lint — запустить линтер"
@echo " make test — запустить тесты" @echo " make test — запустить тесты"
@echo " make clean — удалить все контейнеры и volumes" @echo " make clean — удалить прод-контейнеры и ЛОКАЛЬНЫЕ volume"

View File

@@ -134,21 +134,31 @@ python scripts/run_parser.py arxiv \
Смотри `.env.example` для полного списка переменных. Смотри `.env.example` для полного списка переменных.
Обязательно смените `SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`. Обязательно смените `SECRET_KEY`, `POSTGRES_PASSWORD`, `MINIO_SECRET_KEY`.
## Три docker-compose файла
| Файл | Назначение |
|------|-----------|
| `docker-compose.prod.yml` | **Реальный прод.** app-сервисы + nginx (собранный фронтенд + TLS) + локальный Elasticsearch. Postgres/Redis/RabbitMQ/MinIO/Ollama — уже существующие общие серверы, адреса в `.env`. |
| `docker-compose.test.yml` | Повседневная разработка — hot reload против той же общей инфры, что и прод (`make test-up`). |
| `docker-compose.selfhosted.yml.example` | Не используется. Полностью автономный вариант (свои Postgres/Redis/RabbitMQ/ES/MinIO/Ollama + GPU passthrough) — на случай отдельного выделенного сервера в будущем. |
## Продакшн деплой ## Продакшн деплой
```bash ```bash
# На сервере # На сервере
cp .env.example .env cp .env.example .env
nano .env # Настроить все пароли и ключи nano .env # Настроить все пароли и ключи, указать реальные адреса общих Postgres/Redis/RabbitMQ/MinIO/Ollama
# Сертификат ДО первого запуска nginx-контейнера (standalone, порт 80 должен быть свободен)
certbot certonly --standalone -d academic.jze9.ru
make build make build
make up make up
make migrate make migrate
# SSL сертификат (Let's Encrypt)
certbot --nginx -d academic.jze9.ru
``` ```
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`), контейнер просто читает его.
## Лицензия ## Лицензия
MIT MIT

129
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,129 @@
# ═══════════════════════════════════════════════════════════════════════════════
# ПРОД стек: локально — nginx (static+proxy), api, воркеры, elasticsearch.
# Удалённо (через .env) — postgres, redis, minio, ollama, rabbitmq.
# Тот же принцип, что и docker-compose.test.yml (не дублировать общую инфру),
# но без bind-mount'ов исходников, без --reload, с собранным фронтендом и nginx/TLS.
# Запуск: docker compose -f docker-compose.prod.yml up -d --build
# ═══════════════════════════════════════════════════════════════════════════════
networks:
antiplagiator:
driver: bridge
volumes:
es_prod_data:
faiss_index_prod:
x-app-env: &app-env
env_file: .env
networks:
- antiplagiator
restart: unless-stopped
services:
# ─── Локальная инфраструктура ──────────────────────────────────────────────
elasticsearch:
image: elasticsearch:8.13.0
container_name: antiplagiator-elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- xpack.ml.enabled=false
- ES_JAVA_OPTS=-Xms2g -Xmx2g
- bootstrap.memory_lock=true
- cluster.name=antiplagiator-prod
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- es_prod_data:/usr/share/elasticsearch/data
networks:
- antiplagiator
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -qv '\"status\":\"red\"'"]
interval: 15s
timeout: 10s
retries: 10
start_period: 60s
# ─── Приложение ────────────────────────────────────────────────────────────
# RabbitMQ вынесен на отдельный сервер 192.168.20.82 (см. RABBITMQ_URL в .env)
api:
build:
context: ./services/api
dockerfile: Dockerfile
container_name: antiplagiator-api
<<: *app-env
depends_on:
elasticsearch:
condition: service_healthy
worker-gpu:
build:
context: ./services/worker-gpu
dockerfile: Dockerfile
container_name: antiplagiator-worker-gpu
<<: *app-env
volumes:
- faiss_index_prod:/data/index
depends_on:
elasticsearch:
condition: service_healthy
worker-indexer:
build:
context: ./services/worker-indexer
dockerfile: Dockerfile
container_name: antiplagiator-worker-indexer
<<: *app-env
volumes:
- ./scripts/parsers:/parsers:ro
depends_on:
elasticsearch:
condition: service_healthy
worker-notifier:
build:
context: ./services/worker-notifier
dockerfile: Dockerfile
container_name: antiplagiator-worker-notifier
<<: *app-env
worker-gost:
build:
context: ./services/worker-gost
dockerfile: Dockerfile
container_name: antiplagiator-worker-gost
<<: *app-env
flower:
image: mher/flower:2.0
container_name: antiplagiator-flower
command: celery --broker=${RABBITMQ_URL} flower --port=5555
environment:
CELERY_BROKER_URL: ${RABBITMQ_URL}
CELERY_RESULT_BACKEND: ${REDIS_URL}
networks:
- antiplagiator
restart: unless-stopped
# Flower не публикуется наружу напрямую — доступ через ssh-туннель при необходимости
# ─── Nginx: собранный фронтенд (static) + proxy /api, /ws → api:8000, TLS ──
nginx:
build:
context: .
dockerfile: infra/nginx/Dockerfile
container_name: antiplagiator-nginx
networks:
- antiplagiator
ports:
- "80:80"
- "443:443"
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
restart: unless-stopped

View File

@@ -1,5 +1,13 @@
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# Академический помощник — Production Docker Compose # Академический помощник — self-hosted вариант (свои Postgres/Redis/RabbitMQ/
# ES/MinIO/Ollama + GPU passthrough для ollama/worker-gpu).
#
# НЕ используется сейчас — реальный прод разворачивается через
# docker-compose.prod.yml поверх уже существующей общей инфры (Postgres .11,
# Redis .19, RabbitMQ .82, MinIO .21, Ollama CT108). Держим этот файл на случай,
# если когда-нибудь появится отдельный GPU-сервер под этот проект — тогда
# у worker-indexer нужно будет ещё добавить volume ./scripts/parsers:/parsers:ro
# (в этом файле его сейчас нет, в отличие от prod/test).
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
networks: networks:
@@ -13,7 +21,7 @@ volumes:
elasticsearch_data: elasticsearch_data:
minio_data: minio_data:
ollama_data: ollama_data:
faiss_index: faiss_index_selfhosted:
# ─── Общие переменные окружения для app-сервисов ──────────────────────────── # ─── Общие переменные окружения для app-сервисов ────────────────────────────
x-app-env: &app-env x-app-env: &app-env
@@ -177,7 +185,7 @@ services:
<<: *app-env <<: *app-env
command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=info command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=info
volumes: volumes:
- faiss_index:/data/index - faiss_index_selfhosted:/data/index
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

10
infra/nginx/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:20-slim AS build
WORKDIR /app
COPY services/frontend/package.json services/frontend/package-lock.json* ./
RUN npm install
COPY services/frontend/ .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /var/www/academic
COPY infra/nginx/nginx.conf /etc/nginx/nginx.conf

View File

@@ -72,6 +72,12 @@ http {
add_header X-Frame-Options DENY; add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block"; add_header X-XSS-Protection "1; mode=block";
# ── Health check (не под /api/ в самом приложении) ─────────────────────
location = /health {
proxy_pass http://api/health;
proxy_set_header Host $host;
}
# ── API proxy ────────────────────────────────────────────────────────── # ── API proxy ──────────────────────────────────────────────────────────
location /api/ { location /api/ {
proxy_pass http://api/api/; proxy_pass http://api/api/;

109
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Деплой anti-plagiarism (умная пересборка — только изменённых сервисов).
#
# Запускается Gitea Actions runner'ом в host-режиме прямо на app-хосте 1.32
# (см. .gitea/workflows/deploy.yml). Ожидает:
# - cwd = ПОЛНЫЙ чекаут репозитория (нужен git-лог для diff);
# - переменную PVE_PASSWORD (секрет) для доступа к Proxmox-хосту → CT 102.
#
# Пересобирается только то, чей код изменился с прошлого деплоя (маркер SHA в
# $APP/.last_deploy_sha). Это критично: worker-gpu тянет torch/faiss (~2 ГБ),
# его нельзя пересобирать без нужды. Если менялся docker-compose.prod.yml —
# пересобираются все бэкенд-сервисы.
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
APP=/home/user/anti-plagiarism
SRC="$(pwd)"
PVE_HOST=192.168.20.254
COMPOSE="docker compose -f docker-compose.prod.yml"
ALL_BACKEND="api worker-gpu worker-indexer worker-notifier worker-gost"
MARKER="$APP/.last_deploy_sha"
CUR_SHA="$(git -C "$SRC" rev-parse HEAD)"
# ── Определить, что менялось ──────────────────────────────────────────────────
FULL=0
CHANGED=""
if [ -f "$MARKER" ] && PREV="$(cat "$MARKER")" && [ -n "$PREV" ] \
&& git -C "$SRC" cat-file -e "$PREV" 2>/dev/null; then
CHANGED="$(git -C "$SRC" diff --name-only "$PREV" "$CUR_SHA")"
echo "Изменения с $PREV:"
echo "$CHANGED" | sed 's/^/ /'
# Правка общего compose затрагивает все образы
echo "$CHANGED" | grep -qE '^docker-compose\.prod\.yml$' && FULL=1
else
echo "Маркер прошлого деплоя недоступен — полная пересборка (разовая)"
FULL=1
fi
REBUILD=""
FRONTEND_CHANGED=0
if [ "$FULL" = 1 ]; then
REBUILD="$ALL_BACKEND"
FRONTEND_CHANGED=1
else
for svc in $ALL_BACKEND; do
echo "$CHANGED" | grep -qE "^services/$svc/" && REBUILD="$REBUILD $svc"
done
echo "$CHANGED" | grep -qE '^services/frontend/' && FRONTEND_CHANGED=1
fi
REBUILD="$(echo "$REBUILD" | xargs || true)"
echo "==> [1/5] Синхронизация кода в $APP (сохраняя .env и compose)"
rsync -a \
--exclude='__pycache__/' --exclude='*.pyc' --exclude='.pytest_cache/' \
--exclude='node_modules/' --exclude='dist/' \
"$SRC/services" "$SRC/scripts" "$APP/"
cd "$APP"
if [ -n "$REBUILD" ]; then
echo "==> [2/5] Пересборка и перезапуск: $REBUILD"
# shellcheck disable=SC2086
$COMPOSE up -d --build $REBUILD
else
echo "==> [2/5] Бэкенд-сервисы не менялись — пропуск сборки"
fi
echo "==> [3/5] Миграции БД (с ретраем)"
for i in $(seq 1 10); do
$COMPOSE exec -T api alembic upgrade head && break
echo " api ещё не готов, повтор $i/10..."; sleep 5
done
if [ "$FRONTEND_CHANGED" = 1 ]; then
echo "==> [4/5] Сборка фронтенда"
docker run --rm -v "$APP/services/frontend":/app -w /app node:20-slim \
sh -c "npm install --no-audit --no-fund --loglevel=error && npm run build"
echo "==> [5/5] Выкладка статики на CT 102 (через $PVE_HOST)"
cd "$APP/services/frontend/dist"
tar czf /tmp/academic-dist.tgz .
export SSHPASS="${PVE_PASSWORD:?PVE_PASSWORD не задан}"
SSHOPT="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
# shellcheck disable=SC2086
sshpass -e scp $SSHOPT /tmp/academic-dist.tgz "root@${PVE_HOST}:/tmp/academic-dist.tgz"
# shellcheck disable=SC2086
sshpass -e ssh $SSHOPT "root@${PVE_HOST}" 'bash -s' <<'REMOTE'
set -e
pct push 102 /tmp/academic-dist.tgz /tmp/academic-dist.tgz
pct exec 102 -- bash -c '
set -e
cd /var/www
cp -a academic "academic.bak.$(date +%s)"
cd academic
rm -rf assets index.html
tar xzf /tmp/academic-dist.tgz
chown -R 1000:1000 .
nginx -t && nginx -s reload
'
rm -f /tmp/academic-dist.tgz
REMOTE
rm -f /tmp/academic-dist.tgz
else
echo "==> [4-5/5] Фронтенд не менялся — пропуск сборки и выкладки"
fi
echo "$CUR_SHA" > "$MARKER"
echo "==> Деплой завершён успешно (маркер обновлён: $CUR_SHA)"

View File

@@ -192,9 +192,17 @@ class OpenAlexParser(BaseParser):
source = primary_location.get("source") or {} source = primary_location.get("source") or {}
journal = source.get("display_name") journal = source.get("display_name")
# URL на полный текст # URL на полный текст: предпочитаем прямую ссылку на PDF (её реально можно
# скачать и извлечь текст), иначе oa_url / лендинг журнала.
best_oa = raw.get("best_oa_location") or {}
oa = raw.get("open_access") or {} oa = raw.get("open_access") or {}
url = oa.get("oa_url") or primary_location.get("landing_page_url") url = (
best_oa.get("pdf_url")
or primary_location.get("pdf_url")
or oa.get("oa_url")
or best_oa.get("landing_page_url")
or primary_location.get("landing_page_url")
)
# Аннотация (восстановить из инвертированного индекса) # Аннотация (восстановить из инвертированного индекса)
abstract = None abstract = None

View File

@@ -12,11 +12,19 @@ from app.core.security import (
create_access_token, create_access_token,
get_current_user, get_current_user,
hash_password, hash_password,
invalidate_user_cache,
verify_password, verify_password,
) )
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserInToken from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RegisterRequest,
TokenResponse,
UpdateProfileRequest,
UserInToken,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -98,6 +106,127 @@ async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken:
return UserInToken.model_validate(current_user) return UserInToken.model_validate(current_user)
@router.patch("/me", response_model=UserInToken)
async def update_profile(
data: UpdateProfileRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> UserInToken:
"""
Изменить персональные данные (имя и/или email).
Смена email сбрасывает подтверждение и отправляет новое письмо верификации.
"""
# Загружаем "живого" пользователя из БД: объект из Redis-кэша не привязан
# к сессии и не содержит части полей.
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
email_changed = False
if data.name is not None:
user.name = data.name
if data.email is not None:
new_email = data.email.lower()
if new_email != user.email:
existing = await db.execute(
select(User).where(User.email == new_email, User.id != user.id)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Этот email уже используется другим аккаунтом",
)
user.email = new_email
user.is_verified = False
user.verification_token = secrets.token_urlsafe(32)
email_changed = True
await db.commit()
await db.refresh(user)
await invalidate_user_cache(user.id)
# При смене email — отправить новое письмо подтверждения на новый адрес
if email_changed:
try:
celery_app.send_task(
"notify.send_verification",
args=[user.email, user.name, user.verification_token],
queue="queue.notify",
)
except Exception as e:
logger.warning(f"Не удалось отправить письмо верификации при смене email: {e}")
return UserInToken.model_validate(user)
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
data: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Сменить пароль. Требует текущий пароль для подтверждения."""
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
if not verify_password(data.current_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Текущий пароль указан неверно",
)
if verify_password(data.new_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новый пароль совпадает с текущим",
)
user.hashed_password = hash_password(data.new_password)
await db.commit()
await invalidate_user_cache(user.id)
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
async def resend_verification(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Повторно отправить письмо с подтверждением email."""
if current_user.is_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email уже подтверждён",
)
if not current_user.verification_token:
current_user.verification_token = secrets.token_urlsafe(32)
await db.commit()
await db.refresh(current_user)
try:
celery_app.send_task(
"notify.send_verification",
args=[current_user.email, current_user.name, current_user.verification_token],
queue="queue.notify",
)
except Exception as e:
logger.warning(f"Не удалось поставить задачу повторной верификации: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Не удалось отправить письмо, попробуйте позже",
)
@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK) @router.post("/verify-email/{token}", status_code=status.HTTP_200_OK)
async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict: async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
"""Подтвердить email по токену из письма.""" """Подтвердить email по токену из письма."""
@@ -115,5 +244,6 @@ async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
user.is_verified = True user.is_verified = True
user.verification_token = None user.verification_token = None
await db.commit() await db.commit()
await invalidate_user_cache(user.id)
return {"message": "Email успешно подтверждён"} return {"message": "Email успешно подтверждён"}

View File

@@ -45,11 +45,12 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней
# SMTP # SMTP
SMTP_HOST: str = "smtp.yandex.ru" SMTP_HOST: str = "mail.jze9mail.ru"
SMTP_PORT: int = 465 SMTP_PORT: int = 587
SMTP_USER: str = "noreply@jze9.ru" SMTP_USER: str = "noreply"
SMTP_PASSWORD: str = "changeme" SMTP_PASSWORD: str = "changeme"
SMTP_FROM: str = "noreply@jze9.ru" SMTP_FROM: str = "noreply@jze9mail.ru"
SMTP_TLS_VERIFY: bool = True
# App # App
APP_URL: str = "https://academic.jze9.ru" APP_URL: str = "https://academic.jze9.ru"

View File

@@ -18,6 +18,20 @@ class LoginRequest(BaseModel):
password: str password: str
class UpdateProfileRequest(BaseModel):
"""Изменение персональных данных (имя и/или email)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
email: EmailStr | None = None
class ChangePasswordRequest(BaseModel):
"""Смена пароля: нужен текущий пароль для подтверждения."""
current_password: str
new_password: str = Field(min_length=8, max_length=128)
class UserInToken(BaseModel): class UserInToken(BaseModel):
"""Минимальная информация о пользователе в JWT токене.""" """Минимальная информация о пользователе в JWT токене."""

View File

@@ -38,8 +38,17 @@ export const authApi = {
me: () => api.get('/auth/me'), me: () => api.get('/auth/me'),
updateProfile: (data: { name?: string; email?: string }) =>
api.patch('/auth/me', data),
changePassword: (data: { current_password: string; new_password: string }) =>
api.post('/auth/change-password', data),
verifyEmail: (token: string) => verifyEmail: (token: string) =>
api.post(`/auth/verify-email/${token}`), api.post(`/auth/verify-email/${token}`),
resendVerification: () =>
api.post('/auth/resend-verification'),
}; };
export const tasksApi = { export const tasksApi = {

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { Upload, FileText, X } from 'lucide-react'; import { Upload, FileText, X } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';

View File

@@ -0,0 +1,126 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Mail, X, CheckCircle2, AlertCircle } from 'lucide-react';
import toast from 'react-hot-toast';
import { authApi } from '../api/client';
import { useAuthStore } from '../store/auth';
export function EmailVerificationModal() {
const { user, updateUser } = useAuthStore();
const location = useLocation();
const [dismissed, setDismissed] = useState(false);
const [verified, setVerified] = useState(false);
// Не показывать на странице подтверждения — там своя UI
if (location.pathname.startsWith('/verify-email')) return null;
const resend = useMutation({
mutationFn: () => authApi.resendVerification(),
onSuccess: () => toast.success('Письмо отправлено, проверьте почту'),
onError: () => toast.error('Не удалось отправить письмо'),
});
useQuery({
queryKey: ['email-verification-poll'],
queryFn: async () => {
const res = await authApi.me();
if (res.data.is_verified) {
updateUser({ is_verified: true });
setVerified(true);
}
return res.data;
},
enabled: !!(user && !user.is_verified && !dismissed && !verified),
refetchInterval: 5000,
staleTime: 0,
});
if (!user || dismissed) return null;
if (!user.is_verified && !verified) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8">
<button
onClick={() => setDismissed(true)}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="flex justify-center mb-5">
<div className="p-4 bg-amber-50 rounded-full">
<Mail className="w-8 h-8 text-amber-500" />
</div>
</div>
<h2 className="text-xl font-bold text-gray-900 text-center mb-1">
Подтвердите ваш email
</h2>
<p className="text-center text-sm text-gray-400 mb-6">{user.email}</p>
{/* Статус */}
<div className="flex items-center justify-center gap-2 mb-6 px-4 py-3 bg-amber-50 rounded-xl">
<AlertCircle className="w-4 h-4 text-amber-500 shrink-0" />
<span className="text-sm font-medium text-amber-700">Email не подтверждён</span>
</div>
<p className="text-sm text-gray-500 text-center mb-6 leading-relaxed">
Мы отправили письмо на{' '}
<span className="font-medium text-gray-800">{user.email}</span>.
Нажмите кнопку «Подтвердить» в письме для активации аккаунта.
</p>
<button
onClick={() => resend.mutate()}
disabled={resend.isPending}
className="w-full py-3 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors"
>
{resend.isPending ? 'Отправляем...' : 'Подтвердить'}
</button>
<button
onClick={() => setDismissed(true)}
className="w-full mt-3 py-2.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
Напомнить позже
</button>
</div>
</div>
);
}
if (verified) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8 text-center">
<div className="flex justify-center mb-5">
<div className="p-4 bg-green-50 rounded-full">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Email подтверждён!</h2>
<div className="flex items-center justify-center gap-2 mb-6 px-4 py-3 bg-green-50 rounded-xl">
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />
<span className="text-sm font-medium text-green-700">Email подтверждён</span>
</div>
<p className="text-sm text-gray-500 mb-6">
Аккаунт активирован. Теперь вы получаете уведомления о завершении задач.
</p>
<button
onClick={() => setDismissed(true)}
className="w-full py-3 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors"
>
Закрыть
</button>
</div>
</div>
);
}
return null;
}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Copy, Check } from 'lucide-react'; import { Copy, Check } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom'; import { Link, NavLink, useNavigate } from 'react-router-dom';
import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react'; import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { EmailVerificationModal } from './EmailVerificationModal';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -144,6 +144,8 @@ export function Layout({ children }: LayoutProps) {
<main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children} {children}
</main> </main>
<EmailVerificationModal />
</div> </div>
); );
} }

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { AlertTriangle, CheckCircle, Info } from 'lucide-react'; import { AlertTriangle, CheckCircle, Info } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { PlagiarismResultData } from '../types'; import type { PlagiarismResultData } from '../types';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react'; import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { TaskStatus } from '../types'; import type { TaskStatus } from '../types';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale'; import { ru } from 'date-fns/locale';

View File

@@ -10,10 +10,12 @@ import { Search } from './pages/Search';
import { Check } from './pages/Check'; import { Check } from './pages/Check';
import { Bibliography } from './pages/Bibliography'; import { Bibliography } from './pages/Bibliography';
import { Cabinet } from './pages/Cabinet'; import { Cabinet } from './pages/Cabinet';
import { Settings } from './pages/Settings';
import { Task } from './pages/Task'; import { Task } from './pages/Task';
import { Pricing } from './pages/Pricing'; import { Pricing } from './pages/Pricing';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
import { Register } from './pages/Register'; import { Register } from './pages/Register';
import { VerifyEmail } from './pages/VerifyEmail';
import { AdminLayout } from './pages/admin/AdminLayout'; import { AdminLayout } from './pages/admin/AdminLayout';
import { Dashboard } from './pages/admin/Dashboard'; import { Dashboard } from './pages/admin/Dashboard';
import { Users as AdminUsers } from './pages/admin/Users'; import { Users as AdminUsers } from './pages/admin/Users';
@@ -44,10 +46,12 @@ function PublicApp() {
<Route path="/check" element={<Check />} /> <Route path="/check" element={<Check />} />
<Route path="/bibliography" element={<Bibliography />} /> <Route path="/bibliography" element={<Bibliography />} />
<Route path="/cabinet" element={<Cabinet />} /> <Route path="/cabinet" element={<Cabinet />} />
<Route path="/settings" element={<Settings />} />
<Route path="/tasks/:taskId" element={<Task />} /> <Route path="/tasks/:taskId" element={<Task />} />
<Route path="/pricing" element={<Pricing />} /> <Route path="/pricing" element={<Pricing />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/verify-email/:token" element={<VerifyEmail />} />
</Routes> </Routes>
</Layout> </Layout>
); );

View File

@@ -1,16 +1,14 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react'; import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { GostCitation } from '../components/GostCitation'; import { GostCitation } from '../components/GostCitation';
import { useBibliographyStore } from '../store/bibliography'; import { useBibliographyStore } from '../store/bibliography';
import { useAuthStore } from '../store/auth';
import { api } from '../api/client'; import { api } from '../api/client';
import type { GostResultData } from '../types'; import type { GostResultData } from '../types';
export function Bibliography() { export function Bibliography() {
const { sources, removeSource, clearSources } = useBibliographyStore(); const { sources, removeSource, clearSources } = useBibliographyStore();
const { isAuthenticated } = useAuthStore();
const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1'); const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1');
const [formattedResult, setFormattedResult] = useState<GostResultData | null>(null); const [formattedResult, setFormattedResult] = useState<GostResultData | null>(null);

View File

@@ -1,7 +1,6 @@
import React from 'react'; import { Navigate, useNavigate, Link } from 'react-router-dom';
import { Navigate, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Crown, Search, Shield, BookOpen, ShieldAlert } from 'lucide-react'; import { Crown, Search, Shield, BookOpen, ShieldAlert, Settings as SettingsIcon } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { TaskCard } from '../components/TaskCard'; import { TaskCard } from '../components/TaskCard';
import { tasksApi, adminApi } from '../api/client'; import { tasksApi, adminApi } from '../api/client';
@@ -61,6 +60,13 @@ export function Cabinet() {
<p className="text-gray-500 text-sm">{user?.email}</p> <p className="text-gray-500 text-sm">{user?.email}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link
to="/settings"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<SettingsIcon className="w-4 h-4" />
Настройки
</Link>
{user?.is_admin && ( {user?.is_admin && (
<button <button
onClick={openAdmin} onClick={openAdmin}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Search, Shield, BookOpen, Zap } from 'lucide-react'; import { Search, Shield, BookOpen, Zap } from 'lucide-react';
import { SearchBar } from '../components/SearchBar'; import { SearchBar } from '../components/SearchBar';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { PLAN_LIMITS } from '../types'; import { PLAN_LIMITS } from '../types';

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { Filter, Loader2 } from 'lucide-react'; import { Filter, Loader2 } from 'lucide-react';
@@ -41,16 +41,13 @@ export function Search() {
}); });
// Поллинг задачи // Поллинг задачи
const { const { data: task } = useQuery({
data: task,
isLoading: isPolling,
} = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task), queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task),
enabled: !!taskId, enabled: !!taskId,
refetchInterval: (data) => { refetchInterval: (query) => {
if (!data) return 3000; const status = query.state.data?.status;
if (data.status === 'done' || data.status === 'failed') return false; if (status === 'done' || status === 'failed') return false;
return 3000; return 3000;
}, },
}); });

View File

@@ -0,0 +1,237 @@
import { useState } from 'react';
import { Navigate, Link } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { ArrowLeft, User as UserIcon, Lock, Mail, AlertCircle, CheckCircle2 } from 'lucide-react';
import toast from 'react-hot-toast';
import { authApi } from '../api/client';
import { useAuthStore } from '../store/auth';
import type { User } from '../types';
export function Settings() {
const { isAuthenticated, user, updateUser } = useAuthStore();
// Персональные данные
const [name, setName] = useState(user?.name ?? '');
const [email, setEmail] = useState(user?.email ?? '');
// Смена пароля
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
if (!isAuthenticated) return <Navigate to="/login" replace />;
const emailChanged = email.trim().toLowerCase() !== (user?.email ?? '').toLowerCase();
const profileDirty = name.trim() !== (user?.name ?? '') || emailChanged;
const saveProfile = useMutation({
mutationFn: () => {
const payload: { name?: string; email?: string } = {};
if (name.trim() !== user?.name) payload.name = name.trim();
if (emailChanged) payload.email = email.trim();
return authApi.updateProfile(payload);
},
onSuccess: (response) => {
const updated: User = response.data;
updateUser(updated);
setEmail(updated.email);
setName(updated.name);
toast.success(
emailChanged
? 'Данные сохранены. Мы отправили письмо для подтверждения нового email.'
: 'Данные сохранены'
);
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Не удалось сохранить данные');
},
});
const changePassword = useMutation({
mutationFn: () =>
authApi.changePassword({ current_password: currentPassword, new_password: newPassword }),
onSuccess: () => {
toast.success('Пароль изменён');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Не удалось сменить пароль');
},
});
const passwordMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
const canChangePassword =
currentPassword.length > 0 &&
newPassword.length >= 8 &&
newPassword === confirmPassword &&
!changePassword.isPending;
const inputClass =
'w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-shadow';
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Шапка */}
<div className="flex items-center gap-3">
<Link
to="/cabinet"
className="p-2 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl font-semibold text-gray-900">Настройки профиля</h1>
<p className="text-sm text-gray-400">Персональные данные и безопасность</p>
</div>
</div>
{/* Персональные данные */}
<form
onSubmit={(e) => {
e.preventDefault();
if (profileDirty) saveProfile.mutate();
}}
className="bg-white rounded-2xl border border-gray-100 p-6"
>
<div className="flex items-center gap-2 mb-5">
<div className="w-9 h-9 bg-brand-50 rounded-lg flex items-center justify-center">
<UserIcon className="w-4 h-4 text-brand-600" />
</div>
<h2 className="text-base font-semibold text-gray-900">Персональные данные</h2>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Имя</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
minLength={1}
maxLength={255}
placeholder="Иван Иванов"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Email</label>
<div className="relative">
<Mail className="w-4 h-4 text-gray-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="ivan@example.com"
className={`${inputClass} pl-10`}
/>
</div>
{/* Статус подтверждения / предупреждение о смене */}
{emailChanged ? (
<div className="flex items-start gap-2 mt-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
После сохранения потребуется подтвердить новый адрес мы пришлём письмо со ссылкой.
</span>
</div>
) : user?.is_verified ? (
<div className="flex items-center gap-1.5 mt-2 text-xs text-green-600">
<CheckCircle2 className="w-3.5 h-3.5" />
Email подтверждён
</div>
) : (
<div className="flex items-center gap-1.5 mt-2 text-xs text-amber-600">
<AlertCircle className="w-3.5 h-3.5" />
Email не подтверждён
</div>
)}
</div>
</div>
<div className="flex justify-end mt-6">
<button
type="submit"
disabled={!profileDirty || saveProfile.isPending}
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saveProfile.isPending ? 'Сохраняем...' : 'Сохранить изменения'}
</button>
</div>
</form>
{/* Смена пароля */}
<form
onSubmit={(e) => {
e.preventDefault();
if (canChangePassword) changePassword.mutate();
}}
className="bg-white rounded-2xl border border-gray-100 p-6"
>
<div className="flex items-center gap-2 mb-5">
<div className="w-9 h-9 bg-brand-50 rounded-lg flex items-center justify-center">
<Lock className="w-4 h-4 text-brand-600" />
</div>
<h2 className="text-base font-semibold text-gray-900">Смена пароля</h2>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Текущий пароль</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
placeholder="••••••••"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">Новый пароль</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
placeholder="Минимум 8 символов"
className={inputClass}
/>
</div>
<div>
<label className="text-sm font-medium text-gray-700 block mb-1">
Повторите новый пароль
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="••••••••"
className={`${inputClass} ${passwordMismatch ? 'border-red-300 focus:ring-red-400' : ''}`}
/>
{passwordMismatch && (
<p className="text-xs text-red-500 mt-1.5">Пароли не совпадают</p>
)}
</div>
</div>
<div className="flex justify-end mt-6">
<button
type="submit"
disabled={!canChangePassword}
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{changePassword.isPending ? 'Меняем...' : 'Изменить пароль'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useParams, Navigate } from 'react-router-dom'; import { useParams, Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Loader2, ArrowLeft } from 'lucide-react'; import { Loader2, ArrowLeft } from 'lucide-react';
@@ -22,9 +21,9 @@ export function Task() {
const { data: task, isLoading } = useQuery({ const { data: task, isLoading } = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
queryFn: () => tasksApi.get(taskId).then((r) => r.data as TaskType), queryFn: () => tasksApi.get(taskId).then((r) => r.data as TaskType),
refetchInterval: (data) => { refetchInterval: (query) => {
if (!data) return 3000; const status = query.state.data?.status;
return (data.status === 'done' || data.status === 'failed') ? false : 3000; return (status === 'done' || status === 'failed') ? false : 3000;
}, },
}); });

View File

@@ -0,0 +1,83 @@
import { useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { authApi } from '../api/client';
import { useAuthStore } from '../store/auth';
export function VerifyEmail() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { updateUser } = useAuthStore();
const verify = useMutation({
mutationFn: () => authApi.verifyEmail(token!),
onSuccess: () => {
updateUser({ is_verified: true });
setTimeout(() => navigate('/cabinet'), 3000);
},
});
useEffect(() => {
if (token) verify.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<div className="max-w-md mx-auto pt-16 text-center">
<div className="bg-white rounded-2xl border border-gray-100 p-10">
{verify.isPending && (
<>
<div className="flex justify-center mb-5">
<Loader2 className="w-14 h-14 text-brand-600 animate-spin" />
</div>
<h1 className="text-xl font-bold text-gray-900 mb-2">Подтверждаем email</h1>
<p className="text-sm text-gray-500">Пожалуйста, подождите</p>
</>
)}
{verify.isSuccess && (
<>
<div className="flex justify-center mb-5">
<div className="p-4 bg-green-50 rounded-full">
<CheckCircle2 className="w-14 h-14 text-green-500" />
</div>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Email подтверждён!</h1>
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-50 rounded-xl mb-4">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-green-700">Email подтверждён</span>
</div>
<p className="text-sm text-gray-500">
Аккаунт активирован. Перенаправляем в личный кабинет
</p>
</>
)}
{verify.isError && (
<>
<div className="flex justify-center mb-5">
<div className="p-4 bg-red-50 rounded-full">
<XCircle className="w-14 h-14 text-red-500" />
</div>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ошибка подтверждения</h1>
<div className="inline-flex items-center gap-2 px-4 py-2 bg-red-50 rounded-xl mb-4">
<XCircle className="w-4 h-4 text-red-500" />
<span className="text-sm font-medium text-red-700">Email не подтверждён</span>
</div>
<p className="text-sm text-gray-500 mb-6">
Ссылка недействительна или устарела. Запросите новое письмо в личном кабинете.
</p>
<Link
to="/cabinet"
className="inline-block px-6 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors"
>
В личный кабинет
</Link>
</>
)}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2, Search } from 'lucide-react'; import { Trash2, Search } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react'; import { Play, Trash2, Plus } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, X, Eye } from 'lucide-react'; import { Check, X, Eye } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { HardDrive } from 'lucide-react'; import { HardDrive } from 'lucide-react';
import { adminApi } from '../../api/client'; import { adminApi } from '../../api/client';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2, Search } from 'lucide-react'; import { Trash2, Search } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Trash2 } from 'lucide-react'; import { RefreshCw, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';

1
services/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,14 +1,20 @@
"""Singleton менеджер FAISS GPU индекса. """Singleton менеджер FAISS индекса.
Использует IVFFlat (а не HNSW — не поддерживается на GPU). Используется плоский индекс IndexFlatIP, обёрнутый в IndexIDMap2, что даёт:
Поддерживает graceful degradation на CPU если GPU недоступна. - отсутствие этапа обучения (в отличие от IVFFlat) — индекс работоспособен сразу,
начиная с первого вектора;
- хранение doc_id прямо внутри индекса (add_with_ids) — не нужен отдельный
маппинг на диске, а поиск сразу возвращает doc_id из PostgreSQL;
- корректную персистентность через faiss.write_index / read_index.
Векторы модели нормализованы (normalize_embeddings=True), поэтому inner product
эквивалентен косинусной близости. Для масштаба проекта (сотни тысяч — единицы
миллионов документов) полный перебор по FlatIP по скорости приемлем.
""" """
import json
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional
import numpy as np import numpy as np
@@ -18,70 +24,83 @@ logger = logging.getLogger(__name__)
class FAISSManager: class FAISSManager:
"""Singleton для управления FAISS индексом на GPU.""" """Singleton для управления FAISS индексом (IndexIDMap2 поверх IndexFlatIP)."""
_index = None _index = None
_id_map: dict[int, int] = {} # faiss_internal_id -> doc_id (PostgreSQL) # doc_id -> faiss_id. Для IDMap2 faiss_id == doc_id, но маппинг сохраняем
_reverse_map: dict[int, int] = {} # doc_id -> faiss_internal_id # для совместимости с вызывающим кодом (plagiarism.embed_documents).
_reverse_map: dict[int, int] = {}
_use_gpu: bool = False _use_gpu: bool = False
_is_trained: bool = False
@classmethod
def _new_index(cls):
"""Создать новый пустой индекс нужного типа."""
import faiss
base = faiss.IndexFlatIP(settings.EMBED_DIM)
return faiss.IndexIDMap2(base)
@classmethod @classmethod
def load_or_create(cls) -> None: def load_or_create(cls) -> None:
""" """Загрузить индекс с диска или создать новый.
Загрузить индекс с диска или создать новый.
Пытается перенести индекс на GPU, при ошибке остаётся на CPU. Старый несовместимый индекс (например, IVFFlat от предыдущей версии,
который не хранит id_map) безопасно пересоздаётся — полезных векторов в
нём всё равно не было.
""" """
import faiss import faiss
index_path = settings.FAISS_INDEX_PATH index_path = settings.FAISS_INDEX_PATH
id_map_path = settings.FAISS_ID_MAP_PATH
# Загрузить ID маппинг
if os.path.exists(id_map_path):
with open(id_map_path, "r") as f:
raw_map = json.load(f)
cls._id_map = {int(k): int(v) for k, v in raw_map.items()}
cls._reverse_map = {v: k for k, v in cls._id_map.items()}
logger.info(f"ID маппинг загружен: {len(cls._id_map)} записей")
if os.path.exists(index_path): if os.path.exists(index_path):
# Загрузить существующий индекс try:
logger.info(f"Загрузка FAISS индекса из {index_path}") loaded = faiss.read_index(index_path)
cpu_index = faiss.read_index(index_path) if hasattr(loaded, "id_map"):
cls._is_trained = cpu_index.is_trained cls._index = loaded
cls._rebuild_reverse_map()
logger.info(
f"Загрузка FAISS индекса из {index_path} "
f"({cls._index.ntotal} векторов)"
)
else:
logger.warning(
"На диске несовместимый FAISS индекс (без id_map) — "
"пересоздаём как IndexIDMap2(IndexFlatIP)"
)
cls._index = cls._new_index()
cls._reverse_map = {}
except Exception as e:
logger.warning(f"Не удалось загрузить FAISS индекс ({e}) — создаём новый")
cls._index = cls._new_index()
cls._reverse_map = {}
else: else:
# Создать новый IVFFlat индекс logger.info("Создание нового FAISS индекса IndexIDMap2(IndexFlatIP)...")
logger.info("Создание нового FAISS IVFFlat индекса...") cls._index = cls._new_index()
quantizer = faiss.IndexFlatIP(settings.EMBED_DIM) cls._reverse_map = {}
cpu_index = faiss.IndexIVFFlat(
quantizer, cls._use_gpu = False # FlatIP на CPU достаточно быстр для целевого масштаба
settings.EMBED_DIM,
settings.FAISS_NLIST, @classmethod
faiss.METRIC_INNER_PRODUCT, def _ensure(cls) -> None:
) """Ленивая инициализация индекса при первом обращении."""
# IVFFlat требует обучения перед использованием if cls._index is None:
cls._is_trained = False cls.load_or_create()
@classmethod
def _rebuild_reverse_map(cls) -> None:
"""Восстановить _reverse_map из id, хранящихся внутри загруженного индекса."""
import faiss
# Попытка перенести на GPU
try: try:
res = faiss.StandardGpuResources() ids = faiss.vector_to_array(cls._index.id_map)
cls._index = faiss.index_cpu_to_gpu(res, 0, cpu_index) cls._reverse_map = {int(i): int(i) for i in ids}
cls._use_gpu = True
logger.info("FAISS индекс размещён на GPU")
except Exception as e: except Exception as e:
logger.warning(f"GPU недоступна: {e}. Используем CPU FAISS.") logger.warning(f"Не удалось восстановить reverse_map из индекса: {e}")
cls._index = cpu_index cls._reverse_map = {}
cls._use_gpu = False
if cls._is_trained:
cls._index.nprobe = settings.FAISS_NPROBE
@classmethod @classmethod
def search(cls, query_vector: np.ndarray, k: int = 20) -> list[tuple[int, float]]: def search(cls, query_vector: np.ndarray, k: int = 20) -> list[tuple[int, float]]:
""" """Поиск k ближайших векторов.
Поиск k ближайших векторов.
Args: Args:
query_vector: Нормализованный вектор запроса, форма (768,) query_vector: Нормализованный вектор запроса, форма (768,)
@@ -90,21 +109,21 @@ class FAISSManager:
Returns: Returns:
Список кортежей (doc_id, cosine_score), отсортированных по убыванию score Список кортежей (doc_id, cosine_score), отсортированных по убыванию score
""" """
if cls._index is None or not cls._is_trained: cls._ensure()
logger.warning("FAISS индекс не инициализирован или не обучен, пропускаем поиск")
if cls._index.ntotal == 0:
return [] return []
try: try:
query = query_vector.reshape(1, -1).astype(np.float32) query = query_vector.reshape(1, -1).astype(np.float32)
distances, indices = cls._index.search(query, k) distances, ids = cls._index.search(query, min(k, cls._index.ntotal))
results = [] results = []
for idx, dist in zip(indices[0], distances[0]): for idx, dist in zip(ids[0], distances[0]):
if idx == -1: if idx == -1:
continue continue
doc_id = cls._id_map.get(int(idx)) # Для IDMap2 idx — это уже doc_id из PostgreSQL
if doc_id is not None: results.append((int(idx), float(dist)))
results.append((doc_id, float(dist)))
return results return results
@@ -114,95 +133,49 @@ class FAISSManager:
@classmethod @classmethod
def add_vectors(cls, vectors: np.ndarray, doc_ids: list[int]) -> None: def add_vectors(cls, vectors: np.ndarray, doc_ids: list[int]) -> None:
""" """Добавить (или обновить) векторы в индекс.
Добавить векторы в индекс.
Если индекс не обучен и накопилось достаточно векторов — обучить его. Идемпотентно по doc_id: при повторном эмбеддинге старый вектор документа
удаляется перед добавлением нового, чтобы не плодить дубли.
Args: Args:
vectors: numpy массив формы (N, 768) vectors: numpy массив формы (N, 768)
doc_ids: Список doc_id из PostgreSQL doc_ids: Список doc_id из PostgreSQL
""" """
import faiss cls._ensure()
if cls._index is None: if len(doc_ids) == 0:
cls.load_or_create() return
vectors = vectors.astype(np.float32) vectors = np.asarray(vectors, dtype=np.float32)
ids = np.asarray(doc_ids, dtype=np.int64)
if not cls._is_trained: # Удалить существующие id, чтобы повторный эмбеддинг не создавал дубли
# Для IVFFlat нужно минимум nlist * 39 обучающих примеров try:
min_train = settings.FAISS_NLIST * 39 cls._index.remove_ids(ids)
current_n = cls._index.ntotal if cls._index is not None else 0 except Exception:
pass
if current_n + len(vectors) >= min_train: cls._index.add_with_ids(vectors, ids)
logger.info(f"Обучение IVFFlat индекса на {current_n + len(vectors)} векторах...") for doc_id in doc_ids:
cls._index.train(vectors) cls._reverse_map[int(doc_id)] = int(doc_id)
cls._is_trained = True
cls._index.nprobe = settings.FAISS_NPROBE
logger.info("Обучение завершено")
else:
logger.info(
f"Недостаточно векторов для обучения IVFFlat "
f"({current_n + len(vectors)} < {min_train}). "
"Используйте FlatIP до накопления достаточного количества документов."
)
# Временный flat индекс для малого количества документов
if not hasattr(cls, '_flat_index') or cls._flat_index is None:
cls._flat_index = faiss.IndexFlatIP(settings.EMBED_DIM)
# Добавить в flat индекс
start_id = cls._flat_index.ntotal
cls._flat_index.add(vectors)
for i, doc_id in enumerate(doc_ids):
internal_id = start_id + i
cls._id_map[internal_id] = doc_id
cls._reverse_map[doc_id] = internal_id
cls._save_id_map()
return
if cls._is_trained:
start_id = cls._index.ntotal
cls._index.add(vectors)
for i, doc_id in enumerate(doc_ids):
internal_id = start_id + i
cls._id_map[internal_id] = doc_id
cls._reverse_map[doc_id] = internal_id
cls._save_id_map()
logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}") logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}")
@classmethod @classmethod
def save(cls) -> None: def save(cls) -> None:
"""Сохранить индекс на диск (CPU версия).""" """Сохранить индекс на диск."""
if cls._index is None: cls._ensure()
return
import faiss import faiss
index_path = Path(settings.FAISS_INDEX_PATH) index_path = Path(settings.FAISS_INDEX_PATH)
index_path.parent.mkdir(parents=True, exist_ok=True) index_path.parent.mkdir(parents=True, exist_ok=True)
# Перенести на CPU перед сохранением faiss.write_index(cls._index, str(index_path))
if cls._use_gpu: logger.info(f"FAISS индекс сохранён: {index_path} ({cls._index.ntotal} векторов)")
cpu_index = faiss.index_gpu_to_cpu(cls._index)
else:
cpu_index = cls._index
faiss.write_index(cpu_index, str(index_path))
cls._save_id_map()
logger.info(f"FAISS индекс сохранён: {index_path} ({cpu_index.ntotal} векторов)")
@classmethod @classmethod
def _save_id_map(cls) -> None:
"""Сохранить маппинг faiss_id -> doc_id на диск."""
id_map_path = Path(settings.FAISS_ID_MAP_PATH)
id_map_path.parent.mkdir(parents=True, exist_ok=True)
with open(id_map_path, "w") as f:
json.dump({str(k): v for k, v in cls._id_map.items()}, f)
@classmethod
@property
def total_vectors(cls) -> int: def total_vectors(cls) -> int:
"""Количество векторов в индексе.""" """Количество векторов в индексе."""
if cls._index is None: if cls._index is None:

View File

@@ -15,7 +15,7 @@ class OllamaClient:
def __init__(self) -> None: def __init__(self) -> None:
self.base_url = settings.OLLAMA_URL self.base_url = settings.OLLAMA_URL
self.model = "llama3:8b" self.model = "qwen2.5:7b"
self.timeout = 60.0 # секунд self.timeout = 60.0 # секунд
def check_paraphrase(self, text_a: str, text_b: str) -> dict: def check_paraphrase(self, text_a: str, text_b: str) -> dict:

View File

@@ -152,10 +152,14 @@ def check_plagiarism(
seen_sources.add(key) seen_sources.add(key)
unique_matches.append(m) unique_matches.append(m)
# Вычислить общий процент схожести # Вычислить общий процент схожести по ДОЛЕ помеченных фрагментов документа.
# Считаем уникальные позиции (фрагмент, совпавший с несколькими источниками,
# не должен раздувать процент выше 100).
total_frags = len(fragments) total_frags = len(fragments)
flagged_frags = len(unique_matches) 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 = (flagged_frags / total_frags * 100) if total_frags > 0 else 0.0
overall_similarity = min(overall_similarity, 100.0)
result = { result = {
"overall_similarity": round(overall_similarity, 2), "overall_similarity": round(overall_similarity, 2),

View File

@@ -1,29 +1,73 @@
"""MinHash LSH для нечёткого поиска похожих документов. """MinHash LSH для нечёткого поиска похожих документов (уровень 2).
Позволяет быстро находить документы с похожим содержимым Индекс хранится в общем Redis (тот же, что кэш/rate-limits, см. REDIS_URL),
без точного сравнения всех пар. а не в памяти процесса. Это даёт:
- переживаемость рестарта воркера (индекс не теряется);
- общий индекс для всех воркеров-индексеров (add в одном видно в query другого).
При недоступности Redis — graceful-фолбэк в in-memory (деградация: индекс
локальный и теряется при рестарте), чтобы воркер не падал целиком.
""" """
import logging import logging
from urllib.parse import urlparse
from datasketch import MinHash, MinHashLSH from datasketch import MinHash, MinHashLSH
from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Параметры MinHash LSH # Параметры MinHash LSH
LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты
LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память) LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память)
# Префикс ключей в общем Redis — изолирует индекс от кэша/rate-limits в той же БД
LSH_BASENAME = b"antiplag_lsh"
# Глобальный LSH индекс (in-memory)
_lsh: MinHashLSH | None = None _lsh: MinHashLSH | None = None
_redis_unavailable = False # чтобы не долбить недоступный Redis на каждый вызов
def _redis_storage_config() -> dict | None:
"""Собрать storage_config datasketch из REDIS_URL. None — если разбор не удался."""
try:
u = urlparse(settings.REDIS_URL)
redis_kwargs: dict = {
"host": u.hostname or "localhost",
"port": u.port or 6379,
"db": int((u.path or "/0").lstrip("/") or 0),
}
if u.username:
redis_kwargs["username"] = u.username
if u.password:
redis_kwargs["password"] = u.password
return {"type": "redis", "basename": LSH_BASENAME, "redis": redis_kwargs}
except Exception as e:
logger.error(f"MinHash LSH: не удалось разобрать REDIS_URL: {e}")
return None
def get_lsh() -> MinHashLSH: def get_lsh() -> MinHashLSH:
"""Получить или создать глобальный LSH индекс.""" """Получить или создать глобальный LSH индекс (Redis-backed, фолбэк — память)."""
global _lsh global _lsh, _redis_unavailable
if _lsh is None: if _lsh is not None:
_lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM) return _lsh
logger.info("MinHash LSH индекс создан")
if not _redis_unavailable:
cfg = _redis_storage_config()
if cfg is not None:
try:
_lsh = MinHashLSH(
threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM, storage_config=cfg
)
logger.info("MinHash LSH: общий Redis-бэкенд подключён")
return _lsh
except Exception as e:
logger.error(f"MinHash LSH: Redis недоступен ({e}) — фолбэк в память")
_redis_unavailable = True
_lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM)
logger.warning("MinHash LSH: in-memory режим (индекс не шарится и теряется при рестарте)")
return _lsh return _lsh
@@ -62,7 +106,10 @@ def text_to_minhash(text: str) -> MinHash:
def add_to_lsh(doc_key: str, text: str) -> None: def add_to_lsh(doc_key: str, text: str) -> None:
""" """
Добавить документ в LSH индекс. Добавить (или обновить) документ в LSH индекс.
Upsert: если документ уже есть (например, был добавлен по аннотации, а теперь
пересчитывается по полному тексту) — старая подпись удаляется, вставляется новая.
Args: Args:
doc_key: Уникальный ключ документа (например, "doc:{id}") doc_key: Уникальный ключ документа (например, "doc:{id}")
@@ -71,10 +118,13 @@ 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:
lsh.remove(doc_key) # снять прежнюю версию, если была
except Exception:
pass # ключа не было — это норма
lsh.insert(doc_key, m) lsh.insert(doc_key, m)
except ValueError: except Exception as e:
# Документ уже в индексе — игнорируем logger.warning(f"MinHash LSH: не удалось добавить {doc_key!r}: {e}")
pass
def find_similar(text: str) -> list[str]: def find_similar(text: str) -> list[str]:
@@ -113,6 +163,7 @@ def compute_jaccard_minhash(text_a: str, text_b: str) -> float:
def reset_lsh() -> None: def reset_lsh() -> None:
"""Сбросить LSH индекс (для тестов).""" """Сбросить handle LSH (для тестов). Данные в Redis не трогает."""
global _lsh global _lsh, _redis_unavailable
_lsh = None _lsh = None
_redis_unavailable = False

View File

@@ -39,7 +39,22 @@ class Settings(BaseSettings):
# Настройки обработки текста # Настройки обработки текста
FRAGMENT_WINDOW_WORDS: int = 200 # Размер окна для фрагментов FRAGMENT_WINDOW_WORDS: int = 200 # Размер окна для фрагментов
FRAGMENT_OVERLAP_WORDS: int = 50 # Перекрытие фрагментов FRAGMENT_OVERLAP_WORDS: int = 50 # Перекрытие фрагментов
MAX_FINGERPRINTS_PER_DOC: int = 500 # Максимум хэшей Winnowing на документ # Максимум хэшей Winnowing на документ. Winnowing рассчитан на ПОЛНОЕ
# хранение отпечатков: обрезка теряет гарантию, что скопированный фрагмент
# разделит отпечатки с источником (при 500 хранилось ~14% отпечатков статьи,
# и локализованный поиск фрагментов почти не срабатывал). Держим высоким;
# цена — размер таблицы fingerprints (~3-4К строк на статью).
MAX_FINGERPRINTS_PER_DOC: int = 20000
# Порог уровня 1: % отпечатков фрагмента, найденных в источнике, чтобы
# пометить фрагмент как скопированный
EXACT_FRAGMENT_THRESHOLD: float = 40.0
# Скачивание полного текста статей (PDF по URL источника)
FETCH_FULL_TEXT: bool = False # Включить обогащение полным текстом при заливке корпуса
FULL_TEXT_TIMEOUT: float = 30.0 # Таймаут скачивания одного документа, сек
FULL_TEXT_MAX_BYTES: int = 30 * 1024 * 1024 # Лимит размера скачиваемого файла (30 МБ)
FULL_TEXT_MIN_CHARS: int = 500 # Минимум символов, иначе считаем извлечение неудачным
EMBED_BATCH_SIZE: int = 64 # Размер пачки документов для диспатча эмбеддингов
# App # App
ENVIRONMENT: str = "development" ENVIRONMENT: str = "development"

View File

@@ -0,0 +1,76 @@
"""Скачивание и извлечение полного текста статьи по URL источника.
OpenAlex/arXiv отдают в метаданных ссылку (`url` = oa_url / pdf), которая часто
ведёт на PDF открытого доступа. Здесь мы best-effort скачиваем файл и извлекаем
из него текст. HTML-страницы (лендинги журналов) пропускаем — надёжно доставать
текст статьи из произвольного HTML нельзя.
Всё завёрнуто в широкий except: недоступный/битый источник не должен ронять
заливку корпуса, просто у документа не будет полного текста.
"""
import logging
import httpx
from app.config import settings
from app.extractors.pdf import extract_text_from_pdf
logger = logging.getLogger(__name__)
_HEADERS = {
"User-Agent": "AcademicHelper/1.0 (+https://academic.jze9.ru; mailto:noreply@jze9mail.ru)",
"Accept": "application/pdf,*/*",
}
def fetch_full_text(url: str) -> str | None:
"""Скачать документ по URL и вернуть извлечённый текст, либо None.
Возвращает None, если: url пустой, файл не PDF, скачивание не удалось,
или извлечённого текста слишком мало (< FULL_TEXT_MIN_CHARS).
"""
if not url:
return None
try:
with httpx.Client(
follow_redirects=True,
timeout=settings.FULL_TEXT_TIMEOUT,
headers=_HEADERS,
) as client:
with client.stream("GET", url) as resp:
resp.raise_for_status()
ctype = resp.headers.get("content-type", "").lower()
# Скачиваем с ограничением размера
buf = bytearray()
for chunk in resp.iter_bytes():
buf += chunk
if len(buf) > settings.FULL_TEXT_MAX_BYTES:
logger.info(
f"full-text превысил лимит {settings.FULL_TEXT_MAX_BYTES} байт, "
f"обрезаю: {url}"
)
break
data = bytes(buf)
except Exception as e:
logger.info(f"full-text: скачать не удалось {url!r}: {type(e).__name__}: {str(e)[:120]}")
return None
# Определяем PDF по content-type или magic-байтам
is_pdf = "pdf" in ctype or data[:5] == b"%PDF-"
if not is_pdf:
logger.debug(f"full-text: не PDF (content-type={ctype!r}), пропускаю: {url}")
return None
try:
text = (extract_text_from_pdf(data) or "").strip()
except Exception as e:
logger.info(f"full-text: извлечение PDF не удалось {url!r}: {type(e).__name__}: {str(e)[:120]}")
return None
if len(text) < settings.FULL_TEXT_MIN_CHARS:
return None
return text

View File

@@ -139,50 +139,64 @@ def extract_and_check(
) )
logger.info(f"Фрагментов создано: {len(fragments)}") logger.info(f"Фрагментов создано: {len(fragments)}")
# ──── Уровень 1: Winnowing fingerprints ──────────────────────────────── # ──── Уровень 1: Winnowing fingerprints (пофрагментно, с локализацией) ───
# Winnow'им КАЖДЫЙ фрагмент отдельно и ищем, с каким источником и на
# какой позиции он совпадает. Так в отчёте видно не «документ похож на X»,
# а «фрагмент на символах AB скопирован из источника X».
level1_matches: list[dict] = [] level1_matches: list[dict] = []
doc_fingerprint = winnow(text) from app.models import Document, Fingerprint
if doc_fingerprint: with db_session() as session:
from app.models import Document, Fingerprint doc_cache: dict[int, Document] = {}
with db_session() as session: for frag in fragments:
hashes = list(doc_fingerprint)[: settings.MAX_FINGERPRINTS_PER_DOC] frag_fp = winnow(frag["text"])
if not frag_fp:
continue
frag_hashes = list(frag_fp)
# Найти совпадения в базе fingerprints # Источник, разделяющий больше всего отпечатков с этим фрагментом
matching_docs = session.execute( row = session.execute(
select( select(
Fingerprint.doc_id, Fingerprint.doc_id,
func.count(Fingerprint.id).label("match_count"), func.count(Fingerprint.id).label("cnt"),
) )
.where(Fingerprint.hash_value.in_(hashes)) .where(Fingerprint.hash_value.in_(frag_hashes))
.group_by(Fingerprint.doc_id) .group_by(Fingerprint.doc_id)
.having(func.count(Fingerprint.id) > len(hashes) * 0.1)
.order_by(func.count(Fingerprint.id).desc()) .order_by(func.count(Fingerprint.id).desc())
.limit(20) .limit(1)
).all() ).first()
for doc_id, match_count in matching_docs: if row is None:
similarity = match_count / len(hashes) * 100 continue
if similarity < 20:
continue
doc_id, cnt = row
# Доля отпечатков фрагмента, найденных в источнике
similarity = cnt / len(frag_hashes) * 100
if similarity < settings.EXACT_FRAGMENT_THRESHOLD:
continue
doc = doc_cache.get(doc_id)
if doc is None:
doc = session.get(Document, doc_id) doc = session.get(Document, doc_id)
if not doc: if doc is None:
continue continue
doc_cache[doc_id] = doc
level1_matches.append({ level1_matches.append({
"fragment": text[:200], "fragment": frag["text"][:300],
"position_start": 0, "position_start": frag["start"],
"position_end": len(text), "position_end": frag["end"],
"similarity": round(similarity, 1), "similarity": round(min(similarity, 100.0), 1),
"method": "exact", "method": "exact",
"source_title": doc.title, "source_title": doc.title,
"source_url": doc.url, "source_url": doc.url,
"source_db": doc.source, "source_db": doc.source,
}) })
logger.info(f"Уровень 1 (Winnowing): {len(level1_matches)} совпадений") logger.info(
f"Уровень 1 (Winnowing): {len(level1_matches)} совпадений-фрагментов"
)
# ──── Уровень 2: MinHash LSH ──────────────────────────────────────────── # ──── Уровень 2: MinHash LSH ────────────────────────────────────────────
level2_matches: list[dict] = [] level2_matches: list[dict] = []
@@ -242,20 +256,24 @@ def extract_and_check(
@celery_app.task(name="index.add_document") @celery_app.task(name="index.add_document")
def add_document(doc_data: dict[str, Any]) -> dict[str, Any]: def add_document(doc_data: dict[str, Any], dispatch_embed: bool = True) -> dict[str, Any]:
""" """
Добавить документ из внешнего источника в систему. Добавить документ из внешнего источника в систему.
Алгоритм: Алгоритм:
1. Дедупликация по ext_id 1. Дедупликация по ext_id
2. Сохранить метаданные в PostgreSQL 2. Сохранить метаданные в PostgreSQL
3. Индексировать в Elasticsearch 3. Вычислить провизорные Winnowing fingerprints (из аннотации)
4. Вычислить Winnowing fingerprints 4. Добавить в MinHash LSH
5. Добавить в MinHash LSH 5. Индексировать в Elasticsearch
6. Диспатч gpu.embed_documents для FAISS эмбеддингов 6. Диспатч gpu.embed_documents для FAISS эмбеддингов (если dispatch_embed)
7. Если включён FETCH_FULL_TEXT и есть url — диспатч index.enrich_full_text,
который скачает полный текст и пересчитает fingerprints по нему.
Args: Args:
doc_data: Словарь с метаданными документа doc_data: Словарь с метаданными документа
dispatch_embed: Диспатчить ли эмбеддинг по одному документу. При массовой
заливке run_parser выключает это и батчит эмбеддинги сам.
Returns: Returns:
dict со статусом операции и doc_id dict со статусом операции и doc_id
@@ -325,16 +343,91 @@ def add_document(doc_data: dict[str, Any]) -> dict[str, Any]:
except Exception as e: except Exception as e:
logger.warning(f"Ошибка индексации в ES для документа {doc_id}: {e}") logger.warning(f"Ошибка индексации в ES для документа {doc_id}: {e}")
# Диспатч FAISS эмбеддингов # Диспатч FAISS эмбеддингов (по одному документу; при массовой заливке
celery_app.send_task( # run_parser выключает это и батчит сам)
"gpu.embed_documents", if dispatch_embed:
args=[[doc_id]], celery_app.send_task(
queue="queue.gpu", "gpu.embed_documents",
) args=[[doc_id]],
queue="queue.gpu",
)
# Обогащение полным текстом: скачать PDF по url и пересчитать fingerprints
if settings.FETCH_FULL_TEXT and doc_data.get("url"):
celery_app.send_task(
"index.enrich_full_text",
args=[doc_id, doc_data["url"]],
queue="queue.index",
)
return {"status": "indexed", "doc_id": doc_id} return {"status": "indexed", "doc_id": doc_id}
@celery_app.task(
name="index.enrich_full_text",
bind=True,
max_retries=2,
default_retry_delay=120,
)
def enrich_full_text(self, doc_id: int, url: str) -> dict[str, Any]:
"""Скачать полный текст статьи и пересчитать по нему fingerprints.
Метаданные и провизорные fingerprints (из аннотации) уже сохранены в
add_document. Здесь мы:
1. Скачиваем PDF по url и извлекаем текст (best-effort).
2. Сохраняем полный текст в MinIO (bucket documents, префикс corpus/).
3. Заменяем fingerprints документа на посчитанные по полному тексту.
4. Обновляем MinHash LSH.
Недоступный/не-PDF источник — не ошибка: возвращаем no_fulltext.
"""
from app.fulltext import fetch_full_text
from app.models import Document, Fingerprint
text = fetch_full_text(url)
if not text:
return {"status": "no_fulltext", "doc_id": doc_id}
# Сохранить полный текст в MinIO
try:
minio = get_minio()
key = f"corpus/{doc_id}.txt"
data = text.encode("utf-8")
minio.put_object(
settings.MINIO_BUCKET_DOCS, key, io.BytesIO(data), length=len(data),
content_type="text/plain; charset=utf-8",
)
except Exception as exc:
logger.error(f"enrich_full_text: не удалось сохранить текст в MinIO для {doc_id}: {exc}")
raise self.retry(exc=exc, countdown=120)
# Пересчитать fingerprints по полному тексту
doc_fp = winnow(text)
hashes = list(doc_fp)[: settings.MAX_FINGERPRINTS_PER_DOC]
from sqlalchemy import delete
with db_session() as session:
doc = session.get(Document, doc_id)
if doc is None:
return {"status": "doc_gone", "doc_id": doc_id}
doc.minio_key = key
# Удалить провизорные fingerprints и записать новые
session.execute(delete(Fingerprint).where(Fingerprint.doc_id == doc_id))
for i, hash_val in enumerate(hashes):
session.add(Fingerprint(doc_id=doc_id, hash_value=hash_val, position=i))
session.commit()
# Обновить MinHash LSH по полному тексту
add_to_lsh(f"doc:{doc_id}", text)
logger.info(
f"enrich_full_text: doc={doc_id} полный текст {len(text)} симв., "
f"fingerprints={len(hashes)}"
)
return {"status": "ok", "doc_id": doc_id, "chars": len(text), "fingerprints": len(hashes)}
def _stage_work( def _stage_work(
task_id: str, task_id: str,
minio_key: str, minio_key: str,
@@ -451,16 +544,35 @@ def run_parser(source_id: int) -> dict[str, Any]:
parser = P() parser = P()
# fetch+transform без записи в JSONL — работаем in-memory # fetch+transform без записи в JSONL — работаем in-memory
raw_docs = parser.fetch(**fetch_kwargs) raw_docs = parser.fetch(**fetch_kwargs)
# Эмбеддинги диспатчим пачками, а не по одному документу: так worker-gpu
# кодирует батч разом и переписывает FAISS-индекс на диск раз в N добавлений,
# а не на каждый документ.
embed_batch: list[int] = []
batch_size = settings.EMBED_BATCH_SIZE
def _flush_embed() -> None:
if embed_batch:
celery_app.send_task(
"gpu.embed_documents", args=[list(embed_batch)], queue="queue.gpu"
)
embed_batch.clear()
for raw in raw_docs: for raw in raw_docs:
try: try:
doc = parser.transform(raw) doc = parser.transform(raw)
if not (doc and doc.get("title") and doc.get("ext_id")): if not (doc and doc.get("title") and doc.get("ext_id")):
continue continue
result = add_document(doc) result = add_document(doc, dispatch_embed=False)
if result.get("status") == "indexed": if result.get("status") == "indexed":
added += 1 added += 1
embed_batch.append(result["doc_id"])
if len(embed_batch) >= batch_size:
_flush_embed()
except Exception as e: except Exception as e:
logger.warning(f"run_parser: ошибка документа: {e}") logger.warning(f"run_parser: ошибка документа: {e}")
_flush_embed()
except Exception as e: except Exception as e:
error_msg = str(e)[:500] error_msg = str(e)[:500]
logger.error(f"run_parser source={source_id} ошибка: {e}", exc_info=True) logger.error(f"run_parser source={source_id} ошибка: {e}", exc_info=True)

View File

@@ -24,11 +24,12 @@ class Settings(BaseSettings):
RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/" RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/"
# SMTP # SMTP
SMTP_HOST: str = "smtp.yandex.ru" SMTP_HOST: str = "mail.jze9mail.ru"
SMTP_PORT: int = 465 SMTP_PORT: int = 587
SMTP_USER: str = "noreply@jze9.ru" SMTP_USER: str = "noreply"
SMTP_PASSWORD: str = "changeme" SMTP_PASSWORD: str = "changeme"
SMTP_FROM: str = "noreply@jze9.ru" SMTP_FROM: str = "noreply@jze9mail.ru"
SMTP_TLS_VERIFY: bool = True
# App # App
APP_URL: str = "https://academic.jze9.ru" APP_URL: str = "https://academic.jze9.ru"

View File

@@ -2,6 +2,7 @@
import logging import logging
import smtplib import smtplib
import ssl
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
@@ -37,9 +38,22 @@ class EmailSender:
msg.attach(MIMEText(html_body, "html", "utf-8")) msg.attach(MIMEText(html_body, "html", "utf-8"))
try: try:
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as smtp: tls_ctx = ssl.create_default_context()
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) if not settings.SMTP_TLS_VERIFY:
smtp.send_message(msg) tls_ctx.check_hostname = False
tls_ctx.verify_mode = ssl.CERT_NONE
if settings.SMTP_PORT == 465:
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=tls_ctx) as smtp:
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
smtp.send_message(msg)
else:
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
smtp.ehlo()
smtp.starttls(context=tls_ctx)
smtp.ehlo()
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
smtp.send_message(msg)
logger.info(f"Email отправлен: {to_email!r}, тема: {subject!r}") logger.info(f"Email отправлен: {to_email!r}, тема: {subject!r}")
except smtplib.SMTPException as e: except smtplib.SMTPException as e:
logger.error(f"SMTP ошибка при отправке письма на {to_email!r}: {e}") logger.error(f"SMTP ошибка при отправке письма на {to_email!r}: {e}")

View File

@@ -25,6 +25,7 @@ class Task(Base):
user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
type: Mapped[str] = mapped_column(String(20)) type: Mapped[str] = mapped_column(String(20))
status: Mapped[str] = mapped_column(String(20)) status: Mapped[str] = mapped_column(String(20))
input_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
result: Mapped[dict | None] = mapped_column(JSON, nullable=True) result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True) error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now()) created_at: Mapped[datetime] = mapped_column(server_default=func.now())

View File

@@ -68,10 +68,11 @@ def send_task_done(self, task_id: str) -> dict:
def _build_summary(task) -> str: def _build_summary(task) -> str:
"""Сформировать краткое описание результата для email.""" """Сформировать краткое описание результата для email."""
result = task.result or {} result = task.result or {}
input_data = task.input_data or {}
if task.type == "search": if task.type == "search":
count = len(result.get("sources", [])) count = len(result.get("sources", []))
query = task.input_data.get("query", "") query = input_data.get("query", "")
return ( return (
f'Найдено <strong>{count} источников</strong> по запросу "{query[:80]}".' f'Найдено <strong>{count} источников</strong> по запросу "{query[:80]}".'
f' Откройте результат, чтобы просмотреть список с ГОСТ-цитатами.' f' Откройте результат, чтобы просмотреть список с ГОСТ-цитатами.'
@@ -81,7 +82,7 @@ def _build_summary(task) -> str:
similarity = result.get("overall_similarity", 0) similarity = result.get("overall_similarity", 0)
total = result.get("total_fragments", 0) total = result.get("total_fragments", 0)
flagged = result.get("flagged_fragments", 0) flagged = result.get("flagged_fragments", 0)
filename = task.input_data.get("filename", "") filename = input_data.get("filename", "")
color = "#dc2626" if similarity > 30 else "#d97706" if similarity > 10 else "#16a34a" color = "#dc2626" if similarity > 30 else "#d97706" if similarity > 10 else "#16a34a"
label = "Высокий" if similarity > 30 else "Средний" if similarity > 10 else "Низкий" label = "Высокий" if similarity > 30 else "Средний" if similarity > 10 else "Низкий"