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