fix(customer-service): 自述等级的说明行不能挡住产品名

实测连问「季季盈90天起投多少」→「C3 客户能买它吗」→「那我能买它吗」,第三问转人工。
原因是上一步刚加的客户自述等级说明("您提到自己是 C3。…以您在公司留存的测评结果为准")
占了回答首行,而 _topic_of 只看首行,产品名被挤到第二行,追问于是丢掉了指代对象。

这是 _topic_of 第三次因为"只认固定格式"而咬人(前两次:FAQ 的"问"字、适当性回答没有
冒号)。改成遍历回答前四行、逐行尝试解析,并把单行解析抽成 _topic_in。

同时补上自述等级时的依据说明:客户说"我是 C3"而系统答"您没有测评结果",在他眼里是
矛盾的,必须点明判断以档案为准、不以自述为准。自述等级只用于这一句说明,**绝不**参与
裁决。拒绝后的指引也从"联系客户经理或拨打热线"改成"请先完成风险测评"。

新增三条单测:自述等级识别(含小写)、自述说明行占首行时仍能取到主语、
"为 R2"开头说明前面没有产品名时返回空。
This commit is contained in:
2026-09-10 22:48:56 +08:00
parent c564494f84
commit 8b1ed70916
3 changed files with 65 additions and 15 deletions
@@ -251,11 +251,32 @@ class CustomerServiceAgent(BaseAgent):
return self._guide_to_human("适当性校验调用失败")
if not isinstance(decision, dict):
return self._guide_to_human("适当性校验返回格式异常")
text = self._suitability_text(product, risk_level, decision)
claimed = self._self_claimed_level(request.message)
if claimed:
# 客户自述等级时必须点明判断依据,否则他会觉得"我明明说了我是 C3,
# 你却说我没有任何测评结果"——两句话在他眼里是矛盾的。
# 依据在档案、不在自述,这既是合规要求,也得跟客户讲明白。
text = (
f"您提到自己是 {claimed}。适当性判断以您在公司留存的、在有效期内的"
f"风险测评结果为准,不以本次自述为准。\n{text}"
)
return CoreResult(
text=f"{self._suitability_text(product, risk_level, decision)}\n{DISCLAIMER}",
text=f"{text}\n{DISCLAIMER}",
intent=self._classified_intent,
)
@staticmethod
def _self_claimed_level(message: str) -> str:
"""客户在问句里自述的风险等级("我是 C3""C3 客户能买吗"),没有则返回空串。
只用来在回答里说明判断依据,**绝不**参与裁决——客户等级只能来自档案。
"""
for level in range(1, 6):
if f"C{level}" in message.upper():
return f"C{level}"
return ""
@classmethod
def _previous_topic(cls, request: AgentRequest) -> str:
"""上一轮回答里的主语(产品名);取不到返回空串。"""
@@ -331,7 +352,7 @@ class CustomerServiceAgent(BaseAgent):
f"{product}为 {level_name},与您当前的风险测评结果不匹配,"
"按照投资者适当性管理规定暂时无法购买。",
)
lines.append("如需了解具体原因或申请重新测评,请联系您的客户经理或拨打客服热线。")
lines.append("请先联系您的客户经理完成风险测评,之后即可查询可购买的产品范围。")
valid_until = str(decision.get("assessment_valid_until") or "")
if valid_until:
lines.append(f"风险测评有效期至 {valid_until[:10]},过期需重新测评。")
@@ -389,21 +410,29 @@ class CustomerServiceAgent(BaseAgent):
# 短到这种程度的问题,自己通常不构成完整意图("起投多少""风险高吗")
_MIN_STANDALONE_CHARS = 8
@staticmethod
def _topic_of(answer: str) -> str:
"""从客服上一轮的回答里取出"这一轮在说哪个产品",取不出来就返回空串。
@classmethod
def _topic_of(cls, answer: str) -> str:
"""从客服上一轮的回答里取出"这一轮在说哪个产品",取不出来返回空串。
回答是我们自己组装的,只有两种形状,都取自知识块的字段:
- 行级块:`南方季季盈90天:起投金额 1万元`(首个冒号前就是产品名)
- 整节块:`### 2.1 南方季季盈90天`(首个标题行去掉 # 号)
遍历前几行而不是只看首行:适当性回答在客户自述等级时会先插一行
"您提到自己是 C3。…以您在公司留存的测评结果为准",产品名被挤到第二行——
只看首行会让紧随其后的追问丢掉指代对象(实测直接转人工)。
"""
for line in answer.splitlines()[:4]:
topic = cls._topic_in(line.strip().lstrip("#").strip())
if topic:
return topic
return ""
取不出来时宁可返回空、退化成不带主语,也不要拿兜底话术当主语:
「抱歉,这个问题我暂时无法给出准确答复」这种句子拿去检索只会把问题带偏。
@staticmethod
def _topic_in(first: str) -> str:
"""解析单行里可能的主语;解析不出返回空串。
回答是我们自己组装的,主语只可能出现在三种形状里:
- 行级块:「南方季季盈90天:起投金额 1万元」——首个冒号之前;
- 整节块:「### 2.1 南方季季盈90天」——标题行;
- 适当性:「南方季季盈90天为 R2(中低风险),…」——"为 R"之前。
"""
first = next((line.strip() for line in answer.splitlines() if line.strip()), "")
first = first.lstrip("#").strip()
# 适当性回答的首行是「{产品名}为 R2(中低风险),与您当前的风险测评结果不匹配…」,
# 产品名在"为 R"之前。这一条必须在冒号判断**之前**:那一行里没有冒号。
for level in range(1, 6):
index = first.find(f"为 R{level}")
if index >= 0:
@@ -411,7 +440,8 @@ class CustomerServiceAgent(BaseAgent):
return candidate if 0 < len(candidate) <= 20 else ""
topic = first.split(":", 1)[0].strip() if ":" in first else first
# FAQ 型回答的首行是"问:C1 客户能买什么产品?",冒号前只有一个"问"字;
# 拿它当主语只会把检索词污染成"问 那它风险高吗"。
# 拿它当主语只会把检索词污染成"问 那它风险高吗"。兜底话术同理,它含",",
# 会被下面的长度/标点校验挡掉。
if not topic or topic in {"问", "答"}:
return ""
if len(topic) > 20 or "," in topic or "。" in topic: