feat: 接入fastapi
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
"""大屏 Agent 启动与澄清恢复接口"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.common.response import ApiResponse, success_response
|
||||
from app.schemas.agent import AgentResult, ResumeRequest
|
||||
from app.schemas.request import DesignRequest
|
||||
|
||||
router = APIRouter(prefix="/agent", tags=["Agent"])
|
||||
|
||||
|
||||
@router.post("/run", response_model=ApiResponse[AgentResult])
|
||||
async def run_agent(body: DesignRequest, request: Request):
|
||||
return success_response(await request.app.state.agent_service.run(body))
|
||||
|
||||
|
||||
@router.post("/resume", response_model=ApiResponse[AgentResult])
|
||||
async def resume_agent(body: ResumeRequest, request: Request):
|
||||
return success_response(await request.app.state.agent_service.resume(body))
|
||||
@@ -0,0 +1,40 @@
|
||||
"""定义统一 API 响应结构"""
|
||||
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
DataT = TypeVar("DataT")
|
||||
ResponseData = BaseModel | dict[str, Any] | list[Any] | None
|
||||
|
||||
|
||||
class ApiResponse(BaseModel, Generic[DataT]):
|
||||
code: int = 0
|
||||
data: DataT | None = None
|
||||
msg: str = ""
|
||||
|
||||
@field_validator("data")
|
||||
@classmethod
|
||||
def validate_data_shape(cls, value: DataT | None) -> DataT | None:
|
||||
"""限制响应数据为对象、数组或空值"""
|
||||
if value is not None and not isinstance(value, (BaseModel, dict, list)):
|
||||
raise ValueError("响应 data 必须为对象、数组或 null")
|
||||
return value
|
||||
|
||||
|
||||
def success_response(
|
||||
data: DataT | None = None,
|
||||
msg: str = "",
|
||||
) -> ApiResponse[DataT | dict[str, Any]]:
|
||||
"""构造统一成功响应"""
|
||||
response_data: DataT | dict[str, Any] = {} if data is None else data
|
||||
return ApiResponse(code=0, data=response_data, msg=msg)
|
||||
|
||||
|
||||
def error_content(
|
||||
code: int, msg: str = "请求失败"
|
||||
) -> dict[str, ResponseData | int | str]:
|
||||
"""构造统一失败响应内容"""
|
||||
return ApiResponse[ResponseData](code=code, data=None, msg=msg).model_dump(
|
||||
mode="json"
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""业务异常与全局 API 异常处理"""
|
||||
|
||||
import logging
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from openai import APIError, APITimeoutError
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from app.common.response import error_content
|
||||
from app.exceptions import AppError, DecisionError, ImportProcessError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BizException(AppError):
|
||||
"""可安全返回给调用方的业务错误"""
|
||||
|
||||
def __init__(self, code: int, msg: str = "请求失败") -> None:
|
||||
if not 400 <= code <= 599:
|
||||
raise ValueError("业务错误码必须是 400 到 599 的 HTTP 状态码")
|
||||
self.code = code
|
||||
self.msg = msg
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
async def handle_biz_exception(_request: Request, exc: BizException) -> JSONResponse:
|
||||
return JSONResponse(status_code=exc.code, content=error_content(exc.code, exc.msg))
|
||||
|
||||
|
||||
async def handle_validation_exception(
|
||||
_request: Request, _exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
# 不返回原始输入,避免校验错误携带用户数据
|
||||
return JSONResponse(status_code=422, content=error_content(422, "请求参数校验失败"))
|
||||
|
||||
|
||||
async def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse:
|
||||
# 保留状态与协议响应头,例如 Allow、WWW-Authenticate 和 Retry-After
|
||||
try:
|
||||
message = HTTPStatus(exc.status_code).phrase
|
||||
except ValueError:
|
||||
message = "请求失败"
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_content(exc.status_code, message),
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
|
||||
async def handle_unexpected_exception(
|
||||
_request: Request, exc: Exception
|
||||
) -> JSONResponse:
|
||||
# 详细异常仅记录在服务端,不将堆栈或内部错误内容返回给前端
|
||||
logger.error("未处理的服务器异常", exc_info=(type(exc), exc, exc.__traceback__))
|
||||
# 节点包装异常时沿原始原因查找,区分上游服务故障与内部错误
|
||||
cause = exc
|
||||
while isinstance(cause, ImportProcessError):
|
||||
nested = cause.cause or cause.__cause__
|
||||
if nested is None:
|
||||
break
|
||||
cause = nested
|
||||
if isinstance(cause, APITimeoutError) or (
|
||||
isinstance(cause, DecisionError) and cause.kind == "timeout"
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=504, content=error_content(504, "模型服务请求超时")
|
||||
)
|
||||
if isinstance(cause, APIError) or (
|
||||
isinstance(cause, DecisionError)
|
||||
and cause.kind not in {"configuration", "closed"}
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=502, content=error_content(502, "模型服务调用失败")
|
||||
)
|
||||
return JSONResponse(status_code=500, content=error_content(500, "服务内部错误"))
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""集中注册异常处理,接口无需重复捕获"""
|
||||
app.add_exception_handler(BizException, handle_biz_exception)
|
||||
app.add_exception_handler(RequestValidationError, handle_validation_exception)
|
||||
app.add_exception_handler(HTTPException, handle_http_exception)
|
||||
app.add_exception_handler(Exception, handle_unexpected_exception)
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"""FastAPI 应用入口"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.agent import router
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(application: FastAPI):
|
||||
from app.clients.decision import close_decision_engine
|
||||
from app.observability.tracing import flush_tracing
|
||||
from app.processor.graph import build_graph
|
||||
from app.services.agent import AgentService
|
||||
|
||||
application.state.agent_service = AgentService(build_graph())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# 共享客户端只在应用退出时关闭,追踪刷新放到线程避免阻塞事件循环
|
||||
try:
|
||||
await close_decision_engine()
|
||||
finally:
|
||||
await asyncio.to_thread(flush_tracing)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
application = FastAPI(title="Screen Agent API", lifespan=lifespan)
|
||||
# 统一处理请求校验、HTTP 错误和未捕获异常
|
||||
register_exception_handlers(application)
|
||||
application.include_router(router)
|
||||
return application
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Agent HTTP 请求与结果,不向前端暴露内部 Graph State"""
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.actions import Action
|
||||
from app.schemas.clarification import Clarification
|
||||
from app.schemas.clarification_reply import ClarificationReply
|
||||
|
||||
|
||||
class ResumeRequest(BaseModel):
|
||||
thread_id: UUID
|
||||
reply: ClarificationReply
|
||||
|
||||
|
||||
class AgentResult(BaseModel):
|
||||
thread_id: str
|
||||
status: Literal["completed", "waiting_for_input", "failed", "cancelled"]
|
||||
actions: list[Action] = Field(default_factory=list)
|
||||
answer: str | None = None
|
||||
clarification: Clarification | None = None
|
||||
failure_reason: str | None = None
|
||||
@@ -0,0 +1,85 @@
|
||||
"""异步执行与恢复大屏任务,当前使用单进程内存检查点"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.core.exceptions import BizException
|
||||
from app.observability.tracing import ainvoke_screen
|
||||
from app.schemas.agent import AgentResult, ResumeRequest
|
||||
from app.schemas.request import DesignRequest
|
||||
|
||||
|
||||
class AgentService:
|
||||
def __init__(self, graph):
|
||||
self.graph = graph
|
||||
self._active: set[str] = set()
|
||||
|
||||
async def run(self, request: DesignRequest) -> AgentResult:
|
||||
# 新请求始终独立建任务,不用 conversation_id 充当检查点 ID
|
||||
thread_id = str(uuid4())
|
||||
return await self._invoke(
|
||||
thread_id,
|
||||
{"request": request.model_dump(mode="json")},
|
||||
request.context.conversation_id,
|
||||
)
|
||||
|
||||
async def resume(self, request: ResumeRequest) -> AgentResult:
|
||||
thread_id = str(request.thread_id)
|
||||
# 检查和占用之间没有 await,同一事件循环内不会重复进入
|
||||
self._claim(thread_id)
|
||||
try:
|
||||
snapshot = await self.graph.aget_state(
|
||||
{"configurable": {"thread_id": thread_id}}
|
||||
)
|
||||
if not snapshot.values:
|
||||
raise BizException(404, "任务不存在或已过期")
|
||||
if not any(task.interrupts for task in snapshot.tasks):
|
||||
raise BizException(409, "任务当前未等待用户补充")
|
||||
# 恢复前校验选项,避免无效回答消耗一次 interrupt 恢复
|
||||
clarification = snapshot.values["clarification"]
|
||||
reply = request.reply
|
||||
if (
|
||||
not reply.cancel
|
||||
and reply.option_id is not None
|
||||
and reply.option_id not in {item.id for item in clarification.options}
|
||||
):
|
||||
raise BizException(422, "请选择当前问题的有效选项")
|
||||
session_id = (
|
||||
snapshot.values["request"].get("context", {}).get("conversation_id")
|
||||
)
|
||||
return await self._execute(
|
||||
thread_id, Command(resume=reply.model_dump(mode="json")), session_id
|
||||
)
|
||||
finally:
|
||||
self._active.discard(thread_id)
|
||||
|
||||
def _claim(self, thread_id: str) -> None:
|
||||
if thread_id in self._active:
|
||||
raise BizException(409, "任务正在执行,请勿重复提交")
|
||||
self._active.add(thread_id)
|
||||
|
||||
async def _invoke(self, thread_id, payload, session_id) -> AgentResult:
|
||||
self._claim(thread_id)
|
||||
try:
|
||||
return await self._execute(thread_id, payload, session_id)
|
||||
finally:
|
||||
# 异常和取消也必须释放占用,不阻塞后续请求
|
||||
self._active.discard(thread_id)
|
||||
|
||||
async def _execute(self, thread_id, payload, session_id) -> AgentResult:
|
||||
state = await ainvoke_screen(
|
||||
self.graph, payload, thread_id=thread_id, session_id=session_id
|
||||
)
|
||||
waiting = bool(state.get("__interrupt__"))
|
||||
# 只交付校验通过的完整动作批次,失败草稿留在后端
|
||||
return AgentResult(
|
||||
thread_id=thread_id,
|
||||
status="waiting_for_input" if waiting else state["status"],
|
||||
actions=state.get("actions") or []
|
||||
if state.get("validation_passed") is True and state["status"] == "completed"
|
||||
else [],
|
||||
answer=state.get("answer"),
|
||||
clarification=state.get("clarification") if waiting else None,
|
||||
failure_reason=state.get("failure_reason"),
|
||||
)
|
||||
Reference in New Issue
Block a user