1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
442 lines
18 KiB
Python
442 lines
18 KiB
Python
"""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, *, retake: bool = False
|
|
) -> dict[str, object]:
|
|
"""Return the current questionnaire definition for a customer.
|
|
|
|
A completed assessment remains the default read state so onboarding guards
|
|
continue to behave exactly as before. The explicit ``retake`` mode lets a
|
|
customer request the same server-owned questions again without weakening
|
|
submission frequency limits or exposing scoring rules.
|
|
"""
|
|
self._require_customer(context)
|
|
required = await self.is_required(context)
|
|
pending_review = False
|
|
if not required:
|
|
async with SessionFactory() as session:
|
|
pending_review = (
|
|
await RiskQuestionnaireRepository(session).pending_drift_review(
|
|
int(context.user_id)
|
|
)
|
|
) is not None
|
|
return {
|
|
"data": {
|
|
"required": required,
|
|
"review_status": "pending_review" if pending_review else None,
|
|
"questionnaire": {
|
|
"version": QUESTIONNAIRE_VERSION,
|
|
"questions": QUESTIONS,
|
|
"declaration": DECLARATION,
|
|
} if required or retake 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)
|
|
review_pending = bool(active_tags and changes)
|
|
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,
|
|
"status": "pending_review" if review_pending else "active",
|
|
"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)
|