"""Opening risk-questionnaire application service with server-only scoring.""" from dataclasses import dataclass from datetime import UTC, datetime, timedelta from decimal import Decimal from typing import Any from uuid import uuid4 from sqlalchemy.ext.asyncio import AsyncSession from app.core.contracts import RequestContext from app.core.errors import ForbiddenAgentError, InvalidStateError from app.core.risk_questionnaire_contracts import ( DECLARATION, QUESTIONNAIRE_VERSION, QUESTIONS, RiskQuestionnaireSubmission, ) from app.infrastructure.db import SessionFactory from app.model.audit import InteractionAudit from app.model.fund import FundRiskAssessment as RiskAssessment from app.model.memory import MemorySyncOutbox from app.model.profile_tag import AdvisorProfileDriftReview, AdvisorProfileTag from app.model.risk_questionnaire import ProfileSnapshot from app.repository.risk_questionnaire_repository import RiskQuestionnaireRepository from app.service.api_transaction_service import ApiTransactionService, digest # This versioned mapping is deliberately server-side. It is never included in customer responses. _SCORE_RULES: dict[str, dict[int, int]] = { "q1": {1: 5, 2: 3, 3: 3, 4: 4, 5: 4, 6: 2, 7: 3, 8: 2, 9: 4, 10: 2, 11: 3, 12: 1, 13: 1, 14: 3, 15: 3, 16: 2}, "q2": {1: 4, 2: 3, 3: 2, 4: 1}, "q3": {1: 3, 2: 4, 3: 2, 4: 5, 5: 1}, "q4": {1: 4, 2: 3, 3: 2, 4: 1}, "q5": {1: 4, 2: 3, 3: 2, 4: 1}, "q6": {1: 5, 2: 4, 3: 3, 4: 2, 5: 1}, "q7": {1: 5, 2: 4, 3: 3, 4: 2, 5: 1}, "q8": {1: 4, 2: 3, 3: 2, 4: 1}, "q9": {1: 4, 2: 3, 3: 2, 4: 1}, "q10": {1: 1, 2: 2, 3: 3, 4: 4}, "q11": {1: 5, 2: 4, 3: 3, 4: 2, 5: 1}, "q12": {1: 4, 2: 3, 3: 2, 4: 1}, "q13": {1: 4, 2: 3, 3: 2, 4: 1}, } _RISK_BANDS = ((22, "C1", "谨慎型"), (31, "C2", "稳健型"), (40, "C3", "平衡型"), (49, "C4", "成长型"), (57, "C5", "进取型")) _HORIZONS = {1: "5年以上", 2: "3至5年", 3: "1至3年", 4: "1年以下"} _ASSET_PREFERENCES = { 1: ["固定收益类"], 2: ["固定收益类", "权益类"], 3: ["固定收益类", "权益类", "衍生品"], 4: ["固定收益类", "权益类", "衍生品", "其他"], } @dataclass(frozen=True) class _ScoreResult: total: int risk_level: str risk_profile: str class RiskQuestionnaireService: async def questionnaire(self, context: RequestContext) -> dict[str, object]: self._require_customer(context) required = await self.is_required(context) return { "data": { "required": required, "questionnaire": { "version": QUESTIONNAIRE_VERSION, "questions": QUESTIONS, "declaration": DECLARATION, } if required else None, }, "meta": {"trace_id": context.trace_id}, } async def is_required(self, context: RequestContext) -> bool: if "customer" not in context.roles: return False async with SessionFactory() as session: return not await RiskQuestionnaireRepository(session).has_valid_assessment( int(context.user_id), _utc_now() ) async def submit( self, payload: RiskQuestionnaireSubmission, context: RequestContext, key: str | None ) -> dict[str, object]: self._require_customer(context) async def operation(session: AsyncSession) -> dict[str, Any]: now = _utc_now() customer_id = int(context.user_id) repository = RiskQuestionnaireRepository(session) if await repository.pending_drift_review(customer_id, lock=True) is not None: raise InvalidStateError("画像标签漂移正在复核,暂不能提交新的风险测评") await self._validate_submission_rate(repository, customer_id, now) score = self.score(payload.answers) assessment_id = _identifier() valid_until = _next_year(now) assessment = RiskAssessment( id=assessment_id, customer_id=customer_id, questionnaire_version=QUESTIONNAIRE_VERSION, answers=dict(payload.answers), total_score=score.total, investor_type=score.risk_level, assessed_at=now, valid_until=valid_until, created_at=now, ) version = await repository.next_profile_version(customer_id) profile_uuid = str(uuid4()) snapshot = self._profile_snapshot(score, payload.answers, valid_until) repository.add_assessment(assessment) basis = { "source": "fin_risk_assessment", "assessment_id": str(assessment_id), "questionnaire_version": QUESTIONNAIRE_VERSION, } active_tags = await repository.active_tags(customer_id, lock=True) candidates = self._profile_tag_candidates(score, payload.answers, assessment_id) changes = self._drift_changes(active_tags, candidates) audit_action: str audit_detail: dict[str, object] if active_tags and changes: review = AdvisorProfileDriftReview( drift_no=str(uuid4()), customer_id=customer_id, source_assessment_id=assessment_id, candidate_profile_uuid=profile_uuid, candidate_profile_version=version, candidate_snapshot=snapshot, candidate_generation_basis=basis, changed_tags=changes, status="pending_review", reviewer_user_id=None, reviewed_at=None, review_comment=None, created_at=now, updated_at=now, ) repository.add_drift_review(review) await session.flush() self._add_tags( repository, customer_id, version, candidates, active_tags, now, status="pending_review", drift_review_id=review.id, drift_reasons={ str(item["tag_key"]): item["reason"] for item in changes }, ) audit_action = "onboarding.profile_drift_detected" audit_detail = { "assessment_id": str(assessment_id), "drift_no": review.drift_no, "changed_tag_keys": [str(item["tag_key"]) for item in changes], } else: await repository.deactivate_current_profile(customer_id, now) if active_tags: await repository.supersede_active_tags( customer_id, tuple(tag.tag_key for tag in active_tags), now ) repository.add_profile(ProfileSnapshot( profile_uuid=profile_uuid, customer_id=customer_id, version=version, snapshot=snapshot, generation_basis=basis, snapshot_hash=digest(snapshot), is_current=True, generated_at=now, created_at=now, updated_at=now, )) self._add_tags( repository, customer_id, version, candidates, active_tags, now, status="active", drift_review_id=None, drift_reasons={}, ) self._add_profile_sync_events( repository, profile_uuid, version, customer_id, snapshot, now ) audit_action = "onboarding.risk_questionnaire_completed" audit_detail = {"assessment_id": str(assessment_id), "profile_version": version} session.add(InteractionAudit( actor_type="user", actor_id=customer_id, target_customer_id=customer_id, portal=context.portal, action_type=audit_action, detail={ "questionnaire_version": QUESTIONNAIRE_VERSION, "trace_id": context.trace_id, **audit_detail, }, created_at=now, )) return { "data": { "completed": True, "questionnaire_version": QUESTIONNAIRE_VERSION, "valid_until": valid_until.isoformat() + "Z", }, "meta": {"trace_id": context.trace_id}, } return await ApiTransactionService().execute( context, "onboarding:risk-questionnaire", key, payload.model_dump(mode="json"), operation, ) @staticmethod def score(answers: dict[str, int]) -> _ScoreResult: total = sum( _SCORE_RULES[question_id][option_id] for question_id, option_id in answers.items() ) for maximum, risk_level, risk_profile in _RISK_BANDS: if total <= maximum: return _ScoreResult(total, risk_level, risk_profile) raise ValueError("questionnaire score exceeds configured range") @staticmethod def _profile_snapshot( score: _ScoreResult, answers: dict[str, int], valid_until: datetime ) -> dict[str, object]: return { "profile_type": "opening_risk_assessment", "risk_level": score.risk_level, "risk_profile": score.risk_profile, "investment_horizon": _HORIZONS[answers["q9"]], "preferred_asset_classes": _ASSET_PREFERENCES[answers["q10"]], "source": "formal_risk_assessment", "valid_until": valid_until.isoformat() + "Z", } @staticmethod def _profile_tag_candidates( score: _ScoreResult, answers: dict[str, int], assessment_id: int ) -> dict[str, dict[str, object]]: source_reference = f"fin_risk_assessment:{assessment_id}" risk_confidence = RiskQuestionnaireService._risk_tag_confidence(score.total) return { "risk_level": { "value": score.risk_level, "confidence": risk_confidence, "source_type": "formal_risk_assessment", "source_reference": source_reference, "source_confidence": Decimal("1.0000"), }, "risk_profile": { "value": score.risk_profile, "confidence": risk_confidence, "source_type": "formal_risk_assessment", "source_reference": source_reference, "source_confidence": Decimal("1.0000"), }, "investment_horizon": { "value": _HORIZONS[answers["q9"]], "confidence": Decimal("1.0000"), "source_type": "formal_risk_assessment", "source_reference": source_reference, "source_confidence": Decimal("1.0000"), }, "preferred_asset_classes": { "value": _ASSET_PREFERENCES[answers["q10"]], "confidence": Decimal("1.0000"), "source_type": "formal_risk_assessment", "source_reference": source_reference, "source_confidence": Decimal("1.0000"), }, } @staticmethod def _risk_tag_confidence(total: int) -> Decimal: lower = 13 for upper, _risk_level, _risk_profile in _RISK_BANDS: if total <= upper: distance = min(total - lower, upper - total) return min( Decimal("0.9500"), Decimal("0.7000") + Decimal(distance) * Decimal("0.0500"), ) lower = upper + 1 raise ValueError("questionnaire score exceeds configured range") @staticmethod def _drift_changes( active_tags: list[AdvisorProfileTag], candidates: dict[str, dict[str, object]] ) -> list[dict[str, object]]: active_by_key = {tag.tag_key: tag for tag in active_tags} changes: list[dict[str, object]] = [] for tag_key, candidate in candidates.items(): previous = active_by_key.get(tag_key) if previous is None: continue reason: str | None = None if previous.tag_value != candidate["value"]: reason = "value_changed" elif previous.source_type != candidate["source_type"]: reason = "source_changed" elif ( Decimal(str(candidate["confidence"])) + Decimal("0.1500") < Decimal(previous.confidence) ): reason = "confidence_decreased" if reason is not None: changes.append({ "tag_key": tag_key, "reason": reason, "previous_value": previous.tag_value, "candidate_value": candidate["value"], "previous_confidence": str(previous.confidence), "candidate_confidence": str(candidate["confidence"]), "previous_source_type": previous.source_type, "candidate_source_type": candidate["source_type"], }) return changes @staticmethod def _add_tags( repository: RiskQuestionnaireRepository, customer_id: int, profile_version: int, candidates: dict[str, dict[str, object]], active_tags: list[AdvisorProfileTag], now: datetime, *, status: str, drift_review_id: int | None, drift_reasons: dict[str, object], ) -> None: active_by_key = {tag.tag_key: tag for tag in active_tags} for tag_key, candidate in candidates.items(): previous = active_by_key.get(tag_key) repository.add_tag(AdvisorProfileTag( tag_uuid=str(uuid4()), customer_id=customer_id, tag_key=tag_key, tag_value=candidate["value"], tag_value_hash=digest(candidate["value"]), confidence=Decimal(str(candidate["confidence"])), source_type=str(candidate["source_type"]), source_reference=str(candidate["source_reference"]), source_confidence=Decimal(str(candidate["source_confidence"])), profile_version=profile_version, drift_review_id=drift_review_id, previous_tag_id=previous.id if previous is not None else None, drift_reason=str(drift_reasons[tag_key]) if tag_key in drift_reasons else None, status=status, active_customer_tag=(f"{customer_id}:{tag_key}" if status == "active" else None), created_at=now, updated_at=now, )) @staticmethod def _add_profile_sync_events( repository: RiskQuestionnaireRepository, profile_uuid: str, version: int, customer_id: int, snapshot: dict[str, object], now: datetime, ) -> None: event_uuid = str(uuid4()) payload = { "customer_id": str(customer_id), "profile_uuid": profile_uuid, "version": version, "profile": snapshot, } for target_store in ("milvus", "neo4j"): repository.add_sync_event(MemorySyncOutbox( event_uuid=event_uuid, aggregate_type="profile", aggregate_uuid=profile_uuid, aggregate_version=version, target_store=target_store, operation="upsert", payload=payload, status="pending", retry_count=0, next_retry_at=None, last_error=None, created_at=now, processed_at=None, )) @staticmethod async def _validate_submission_rate( repository: RiskQuestionnaireRepository, customer_id: int, now: datetime ) -> None: start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) if await repository.assessments_since(customer_id, start_of_day) >= 2: raise InvalidStateError("风险测评在一个自然日内不能超过两次") if await repository.assessments_since(customer_id, now - timedelta(days=365)) >= 8: raise InvalidStateError("风险测评在一年内不能超过八次") @staticmethod def _require_customer(context: RequestContext) -> None: if "customer" not in context.roles: raise ForbiddenAgentError("仅客户可以填写开户风险测评问卷") def _identifier() -> int: return (uuid4().int >> 64) or 1 def _next_year(value: datetime) -> datetime: try: return value.replace(year=value.year + 1) except ValueError: return value.replace(year=value.year + 1, month=2, day=28) def _utc_now() -> datetime: return datetime.now(UTC).replace(tzinfo=None)