validator.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. # -*- coding: utf-8 -*-
  2. """规则校验器(RULE VALIDATOR)。
  3. 按 ARCHITECTURE.md Phase 7 要求,规则生成后必须运行三项校验:
  4. 1. **Schema 校验** — YAML 文件结构符合 ``schema/pattern_schema.json``
  5. 2. **黄金样例** — 正例必须匹配,负例必须不匹配
  6. 3. **正则性能** — 编译时间 < 100ms,单次匹配时间 < 1ms
  7. 使用方式::
  8. python -m BiddingKG.dl.rules.validator
  9. # 或
  10. from BiddingKG.dl.rules.validator import RuleValidator
  11. RuleValidator.run_all()
  12. 依赖方向:rules 只依赖 common,不反向依赖业务层。
  13. """
  14. from __future__ import absolute_import
  15. import os
  16. import re
  17. import time
  18. import json
  19. from typing import List, Dict, Tuple
  20. import yaml
  21. from BiddingKG.dl.rules.loader import load_rule_yaml
  22. # ----------------------------------------------------------------------------
  23. # 路径常量
  24. # ----------------------------------------------------------------------------
  25. _RULES_DIR = os.path.dirname(os.path.abspath(__file__))
  26. _PATTERNS_DIR = os.path.join(_RULES_DIR, "patterns")
  27. _SCHEMA_DIR = os.path.join(_RULES_DIR, "schema")
  28. # 性能阈值
  29. _COMPILE_TIME_THRESHOLD_MS = 100 # 正则编译时间阈值
  30. _MATCH_TIME_THRESHOLD_MS = 1 # 单次匹配时间阈值
  31. _PERF_MATCH_ITERATIONS = 1000 # 性能测试迭代次数
  32. # JSON Schema 可选依赖
  33. try:
  34. import jsonschema
  35. _HAS_JSONSCHEMA = True
  36. except ImportError:
  37. _HAS_JSONSCHEMA = False
  38. class RuleValidator:
  39. """规则校验器。"""
  40. def __init__(self):
  41. self.schema = self._load_schema()
  42. self.results = {
  43. "schema": [], # [(file, passed, message)]
  44. "golden": [], # [(pattern_id, passed, message)]
  45. "performance": [], # [(pattern_id, passed, compile_ms, match_ms, message)]
  46. }
  47. # ------------------------------------------------------------------------
  48. # Schema 加载
  49. # ------------------------------------------------------------------------
  50. def _load_schema(self) -> dict:
  51. path = os.path.join(_SCHEMA_DIR, "pattern_schema.json")
  52. with open(path, encoding="utf-8") as f:
  53. return json.load(f)
  54. # ------------------------------------------------------------------------
  55. # 1. Schema 校验
  56. # ------------------------------------------------------------------------
  57. def validate_schema(self) -> bool:
  58. """校验所有 YAML 文件是否符合 JSON Schema。"""
  59. print("=" * 70)
  60. print("1. Schema 校验")
  61. print("=" * 70)
  62. all_passed = True
  63. for fname in sorted(os.listdir(_PATTERNS_DIR)):
  64. if not fname.endswith((".yaml", ".yml")):
  65. continue
  66. fpath = os.path.join(_PATTERNS_DIR, fname)
  67. with open(fpath, encoding="utf-8") as f:
  68. data = load_rule_yaml(f)
  69. passed, msg = self._validate_one_schema(data, fname)
  70. status = "OK " if passed else "FAIL"
  71. print(" %s %s: %s" % (status, fname, msg if not passed else ""))
  72. self.results["schema"].append((fname, passed, msg))
  73. if not passed:
  74. all_passed = False
  75. n_pass = sum(1 for _, p, _ in self.results["schema"] if p)
  76. n_total = len(self.results["schema"])
  77. print(" ---")
  78. print(" Schema: %d/%d passed" % (n_pass, n_total))
  79. return all_passed
  80. def _validate_one_schema(self, data: dict, fname: str) -> Tuple[bool, str]:
  81. """校验单个 YAML 文件。"""
  82. if _HAS_JSONSCHEMA:
  83. try:
  84. jsonschema.validate(data, self.schema)
  85. return True, ""
  86. except jsonschema.ValidationError as e:
  87. return False, str(e.message)
  88. except jsonschema.SchemaError as e:
  89. return False, "Schema error: %s" % e
  90. else:
  91. return self._validate_basic(data, fname)
  92. def _validate_basic(self, data: dict, fname: str) -> Tuple[bool, str]:
  93. """无 jsonschema 时的基础结构校验。"""
  94. if not isinstance(data, dict):
  95. return False, "顶层应为 dict"
  96. for field in ("version", "category"):
  97. if field not in data:
  98. return False, "缺少必填字段: %s" % field
  99. if "patterns" not in data and "keyword_rules" not in data:
  100. return False, "缺少 patterns 或 keyword_rules"
  101. if "patterns" in data:
  102. for p in data["patterns"]:
  103. if "id" not in p or "pattern" not in p:
  104. return False, "pattern 缺少 id 或 pattern 字段"
  105. if "keyword_rules" in data:
  106. for r in data["keyword_rules"]:
  107. if "id" not in r or "label" not in r or "keywords" not in r:
  108. return False, "keyword_rule 缺少 id/label/keywords 字段"
  109. return True, ""
  110. # ------------------------------------------------------------------------
  111. # 2. 黄金样例
  112. # ------------------------------------------------------------------------
  113. def validate_golden(self) -> bool:
  114. """运行所有黄金样例。"""
  115. print()
  116. print("=" * 70)
  117. print("2. 黄金样例校验")
  118. print("=" * 70)
  119. all_passed = True
  120. for fname in sorted(os.listdir(_PATTERNS_DIR)):
  121. if not fname.endswith((".yaml", ".yml")):
  122. continue
  123. fpath = os.path.join(_PATTERNS_DIR, fname)
  124. with open(fpath, encoding="utf-8") as f:
  125. data = load_rule_yaml(f)
  126. patterns = data.get("patterns") or []
  127. for p in patterns:
  128. pid = p["id"]
  129. examples = p.get("golden_examples") or []
  130. if not examples:
  131. continue
  132. passed, msg = self._run_golden_for_pattern(p)
  133. status = "OK " if passed else "FAIL"
  134. print(" %s %s (%d examples): %s" % (
  135. status, pid, len(examples), msg if not passed else ""))
  136. self.results["golden"].append((pid, passed, msg))
  137. if not passed:
  138. all_passed = False
  139. n_pass = sum(1 for _, p, _ in self.results["golden"] if p)
  140. n_total = len(self.results["golden"])
  141. print(" ---")
  142. print(" Golden: %d/%d patterns passed" % (n_pass, n_total))
  143. return all_passed
  144. def _run_golden_for_pattern(self, p: dict) -> Tuple[bool, str]:
  145. """对单个 pattern 运行黄金样例。"""
  146. pattern_str = p["pattern"]
  147. method = p.get("method", "search")
  148. flags = 0
  149. for f in p.get("flags", []):
  150. flags |= getattr(re, f, 0)
  151. try:
  152. compiled = re.compile(pattern_str, flags)
  153. except re.error as e:
  154. return False, "正则编译失败: %s" % e
  155. examples = p.get("golden_examples") or []
  156. for ex in examples:
  157. text = ex["input"]
  158. should_match = ex["should_match"]
  159. if method == "match":
  160. m = compiled.match(text)
  161. elif method == "fullmatch":
  162. m = compiled.fullmatch(text)
  163. else:
  164. m = compiled.search(text)
  165. matched = m is not None
  166. if matched != should_match:
  167. return False, "输入 %r 期望 match=%s 实际 match=%s" % (
  168. text, should_match, matched)
  169. # 检查期望的匹配组内容
  170. if "expected_group" in ex and m:
  171. actual = m.group(0)
  172. if actual != ex["expected_group"]:
  173. return False, "输入 %r 期望 group(0)=%r 实际=%r" % (
  174. text, ex["expected_group"], actual)
  175. return True, ""
  176. # ------------------------------------------------------------------------
  177. # 3. 正则性能
  178. # ------------------------------------------------------------------------
  179. def validate_performance(self) -> bool:
  180. """正则性能测试。"""
  181. print()
  182. print("=" * 70)
  183. print("3. 正则性能测试")
  184. print("=" * 70)
  185. all_passed = True
  186. for fname in sorted(os.listdir(_PATTERNS_DIR)):
  187. if not fname.endswith((".yaml", ".yml")):
  188. continue
  189. fpath = os.path.join(_PATTERNS_DIR, fname)
  190. with open(fpath, encoding="utf-8") as f:
  191. data = load_rule_yaml(f)
  192. patterns = data.get("patterns") or []
  193. for p in patterns:
  194. pid = p["id"]
  195. examples = p.get("golden_examples") or []
  196. passed, compile_ms, match_ms, msg = self._perf_one_pattern(p, examples)
  197. status = "OK " if passed else "WARN"
  198. detail = "" if passed else " " + msg
  199. print(" %s %s: compile=%.2fms, match=%.3fms%s" % (
  200. status, pid, compile_ms, match_ms, detail))
  201. self.results["performance"].append(
  202. (pid, passed, compile_ms, match_ms, msg))
  203. if not passed:
  204. all_passed = False
  205. n_pass = sum(1 for _, p, _, _, _ in self.results["performance"] if p)
  206. n_total = len(self.results["performance"])
  207. print(" ---")
  208. print(" Performance: %d/%d passed (thresholds: compile<%dms, match<%dms)" % (
  209. n_pass, n_total, _COMPILE_TIME_THRESHOLD_MS, _MATCH_TIME_THRESHOLD_MS))
  210. return all_passed
  211. def _perf_one_pattern(self, p: dict, examples: list) -> Tuple[bool, float, float, str]:
  212. """测试单个 pattern 的性能。"""
  213. pattern_str = p["pattern"]
  214. flags = 0
  215. for f in p.get("flags", []):
  216. flags |= getattr(re, f, 0)
  217. # 编译时间
  218. t0 = time.perf_counter()
  219. try:
  220. compiled = re.compile(pattern_str, flags)
  221. except re.error as e:
  222. return False, 0, 0, "编译失败: %s" % e
  223. t1 = time.perf_counter()
  224. compile_ms = (t1 - t0) * 1000
  225. # 匹配时间(取第一个正例做迭代)
  226. match_ms = 0
  227. test_text = ""
  228. for ex in examples:
  229. if ex.get("should_match", True):
  230. test_text = ex["input"]
  231. break
  232. if test_text:
  233. method = p.get("method", "search")
  234. matcher = getattr(compiled, method)
  235. t0 = time.perf_counter()
  236. for _ in range(_PERF_MATCH_ITERATIONS):
  237. matcher(test_text)
  238. t1 = time.perf_counter()
  239. match_ms = (t1 - t0) / _PERF_MATCH_ITERATIONS * 1000
  240. msg = ""
  241. passed = True
  242. if compile_ms > _COMPILE_TIME_THRESHOLD_MS:
  243. msg += "编译时间 %.2fms > %dms; " % (compile_ms, _COMPILE_TIME_THRESHOLD_MS)
  244. passed = False
  245. if match_ms > _MATCH_TIME_THRESHOLD_MS:
  246. msg += "匹配时间 %.3fms > %dms; " % (match_ms, _MATCH_TIME_THRESHOLD_MS)
  247. passed = False
  248. return passed, compile_ms, match_ms, msg.strip("; ")
  249. # ------------------------------------------------------------------------
  250. # 汇总
  251. # ------------------------------------------------------------------------
  252. def run_all(self) -> bool:
  253. """运行全部三项校验,返回是否全部通过。"""
  254. print("BiddingKG 规则校验")
  255. print("patterns 目录: %s" % _PATTERNS_DIR)
  256. print("schema: %s" % ("jsonschema" if _HAS_JSONSCHEMA else "basic 结构校验"))
  257. print()
  258. s = self.validate_schema()
  259. g = self.validate_golden()
  260. p = self.validate_performance()
  261. print()
  262. print("=" * 70)
  263. print("SUMMARY")
  264. print("=" * 70)
  265. n_schema = sum(1 for _, p, _ in self.results["schema"] if p)
  266. n_golden = sum(1 for _, p, _ in self.results["golden"] if p)
  267. n_perf = sum(1 for _, p, _, _, _ in self.results["performance"] if p)
  268. print(" Schema: %d/%d" % (n_schema, len(self.results["schema"])))
  269. print(" Golden: %d/%d" % (n_golden, len(self.results["golden"])))
  270. print(" Performance: %d/%d" % (n_perf, len(self.results["performance"])))
  271. print(" ---")
  272. all_passed = s and g and p
  273. if all_passed:
  274. print(" ALL CHECKS PASSED")
  275. else:
  276. print(" SOME CHECKS FAILED")
  277. return all_passed
  278. # ----------------------------------------------------------------------------
  279. # 命令行入口
  280. # ----------------------------------------------------------------------------
  281. if __name__ == "__main__":
  282. validator = RuleValidator()
  283. passed = validator.run_all()
  284. exit(0 if passed else 1)