- Implemented `_merged_items` and `_merged_memory_text` functions to consolidate consult and chitchat memories, improving context awareness in intent classification and response generation. - Updated intent prompts to include recent dialogue history, aiding in the resolution of ambiguous user queries. - Enhanced `search_knowledge` tool to utilize context window for better query understanding, addressing issues with omitted references in user inputs. - Fixed existing test cases to reflect changes in intent constants and ensure accurate context handling during tests. This update significantly improves the handling of multi-turn dialogues, ensuring a more coherent and contextually aware interaction for users.
724 lines
25 KiB
Python
724 lines
25 KiB
Python
"""已注册客户 Agent 编排:LangGraph 14 节点 + DeepSeek(CS Wave 3)。
|
||
|
||
流程(方案 §5.2):
|
||
recall_memory → intent_classify
|
||
├─ RAG 3 类 → rag_search → generate
|
||
├─ 数据查询 4 类 → param_extract → tool_call → interpret
|
||
├─ chit_chat → chitchat
|
||
└─ reject / transfer_human / fallback → 静态话术
|
||
→ save_memory → profile_maybe_extract(每 5 轮节流)→ archive_check(显式结束/懒扫描超时)→ END
|
||
|
||
铁律:
|
||
1. 数值不经过 LLM 生成:数据查询结果 100% 来自 core_ro_tool 的 fact_text,LLM 只解读;
|
||
2. customer_id 一律来自 JWT 解析(chat.py 已 resolve),不取用户消息中的任何客户号;
|
||
3. 画像抽槽与归档在后台线程执行,失败不影响对话响应。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any, TypedDict
|
||
|
||
from langgraph.graph import END, StateGraph
|
||
|
||
from app.config.settings import settings
|
||
from app.model.schemas import AuthContext
|
||
from app.service.customer_prompts import (
|
||
CHITCHAT_DEGRADED_TEXT,
|
||
CHITCHAT_SYSTEM,
|
||
CHITCHAT_USER_TEMPLATE,
|
||
DATA_ERROR_TEXT,
|
||
FALLBACK_TEXT,
|
||
GENERATE_SYSTEM,
|
||
GENERATE_USER_TEMPLATE,
|
||
INTENT_SYSTEM,
|
||
INTENT_USER_TEMPLATE,
|
||
INTERPRET_SYSTEM,
|
||
INTERPRET_USER_TEMPLATE,
|
||
NOTE_SAVED_TEXT,
|
||
PARAM_EXTRACT_SYSTEM,
|
||
PARAM_EXTRACT_USER_TEMPLATE,
|
||
REJECT_BY_KIND,
|
||
REJECT_GENERAL_TEXT,
|
||
TRANSFER_TEXT,
|
||
VALID_INTENTS,
|
||
keyword_route,
|
||
)
|
||
from app.service.note_service import (
|
||
render_notes_context,
|
||
save_note_from_message,
|
||
)
|
||
from app.service.profile_service import (
|
||
CustomerMemoryService,
|
||
ProfileHotCache,
|
||
_spawn,
|
||
archive_idle_sessions,
|
||
archive_session,
|
||
extract_profile,
|
||
memory_kind_for,
|
||
parse_llm_json,
|
||
record_intent,
|
||
render_profile_context,
|
||
)
|
||
from app.service.rag_service import VisitorRagService
|
||
from app.tool.core_ro_tool import (
|
||
query_holdings,
|
||
query_product_nav,
|
||
query_risk_profile,
|
||
query_suitability,
|
||
query_trades,
|
||
)
|
||
from app.utils.compliance_guard import RISK_DISCLAIMER, should_add_disclaimer
|
||
from app.service.trade_action_service import (
|
||
TRADE_MODAL_HINT,
|
||
TRADE_PARSE_FAIL_HINT,
|
||
pending_trade_from_draft,
|
||
)
|
||
from app.service.trade_action_service import should_use_suitability_instead_of_trade
|
||
from app.service.trade_flow_service import (
|
||
clear_trade_flow,
|
||
process_trade_turn,
|
||
trade_dialogue_should_continue,
|
||
)
|
||
from app.utils.sanitize_postprocess import finalize_sanitized_reply
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# LLM 调用
|
||
# ---------------------------------------------------------------------------
|
||
|
||
from langchain_openai import ChatOpenAI
|
||
|
||
|
||
def _build_llm() -> ChatOpenAI:
|
||
return ChatOpenAI(
|
||
model=settings.deepseek_model,
|
||
api_key=settings.deepseek_api_key,
|
||
base_url=settings.deepseek_base_url,
|
||
temperature=settings.deepseek_temperature,
|
||
max_tokens=settings.deepseek_max_tokens,
|
||
)
|
||
|
||
|
||
def _invoke(system: str, user: str) -> str:
|
||
llm = _build_llm()
|
||
resp = llm.invoke([
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": user},
|
||
])
|
||
content = resp if isinstance(resp, str) else resp.content
|
||
return content or ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# LangGraph State
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class CustomerState(TypedDict):
|
||
session_id: str
|
||
trace_id: str
|
||
customer_id: str
|
||
message: str
|
||
end_session: bool
|
||
intent: str
|
||
reject_kind: str
|
||
chitchat_memory: list[dict]
|
||
consult_memory: list[dict]
|
||
profile_context: str
|
||
notes_context: str
|
||
params: dict
|
||
tool_result: dict | None
|
||
rag_context: str
|
||
rag_sources: list[dict]
|
||
reply: str
|
||
has_disclaimer: bool
|
||
transfer_to_human: bool
|
||
pending_trade: dict | None
|
||
|
||
|
||
_TOOL_BY_INTENT = {
|
||
"holding_query": query_holdings,
|
||
"transaction_query": query_trades,
|
||
"risk_assessment_query": query_risk_profile,
|
||
"suitability_check": query_suitability,
|
||
"nav_query": query_product_nav,
|
||
}
|
||
|
||
_DATA_QUERY_INTENTS = frozenset(_TOOL_BY_INTENT.keys())
|
||
|
||
_R_LEVEL_FULL_RE = re.compile(r"[Rr]\s*([1-5])")
|
||
|
||
|
||
def _session_memory_context(state: CustomerState) -> str:
|
||
"""合并 consult/chitchat 短期记忆,供交易续轮与意图判断。"""
|
||
merged: list[dict] = []
|
||
merged.extend(state.get("consult_memory") or [])
|
||
merged.extend(state.get("chitchat_memory") or [])
|
||
lines: list[str] = []
|
||
for item in merged[-16:]:
|
||
role = item.get("role") or "user"
|
||
content = (item.get("content") or "").strip()
|
||
if content:
|
||
lines.append(f"{role}: {content}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _merged_items(state: CustomerState) -> list[dict]:
|
||
"""consult + chitchat 记忆按 ts 合并(recall_memory 已载入 state)。"""
|
||
items: list[dict] = []
|
||
items.extend(state.get("consult_memory") or [])
|
||
items.extend(state.get("chitchat_memory") or [])
|
||
items.sort(key=lambda m: m.get("ts", 0))
|
||
return items
|
||
|
||
|
||
def _merged_memory_prompt(state: CustomerState, max_items: int = 16) -> str:
|
||
"""合并两类近期对话为「用户/客服」文本(供意图/生成/解读/闲聊 prompt)。
|
||
|
||
与 _session_memory_context(英文 role 标签,供交易续轮)并存;数据源为
|
||
recall_memory 已载入的 state 记忆,不重复回源 Redis。
|
||
"""
|
||
lines: list[str] = []
|
||
for msg in _merged_items(state)[-max_items:]:
|
||
role = "用户" if msg.get("role") == "user" else "客服"
|
||
content = (msg.get("content") or "").strip()
|
||
if content:
|
||
lines.append(f"{role}: {content}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _rag_query(state: CustomerState, max_items: int = 6) -> str:
|
||
"""历史感知检索 query:拼最近几轮原始内容,解决「那申购呢」类省略指代。"""
|
||
msg = state["message"]
|
||
recent = [(m.get("content") or "").strip() for m in _merged_items(state)[-max_items:]]
|
||
recent = [c for c in recent if c]
|
||
if not recent:
|
||
return msg
|
||
return "\n".join(recent) + "\n" + msg
|
||
|
||
|
||
def _trade_context_window(state: CustomerState) -> str:
|
||
"""交易续轮上下文:Redis 双线记忆 + MySQL 会话落库(与 chat.insert_turn 对齐)。
|
||
|
||
仅依赖 Redis 时,若 Redis 未写入/过期而前端仍展示 MySQL 历史,回复「1」会误判 fallback。
|
||
"""
|
||
ctx = _session_memory_context(state)
|
||
if ctx.strip():
|
||
return ctx
|
||
sid = state.get("session_id") or ""
|
||
if not sid:
|
||
return ctx
|
||
try:
|
||
ctx = CustomerMemoryService().recall_window(sid)
|
||
except Exception:
|
||
ctx = ""
|
||
if ctx.strip():
|
||
return ctx
|
||
try:
|
||
from app.repository.session_repository import SessionRepository
|
||
|
||
msgs = SessionRepository().list_messages(sid, limit=16)
|
||
lines: list[str] = []
|
||
for m in msgs:
|
||
role = m.get("role") or "user"
|
||
lines.append(f"{role}: {(m.get('content') or '').strip()}")
|
||
return "\n".join(lines)
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 1:读取短期记忆 + 画像热缓存
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def recall_memory(state: CustomerState) -> CustomerState:
|
||
sid, cid = state["session_id"], state["customer_id"]
|
||
mem = CustomerMemoryService()
|
||
profile_context = ""
|
||
notes_context = ""
|
||
try:
|
||
if cid:
|
||
profile_context = render_profile_context(ProfileHotCache().get_style_tags_lazy(cid))
|
||
notes_context = render_notes_context(cid)
|
||
chitchat_memory = mem.recall(sid, "chitchat")
|
||
consult_memory = mem.recall(sid, "consult")
|
||
except Exception:
|
||
chitchat_memory = []
|
||
consult_memory = []
|
||
return {
|
||
"chitchat_memory": chitchat_memory,
|
||
"consult_memory": consult_memory,
|
||
"profile_context": profile_context,
|
||
"notes_context": notes_context,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 2:意图分类(关键词快路由优先,未命中 DeepSeek)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def intent_classify(state: CustomerState) -> CustomerState:
|
||
msg = state["message"]
|
||
|
||
sid = state.get("session_id") or ""
|
||
mem_ctx = _trade_context_window(state)
|
||
if should_use_suitability_instead_of_trade(msg):
|
||
clear_trade_flow(sid)
|
||
elif trade_dialogue_should_continue(sid, msg, mem_ctx):
|
||
return {"intent": "trade_action"}
|
||
|
||
hit = keyword_route(msg)
|
||
if hit:
|
||
intent, kind = hit
|
||
if intent == "reject":
|
||
return {"intent": "reject", "reject_kind": kind}
|
||
return {"intent": intent}
|
||
|
||
try:
|
||
content = _invoke(INTENT_SYSTEM, INTENT_USER_TEMPLATE.format(memory=_merged_memory_prompt(state), message=msg))
|
||
intent = content.strip().lower()
|
||
if intent not in VALID_INTENTS:
|
||
intent = "fallback"
|
||
except Exception:
|
||
intent = "fallback"
|
||
return {"intent": intent}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 3:RAG 检索(RAG 3 类)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def rag_search(state: CustomerState) -> CustomerState:
|
||
try:
|
||
context, sources = VisitorRagService().retrieve(state["intent"], _rag_query(state))
|
||
except Exception:
|
||
context, sources = "", []
|
||
|
||
if not context:
|
||
return {"rag_context": "", "rag_sources": [], "reply": FALLBACK_TEXT, "intent": "fallback"}
|
||
return {"rag_context": context, "rag_sources": sources}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 4:参数抽取(仅 transaction_query / suitability_check 需要)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def param_extract(state: CustomerState) -> CustomerState:
|
||
intent = state["intent"]
|
||
if intent not in ("transaction_query", "suitability_check", "nav_query"):
|
||
return {"params": {}}
|
||
|
||
try:
|
||
content = _invoke(
|
||
PARAM_EXTRACT_SYSTEM,
|
||
PARAM_EXTRACT_USER_TEMPLATE.format(message=state["message"]),
|
||
)
|
||
data = parse_llm_json(content) or {}
|
||
except Exception:
|
||
return {"params": {}}
|
||
|
||
params: dict = {}
|
||
months = data.get("months")
|
||
if months is not None:
|
||
try:
|
||
params["months"] = max(1, min(36, int(months)))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
kw = data.get("product_keyword")
|
||
if isinstance(kw, str) and kw.strip():
|
||
params["product_keyword"] = kw.strip()[:32]
|
||
rl = data.get("risk_level")
|
||
if isinstance(rl, str):
|
||
m = _R_LEVEL_FULL_RE.fullmatch(rl.strip())
|
||
if m:
|
||
params["risk_level"] = f"R{m.group(1)}"
|
||
return {"params": params}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 5:Core 只读工具调用(customer_id 强制来自 state,即 JWT 解析值)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def tool_call(state: CustomerState) -> CustomerState:
|
||
cid = state["customer_id"]
|
||
intent = state["intent"]
|
||
params = state.get("params") or {}
|
||
fn = _TOOL_BY_INTENT[intent]
|
||
|
||
try:
|
||
if intent == "transaction_query":
|
||
result = fn(cid, months=params.get("months"))
|
||
elif intent == "suitability_check":
|
||
result = fn(
|
||
cid,
|
||
product_keyword=params.get("product_keyword"),
|
||
risk_level=params.get("risk_level"),
|
||
user_message=state.get("message"),
|
||
)
|
||
elif intent == "nav_query":
|
||
result = fn(cid, product_keyword=params.get("product_keyword"))
|
||
else:
|
||
result = fn(cid)
|
||
except Exception:
|
||
return {"tool_result": None, "reply": DATA_ERROR_TEXT}
|
||
|
||
result = dict(result)
|
||
if not result.get("ok"):
|
||
return {"tool_result": result, "reply": DATA_ERROR_TEXT}
|
||
return {"tool_result": result}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 6:工具结果解读(数值原样引用,铁律在 INTERPRET_SYSTEM)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def interpret(state: CustomerState) -> CustomerState:
|
||
tr = state.get("tool_result") or {}
|
||
if not tr.get("ok"):
|
||
return {"reply": state.get("reply") or DATA_ERROR_TEXT}
|
||
fact_text = tr.get("fact_text", "")
|
||
if not fact_text:
|
||
return {"reply": DATA_ERROR_TEXT}
|
||
|
||
# 产品选购清单含序号,禁止 LLM 改写为「能买几只」类汇总
|
||
if "请回复上方序号" in fact_text and "【R" in fact_text:
|
||
reply = fact_text
|
||
reply, need_transfer = finalize_sanitized_reply(
|
||
reply, intent=state.get("intent"), fact_text=fact_text,
|
||
)
|
||
if need_transfer:
|
||
return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False}
|
||
return {"reply": reply, "has_disclaimer": False}
|
||
|
||
try:
|
||
mem = _merged_memory_prompt(state)
|
||
content = _invoke(
|
||
INTERPRET_SYSTEM,
|
||
INTERPRET_USER_TEMPLATE.format(
|
||
fact_text=fact_text,
|
||
notes_context=state.get("notes_context", ""),
|
||
memory=mem,
|
||
message=state["message"],
|
||
),
|
||
)
|
||
reply = content.strip() or fact_text
|
||
except Exception:
|
||
reply = fact_text # LLM 不可用时直接返回脱敏事实文本
|
||
|
||
reply, need_transfer = finalize_sanitized_reply(
|
||
reply, intent=state.get("intent"), fact_text=fact_text,
|
||
)
|
||
if need_transfer:
|
||
return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False}
|
||
return {"reply": reply, "has_disclaimer": False}
|
||
|
||
def generate(state: CustomerState) -> CustomerState:
|
||
if not state.get("rag_context"):
|
||
return {"reply": state.get("reply") or FALLBACK_TEXT, "has_disclaimer": False}
|
||
|
||
try:
|
||
mem = _merged_memory_prompt(state)
|
||
content = _invoke(
|
||
GENERATE_SYSTEM,
|
||
GENERATE_USER_TEMPLATE.format(
|
||
rag_context=state["rag_context"],
|
||
profile_context=state.get("profile_context", ""),
|
||
memory=mem,
|
||
message=state["message"],
|
||
),
|
||
)
|
||
reply = content.strip()
|
||
if not reply:
|
||
return {"reply": FALLBACK_TEXT, "intent": "fallback"}
|
||
except Exception:
|
||
return {"reply": FALLBACK_TEXT, "intent": "fallback"}
|
||
|
||
reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent"))
|
||
if need_transfer:
|
||
return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False}
|
||
|
||
has_disclaimer = should_add_disclaimer(state["intent"])
|
||
if has_disclaimer:
|
||
reply = f"{reply}\n\n{RISK_DISCLAIMER}"
|
||
return {"reply": reply, "has_disclaimer": has_disclaimer}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 8:闲聊(注入画像语境)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def chitchat(state: CustomerState) -> CustomerState:
|
||
try:
|
||
mem = _merged_memory_prompt(state)
|
||
content = _invoke(
|
||
CHITCHAT_SYSTEM,
|
||
CHITCHAT_USER_TEMPLATE.format(
|
||
profile_context=state.get("profile_context", ""),
|
||
notes_context=state.get("notes_context", ""),
|
||
memory=mem,
|
||
message=state["message"],
|
||
),
|
||
)
|
||
reply = content.strip()
|
||
if not reply:
|
||
return {"reply": CHITCHAT_DEGRADED_TEXT, "intent": "chit_chat"}
|
||
except Exception:
|
||
return {"reply": CHITCHAT_DEGRADED_TEXT, "intent": "chit_chat"}
|
||
|
||
reply, need_transfer = finalize_sanitized_reply(reply, intent=state.get("intent"))
|
||
if need_transfer:
|
||
return {"reply": reply, "transfer_to_human": True, "has_disclaimer": False}
|
||
return {"reply": reply, "has_disclaimer": False}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 9~11:静态话术
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def reject(state: CustomerState) -> CustomerState:
|
||
kind = state.get("reject_kind") or ""
|
||
return {"reply": REJECT_BY_KIND.get(kind, REJECT_GENERAL_TEXT)}
|
||
|
||
|
||
def transfer_human(state: CustomerState) -> CustomerState:
|
||
return {"reply": TRANSFER_TEXT, "transfer_to_human": True}
|
||
|
||
|
||
def fallback(state: CustomerState) -> CustomerState:
|
||
return {"reply": FALLBACK_TEXT}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 11:显式备注(用户主动要求记忆;调 LLM 抽取 → 写库 → 固定回复)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def save_note(state: CustomerState) -> CustomerState:
|
||
cid = state.get("customer_id") or ""
|
||
sid = state.get("session_id") or ""
|
||
trace_id = state.get("trace_id") or ""
|
||
try:
|
||
_content, _cat, reply = save_note_from_message(
|
||
cid, sid, trace_id, state["message"]
|
||
)
|
||
except Exception:
|
||
reply = NOTE_SAVED_TEXT
|
||
return {"reply": reply}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 12:保存短期记忆(Redis 双线;MySQL 落库由 chat.py 负责)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def trade_prepare(state: CustomerState) -> CustomerState:
|
||
"""交易意图:只生成 pending_trade,不调网关。"""
|
||
cid = state["customer_id"]
|
||
sid = state.get("session_id") or ""
|
||
mem_ctx = _trade_context_window(state)
|
||
draft = process_trade_turn(sid, cid, state["message"], mem_ctx)
|
||
if not draft.get("ok"):
|
||
return {
|
||
"pending_trade": None,
|
||
"reply": draft.get("hint") or TRADE_PARSE_FAIL_HINT,
|
||
"has_disclaimer": False,
|
||
}
|
||
return {
|
||
"pending_trade": pending_trade_from_draft(draft),
|
||
"reply": TRADE_MODAL_HINT,
|
||
"has_disclaimer": False,
|
||
}
|
||
|
||
|
||
def save_memory(state: CustomerState) -> CustomerState:
|
||
intent = state.get("intent", "fallback")
|
||
kind = memory_kind_for(intent)
|
||
try:
|
||
mem = CustomerMemoryService()
|
||
mem.append(state["session_id"], kind, "user", state["message"])
|
||
mem.append(state["session_id"], kind, "assistant", state["reply"])
|
||
if state.get("customer_id"):
|
||
record_intent(state["customer_id"], intent)
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 13:画像抽槽节流(每 5 轮触发,后台线程)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def profile_maybe_extract(state: CustomerState) -> CustomerState:
|
||
sid, cid = state["session_id"], state["customer_id"]
|
||
if not sid or not cid:
|
||
return {}
|
||
try:
|
||
from app.config.database import get_redis_client
|
||
|
||
r = get_redis_client()
|
||
key = f"customer:{sid}:rounds"
|
||
count = r.incr(key)
|
||
r.expire(key, settings.customer_session_ttl)
|
||
if count >= settings.profile_extract_every_rounds:
|
||
r.delete(key)
|
||
window = CustomerMemoryService().recall_window(sid)
|
||
_spawn(extract_profile, cid, window, state["trace_id"])
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点 14:归档检查(显式结束 + 懒扫描超时会话,后台线程)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def archive_check(state: CustomerState) -> CustomerState:
|
||
trace_id = state["trace_id"]
|
||
sid, cid = state["session_id"], state["customer_id"]
|
||
|
||
if state.get("end_session") and sid and cid:
|
||
_spawn(archive_session, sid, cid, trace_id, "explicit")
|
||
|
||
# 懒扫描:顺带归档其他空闲超时的客户会话(不含当前会话)
|
||
_spawn(archive_idle_sessions, trace_id, sid)
|
||
return {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 图构建
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_graph():
|
||
g = StateGraph(CustomerState)
|
||
|
||
g.add_node("recall_memory", recall_memory)
|
||
g.add_node("intent_classify", intent_classify)
|
||
g.add_node("rag_search", rag_search)
|
||
g.add_node("param_extract", param_extract)
|
||
g.add_node("tool_call", tool_call)
|
||
g.add_node("interpret", interpret)
|
||
g.add_node("generate", generate)
|
||
g.add_node("chitchat", chitchat)
|
||
g.add_node("reject", reject)
|
||
g.add_node("transfer_human", transfer_human)
|
||
g.add_node("fallback", fallback)
|
||
g.add_node("save_note", save_note)
|
||
g.add_node("trade_prepare", trade_prepare)
|
||
g.add_node("save_memory", save_memory)
|
||
g.add_node("profile_maybe_extract", profile_maybe_extract)
|
||
g.add_node("archive_check", archive_check)
|
||
|
||
g.set_entry_point("recall_memory")
|
||
g.add_edge("recall_memory", "intent_classify")
|
||
|
||
def _route(state: CustomerState) -> str:
|
||
intent = state["intent"]
|
||
if intent == "trade_action":
|
||
return "trade_prepare"
|
||
if intent in _TOOL_BY_INTENT:
|
||
return "param_extract"
|
||
if intent in ("product_consult", "policy_interpret", "faq"):
|
||
return "rag_search"
|
||
if intent == "chit_chat":
|
||
return "chitchat"
|
||
if intent == "reject":
|
||
return "reject"
|
||
if intent == "transfer_human":
|
||
return "transfer_human"
|
||
if intent == "save_note":
|
||
return "save_note"
|
||
return "fallback"
|
||
|
||
g.add_conditional_edges("intent_classify", _route, {
|
||
"trade_prepare": "trade_prepare",
|
||
"param_extract": "param_extract",
|
||
"rag_search": "rag_search",
|
||
"chitchat": "chitchat",
|
||
"reject": "reject",
|
||
"transfer_human": "transfer_human",
|
||
"save_note": "save_note",
|
||
"fallback": "fallback",
|
||
})
|
||
|
||
g.add_edge("param_extract", "tool_call")
|
||
g.add_edge("tool_call", "interpret")
|
||
g.add_edge("rag_search", "generate")
|
||
g.add_edge("trade_prepare", "save_memory")
|
||
for node in ("generate", "interpret", "chitchat", "reject", "transfer_human", "fallback", "save_note"):
|
||
g.add_edge(node, "save_memory")
|
||
g.add_edge("save_memory", "profile_maybe_extract")
|
||
g.add_edge("profile_maybe_extract", "archive_check")
|
||
g.add_edge("archive_check", END)
|
||
return g.compile()
|
||
|
||
|
||
_GRAPH = _build_graph()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 编排入口(chat.py 按 X-Agent-Type: customer 分流到此处)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def run_customer_chat(
|
||
ctx: AuthContext,
|
||
message: str,
|
||
session_id: str,
|
||
customer_id: str,
|
||
end_session: bool = False,
|
||
) -> tuple[str, bool, str, bool]:
|
||
"""客户对话入口,返回 (reply, has_disclaimer, intent, transfer_to_human)。"""
|
||
state: CustomerState = {
|
||
"session_id": session_id,
|
||
"trace_id": ctx.trace_id,
|
||
"customer_id": customer_id,
|
||
"message": message,
|
||
"end_session": end_session,
|
||
"intent": "",
|
||
"reject_kind": "",
|
||
"chitchat_memory": [],
|
||
"consult_memory": [],
|
||
"profile_context": "",
|
||
"params": {},
|
||
"tool_result": None,
|
||
"rag_context": "",
|
||
"rag_sources": [],
|
||
"reply": "",
|
||
"has_disclaimer": False,
|
||
"transfer_to_human": False,
|
||
"pending_trade": None,
|
||
}
|
||
result = _GRAPH.invoke(state)
|
||
return (
|
||
result["reply"],
|
||
result.get("has_disclaimer", False),
|
||
result.get("intent", "fallback"),
|
||
result.get("transfer_to_human", False),
|
||
result.get("pending_trade"),
|
||
)
|
||
|
||
|
||
def _chunk_reply_text(reply: str) -> list[str]:
|
||
"""将完整回复切成 SSE 块(图跑完后推送,Tool/RAG 仍同步完成)。"""
|
||
if not reply:
|
||
return []
|
||
step = max(1, len(reply) // 16)
|
||
return [reply[i : i + step] for i in range(0, len(reply), step)]
|
||
|
||
|
||
def prepare_customer_stream(
|
||
ctx: AuthContext,
|
||
message: str,
|
||
session_id: str,
|
||
customer_id: str,
|
||
end_session: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""客户流式:先跑完 LangGraph,再按块推送(与客服线同步路径同编排)。"""
|
||
reply, has_disclaimer, intent, transfer, pending_trade = run_customer_chat(
|
||
ctx, message, session_id, customer_id, end_session
|
||
)
|
||
return {
|
||
"reply": reply,
|
||
"has_disclaimer": has_disclaimer,
|
||
"intent": intent,
|
||
"transfer_to_human": transfer,
|
||
"pending_trade": pending_trade,
|
||
"chunks": _chunk_reply_text(reply),
|
||
}
|