Compare commits
7 Commits
25e55a3f7d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc51a4f65 | ||
|
|
34fa495fb1 | ||
|
|
b0e1eadcbb | ||
|
|
f1c1530cea | ||
|
|
5cbc1e127b | ||
|
|
3af79e9d03 | ||
|
|
673e72a15a |
37
.gitea/workflows/deploy.yml
Normal file
37
.gitea/workflows/deploy.yml
Normal 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}"
|
||||||
95
Makefile
95
Makefile
@@ -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"
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -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
129
docker-compose.prod.yml
Normal 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
|
||||||
@@ -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
10
infra/nginx/Dockerfile
Normal 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
|
||||||
@@ -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
109
scripts/deploy.sh
Executable 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)"
|
||||||
@@ -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,96 @@ 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)
|
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def resend_verification(
|
async def resend_verification(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -146,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 успешно подтверждён"}
|
||||||
|
|||||||
@@ -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 токене."""
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ 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}`),
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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';
|
import { EmailVerificationModal } from './EmailVerificationModal';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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';
|
||||||
@@ -45,6 +46,7 @@ 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 />} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
237
services/frontend/src/pages/Settings.tsx
Normal file
237
services/frontend/src/pages/Settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
1
services/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user