Files
group_xinghuo_jinrong/app/service/agent_service.py
T
zhanghongyu_0626 647c07062e feat(trade): Enhance trade processing and context handling in customer service
- Introduced `pending_trade` handling in the chat API to manage trade requests more effectively.
- Updated the `submit_trade_api` to allow advisors to access customer trades based on assigned roles.
- Added new methods in `GatewayRepository` for managing core holdings during trade subscriptions and redemptions.
- Implemented context-aware trade dialogue management in the customer service layer to improve user experience during multi-turn interactions.
- Enhanced the tool service to support trade actions and suitability checks, ensuring accurate processing of user requests.

This update significantly improves the trade interaction flow, providing a more robust and user-friendly experience for customers engaging in trading activities.
2026-09-13 17:45:04 +08:00

434 lines
16 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 编排(T-07 骨架 / T-04 Tool 节点):LangGraph StateGraph + DeepSeek 对话。
链路(FLOW §2):tool(意图匹配→归属校验→只读查询→agent_tool_call 落库)
→ llm(DeepSeek,工具结果注入上下文)→ guard(合规护栏)。四 Agent 能力
边界(FLOW §4 与 MEMORY 禁止项)由 system prompt 固化:客户无投资建议/
收益承诺/自动下单;代理人草稿不外发;分析只读;风控辅助不自动处置。
Tool 节点(T-04):一期关键词意图(tool_service.match_intent)仅对
customer/advisor 分支查 Core RO(持仓/流水/L0);customer_id 由会话注入,
不来自 LLM。risk 分支四个风控 Tool 与 LLM intent 归 C1/C2(开发计划)。
LLM 未配置(DEEPSEEK_API_KEY 为空)时降级:回复携带 Tool 查询摘要
(演示链路不断且查询不白跑);单测经 FakeLLM 注入,不依赖外网。
方案 C(SSE):`stream_chat` 为流式入口——Tool 节点同步跑完后逐块产出
LLM 文本,落库由 api 层在收完 done 后统一写(断连整轮不落消息)。
免责判定抽 `needs_disclaimer`,首帧 meta 与落库文本共用同一口径。
"""
from __future__ import annotations
import threading
from collections.abc import Iterator
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langgraph.graph import END, START, StateGraph
from operator import add
from app.config.settings import settings
from app.service import tool_service
from app.service.trade_action_service import (
TRADE_MODAL_HINT,
TRADE_PARSE_FAIL_HINT,
should_use_suitability_instead_of_trade,
)
from app.service.trade_flow_service import trade_dialogue_should_continue
# 客户/对外口径的固定免责声明(随回复文本尾部输出;G-08 阻断响应另有两要素)
CHAT_DISCLAIMER = "以上内容由 AI 生成,仅供业务参考,不构成投资建议。"
# LLM 未配置时的降级回复(明确标注非模型生成,演示链路不断)
_DEGRADED_PREFIX = "(LLM 未配置:请在 .env 设置 DEEPSEEK_API_KEY 后重启)"
# 四 Agent 能力边界 system prompt(FLOW §4;MEMORY 禁止项固化)
_SYSTEM_PROMPTS: dict[str, str] = {
"customer": (
"你是客户财富智能管家。仅服务当前登录客户本人;不得提供投资建议、"
"收益承诺或自动下单;事实性数据须注明来源,无法回答时如实告知。"
),
"advisor": (
"你是代理人助手,服务理财顾问。仅可讨论名下客户(归属经平台校验);"
"生成的草稿内容不会自动外发给客户;引用数据须可溯源。"
),
"analyst": (
"你是数据分析助手。仅做只读查询与聚合分析,不写任何画像/预警/业务数据。"
),
"risk": (
"你是风控监测助手。可查询预警台账与客户监测信息,输出仅供参考,"
"最终处置须经风控专员通过处置接口人工完成;不得自动处置预警。"
),
}
class ChatState(TypedDict):
"""对话图状态(messages 由各节点以 add 归并,避免覆盖历史)。
T-04 Tool 上下文:session_id/trace_id 供 agent_tool_call 落库;actor 为
api/chat 展开的鉴权字段({actor_id, roles, token_type});customer_id 为
会话绑定客户(归属校验后的值,Tool 查询主体恒取此处)。
"""
agent_type: str
history: list[dict]
user_message: str
messages: Annotated[list[BaseMessage], add]
reply: str
has_disclaimer: bool
session_id: str | None
trace_id: str | None
actor: dict[str, Any] | None
customer_id: str | None
tool_results: list[dict]
def _compose_messages(state: ChatState) -> list[BaseMessage]:
"""system(角色边界)+ 工具结果(有则注入)+ 历史窗口 + 本轮用户消息。"""
msgs: list[BaseMessage] = [
SystemMessage(content=_SYSTEM_PROMPTS.get(state["agent_type"], _SYSTEM_PROMPTS["customer"]))
]
tool_text = tool_service.context_text(state.get("tool_results") or [])
if tool_text:
msgs.append(SystemMessage(content=tool_text))
for m in state["history"]:
role = m.get("role")
content = m.get("content", "")
if role == "user":
msgs.append(HumanMessage(content=content))
elif role == "assistant":
msgs.append(AIMessage(content=content))
msgs.append(HumanMessage(content=state["user_message"]))
return msgs
def _chat_history_context(history: list[dict]) -> str:
lines: list[str] = []
for m in history[-16:]:
role = m.get("role") or "user"
content = (m.get("content") or "").strip()
if content:
lines.append(f"{role}: {content}")
return "\n".join(lines)
def _resolve_advisor_tool(state: ChatState, ctx: str) -> str | None:
"""顾问线:交易续轮 / 选购清单优先于泛化关键词。"""
if state.get("agent_type") != "advisor" or not state.get("customer_id"):
return None
msg = state.get("user_message") or ""
sid = state.get("session_id") or ""
if trade_dialogue_should_continue(sid, msg, ctx):
return "prepare_simulate_trade"
if should_use_suitability_instead_of_trade(msg):
return "query_suitability_catalog"
from app.tool.trade_tools import _wants_purchasable_catalog
if _wants_purchasable_catalog(msg):
return "query_suitability_catalog"
return None
def tool_node(state: ChatState) -> dict[str, Any]:
"""Tool 节点(T-04 + C2):意图匹配 → tool_service.run_tool(校验+落库)。
守卫(C2 放宽):会话与 actor 恒为必需;requires_customer=False 的 Tool
(如 alert_query 全量待审)允许无绑定客户运行——否则 risk_officer 查
"今天有多少待审预警"(A-6)会被 customer_id 空缺拦死。requires_customer
为 True 的 Tool 仍需绑定客户(无则空转,由 run_tool 判
TOOL_BLOCKED_NO_CUSTOMER)。
"""
if not (state.get("session_id") and state.get("actor")):
return {"tool_results": []}
ctx = _chat_history_context(state.get("history") or [])
tool_name = _resolve_advisor_tool(state, ctx)
if tool_name is None:
tool_name = tool_service.match_intent(state["agent_type"], state["user_message"])
if tool_name is None:
return {"tool_results": []}
spec = tool_service.get_registered_tool(tool_name)
if spec is None:
return {"tool_results": []}
if spec.get("requires_customer") and not state.get("customer_id"):
return {"tool_results": []}
# 带参 Tool(T21 知识库):spec 白名单声明了 "query" → 由代码注入用户消息
# (一期 tool_input 恒来自代码,不来自 LLM 输出,与 T-04 口径一致)
whitelist = tuple(spec.get("param_whitelist") or ())
tool_input = (
{"query": state["user_message"], "_context_window": ctx}
if "query" in whitelist
else None
)
record = tool_service.run_tool(
tool_name=tool_name,
agent_type=state["agent_type"],
actor=state["actor"],
customer_id=state.get("customer_id") or "",
tool_input=tool_input,
session_id=state["session_id"],
trace_id=state.get("trace_id"),
)
return {"tool_results": [record]}
def _degraded_reply(state: ChatState) -> str:
"""无 LLM key 时的降级回复(T-04:查询不白跑,摘要直出)。"""
summaries = [tool_service.summarize(r) for r in (state.get("tool_results") or [])]
if summaries:
return f"{_DEGRADED_PREFIX}\n" + "\n".join(summaries)
return f"{_DEGRADED_PREFIX}已收到您的消息:{state['user_message']}"
def llm_node(state: ChatState) -> dict[str, Any]:
"""组装 messages 后调 LLM;未配置 key 时降级(不抛异常,演示链路不断)。
降级回复携带 Tool 查询摘要(T-04:无 LLM 时查询不白跑,结果直出)。
"""
messages = _compose_messages(state)
if not settings.deepseek_api_key:
reply = _degraded_reply(state)
return {"messages": messages + [AIMessage(content=reply)], "reply": reply}
llm = _get_llm()
result = llm.invoke(messages)
return {"messages": messages + [result], "reply": result.content}
def needs_disclaimer(agent_type: str) -> bool:
"""是否需附免责声明(与 guard_node 同口径,未知类型 fail-safe 按最严)。
方案 C(SSE):流式下声明无法再拼在尾部——首帧 meta 先下发声明文本供
前端常驻,落库文本仍按 guard_node 口径拼尾部,两处判定共用此函数,
避免"首帧说有、落库说无"的口径漂移。
"""
return agent_type in ("customer", "risk") or agent_type not in _SYSTEM_PROMPTS
def guard_node(state: ChatState) -> dict[str, Any]:
"""合规护栏:对外角色(customer/risk,未知类型 fail-safe 按最严口径)附免责声明。"""
if needs_disclaimer(state["agent_type"]):
return {"reply": f"{state['reply']}\n\n{CHAT_DISCLAIMER}", "has_disclaimer": True}
return {"has_disclaimer": False}
def build_graph():
"""StateGraph:START → tool → llm → guard → END(T-04 接入 Tool 节点)。"""
graph = StateGraph(ChatState)
graph.add_node("tool", tool_node)
graph.add_node("llm", llm_node)
graph.add_node("guard", guard_node)
graph.add_edge(START, "tool")
graph.add_edge("tool", "llm")
graph.add_edge("llm", "guard")
graph.add_edge("guard", END)
return graph.compile()
_graph = None
_graph_lock = threading.Lock()
_llm: Any | None = None
_llm_lock = threading.Lock()
def _get_graph():
global _graph
with _graph_lock:
if _graph is None:
_graph = build_graph()
return _graph
def _get_llm() -> Any:
"""DeepSeek 经 langchain-openai 兼容接口(懒构造加锁;测试注入 _llm)。"""
global _llm
with _llm_lock:
if _llm is None:
from langchain_openai import ChatOpenAI
_llm = ChatOpenAI(
model="deepseek-chat",
api_key=settings.deepseek_api_key,
base_url=settings.deepseek_base_url,
temperature=0.3,
)
return _llm
def _base_state(
agent_type: str,
history: list[dict],
user_message: str,
*,
session_id: str | None,
trace_id: str | None,
actor: dict[str, Any] | None,
customer_id: str | None,
) -> ChatState:
"""图初始状态(chat 与 stream_chat 共用;Tool 上下文一致)。"""
return {
"agent_type": agent_type,
"history": history,
"user_message": user_message,
"messages": [],
"reply": "",
"has_disclaimer": False,
"session_id": session_id,
"trace_id": trace_id,
"actor": actor,
"customer_id": customer_id,
"tool_results": [],
}
def chat(
agent_type: str,
history: list[dict],
user_message: str,
*,
session_id: str | None = None,
trace_id: str | None = None,
actor: dict[str, Any] | None = None,
customer_id: str | None = None,
) -> dict[str, Any]:
"""对话入口(T-06 api/chat 调用):返回 {reply, has_disclaimer}。
T-04:session 上下文(session_id/trace_id/actor/customer_id)可选传入;
缺省时 Tool 节点空转(既有用例与纯闲聊不受影响)。
"""
final = _get_graph().invoke(
_base_state(
agent_type,
history,
user_message,
session_id=session_id,
trace_id=trace_id,
actor=actor,
customer_id=customer_id,
)
)
tool_results = final.get("tool_results") or []
catalog_reply = _reply_for_catalog_tool(tool_results)
if catalog_reply:
return {
"reply": catalog_reply,
"has_disclaimer": False,
"tool_results": tool_results,
"pending_trade": None,
}
if any(r.get("tool_name") == "prepare_simulate_trade" for r in tool_results):
return {
"reply": _reply_for_trade_tool(tool_results),
"has_disclaimer": final.get("has_disclaimer", False),
"tool_results": tool_results,
"pending_trade": _pending_trade_from_tool_results(tool_results),
}
return {
"reply": final["reply"],
"has_disclaimer": final["has_disclaimer"],
"tool_results": tool_results,
"pending_trade": _pending_trade_from_tool_results(tool_results),
}
def _pending_trade_from_tool_results(tool_results: list[dict]) -> dict[str, Any] | None:
for record in tool_results:
data = record.get("data")
if not isinstance(data, dict):
continue
if data.get("status") == "pending_confirmation":
pt = data.get("pending_trade")
if isinstance(pt, dict):
return pt
return None
def _reply_for_trade_tool(tool_results: list[dict]) -> str:
for record in tool_results:
data = record.get("data")
if isinstance(data, dict) and data.get("status") == "parse_failed":
return str(data.get("hint") or TRADE_PARSE_FAIL_HINT)
return TRADE_MODAL_HINT
def _reply_for_catalog_tool(tool_results: list[dict]) -> str | None:
for record in tool_results:
if record.get("tool_name") != "query_suitability_catalog":
continue
data = record.get("data")
if isinstance(data, dict):
text = (data.get("fact_text") or "").strip()
if text:
return text
return None
def stream_chat(
agent_type: str,
history: list[dict],
user_message: str,
*,
session_id: str | None = None,
trace_id: str | None = None,
actor: dict[str, Any] | None = None,
customer_id: str | None = None,
) -> Iterator[tuple[str, Any]]:
"""流式对话(方案 C):yield ("delta", 文本块)... → ("done", 完整正文)。
与 chat() 同口径:Tool 节点先同步跑完(落 agent_tool_call + 结果注入
上下文),再推 LLM 文本——Tool 不流式,因为要留痕且结果是 LLM 输入。
未配置 key 时降级整块输出(契约不变,前端无需特判)。
**落库由调用方(api/chat)在收完 done 后统一写**:中途异常/客户端断连
→ 整轮消息不落(Tool 留痕已落,可审计),不产生半截内容污染历史窗口。
异常上抛由路由层转 SSE error 事件,保证前端拿到的是结构化错误而非
断流。
"""
state = _base_state(
agent_type,
history,
user_message,
session_id=session_id,
trace_id=trace_id,
actor=actor,
customer_id=customer_id,
)
state["tool_results"] = tool_node(state).get("tool_results") or []
catalog_reply = _reply_for_catalog_tool(state["tool_results"])
if catalog_reply:
yield ("delta", catalog_reply)
yield ("done", catalog_reply)
return
pending_trade = _pending_trade_from_tool_results(state["tool_results"])
if pending_trade is not None or any(
(r.get("data") or {}).get("status") == "parse_failed"
for r in state["tool_results"]
if r.get("tool_name") == "prepare_simulate_trade"
):
reply = _reply_for_trade_tool(state["tool_results"])
if pending_trade is not None:
yield ("pending_trade", pending_trade)
yield ("delta", reply)
yield ("done", reply)
return
messages = _compose_messages(state)
if not settings.deepseek_api_key:
reply = _degraded_reply(state)
yield ("delta", reply)
yield ("done", reply)
return
llm = _get_llm()
buf: list[str] = []
for chunk in llm.stream(messages): # DeepSeek / OpenAI 兼容:逐 chunk 文本
text = getattr(chunk, "content", None) or ""
if text:
buf.append(text)
yield ("delta", text)
yield ("done", "".join(buf))
def reset_cache() -> None:
"""测试隔离出口:清空图与 LLM 单例缓存。"""
global _graph, _llm
with _graph_lock:
_graph = None
_llm = None