Compare commits
4 Commits
5cbc1e127b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dc51a4f65 | ||
|
|
34fa495fb1 | ||
|
|
b0e1eadcbb | ||
|
|
f1c1530cea |
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}"
|
||||
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)"
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
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';
|
||||
|
||||
@@ -1,29 +1,73 @@
|
||||
"""MinHash LSH для нечёткого поиска похожих документов.
|
||||
"""MinHash LSH для нечёткого поиска похожих документов (уровень 2).
|
||||
|
||||
Позволяет быстро находить документы с похожим содержимым
|
||||
без точного сравнения всех пар.
|
||||
Индекс хранится в общем Redis (тот же, что кэш/rate-limits, см. REDIS_URL),
|
||||
а не в памяти процесса. Это даёт:
|
||||
- переживаемость рестарта воркера (индекс не теряется);
|
||||
- общий индекс для всех воркеров-индексеров (add в одном видно в query другого).
|
||||
|
||||
При недоступности Redis — graceful-фолбэк в in-memory (деградация: индекс
|
||||
локальный и теряется при рестарте), чтобы воркер не падал целиком.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from datasketch import MinHash, MinHashLSH
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Параметры MinHash LSH
|
||||
LSH_THRESHOLD = 0.5 # Минимальная схожесть для включения в результаты
|
||||
LSH_NUM_PERM = 128 # Количество хэш-функций (точность vs память)
|
||||
# Префикс ключей в общем Redis — изолирует индекс от кэша/rate-limits в той же БД
|
||||
LSH_BASENAME = b"antiplag_lsh"
|
||||
|
||||
# Глобальный LSH индекс (in-memory)
|
||||
_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:
|
||||
"""Получить или создать глобальный LSH индекс."""
|
||||
global _lsh
|
||||
if _lsh is None:
|
||||
_lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM)
|
||||
logger.info("MinHash LSH индекс создан")
|
||||
"""Получить или создать глобальный LSH индекс (Redis-backed, фолбэк — память)."""
|
||||
global _lsh, _redis_unavailable
|
||||
if _lsh is not None:
|
||||
return _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
|
||||
|
||||
|
||||
@@ -62,7 +106,10 @@ def text_to_minhash(text: str) -> MinHash:
|
||||
|
||||
def add_to_lsh(doc_key: str, text: str) -> None:
|
||||
"""
|
||||
Добавить документ в LSH индекс.
|
||||
Добавить (или обновить) документ в LSH индекс.
|
||||
|
||||
Upsert: если документ уже есть (например, был добавлен по аннотации, а теперь
|
||||
пересчитывается по полному тексту) — старая подпись удаляется, вставляется новая.
|
||||
|
||||
Args:
|
||||
doc_key: Уникальный ключ документа (например, "doc:{id}")
|
||||
@@ -71,10 +118,13 @@ def add_to_lsh(doc_key: str, text: str) -> None:
|
||||
lsh = get_lsh()
|
||||
m = text_to_minhash(text)
|
||||
try:
|
||||
try:
|
||||
lsh.remove(doc_key) # снять прежнюю версию, если была
|
||||
except Exception:
|
||||
pass # ключа не было — это норма
|
||||
lsh.insert(doc_key, m)
|
||||
except ValueError:
|
||||
# Документ уже в индексе — игнорируем
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"MinHash LSH: не удалось добавить {doc_key!r}: {e}")
|
||||
|
||||
|
||||
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:
|
||||
"""Сбросить LSH индекс (для тестов)."""
|
||||
global _lsh
|
||||
"""Сбросить handle LSH (для тестов). Данные в Redis не трогает."""
|
||||
global _lsh, _redis_unavailable
|
||||
_lsh = None
|
||||
_redis_unavailable = False
|
||||
|
||||
Reference in New Issue
Block a user