"""规则引擎黄金集雏形(架构 §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"]