Files
group_fqcd_jr/app/service/promotion_compliance.py

171 lines
7.0 KiB
Python

"""产品推介材料确定性合规检查。"""
import re
from dataclasses import dataclass
from typing import Any
from app.core.promotion_material_contracts import MANDATORY_RISK_DISCLOSURE
@dataclass(frozen=True)
class ComplianceFinding:
rule_code: str
rule_name: str
scope: str
severity: str
hit_text: str | None
suggestion: str
BLOCKED_PHRASES = (
"保本",
"稳赚",
"无风险",
"保证收益",
"快速募集",
"抢购",
"稳赚不赔",
"本金无忧",
)
class PromotionComplianceChecker:
"""不依赖模型的输入和文案规则检查器。"""
def check_inputs(self, inputs: dict[str, Any]) -> list[ComplianceFinding]:
findings: list[ComplianceFinding] = []
required_paths = (
("product_info", "fund_type"),
("product_info", "operation_mode"),
("product_info", "investment_objective"),
("manager_info", "management_company"),
("manager_info", "registration_code"),
("manager_info", "manager_name"),
("team_info", "team_description"),
("strategy_info", "investment_scope"),
("strategy_info", "strategy"),
("strategy_info", "restrictions"),
)
for group, field in required_paths:
value = inputs.get(group, {}).get(field)
if not str(value or "").strip():
findings.append(ComplianceFinding(
f"required.{group}.{field}", "必填信息缺失", "input", "block",
None, f"请补充 {group}.{field}",
))
fees = inputs.get("fee_structure", {})
fee_fields = (
"subscription_fee",
"purchase_fee",
"redemption_fee",
"sales_service_fee",
"management_fee",
"custody_fee",
"client_maintenance_fee",
)
missing_fees = [
field for field in fee_fields if not str(fees.get(field) or "").strip()
]
if missing_fees:
findings.append(ComplianceFinding(
"fee_structure.incomplete", "费用结构未完整揭示", "input", "block",
"、".join(missing_fees), "请逐项填写费率,暂不适用的项目填写“不适用”",
))
performance = inputs.get("performance_info", {})
if performance.get("show_product_performance"):
months = performance.get("history_months")
if not isinstance(months, int) or months <= 6:
findings.append(ComplianceFinding(
"performance.history_short", "业绩展示区间不超过六个月",
"input", "block", str(months), "不得展示为本基金历史业绩",
))
ranking = performance.get("ranking", {})
if ranking.get("enabled"):
years = self._years(ranking.get("evaluation_period_years"))
if years < 3 or not ranking.get("institution_name") or not ranking.get(
"public_source"
):
findings.append(ComplianceFinding(
"performance.ranking_source_invalid", "业绩排名来源不满足要求",
"input", "block", ranking.get("ranking_text"), "补充三年期以上公开评价数据来源",
))
shows_performance = (
performance.get("show_product_performance")
or performance.get("show_manager_performance")
)
if shows_performance and not performance.get("performance_attachment_id"):
findings.append(ComplianceFinding(
"performance.data_attachment_missing", "业绩曲线数据附件缺失",
"input", "block", None, "上传包含产品、基准或代表产品曲线的 CSV/XLSX 文件",
))
return findings
def check_draft(
self, draft: dict[str, Any], inputs: dict[str, Any]
) -> list[ComplianceFinding]:
text = self._flatten_text(draft)
findings: list[ComplianceFinding] = []
for phrase in BLOCKED_PHRASES:
if phrase in text:
findings.append(ComplianceFinding(
"copy.blocked_phrase", "宣传禁用表达", "generated_text", "block",
phrase, "删除收益承诺、无风险或快速募集导向的表达",
))
if MANDATORY_RISK_DISCLOSURE not in text:
findings.append(ComplianceFinding(
"disclosure.mandatory", "法定风险声明缺失", "generated_text", "block",
None, "必须保留完整风险声明",
))
manager = inputs.get("manager_info", {})
if manager.get("employment_years") and manager.get("investment_management_experience"):
if re.search(r"(从业年限|投资管理经验).{0,8}(等同|即|就是|等于)", text):
findings.append(ComplianceFinding(
"manager.experience_distinction", "从业年限与投资管理经验混淆",
"generated_text", "block", "从业年限/投资管理经验",
"分别说明两个口径,不得互相替代",
))
performance = inputs.get("performance_info", {})
if (
performance.get("show_product_performance")
or performance.get("show_manager_performance")
):
months = performance.get("history_months")
if not isinstance(months, int) or months <= 6:
findings.append(ComplianceFinding(
"performance.history_claim", "业绩展示区间不超过六个月",
"generated_text", "block", "本基金历史业绩", "改为不展示本基金业绩",
))
has_return = any(
performance.get(key)
for key in ("product_return", "max_drawdown", "volatility", "sharpe_ratio")
)
has_risk = all(
performance.get(key)
for key in ("max_drawdown", "volatility", "sharpe_ratio")
)
shows_performance = (
performance.get("show_product_performance")
or performance.get("show_manager_performance")
)
if (has_return or shows_performance) and not has_risk:
findings.append(ComplianceFinding(
"performance.risk_metrics_missing", "业绩展示缺少风险指标",
"generated_text", "block", None, "同时展示最大回撤、波动率和夏普比率",
))
return findings
@staticmethod
def _years(value: object) -> int:
match = re.search(r"\d+", str(value or ""))
return int(match.group()) if match else 0
@staticmethod
def _flatten_text(value: object) -> str:
if isinstance(value, dict):
return " ".join(
PromotionComplianceChecker._flatten_text(item) for item in value.values()
)
if isinstance(value, list):
return " ".join(PromotionComplianceChecker._flatten_text(item) for item in value)
return str(value or "")