193 lines
5.2 KiB
Python
193 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
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,
|
|
periodic_interval_seconds=float(os.getenv("WIKI_SYNC_INTERVAL_SECONDS", "0")))
|
|
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)
|