215 lines
7.6 KiB
Python
215 lines
7.6 KiB
Python
"""投顾Agent HTTP 客户端 + 错误码映射层(工作台 → Agent 的唯一出口)。
|
||
|
||
职责:
|
||
1. 通过 httpx 调用投顾Agent 独立服务(统一前缀 /api/advisor-agent,Agent 文档 §5);
|
||
2. 透传上游投顾 JWT 与 X-Trace-Id;
|
||
3. 快接口超时/重试(仅幂等 GET 重试,写操作不盲目重试);
|
||
4. 将 Agent 自有错误码(0/40001/40020/40030/40401/50001/50002)映射为工作台异常,
|
||
绝不把 Agent 码透传给上层调用方(common_const §6 错误码域边界)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
|
||
from common_const import (
|
||
AGENT_ERR_MESSAGE,
|
||
ERR_CODE_DRAFT_NOT_FOUND,
|
||
ERR_CODE_FORBIDDEN_CUSTOMER,
|
||
ERR_CODE_GRAPH_ERROR,
|
||
ERR_CODE_LLM_ERROR,
|
||
ERR_CODE_NOT_SIGNED_REBALANCE,
|
||
ERR_CODE_OK,
|
||
ERR_CODE_SUITABILITY_INVALID,
|
||
)
|
||
from config.settings import settings
|
||
from utils.exceptions import (
|
||
ForbiddenError,
|
||
LLMFailError,
|
||
NotFoundError,
|
||
NotSuitableError,
|
||
ParamError,
|
||
)
|
||
|
||
# Agent 统一前缀(Agent 文档 §5)
|
||
_AGENT_PREFIX = "/api/advisor-agent"
|
||
|
||
|
||
def translate_agent_error(code: int, message: str | None = None) -> str | None:
|
||
"""把 Agent 业务码映射为工作台结果。
|
||
|
||
- code == 0:正常,返回 None;
|
||
- code == 50002:降级但成功,返回告警文案(不抛异常);
|
||
- 其余:抛出映射后的 ApiError(工作台码域,不透传 Agent 码)。
|
||
"""
|
||
if code == ERR_CODE_OK:
|
||
return None
|
||
if code == ERR_CODE_GRAPH_ERROR:
|
||
return message or AGENT_ERR_MESSAGE.get(code, "图谱查询异常,已降级返回部分结果")
|
||
default = message or AGENT_ERR_MESSAGE.get(code, "AI 服务调用异常,请稍后重试")
|
||
if code == ERR_CODE_FORBIDDEN_CUSTOMER:
|
||
raise ForbiddenError(default)
|
||
if code == ERR_CODE_SUITABILITY_INVALID:
|
||
raise NotSuitableError(default)
|
||
if code == ERR_CODE_NOT_SIGNED_REBALANCE:
|
||
raise ParamError(default)
|
||
if code == ERR_CODE_DRAFT_NOT_FOUND:
|
||
raise NotFoundError(default)
|
||
if code == ERR_CODE_LLM_ERROR:
|
||
raise LLMFailError(default)
|
||
# 未知 Agent 业务码:统一按 AI 服务异常兜底,不透传
|
||
raise LLMFailError(default)
|
||
|
||
|
||
class AdvisorAgentClient:
|
||
"""投顾Agent 客户端(模块级单例 get_agent_client() 获取)。"""
|
||
|
||
def __init__(self, base_url: str, timeout: float, retry: int):
|
||
self.base_url = (base_url or "").rstrip("/")
|
||
self.timeout = timeout
|
||
self.retry = max(0, retry)
|
||
|
||
@property
|
||
def configured(self) -> bool:
|
||
return bool(self.base_url)
|
||
|
||
def _url(self, path: str) -> str:
|
||
return f"{self.base_url}{_AGENT_PREFIX}{path}"
|
||
|
||
def _headers(self, auth_header: str, trace_id: str) -> dict:
|
||
return {
|
||
"Authorization": auth_header,
|
||
"X-Trace-Id": trace_id,
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
async def _request(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
auth_header: str,
|
||
trace_id: str,
|
||
params: dict | None = None,
|
||
json: dict | None = None,
|
||
allow_retry: bool = False,
|
||
) -> dict:
|
||
"""统一请求:校验配置 → 超时重试 → 解析返回体 → 映射业务码。
|
||
|
||
返回 {"data": ..., "warning": ...};业务失败抛出映射后的 ApiError。
|
||
"""
|
||
if not self.configured:
|
||
raise LLMFailError("投顾Agent 服务未配置,请联系管理员")
|
||
url = self._url(path)
|
||
headers = self._headers(auth_header, trace_id)
|
||
attempts = self.retry + 1 if allow_retry else 1
|
||
for attempt in range(attempts):
|
||
try:
|
||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||
resp = await client.request(
|
||
method, url, headers=headers, params=params, json=json
|
||
)
|
||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||
if attempt < attempts - 1:
|
||
continue
|
||
raise LLMFailError("AI 服务调用异常,请稍后重试") from exc
|
||
|
||
if resp.status_code != 200:
|
||
if attempt < attempts - 1:
|
||
continue
|
||
raise LLMFailError("AI 服务调用异常,请稍后重试")
|
||
|
||
try:
|
||
body = resp.json()
|
||
except ValueError as exc:
|
||
raise LLMFailError("AI 服务返回格式异常,请稍后重试") from exc
|
||
|
||
code = body.get("code", ERR_CODE_LLM_ERROR)
|
||
warning = translate_agent_error(code, body.get("message"))
|
||
return {"data": body.get("data"), "warning": warning}
|
||
raise LLMFailError("AI 服务调用异常,请稍后重试") # 理论不可达,防御
|
||
|
||
# ---- 各 Agent 接口(字段与 Agent 文档 §5 对齐) ----
|
||
async def draft_list(
|
||
self,
|
||
*,
|
||
auth_header: str,
|
||
trace_id: str,
|
||
advisor_id: int,
|
||
customer_id: int | None = None,
|
||
status: str | None = None,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> dict:
|
||
params = {"advisor_id": advisor_id, "page": page, "page_size": page_size}
|
||
if customer_id is not None:
|
||
params["customer_id"] = customer_id
|
||
if status:
|
||
params["status"] = status
|
||
return await self._request(
|
||
"GET", "/draft/list", auth_header=auth_header, trace_id=trace_id, params=params,
|
||
allow_retry=True,
|
||
)
|
||
|
||
async def draft_detail(self, draft_id: str, *, auth_header: str, trace_id: str) -> dict:
|
||
return await self._request(
|
||
"GET", f"/draft/{draft_id}", auth_header=auth_header, trace_id=trace_id,
|
||
allow_retry=True,
|
||
)
|
||
|
||
async def draft_save(
|
||
self, draft_id: str, payload: dict, *, auth_header: str, trace_id: str
|
||
) -> dict:
|
||
return await self._request(
|
||
"PUT", f"/draft/{draft_id}/save", auth_header=auth_header,
|
||
trace_id=trace_id, json=payload,
|
||
)
|
||
|
||
async def draft_operate(
|
||
self, draft_id: str, action: str, *, auth_header: str, trace_id: str
|
||
) -> dict:
|
||
return await self._request(
|
||
"POST", f"/draft/{draft_id}/operate", auth_header=auth_header,
|
||
trace_id=trace_id, json={"operation": action},
|
||
)
|
||
|
||
async def rebalance_run(
|
||
self, customer_id: int, *, auth_header: str, trace_id: str
|
||
) -> dict:
|
||
return await self._request(
|
||
"POST", "/rebalance/run", auth_header=auth_header,
|
||
trace_id=trace_id, json={"customer_id": customer_id},
|
||
)
|
||
|
||
async def generate_talk_script(
|
||
self, customer_id: int, scene_type: str, *, auth_header: str, trace_id: str
|
||
) -> dict:
|
||
return await self._request(
|
||
"POST", "/generate-talk-script", auth_header=auth_header,
|
||
trace_id=trace_id, json={"customer_id": customer_id, "scene_type": scene_type},
|
||
)
|
||
|
||
async def data_query(
|
||
self,
|
||
payload: dict,
|
||
*,
|
||
auth_header: str,
|
||
trace_id: str,
|
||
) -> dict:
|
||
"""代理当前投顾选中客户的数据查询,不向工作台暴露 SQL。"""
|
||
return await self._request(
|
||
"POST", "/data-query", auth_header=auth_header,
|
||
trace_id=trace_id, json=payload,
|
||
)
|
||
|
||
|
||
_client: AdvisorAgentClient | None = None
|
||
|
||
|
||
def get_agent_client() -> AdvisorAgentClient:
|
||
"""取全局客户端单例(按 config.settings 组装)。"""
|
||
global _client
|
||
if _client is None:
|
||
cfg = settings.advisor_agent
|
||
_client = AdvisorAgentClient(cfg.base_url, cfg.timeout, cfg.retry)
|
||
return _client
|