feat: 添加可配置场景注册表与规则引擎

- 新增场景基类、配置加载、注册表与原语模块
- 添加 r10_refund_split 规则及场景 JSON Schema
- 扩展 scan 引擎与 scenarios API
- 新增场景注册表/配置/集成测试
- 更新前端 App、api、labels 支持新场景
This commit is contained in:
selfrelease
2026-06-17 09:57:02 +08:00
parent cbaa2156fc
commit 74e35fed96
20 changed files with 998 additions and 2 deletions
+210
View File
@@ -0,0 +1,210 @@
"""检测原语库(B 快捷通道):可被 YAML 配置场景复用的通用检测/评分模式。
每个原语接收数据行(list[dict])与参数(dict),返回统一的 PrimitiveResult。
新增"同构"场景时,可在 YAML 中通过 detector 引用原语、params 传参,无需编写 Python。
若遇到原语无法表达的全新算法,则回到 A 方案实现一个 BaseScenario 子类(或新增原语)。
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
# 原语统一签名:(rows, params) -> PrimitiveResult
PrimitiveFn = Callable[[list[dict], dict], "PrimitiveResult"]
@dataclass
class PrimitiveResult:
"""原语输出:是否命中、评分(0-1)、证据、主体、可读简述与可插值指标。"""
hit: bool
score: float
evidence: dict = field(default_factory=dict)
subjects: dict = field(default_factory=dict)
summary: str = "" # 供 rationale_template 的 {summary} 占位
metrics: dict = field(default_factory=dict) # 供 rationale_template 的其它占位
# 全局原语注册表:name -> 原语函数
PRIMITIVE_REGISTRY: dict[str, PrimitiveFn] = {}
def register_primitive(name: str) -> Callable[[PrimitiveFn], PrimitiveFn]:
"""函数装饰器:把检测原语注册到全局表。"""
def deco(fn: PrimitiveFn) -> PrimitiveFn:
PRIMITIVE_REGISTRY[name] = fn
return fn
return deco
def get_primitive(name: str) -> PrimitiveFn | None:
"""按名称查找已注册的检测原语。"""
return PRIMITIVE_REGISTRY.get(name)
def _to_float(value: object, default: float = 0.0) -> float:
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
@register_primitive("threshold_edge")
def threshold_edge(rows: list[dict], params: dict) -> PrimitiveResult:
"""金额集中在审批阈值边缘 [edge_ratio*threshold, threshold) 的拆单型模式。
params:
amount_field (str): 金额字段名
threshold (float): 审批阈值(>0
edge_ratio (float): 边缘下界比例,默认 0.8
min_count (int): 触发命中的最小记录数,默认 3
key_field (str, 可选): 主体字段名(用于汇总涉及主体)
"""
amount_field = params["amount_field"]
threshold = _to_float(params.get("threshold"))
edge_ratio = _to_float(params.get("edge_ratio", 0.8), 0.8)
min_count = int(params.get("min_count", 3))
if threshold <= 0:
raise ValueError("threshold 必须为正数")
lower = edge_ratio * threshold
near = [r for r in rows if lower <= _to_float(r.get(amount_field)) < threshold]
ratio = (len(near) / len(rows)) if rows else 0.0
total = sum(_to_float(r.get(amount_field)) for r in near)
hit = len(near) >= min_count
score = 0.0
if hit:
score = min(0.6, 0.1 * len(near)) + 0.2 * ratio
score = round(min(score, 1.0), 3)
subjects: dict = {}
key_field = params.get("key_field")
if key_field:
subjects = {
"keys": sorted(
{str(r.get(key_field)) for r in near if r.get(key_field) is not None}
)
}
return PrimitiveResult(
hit=hit,
score=score,
evidence={
"near_count": len(near),
"edge_ratio": round(ratio, 3),
"near_amount": total,
"threshold": threshold,
},
subjects=subjects,
summary=(
f"检测到 {len(near)} 条记录金额集中在审批阈值 {threshold:.0f} 的边缘区间"
f"(占比 {ratio:.0%}),合计约 {total:.0f}"
),
metrics={
"near_count": len(near),
"ratio": round(ratio, 3),
"near_amount": total,
"threshold": threshold,
},
)
@register_primitive("cliff_drop")
def cliff_drop(rows: list[dict], params: dict) -> PrimitiveResult:
"""时序断崖:按 index 排序后,相邻点 value 骤降超过 cliff_drop 即命中。
params:
index_field (str): 序号/时间字段名
value_field (str): 取值字段名(如留存率)
cliff_drop (float): 触发断崖的最小跌幅,默认 0.5
"""
index_field = params["index_field"]
value_field = params["value_field"]
cliff = _to_float(params.get("cliff_drop", 0.5), 0.5)
ordered = sorted(rows, key=lambda r: _to_float(r.get(index_field)))
max_drop = 0.0
cliff_at: object = None
for prev, cur in zip(ordered, ordered[1:], strict=False):
drop = _to_float(prev.get(value_field)) - _to_float(cur.get(value_field))
if drop > max_drop:
max_drop = drop
if drop >= cliff:
cliff_at = cur.get(index_field)
hit = cliff_at is not None
max_drop = round(max_drop, 3)
score = round(min(0.4 + 0.5 * max_drop, 1.0), 3) if hit else round(0.3 * max_drop, 3)
return PrimitiveResult(
hit=hit,
score=score,
evidence={"cliff_at": cliff_at, "max_drop": max_drop},
summary=(
f"{cliff_at} 处出现断崖式骤降(最大跌幅 {max_drop:.0%}"
if hit
else f"未见明显断崖(最大跌幅 {max_drop:.0%}"
),
metrics={"cliff_at": cliff_at, "max_drop": max_drop},
)
@register_primitive("ratio_threshold")
def ratio_threshold(rows: list[dict], params: dict) -> PrimitiveResult:
"""聚合比率/阈值判定:对某字段做聚合并与阈值比较,命中则给定分。
params:
value_field (str): 取值字段名
agg (str): 聚合方式 mean/sum/max/min/count,默认 mean
op (str): 比较运算 >=,>,<=,<,默认 >=
threshold (float): 比较阈值
score (float): 命中评分,默认 0.8
"""
value_field = params["value_field"]
agg = str(params.get("agg", "mean"))
op = str(params.get("op", ">="))
threshold = _to_float(params.get("threshold"))
hit_score = _to_float(params.get("score", 0.8), 0.8)
values = [_to_float(r.get(value_field)) for r in rows]
if not values and agg != "count":
return PrimitiveResult(hit=False, score=0.0, summary="无数据,未触发")
if agg == "sum":
actual = sum(values)
elif agg == "max":
actual = max(values)
elif agg == "min":
actual = min(values)
elif agg == "count":
actual = float(len(values))
else: # mean
actual = sum(values) / len(values)
comparators = {
">=": lambda a, b: a >= b,
">": lambda a, b: a > b,
"<=": lambda a, b: a <= b,
"<": lambda a, b: a < b,
}
cmp = comparators.get(op)
if cmp is None:
raise ValueError(f"不支持的比较运算: {op}")
hit = cmp(actual, threshold)
score = round(hit_score, 3) if hit else 0.0
return PrimitiveResult(
hit=hit,
score=score,
evidence={"agg": agg, "actual": round(actual, 3), "op": op, "threshold": threshold},
summary=(
f"{value_field}{agg}{actual:.3g}{op} 阈值 {threshold:.3g}"
if hit
else f"{value_field}{agg}{actual:.3g},未达阈值 {threshold:.3g}"
),
metrics={"actual": round(actual, 3), "threshold": threshold},
)