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
+118
View File
@@ -0,0 +1,118 @@
"""YAML 配置驱动场景(B 快捷通道):用「配置 + 原语库」声明同构场景,免写 Python。
加载 scenarios/rules/*.yaml,经 pydantic 校验后为每条配置动态生成一个 BaseScenario
子类并注册。新增此类场景 = 新增一个 YAML 文件,无需改动任何 Python 代码。
运行时与代码场景一致:实例化后注入数据行 rows,由 run_scenarios 统一调度落库。
"""
from __future__ import annotations
import uuid
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, ValidationError
from app.scenarios.base import BaseScenario, ClueDraft, ScanOutcome
from app.scenarios.primitives import get_primitive
from app.scenarios.registry import register_scenario
_DEFAULT_RULES_DIR = Path(__file__).parent / "rules"
class ScenarioConfig(BaseModel):
"""YAML 场景配置的 schemapydantic 校验,字段缺失/类型错误即报错)。"""
code: str = Field(min_length=1)
title: str = Field(min_length=1)
risk_domain: str = Field(min_length=1)
label: str = Field(min_length=1)
detector: str = Field(min_length=1)
params: dict = Field(default_factory=dict)
threshold: float = 0.5
rationale_template: str = "{summary}"
rule_version: str | None = None
class ConfigScenario(BaseScenario):
"""由 YAML 配置驱动的场景:运行时按 detector 调用原语,模板生成人话理由。
config 由动态子类(build_scenario_class)设置;数据行 rows 在实例化时注入。
"""
config: ScenarioConfig
def __init__(self, rows: list[dict] | None = None) -> None:
self._rows = rows or []
def with_rows(self, rows: list[dict]) -> ConfigScenario:
"""注入待扫描的数据行(链式返回自身)。"""
self._rows = rows
return self
def scan(self, session, *, data_version_id: uuid.UUID | None = None) -> ScanOutcome:
fn = get_primitive(self.config.detector)
if fn is None:
raise ValueError(f"未知检测原语: {self.config.detector}")
result = fn(self._rows, self.config.params)
drafts: list[ClueDraft] = []
if result.hit and result.score > 0:
try:
rationale = self.config.rationale_template.format(
summary=result.summary, **result.metrics
)
except (KeyError, IndexError, ValueError):
rationale = result.summary
drafts.append(
ClueDraft(
score=result.score,
rationale=rationale,
evidence=result.evidence,
subjects=result.subjects,
)
)
return ScanOutcome(scanned_count=len(self._rows), drafts=drafts)
def build_scenario_class(config: ScenarioConfig) -> type[ConfigScenario]:
"""按配置动态生成并注册一个 ConfigScenario 子类(类属性承载元数据)。"""
attrs = {
"code": config.code,
"title": config.title,
"risk_domain": config.risk_domain,
"label": config.label,
"score_threshold": config.threshold,
"rule_version": config.rule_version,
"config": config,
}
cls = type(f"ConfigScenario_{config.code}", (ConfigScenario,), attrs)
return register_scenario(cls)
def load_scenario_config(path: Path) -> ScenarioConfig:
"""读取并校验单个 YAML 场景配置文件。"""
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
try:
config = ScenarioConfig(**raw)
except ValidationError as exc:
raise ValueError(f"场景配置 {path.name} 校验失败: {exc}") from exc
if get_primitive(config.detector) is None:
raise ValueError(
f"场景配置 {path.name} 引用了未注册的检测原语: {config.detector}"
)
return config
def load_yaml_scenarios(rules_dir: Path | str | None = None) -> list[type[ConfigScenario]]:
"""加载 rules 目录下全部 *.yaml 场景配置,动态生成并注册场景类。
新增 YAML 文件即被发现,无需改动代码。返回新注册的场景类列表(便于测试)。
"""
rules_dir = Path(rules_dir) if rules_dir else _DEFAULT_RULES_DIR
classes: list[type[ConfigScenario]] = []
if not rules_dir.exists():
return classes
for path in sorted(rules_dir.glob("*.yaml")):
config = load_scenario_config(path)
classes.append(build_scenario_class(config))
return classes