Документация сильно разошлась с кодом за последние недели работы — привёл в
соответствие 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 нигде не строится, эти поля ничего не делали.
143 lines
6.1 KiB
Python
143 lines
6.1 KiB
Python
"""Точка входа 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"})
|