fix(customer-service): 检索问句只在客户这一句说不清楚时才带上文
实测的答非所问:同一会话先问「季季盈90天的起投金额是多少」,再问「基金赎回几天到账」, 第二问答出的是季季盈的产品介绍。原因是 _search_query 无条件把上一轮客户问题拼进检索词, 客户换话题时旧话题的检索结果被带了回来。在金融场景里这比"引导转人工"糟得多:客户问 A 得到 B 的答案会直接失去对客服的信任,而"答不了"至少是诚实的。 改为只在两种真正需要上文的情况下拼接:句子里有明确指代词("这个产品""该基金"), 或短到不构成完整意图("那它风险高吗"只有 6 个字)。指代词刻意不收单字"它/他"——中文里 "其他产品"会被误判,而这类短句已经由长度规则覆盖。 验证:换话题场景第二问回到 FAQ-0016 的到账时间,指代追问仍命中季季盈产品块; ruff / mypy(113 文件) / 457 unit+contract 全绿。
This commit is contained in:
@@ -205,19 +205,41 @@ class CustomerServiceAgent(BaseAgent):
|
|||||||
row.user_prompt_template or DEFAULT_CHITCHAT_TEMPLATE,
|
row.user_prompt_template or DEFAULT_CHITCHAT_TEMPLATE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
# 客户这一句里出现这些词,说明主语要靠上文补全("这个产品""该基金")。
|
||||||
def _search_query(request: AgentRequest) -> str:
|
# 刻意不收"它/他":单字指代在中文里极易误伤("其他产品""其它"都含"他/它"),
|
||||||
"""构造交给检索的查询串:把最近几轮**客户**说过的话与当前问题拼在一起。
|
# 而这类短句已经由 _MIN_STANDALONE_CHARS 覆盖,不需要靠它兜。
|
||||||
|
_REFERRING_WORDS = (
|
||||||
|
"这个", "那个", "这款", "这只", "该产品", "该基金", "上述", "前面提", "刚刚说",
|
||||||
|
)
|
||||||
|
# 短到这种程度的问题,自己通常不构成完整意图("起投多少""风险高吗")
|
||||||
|
_MIN_STANDALONE_CHARS = 8
|
||||||
|
|
||||||
为什么必须带上文:检索是向量匹配,只看当前这一句时,"那它风险高吗"里的"它"
|
@classmethod
|
||||||
无从对应,检索会落到无关内容上、进而整条回答走兜底。把上文一并向量化,
|
def _search_query(cls, request: AgentRequest) -> str:
|
||||||
检索才能落到上文提到的那个产品上。
|
"""构造交给检索的查询串。
|
||||||
|
|
||||||
为什么只取客户的话、不取 Agent 自己的回答:把 Agent 的措辞也拼进来会让检索
|
只有当客户这一句**自己说不清楚**时,才把上文接进来。
|
||||||
偏向自己上一轮的说法,而客户的真实意图可能已经在下一句里被修正过。
|
|
||||||
|
为什么不能无条件拼接(实测):同一会话先问「季季盈90天的起投金额是多少」,
|
||||||
|
再问「基金赎回几天到账」,若把上一轮问题拼进检索词,第二问会命中季季盈的产品块、
|
||||||
|
答出产品介绍——**答非所问**。在金融场景里这比"引导转人工"糟得多:客户问 A 得到 B
|
||||||
|
的答案,会直接失去对客服的信任,而"答不了"至少是诚实的。
|
||||||
|
|
||||||
|
所以拼接只留给两种真正需要上文的情况:句子里有指代词,或短到不足以独立表达意图。
|
||||||
|
其余一律以当前问题检索,客户换话题就不会被上一轮拖回去。
|
||||||
|
|
||||||
|
只取客户的话、不取 Agent 自己的回答:把 Agent 的措辞也拼进来会让检索偏向自己
|
||||||
|
上一轮的说法,而客户的真实意图可能已经在下一句里被修正过。
|
||||||
"""
|
"""
|
||||||
|
message = request.message.strip()
|
||||||
|
needs_context = (
|
||||||
|
len(message) < cls._MIN_STANDALONE_CHARS
|
||||||
|
or any(word in message for word in cls._REFERRING_WORDS)
|
||||||
|
)
|
||||||
|
if not needs_context:
|
||||||
|
return message[:500]
|
||||||
recent_user = [turn.content for turn in request.history if turn.role == "user"][-2:]
|
recent_user = [turn.content for turn in request.history if turn.role == "user"][-2:]
|
||||||
return " ".join([*recent_user, request.message])[:500]
|
return " ".join([*recent_user, message])[:500]
|
||||||
|
|
||||||
# ---- 出口三:引导人工客服(不做工单,只回话并留痕) ----
|
# ---- 出口三:引导人工客服(不做工单,只回话并留痕) ----
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""客服检索问句构造的单元测试:什么时候该带上文,什么时候绝不能带。
|
||||||
|
|
||||||
|
背景是实测出来的:**无条件**拼接上文会让客户换话题时答非所问——同一会话先问
|
||||||
|
「季季盈90天的起投金额是多少」,再问「基金赎回几天到账」,第二问的检索词变成两句话的
|
||||||
|
混合,结果命中季季盈的产品块、答出产品介绍,而客户问的是赎回到账时间。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.core.contracts import AgentRequest, ConversationTurn
|
||||||
|
from app.service.agent.implementations.customer_service import CustomerServiceAgent
|
||||||
|
|
||||||
|
PREVIOUS = "季季盈90天的起投金额是多少"
|
||||||
|
|
||||||
|
|
||||||
|
def _request(message: str, *history: str) -> AgentRequest:
|
||||||
|
turns = tuple(
|
||||||
|
ConversationTurn(role="user" if index % 2 == 0 else "assistant", content=text)
|
||||||
|
for index, text in enumerate(history)
|
||||||
|
)
|
||||||
|
return AgentRequest(
|
||||||
|
agent_type="customer_service",
|
||||||
|
session_id="s",
|
||||||
|
message=message,
|
||||||
|
idempotency_key="1234567890123456",
|
||||||
|
history=turns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_topic_change_does_not_drag_previous_question_in() -> None:
|
||||||
|
"""客户换话题时,检索词里不能出现上一轮的问题(实测的答非所问场景)。"""
|
||||||
|
query = CustomerServiceAgent._search_query(_request("基金赎回几天到账", PREVIOUS))
|
||||||
|
|
||||||
|
assert query == "基金赎回几天到账"
|
||||||
|
assert "季季盈" not in query
|
||||||
|
|
||||||
|
|
||||||
|
def test_referring_phrase_brings_context_in() -> None:
|
||||||
|
"""带明确指代词的问句要靠上文补全主语。"""
|
||||||
|
query = CustomerServiceAgent._search_query(_request("这个产品的费率是多少", PREVIOUS))
|
||||||
|
|
||||||
|
assert PREVIOUS in query
|
||||||
|
|
||||||
|
|
||||||
|
def test_very_short_question_brings_context_in() -> None:
|
||||||
|
"""短到不构成完整意图的问句同样需要上文("那它风险高吗"只有 6 个字)。"""
|
||||||
|
query = CustomerServiceAgent._search_query(_request("那它风险高吗", PREVIOUS))
|
||||||
|
|
||||||
|
assert PREVIOUS in query
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_turn_has_no_history_to_add() -> None:
|
||||||
|
"""首轮没有历史可拼,检索词就是客户这一句。"""
|
||||||
|
assert CustomerServiceAgent._search_query(
|
||||||
|
_request("基金赎回几天到账")
|
||||||
|
) == "基金赎回几天到账"
|
||||||
Reference in New Issue
Block a user