2026-09-11 11:02:15 +08:00
|
|
|
|
"""Anonymous customer-service orchestration without private customer access."""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
2026-09-13 18:46:05 +08:00
|
|
|
|
import logging
|
2026-09-11 11:02:15 +08:00
|
|
|
|
import re
|
|
|
|
|
|
from inspect import isawaitable
|
|
|
|
|
|
|
2026-09-13 18:46:05 +08:00
|
|
|
|
from rag.intent import Intent, IntentResult
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2026-09-11 11:02:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QueryTooLongError(ValueError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 20:48:42 +08:00
|
|
|
|
class DataQueryRejected(ValueError):
|
|
|
|
|
|
"""NL2SQL 数据查询被拒绝(未开放、无权限或配额不足),message 可直接回复用户。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 11:02:15 +08:00
|
|
|
|
async def _config(config_getter, key: str, default: str):
|
|
|
|
|
|
value = config_getter(key, default)
|
|
|
|
|
|
if isawaitable(value):
|
|
|
|
|
|
value = await value
|
|
|
|
|
|
return value or default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _maybe_await(value):
|
|
|
|
|
|
return await value if isawaitable(value) else value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AnonymousCustomerAgent:
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
context,
|
|
|
|
|
|
rag_retrieve,
|
|
|
|
|
|
intent_recognize,
|
|
|
|
|
|
generate_answer,
|
|
|
|
|
|
audit_writer,
|
|
|
|
|
|
config_getter,
|
2026-09-13 20:48:42 +08:00
|
|
|
|
data_query=None,
|
2026-09-11 11:02:15 +08:00
|
|
|
|
):
|
|
|
|
|
|
self.context = context
|
|
|
|
|
|
self.rag_retrieve = rag_retrieve
|
|
|
|
|
|
self.intent_recognize = intent_recognize
|
|
|
|
|
|
self.generate_answer = generate_answer
|
|
|
|
|
|
self.audit_writer = audit_writer
|
|
|
|
|
|
self.config_getter = config_getter
|
2026-09-13 20:48:42 +08:00
|
|
|
|
# 可选 NL2SQL 数据查询依赖:签名 data_query(*, question, customer_id,
|
|
|
|
|
|
# session_id, trace_id) -> dict;匿名 runtime 不装配(None),行为不变。
|
|
|
|
|
|
self.data_query = data_query
|
2026-09-11 11:02:15 +08:00
|
|
|
|
|
2026-09-13 20:48:42 +08:00
|
|
|
|
async def handle(
|
|
|
|
|
|
self,
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
query: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
trace_id: str,
|
|
|
|
|
|
customer_id: int | None = None,
|
|
|
|
|
|
) -> dict:
|
2026-09-11 11:02:15 +08:00
|
|
|
|
if len(query) > 2000:
|
|
|
|
|
|
raise QueryTooLongError("query长度不能超过2000字符")
|
2026-09-13 18:46:05 +08:00
|
|
|
|
# 先取历史再写入当前问题,保证意图识别拿到的历史不含本轮输入;取不到历史不阻断请求
|
|
|
|
|
|
try:
|
|
|
|
|
|
history = await self.context.get(session_id)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.exception("load conversation history failed: session_id=%s", session_id)
|
|
|
|
|
|
history = []
|
2026-09-11 11:02:15 +08:00
|
|
|
|
await self.context.append(session_id, "user", query)
|
|
|
|
|
|
if self._contains_sensitive_input(query):
|
|
|
|
|
|
await _maybe_await(self.audit_writer(
|
|
|
|
|
|
action="anon_sensitive_input",
|
|
|
|
|
|
trace_id=trace_id,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
))
|
|
|
|
|
|
|
2026-09-13 18:46:05 +08:00
|
|
|
|
recognized = await _maybe_await(self.intent_recognize(query, history))
|
|
|
|
|
|
if isinstance(recognized, IntentResult):
|
|
|
|
|
|
intent, search_query = recognized.intent, recognized.query
|
|
|
|
|
|
else:
|
|
|
|
|
|
intent, search_query = recognized, query
|
2026-09-11 11:02:15 +08:00
|
|
|
|
sources = []
|
2026-09-13 20:48:42 +08:00
|
|
|
|
data_query_meta = None
|
2026-09-12 15:06:34 +08:00
|
|
|
|
if intent == Intent.GUIDE_PURCHASE:
|
2026-09-11 11:02:15 +08:00
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.guide_purchase",
|
|
|
|
|
|
"请前往开户页面办理。",
|
|
|
|
|
|
)
|
2026-09-12 15:06:34 +08:00
|
|
|
|
elif intent == Intent.WANT_ADVISOR:
|
2026-09-11 11:02:15 +08:00
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.guide_advisor",
|
|
|
|
|
|
"如需基金推荐,请联系投资顾问。",
|
|
|
|
|
|
)
|
2026-09-12 15:06:34 +08:00
|
|
|
|
elif intent == Intent.OFF_TOPIC:
|
|
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.off_topic",
|
|
|
|
|
|
"我是华夏科技的智能客服,只能解答基金与公司业务相关的问题,"
|
|
|
|
|
|
"您可以问我基金知识、开户流程或公司信息~",
|
|
|
|
|
|
)
|
2026-09-15 11:42:26 +08:00
|
|
|
|
elif intent == Intent.TRANSFER_HUMAN:
|
|
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.transfer_human",
|
|
|
|
|
|
"好的,正在为您转接人工客服,请稍候。"
|
|
|
|
|
|
"您也可以拨打官方客服热线 400-XXX-XXXX 直接联系我们。",
|
|
|
|
|
|
)
|
2026-09-12 15:06:34 +08:00
|
|
|
|
elif intent == Intent.CHITCHAT:
|
|
|
|
|
|
answer = await self._chitchat(session_id)
|
2026-09-13 20:48:42 +08:00
|
|
|
|
elif intent == Intent.NL2SQL_REQUEST:
|
|
|
|
|
|
if self.data_query is None or customer_id is None:
|
|
|
|
|
|
# 匿名会话或未装配数据查询能力:引导登录,不触发任何数据库查询
|
|
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.nl2sql_unavailable",
|
|
|
|
|
|
"数据查询功能需要登录后使用,请先登录再来问我您的持仓和交易信息~",
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
answer, sources, data_query_meta = await self._run_data_query(
|
|
|
|
|
|
question=search_query,
|
|
|
|
|
|
customer_id=customer_id,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
trace_id=trace_id,
|
|
|
|
|
|
)
|
2026-09-12 15:06:34 +08:00
|
|
|
|
elif intent in (Intent.KNOWLEDGE_QA, Intent.COMPANY_INFO):
|
2026-09-11 11:02:15 +08:00
|
|
|
|
try:
|
2026-09-13 18:46:05 +08:00
|
|
|
|
# 用补全指代后的问题检索,省略主语的追问才能命中
|
|
|
|
|
|
sources = await _maybe_await(self.rag_retrieve(search_query, None))
|
2026-09-11 11:02:15 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
sources = []
|
|
|
|
|
|
if not sources:
|
|
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.fallback_human",
|
|
|
|
|
|
"当前未找到匹配信息,请转人工客服。",
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
messages = await self.context.get(session_id)
|
|
|
|
|
|
prompt = messages + [
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "system",
|
|
|
|
|
|
"content": "仅根据提供的知识来源回答,不得编造基金推荐。",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "system",
|
|
|
|
|
|
"content": f"知识来源:{json.dumps(sources, ensure_ascii=False)}",
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
try:
|
|
|
|
|
|
answer = await _maybe_await(self.generate_answer(prompt))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.fallback_human",
|
|
|
|
|
|
"当前服务繁忙,请转人工客服。",
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.fallback_human",
|
|
|
|
|
|
"当前未找到匹配信息,请转人工客服。",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await self.context.append(session_id, "assistant", answer)
|
2026-09-13 20:48:42 +08:00
|
|
|
|
result = {
|
2026-09-11 11:02:15 +08:00
|
|
|
|
"answer": answer,
|
|
|
|
|
|
"sources": sources,
|
|
|
|
|
|
"intent": intent.value,
|
2026-09-13 18:46:05 +08:00
|
|
|
|
"rewritten_query": search_query,
|
2026-09-11 11:02:15 +08:00
|
|
|
|
"trace_id": trace_id,
|
|
|
|
|
|
}
|
2026-09-13 20:48:42 +08:00
|
|
|
|
if data_query_meta is not None:
|
|
|
|
|
|
result["data_query"] = data_query_meta
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
async def _run_data_query(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
question: str,
|
|
|
|
|
|
customer_id: int,
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
trace_id: str,
|
|
|
|
|
|
) -> tuple[str, list, dict]:
|
|
|
|
|
|
"""调用注入的 NL2SQL 数据查询能力,失败时统一降级为客服话术。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = await _maybe_await(
|
|
|
|
|
|
self.data_query(
|
|
|
|
|
|
question=question,
|
|
|
|
|
|
customer_id=customer_id,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
trace_id=trace_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
except DataQueryRejected as exc:
|
|
|
|
|
|
return str(exc), [], None
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.exception(
|
|
|
|
|
|
"client data query failed: trace_id=%s session_id=%s customer_id=%s",
|
|
|
|
|
|
trace_id,
|
|
|
|
|
|
session_id,
|
|
|
|
|
|
customer_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
answer = await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.nl2sql_fallback",
|
|
|
|
|
|
"暂时无法完成数据查询,请稍后再试或联系人工客服。",
|
|
|
|
|
|
)
|
|
|
|
|
|
return answer, [], None
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(payload, dict) or not str(payload.get("answer") or "").strip():
|
|
|
|
|
|
return await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.nl2sql_fallback",
|
|
|
|
|
|
"暂时无法完成数据查询,请稍后再试或联系人工客服。",
|
|
|
|
|
|
), [], None
|
|
|
|
|
|
meta = {
|
|
|
|
|
|
key: payload[key]
|
|
|
|
|
|
for key in ("query_id", "row_count", "truncated", "chart")
|
|
|
|
|
|
if payload.get(key) is not None
|
|
|
|
|
|
}
|
|
|
|
|
|
return str(payload["answer"]), list(payload.get("sources") or []), meta or None
|
2026-09-11 11:02:15 +08:00
|
|
|
|
|
2026-09-12 15:06:34 +08:00
|
|
|
|
async def _chitchat(self, session_id: str) -> str:
|
|
|
|
|
|
"""带对话历史调用 LLM 做受限闲聊,失败时退回固定话术。"""
|
|
|
|
|
|
messages = await self.context.get(session_id)
|
|
|
|
|
|
prompt = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "system",
|
|
|
|
|
|
"content": (
|
|
|
|
|
|
"你是华夏科技(基金代销金融机构)的智能客服助手。"
|
|
|
|
|
|
"用户正在与你寒暄,请用一两句话简短、友好地回应,"
|
|
|
|
|
|
"并自然地引导用户咨询基金知识、开户流程或公司信息。"
|
|
|
|
|
|
"不得推荐任何基金产品,不得谈论具体收益,不得回答金融之外的实质性问题。"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
*messages,
|
|
|
|
|
|
]
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await _maybe_await(self.generate_answer(prompt))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return await _config(
|
|
|
|
|
|
self.config_getter,
|
|
|
|
|
|
"agent.customer.template.chitchat_fallback",
|
|
|
|
|
|
"您好,我是华夏科技的智能客服,很高兴为您服务!"
|
|
|
|
|
|
"您可以问我基金知识、开户流程或公司信息~",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-11 11:02:15 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _contains_sensitive_input(query: str) -> bool:
|
|
|
|
|
|
return bool(
|
|
|
|
|
|
re.search(r"(?<!\d)1[3-9]\d{9}(?!\d)", query)
|
|
|
|
|
|
or re.search(r"(?:客户|customer)[_ -]?(?:id|号)?\s*[::]?\s*[A-Za-z0-9_-]{4,}", query, re.I)
|
2026-09-15 11:42:26 +08:00
|
|
|
|
)
|