用 Python 标准库写一个可解释的规则引擎:从 JSON 规则到分级评分
很多内容相关的判断需求,第一反应是「丢给模型打分」。但有些场景不适合:结论要能复现、要能追溯到条款、要能在流水线里跑成千上万次,而且每次结果必须一样。
这类场景适合规则引擎。这篇讲怎么用纯标准库写一个,重点是可解释和误报控制这两件事。
一、规则长什么样
一条规则就是一段 JSON:
{
"id": "ABS-001",
"category": "absolute",
"severity": "high",
"pattern": "最(好|佳|棒|优|强|低|便宜|先进|专业)",
"suggestion": "改为可验证的相对表述,如「我用过的几款里比较顺手」",
"reference": "《广告法》第九条"
}
五个字段各有分工:
id是稳定标识,报告里要能指回这一条category用来做归类统计severity决定扣多少分pattern是 Python 正则suggestion是给人看的改写建议reference是依据,这一条最容易被忽略,但它是「可解释」的核心
加规则只需要往数组里加一条,不用动代码。
二、模式要写得窄
这是整个设计里最需要克制的地方。
「最好」这个词本身没问题——「这道菜最好趁热吃」完全正常。所以模式不能写 最好,要写 最(好|佳|棒|...) 把它限制在形容词位,而不是程度副词位。
同理,如果要匹配「治疗」,就不能只写 治疗,因为在「治疗流程」「治疗结束后」这种描述性语境里它是中性的。可以收窄成 (可|能|用于|能够)治疗 这类带谓词前缀的组合。
模式写宽的代价是误报,而误报会直接杀死工具的可用性。 用户被误报烦两次,就不用了。
三、评分:减分制,不是加权求和
WEIGHTS = {"high": 30, "medium": 15, "low": 5}
def score(hits):
lost = 0
seen = set()
for h in hits:
if h.rule_id in seen:
continue
seen.add(h.rule_id)
lost += WEIGHTS.get(h.severity, 0)
return max(0, 100 - lost)
两个细节:
同一条规则只扣一次。 一篇文章里出现五次同一个词,那是同一个问题,不该扣五次。否则长文本会天然吃亏。
下限 0,不做负分。 负分没有信息量,用户只需要知道「很糟」和「具体哪里糟」。
为什么不用加权模型?因为不可解释。用户看到 43 分,得能一眼数出来是哪两条 high 造成的。加权模型给不出这个。
四、输出两种格式
默认 Markdown,给人看:
- 风险分:40 / 100
- 风险等级:中风险
| 编号 | 类别 | 等级 | 原文 | 建议 |
|---|---|---|---|---|
| ABS-001 | 绝对化用语 | high | 最好 / 最优 | 改为可验证的相对表述 |
--format json 给流水线:
{
"score": 40,
"level": "中风险",
"hits": [
{"rule_id": "ABS-001", "severity": "high", "occurrences": [...]}
]
}
同一份计算结果,两种呈现。不要在内部先算成一段文本再反解析,那样机器读起来很痛苦。
五、误报控制:否定语境
规则匹配最典型的误报来源是否定和反讽。
> 别信那些说几天就能见效的话术。
这句里有「见效」,字面命中效果承诺类规则,但它恰恰是在劝人别信。
处理方式是在命中词前若干字符内找否定线索:
NEG_CUES = re.compile(
r"(别信|不要信|别买|不是|谁说|避免|拒绝|切勿|没有|无需|不再|不会|智商税|踩坑|别再)"
)
WINDOW = 24
def is_negated(text, start):
return bool(NEG_CUES.search(text[max(0, start - WINDOW):start]))
命中否定线索就跳过计分。
关键点在于:不要直接丢弃。
if is_negated(text, m.start()):
suppressed.append(item) # 单独收集
continue
这些被跳过的命中要在报告里单独列出来,标上「已忽略,建议人工确认」。
理由很简单:如果静默丢弃,用户会以为漏检;列出来但不计分,既降低了误报,又没有隐藏信息。这个取舍是整套设计里最值钱的一处。
六、测试要覆盖什么
除了常规的功能测试,这类规则库有两类测试特别重要:
def test_rule_ids_unique(self):
ids = [r["id"] for r in self.rules["rules"]]
self.assertEqual(len(ids), len(set(ids)))
def test_all_patterns_compile(self):
for r in self.rules["rules"]:
re.compile(r["pattern"])
规则库会一直长。id 重复和正则写错,是增量维护时最容易崩的两个点,而且都是运行时才炸。把它们变成单元测试,加规则的时候就能立刻发现。
另外要有黄金样例:一份故意写满问题的样本,一份改写后的干净样本,断言分数落在预期区间。这两条测试能挡住大部分「改了一条规则,另一类全崩」的回归。
七、几个工程细节
匹配要限次。 同一条规则命中几十次时,只保留前三条上下文就够了,报告不会变成噪音。
上下文要带。 每条命中附带命中词前后各十几个字符,用户不用回原文找位置。
规则要能按类别关。 流水线里可能只需要某一类检查,加个 --only 参数比拆库简单。
报告里保留 reference。 这是它和「模型说这样不行」的根本区别:用户可以自己去查条款,判断到底是规则写错了还是内容确实有问题。
八、边界
规则引擎做的是有明确文本特征的判断。语义层面的东西它做不了,也不该硬做。
我的做法是把「查」和「改」分开:规则引擎负责查,结果确定可复现;需要语境理解的改写交给模型。中间再留一道人工复核,处理那些规则判断不了的边界情况。
这套结构跑下来,最省事的其实不是写规则,而是设计好误报的处理路径。规则写多少条都是体力活,误报怎么呈现、怎么让人确认、怎么不丢信息,才是决定这东西能不能被用起来的地方。
- 点赞
- 收藏
- 关注作者
评论(0)