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 可撤稿)。
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
"""纯域求值引擎:排序、硬约束拦截、用户禁忌压过专业推荐、性能基准(T-P2-02)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from packages.rules import dsl, engine
|
||||
|
||||
|
||||
def _rule(
|
||||
rule_key: str,
|
||||
priority: int,
|
||||
*,
|
||||
category: str = "test",
|
||||
confidence: float = 0.9,
|
||||
is_hard: bool = False,
|
||||
conditions: dict[str, Any] | None = None,
|
||||
recommend: tuple[dict[str, Any], ...] = (),
|
||||
avoid: tuple[dict[str, Any], ...] = (),
|
||||
explanation: str | None = None,
|
||||
) -> dsl.Rule:
|
||||
effects: dict[str, Any] = {}
|
||||
if recommend:
|
||||
effects["recommend"] = list(recommend)
|
||||
if avoid:
|
||||
effects["avoid"] = list(avoid)
|
||||
if explanation:
|
||||
effects["explanation"] = explanation
|
||||
if not effects:
|
||||
effects["explanation"] = "测试规则" # 空效果本身非法,测试助手兜底
|
||||
return dsl.Rule.model_validate(
|
||||
{
|
||||
"rule_key": rule_key,
|
||||
"category": category,
|
||||
"priority": priority,
|
||||
"conditions": conditions or {},
|
||||
"effects": effects,
|
||||
"is_hard": is_hard,
|
||||
"confidence": confidence,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── 排序与命中链 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_hits_sorted_by_priority_then_confidence_then_key() -> None:
|
||||
rules = [
|
||||
_rule("R-LOW", 50),
|
||||
_rule("R-TIE-B", 90, confidence=0.9),
|
||||
_rule("R-TOP", 95, confidence=0.5),
|
||||
_rule("R-TIE-A", 90, confidence=0.9),
|
||||
_rule("R-TIE-C", 90, confidence=0.8),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert [h.rule_key for h in verdict.rule_hits] == [
|
||||
"R-TOP",
|
||||
"R-TIE-A",
|
||||
"R-TIE-B",
|
||||
"R-TIE-C",
|
||||
"R-LOW",
|
||||
]
|
||||
assert [h.priority for h in verdict.rule_hits] == [95, 90, 90, 90, 50]
|
||||
|
||||
|
||||
def test_rule_hit_carries_metadata_and_explanations_in_priority_order() -> None:
|
||||
rules = [
|
||||
_rule("R-LOW", 50, explanation="趋势解释", category="trend", confidence=0.7),
|
||||
_rule("R-HIGH", 90, explanation="体型解释", category="body_shape", confidence=0.95),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
top = verdict.rule_hits[0]
|
||||
assert (top.rule_key, top.priority, top.category, top.confidence) == (
|
||||
"R-HIGH",
|
||||
90,
|
||||
"body_shape",
|
||||
0.95,
|
||||
)
|
||||
assert top.is_hard is False
|
||||
assert verdict.explanations == ("体型解释", "趋势解释")
|
||||
assert verdict.explanation == "体型解释;趋势解释"
|
||||
|
||||
|
||||
def test_no_rules_or_no_hits_yield_empty_verdict() -> None:
|
||||
assert engine.evaluate([], dsl.RuleContext()).recommend == ()
|
||||
verdict = engine.evaluate(
|
||||
[_rule("R-X", 90, conditions={"body_shape": "A型"})],
|
||||
dsl.RuleContext(body_shape="H型"),
|
||||
)
|
||||
assert verdict.rule_hits == ()
|
||||
assert verdict.blocked is False
|
||||
|
||||
|
||||
# ── 硬约束 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_hard_rule_with_avoid_populates_hard_blocks() -> None:
|
||||
avoid = {"slot": "shoes", "attrs": {"material": ["真皮"]}}
|
||||
rules = [_rule("R-HARD", 100, is_hard=True, avoid=(avoid,), explanation="合规红线")]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert verdict.blocked is True
|
||||
assert len(verdict.hard_blocks) == 1
|
||||
block = verdict.hard_blocks[0]
|
||||
assert block.rule_key == "R-HARD"
|
||||
assert block.avoid[0].slot == "shoes"
|
||||
assert block.avoid[0].attrs["material"] == ("真皮",)
|
||||
assert block.explanation == "合规红线"
|
||||
|
||||
|
||||
def test_hard_rule_without_avoid_does_not_block() -> None:
|
||||
# 拦截语义只由 avoid 表达:is_hard 规则只写 recommend 就无物可拦
|
||||
rules = [
|
||||
_rule(
|
||||
"R-HARD-REC",
|
||||
100,
|
||||
is_hard=True,
|
||||
recommend=({"slot": "top", "attrs": {"collar": ["衬衫领"]}},),
|
||||
)
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert verdict.hard_blocks == ()
|
||||
assert verdict.blocked is False
|
||||
|
||||
|
||||
def test_hard_rule_not_fired_does_not_block() -> None:
|
||||
rules = [
|
||||
_rule(
|
||||
"R-HARD",
|
||||
100,
|
||||
is_hard=True,
|
||||
conditions={"occasion": "面试"},
|
||||
avoid=({"slot": "top", "attrs": {"neckline": ["深V"]}},),
|
||||
)
|
||||
]
|
||||
assert engine.evaluate(rules, dsl.RuleContext(occasion="通勤")).blocked is False
|
||||
assert engine.evaluate(rules, dsl.RuleContext(occasion="面试")).blocked is True
|
||||
|
||||
|
||||
def test_soft_rule_with_avoid_never_blocks() -> None:
|
||||
rules = [_rule("R-SOFT", 90, avoid=({"slot": "top", "attrs": {"silhouette": ["修身"]}},))]
|
||||
assert engine.evaluate(rules, dsl.RuleContext()).hard_blocks == ()
|
||||
|
||||
|
||||
# ── 冲突仲裁:高优先级 avoid 剔除严格低优先级的冲突 recommend ────────────────
|
||||
|
||||
|
||||
def test_user_taboo_overrides_professional_recommend() -> None:
|
||||
# 用户禁忌(95)与专业推荐(90)同 slot 同维度取值相交 → 推荐被剔除
|
||||
rules = [
|
||||
_rule(
|
||||
"R-PRO",
|
||||
dsl.PRIORITY_BODY_SHAPE,
|
||||
recommend=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},),
|
||||
),
|
||||
_rule(
|
||||
"R-TABOO",
|
||||
dsl.PRIORITY_USER_TABOO,
|
||||
conditions={"user_taboos": {"include": ["oversize"]}},
|
||||
avoid=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},),
|
||||
),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext(user_taboos=("oversize",)))
|
||||
assert verdict.recommend == ()
|
||||
assert verdict.avoid != () # 禁忌约束保留在 avoid 里供下游过滤
|
||||
assert [h.rule_key for h in verdict.rule_hits] == ["R-TABOO", "R-PRO"]
|
||||
|
||||
|
||||
def test_taboo_keeps_non_conflicting_recommend() -> None:
|
||||
# 不同维度(length vs shoulder)不构成冲突:专业推荐保留
|
||||
rules = [
|
||||
_rule(
|
||||
"R-PRO",
|
||||
90,
|
||||
recommend=({"slot": "top", "attrs": {"shoulder": ["垫肩"]}},),
|
||||
),
|
||||
_rule(
|
||||
"R-TABOO",
|
||||
95,
|
||||
avoid=({"slot": "top", "attrs": {"length": ["短款"]}},),
|
||||
),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert [item.slot for item in verdict.recommend] == ["top"]
|
||||
assert verdict.recommend[0].attrs["shoulder"] == ("垫肩",)
|
||||
|
||||
|
||||
def test_taboo_does_not_override_higher_priority_recommend() -> None:
|
||||
# 合规(100)> 用户禁忌(95):只有"严格更低"优先级的推荐才会被剔除
|
||||
rules = [
|
||||
_rule(
|
||||
"R-COMPLIANCE",
|
||||
dsl.PRIORITY_COMPLIANCE,
|
||||
recommend=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},),
|
||||
),
|
||||
_rule(
|
||||
"R-TABOO",
|
||||
dsl.PRIORITY_USER_TABOO,
|
||||
avoid=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},),
|
||||
),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert len(verdict.recommend) == 1
|
||||
|
||||
|
||||
def test_lower_priority_recommend_vetoed_by_higher_soft_avoid() -> None:
|
||||
# 通用优先级语义:体型 avoid(90)同样压过趋势 recommend(50)
|
||||
rules = [
|
||||
_rule(
|
||||
"R-TREND",
|
||||
dsl.PRIORITY_TREND,
|
||||
recommend=({"slot": "top", "attrs": {"silhouette": ["修身"]}},),
|
||||
),
|
||||
_rule(
|
||||
"R-BODY",
|
||||
dsl.PRIORITY_BODY_SHAPE,
|
||||
avoid=({"slot": "top", "attrs": {"silhouette": ["修身"]}},),
|
||||
),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert verdict.recommend == ()
|
||||
|
||||
|
||||
def test_same_priority_avoid_does_not_veto_recommend() -> None:
|
||||
# 同优先级不仲裁:推荐与回避并存,由下游按场景取舍
|
||||
rules = [
|
||||
_rule(
|
||||
"R-A",
|
||||
90,
|
||||
recommend=({"slot": "top", "attrs": {"sleeve": ["泡泡袖"]}},),
|
||||
),
|
||||
_rule(
|
||||
"R-B",
|
||||
90,
|
||||
avoid=({"slot": "top", "attrs": {"sleeve": ["泡泡袖"]}},),
|
||||
),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert len(verdict.recommend) == 1
|
||||
|
||||
|
||||
def test_different_slot_never_conflicts() -> None:
|
||||
rules = [
|
||||
_rule(
|
||||
"R-REC",
|
||||
50,
|
||||
recommend=({"slot": "bottom", "attrs": {"silhouette": ["紧身"]}},),
|
||||
),
|
||||
_rule(
|
||||
"R-AVOID",
|
||||
100,
|
||||
is_hard=True,
|
||||
avoid=({"slot": "top", "attrs": {"silhouette": ["紧身"]}},),
|
||||
),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert len(verdict.recommend) == 1
|
||||
assert verdict.blocked is True # 拦截归拦截,推荐归推荐
|
||||
|
||||
|
||||
# ── 汇总与去重 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_identical_recommend_deduped_and_order_follows_priority() -> None:
|
||||
item = {"slot": "top", "attrs": {"collar": ["衬衫领"]}}
|
||||
rules = [
|
||||
_rule("R-STYLE", 70, recommend=(item,)),
|
||||
_rule("R-OCCASION", 80, recommend=(item,)),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert len(verdict.recommend) == 1
|
||||
assert len(verdict.avoid) == 0
|
||||
|
||||
|
||||
def test_identical_avoid_deduped() -> None:
|
||||
avoid = {"slot": "top", "attrs": {"silhouette": ["修身"]}}
|
||||
rules = [
|
||||
_rule("R-A", 90, avoid=(avoid,)),
|
||||
_rule("R-B", 60, avoid=(avoid,)),
|
||||
]
|
||||
verdict = engine.evaluate(rules, dsl.RuleContext())
|
||||
assert len(verdict.avoid) == 1
|
||||
|
||||
|
||||
# ── 性能基准(T-P2-02:p95 < 50ms @ 400 条规则)─────────────────────────────
|
||||
|
||||
|
||||
def test_benchmark_400_rules_well_under_50ms_budget() -> None:
|
||||
"""400 条规则 × 1000 次求值。
|
||||
|
||||
断言均值 < 5ms/次(对 50ms 预算留 10 倍裕量,CI 机器差异不至于 flaky),
|
||||
并直接断言 p95 < 50ms 对应验收指标。求值是纯内存操作,规则从缓存来,
|
||||
不含任何 I/O。
|
||||
"""
|
||||
shapes = ("A型", "H型", "X型", "O型")
|
||||
rules = [
|
||||
_rule(
|
||||
f"R-BENCH-{i:03d}",
|
||||
50 + (i % 6) * 10,
|
||||
category="bench",
|
||||
confidence=0.5 + (i % 10) / 20,
|
||||
conditions={"body_shape": shapes[i % 4], "season": {"exclude": ["夏"]}},
|
||||
recommend=({"slot": "top", "attrs": {"collar": [f"领型{i % 5}"]}},),
|
||||
avoid=({"slot": "bottom", "attrs": {"silhouette": ["紧身"]}},),
|
||||
explanation=f"基准规则 {i}",
|
||||
)
|
||||
for i in range(400)
|
||||
]
|
||||
assert len(rules) == 400
|
||||
ctx = dsl.RuleContext(body_shape="A型", season="冬", temperature=18.0)
|
||||
|
||||
engine.evaluate(rules, ctx) # 预热:首次调用的函数定位等开销不摊给被测对象
|
||||
timings: list[float] = []
|
||||
for _ in range(1000):
|
||||
start = time.perf_counter()
|
||||
engine.evaluate(rules, ctx)
|
||||
timings.append(time.perf_counter() - start)
|
||||
|
||||
mean = sum(timings) / len(timings)
|
||||
p95 = sorted(timings)[int(len(timings) * 0.95) - 1]
|
||||
assert mean < 0.005, f"平均单次求值 {mean * 1000:.2f}ms,超出 5ms 基线"
|
||||
assert p95 < 0.05, f"p95 单次求值 {p95 * 1000:.2f}ms,超出 50ms 预算"
|
||||
Reference in New Issue
Block a user