Files
group_fqcd_jr/app/service/suitability_service.py
T
lzf_0626 575b4c2baa 客服适当性改按第十二条匹配矩阵(更正我上一轮的复核结论)
你说得对: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。
2026-09-11 14:47:08 +08:00

302 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""公共投顾适当性校验服务。
适当性是治理边界,不属于任何一个业务 Agent。该服务只读客户/产品风险信息,
返回不可变决定;拒绝决定必须留下审计记录,且不会修改交易或产品数据。
合规要点(B2 修复):
1. **客户风险等级只能来自服务端权威来源**:取 ``fin_risk_assessment`` 中该客户最新一条
测评(``assessed_at`` 最新),调用方传入的 ``customer_risk_level`` 一律不接受
(DTO ``extra="forbid"`` 直接拒绝伪造字段)。
2. **测评有效期以权威记录的 ``valid_until`` 为准**,调用方不能自报过期时间;测评缺失或
过期一律失败关闭,不做静默降级。
3. **专业投资者身份来自 ``sys_user``**(``is_professional_investor`` 且
``professional_investor_status='已认定'``),而非调用方参数;已认定的专业投资者可豁免
C/R 等级匹配,但仍强制风险揭示、确认与录音,且不能绕过测评有效期与审计。
"""
import re
from collections.abc import Callable, Mapping
from datetime import UTC, datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text
from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
PROFESSIONAL_INVESTOR_CERTIFIED = "已认定"
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[
"AUTHORITY_OK",
"CUSTOMER_NOT_FOUND",
"ASSESSMENT_MISSING",
"ASSESSMENT_EXPIRED",
"RISK_LEVEL_INVALID",
]
# 只读查询:客户专业投资者身份(sys_user)+ 最新一条风险测评(fin_risk_assessment)。
# 不读取 answers 问卷原文,避免把敏感测评明细带入服务层。
_AUTHORITY_SQL = text(
"""
SELECT u.is_professional_investor,
u.professional_investor_status,
a.investor_type,
a.assessed_at,
a.valid_until
FROM sys_user u
LEFT JOIN fin_risk_assessment a ON a.id = (
SELECT x.id FROM fin_risk_assessment x
WHERE x.customer_id = u.id
ORDER BY x.assessed_at DESC, x.id DESC
LIMIT 1
)
WHERE u.id = :customer_id
"""
)
class RiskAuthorityProfile(BaseModel):
"""服务端权威风险画像(只读汇总,不含测评问卷原文)。"""
model_config = ConfigDict(extra="forbid", frozen=True)
customer_id: str
customer_risk_level: int | None = None
professional_investor: bool = False
assessed_at: datetime | None = None
valid_until: datetime | None = None
authority_reason: AuthorityReason = "AUTHORITY_OK"
class SuitabilityToolInput(BaseModel):
"""ToolExecutor 使用的严格输入模型,避免业务 Agent 自行拼接规则。
调用方只能声明“给谁、买什么等级的产品、是否需要揭示/确认”,
风险等级与测评有效期一律由服务端权威来源解析。
"""
model_config = ConfigDict(extra="forbid", frozen=True)
customer_id: str = Field(min_length=1, max_length=20, pattern=r"^[0-9]+$")
product_risk_level: int = Field(ge=1, le=5)
product_requires_disclosure: bool = True
requires_confirmation: bool = False
class SuitabilityDecision(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
allowed: bool
reason_code: str
required_disclosure: bool
requires_confirmation: bool
requires_recording: bool
customer_risk_level: int | None = None
risk_level_source: str = RISK_LEVEL_SOURCE
professional_investor: bool = False
assessment_valid_until: datetime | None = None
def _as_utc(value: object) -> datetime | None:
"""库内 DATETIME 为 UTC naive,统一规范化为带时区,便于安全比较。"""
if not isinstance(value, datetime):
return None
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _risk_level_from_investor_type(investor_type: object) -> int | None:
if not isinstance(investor_type, str):
return None
matched = _INVESTOR_TYPE_PATTERN.match(investor_type.strip().upper())
return int(matched.group(1)) if matched is not None else None
class SuitabilityService:
"""执行 C1-C5/R1-R5 的公共、只读适当性规则。"""
def __init__(self, *, session_factory: Callable[[], Any] | None = None) -> None:
self._session_factory: Callable[[], Any] = session_factory or SessionFactory
async def evaluate(
self, request: SuitabilityToolInput, context: RequestContext, *, now: datetime | None = None
) -> SuitabilityDecision:
current = now or datetime.now(UTC)
self._assert_customer_scope(request.customer_id, context)
profile = await self._load_authority_profile(request.customer_id)
return self._decide(request, profile, current)
async def _load_authority_profile(self, customer_id: str) -> RiskAuthorityProfile:
async with self._session_factory() as session:
result = await session.execute(_AUTHORITY_SQL, {"customer_id": int(customer_id)})
row: Mapping[str, Any] | None = result.mappings().first()
if row is None:
return RiskAuthorityProfile(
customer_id=customer_id, authority_reason="CUSTOMER_NOT_FOUND"
)
level = _risk_level_from_investor_type(row["investor_type"])
valid_until = _as_utc(row["valid_until"])
if row["investor_type"] is None:
reason: AuthorityReason = "ASSESSMENT_MISSING"
elif level is None:
reason = "RISK_LEVEL_INVALID"
elif valid_until is None:
# 有效期缺失视为不可用,绝不按“长期有效”放行。
reason = "ASSESSMENT_EXPIRED"
else:
reason = "AUTHORITY_OK"
return RiskAuthorityProfile(
customer_id=customer_id,
customer_risk_level=level,
professional_investor=(
bool(row["is_professional_investor"])
and str(row["professional_investor_status"]) == PROFESSIONAL_INVESTOR_CERTIFIED
),
assessed_at=_as_utc(row["assessed_at"]),
valid_until=valid_until,
authority_reason=reason,
)
def _decide(
self, request: SuitabilityToolInput, profile: RiskAuthorityProfile, current: datetime
) -> SuitabilityDecision:
if profile.authority_reason == "CUSTOMER_NOT_FOUND":
return self._denied("CUSTOMER_NOT_FOUND", request, profile)
if profile.authority_reason == "ASSESSMENT_MISSING":
return self._denied("ASSESSMENT_MISSING", request, profile)
if profile.authority_reason == "RISK_LEVEL_INVALID":
return self._denied("RISK_LEVEL_INVALID", request, profile)
if profile.valid_until is None or profile.valid_until <= current:
# 测评过期即拒绝:专业投资者也不能绕过有效期。
return self._denied("ASSESSMENT_EXPIRED", request, profile)
if profile.customer_risk_level is None:
return self._denied("ASSESSMENT_MISSING", request, profile)
if profile.professional_investor:
# 已认定专业投资者可豁免等级匹配,但必须揭示、确认并录音留痕。
return SuitabilityDecision(
allowed=True,
reason_code="SUITABLE_PROFESSIONAL_INVESTOR",
required_disclosure=True,
requires_confirmation=True,
requires_recording=True,
customer_risk_level=profile.customer_risk_level,
professional_investor=True,
assessment_valid_until=profile.valid_until,
)
# 按第十二条匹配矩阵裁决(见 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)
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_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=level,
professional_investor=False,
assessment_valid_until=profile.valid_until,
)
async def evaluate_and_audit(
self, request: SuitabilityToolInput, context: RequestContext, *, now: datetime | None = None
) -> SuitabilityDecision:
decision = await self.evaluate(request, context, now=now)
# 适当性决定是受监管业务决策,拒绝和通过都留痕;只记录权威来源摘要,
# 不保存测评问卷原文,也不保存调用方自报的任何等级。
async with self._session_factory() as session, session.begin():
actor_id = int(context.user_id) if context.user_id.isdecimal() else None
session.add(InteractionAudit(
actor_type="agent",
actor_id=actor_id,
portal=context.portal,
action_type="suitability.checked",
detail={
"trace_id": context.trace_id,
"status": "allowed" if decision.allowed else "denied",
"reason_code": decision.reason_code,
"customer_id": request.customer_id,
"customer_risk_level": decision.customer_risk_level,
"risk_level_source": decision.risk_level_source,
"professional_investor": decision.professional_investor,
"professional_investor_source": PROFESSIONAL_INVESTOR_SOURCE,
"assessment_valid_until": (
decision.assessment_valid_until.isoformat()
if decision.assessment_valid_until is not None
else None
),
"product_risk_level": request.product_risk_level,
},
created_at=datetime.now(UTC).replace(tzinfo=None),
))
return decision
@staticmethod
def _assert_customer_scope(customer_id: str, context: RequestContext) -> None:
"""公共鉴权:除管理员外不得查询他人风险测评。"""
if set(context.roles).intersection(CUSTOMER_SCOPE_EXEMPT_ROLES):
return
if customer_id == context.user_id or customer_id in context.customer_ids:
return
raise ForbiddenAgentError("不能查询该客户的风险测评")
@staticmethod
def _denied(
reason_code: str, request: SuitabilityToolInput, profile: RiskAuthorityProfile
) -> SuitabilityDecision:
return SuitabilityDecision(
allowed=False,
reason_code=reason_code,
required_disclosure=request.product_requires_disclosure,
requires_confirmation=True,
requires_recording=True,
customer_risk_level=profile.customer_risk_level,
professional_investor=profile.professional_investor,
assessment_valid_until=profile.valid_until,
)
async def suitability_tool_handler(
arguments: SuitabilityToolInput, context: RequestContext
) -> dict[str, Any]:
decision = await SuitabilityService().evaluate_and_audit(arguments, context)
return decision.model_dump(mode="json")