Files
group_fqcd_jr/app/service/risk_judgement_service.py
T
lzf_0626 b241059c2a fix(risk): 合并预警的嵌套证据在研判入口摊平,RW-003 不再一律降级
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 全绿。
2026-09-11 13:49:57 +08:00

507 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""风控预警只读研判草案。
本模块只根据现有规则命中条件和证据字段给出复核方向,不执行误报关闭、
放行、结案或升级等人工处置动作。
"""
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