Files
group_fqcd_jr/app/service/suitability_service.py
T

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")