Files
meida_backend/tests/unit/test_rules_evaluation.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

323 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""纯域求值引擎:排序、硬约束拦截、用户禁忌压过专业推荐、性能基准(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 预算"