- 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.
434 lines
16 KiB
Python
434 lines
16 KiB
Python
"""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
|