"""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 场景配置的 schema(pydantic 校验,字段缺失/类型错误即报错)。""" 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