feat: 接入fastapi

This commit is contained in:
2026-09-29 00:47:57 +08:00
parent e5efaf507d
commit 0fdde8c3e6
10 changed files with 1088 additions and 2 deletions
+19
View File
@@ -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))
View File
+40
View File
@@ -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"
)
+86
View File
@@ -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
View File
@@ -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()
+24
View File
@@ -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
View File
+85
View File
@@ -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"),
)