你说得对: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。
302 lines
13 KiB
Python
302 lines
13 KiB
Python
"""公共投顾适当性校验服务。
|
||
|
||
适当性是治理边界,不属于任何一个业务 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")
|