Files
PlantAI-rag-new/sdk/app/main.py

152 lines
6.8 KiB
Python
Raw Permalink 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.
"""main.py —— BFF 应用入口(ARCHITECTURE.md §2/§3)。
- 挂全部 5 个 router(qa/kg/extraction 为 A2/A3 实现,import 路径不变)。
- CORS:允许 http://localhost:5180 与 http://127.0.0.1:5180(Vite dev)。
- lifespan:init_db(幂等迁移);关闭回收 httpx 客户端与 Neo4j 连接。
- 全局 RequestValidationError → VALIDATION 信封(API.md §1,2026-08-25 PM 集成)。
- docgen(P2):routers/docgen.py 对 services/docgen/*(dev1 归属)按契约 import,
模块缺失时自动回落到内置最小接口桩(生成/编辑返回 503),不阻断 BFF 启动。
- 生产形态(M6):ui/dist 存在时静态托管(ARCHITECTURE.md §3)。
"""
from __future__ import annotations
import logging
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from .config import get_settings
from .db import close_db, init_db
from .routers import capabilities, docgen, extraction, figures, health, kg, legacy, qa, sessions
from .routers import bioflow_proxy
from .routers import paper
from .routers import private_docs
from .routers import review_proxy
from .routers import admin
from .routers import auth
from . import logwriter
from .auth import AuthError, get_current_user, require_authorization
from .services.auth import seed_admin
from .services.settings import get_hub_settings
from .upstream import close_client
logging.basicConfig(level=logging.INFO)
_ALLOWED_ORIGINS = [
"http://localhost:5180",
"http://127.0.0.1:5180",
]
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, version=settings.version)
app.add_middleware(
CORSMiddleware,
allow_origins=_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 行为日志请求中间件:自动采集所有 /api/* 请求(user_id 待鉴权上线后补)
@app.middleware("http")
async def _log_requests(request: Request, call_next):
start = time.monotonic()
response = await call_next(request)
if request.url.path.startswith("/api/"):
user = getattr(request.state, "user", None)
logwriter.log_event(
"http_request",
user_id=user["id"] if user else None,
method=request.method,
path=request.url.path,
status=response.status_code,
latency_ms=round((time.monotonic() - start) * 1000, 1),
)
return response
# 鉴权异常 → 统一错误信封
@app.exception_handler(AuthError)
async def _auth_error_handler(_: Request, exc: AuthError) -> JSONResponse:
return JSONResponse(
status_code=exc.status,
content={"ok": False, "error": {"code": exc.code, "message": exc.message, "degraded": False}},
)
app.include_router(health.router)
app.include_router(auth.router) # /api/auth/*(login/status 免鉴权,me/logout 需登录)
app.include_router(sessions.router, dependencies=[Depends(get_current_user)])
app.include_router(qa.router, dependencies=[Depends(require_authorization("qa"))])
app.include_router(capabilities.router, dependencies=[Depends(get_current_user)])
app.include_router(kg.router, dependencies=[Depends(require_authorization("kg"))])
app.include_router(extraction.router, dependencies=[Depends(require_authorization("extraction"))])
app.include_router(figures.router, dependencies=[Depends(require_authorization("figures"))])
app.include_router(legacy.router, dependencies=[Depends(require_authorization("legacy"))])
app.include_router(docgen.router, dependencies=[Depends(require_authorization("docgen"))]) # docgen(P2)
app.include_router(private_docs.router, dependencies=[Depends(require_authorization("private_docs"))]) # 私有资料问答(FR-PRIV-004)
if get_settings().bioflow_enabled:
app.include_router(bioflow_proxy.router, dependencies=[Depends(require_authorization("bioflow"))]) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028)
if get_settings().paper_writer_enabled:
app.include_router(paper.router, dependencies=[Depends(require_authorization("paper"))]) # 论文撰写(PAPER_WRITER_ENABLED 开关)
if get_settings().review_enabled:
app.include_router(review_proxy.router, dependencies=[Depends(get_current_user)]) # 科研审稿反代(REVIEW_ENABLED 开关)
app.include_router(admin.router) # 管理后台(/api/admin/*)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
# API.md §1 已裁决:参数校验错误收敛为统一 VALIDATION 信封(422)
return JSONResponse(
status_code=422,
content={
"ok": False,
"error": {"code": "VALIDATION", "message": "请求参数不合法", "degraded": False},
},
)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
_db = await init_db()
await seed_admin(_db)
hub = await get_hub_settings(_db)
logwriter.init_writer(settings.log_dir, settings.log_retention_days, hub_id=hub["hub_id"])
await logwriter.start_writer()
try:
yield
finally:
await logwriter.stop_writer()
await close_client()
try:
from .services.neo4j import close_neo4j # A3 提供;缺失则跳过
await close_neo4j()
except ImportError:
pass
await close_db()
app.router.lifespan_context = _lifespan
# 生产形态:静态托管 ui/dist(存在时挂载;本地开发期可无)。
# 2026-08-25 PM 修复:dist 路径必须基于项目根解析(BFF 工作目录是 sdk/,
# 相对路径会解析成 sdk/ui/dist);sync.sh 排除 dist,需单独同步。
project_root = Path(__file__).resolve().parent.parent.parent
# 管理后台静态托管(/admin;须在 "/" 之前挂载)
admin_dist = project_root / settings.admin_dist_dir if not Path(settings.admin_dist_dir).is_absolute() else Path(settings.admin_dist_dir)
if admin_dist.is_dir():
app.mount("/admin", StaticFiles(directory=str(admin_dist), html=True), name="admin")
dist = project_root / settings.frontend_dist_dir if not Path(settings.frontend_dist_dir).is_absolute() else Path(settings.frontend_dist_dir)
if dist.is_dir():
app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend")
return app
app = create_app()