121 lines
4.7 KiB
Python
121 lines
4.7 KiB
Python
"""公共投顾适当性校验服务。
|
|||
|
|
|
||
|
|
适当性是治理边界,不属于任何一个业务 Agent。该服务只读客户/产品风险信息,
|
||
|
|
返回不可变决定;拒绝决定必须留下审计记录,且不会修改交易或产品数据。
|
||
|
|
"""
|
||
|
|
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||
|
|
|
||
|
|
from app.core.contracts import RequestContext
|
||
|
|
from app.infrastructure.db import SessionFactory
|
||
|
|
from app.model.audit import InteractionAudit
|
||
|
|
|
||
|
|
|
||
|
|
class SuitabilityRequest(BaseModel):
|
||
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||
|
|
|
||
|
|
customer_risk_level: int = Field(ge=1, le=5)
|
||
|
|
product_risk_level: int = Field(ge=1, le=5)
|
||
|
|
professional_investor: bool = False
|
||
|
|
product_requires_disclosure: bool = True
|
||
|
|
requires_confirmation: bool = False
|
||
|
|
assessment_expires_at: datetime | None = None
|
||
|
|
|
||
|
|
@model_validator(mode="after")
|
||
|
|
def validate_expiry_timezone(self) -> "SuitabilityRequest":
|
||
|
|
if self.assessment_expires_at is not None and self.assessment_expires_at.tzinfo is None:
|
||
|
|
raise ValueError("assessment_expires_at must include timezone")
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
class SuitabilityDecision(BaseModel):
|
||
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||
|
|
|
||
|
|
allowed: bool
|
||
|
|
reason_code: str
|
||
|
|
required_disclosure: bool
|
||
|
|
requires_confirmation: bool
|
||
|
|
requires_recording: bool
|
||
|
|
|
||
|
|
|
||
|
|
class SuitabilityService:
|
||
|
|
"""执行 C1-C5/R1-R5 的公共、只读适当性规则。"""
|
||
|
|
|
||
|
|
def evaluate(
|
||
|
|
self, request: SuitabilityRequest, *, now: datetime | None = None
|
||
|
|
) -> SuitabilityDecision:
|
||
|
|
current = now or datetime.now(UTC)
|
||
|
|
if request.assessment_expires_at is not None:
|
||
|
|
expiry = request.assessment_expires_at
|
||
|
|
if expiry.tzinfo is not None and expiry <= current:
|
||
|
|
return self._denied("ASSESSMENT_EXPIRED", request)
|
||
|
|
if request.customer_risk_level < request.product_risk_level:
|
||
|
|
return self._denied("RISK_LEVEL_MISMATCH", request)
|
||
|
|
required_disclosure = request.product_requires_disclosure
|
||
|
|
return SuitabilityDecision(
|
||
|
|
allowed=True,
|
||
|
|
reason_code="SUITABLE",
|
||
|
|
required_disclosure=required_disclosure,
|
||
|
|
requires_confirmation=request.requires_confirmation or required_disclosure,
|
||
|
|
requires_recording=required_disclosure or request.requires_confirmation,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def evaluate_and_audit(
|
||
|
|
self, request: SuitabilityRequest, context: RequestContext, *, now: datetime | None = None
|
||
|
|
) -> SuitabilityDecision:
|
||
|
|
decision = self.evaluate(request, now=now)
|
||
|
|
# 适当性决定是受监管业务决策,拒绝和通过都留痕;不保存敏感原始输入。
|
||
|
|
async with SessionFactory() 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_risk_level": request.customer_risk_level,
|
||
|
|
"product_risk_level": request.product_risk_level,
|
||
|
|
},
|
||
|
|
created_at=datetime.now(UTC).replace(tzinfo=None),
|
||
|
|
))
|
||
|
|
return decision
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _denied(reason_code: str, request: SuitabilityRequest) -> SuitabilityDecision:
|
||
|
|
return SuitabilityDecision(
|
||
|
|
allowed=False,
|
||
|
|
reason_code=reason_code,
|
||
|
|
required_disclosure=request.product_requires_disclosure,
|
||
|
|
requires_confirmation=True,
|
||
|
|
requires_recording=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class SuitabilityToolInput(BaseModel):
|
||
|
|
"""ToolExecutor 使用的严格输入模型,避免业务 Agent 自行拼接规则。"""
|
||
|
|
|
||
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||
|
|
|
||
|
|
customer_risk_level: int = Field(ge=1, le=5)
|
||
|
|
product_risk_level: int = Field(ge=1, le=5)
|
||
|
|
professional_investor: bool = False
|
||
|
|
product_requires_disclosure: bool = True
|
||
|
|
requires_confirmation: bool = False
|
||
|
|
assessment_expires_at: datetime | None = None
|
||
|
|
|
||
|
|
def to_request(self) -> SuitabilityRequest:
|
||
|
|
return SuitabilityRequest.model_validate(self.model_dump())
|
||
|
|
|
||
|
|
|
||
|
|
async def suitability_tool_handler(
|
||
|
|
arguments: SuitabilityToolInput, context: RequestContext
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
decision = await SuitabilityService().evaluate_and_audit(arguments.to_request(), context)
|
||
|
|
return decision.model_dump(mode="json")
|