feat: add backend application foundation
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP API infrastructure."""
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
status_code: int,
|
||||||
|
*,
|
||||||
|
retryable: bool = False,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.retryable = retryable
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
retryable: bool = False,
|
||||||
|
details: Any | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
error: dict[str, Any] = {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"request_id": request.state.request_id,
|
||||||
|
}
|
||||||
|
if retryable:
|
||||||
|
error["retryable"] = True
|
||||||
|
if details is not None:
|
||||||
|
error["details"] = details
|
||||||
|
return {"error": error}
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=_payload(
|
||||||
|
request,
|
||||||
|
code=exc.code,
|
||||||
|
message=exc.message,
|
||||||
|
retryable=exc.retryable,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def handle_validation_error(
|
||||||
|
request: Request, exc: RequestValidationError
|
||||||
|
) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content=_payload(
|
||||||
|
request,
|
||||||
|
code="VALIDATION_ERROR",
|
||||||
|
message="请求参数校验失败",
|
||||||
|
details=exc.errors(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def handle_unexpected_error(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
logger.exception("Unhandled request error request_id=%s", request.state.request_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=_payload(
|
||||||
|
request,
|
||||||
|
code="INTERNAL_ERROR",
|
||||||
|
message="服务暂时不可用,请稍后重试",
|
||||||
|
retryable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||||
|
request.state.request_id = request_id
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_prefix="KBQA_",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
app_name: str = "KBQA"
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
dashscope_api_key: str = ""
|
||||||
|
dashscope_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
chat_model: str = "qwen3.5-flash"
|
||||||
|
embedding_model: str = "text-embedding-v4"
|
||||||
|
embedding_dim: int = 1024
|
||||||
|
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./data/kbqa.sqlite3"
|
||||||
|
milvus_uri: str = "./data/milvus/kbqa.db"
|
||||||
|
raw_data_dir: Path = Path("./data/raw")
|
||||||
|
|
||||||
|
chunk_size: int = Field(default=500, ge=1)
|
||||||
|
chunk_overlap: int = Field(default=50, ge=0)
|
||||||
|
retrieval_top_k: int = Field(default=5, ge=1, le=100)
|
||||||
|
max_research_searches: int = Field(default=3, ge=1, le=10)
|
||||||
|
max_sources: int = Field(default=8, ge=1, le=50)
|
||||||
|
history_message_limit: int = Field(default=20, ge=1, le=200)
|
||||||
|
max_upload_mib: int = Field(default=20, ge=1, le=1024)
|
||||||
|
max_query_length: int = Field(default=10_000, ge=1, le=100_000)
|
||||||
|
cors_origins: list[str] = [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
]
|
||||||
|
|
||||||
|
run_live_tests: bool = False
|
||||||
|
live_test_file: Path | None = None
|
||||||
|
|
||||||
|
@field_validator("chunk_overlap")
|
||||||
|
@classmethod
|
||||||
|
def overlap_must_be_smaller_than_chunk(cls, value: int, info) -> int:
|
||||||
|
chunk_size = info.data.get("chunk_size", 500)
|
||||||
|
if value >= chunk_size:
|
||||||
|
raise ValueError("chunk_overlap must be smaller than chunk_size")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_live_settings(settings: Settings) -> None:
|
||||||
|
missing: list[str] = []
|
||||||
|
if not settings.dashscope_api_key:
|
||||||
|
missing.append("KBQA_DASHSCOPE_API_KEY")
|
||||||
|
if not settings.dashscope_base_url:
|
||||||
|
missing.append("KBQA_DASHSCOPE_BASE_URL")
|
||||||
|
if missing:
|
||||||
|
joined = ", ".join(missing)
|
||||||
|
raise RuntimeError(f"Missing live model configuration: {joined}")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True)
|
||||||
|
self.session_factory = async_sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
class_=AsyncSession,
|
||||||
|
)
|
||||||
|
self._install_sqlite_pragmas(self.engine.sync_engine)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _install_sqlite_pragmas(engine: Engine) -> None:
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def configure_sqlite(dbapi_connection, _connection_record) -> None:
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000")
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
async def dispose(self) -> None:
|
||||||
|
await self.engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
|
||||||
|
database: Database = request.app.state.database
|
||||||
|
async with database.session_factory() as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Health check feature."""
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health(request: Request) -> dict[str, object]:
|
||||||
|
components: dict[str, dict[str, str]] = {
|
||||||
|
"app": {"status": "ok"},
|
||||||
|
"sqlite": {"status": "ok"},
|
||||||
|
"milvus": {"status": "ok"},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with request.app.state.database.engine.connect() as connection:
|
||||||
|
await connection.execute(text("SELECT 1"))
|
||||||
|
except Exception:
|
||||||
|
components["sqlite"] = {"status": "error"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(request.app.state.milvus_client.list_collections)
|
||||||
|
except Exception:
|
||||||
|
components["milvus"] = {"status": "error"}
|
||||||
|
|
||||||
|
overall = "ok" if all(item["status"] == "ok" for item in components.values()) else "degraded"
|
||||||
|
return {"status": overall, "components": components}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pymilvus import MilvusClient
|
||||||
|
|
||||||
|
from kbqa.config import get_settings
|
||||||
|
from kbqa.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_runtime_directories(database_url: str, milvus_uri: str, raw_data_dir: Path) -> None:
|
||||||
|
raw_data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
if database_url.startswith("sqlite+aiosqlite:///./"):
|
||||||
|
Path(database_url.removeprefix("sqlite+aiosqlite:///./")).parent.mkdir(
|
||||||
|
parents=True, exist_ok=True
|
||||||
|
)
|
||||||
|
Path(milvus_uri).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
settings = get_settings()
|
||||||
|
_ensure_runtime_directories(
|
||||||
|
settings.database_url,
|
||||||
|
settings.milvus_uri,
|
||||||
|
settings.raw_data_dir,
|
||||||
|
)
|
||||||
|
database = Database(settings.database_url)
|
||||||
|
milvus_client = MilvusClient(uri=settings.milvus_uri)
|
||||||
|
app.state.settings = settings
|
||||||
|
app.state.database = database
|
||||||
|
app.state.milvus_client = milvus_client
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
milvus_client.close()
|
||||||
|
await database.dispose()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from kbqa.api.errors import register_exception_handlers
|
||||||
|
from kbqa.api.middleware import RequestIDMiddleware
|
||||||
|
from kbqa.api.router import api_router
|
||||||
|
from kbqa.config import get_settings
|
||||||
|
from kbqa.health.routes import router as health_router
|
||||||
|
from kbqa.lifecycle import lifespan
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
|
||||||
|
app.add_middleware(RequestIDMiddleware)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
register_exception_handlers(app)
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(api_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
Reference in New Issue
Block a user