Files
anti-plagiarism/services/api/app/main.py
jze9 fc40793f4d
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / test (push) Has been cancelled
docs: актуализировать документацию (Infisical, OpenRouter, bge-m3, PMC) + чистка
Документация сильно разошлась с кодом за последние недели работы — привёл в
соответствие ARCHITECTURE.md, README, DIAGRAM.md, INGESTION.md:
- LLM (L4) и эмбеддинги (L3) теперь переключаемые бэкенды (LLM_BACKEND,
  EMBED_BACKEND), а не жёстко Ollama/qwen2.5:7b — старая модель эмбеддингов
  (768d mpnet) заменена на bge-m3 (1024d); L4 может идти через OpenRouter
  (DeepSeek) с прокси singbox для обхода геоблокировки.
- .env на проде больше не редактируется руками — на каждом деплое
  генерируется из Infisical; старая инструкция "cp .env.example .env; nano
  .env" вводила в заблуждение (правки терялись бы на следующем деплое).
- README "Три docker-compose файла"/deploy разделы противоречили реальности:
  фронтенд+TLS реально раздаёт хостовой nginx на CT 102, не докеризованный
  nginx-сервис из compose (тот не запускается вообще).
  добавлен PMC-парсер, self-match исключение объяснено, число тестов (113)
  синхронизировано между файлами (было 82/122 в разных местах).
- INGESTION.md: снимок "42K доков, 0 русских" от 12.08 заменён актуальным
  (165K доков, ru теперь большинство) — иначе документ откровенно врёт.
- Diagram: узлы под текущую топологию (embedding-gpu, OpenRouter, Infisical).

Заодно, раз перепроверял код на соответствие докам:
- Удалён мёртвый и битый эндпоинт GET /reports/{task_id} — фильтровал по
  внутреннему Task.id вместо public_id (никогда не мог сработать через
  обычный клиентский поток), фронтенд его всё равно не вызывал —
  функциональность полностью дублирует рабочий GET /tasks/{public_id}.
- Убран мёртвый конфиг FAISS_NLIST/FAISS_NPROBE (worker-gpu/config.py) —
  индекс всегда IndexFlatIP, IVF нигде не строится, эти поля ничего не делали.
2026-08-27 16:51:31 +05:00

143 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Точка входа FastAPI приложения — Академический помощник."""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from prometheus_fastapi_instrumentator import Instrumentator
from app.api import admin, auth, documents, search, tasks
from app.config import settings
from app.core.minio_client import ensure_bucket
from app.core.redis_client import close_pool
from app.core.security import get_ws_user
from app.core.websocket_manager import ws_manager
from app.database import engine
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Инициализация при старте, очистка при остановке."""
logger.info("Запуск Академического помощника...")
# Проверяем PostgreSQL
try:
import sqlalchemy
async with engine.connect() as conn:
await conn.execute(sqlalchemy.text("SELECT 1"))
logger.info("PostgreSQL: OK")
except Exception as e:
logger.error(f"PostgreSQL: {e}")
# Инициализируем MinIO бакеты (один раз при старте, не на каждый запрос)
try:
ensure_bucket(settings.MINIO_BUCKET_DOCS)
ensure_bucket(settings.MINIO_BUCKET_BACKUPS)
logger.info("MinIO: бакеты готовы")
except Exception as e:
logger.error(f"MinIO: {e}")
yield
logger.info("Остановка приложения...")
await engine.dispose()
await close_pool()
app = FastAPI(
title="Академический помощник API",
version="0.1.0",
lifespan=lifespan,
docs_url="/api/docs" if settings.ENVIRONMENT == "development" else None,
redoc_url=None,
openapi_url="/api/openapi.json" if settings.ENVIRONMENT == "development" else None,
)
# ─── CORS ─────────────────────────────────────────────────────────────────────
# allow_credentials=True несовместим с allow_origins=["*"].
# Указываем явные origins. Wildcard заголовки/методы запрещены с credentials.
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "Accept"],
)
# ─── Роутеры ──────────────────────────────────────────────────────────────────
app.include_router(auth.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(search.router, prefix="/api")
app.include_router(documents.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
# ─── Метрики Prometheus ───────────────────────────────────────────────────────
# HTTP-метрики (кол-во/латентность запросов по хендлерам) на /metrics.
# Prometheus скрейпит их (см. infra/prometheus/prometheus.yml, профиль observability).
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
# ─── WebSocket ────────────────────────────────────────────────────────────────
@app.websocket("/ws/tasks/{public_id}")
async def websocket_task_updates(
websocket: WebSocket,
public_id: str,
# Аутентификация через ?token=JWT (браузеры не могут передавать Authorization header в WS)
current_user=Depends(get_ws_user),
) -> None:
"""
Подписка на обновления задачи в реальном времени.
Подключение: ws://host/ws/tasks/{public_id}?token=<JWT>
Пользователь получает только обновления СВОИХ задач —
ownership проверяется до установки соединения.
"""
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models.task import Task
# Проверяем что задача принадлежит этому пользователю
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Task).where(
Task.public_id == public_id,
Task.user_id == current_user.id,
)
)
task = result.scalar_one_or_none()
if task is None:
await websocket.close(code=4004, reason="Task not found or access denied")
return
await ws_manager.connect(public_id, websocket)
try:
while True:
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
ws_manager.disconnect(public_id, websocket)
# ─── Health ────────────────────────────────────────────────────────────────────
@app.get("/health", tags=["monitoring"])
async def health_check() -> JSONResponse:
return JSONResponse({"status": "ok", "version": "0.1.0"})
@app.get("/", include_in_schema=False)
async def root() -> JSONResponse:
return JSONResponse({"message": "Академический помощник API"})