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
@@ -0,0 +1,62 @@
"""通用场景调度器 run_scenarios 的落库集成测试(需 PostgreSQL)。
验证「场景插件 → 统一调度 → 阈值过滤 → 落库」链路,且与旧 run_xxx_scan 行为一致。
"""
from __future__ import annotations
from app.clues.models import ClueStatus, ConfidenceTier
from app.engines import scan
from app.scenarios.churn_fraud import ChurnFraudScenario, CohortPoint
from app.scenarios.split_contract import ContractRecord, SplitContractScenario
def test_run_scenarios_creates_clues(session):
contracts = [ContractRecord(f"C{i}", f"CUST{i}", 850000) for i in range(8)]
curve = [CohortPoint(0, 1.0), CohortPoint(1, 0.95), CohortPoint(2, 0.1)]
scenarios = [
SplitContractScenario(
contracts, approval_threshold=1_000_000, shared_controller=True
),
ChurnFraudScenario(
retention_curve=curve,
commission_paid=300000,
active_ratio=0.05,
zero_usage_ratio=0.9,
channel_key="CH-001",
),
]
results = scan.run_scenarios(session, scenarios)
by_code = {r.scenario_code: r for r in results}
assert by_code["R8"].scanned_count == 8
assert len(by_code["R8"].clues) == 1
r8_clue = by_code["R8"].clues[0]
assert r8_clue.confidence == ConfidenceTier.HIGH
assert r8_clue.status == ClueStatus.NEW
assert r8_clue.amount_involved > 0
assert r8_clue.model_version == scan.MODEL_VERSION
assert by_code["R9"].scanned_count == 3
assert len(by_code["R9"].clues) == 1
assert by_code["R9"].clues[0].scenario_code == "R9"
assert by_code["R9"].clues[0].subjects["channel"] == "CH-001"
def test_run_scenarios_filters_below_threshold(session):
# 干净数据:拆单不命中、养卡曲线平滑且佣金匹配 → 均不应生成线索
contracts = [ContractRecord("C1", "A", 100000), ContractRecord("C2", "B", 3_000_000)]
curve = [CohortPoint(0, 1.0), CohortPoint(1, 0.98), CohortPoint(2, 0.97)]
scenarios = [
SplitContractScenario(contracts, approval_threshold=1_000_000),
ChurnFraudScenario(
retention_curve=curve,
commission_paid=10000,
active_ratio=0.9,
zero_usage_ratio=0.05,
channel_key="CH-X",
),
]
results = scan.run_scenarios(session, scenarios)
for r in results:
assert r.clues == []
+100
View File
@@ -0,0 +1,100 @@
"""配置驱动场景(B 快捷通道)测试:原语库、YAML 加载与校验、ConfigScenario 扫描。
纯逻辑,无需数据库。验证「新增 YAML → 自动注册 → 可扫描产出线索草稿」链路。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.scenarios.config import (
ScenarioConfig,
load_yaml_scenarios,
)
from app.scenarios.primitives import cliff_drop, ratio_threshold, threshold_edge
from app.scenarios.registry import SCENARIO_REGISTRY, get_scenario
# ---------- 原语库 ----------
def test_threshold_edge_primitive_hits():
rows = [{"refund_amount": 450000, "customer": f"C{i}"} for i in range(5)]
res = threshold_edge(
rows,
{"amount_field": "refund_amount", "threshold": 500000, "key_field": "customer"},
)
assert res.hit
assert res.score > 0
assert res.evidence["near_count"] == 5
assert len(res.subjects["keys"]) == 5
def test_threshold_edge_primitive_no_hit_when_spread():
rows = [{"refund_amount": 100000}, {"refund_amount": 900000}]
res = threshold_edge(rows, {"amount_field": "refund_amount", "threshold": 500000})
assert not res.hit
assert res.score == 0.0
def test_threshold_edge_requires_positive_threshold():
with pytest.raises(ValueError):
threshold_edge([], {"amount_field": "x", "threshold": 0})
def test_cliff_drop_primitive():
rows = [{"m": 0, "v": 1.0}, {"m": 1, "v": 0.95}, {"m": 2, "v": 0.1}]
res = cliff_drop(rows, {"index_field": "m", "value_field": "v"})
assert res.hit
assert res.evidence["cliff_at"] == 2
def test_ratio_threshold_primitive():
rows = [{"z": 0.9}, {"z": 0.95}]
res = ratio_threshold(
rows, {"value_field": "z", "agg": "mean", "op": ">=", "threshold": 0.8}
)
assert res.hit
assert res.score == 0.8
# ---------- YAML 配置校验 ----------
def test_scenario_config_validation_error():
with pytest.raises(ValidationError):
ScenarioConfig(code="R99") # 缺 title/risk_domain/label/detector
def test_load_yaml_scenarios_registers_r10():
load_yaml_scenarios() # 加载内置 rules/*.yaml(含 R10 示例)
assert "R10" in SCENARIO_REGISTRY
cls = get_scenario("R10")
assert cls is not None
assert cls.label == "退款拆分"
assert cls.risk_domain == "收入"
# ---------- ConfigScenario 扫描 ----------
def test_config_scenario_scan_produces_clue_draft():
load_yaml_scenarios()
cls = get_scenario("R10")
rows = [{"refund_amount": 460000 + i * 5000, "customer": f"政企{i}"} for i in range(6)]
outcome = cls(rows=rows).scan(None)
assert outcome.scanned_count == 6
assert len(outcome.drafts) == 1
draft = outcome.drafts[0]
assert draft.score > 0
assert "退款" in draft.rationale # rationale_template 生效
assert "keys" in draft.subjects
def test_config_scenario_no_draft_when_clean():
load_yaml_scenarios()
cls = get_scenario("R10")
rows = [
{"refund_amount": 100000, "customer": "A"},
{"refund_amount": 2_000_000, "customer": "B"},
]
outcome = cls(rows=rows).scan(None)
assert outcome.drafts == []
+74
View File
@@ -0,0 +1,74 @@
"""场景插件框架测试:注册表、场景 scan() 产出、/scenarios API(纯逻辑,无需数据库)。"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app
from app.scenarios.base import ScanOutcome
from app.scenarios.churn_fraud import ChurnFraudScenario, CohortPoint
from app.scenarios.registry import SCENARIO_REGISTRY, get_scenario, scenario_meta
from app.scenarios.split_contract import ContractRecord, SplitContractScenario
def test_registry_contains_builtin_scenarios():
# 导入 app.scenarios.* 已通过自动发现触发 @register_scenario 注册 R8/R9
assert "R8" in SCENARIO_REGISTRY
assert "R9" in SCENARIO_REGISTRY
assert get_scenario("R8") is SplitContractScenario
assert get_scenario("R9") is ChurnFraudScenario
def test_scenario_meta_fields():
meta = {m["code"]: m for m in scenario_meta()}
assert meta["R8"]["label"] == "政企拆单"
assert meta["R8"]["risk_domain"] == "收入"
assert meta["R9"]["label"] == "养卡骗补"
assert set(meta["R8"]) == {"code", "title", "label", "risk_domain"}
def test_split_scenario_scan_hits():
contracts = [ContractRecord(f"C{i}", f"CUST{i}", 850000) for i in range(8)]
scenario = SplitContractScenario(
contracts, approval_threshold=1_000_000, shared_controller=True
)
outcome = scenario.scan(None)
assert isinstance(outcome, ScanOutcome)
assert outcome.scanned_count == 8
assert len(outcome.drafts) == 1
draft = outcome.drafts[0]
assert draft.score >= scenario.score_threshold
assert draft.amount_involved > 0
assert draft.evidence["shared_controller"] is True
def test_split_scenario_no_draft_when_clean():
contracts = [ContractRecord("C1", "A", 100000), ContractRecord("C2", "B", 3_000_000)]
outcome = SplitContractScenario(contracts, approval_threshold=1_000_000).scan(None)
assert outcome.drafts == []
assert outcome.scanned_count == 2
def test_churn_scenario_scan_produces_draft():
curve = [CohortPoint(0, 1.0), CohortPoint(1, 0.95), CohortPoint(2, 0.1)]
scenario = ChurnFraudScenario(
retention_curve=curve,
commission_paid=300000,
active_ratio=0.05,
zero_usage_ratio=0.9,
channel_key="CH-001",
)
outcome = scenario.scan(None)
assert outcome.scanned_count == 3
assert len(outcome.drafts) == 1
draft = outcome.drafts[0]
assert draft.subjects["channel"] == "CH-001"
assert draft.score >= scenario.score_threshold
def test_scenarios_api_endpoint():
client = TestClient(app)
resp = client.get("/scenarios")
assert resp.status_code == 200
codes = {item["code"] for item in resp.json()}
assert {"R8", "R9"} <= codes