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 { Navigate, Link } from 'react-router-dom';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { ArrowLeft, User as UserIcon, Lock, Mail, AlertCircle, CheckCircle2 } from 'lucide-react';
|
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
|
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:
|
||||||
|
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)
|
_lsh = MinHashLSH(threshold=LSH_THRESHOLD, num_perm=LSH_NUM_PERM)
|
||||||
logger.info("MinHash LSH индекс создан")
|
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