"""公共投顾适当性校验服务。 适当性是治理边界,不属于任何一个业务 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")