Files
meida_backend/tests/unit/test_rules_dsl.py
T
xsl e50a9424a1 feat(rules): data-driven decision table engine with hard constraints
kb_rule DSL 解析 + 纯域求值(优先级分组/硬约束/用户禁忌压过专业推荐)
+ 400 条基准 + 黄金集雏形(T-P2-02,TD-10)。

实现:dsl.py 封闭词汇表 pydantic 校验(未识别键拒绝)+ match 纯逻辑
(等值/include/exclude/min-max,缺字段不命中);engine.py 纯域求值
(priority 降序、confidence 次序、is_hard avoid → hard_blocks、高优先级
avoid 剔除冲突 recommend);service.py+repo.py published 整表载入进程内
缓存 + CRUD + draft→published→archived 状态机(同 T6 语义)。
测试:DSL 解析与 match 矩阵、排序/硬约束/禁忌优先、状态机矩阵、
黄金集 11 组(含用户禁忌压过专业推荐与多规则叠加)、400 条 × 1000 次
基准(实测均值 0.26ms/次、p95 0.41ms,预算 50ms);集成 3 用例
(published 载入命中拦截、draft 不载入、DSL 写入拒绝与状态机守卫)。
单测 310 通过(+72),集成 19 通过(+3)。

偏差说明:
1. 冲突仲裁按通用优先级语义实现(高优先级 avoid 剔除"严格更低"优先级的
   冲突 recommend),任务文档的"用户禁忌(≥95)永远压过专业推荐(≤90)"
   是该规则的特例,黄金集已含该场景用例。
2. include 子句按字段类型取语义:标量字段=命中列表任一值;集合字段=
   列表全部在场(子集,精确命中);文档未区分,此实现两类字段均可用。
3. 单测引擎文件命名为 tests/unit/test_rules_evaluation.py(而非
   test_rules_engine.py):与契约指定的集成文件 tests/integration/
   test_rules_engine.py 同名会导致 pytest 收集冲突(tests 目录非包)。
4. 集成测试每用例独立 engine 并 dispose:pytest-asyncio 每用例一个事件
   循环,直接用全局 session 工厂会让连接跨 loop 污染其它集成用例。
5. 规则不提供物理删除(命中链需可追溯,下线走 archived);kb_rule 契约
   无 reviewing 状态,状态机为 draft→published→archived(draft 可撤稿)。
2026-08-14 20:10:21 +08:00

261 lines
10 KiB
Python

"""kb_rule DSL 解析校验与 match 矩阵(TD-10,纯逻辑)。
核心验收点:**非法结构 100% 拒绝**——未识别的键(顶层/子句/条件字段)全部
在解析时抛错,而不是被静默忽略后变成一条永不生效的死规则。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from packages.rules import dsl
VALID_RULE = {
"rule_key": "R-BODY-A-002",
"category": "body_shape",
"priority": 90,
"conditions": {
"body_shape": "A型",
"features": {"include": ["溜肩"]},
"goals": {"include": ["修饰溜肩"]},
},
"effects": {
"recommend": [
{"slot": "top", "attrs": {"shoulder": ["垫肩", "自然肩"], "sleeve": ["泡泡袖"]}}
],
"avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"], "silhouette": ["修身"]}}],
"explanation": "溜肩需要在肩部建立结构感",
},
"is_hard": False,
"confidence": 0.95,
}
# ── 解析:合法结构 ─────────────────────────────────────────────────────────
def test_parse_doc_example() -> None:
rule = dsl.Rule.model_validate(VALID_RULE)
assert rule.rule_key == "R-BODY-A-002"
assert rule.priority == 90
assert rule.is_hard is False
assert rule.confidence == pytest.approx(0.95)
# 等值子句解析为 str,集合子句解析为 ConditionClause
assert rule.conditions.clauses["body_shape"] == "A型"
features = rule.conditions.clauses["features"]
assert isinstance(features, dsl.ConditionClause)
assert features.include == ("溜肩",)
assert rule.effects.recommend[0].slot == "top"
assert rule.effects.avoid[0].attrs["shoulder"] == ("落肩",)
def test_parse_min_max_and_exclude_clause() -> None:
rule = dsl.Rule.model_validate(
{
**VALID_RULE,
"conditions": {
"temperature": {"min": 18, "max": 26},
"season": {"exclude": ["夏"]},
},
}
)
temp = rule.conditions.clauses["temperature"]
assert isinstance(temp, dsl.ConditionClause)
assert temp.min == 18.0
assert temp.max == 26.0
season = rule.conditions.clauses["season"]
assert isinstance(season, dsl.ConditionClause)
assert season.exclude == ("夏",)
def test_empty_conditions_means_match_all() -> None:
# 合规类兜底规则("全场禁真兽皮")无条件命中是合法语义。
rule = dsl.Rule.model_validate({**VALID_RULE, "conditions": {}})
assert rule.conditions.clauses == {}
assert dsl.match(rule.conditions, dsl.RuleContext()) is True
def test_explanation_only_effect_is_valid() -> None:
rule = dsl.Rule.model_validate(
{**VALID_RULE, "effects": {"explanation": "整体偏保守的通勤思路"}}
)
assert rule.effects.recommend == ()
assert rule.effects.avoid == ()
def test_attrs_empty_means_whole_slot() -> None:
rule = dsl.Rule.model_validate(
{**VALID_RULE, "effects": {"avoid": [{"slot": "swimsuit"}], "explanation": "回避品类"}}
)
assert rule.effects.avoid[0].attrs == {}
# ── 解析:非法结构 100% 拒绝 ───────────────────────────────────────────────
@pytest.mark.parametrize(
"mutation",
[
{"rule_key": 12345}, # 非 str
{"rule_key": "R-" + "x" * 63}, # 超出 64 字符(列宽)
{"category": "c" * 33}, # 超出 32 字符(列宽)
{"priority": 101}, # 超出分组上限 100
{"priority": -1},
{"confidence": 1.5},
{"confidence": -0.1},
],
)
def test_invalid_top_level_values_rejected(mutation: dict) -> None:
with pytest.raises(ValidationError):
dsl.Rule.model_validate({**VALID_RULE, **mutation})
def test_unknown_top_level_key_rejected() -> None:
with pytest.raises(ValidationError, match="unexpected"):
dsl.Rule.model_validate({**VALID_RULE, "unexpected": 1})
def test_unknown_condition_field_rejected() -> None:
# 条件字段名 typo:静默忽略会让这条规则永不命中
with pytest.raises(ValidationError, match="未识别的条件字段"):
dsl.Rule.model_validate({**VALID_RULE, "conditions": {"body_shap": "A型"}})
def test_unknown_clause_key_rejected() -> None:
with pytest.raises(ValidationError):
dsl.Rule.model_validate({**VALID_RULE, "conditions": {"features": {"includes": ["溜肩"]}}})
def test_empty_clause_rejected() -> None:
with pytest.raises(ValidationError, match="至少要包含"):
dsl.Rule.model_validate({**VALID_RULE, "conditions": {"features": {}}})
def test_min_greater_than_max_rejected() -> None:
with pytest.raises(ValidationError, match="min 不能大于 max"):
dsl.Rule.model_validate(
{**VALID_RULE, "conditions": {"temperature": {"min": 26, "max": 18}}}
)
def test_empty_attr_values_rejected() -> None:
with pytest.raises(ValidationError, match="取值列表不能为空"):
dsl.Rule.model_validate(
{
**VALID_RULE,
"effects": {
"recommend": [{"slot": "top", "attrs": {"shoulder": []}}],
"explanation": "x",
},
}
)
def test_empty_attr_dim_rejected() -> None:
with pytest.raises(ValidationError, match="维度名不能为空"):
dsl.Rule.model_validate(
{
**VALID_RULE,
"effects": {
"recommend": [{"slot": "top", "attrs": {"": ["垫肩"]}}],
"explanation": "x",
},
}
)
def test_empty_effects_rejected() -> None:
with pytest.raises(ValidationError, match="至少要包含"):
dsl.Rule.model_validate({**VALID_RULE, "effects": {}})
def test_blank_explanation_alone_rejected() -> None:
# explanation="" 视同未填,效果为空的规则没有存在意义
with pytest.raises(ValidationError, match="至少要包含"):
dsl.Rule.model_validate({**VALID_RULE, "effects": {"explanation": ""}})
def test_non_dict_conditions_rejected() -> None:
with pytest.raises(ValidationError):
dsl.Rule.model_validate({**VALID_RULE, "conditions": ["body_shape"]})
def test_effects_missing_rejected() -> None:
payload = dict(VALID_RULE)
del payload["effects"]
with pytest.raises(ValidationError):
dsl.Rule.model_validate(payload)
# ── match 矩阵 ─────────────────────────────────────────────────────────────
def _cond(**clauses: object) -> dsl.RuleCondition:
return dsl.RuleCondition.model_validate(clauses)
def test_match_equality_hit_and_miss() -> None:
cond = _cond(body_shape="A型")
assert dsl.match(cond, dsl.RuleContext(body_shape="A型")) is True
assert dsl.match(cond, dsl.RuleContext(body_shape="H型")) is False
assert dsl.match(cond, dsl.RuleContext(body_shape=None)) is False # 缺字段
def test_match_equality_on_collection_field_is_membership() -> None:
cond = _cond(user_taboos="露脐")
assert dsl.match(cond, dsl.RuleContext(user_taboos=("露脐", "豹纹"))) is True
assert dsl.match(cond, dsl.RuleContext(user_taboos=())) is False
def test_match_include_requires_all_values() -> None:
cond = _cond(features={"include": ["溜肩", "宽胯"]})
assert dsl.match(cond, dsl.RuleContext(features=("溜肩", "宽胯", "小腿粗"))) is True
# 子集语义:只命中一半不算命中
assert dsl.match(cond, dsl.RuleContext(features=("溜肩",))) is False
assert dsl.match(cond, dsl.RuleContext(features=())) is False
def test_match_include_on_scalar_field() -> None:
cond = _cond(occasion={"include": ["面试", "通勤"]})
assert dsl.match(cond, dsl.RuleContext(occasion="面试")) is True
assert dsl.match(cond, dsl.RuleContext(occasion="约会")) is False
def test_match_exclude() -> None:
cond = _cond(features={"exclude": ["溜肩"]})
assert dsl.match(cond, dsl.RuleContext(features=("宽胯",))) is True
assert dsl.match(cond, dsl.RuleContext(features=("宽胯", "溜肩"))) is False
def test_match_min_max_closed_interval() -> None:
cond = _cond(temperature={"min": 18, "max": 26})
assert dsl.match(cond, dsl.RuleContext(temperature=18.0)) is True # 闭区间含端点
assert dsl.match(cond, dsl.RuleContext(temperature=26.0)) is True
assert dsl.match(cond, dsl.RuleContext(temperature=21.5)) is True
assert dsl.match(cond, dsl.RuleContext(temperature=17.9)) is False
assert dsl.match(cond, dsl.RuleContext(temperature=26.1)) is False
assert dsl.match(cond, dsl.RuleContext(temperature=None)) is False # 缺字段
def test_match_min_only_and_max_only() -> None:
assert dsl.match(_cond(temperature={"min": 10}), dsl.RuleContext(temperature=10.0))
assert not dsl.match(_cond(temperature={"min": 10}), dsl.RuleContext(temperature=9.9))
assert dsl.match(_cond(temperature={"max": 10}), dsl.RuleContext(temperature=10.0))
assert not dsl.match(_cond(temperature={"max": 10}), dsl.RuleContext(temperature=10.1))
def test_match_clauses_are_anded() -> None:
cond = _cond(body_shape="A型", features={"include": ["溜肩"]})
assert dsl.match(cond, dsl.RuleContext(body_shape="A型", features=("溜肩",))) is True
assert dsl.match(cond, dsl.RuleContext(body_shape="A型", features=())) is False
assert dsl.match(cond, dsl.RuleContext(body_shape="H型", features=("溜肩",))) is False
def test_match_include_and_min_max_combined_in_one_clause() -> None:
# 同一子句内 include 与区间也按 AND 处理
cond = _cond(goals={"include": ["显瘦"], "exclude": ["显壮"]})
assert dsl.match(cond, dsl.RuleContext(goals=("显瘦",))) is True
assert dsl.match(cond, dsl.RuleContext(goals=("显瘦", "显壮"))) is False
assert dsl.match(cond, dsl.RuleContext(goals=("显高",))) is False