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 可撤稿)。
261 lines
10 KiB
Python
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
|