docs/25 P2。核实后比报告描述的更麻烦一点:合并后的 evidence_snapshot 只留
product_id 与 merged_alerts,**各条规则原有的证据键被塞进了嵌套结构**
(risk_scan_service._merge_same_transaction_alerts:398-404)。而各研判函数读的是
**顶层键**(snapshot.get("ratio") 之类),于是合并过的预警一律读不到证据、降级成
"缺证据无法复核" —— 合并本来是为了少几条噪音,结果把这些预警的研判全废了。
修法:在**研判入口统一摊平**,而不是让每条规则各自去认嵌套结构。
- 新增 _flatten_merged_evidence(detail):顶层已有的键优先(来自 priority_score 最高的
主预警),再按顺序补入各子条目 evidence 里的键;merged_alerts 本身保留,
可追溯性不受影响;非合并结构原样返回。
- 列表级(_assess_list_rule)与详情级(_assess_detail_rule)两个入口都调用它,
所有规则(RW-003/007/012/015/018)一并受益。
新增 tests/unit/service/test_risk_judgement_merged_evidence.py(5 条):非合并结构原样返回、
嵌套键被抬到顶层、冲突时顶层优先、多子条目全部抬平,以及**报告症状的回归** ——
对比"没有 ratio"与"ratio 藏在 merged_alerts 里"两种输入,摊平后 RW-003 的研判结果
不再相同(原先两者都会降级成同一句话)。
ruff / mypy(136 文件) / 637 unit+contract 全绿。
507 lines
20 KiB
Python
507 lines
20 KiB
Python
"""风控预警只读研判草案。
|
||
|
||
本模块只根据现有规则命中条件和证据字段给出复核方向,不执行误报关闭、
|
||
放行、结案或升级等人工处置动作。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import UTC, date, datetime
|
||
from decimal import Decimal, InvalidOperation
|
||
from typing import Any
|
||
|
||
from app.core.timeutil import local_date, local_hour
|
||
|
||
VERDICT_RELEASE = "可考虑放行"
|
||
|
||
# 定投类工单渠道。扫描侧按"有效定投工单"生成 RW-018 预警时接受这两个值
|
||
# (`risk_scan_service.py:294` 的 `work_order.channel in {"定投", "自动定投"}`)。
|
||
# 研判侧必须用**同一份口径**:原先详情级只认 `"定投"`,于是"自动定投"的预警
|
||
# 会出现"扫描按有效工单生成、详情却说未确认有效定投工单"的自相矛盾。
|
||
DIRECT_INVESTMENT_CHANNELS = frozenset({"定投", "自动定投"})
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE = "疑似误报"
|
||
VERDICT_CONTINUE_REVIEW = "继续复核"
|
||
VERDICT_RISK_SUPPORTED = "证据支持风险"
|
||
|
||
_VERDICT_SEVERITY = {
|
||
VERDICT_RISK_SUPPORTED: 4,
|
||
VERDICT_CONTINUE_REVIEW: 3,
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE: 2,
|
||
VERDICT_RELEASE: 1,
|
||
}
|
||
_CONFIDENCE_SEVERITY = {"低": 1, "中": 2, "高": 3}
|
||
|
||
|
||
def assess_alert_list_item(item: dict[str, Any]) -> dict[str, Any]:
|
||
"""根据预警列表字段给出初步复核方向。"""
|
||
rules = _rule_codes(item)
|
||
assessments = [_assess_list_rule(rule, item) for rule in rules]
|
||
return _combine_assessments(item, rules, assessments)
|
||
|
||
|
||
def assess_alert_detail(detail: dict[str, Any]) -> dict[str, Any]:
|
||
"""根据完整证据详情给出规则级只读研判草案。"""
|
||
alert = _mapping(detail.get("alert"))
|
||
rules = _rule_codes(alert)
|
||
assessments = [_assess_detail_rule(rule, detail) for rule in rules]
|
||
return _combine_assessments(alert, rules, assessments)
|
||
|
||
|
||
def _assess_list_rule(rule: str, item: dict[str, Any]) -> dict[str, Any]:
|
||
item = _flatten_merged_evidence(item)
|
||
if rule == "RW-018":
|
||
# 原先这里**完全不看 item**,无条件返回"可考虑放行",而且理由文本是硬编码的
|
||
# "现有摘要显示交易来自有效定投工单"。于是列表里那些渠道并不匹配、或证据里
|
||
# 根本没有工单信息的 RW-018 预警,也被标成"可考虑放行"——等于把待核实的预警
|
||
# 在列表层提前放掉了(详情层另有判断,但列表是风控专员最先看到的一屏)。
|
||
#
|
||
# 现在按 snapshot 里的渠道判断,并与扫描侧(`risk_scan_service.py:294`)
|
||
# 共用同一份渠道口径 —— 两处不一致会造成"扫描认为有效、详情认为未确认"的自相矛盾。
|
||
snapshot = _mapping(item.get("evidence_snapshot"))
|
||
channel = snapshot.get("channel")
|
||
if isinstance(channel, str) and channel in DIRECT_INVESTMENT_CHANNELS:
|
||
return _assessment(
|
||
VERDICT_RELEASE,
|
||
"高",
|
||
[f"命中低优先级频繁交易初筛,关联工单渠道为 {channel},属有效定投场景。"],
|
||
["核验定投工单状态、交易周期和客户授权记录。"],
|
||
)
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"中",
|
||
["命中频繁交易初筛,但证据中未确认有效定投工单。"],
|
||
["核验定投工单状态与客户授权记录后,由风控专员判断是否放行。"],
|
||
)
|
||
if rule == "RW-015":
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"中",
|
||
["命中低风险非正常时段小额操作,初步更偏向运营特征而非高风险欺诈。"],
|
||
["核验登录设备、交易地点和客户当日操作意图。"],
|
||
)
|
||
if rule == "RW-007":
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
["适当性错配需要核对客户风险等级、产品风险等级和交易留痕完整性。"],
|
||
["读取预警详情,核验风险等级差和双录、确认、留痕字段。"],
|
||
)
|
||
if rule in {"RW-003", "RW-012"}:
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
[f"规则 {rule} 属于大额资金或老年客户高风险场景,列表不足以判断误报。"],
|
||
["读取预警详情并核对资金流、历史均值和登录设备证据。"],
|
||
)
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"低",
|
||
["当前规则没有内置误报豁免判断。"],
|
||
["读取完整证据后由风控专员人工复核。"],
|
||
)
|
||
|
||
|
||
def _flatten_merged_evidence(detail: dict[str, Any]) -> dict[str, Any]:
|
||
"""把"合并预警"的嵌套证据摊回顶层,再交给各条规则的研判函数。
|
||
|
||
同一笔交易命中多条规则时,扫描器会把它们合并成一条
|
||
(`risk_scan_service._merge_same_transaction_alerts`)。合并后的 `evidence_snapshot`
|
||
只保留 `product_id` 和 `merged_alerts`,各条**原有的证据键被塞进
|
||
`merged_alerts[].evidence`**;而各研判函数读的是**顶层键**(`snapshot.get("ratio")`
|
||
之类),于是合并过的预警一律读不到证据、降级成"缺证据无法复核" ——
|
||
合并本来是为了少几条噪音,结果把这些预警的研判全废掉了(docs/25 P2)。
|
||
|
||
在入口统一摊平:顶层已有的键优先(它来自 priority_score 最高的那条主预警),
|
||
再按顺序补入各子条目的证据键。所有规则共用这一步,不必各自去认嵌套结构。
|
||
"""
|
||
snapshot = _mapping(detail.get("evidence_snapshot"))
|
||
merged = snapshot.get("merged_alerts")
|
||
if not isinstance(merged, list):
|
||
return detail
|
||
flat = dict(snapshot)
|
||
for entry in merged:
|
||
for key, value in _mapping(_mapping(entry).get("evidence")).items():
|
||
flat.setdefault(key, value)
|
||
return {**detail, "evidence_snapshot": flat}
|
||
|
||
|
||
def _assess_detail_rule(rule: str, detail: dict[str, Any]) -> dict[str, Any]:
|
||
detail = _flatten_merged_evidence(detail)
|
||
if rule == "RW-003":
|
||
return _assess_rw003(detail)
|
||
if rule == "RW-007":
|
||
return _assess_rw007(detail)
|
||
if rule == "RW-012":
|
||
return _assess_rw012(detail)
|
||
if rule == "RW-015":
|
||
return _assess_rw015(detail)
|
||
if rule == "RW-018":
|
||
return _assess_rw018(detail)
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"低",
|
||
[f"规则 {rule} 尚未配置只读误报研判依据。"],
|
||
["由风控专员根据完整证据人工判断。"],
|
||
)
|
||
|
||
|
||
def _assess_rw003(detail: dict[str, Any]) -> dict[str, Any]:
|
||
transaction = _mapping(detail.get("transaction"))
|
||
snapshot = _mapping(detail.get("evidence_snapshot"))
|
||
amount = _decimal(transaction.get("amount"))
|
||
ratio = _decimal(snapshot.get("ratio"))
|
||
if amount is None or ratio is None:
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
["缺少赎回金额或赎回比例,无法复核大额快进快出条件。"],
|
||
["补查对应入金流水和赎回交易金额。"],
|
||
)
|
||
if amount >= Decimal("500000") and ratio >= Decimal("0.8"):
|
||
return _assessment(
|
||
VERDICT_RISK_SUPPORTED,
|
||
"高",
|
||
[
|
||
f"赎回金额 {amount:.2f} 元已达到 500000 元阈值。",
|
||
f"赎回比例 {ratio:.2%} 已达到 80% 阈值。",
|
||
],
|
||
["继续核实资金来源、交易目的和客户风险承受能力。"],
|
||
)
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"高",
|
||
[
|
||
f"当前赎回金额 {amount:.2f} 元或赎回比例 {ratio:.2%} 已不满足规则阈值。",
|
||
],
|
||
["核对交易是否发生冲正、撤销或证据快照是否过期。"],
|
||
)
|
||
|
||
|
||
def _assess_rw007(detail: dict[str, Any]) -> dict[str, Any]:
|
||
customer = _mapping(detail.get("customer"))
|
||
product = _mapping(detail.get("product"))
|
||
work_order = _mapping(detail.get("work_order"))
|
||
investor_value = _risk_level_number(customer.get("investor_type"), "C")
|
||
product_value = _risk_level_number(product.get("risk_level"), "R")
|
||
if investor_value is None or product_value is None:
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
["缺少客户风险等级或产品风险等级,无法复核适当性错配。"],
|
||
["补查客户最新风险测评和产品风险等级。"],
|
||
)
|
||
level_gap = product_value - investor_value
|
||
if level_gap <= 0:
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"高",
|
||
[
|
||
f"当前客户等级 {customer.get('investor_type')} 与产品等级 "
|
||
f"{product.get('risk_level')} 已不存在风险等级差。",
|
||
],
|
||
["核对预警生成时和当前测评记录是否发生变更。"],
|
||
)
|
||
|
||
missing_traces = _missing_traces(product, work_order)
|
||
if not missing_traces:
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"高",
|
||
[
|
||
f"当前存在 {level_gap} 级风险等级差,但产品要求的交易留痕均已具备。",
|
||
],
|
||
["复核留痕时间、录音编号和二次确认记录的真实有效性。"],
|
||
)
|
||
return _assessment(
|
||
VERDICT_RISK_SUPPORTED,
|
||
"高",
|
||
[
|
||
f"客户等级与产品等级相差 {level_gap} 级。",
|
||
f"缺少交易留痕:{'、'.join(missing_traces)}。",
|
||
],
|
||
["继续核实双录、风险揭示确认和二次确认材料。"],
|
||
)
|
||
|
||
|
||
def _assess_rw012(detail: dict[str, Any]) -> dict[str, Any]:
|
||
customer = _mapping(detail.get("customer"))
|
||
transaction = _mapping(detail.get("transaction"))
|
||
age = _age(customer.get("birth_date"))
|
||
amount = _decimal(transaction.get("amount"))
|
||
if age is None or amount is None:
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
["缺少客户年龄或赎回金额,无法复核老年客户异常赎回。"],
|
||
["补查客户出生日期和赎回交易金额。"],
|
||
)
|
||
if age < 65 or amount < Decimal("300000"):
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"高",
|
||
[
|
||
f"当前客户年龄 {age} 岁或赎回金额 {amount:.2f} 元已不满足规则门槛。",
|
||
],
|
||
["核对证据快照是否来自已变更或已冲正的交易。"],
|
||
)
|
||
|
||
# 复核生成条件里的"≥ 3 倍历史均值"。原先这里没有这一项,快照里也没有均值,
|
||
# 于是一笔只达到 2 倍均值的赎回会被判成"证据支持风险" —— 而它的建议文本写着
|
||
# "继续核实一年期历史交易均值",说明作者知道该看,只是当时确实没有数据可看。
|
||
# 现在扫描侧把 average_amount / ratio 写进了快照,这里就能真正核。
|
||
snapshot = _mapping(detail.get("evidence_snapshot"))
|
||
ratio = _decimal(snapshot.get("ratio"))
|
||
if ratio is None:
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
["证据快照中没有历史交易均值,无法复核是否达到规则的 3 倍门槛。"],
|
||
["补查该客户近一年历史交易均值;数据缺失时由风控专员人工判断。"],
|
||
)
|
||
if ratio < Decimal("3"):
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"高",
|
||
[f"赎回金额为历史均值的 {ratio:.2f} 倍,未达到规则的 3 倍门槛。"],
|
||
["核对历史均值口径与统计窗口;确认后按误报关闭。"],
|
||
)
|
||
|
||
confirmed_at = _datetime(transaction.get("confirmed_at"))
|
||
logins = detail.get("login_records")
|
||
latest_login = _latest_successful_login(logins, confirmed_at)
|
||
if latest_login is None:
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
["客户年龄和赎回金额达到门槛,但缺少交易日前的成功登录记录。"],
|
||
["补查交易前登录设备、IP 地区和设备常用性。"],
|
||
)
|
||
if not bool(latest_login.get("is_common_device")):
|
||
return _assessment(
|
||
VERDICT_RISK_SUPPORTED,
|
||
"高",
|
||
[
|
||
f"{age} 岁客户赎回 {amount:.2f} 元。",
|
||
"交易前最近一次成功登录使用非常用设备。",
|
||
],
|
||
["继续核实一年期历史交易均值、客户本人意愿和设备归属。"],
|
||
)
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"中",
|
||
["当前交易前最近一次成功登录使用常用设备。"],
|
||
["继续核验历史交易均值和客户赎回意图。"],
|
||
)
|
||
|
||
|
||
def _assess_rw015(detail: dict[str, Any]) -> dict[str, Any]:
|
||
transaction = _mapping(detail.get("transaction"))
|
||
amount = _decimal(transaction.get("amount"))
|
||
confirmed_at = _datetime(transaction.get("confirmed_at"))
|
||
if amount is None or confirmed_at is None:
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
["缺少交易金额或成交时间。"],
|
||
["补查交易明细和成交时间。"],
|
||
)
|
||
# 必须按**北京时间**判断"凌晨":库里存 UTC,直接取 .hour 会让 [0,6) UTC 变成
|
||
# 北京 08:00–14:00(docs/25 P1 #6)。展示的小时数同样要换算,否则风控专员看到的
|
||
# 是 UTC 时刻、与他的直觉差 8 小时,无法核对。
|
||
if amount <= Decimal("10000") and 0 <= local_hour(confirmed_at) < 6:
|
||
return _assessment(
|
||
VERDICT_RELEASE,
|
||
"中",
|
||
[
|
||
f"交易金额 {amount:.2f} 元较小,并发生在 {local_hour(confirmed_at)} 时。",
|
||
"该规则本身属于低优先级运营特征初筛。",
|
||
],
|
||
["核验设备、交易地点和客户操作意图后人工决定是否放行。"],
|
||
)
|
||
return _assessment(
|
||
VERDICT_SUSPECTED_FALSE_POSITIVE,
|
||
"中",
|
||
["当前金额或成交时段已不满足非正常时段小额操作条件。"],
|
||
["核对证据快照和当前交易记录是否一致。"],
|
||
)
|
||
|
||
|
||
def _assess_rw018(detail: dict[str, Any]) -> dict[str, Any]:
|
||
work_order = _mapping(detail.get("work_order"))
|
||
channel = work_order.get("channel")
|
||
if channel in DIRECT_INVESTMENT_CHANNELS:
|
||
return _assessment(
|
||
VERDICT_RELEASE,
|
||
"高",
|
||
["频繁交易初筛关联有效定投工单,现有证据支持正常定投场景。"],
|
||
["人工核验工单状态、签约周期、扣款授权和交易频率后考虑放行。"],
|
||
)
|
||
return _assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"中",
|
||
["命中频繁交易初筛,但当前未确认有效定投工单。"],
|
||
["补查关联工单渠道、状态和客户授权记录。"],
|
||
)
|
||
|
||
|
||
def _combine_assessments(
|
||
source: dict[str, Any],
|
||
rules: list[str],
|
||
assessments: list[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
if not assessments:
|
||
assessments = [_assessment(
|
||
VERDICT_CONTINUE_REVIEW,
|
||
"低",
|
||
["预警没有可识别的规则编号。"],
|
||
["由风控专员根据完整证据人工复核。"],
|
||
)]
|
||
primary = max(
|
||
assessments,
|
||
key=lambda item: (
|
||
_VERDICT_SEVERITY[item["verdict"]],
|
||
_CONFIDENCE_SEVERITY[item["confidence"]],
|
||
),
|
||
)
|
||
return {
|
||
"alert_no": source.get("alert_no"),
|
||
"rule_codes": rules,
|
||
"verdict": primary["verdict"],
|
||
"confidence": primary["confidence"],
|
||
"reasons": _deduplicate(
|
||
reason for item in assessments for reason in item["reasons"]
|
||
),
|
||
"review_actions": _deduplicate(
|
||
action for item in assessments for action in item["review_actions"]
|
||
),
|
||
"boundary": "仅为只读研判草案,不能替代风控专员人工复核和正式处置。",
|
||
}
|
||
|
||
|
||
def _assessment(
|
||
verdict: str,
|
||
confidence: str,
|
||
reasons: list[str],
|
||
review_actions: list[str],
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"verdict": verdict,
|
||
"confidence": confidence,
|
||
"reasons": reasons,
|
||
"review_actions": review_actions,
|
||
}
|
||
|
||
|
||
def _missing_traces(
|
||
product: dict[str, Any],
|
||
work_order: dict[str, Any],
|
||
) -> list[str]:
|
||
checks = (
|
||
("风险揭示确认", "risk_disclosure_required", "risk_disclosure_ack_at"),
|
||
("二次确认", "second_confirmation_required", "second_confirmation_at"),
|
||
("录音留痕", "recording_required", "recording_reference"),
|
||
)
|
||
missing: list[str] = []
|
||
for label, required_field, evidence_field in checks:
|
||
if bool(product.get(required_field)) and not work_order.get(evidence_field):
|
||
missing.append(label)
|
||
return missing
|
||
|
||
|
||
def _latest_successful_login(
|
||
value: Any,
|
||
confirmed_at: datetime | None,
|
||
) -> dict[str, Any] | None:
|
||
if not isinstance(value, list):
|
||
return None
|
||
candidates = []
|
||
for item in value:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if item.get("login_result") != "成功":
|
||
continue
|
||
login_at = _datetime(item.get("login_at"))
|
||
if login_at is None:
|
||
continue
|
||
if confirmed_at is not None and login_at > confirmed_at:
|
||
continue
|
||
candidates.append((login_at, item))
|
||
if not candidates:
|
||
return None
|
||
return max(candidates, key=lambda item: item[0])[1]
|
||
|
||
|
||
def _rule_codes(value: dict[str, Any]) -> list[str]:
|
||
raw = value.get("rule_codes") or value.get("trigger_rule_codes") or []
|
||
if isinstance(raw, str):
|
||
raw = [raw]
|
||
if not isinstance(raw, (list, tuple)):
|
||
return []
|
||
return _deduplicate(str(item).upper() for item in raw if item)
|
||
|
||
|
||
def _risk_level_number(value: Any, prefix: str) -> int | None:
|
||
if not isinstance(value, str):
|
||
return None
|
||
matched = re.fullmatch(rf"{re.escape(prefix)}(\d+)", value.strip().upper())
|
||
return int(matched.group(1)) if matched else None
|
||
|
||
|
||
def _age(value: Any) -> int | None:
|
||
birth_date = _date(value)
|
||
if birth_date is None:
|
||
return None
|
||
# 用**北京时间**的今天。原先这里用服务器 date.today()(东八区的机器上是北京日期,
|
||
# 但不保证),而扫描侧用 UTC 日期——两处口径不一致会让同一客户在生日边界上差一岁
|
||
# (docs/25 P1 #6)。统一走 timeutil。
|
||
today = local_date(datetime.now(UTC))
|
||
return today.year - birth_date.year - (
|
||
(today.month, today.day) < (birth_date.month, birth_date.day)
|
||
)
|
||
|
||
|
||
def _date(value: Any) -> date | None:
|
||
if isinstance(value, date) and not isinstance(value, datetime):
|
||
return value
|
||
if isinstance(value, datetime):
|
||
return value.date()
|
||
if not isinstance(value, str) or not value.strip():
|
||
return None
|
||
try:
|
||
return date.fromisoformat(value.strip()[:10])
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _datetime(value: Any) -> datetime | None:
|
||
if isinstance(value, datetime):
|
||
return value
|
||
if not isinstance(value, str) or not value.strip():
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _decimal(value: Any) -> Decimal | None:
|
||
if value is None or isinstance(value, bool):
|
||
return None
|
||
try:
|
||
return Decimal(str(value))
|
||
except (InvalidOperation, TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _mapping(value: Any) -> dict[str, Any]:
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
def _deduplicate(values: Any) -> list[str]:
|
||
result: list[str] = []
|
||
for value in values:
|
||
if value not in result:
|
||
result.append(value)
|
||
return result
|