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 可撤稿)。
323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""纯域求值引擎:排序、硬约束拦截、用户禁忌压过专业推荐、性能基准(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 预算"
|