Files
Mutual_Fund/service/advisor/agent_client.py
T

215 lines
7.6 KiB
Python
Raw 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.
"""投顾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 = 10,
) -> 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