Files
Mutual_Fund/service/customer_agent/chat.py
T

258 lines
9.9 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.
"""Anonymous customer-service orchestration without private customer access."""
from __future__ import annotations
import json
import logging
import re
from inspect import isawaitable
from rag.intent import Intent, IntentResult
logger = logging.getLogger(__name__)
class QueryTooLongError(ValueError):
pass
class DataQueryRejected(ValueError):
"""NL2SQL 数据查询被拒绝(未开放、无权限或配额不足),message 可直接回复用户。"""
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,
data_query=None,
):
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
# 可选 NL2SQL 数据查询依赖:签名 data_query(*, question, customer_id,
# session_id, trace_id) -> dict;匿名 runtime 不装配(None),行为不变。
self.data_query = data_query
async def handle(
self,
session_id: str,
query: str,
*,
trace_id: str,
customer_id: int | None = None,
) -> dict:
if len(query) > 2000:
raise QueryTooLongError("query长度不能超过2000字符")
# 先取历史再写入当前问题,保证意图识别拿到的历史不含本轮输入;取不到历史不阻断请求
try:
history = await self.context.get(session_id)
except Exception:
logger.exception("load conversation history failed: session_id=%s", session_id)
history = []
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,
))
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
sources = []
data_query_meta = None
if intent == Intent.GUIDE_PURCHASE:
answer = await _config(
self.config_getter,
"agent.customer.template.guide_purchase",
"请前往开户页面办理。",
)
elif intent == Intent.WANT_ADVISOR:
answer = await _config(
self.config_getter,
"agent.customer.template.guide_advisor",
"如需基金推荐,请联系投资顾问。",
)
elif intent == Intent.OFF_TOPIC:
answer = await _config(
self.config_getter,
"agent.customer.template.off_topic",
"我是华夏科技的智能客服,只能解答基金与公司业务相关的问题,"
"您可以问我基金知识、开户流程或公司信息~",
)
elif intent == Intent.TRANSFER_HUMAN:
answer = await _config(
self.config_getter,
"agent.customer.template.transfer_human",
"好的,正在为您转接人工客服,请稍候。"
"您也可以拨打官方客服热线 400-XXX-XXXX 直接联系我们。",
)
elif intent == Intent.CHITCHAT:
answer = await self._chitchat(session_id)
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,
)
elif intent in (Intent.KNOWLEDGE_QA, Intent.COMPANY_INFO):
try:
# 用补全指代后的问题检索,省略主语的追问才能命中
sources = await _maybe_await(self.rag_retrieve(search_query, None))
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)
result = {
"answer": answer,
"sources": sources,
"intent": intent.value,
"rewritten_query": search_query,
"trace_id": trace_id,
}
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
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",
"您好,我是华夏科技的智能客服,很高兴为您服务!"
"您可以问我基金知识、开户流程或公司信息~",
)
@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)
)