dp-zp-agent/app/main.py

191 lines
5.1 KiB
Python

from __future__ import annotations
import asyncio
import logging
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import (
FastAPI,
Request,
)
from fastapi.middleware.cors import (
CORSMiddleware,
)
from fastapi.exceptions import RequestValidationError
from fastapi.encoders import jsonable_encoder
from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import JSONResponse
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(
0,
str(PROJECT_ROOT),
)
from app.routes import router
from app.security import (
validate_security_configuration,
)
from tools.rag.embedding_utils import embed_query
from tools.retrieval.retrieval_contract import error_result
from tools.rag.common import DATA_DIR
from tools.sync.sync_jobs import SyncJobs
from tools.rag.rebuild_index import rebuild_index
logger = logging.getLogger(__name__)
OPENWEBUI_ORIGIN = "https://ui.tukekemt.xyz"
@asynccontextmanager
async def lifespan(
application: FastAPI,
):
validate_security_configuration()
application.state.embedding_ready = False
try:
await asyncio.to_thread(
embed_query,
"warmup",
)
application.state.embedding_ready = True
except Exception:
logger.exception(
"Predhriatie embedding modelu " "zlyhalo. API pokračuje bez " "úspešného warm-upu."
)
def run_sync(**kwargs):
result = rebuild_index(**kwargs)
application.state.embedding_ready = True
return result
application.state.sync_jobs = SyncJobs(DATA_DIR, runner=run_sync)
application.state.sync_jobs.start()
try:
yield
finally:
await asyncio.to_thread(application.state.sync_jobs.stop)
app = FastAPI(
title="ZP Agent API",
description=(
"RAG API pre vyhľadávanie "
"a získavanie informácií "
"z repozitára záverečných prác "
"ZP Wiki."
),
version="0.9.0",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
)
@app.exception_handler(StarletteHTTPException)
async def retrieval_http_error(request: Request, error: StarletteHTTPException):
if request.url.path not in {"/rag", "/search", "/graphrag"}:
return await http_exception_handler(request, error)
reason = {
400: "invalid_query",
401: "unauthorized",
403: "forbidden",
404: "not_found",
405: "method_not_allowed",
422: "invalid_request",
503: "retrieval_unavailable",
}.get(error.status_code, "internal_error")
return JSONResponse(
status_code=error.status_code,
headers=error.headers,
content={**error_result(reason=reason), "detail": error.detail},
)
@app.exception_handler(RequestValidationError)
async def retrieval_validation_error(request: Request, error: RequestValidationError):
if request.url.path not in {"/rag", "/search", "/graphrag"}:
return await request_validation_exception_handler(request, error)
# Retain useful validation locations/types without echoing submitted secrets.
details = [
{"loc": row["loc"], "type": row["type"], "msg": row["msg"]} for row in error.errors()
]
return JSONResponse(
status_code=422,
content=jsonable_encoder({**error_result(reason="invalid_request"), "detail": details}),
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
OPENWEBUI_ORIGIN,
],
allow_credentials=True,
allow_methods=[
"GET",
"POST",
"OPTIONS",
],
allow_headers=[
"Authorization",
"Content-Type",
"X-API-Key",
"X-Session-Id",
"X-OpenWebUI-Chat-Id",
"X-OpenWebUI-Message-Id",
"X-OpenWebUI-User-Id",
"X-OpenWebUI-User-Name",
"X-OpenWebUI-User-Email",
"X-OpenWebUI-User-Role",
"X-OpenWebUI-User-Jwt",
"X-OpenWebUI-User-Message-Id",
"X-OpenWebUI-User-Message-Parent-Id",
"X-OpenWebUI-Task",
],
allow_private_network=True,
)
@app.middleware("http")
async def add_security_headers(
request: Request,
call_next,
):
try:
response = await call_next(request)
except Exception:
if request.url.path not in {"/rag", "/search", "/graphrag"}:
raise
# Serialization failures must obey the same contract as backend errors.
logger.exception("Neočakávaná chyba výstupu retrieval API.")
response = JSONResponse(
status_code=500,
content={
**error_result(reason="internal_error"),
"detail": "Nastala interná chyba servera.",
},
)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Frame-Options"] = "DENY"
return response
app.include_router(router)