From 8b1ed70916b6c5bab5900b126fc767b84e8297a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Thu, 10 Sep 2026 22:48:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(customer-service):=20=E8=87=AA=E8=BF=B0?= =?UTF-8?q?=E7=AD=89=E7=BA=A7=E7=9A=84=E8=AF=B4=E6=98=8E=E8=A1=8C=E4=B8=8D?= =?UTF-8?q?=E8=83=BD=E6=8C=A1=E4=BD=8F=E4=BA=A7=E5=93=81=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测连问「季季盈90天起投多少」→「C3 客户能买它吗」→「那我能买它吗」,第三问转人工。 原因是上一步刚加的客户自述等级说明("您提到自己是 C3。…以您在公司留存的测评结果为准") 占了回答首行,而 _topic_of 只看首行,产品名被挤到第二行,追问于是丢掉了指代对象。 这是 _topic_of 第三次因为"只认固定格式"而咬人(前两次:FAQ 的"问"字、适当性回答没有 冒号)。改成遍历回答前四行、逐行尝试解析,并把单行解析抽成 _topic_in。 同时补上自述等级时的依据说明:客户说"我是 C3"而系统答"您没有测评结果",在他眼里是 矛盾的,必须点明判断以档案为准、不以自述为准。自述等级只用于这一句说明,**绝不**参与 裁决。拒绝后的指引也从"联系客户经理或拨打热线"改成"请先完成风险测评"。 新增三条单测:自述等级识别(含小写)、自述说明行占首行时仍能取到主语、 "为 R2"开头说明前面没有产品名时返回空。 --- .../agent/implementations/customer_service.py | 60 ++++++++++++++----- .../test_customer_service_search_query.py | 11 ++++ .../test_customer_service_suitability.py | 9 +++ 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py index 3bb641f..e3230db 100644 --- a/app/service/agent/implementations/customer_service.py +++ b/app/service/agent/implementations/customer_service.py @@ -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: diff --git a/tests/unit/service/test_customer_service_search_query.py b/tests/unit/service/test_customer_service_search_query.py index f9b307c..ba255c2 100644 --- a/tests/unit/service/test_customer_service_search_query.py +++ b/tests/unit/service/test_customer_service_search_query.py @@ -63,6 +63,17 @@ def test_topic_extracted_from_suitability_answer() -> None: ) == "南方季季盈90天" +def test_topic_skips_the_self_claim_notice_line() -> None: + """自述等级的说明行占着首行,产品名在第二行——只看首行会丢掉主语(实测转人工)。""" + assert CustomerServiceAgent._topic_of( + "您提到自己是 C3。适当性判断以您在公司留存的、在有效期内的风险测评结果为准," + "不以本次自述为准。\n" + "南方季季盈90天为 R2(中低风险),与您当前的风险测评结果不匹配。\n" + "您目前没有在有效期内的风险测评结果。\n" + "请先联系您的客户经理完成风险测评,之后即可查询可购买的产品范围。" + ) == "南方季季盈90天" + + def test_topic_from_suitability_answer_requires_a_name_before_the_marker() -> None: """以"为 R2"开头说明前面没有产品名,不能把空串或等级本身当成主语。""" assert CustomerServiceAgent._topic_of("为 R2(中低风险),与您的测评结果不匹配。") == "" diff --git a/tests/unit/service/test_customer_service_suitability.py b/tests/unit/service/test_customer_service_suitability.py index bac196f..4e4b625 100644 --- a/tests/unit/service/test_customer_service_suitability.py +++ b/tests/unit/service/test_customer_service_suitability.py @@ -98,6 +98,15 @@ def test_unknown_level_falls_back_to_the_code() -> None: assert "R9" in text +# ---- 客户自述等级 ---- + +def test_self_claimed_level_is_detected_case_insensitively() -> None: + """客户自述等级只用来在回答里说明判断依据,绝不参与裁决。""" + assert CustomerServiceAgent._self_claimed_level("C3 客户能买它吗") == "C3" + assert CustomerServiceAgent._self_claimed_level("我是c1客户,能买吗") == "C1" + assert CustomerServiceAgent._self_claimed_level("那我能买它吗") == "" + + # ---- 产品名来源 ---- def test_previous_topic_takes_the_subject_of_the_last_answer() -> None: