Files
group_xinghuo_jinrong/app/service/customer_service.py
T

690 lines
23 KiB
Python
Raw Normal View History

"""已注册客户 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 _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(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"], state["message"])
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 = CustomerMemoryService().as_prompt_text(state["session_id"], "consult")
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 = CustomerMemoryService().as_prompt_text(state["session_id"], "consult")
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 = CustomerMemoryService().as_prompt_text(state["session_id"], "chitchat")
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),
}