Files
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

331 lines
13 KiB
Python
Raw Permalink 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.
"""规则引擎黄金集雏形(架构 §1081):固定画像 → 期望输出。
改引擎行为必须先过黄金集——这是"专业可信"的回归防线。必含两类场景:
硬禁忌(用户禁忌压过专业推荐)与多规则叠加。规则库是知识库文档 §3.2⑩ 的
迷你缩影(体型/场景/温度/风格/趋势/用户禁忌/合规硬约束各至少一条)。
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from packages.rules import dsl, engine
# ── 固定规则库(8 条)───────────────────────────────────────────────────────
_GOLDEN_RULES = [
# 体型(90):文档 §3.2⑩ 原始示例规则
{
"rule_key": "R-BODY-A-002",
"category": "body_shape",
"priority": 90,
"confidence": 0.95,
"conditions": {
"body_shape": "A型",
"features": {"include": ["溜肩"]},
"goals": {"include": ["修饰溜肩"]},
},
"effects": {
"recommend": [
{"slot": "top", "attrs": {"shoulder": ["垫肩", "自然肩"], "sleeve": ["泡泡袖"]}}
],
"avoid": [{"slot": "top", "attrs": {"shoulder": ["落肩"], "silhouette": ["修身"]}}],
"explanation": "溜肩需要用垫肩与泡泡袖在肩部建立结构感。",
},
},
# 体型(90,同优先级低置信度:验证二级排序)
{
"rule_key": "R-BODY-A-010",
"category": "body_shape",
"priority": 90,
"confidence": 0.90,
"conditions": {"body_shape": "A型"},
"effects": {
"recommend": [{"slot": "bottom", "attrs": {"silhouette": ["直筒", "阔腿"]}}],
"avoid": [{"slot": "bottom", "attrs": {"silhouette": ["紧身"]}}],
"explanation": "A型下半身宜直线轮廓,避免紧身放大胯部。",
},
},
# 场景(80)
{
"rule_key": "R-OCCASION-INTERVIEW-001",
"category": "occasion",
"priority": 80,
"confidence": 0.90,
"conditions": {"occasion": "面试"},
"effects": {
"recommend": [
{"slot": "top", "attrs": {"collar": ["衬衫领"]}},
{"slot": "bottom", "attrs": {"silhouette": ["直筒"]}},
],
"avoid": [{"slot": "top", "attrs": {"neckline": ["深V"]}}],
"explanation": "面试场合需要干练可信的轮廓。",
},
},
# 温度(75):闭区间
{
"rule_key": "R-TEMP-COMFORT-18-26",
"category": "temperature",
"priority": 75,
"confidence": 0.85,
"conditions": {"temperature": {"min": 18, "max": 26}},
"effects": {
"recommend": [{"slot": "outer", "attrs": {"thickness": ["薄款"]}}],
"avoid": [{"slot": "outer", "attrs": {"thickness": ["厚款"]}}],
"explanation": "18-26℃ 单层薄外套最舒适。",
},
},
# 风格(70):与场景规则的推荐完全相同 → 验证去重
{
"rule_key": "R-STYLE-OL-001",
"category": "style",
"priority": 70,
"confidence": 0.80,
"conditions": {"style": "通勤"},
"effects": {
"recommend": [{"slot": "top", "attrs": {"collar": ["衬衫领"]}}],
"explanation": "通勤风格以衬衫领立住精神气。",
},
},
# 趋势(50):推荐恰与用户禁忌冲突 → 必须被压过
{
"rule_key": "R-TREND-SWEET-001",
"category": "trend",
"priority": 50,
"confidence": 0.70,
"conditions": {"style": "甜酷"},
"effects": {
"recommend": [{"slot": "top", "attrs": {"length": ["短款"]}}],
"explanation": "短款上衣是当季甜酷风的核心单品。",
},
},
# 用户禁忌(95):avoid 永远压过专业推荐
{
"rule_key": "R-TABOO-NAVEL-001",
"category": "user_taboo",
"priority": 95,
"confidence": 1.0,
"conditions": {"user_taboos": {"include": ["露脐"]}},
"effects": {
"avoid": [{"slot": "top", "attrs": {"length": ["短款", "露脐"]}}],
"explanation": "用户不接受露脐,已剔除相关单品。",
},
},
# 合规(100):is_hard,avoid 命中即硬拦截
{
"rule_key": "R-HARD-EXOTIC-001",
"category": "compliance",
"priority": 100,
"confidence": 1.0,
"is_hard": True,
"conditions": {"occasion": {"include": ["红毯", "晚宴"]}},
"effects": {
"avoid": [
{"slot": "accessory", "attrs": {"material": ["真兽皮"]}},
{"slot": "shoes", "attrs": {"material": ["真皮"]}},
],
"explanation": "合规红线:不推荐真兽皮材质。",
},
},
]
_RULES = [dsl.Rule.model_validate(r) for r in _GOLDEN_RULES]
# ── 断言辅助:把 SlotAttrs 规整成可比较的集合 ────────────────────────────────
#: 单个 recommend/avoid 条目规整后的形状。
NormItem = tuple[str, tuple[tuple[str, tuple[str, ...]], ...]]
def _item(slot: str, **dims: tuple[str, ...]) -> NormItem:
# 与 _norm 相同的规整规则(维度与取值都排序),期望值不必手写排序
return (slot, tuple(sorted((d, tuple(sorted(v))) for d, v in dims.items())))
def _norm(items: Iterable[dsl.SlotAttrs]) -> frozenset[NormItem]:
return frozenset(
(i.slot, tuple(sorted((d, tuple(sorted(v))) for d, v in i.attrs.items()))) for i in items
)
def _evaluate(**ctx: Any) -> engine.RuleVerdict:
return engine.evaluate(_RULES, dsl.RuleContext(**ctx))
def _hit_keys(verdict: engine.RuleVerdict) -> list[str]:
return [h.rule_key for h in verdict.rule_hits]
# ── 1. 基础命中 ─────────────────────────────────────────────────────────────
def test_a_shape_with_sloped_shoulders() -> None:
verdict = _evaluate(body_shape="A型", features=("溜肩",), goals=("修饰溜肩",))
assert _hit_keys(verdict) == ["R-BODY-A-002", "R-BODY-A-010"]
assert _norm(verdict.recommend) == {
_item("top", shoulder=("垫肩", "自然肩"), sleeve=("泡泡袖",)),
_item("bottom", silhouette=("阔腿", "直筒")),
}
assert _norm(verdict.avoid) == {
_item("top", shoulder=("落肩",), silhouette=("修身",)),
_item("bottom", silhouette=("紧身",)),
}
assert verdict.hard_blocks == ()
assert verdict.blocked is False
def test_missing_body_shape_does_not_fire_shape_rules() -> None:
# 缺字段 = 不匹配该子句:只有 features/goals 时体型规则不命中
verdict = _evaluate(features=("溜肩",), goals=("修饰溜肩",))
assert _hit_keys(verdict) == []
assert verdict.recommend == ()
assert verdict.avoid == ()
assert verdict.explanation == ""
def test_include_is_subset_semantics() -> None:
# features 是 include 列表的超集 → 命中
hit = _evaluate(body_shape="A型", features=("溜肩", "宽胯"), goals=("修饰溜肩",))
assert "R-BODY-A-002" in _hit_keys(hit)
# goals 缺一个 → 不命中
miss = _evaluate(body_shape="A型", features=("溜肩",), goals=("显瘦",))
assert "R-BODY-A-002" not in _hit_keys(miss)
assert _hit_keys(miss) == ["R-BODY-A-010"]
# ── 2. 多规则叠加 ──────────────────────────────────────────────────────────
def test_body_plus_temperature_plus_occasion_stack() -> None:
verdict = _evaluate(
body_shape="A型",
features=("溜肩",),
goals=("修饰溜肩",),
temperature=20.0,
occasion="面试",
)
assert _hit_keys(verdict) == [
"R-BODY-A-002",
"R-BODY-A-010",
"R-OCCASION-INTERVIEW-001",
"R-TEMP-COMFORT-18-26",
]
# 四条规则的推荐全部叠加,互不冲突(不同维度/不同 slot;两条 bottom 推荐
# 的属性集不同,各自保留)
assert _norm(verdict.recommend) == {
_item("top", shoulder=("垫肩", "自然肩"), sleeve=("泡泡袖",)),
_item("bottom", silhouette=("阔腿", "直筒")),
_item("top", collar=("衬衫领",)),
_item("bottom", silhouette=("直筒",)),
_item("outer", thickness=("薄款",)),
}
assert _norm(verdict.avoid) == {
_item("top", shoulder=("落肩",), silhouette=("修身",)),
_item("bottom", silhouette=("紧身",)),
_item("top", neckline=("深V",)),
_item("outer", thickness=("厚款",)),
}
assert verdict.blocked is False
def test_same_priority_sorted_by_confidence_and_dedupe_collapsed() -> None:
# A-002 不命中(缺 features/goals),A-010 与面试/通勤规则叠加;
# 场景(80)与风格(70)推荐同一件衬衫领 → 去重为一条
verdict = _evaluate(body_shape="A型", occasion="面试", style="通勤")
assert _hit_keys(verdict) == [
"R-BODY-A-010",
"R-OCCASION-INTERVIEW-001",
"R-STYLE-OL-001",
]
assert _norm(verdict.recommend) == {
_item("bottom", silhouette=("阔腿", "直筒")),
_item("bottom", silhouette=("直筒",)),
_item("top", collar=("衬衫领",)),
}
assert verdict.explanations == (
"A型下半身宜直线轮廓,避免紧身放大胯部。",
"面试场合需要干练可信的轮廓。",
"通勤风格以衬衫领立住精神气。",
)
def test_temperature_closed_interval_boundaries() -> None:
assert _hit_keys(_evaluate(temperature=18.0)) == ["R-TEMP-COMFORT-18-26"]
assert _hit_keys(_evaluate(temperature=26.0)) == ["R-TEMP-COMFORT-18-26"]
assert _hit_keys(_evaluate(temperature=17.9)) == []
assert _hit_keys(_evaluate(temperature=26.1)) == []
# ── 3. 用户禁忌压过专业推荐(硬性场景)──────────────────────────────────────
def test_user_taboo_overrides_trend_recommendation() -> None:
# 趋势(50)推荐短款上衣,用户禁忌(95)回避短款/露脐 → 推荐被剔除
verdict = _evaluate(style="甜酷", user_taboos=("露脐",))
assert _hit_keys(verdict) == ["R-TABOO-NAVEL-001", "R-TREND-SWEET-001"]
assert verdict.recommend == ()
assert _norm(verdict.avoid) == {_item("top", length=("露脐", "短款"))}
# 禁忌不是 is_hard:不构成硬拦截,但推荐侧已经干净
assert verdict.hard_blocks == ()
assert verdict.explanation.startswith("用户不接受露脐")
def test_user_taboo_keeps_unrelated_slot_recommendations() -> None:
# 禁忌只作用于 top.length:体型对 bottom 的推荐不受影响
verdict = _evaluate(body_shape="A型", user_taboos=("露脐",))
assert _hit_keys(verdict) == ["R-TABOO-NAVEL-001", "R-BODY-A-010"]
assert _norm(verdict.recommend) == {_item("bottom", silhouette=("阔腿", "直筒"))}
# ── 4. 硬约束拦截(is_hard)─────────────────────────────────────────────────
def test_hard_compliance_rule_blocks_gala_occasion() -> None:
verdict = _evaluate(occasion="红毯")
assert _hit_keys(verdict) == ["R-HARD-EXOTIC-001"]
assert verdict.blocked is True
assert [b.rule_key for b in verdict.hard_blocks] == ["R-HARD-EXOTIC-001"]
# 调用方用 hard_blocks.avoid 对候选做硬过滤:约束内容完整带出
assert _norm(verdict.hard_blocks[0].avoid) == {
_item("accessory", material=("真兽皮",)),
_item("shoes", material=("真皮",)),
}
assert verdict.hard_blocks[0].explanation == "合规红线:不推荐真兽皮材质。"
def test_hard_rule_not_fired_on_other_occasions() -> None:
assert _evaluate(occasion="通勤").hard_blocks == ()
assert _evaluate(occasion="通勤").blocked is False
# ── 5. 综合叠加:优先级链 + 禁忌压制 + 硬拦截同时发生 ────────────────────────
def test_composite_profile_full_priority_chain() -> None:
verdict = _evaluate(
body_shape="A型",
features=("溜肩",),
goals=("修饰溜肩",),
style="甜酷",
user_taboos=("露脐",),
occasion="红毯",
)
# 命中链严格按 priority 降序、同优先级按 confidence 降序
assert _hit_keys(verdict) == [
"R-HARD-EXOTIC-001", # 100
"R-TABOO-NAVEL-001", # 95
"R-BODY-A-002", # 90 / 0.95
"R-BODY-A-010", # 90 / 0.90
"R-TREND-SWEET-001", # 50
]
# 体型推荐保留(与禁忌不同维度);趋势推荐短款被禁忌压掉
assert _norm(verdict.recommend) == {
_item("top", shoulder=("垫肩", "自然肩"), sleeve=("泡泡袖",)),
_item("bottom", silhouette=("阔腿", "直筒")),
}
assert verdict.blocked is True
assert [b.rule_key for b in verdict.hard_blocks] == ["R-HARD-EXOTIC-001"]