chore(lint): ruff-гейт в CI + фиксы (0 находок) — блокирует кривой деплой

Второй CI-гейт после тестов: ruff как статический анализатор всего Python-кода
(services + scripts). Раньше ни линта, ни проверки типов в CI не было вовсе.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-11 17:20:24 +05:00
parent ecc10a4413
commit 2daaa8c8a4
38 changed files with 143 additions and 103 deletions

View File

@@ -24,6 +24,12 @@ jobs:
git clone --branch main --depth 1 \ git clone --branch main --depth 1 \
https://gitea.jze9.ru/jze9/anti-plagiarism.git "$SRC" https://gitea.jze9.ru/jze9/anti-plagiarism.git "$SRC"
- name: Линт (ruff) — быстрый гейт, падаем раньше тестов
run: |
set -euo pipefail
cd "$SRC"
bash scripts/run_lint.sh
- name: Юнит-тесты (L1 winnowing, L2 minhash, L3 FAISS, ГОСТ) в контейнерах - name: Юнит-тесты (L1 winnowing, L2 minhash, L3 FAISS, ГОСТ) в контейнерах
run: | run: |
set -euo pipefail set -euo pipefail

View File

@@ -1,4 +1,4 @@
.PHONY: dev build migrate logs shell-api shell-gpu lint test test-one down ps restart clean test-up test-down test-logs test-ps .PHONY: dev build migrate logs shell-api shell-gpu lint lint-fix test test-one down ps restart clean test-up test-down test-logs test-ps
# ─── Переменные ──────────────────────────────────────────────────────────────── # ─── Переменные ────────────────────────────────────────────────────────────────
# Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра # Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
@@ -100,9 +100,13 @@ shell-redis:
@echo "Redis общий (192.168.1.19) — подключайся напрямую: redis-cli -h 192.168.1.19 -n 3" @echo "Redis общий (192.168.1.19) — подключайся напрямую: redis-cli -h 192.168.1.19 -n 3"
# ─── Линтинг и тесты ────────────────────────────────────────────────────────── # ─── Линтинг и тесты ──────────────────────────────────────────────────────────
# Ruff-линт всего Python-кода в изолированном контейнере (конфиг — ruff.toml).
# Тот же скрипт гоняет CI как гейт перед деплоем. Автофиксы: make lint-fix.
lint: lint:
$(COMPOSE_PROD) exec api ruff check . --fix bash scripts/run_lint.sh
$(COMPOSE_PROD) exec api mypy app/
lint-fix:
ruff check services/ scripts/ --fix
lint-frontend: lint-frontend:
cd services/frontend && npm run lint cd services/frontend && npm run lint

26
ruff.toml Normal file
View File

@@ -0,0 +1,26 @@
# Линтер/форматтер для всех Python-сервисов и скриптов.
# Гоняется как гейт в CI (scripts/run_lint.sh) — падение блокирует деплой.
target-version = "py311"
line-length = 100
[lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes (неиспользуемое, неопределённое)
"W", # pycodestyle warnings
"I", # isort (порядок импортов)
"UP", # pyupgrade (устаревшие конструкции)
"B", # flake8-bugbear (частые баги)
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
]
ignore = [
"E501", # длину строк держит форматтер, а не линтер; длинные RU-комментарии — норма
"B008", # FastAPI-идиома: Depends()/Query() в значениях по умолчанию — не баг
"UP042", # (str, Enum) → StrEnum меняет __str__ (сериализацию) — не трогаем намеренно
]
[lint.per-file-ignores]
# В тестах допускаем импорт-после-кода (importorskip) и «неотсортированные» блоки
"**/tests/*" = ["E402"]
"**/conftest.py" = ["E402"]

View File

@@ -6,13 +6,12 @@ API: https://info.arxiv.org/help/api/index.html
Использует Atom XML API. Использует Atom XML API.
""" """
import time
import logging import logging
import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import Any from typing import Any
import httpx import httpx
from base import BaseParser from base import BaseParser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -203,9 +202,6 @@ class ArxivParser(BaseParser):
# Определить язык (arXiv — преимущественно английский) # Определить язык (arXiv — преимущественно английский)
lang = "en" lang = "en"
# Категории как JSON
categories = raw.get("categories", [])
return { return {
"source": self.source_name, "source": self.source_name,
"ext_id": f"arxiv:{ext_id}", "ext_id": f"arxiv:{ext_id}",

View File

@@ -7,15 +7,15 @@
а не недокументированное API. а не недокументированное API.
""" """
import contextlib
import logging
import re import re
import time import time
import logging
from typing import Any from typing import Any
import httpx import httpx
from bs4 import BeautifulSoup
from base import BaseParser from base import BaseParser
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -122,10 +122,8 @@ class CyberLeninkaParser(BaseParser):
year_raw = raw.get("year") year_raw = raw.get("year")
year = None year = None
if year_raw: if year_raw:
try: with contextlib.suppress(ValueError, TypeError):
year = int(str(year_raw)[:4]) year = int(str(year_raw)[:4])
except (ValueError, TypeError):
pass
# URL # URL
link = raw.get("link", "") link = raw.get("link", "")

View File

@@ -9,12 +9,12 @@ API: https://docs.openalex.org/
- Идемпотентность: проверка по ext_id перед добавлением - Идемпотентность: проверка по ext_id перед добавлением
""" """
import time
import logging import logging
from typing import Any, Generator import time
from collections.abc import Generator
from typing import Any
import httpx import httpx
from base import BaseParser from base import BaseParser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

22
scripts/run_lint.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Ruff-линт всего Python-кода (services + scripts) в изолированном контейнере.
# Конфиг — ruff.toml в корне репозитория. Гоняется как гейт в CI перед деплоем.
#
# PIP_INDEX_URL переопределяется при необходимости (по умолчанию Tsinghua-зеркало).
set -euo pipefail
MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUFF_VERSION="0.16.2"
echo "▶ Ruff-линт (services/ scripts/)"
docker run --rm \
-v "$ROOT":/repo -w /repo \
-e PIP_INDEX_URL="$MIRROR" \
-e PIP_DISABLE_PIP_VERSION_CHECK=1 \
python:3.11-slim bash -c "
set -e
pip install --no-cache-dir --timeout 60 -q ruff==$RUFF_VERSION
ruff check services/ scripts/
"
echo "✅ Ruff: все проверки пройдены"

View File

@@ -10,7 +10,6 @@
import argparse import argparse
import logging import logging
import os
import sys import sys
from pathlib import Path from pathlib import Path

View File

@@ -4,14 +4,15 @@ import asyncio
import os import os
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context import app.models # noqa: F401 — регистрирует все модели
# Импорт всех моделей для автоопределения изменений
from app.database import Base
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
# Импорт всех моделей для автоопределения изменений from alembic import context
from app.database import Base
import app.models # noqa: F401 — регистрирует все модели
# Alembic Config # Alembic Config
config = context.config config = context.config

View File

@@ -7,9 +7,10 @@ Create Date: 2024-01-01 00:00:00.000000
Создаёт таблицы: users, tasks, documents, fingerprints, usage_logs Создаёт таблицы: users, tasks, documents, fingerprints, usage_logs
""" """
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
# revision identifiers # revision identifiers
revision = "001" revision = "001"
down_revision = None down_revision = None

View File

@@ -7,10 +7,11 @@ Create Date: 2026-05-24
import secrets import secrets
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import text from sqlalchemy import text
from alembic import op
revision = "002" revision = "002"
down_revision = "001" down_revision = "001"
branch_labels = None branch_labels = None

View File

@@ -5,9 +5,10 @@ Revises: 002
Create Date: 2026-05-30 Create Date: 2026-05-30
""" """
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
revision = "003" revision = "003"
down_revision = "002" down_revision = "002"
branch_labels = None branch_labels = None

View File

@@ -4,8 +4,9 @@
дополнительно проверяют секретный код сессии (verify_admin_code). дополнительно проверяют секретный код сессии (verify_admin_code).
""" """
import contextlib
import logging import logging
from datetime import datetime, timezone from datetime import datetime
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -150,11 +151,11 @@ async def stats(db: AsyncSession = Depends(get_db)) -> AdminStats:
users_total = (await db.execute(select(func.count()).select_from(User))).scalar_one() users_total = (await db.execute(select(func.count()).select_from(User))).scalar_one()
rows = (await db.execute(select(Task.status, func.count()).group_by(Task.status))).all() rows = (await db.execute(select(Task.status, func.count()).group_by(Task.status))).all()
tasks_by_status = {s: c for s, c in rows} tasks_by_status = dict(rows)
docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one() docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one()
rows = (await db.execute(select(Document.source, func.count()).group_by(Document.source))).all() rows = (await db.execute(select(Document.source, func.count()).group_by(Document.source))).all()
docs_by_source = {s: c for s, c in rows} docs_by_source = dict(rows)
staging_pending = ( staging_pending = (
await db.execute( await db.execute(
@@ -257,10 +258,8 @@ async def update_user(
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
# Сбросить кэш пользователя # Сбросить кэш пользователя
try: with contextlib.suppress(Exception):
await get_redis().delete(f"user:cache:{user_id}") await get_redis().delete(f"user:cache:{user_id}")
except Exception:
pass
return AdminUserResponse.model_validate(user) return AdminUserResponse.model_validate(user)
@@ -533,7 +532,7 @@ async def approve_staging(
obj = get_minio().get_object("staging", sw.text_key) obj = get_minio().get_object("staging", sw.text_key)
full_text = obj.read().decode("utf-8", errors="replace") full_text = obj.read().decode("utf-8", errors="replace")
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Не удалось прочитать текст: {e}") raise HTTPException(status_code=500, detail=f"Не удалось прочитать текст: {e}") from e
# Добавить в базу документов через существующую задачу индексатора # Добавить в базу документов через существующую задачу индексатора
doc_data = { doc_data = {

View File

@@ -1,7 +1,7 @@
"""Роутер аутентификации: регистрация, вход, верификация email.""" """Роутер аутентификации: регистрация, вход, верификация email."""
import secrets
import logging import logging
import secrets
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
@@ -224,7 +224,7 @@ async def resend_verification(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Не удалось отправить письмо, попробуйте позже", detail="Не удалось отправить письмо, попробуйте позже",
) ) from e
@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK) @router.post("/verify-email/{token}", status_code=status.HTTP_200_OK)

View File

@@ -116,7 +116,7 @@ async def upload_for_plagiarism_check(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Ошибка сохранения файла. Попробуйте позже.", detail="Ошибка сохранения файла. Попробуйте позже.",
) ) from e
# ── 5. Создаём задачу и диспатчим ───────────────────────────────────────── # ── 5. Создаём задачу и диспатчим ─────────────────────────────────────────

View File

@@ -1,7 +1,6 @@
"""Роутер для получения отчётов о выполненных задачах.""" """Роутер для получения отчётов о выполненных задачах."""
import logging import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select

View File

@@ -1,7 +1,7 @@
"""Авторизация админ-панели: проверка роли is_admin и секретного кода сессии.""" """Авторизация админ-панели: проверка роли is_admin и секретного кода сессии."""
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, Path, status from fastapi import Depends, HTTPException, Path, status
from sqlalchemy import select from sqlalchemy import select

View File

@@ -9,7 +9,7 @@ Redis гарантирует, что между командами внутри
""" """
import logging import logging
from datetime import datetime, timezone from datetime import UTC, datetime
from app.core.redis_client import get_redis from app.core.redis_client import get_redis
@@ -93,7 +93,7 @@ return 1
def _period_suffix(period: str) -> str: def _period_suffix(period: str) -> str:
now = datetime.now(timezone.utc) now = datetime.now(UTC)
return now.strftime("%Y-%m-%d") if period == "day" else now.strftime("%Y-%m") return now.strftime("%Y-%m-%d") if period == "day" else now.strftime("%Y-%m")

View File

@@ -2,7 +2,7 @@
import json import json
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from fastapi import Depends, HTTPException, Query, WebSocket, status from fastapi import Depends, HTTPException, Query, WebSocket, status
@@ -35,7 +35,7 @@ def verify_password(plain: str, hashed: str) -> bool:
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
payload = data.copy() payload = data.copy()
expire = datetime.now(timezone.utc) + ( expire = datetime.now(UTC) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
) )
payload["exp"] = expire payload["exp"] = expire
@@ -58,7 +58,7 @@ def _decode_token(token: str) -> int:
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Невалидный или просроченный токен", detail="Невалидный или просроченный токен",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) ) from None
async def _load_user(user_id: int, db: AsyncSession): async def _load_user(user_id: int, db: AsyncSession):

View File

@@ -1,5 +1,6 @@
"""WebSocket менеджер для real-time обновлений статуса задач.""" """WebSocket менеджер для real-time обновлений статуса задач."""
import contextlib
import json import json
import logging import logging
from typing import Any from typing import Any
@@ -27,10 +28,8 @@ class ConnectionManager:
def disconnect(self, task_id: str, websocket: WebSocket) -> None: def disconnect(self, task_id: str, websocket: WebSocket) -> None:
"""Удалить соединение из реестра.""" """Удалить соединение из реестра."""
if task_id in self._connections: if task_id in self._connections:
try: with contextlib.suppress(ValueError):
self._connections[task_id].remove(websocket) self._connections[task_id].remove(websocket)
except ValueError:
pass
if not self._connections[task_id]: if not self._connections[task_id]:
del self._connections[task_id] del self._connections[task_id]
logger.info(f"WebSocket отключён от задачи {task_id!r}") logger.info(f"WebSocket отключён от задачи {task_id!r}")

View File

@@ -1,10 +1,10 @@
"""Точка входа FastAPI приложения — Академический помощник.""" """Точка входа FastAPI приложения — Академический помощник."""
import logging import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -98,6 +98,7 @@ async def websocket_task_updates(
ownership проверяется до установки соединения. ownership проверяется до установки соединения.
""" """
from sqlalchemy import select from sqlalchemy import select
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models.task import Task from app.models.task import Task

View File

@@ -3,14 +3,14 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import func, String from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.task import Task
from app.models.document import UsageLog from app.models.document import UsageLog
from app.models.task import Task
class User(Base): class User(Base):

View File

@@ -1,7 +1,7 @@
"""Подключение к PostgreSQL для gost-воркера.""" """Подключение к PostgreSQL для gost-воркера."""
from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker

View File

@@ -16,7 +16,6 @@
- Место публикации через "/" - Место публикации через "/"
""" """
import re
def _format_author(author: dict) -> str: def _format_author(author: dict) -> str:
@@ -245,10 +244,7 @@ def _get_sort_key(doc: dict) -> str:
Строка для сортировки Строка для сортировки
""" """
authors = doc.get("authors", []) authors = doc.get("authors", [])
if authors: last_name = authors[0].get("last_name", "") if authors else doc.get("title", "")
last_name = authors[0].get("last_name", "")
else:
last_name = doc.get("title", "")
if not last_name: if not last_name:
return "яяя" # В конец return "яяя" # В конец

View File

@@ -1,6 +1,5 @@
"""Celery задача форматирования библиографии по ГОСТ.""" """Celery задача форматирования библиографии по ГОСТ."""
import logging
from typing import Any from typing import Any
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
@@ -139,4 +138,4 @@ def format_bibliography(
except Exception as db_exc: except Exception as db_exc:
logger.error(f"Не удалось обновить статус задачи: {db_exc}") logger.error(f"Не удалось обновить статус задачи: {db_exc}")
raise self.retry(exc=exc, countdown=30) raise self.retry(exc=exc, countdown=30) from exc

View File

@@ -1,8 +1,8 @@
"""Синхронное подключение к PostgreSQL для Celery воркеров.""" """Синхронное подключение к PostgreSQL для Celery воркеров."""
import logging import logging
from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from typing import Generator
import redis as redis_lib import redis as redis_lib
from sqlalchemy import create_engine from sqlalchemy import create_engine

View File

@@ -3,7 +3,8 @@
import logging import logging
from typing import Any from typing import Any
from elasticsearch import Elasticsearch, exceptions as es_exceptions from elasticsearch import Elasticsearch
from elasticsearch import exceptions as es_exceptions
from app.config import settings from app.config import settings

View File

@@ -12,6 +12,7 @@
миллионов документов) полный перебор по FlatIP по скорости приемлем. миллионов документов) полный перебор по FlatIP по скорости приемлем.
""" """
import contextlib
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -88,7 +89,7 @@ class FAISSManager:
distances, ids = cls._index.search(query, min(k, cls._index.ntotal)) distances, ids = cls._index.search(query, min(k, cls._index.ntotal))
results = [] results = []
for idx, dist in zip(ids[0], distances[0]): for idx, dist in zip(ids[0], distances[0], strict=False):
if idx == -1: if idx == -1:
continue continue
# Для IDMap2 idx — это уже doc_id из PostgreSQL # Для IDMap2 idx — это уже doc_id из PostgreSQL
@@ -120,10 +121,8 @@ class FAISSManager:
ids = np.asarray(doc_ids, dtype=np.int64) ids = np.asarray(doc_ids, dtype=np.int64)
# Удалить существующие id, чтобы повторный эмбеддинг не создавал дубли # Удалить существующие id, чтобы повторный эмбеддинг не создавал дубли
try: with contextlib.suppress(Exception):
cls._index.remove_ids(ids) cls._index.remove_ids(ids)
except Exception:
pass
cls._index.add_with_ids(vectors, ids) cls._index.add_with_ids(vectors, ids)
logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}") logger.info(f"Добавлено {len(doc_ids)} векторов в FAISS. Всего: {cls._index.ntotal}")

View File

@@ -1,10 +1,9 @@
"""Базовые модели данных для GPU воркера (минимальный набор для работы с БД).""" """Базовые модели данных для GPU воркера (минимальный набор для работы с БД)."""
from datetime import datetime from datetime import datetime
from uuid import uuid4
from sqlalchemy import JSON, BigInteger, ForeignKey, Integer, String, Text, func from sqlalchemy import JSON, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase): class Base(DeclarativeBase):

View File

@@ -1,6 +1,5 @@
"""Celery задачи проверки плагиата (уровни 3 и 4) и построения эмбеддингов.""" """Celery задачи проверки плагиата (уровни 3 и 4) и построения эмбеддингов."""
import logging
from typing import Any from typing import Any
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
@@ -209,7 +208,7 @@ def check_plagiarism(
except Exception as db_exc: except Exception as db_exc:
logger.error(f"Не удалось обновить статус задачи: {db_exc}") logger.error(f"Не удалось обновить статус задачи: {db_exc}")
raise self.retry(exc=exc, countdown=120) raise self.retry(exc=exc, countdown=120) from exc
@celery_app.task(name="gpu.embed_documents") @celery_app.task(name="gpu.embed_documents")
@@ -225,10 +224,9 @@ def embed_documents(doc_ids: list[int]) -> dict[str, Any]:
if not doc_ids: if not doc_ids:
return {"status": "ok", "embedded": 0} return {"status": "ok", "embedded": 0}
from app.models import Document
from app.faiss_manager import FAISSManager from app.faiss_manager import FAISSManager
from app.model_manager import ModelManager from app.model_manager import ModelManager
from sqlalchemy import select from app.models import Document
logger.info(f"Построение эмбеддингов для {len(doc_ids)} документов...") logger.info(f"Построение эмбеддингов для {len(doc_ids)} документов...")
@@ -248,7 +246,6 @@ def embed_documents(doc_ids: list[int]) -> dict[str, Any]:
] ]
ids = [d.id for d in docs] ids = [d.id for d in docs]
import numpy as np
vectors = ModelManager.encode(texts) vectors = ModelManager.encode(texts)
FAISSManager.add_vectors(vectors, ids) FAISSManager.add_vectors(vectors, ids)

View File

@@ -2,7 +2,6 @@
import hashlib import hashlib
import json import json
import logging
from typing import Any from typing import Any
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
@@ -278,4 +277,4 @@ def search_semantic(
logger.error(f"Не удалось обновить статус задачи: {db_exc}") logger.error(f"Не удалось обновить статус задачи: {db_exc}")
# Повторить попытку # Повторить попытку
raise self.retry(exc=exc, countdown=60) raise self.retry(exc=exc, countdown=60) from exc

View File

@@ -9,6 +9,7 @@
локальный и теряется при рестарте), чтобы воркер не падал целиком. локальный и теряется при рестарте), чтобы воркер не падал целиком.
""" """
import contextlib
import logging import logging
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -118,10 +119,8 @@ def add_to_lsh(doc_key: str, text: str) -> None:
lsh = get_lsh() lsh = get_lsh()
m = text_to_minhash(text) m = text_to_minhash(text)
try: try:
try: with contextlib.suppress(Exception):
lsh.remove(doc_key) # снять прежнюю версию, если была lsh.remove(doc_key) # снять прежнюю версию, если была (ключа могло не быть)
except Exception:
pass # ключа не было — это норма
lsh.insert(doc_key, m) lsh.insert(doc_key, m)
except Exception as e: except Exception as e:
logger.warning(f"MinHash LSH: не удалось добавить {doc_key!r}: {e}") logger.warning(f"MinHash LSH: не удалось добавить {doc_key!r}: {e}")

View File

@@ -1,8 +1,8 @@
"""Синхронное подключение к PostgreSQL и MinIO для индексер-воркера.""" """Синхронное подключение к PostgreSQL и MinIO для индексер-воркера."""
import logging import logging
from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from typing import Generator
from minio import Minio from minio import Minio
from sqlalchemy import create_engine from sqlalchemy import create_engine

View File

@@ -22,7 +22,6 @@ def extract_text_from_docx(data: bytes) -> str:
ValueError: Если не удалось открыть DOCX ValueError: Если не удалось открыть DOCX
""" """
from docx import Document as DocxDocument from docx import Document as DocxDocument
from docx.oxml.ns import qn
try: try:
doc = DocxDocument(io.BytesIO(data)) doc = DocxDocument(io.BytesIO(data))

View File

@@ -38,22 +38,21 @@ def fetch_full_text(url: str) -> str | None:
follow_redirects=True, follow_redirects=True,
timeout=settings.FULL_TEXT_TIMEOUT, timeout=settings.FULL_TEXT_TIMEOUT,
headers=_HEADERS, headers=_HEADERS,
) as client: ) as client, client.stream("GET", url) as resp:
with client.stream("GET", url) as resp: resp.raise_for_status()
resp.raise_for_status() ctype = resp.headers.get("content-type", "").lower()
ctype = resp.headers.get("content-type", "").lower()
# Скачиваем с ограничением размера # Скачиваем с ограничением размера
buf = bytearray() buf = bytearray()
for chunk in resp.iter_bytes(): for chunk in resp.iter_bytes():
buf += chunk buf += chunk
if len(buf) > settings.FULL_TEXT_MAX_BYTES: if len(buf) > settings.FULL_TEXT_MAX_BYTES:
logger.info( logger.info(
f"full-text превысил лимит {settings.FULL_TEXT_MAX_BYTES} байт, " f"full-text превысил лимит {settings.FULL_TEXT_MAX_BYTES} байт, "
f"обрезаю: {url}" f"обрезаю: {url}"
) )
break break
data = bytes(buf) data = bytes(buf)
except Exception as e: except Exception as e:
logger.info(f"full-text: скачать не удалось {url!r}: {type(e).__name__}: {str(e)[:120]}") logger.info(f"full-text: скачать не удалось {url!r}: {type(e).__name__}: {str(e)[:120]}")
return None return None

View File

@@ -1,7 +1,7 @@
"""Celery задачи индексации документов и проверки плагиата (уровни 1-2).""" """Celery задачи индексации документов и проверки плагиата (уровни 1-2)."""
import io import io
import logging from datetime import UTC
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -253,7 +253,7 @@ def extract_and_check(
except Exception as exc: except Exception as exc:
logger.error(f"Ошибка при обработке задачи {task_id!r}: {exc}", exc_info=True) logger.error(f"Ошибка при обработке задачи {task_id!r}: {exc}", exc_info=True)
update_task_status(task_id, "failed", str(exc)) update_task_status(task_id, "failed", str(exc))
raise self.retry(exc=exc, countdown=60) raise self.retry(exc=exc, countdown=60) from exc
@celery_app.task(name="index.add_document") @celery_app.task(name="index.add_document")
@@ -330,6 +330,7 @@ def add_document(doc_data: dict[str, Any], dispatch_embed: bool = True) -> dict[
# Индексация в Elasticsearch # Индексация в Elasticsearch
try: try:
from elasticsearch import Elasticsearch from elasticsearch import Elasticsearch
from app.config import settings as cfg from app.config import settings as cfg
es = Elasticsearch(cfg.ELASTICSEARCH_URL) es = Elasticsearch(cfg.ELASTICSEARCH_URL)
@@ -409,7 +410,7 @@ def enrich_full_text(self, doc_id: int, url: str) -> dict[str, Any]:
) )
except Exception as exc: except Exception as exc:
logger.error(f"enrich_full_text: не удалось сохранить текст в MinIO для {doc_id}: {exc}") logger.error(f"enrich_full_text: не удалось сохранить текст в MinIO для {doc_id}: {exc}")
raise self.retry(exc=exc, countdown=120) raise self.retry(exc=exc, countdown=120) from exc
# Пересчитать fingerprints по полному тексту # Пересчитать fingerprints по полному тексту
doc_fp = winnow(text) doc_fp = winnow(text)
@@ -501,7 +502,7 @@ def run_parser(source_id: int) -> dict[str, Any]:
задачу add_document для каждого полученного документа. задачу add_document для каждого полученного документа.
""" """
import sys import sys
from datetime import datetime, timezone from datetime import datetime
from app.models import ParseSource from app.models import ParseSource
@@ -594,7 +595,7 @@ def run_parser(source_id: int) -> dict[str, Any]:
src.last_status = "error" if error_msg else "done" src.last_status = "error" if error_msg else "done"
src.last_error = error_msg src.last_error = error_msg
src.docs_added = (src.docs_added or 0) + added src.docs_added = (src.docs_added or 0) + added
src.last_run_at = datetime.now(timezone.utc) src.last_run_at = datetime.now(UTC)
session.commit() session.commit()
return {"status": "error" if error_msg else "done", "added": added, "error": error_msg} return {"status": "error" if error_msg else "done", "added": added, "error": error_msg}

View File

@@ -1,7 +1,7 @@
"""Подключение к PostgreSQL для notifier-воркера.""" """Подключение к PostgreSQL для notifier-воркера."""
from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker

View File

@@ -1,6 +1,5 @@
"""Celery задачи отправки email уведомлений.""" """Celery задачи отправки email уведомлений."""
import logging
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
@@ -67,7 +66,7 @@ def send_task_done(self, task_id: str) -> dict:
except Exception as exc: except Exception as exc:
logger.error(f"Ошибка отправки уведомления для задачи {task_id!r}: {exc}", exc_info=True) logger.error(f"Ошибка отправки уведомления для задачи {task_id!r}: {exc}", exc_info=True)
raise self.retry(exc=exc, countdown=30) raise self.retry(exc=exc, countdown=30) from exc
def _build_summary(task) -> str: def _build_summary(task) -> str:
@@ -144,4 +143,4 @@ def send_verification(self, user_email: str, user_name: str, token: str) -> dict
return {"status": "sent"} return {"status": "sent"}
except Exception as exc: except Exception as exc:
logger.error(f"Ошибка отправки верификации на {user_email!r}: {exc}", exc_info=True) logger.error(f"Ошибка отправки верификации на {user_email!r}: {exc}", exc_info=True)
raise self.retry(exc=exc, countdown=60) raise self.retry(exc=exc, countdown=60) from exc