用 Python 标准库写一个可解释的规则引擎:从 JSON 规则到分级评分

举报
yd_219023509 发表于 2026/10/31 08:51:38 2026/10/31
【摘要】 有些判断不适合交给模型打分:结论要可复现、能追溯到条款、每次结果一致。这篇用纯标准库写一个规则引擎,重点讲模式怎么收窄、减分制为什么可解释、否定语境导致的误报怎么处理。

很多内容相关的判断需求,第一反应是「丢给模型打分」。但有些场景不适合:结论要能复现、要能追溯到条款、要能在流水线里跑成千上万次,而且每次结果必须一样。

这类场景适合规则引擎。这篇讲怎么用纯标准库写一个,重点是可解释和误报控制这两件事。

一、规则长什么样

一条规则就是一段 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。 这是它和「模型说这样不行」的根本区别:用户可以自己去查条款,判断到底是规则写错了还是内容确实有问题。

八、边界

规则引擎做的是有明确文本特征的判断。语义层面的东西它做不了,也不该硬做。

我的做法是把「查」和「改」分开:规则引擎负责查,结果确定可复现;需要语境理解的改写交给模型。中间再留一道人工复核,处理那些规则判断不了的边界情况。

这套结构跑下来,最省事的其实不是写规则,而是设计好误报的处理路径。规则写多少条都是体力活,误报怎么呈现、怎么让人确认、怎么不丢信息,才是决定这东西能不能被用起来的地方。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。