客服适当性改按第十二条匹配矩阵(更正我上一轮的复核结论)

你说得对: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。
This commit is contained in:
2026-09-11 14:47:08 +08:00
parent c11e56c7aa
commit 575b4c2baa
6 changed files with 177 additions and 24 deletions
@@ -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"):
+34 -4
View File
@@ -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,
)
@@ -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`)
+34 -15
View File
@@ -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 已完成)
@@ -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(
+44 -1
View File
@@ -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)