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: