Files
group_xinghuo_jinrong/app/service/customer_service.py
T
zhanghongyu_0626 7dadec279d fix(memory): Address multi-turn dialogue defects and enhance context handling
- 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.
2026-09-14 00:59:14 +08:00

724 lines
25 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 编排: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),
}