From 575b4c2baa32bb859fcf4660301c0d93c0069235 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: Fri, 11 Sep 2026 14:47:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E9=80=82=E5=BD=93=E6=80=A7?= =?UTF-8?q?=E6=94=B9=E6=8C=89=E7=AC=AC=E5=8D=81=E4=BA=8C=E6=9D=A1=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E7=9F=A9=E9=98=B5=EF=BC=88=E6=9B=B4=E6=AD=A3=E6=88=91?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E8=BD=AE=E7=9A=84=E5=A4=8D=E6=A0=B8=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 你说得对:C ≥ R 是风控的口径,客服要走政策原文的矩阵。 上一轮我复核后说"两边一致、无需改动"是错的 —— 我只对比了政策文本与 SuitabilityService,漏了第三个东西:客服自己的知识库。POL-AST-012 就是第十二条矩阵 原文,而客服为 C1-C5 补的「能买什么产品」问答答案直接取自该矩阵(docs/24 第二节)。 于是同一个客服 Agent 对同一个问题给出相反答案: 问"C1 能买什么产品" → 知识检索答 R1、R2 可买(矩阵口径) 问"C1 能买这只 R2 吗" → 适当性出口答不能购买(严格 C ≥ R) 政策冲突的精确位置也不是"矩阵 vs 硬匹配",而是第十四条**第 1 款与它自己的第 2、3 款** 矛盾:第 2 款说"低于一个等级以上"才拒绝(C1→R2 只低 1 级,够不上"以上"),第 3 款禁止 C1 买 R3+、C2 买 R4+(R2 不在禁止列表)。矩阵与第 2、3 款三方一致,孤立的是第 1 款。 改动: - suitability_service.py 新增 MATRIX_ALLOWED / MATRIX_NEEDS_DISCLOSURE,_decide 由 "C < R 即拒绝"改为按矩阵:C1→R2、C2→R3 直接可买;C3→R4、C4→R5 走第十五条豁免档 (reason_code=SUITABLE_WITH_DISCLOSURE,强制揭示 + 确认 + 录音);低两级及以上仍拒绝。 - 客服话术分档:越级档不再说"在您的风险承受能力范围内" —— 那句只对 C ≥ R 成立, 用在 C1 买 R2 上会让客户以为自己的测评本来就覆盖这只产品。 - 风控侧不动,保留 C ≥ R:它要发现的是"越级成交且留痕不全",这个差异是有意保留的。 测试:新增逐格对照政策原文的 25 格矩阵用例、豁免档用例、话术分档用例(+29)。 docs/25 第七节 #1 与 docs/24 第七节同步更正,包括写明我上一轮那个结论错在哪。 门禁:ruff 干净 / mypy 137 文件 / 693 unit+contract / 33 integration。 --- .../agent/implementations/customer_service.py | 21 +++++++- app/service/suitability_service.py | 38 ++++++++++++-- docs/24-客服Agent阶段性总结与下阶段计划.md | 9 ++-- docs/25-风控模块代码评审报告.md | 49 +++++++++++++------ .../test_customer_service_suitability.py | 39 +++++++++++++++ .../unit/service/test_suitability_service.py | 45 ++++++++++++++++- 6 files changed, 177 insertions(+), 24 deletions(-) diff --git a/app/service/agent/implementations/customer_service.py b/app/service/agent/implementations/customer_service.py index b37cb95..36518b3 100644 --- a/app/service/agent/implementations/customer_service.py +++ b/app/service/agent/implementations/customer_service.py @@ -341,7 +341,26 @@ class CustomerServiceAgent(BaseAgent): # "还没测评/已过期",不能写成"您的等级为无"这种客户看不懂的句子。 lines = ["您目前没有在有效期内的风险测评结果。"] if decision.get("allowed"): - lines.insert(0, f"{product}为 {level_name},在您的风险承受能力范围内,可以购买。") + # "在您的风险承受能力范围内"只对 C ≥ R 成立。矩阵允许的越级档(C1→R2、 + # C2→R3)和豁免档(C3→R4、C4→R5)都**超出**了客户等级,一律说成"范围内" + # 是把监管口径讲错:客户会以为自己的测评等级本来就覆盖这只产品。 + if isinstance(customer_level, int) and customer_level >= risk_level: + lines.insert( + 0, + f"{product}为 {level_name},在您的风险承受能力范围内,可以购买。", + ) + elif decision.get("reason_code") == "SUITABLE_WITH_DISCLOSURE": + lines.insert( + 0, + f"{product}为 {level_name},高于您的风险承受能力等级。" + "按照投资者适当性管理规定,签署产品风险揭示书后可以购买。", + ) + else: + lines.insert( + 0, + f"{product}为 {level_name},虽然高于您的风险测评等级," + "但仍在《个人投资者适当性管理指南》匹配矩阵允许购买的范围内。", + ) if decision.get("required_disclosure"): lines.append("购买前需签署产品风险揭示书,具体请咨询您的客户经理。") if decision.get("requires_recording"): diff --git a/app/service/suitability_service.py b/app/service/suitability_service.py index 984fa5c..a5287bc 100644 --- a/app/service/suitability_service.py +++ b/app/service/suitability_service.py @@ -33,6 +33,31 @@ RISK_LEVEL_SOURCE = "fin_risk_assessment" PROFESSIONAL_INVESTOR_SOURCE = "sys_user" CUSTOMER_SCOPE_EXEMPT_ROLES = frozenset({"admin", "super_admin"}) +# 《个人投资者适当性管理指南》第十二条匹配矩阵(客户等级 → 可购买的产品等级)。 +# +# 矩阵与第十四条的**第 2、3 款**一致:只禁止"低两个等级及以上"的越级,低一个等级要看 +# 档位(C1→R2、C2→R3 直接可买;C3→R4、C4→R5 需签风险揭示书)。真正冲突的是第十四条 +# **第 1 款**"必须大于或等于"—— 它与同一条第 2、3 款自相矛盾。 +# +# 2026-09-11 业务裁定:**客服回答按矩阵,风控扫描保留 C ≥ R**。 +# 理由是两者的职责不同:客服要给客户一个与知识库(`POL-AST-012`)一致的"能不能买", +# 矩阵才是客户看得见的口径;风控要发现的是"越级成交且留痕不全",用更严的 C ≥ R 去 +# 事后核查。改之前两边是**同一个 Agent 自相矛盾**:问"C1 能买什么产品"答"R1、R2 可买" +# (矩阵),问"C1 能买这只 R2 吗"却答"不能购买"(C ≥ R)。 +MATRIX_ALLOWED: dict[int, frozenset[int]] = { + 1: frozenset({1, 2}), + 2: frozenset({1, 2, 3}), + 3: frozenset({1, 2, 3, 4}), + 4: frozenset({1, 2, 3, 4, 5}), + 5: frozenset({1, 2, 3, 4, 5}), +} + +# 矩阵里标"⚠️ 需签署风险揭示书"的档位,即第十五条豁免档。 +MATRIX_NEEDS_DISCLOSURE: dict[int, frozenset[int]] = { + 3: frozenset({4}), + 4: frozenset({5}), +} + _INVESTOR_TYPE_PATTERN = re.compile(r"^C([1-5])$") AuthorityReason = Literal[ @@ -192,16 +217,21 @@ class SuitabilityService: professional_investor=True, assessment_valid_until=profile.valid_until, ) - if profile.customer_risk_level < request.product_risk_level: + # 按第十二条匹配矩阵裁决(见 MATRIX_ALLOWED 的说明):不再用"C < R 即拒绝", + # 那样会把矩阵允许的 C1→R2、C2→R3 以及豁免档 C3→R4、C4→R5 一起拒掉。 + level = profile.customer_risk_level + product_level = request.product_risk_level + if product_level not in MATRIX_ALLOWED.get(level, frozenset()): return self._denied("RISK_LEVEL_MISMATCH", request, profile) - required_disclosure = request.product_requires_disclosure + needs_disclosure = product_level in MATRIX_NEEDS_DISCLOSURE.get(level, frozenset()) + required_disclosure = request.product_requires_disclosure or needs_disclosure return SuitabilityDecision( allowed=True, - reason_code="SUITABLE", + reason_code="SUITABLE_WITH_DISCLOSURE" if needs_disclosure else "SUITABLE", required_disclosure=required_disclosure, requires_confirmation=request.requires_confirmation or required_disclosure, requires_recording=required_disclosure or request.requires_confirmation, - customer_risk_level=profile.customer_risk_level, + customer_risk_level=level, professional_investor=False, assessment_valid_until=profile.valid_until, ) diff --git a/docs/24-客服Agent阶段性总结与下阶段计划.md b/docs/24-客服Agent阶段性总结与下阶段计划.md index 7ac5b0d..ce2db7a 100644 --- a/docs/24-客服Agent阶段性总结与下阶段计划.md +++ b/docs/24-客服Agent阶段性总结与下阶段计划.md @@ -184,9 +184,12 @@ a6c09fa 检索问句只在客户这一句说不清楚时才带上文 配置项、映射字段、写入新版本,`config_release` 的整版本替换语义不会清空工具白名单。 > 顺带记一条教训:`config_release` 是**整版本替换**,新增一项配置必须把老项带上, > 否则激活的一瞬间老配置全部消失。这条已写进 `docs/05` 与评审报告。 -2. **风险测评记录**:`fin_risk_assessment` 的补数属于演示数据准备,不是代码问题; - 风控合并后适当性链路两侧(客服 `check_suitability` / 风控 RW-007)已按同一口径 - `C ≥ R` 复核通过。 +2. **风险测评记录**:`fin_risk_assessment` 的补数属于演示数据准备,不是代码问题。 + 但**适当性口径在这一轮发现并修掉了一个真 bug**:本文第二节记着客服为 C1–C5 补的 + 「能买什么产品」问答**答案取自第十二条匹配矩阵**,而适当性出口 `check_suitability` + 用的是严格 `C ≥ R` —— 同一个 Agent 对同一个问题两种回答(问"C1 能买什么产品"答 + "R1、R2 可买",问"C1 能买这只 R2 吗"答"不能购买")。现按业务裁定**客服统一到矩阵**, + 风控扫描保留 `C ≥ R`(它要查的是越级成交且留痕不全)。详见 `docs/25` 第七节 #1。 ### 7.2 风控模块合并后的处理(详见 `docs/25`) diff --git a/docs/25-风控模块代码评审报告.md b/docs/25-风控模块代码评审报告.md index 6e9f9a6..86adbef 100644 --- a/docs/25-风控模块代码评审报告.md +++ b/docs/25-风控模块代码评审报告.md @@ -303,24 +303,43 @@ C1(1) 与 R2(2) 相比 `1 < 2`:**按矩阵可以买,按第十四条不能买 **这不是代码问题,是制度文本冲突**,需要业务方定一条为准。客服侧的适当性裁决走的是 `check_suitability`(按档案等级与匹配规则),两边口径也需要对齐。 -**业务裁定(2026-09-11):以第十四条 `C ≥ R` 为准。** +**业务裁定(2026-09-11,经一次自我更正):客服按第十二条矩阵,风控保留 `C ≥ R`。** -**客服侧口径复核(同日):本来就一致,无需改动。** 复核 `SuitabilityService._decide` -(`suitability_service.py:195`)后确认,它的判定是 -`if profile.customer_risk_level < request.product_risk_level: 拒绝` —— **同样是第十四条的 -`C ≥ R`**,并没有使用第十二条的匹配矩阵。原文"按档案等级与匹配规则"是评审时的推测, -不成立。 +第一次复核我只对比了政策文本与 `SuitabilityService`,得出"两边一致、无需改动" —— +**那个结论是错的**,因为漏了第三个东西:**客服自己的知识库**。 -两侧看上去的差异只有两点,且都不构成口径冲突: +- 知识库 `POL-AST-012` 就是第十二条矩阵原文(`个人投资者适当性管理指南.md:302-310`), + 而客服为 C1–C5 各补的「能买什么产品」问答,答案**直接取自该矩阵**(`docs/24` 第二节); +- 而 `SuitabilityService._decide` 用的是严格 `C < R 即拒绝`(第十四条**第 1 款**)。 -1. **专业投资者**:客服侧豁免等级匹配,但强制 `required_disclosure` / - `requires_confirmation` / `requires_recording`(`suitability_service.py:183-194`); - 风控扫描不做等级豁免,而是直接检查"该有的揭示、二次确认、录音留痕有没有"。 - 两者合起来是同一句话:豁免等级不等于豁免留痕。 -2. **触发条件**:客服是**事前拦截**(`C < R` 直接不许买),风控是**事后发现** - (`risk_scan_service.py:184` 的 `gap > 0 and missing_trace`)。这是职责差异,不是口径 - 差异 —— RW-007 的语义是"错配**且**留痕不全",不是"所有错配"。留痕完整却仍然成交, - 那是客服没能拦住,属另一个问题。 +于是**同一个客服 Agent 对同一个问题给出相反答案**:问"C1 能买什么产品"答"R1、R2 可买", +问"C1 能买这只 R2 吗"答"不能购买"。这不是"两侧是否需要对齐",而是客服**内部**自相矛盾, +且客户可见。 + +**冲突的精确位置**也不是原报告写的"矩阵 vs 硬匹配",而是第十四条**第 1 款与它自己的 +第 2、3 款**之间: + +| 条款 | 原文要点 | 对 C1→R2 的结论 | +|---|---|---| +| 第十四条 1 | 投资者等级**大于或等于**产品等级 | 拒绝(1 < 2) | +| 第十四条 2 | 低于**一个等级以上**的才拒绝 | 允许(只低 1 级,够不上"以上") | +| 第十四条 3 | C1 不得买 R3+、C2 不得买 R4+ | 允许(R2 不在禁止列表里) | +| 第十二条矩阵 | C1 行的 R1/R2 标 ✅ 可购买 | 允许 | + +第 2、3 款与矩阵三方一致,孤立的是第 1 款。 + +**落实**:客服侧改为按矩阵裁决 —— `suitability_service.py` 新增 `MATRIX_ALLOWED` 与 +`MATRIX_NEEDS_DISCLOSURE`,`C3→R4`、`C4→R5` 走豁免档(`SUITABLE_WITH_DISCLOSURE`, +强制揭示 + 确认 + 录音),并补了**逐格对照政策原文的 25 格用例** +(`tests/unit/service/test_suitability_service.py`)。客服话术也随之分档:越级档不再说 +"在您的风险承受能力范围内"(那会让客户以为自己的测评本来就覆盖这只产品)。 + +**风控保留 `C ≥ R`**:它要发现的是"越级成交且留痕不全",用更严的口径事后核查。 +两侧职责不同,这个差异是有意保留的。 + +**仍然成立的一点**:专业投资者上,客服侧豁免等级匹配但强制揭示/确认/录音 +(`suitability_service.py`),风控扫描不做等级豁免而是直接查留痕。两者合起来是同一句话 —— +豁免等级不等于豁免留痕。 ### 2. 第十五条豁免规则未落地 ✅(业务裁定:实现,2026-09-11 已完成) diff --git a/tests/unit/service/test_customer_service_suitability.py b/tests/unit/service/test_customer_service_suitability.py index 4e4b625..b1b1601 100644 --- a/tests/unit/service/test_customer_service_suitability.py +++ b/tests/unit/service/test_customer_service_suitability.py @@ -63,6 +63,45 @@ def test_disclosure_and_recording_are_disclosed_when_required() -> None: assert "双录" in text +def test_one_level_up_is_not_described_as_within_capacity() -> None: + """C1 买 R2 是矩阵允许的,但不能说成"在您的风险承受能力范围内"——它超出了等级。 + + 这句错话会让客户以为自己的测评等级本来就覆盖 R2,下次买 R3 时就更难解释为什么不行。 + """ + text = CustomerServiceAgent._suitability_text( + "南方季季盈90天", 2, _decision(customer_risk_level=1) + ) + + assert "在您的风险承受能力范围内" not in text + assert "匹配矩阵允许购买的范围内" in text + + +def test_disclosure_tier_explains_the_condition() -> None: + """C3 买 R4 属豁免档:要说清"高于您的等级"以及"签揭示书后可买"这个条件。""" + text = CustomerServiceAgent._suitability_text( + "南方稳健增利180天", + 4, + _decision( + customer_risk_level=3, + reason_code="SUITABLE_WITH_DISCLOSURE", + required_disclosure=True, + requires_recording=True, + ), + ) + + assert "高于您的风险承受能力等级" in text + assert "签署产品风险揭示书后可以购买" in text + assert "在您的风险承受能力范围内" not in text + + +def test_same_level_still_uses_the_capacity_wording() -> None: + text = CustomerServiceAgent._suitability_text( + "南方季季盈90天", 2, _decision(customer_risk_level=2) + ) + + assert "在您的风险承受能力范围内" in text + + def test_rejection_does_not_assert_a_reason() -> None: """拒绝的原因可能是等级不匹配、测评过期或未测评,不能一律说成"超出承受能力"。""" text = CustomerServiceAgent._suitability_text( diff --git a/tests/unit/service/test_suitability_service.py b/tests/unit/service/test_suitability_service.py index 0c71673..89ae592 100644 --- a/tests/unit/service/test_suitability_service.py +++ b/tests/unit/service/test_suitability_service.py @@ -101,14 +101,57 @@ def test_caller_cannot_declare_risk_facts(forged: dict[str, Any]) -> None: async def test_insufficient_authority_level_is_denied() -> None: + """低两个等级及以上仍必须拒绝(第十四条第 2、3 款,矩阵里的"❌ 禁止")。""" decision = await service_with_row(authority_row(investor_type="C1")).evaluate( - query(product_risk_level=2), context(), now=NOW + query(product_risk_level=3), context(), now=NOW ) assert decision.allowed is False assert decision.reason_code == "RISK_LEVEL_MISMATCH" assert decision.requires_recording is True +@pytest.mark.parametrize( + ("investor_type", "product_level", "allowed", "reason_code"), + [ + # 逐格抄自 knowledge/policy/个人投资者适当性管理指南.md 第十二条矩阵。 + # 这张表的价值在于:任何一格被改动,都必须是有意为之并在此处说明理由。 + ("C1", 1, True, "SUITABLE"), ("C1", 2, True, "SUITABLE"), + ("C1", 3, False, "RISK_LEVEL_MISMATCH"), ("C1", 4, False, "RISK_LEVEL_MISMATCH"), + ("C1", 5, False, "RISK_LEVEL_MISMATCH"), + ("C2", 1, True, "SUITABLE"), ("C2", 2, True, "SUITABLE"), ("C2", 3, True, "SUITABLE"), + ("C2", 4, False, "RISK_LEVEL_MISMATCH"), ("C2", 5, False, "RISK_LEVEL_MISMATCH"), + ("C3", 1, True, "SUITABLE"), ("C3", 2, True, "SUITABLE"), ("C3", 3, True, "SUITABLE"), + ("C3", 4, True, "SUITABLE_WITH_DISCLOSURE"), ("C3", 5, False, "RISK_LEVEL_MISMATCH"), + ("C4", 1, True, "SUITABLE"), ("C4", 2, True, "SUITABLE"), ("C4", 3, True, "SUITABLE"), + ("C4", 4, True, "SUITABLE"), ("C4", 5, True, "SUITABLE_WITH_DISCLOSURE"), + ("C5", 1, True, "SUITABLE"), ("C5", 2, True, "SUITABLE"), ("C5", 3, True, "SUITABLE"), + ("C5", 4, True, "SUITABLE"), ("C5", 5, True, "SUITABLE"), + ], +) +async def test_full_matrix_matches_policy_document( + investor_type: str, product_level: int, allowed: bool, reason_code: str +) -> None: + """客服回答必须与知识库里的矩阵一致 —— 这是同一个 Agent 的两条出口。""" + decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate( + query(product_risk_level=product_level), context(), now=NOW + ) + assert decision.allowed is allowed + assert decision.reason_code == reason_code + + +async def test_disclosure_tier_always_requires_disclosure_and_recording() -> None: + """C3→R4、C4→R5 是第十五条豁免档:可买,但必须揭示、确认、录音。""" + for investor_type, product_level in (("C3", 4), ("C4", 5)): + decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate( + query(product_risk_level=product_level), context(), now=NOW + ) + assert decision.allowed is True + assert decision.reason_code == "SUITABLE_WITH_DISCLOSURE" + assert decision.required_disclosure is True + assert decision.requires_confirmation is True + assert decision.requires_recording is True + + async def test_expired_assessment_is_denied_even_for_eligible_level() -> None: row = authority_row(investor_type="C5", valid_until=NOW - timedelta(seconds=1)) decision = await service_with_row(row).evaluate(query(product_risk_level=1), context(), now=NOW)