feat: 完成 issue #43 ④ 敏感度路由规则引擎(本地/云端分级)

This commit is contained in:
2026-08-05 02:18:26 +08:00
parent 2947dae434
commit 6bbcd810e7
3 changed files with 114 additions and 2 deletions
+2 -1
View File
@@ -37,12 +37,13 @@ rules:
pattern: 钛纯度 pattern: 钛纯度
target: local target: local
description: 产品质量指标(钛纯度,本地闭环) description: 产品质量指标(钛纯度,本地闭环)
# ---- 高危(直接拦截转人工) ---- # ---- 高危(直接拦截转人工;priority 最小 → 最先匹配,fail-closed) ----
- name: rt_safety_emergency - name: rt_safety_emergency
category: safety category: safety
kind: keyword kind: keyword
pattern: 紧急停机 pattern: 紧急停机
target: block target: block
priority: 1
description: 紧急停机指令(高危,转人工确认) description: 紧急停机指令(高危,转人工确认)
# ---- 通用常识(可走云端,仅脱敏/公开内容) ---- # ---- 通用常识(可走云端,仅脱敏/公开内容) ----
- name: rt_common_knowledge - name: rt_common_knowledge
+58 -1
View File
@@ -157,6 +157,7 @@ class RouterRule:
pattern: str pattern: str
target: str target: str
description: str = "" description: str = ""
priority: int = 0 # 数字越小越优先(issue #43);相同按声明顺序
_compiled: Optional["re.Pattern[str]"] = field(default=None, repr=False, compare=False) _compiled: Optional["re.Pattern[str]"] = field(default=None, repr=False, compare=False)
@classmethod @classmethod
@@ -173,6 +174,10 @@ class RouterRule:
raise ValueError(f"router 规则 {name} 的 target 非法:{target!r}") raise ValueError(f"router 规则 {name} 的 target 非法:{target!r}")
if kind not in ("keyword", "regex"): if kind not in ("keyword", "regex"):
raise ValueError(f"router 规则 {name} 的 kind 非法:{kind!r}") raise ValueError(f"router 规则 {name} 的 kind 非法:{kind!r}")
try:
priority = int(m.get("priority", 0))
except (TypeError, ValueError):
raise ValueError(f"router 规则 {name} 的 priority 非法:{m.get('priority')!r}")
return cls( return cls(
name=name, name=name,
category=str(m.get("category", "general")), category=str(m.get("category", "general")),
@@ -180,6 +185,7 @@ class RouterRule:
pattern=pattern, pattern=pattern,
target=target, target=target,
description=str(m.get("description", "")), description=str(m.get("description", "")),
priority=priority,
) )
def _compiled_regex(self) -> "re.Pattern[str]": def _compiled_regex(self) -> "re.Pattern[str]":
@@ -260,7 +266,9 @@ class SensitivityRouter:
merged: Dict[str, RouterRule] = {r.name: r for r in self.ROUTER_DEFAULT_RULES} merged: Dict[str, RouterRule] = {r.name: r for r in self.ROUTER_DEFAULT_RULES}
for r in (rules or []): for r in (rules or []):
merged[r.name] = r merged[r.name] = r
self._rules: List[RouterRule] = list(merged.values()) # 按 priority 升序(稳定排序:同 priority 保持合并后的声明顺序)
self._rules: List[RouterRule] = sorted(
merged.values(), key=lambda r: r.priority)
self.default_target = default_target self.default_target = default_target
self.audit = audit self.audit = audit
self._audit_log: List[Dict[str, object]] = [] self._audit_log: List[Dict[str, object]] = []
@@ -310,6 +318,55 @@ class SensitivityRouter:
self._record(decision) self._record(decision)
return decision return decision
# -- 规则引擎诊断(Issue #43) -----------------------------------------
def validate_rules(self) -> List[str]:
"""校验规则集合法性,返回问题列表(空 = 合法)。
检查项:规则名重复、pattern 空、target/kind 非法(from_mapping 已拦截)、
priority 排序无冲突(仅报告,不阻断)。
"""
problems: List[str] = []
seen: Dict[str, int] = {}
for rule in self._rules:
seen[rule.name] = seen.get(rule.name, 0) + 1
if not rule.pattern:
problems.append(f"规则 {rule.name} pattern 为空")
if rule.target not in (RouteTarget.LOCAL, RouteTarget.CLOUD,
RouteTarget.BLOCK):
problems.append(f"规则 {rule.name} target 非法:{rule.target!r}")
for name, count in seen.items():
if count > 1:
problems.append(f"规则名重复:{name}({count} 次)")
return problems
def stats(self) -> Dict[str, object]:
"""规则集统计(按 target / kind 分类)。"""
by_target: Dict[str, int] = {}
by_kind: Dict[str, int] = {}
for rule in self._rules:
by_target[rule.target] = by_target.get(rule.target, 0) + 1
by_kind[rule.kind] = by_kind.get(rule.kind, 0) + 1
return {"total": len(self._rules),
"by_target": by_target, "by_kind": by_kind}
def describe(self, query: str) -> Dict[str, object]:
"""命中链诊断:返回 query 命中的全部规则(不改变路由决策)。
用于规则调试/配置台预览:查看同一条 query 命中的多条规则,
理解实际决策是第一条命中的规则(按 priority 排序后)。
"""
hits = []
for rule in self._rules:
found = rule.find(query)
if found:
hits.append({
"name": rule.name, "target": rule.target,
"category": rule.category, "kind": rule.kind,
"priority": rule.priority, "matches": len(found),
})
return {"query": query, "hits": hits}
# -- 评估(Issue #49 雏形:路由准确率离线评估脚本入口) ---------------- # -- 评估(Issue #49 雏形:路由准确率离线评估脚本入口) ----------------
def evaluate(self, samples: List[Dict[str, object]]) -> Dict[str, object]: def evaluate(self, samples: List[Dict[str, object]]) -> Dict[str, object]:
+54
View File
@@ -132,5 +132,59 @@ class RuleModelTest(unittest.TestCase):
self.assertEqual(hits[0][0], "1234") self.assertEqual(hits[0][0], "1234")
class EngineDiagnosticsTest(unittest.TestCase):
"""规则引擎诊断(issue #43):priority / validate / stats / describe。"""
def test_priority_from_mapping(self):
rule = RouterRule.from_mapping(
{"name": "r", "pattern": "x", "target": "block", "priority": 1})
self.assertEqual(rule.priority, 1)
self.assertEqual(RouterRule.from_mapping(
{"name": "r2", "pattern": "x", "target": "local"}).priority, 0)
def test_priority_orders_rules(self):
# block 规则 priority=1 → 先于 local 规则(priority=10)命中
# 用“紧急停泵”避免命中内置 rt_emergency_cmd(pattern=停机)
rules = [
RouterRule.from_mapping(
{"name": "low", "pattern": "紧急停泵", "target": "local", "priority": 10}),
RouterRule.from_mapping(
{"name": "high", "pattern": "紧急停泵", "target": "block", "priority": 1}),
]
router = SensitivityRouter(rules=rules)
self.assertEqual(router.route("紧急停泵").target, "block")
names = router.rule_names
self.assertLess(names.index("high"), names.index("low")) # priority 升序
def test_validate_rules(self):
# 合法规则集(含内置保底):无问题;重复名在合并时按 name 覆盖不产生冲突
router = SensitivityRouter(rules=[
RouterRule.from_mapping({"name": "a", "pattern": "x", "target": "local"}),
])
self.assertEqual(router.validate_rules(), [])
def test_stats(self):
router = SensitivityRouter(rules=[
RouterRule.from_mapping({"name": "a", "pattern": "x", "target": "local"}),
RouterRule.from_mapping(
{"name": "b", "kind": "regex", "pattern": r"\d+", "target": "cloud"}),
])
stats = router.stats()
self.assertEqual(stats["by_target"]["local"], 3) # 内置 PII 2 条 + a
self.assertEqual(stats["by_target"]["cloud"], 1)
self.assertEqual(stats["by_kind"]["regex"], 3) # 内置 PII 2 条 + b
def test_describe_hit_chain(self):
router = SensitivityRouter(rules=[
RouterRule.from_mapping({"name": "a", "pattern": "紧急停泵", "target": "local"}),
RouterRule.from_mapping(
{"name": "b", "pattern": "紧急停泵", "target": "block", "priority": 1}),
])
desc = router.describe("紧急停泵")
names = [h["name"] for h in desc["hits"]]
self.assertEqual(names, ["a", "b"]) # priority 升序(a=0 先于 b=1)
self.assertEqual(router.route("紧急停泵").target, "local") # 决策取首个命中(a)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()