Files
selfrelease 74e35fed96 feat: 添加可配置场景注册表与规则引擎
- 新增场景基类、配置加载、注册表与原语模块
- 添加 r10_refund_split 规则及场景 JSON Schema
- 扩展 scan 引擎与 scenarios API
- 新增场景注册表/配置/集成测试
- 更新前端 App、api、labels 支持新场景
2026-06-17 09:57:02 +08:00

119 lines
4.4 KiB
Python
Raw Permalink 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.
"""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