mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +00:00
134 lines
5.8 KiB
Python
134 lines
5.8 KiB
Python
"""规则 DSL 的编译与 fixture 校验:结构化条件 → 一条只读 SQL,发布前用样例行跑一遍。
|
|
|
|
和口径卡里的 `kind=rule` 不是同一层:口径卡里的规则在单元格上确定性求值(不需要查库);这里给作者和发布检查用,
|
|
把「表 + 条件 → then / else」编译成 SQL,再用 fixture 验证条件写对了。编译只生成 SELECT,不执行任何写操作。
|
|
|
|
规则形状:
|
|
{
|
|
"inputs": {"status": "status", "amount": "amount"}, # 名字 → 表里的列
|
|
"all": [{"input": "status", "op": "eq", "value": "paid"},
|
|
{"input": "amount", "op": "gte", "value": 50}],
|
|
"then": 1, "else": 0
|
|
}
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sqlite3
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
OPS = ("eq", "ne", "gt", "gte", "lt", "lte", "in", "not_in")
|
|
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
|
|
|
|
class RuleInvalid(ValueError):
|
|
"""规则本身写错了:message 给人看。"""
|
|
|
|
|
|
def _ident(value: Any, what: str) -> str:
|
|
text = str(value or "").strip()
|
|
if not _IDENT.match(text):
|
|
raise RuleInvalid(f"{what}「{value}」不是合法的标识符(字母或下划线开头,只含字母、数字、下划线)")
|
|
return text
|
|
|
|
|
|
def _literal(exp: Any, value: Any) -> Any:
|
|
if isinstance(value, bool):
|
|
return exp.Boolean(this=value)
|
|
if isinstance(value, (int, float)):
|
|
return exp.Literal.number(value)
|
|
if value is None:
|
|
return exp.Null()
|
|
return exp.Literal.string(str(value))
|
|
|
|
|
|
def _condition(exp: Any, cond: Mapping[str, Any], inputs: Mapping[str, str]) -> Any:
|
|
name = str(cond.get("input") or "")
|
|
if name not in inputs:
|
|
raise RuleInvalid(f"条件指向了没声明的输入「{name}」")
|
|
column = exp.column(_ident(inputs[name], "列名"))
|
|
op = str(cond.get("op") or "")
|
|
if op not in OPS:
|
|
raise RuleInvalid(f"条件运算符只能是 {'、'.join(OPS)},当前为「{op}」")
|
|
if op in ("in", "not_in"):
|
|
values = cond.get("values")
|
|
if not isinstance(values, list) or not values:
|
|
raise RuleInvalid("in / not_in 需要非空的 values 列表")
|
|
node = exp.In(this=column, expressions=[_literal(exp, v) for v in values])
|
|
return node if op == "in" else exp.Not(this=node)
|
|
if "value" not in cond:
|
|
raise RuleInvalid("条件需要 value")
|
|
right = _literal(exp, cond["value"])
|
|
return {
|
|
"eq": exp.EQ, "ne": exp.NEQ, "gt": exp.GT, "gte": exp.GTE, "lt": exp.LT, "lte": exp.LTE,
|
|
}[op](this=column, expression=right)
|
|
|
|
|
|
def compile_rule(rule: Mapping[str, Any], table: str, *, dialect: str | None = None) -> str:
|
|
"""结构化规则 → `SELECT CASE WHEN … THEN … ELSE … END AS value FROM <table>`。"""
|
|
try:
|
|
import sqlglot
|
|
from sqlglot import exp
|
|
except ImportError as e: # pragma: no cover - 依赖缺失时给一句人话
|
|
raise RuleInvalid("没有安装 sqlglot,无法编译规则") from e
|
|
table_name = _ident(table, "表名")
|
|
if not isinstance(rule, Mapping):
|
|
raise RuleInvalid("规则需要是一个对象")
|
|
inputs = rule.get("inputs")
|
|
if not isinstance(inputs, Mapping) or not inputs:
|
|
raise RuleInvalid("规则需要 inputs:名字 → 表里的列")
|
|
columns = {str(k): _ident(v, "列名") for k, v in inputs.items()}
|
|
conditions = rule.get("all")
|
|
if not isinstance(conditions, list) or not conditions:
|
|
raise RuleInvalid("规则需要 all:至少一条条件")
|
|
where = None
|
|
for cond in conditions:
|
|
node = _condition(exp, cond, columns)
|
|
where = node if where is None else exp.And(this=where, expression=node)
|
|
case = exp.Case(
|
|
ifs=[exp.If(this=where, true=_literal(exp, rule.get("then", 1)))],
|
|
default=_literal(exp, rule.get("else", 0)),
|
|
)
|
|
query = exp.select(exp.alias_(case, "value")).from_(exp.table_(table_name))
|
|
return query.sql(dialect=dialect or "sqlite")
|
|
|
|
|
|
def run_fixtures(rule: Mapping[str, Any], table: str, fixtures: Iterable[Mapping[str, Any]], *,
|
|
dialect: str = "sqlite") -> list[dict[str, Any]]:
|
|
"""用一条只读 SQL 在内存 SQLite 上跑 fixture:每项 {rows, expected},返回 [{ok, expected, actual}]。
|
|
|
|
fixture 的 rows 是列名 → 值;表结构按第一行推(整数 / 实数 / 文本)。空 fixture 返回空列表。
|
|
"""
|
|
items = [dict(f) for f in fixtures]
|
|
if not items:
|
|
return []
|
|
sql = compile_rule(rule, table, dialect="sqlite" if dialect != "sqlite" else dialect)
|
|
db = sqlite3.connect(":memory:")
|
|
try:
|
|
columns: dict[str, str] = {}
|
|
for item in items:
|
|
for row in item.get("rows") or []:
|
|
if not isinstance(row, Mapping):
|
|
raise RuleInvalid("fixture 的 rows 需要是对象")
|
|
for name, value in row.items():
|
|
key = _ident(name, "fixture 列名")
|
|
columns.setdefault(key, "INTEGER" if isinstance(value, int) and not isinstance(value, bool)
|
|
else "REAL" if isinstance(value, float) else "TEXT")
|
|
if not columns:
|
|
raise RuleInvalid("fixture 需要至少一行 rows")
|
|
db.execute(f"CREATE TABLE {table} (" + ", ".join(f"{name} {kind}" for name, kind in columns.items()) + ")")
|
|
out: list[dict[str, Any]] = []
|
|
for item in items:
|
|
db.execute(f"DELETE FROM {table}")
|
|
for row in item.get("rows") or []:
|
|
db.execute(f"INSERT INTO {table} ({', '.join(row)}) VALUES ({', '.join('?' for _ in row)})",
|
|
list(row.values()))
|
|
actual = db.execute(sql).fetchone()[0]
|
|
out.append({"ok": actual == item.get("expected"), "expected": item.get("expected"), "actual": actual})
|
|
return out
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
__all__ = ["OPS", "RuleInvalid", "compile_rule", "run_fixtures"]
|