"""诊断装配:32 维四要素齐全、缺失显式 skipped、规则命中进 rule_hits(T-P3-06)。 规则求值走纯函数 ``engine.evaluate``(内置最小规则集,不连库)—— ``evaluate_dimensions`` 本身不碰 I/O,这正是它能被穷举输入组合的原因。 """ from __future__ import annotations import json from dataclasses import asdict from typing import Any from packages.report.diagnosis_stage import ( ColorEntry, build_rule_context, evaluate_dimensions, ) from packages.report.dimensions import DIMENSION_BY_KEY, DIMENSIONS from packages.report.vision import DeterministicVisionProvider from packages.rules import dsl, engine _QUESTIONNAIRE = { "gender": "女", "height": 165, "weight": 50, "birthday": "1998-06-01 10:00", "occupation": "设计师", "core_goals": ["显高", "显瘦", "显白"], "occasion_weights": {"通勤": 0.6, "休闲": 0.2, "约会": 0.2}, "psyche_style": "自然", "preferred_colors": ["白色", "蓝色"], "disliked_styles": ["前卫"], "taboos": ["不接受深V"], } def _kb_colors_for(season: str) -> list[ColorEntry]: """给定季型的完整色板(base 含中性色命名,覆盖 #19/#20/#21 三个维度)。""" return [ ColorEntry(season_code=season, role="base", hex="#FFFFFF", name_cn="柔白"), ColorEntry(season_code=season, role="base", hex="#2B2B2B", name_cn="炭黑"), ColorEntry(season_code=season, role="base", hex="#6B6B6B", name_cn="中灰"), ColorEntry(season_code=season, role="accent", hex="#0F4C81", name_cn="宝蓝"), ColorEntry(season_code=season, role="avoid", hex="#E4E444", name_cn="芥末黄"), ] def _rule(rule_key: str, category: str, conditions: dict[str, Any]) -> dsl.Rule: return dsl.Rule.model_validate( { "rule_key": rule_key, "category": category, "priority": 90, "conditions": conditions, "effects": { "recommend": [{"slot": "top", "attrs": {"shoulder": ["垫肩"]}}], "avoid": [{"slot": "dress", "attrs": {"color": ["荧光绿"]}}], "explanation": f"{rule_key} 命中", }, } ) async def _derived() -> dict[str, Any]: return await DeterministicVisionProvider().analyze( {"object_key": "unit/report-vision.jpg"}, _QUESTIONNAIRE ) async def _verdict(derived: dict[str, Any]) -> engine.RuleVerdict: rules = [ _rule("T16-BODY", "body_shape", {"body_shape": derived["body_shape"]}), _rule("T16-COLOR", "color", {"season": derived["season_code"]}), ] return engine.evaluate(rules, build_rule_context(_QUESTIONNAIRE, derived)) def _by_key(results: list[Any]) -> dict[str, Any]: return {r.key: r for r in results} def _assert_four_elements(result: Any) -> None: # 四要素 + 定位字段:一个都不能少(T-P3-06 验收原文)。 for field in ("value", "confidence", "source", "evidence", "rule_hits", "status"): assert hasattr(result, field) assert 0.0 <= result.confidence <= 1.0 assert result.evidence, f"{result.key} 的 evidence 不能为空" assert isinstance(result.rule_hits, list) # ── 32 键恒在场(红线) ───────────────────────────────────────────────────── async def test_full_inputs_produce_all_32_dimensions() -> None: derived = await _derived() results = evaluate_dimensions( _QUESTIONNAIRE, derived, await _verdict(derived), _kb_colors_for(derived["season_code"]) ) assert [r.number for r in results] == list(range(1, 33)) assert [r.key for r in results] == [spec.key for spec in DIMENSIONS] for result in results: _assert_four_elements(result) assert result.status == "ok", f"{result.key} 应有产出:{result.evidence}" async def test_missing_everything_still_returns_32_keys_all_skipped() -> None: # 故意缺问卷 + 缺视觉:32 键全在、全部显式 skipped 且带原因(不允许缺键)。 verdict = engine.evaluate((), dsl.RuleContext()) results = evaluate_dimensions({}, {}, verdict) assert len(results) == 32 for result in results: _assert_four_elements(result) assert result.status == "skipped" assert result.value is None assert result.confidence == 0.0 assert result.source == "none" assert result.evidence # 缺失原因必须显式给出 async def test_questionnaire_only_keeps_32_keys_with_vision_skipped() -> None: # 问卷版轻画像(§3.6):视觉维度 skipped,问卷维度照常输出。 verdict = engine.evaluate((), dsl.RuleContext()) results = evaluate_dimensions(_QUESTIONNAIRE, {}, verdict) by_key = _by_key(results) assert len(results) == 32 for key in ("body_shape", "face_shape", "skin_tone", "season_code", "hair_color"): assert by_key[key].status == "skipped" for key in ("gender", "birthday", "occupation", "psyche_style", "core_goals"): assert by_key[key].status == "ok" assert by_key[key].source == "questionnaire" assert by_key[key].confidence == 0.6 async def test_assembly_is_deterministic() -> None: derived = await _derived() verdict = await _verdict(derived) colors = _kb_colors_for(derived["season_code"]) first = evaluate_dimensions(_QUESTIONNAIRE, derived, verdict, colors) second = evaluate_dimensions(_QUESTIONNAIRE, derived, verdict, colors) assert [asdict(r) for r in first] == [asdict(r) for r in second] # ── 规则引擎联动(rule_hits / evidence) ─────────────────────────────────── async def test_rule_hits_land_on_related_dimensions() -> None: derived = await _derived() verdict = await _verdict(derived) by_key = _by_key(evaluate_dimensions(_QUESTIONNAIRE, derived, verdict)) # 体型类命中 → #5 体型维度。 assert "T16-BODY" in by_key["body_shape"].rule_hits assert "T16-BODY" in by_key["body_shape"].evidence # 核心诉求/偏好与禁忌聚合全部命中(recommend/avoid 服务于诉求与禁忌)。 for key in ("core_goals", "preferences_taboos"): assert set(by_key[key].rule_hits) >= {"T16-BODY", "T16-COLOR"} async def test_color_rules_carry_hits_even_when_kb_empty() -> None: # kb_color 为空 → 色板 skipped,但色彩规则命中链仍在(可解释性不丢)。 derived = await _derived() verdict = await _verdict(derived) by_key = _by_key(evaluate_dimensions(_QUESTIONNAIRE, derived, verdict)) for key in ("best_colors", "avoid_colors"): assert by_key[key].status == "skipped" assert "色板未录入" in by_key[key].evidence assert "T16-COLOR" in by_key[key].rule_hits async def test_rule_derived_taboos_when_questionnaire_empty() -> None: # 问卷没有偏好禁忌、但规则有 avoid → #30 以规则为源产出。 derived = await _derived() verdict = await _verdict(derived) by_key = _by_key(evaluate_dimensions({}, derived, verdict)) prefs = by_key["preferences_taboos"] assert prefs.status == "ok" assert prefs.source == "rule" assert prefs.confidence == 0.9 assert any("回避" in taboo for taboo in prefs.value["taboos"]) # ── 色板(kb_color)与知识库映射 ─────────────────────────────────────────── async def test_color_palettes_from_kb_entries() -> None: derived = await _derived() verdict = await _verdict(derived) colors = _kb_colors_for(derived["season_code"]) by_key = _by_key(evaluate_dimensions(_QUESTIONNAIRE, derived, verdict, colors)) best = by_key["best_colors"] assert best.status == "ok" assert len(best.value) <= 8 # 初级口径:最佳色板 8 色 assert best.source == "derived" assert {c.hex for c in colors if c.role != "avoid"} >= {c["hex"] for c in best.value} avoid = by_key["avoid_colors"] assert avoid.status == "ok" assert len(avoid.value) == 1 # 初级口径:避雷色 3 色为上限 assert avoid.value[0]["name_cn"] == "芥末黄" neutral = by_key["neutral_colors"] assert neutral.status == "ok" assert {c["name_cn"] for c in neutral.value} == {"柔白", "炭黑", "中灰"} async def test_other_season_colors_are_not_mixed_in() -> None: derived = await _derived() verdict = await _verdict(derived) # 只录他季色板:本季必须 skipped 而不是混入他季颜色(色板映射按季型联表)。 other = "summer" if derived["season_code"] != "summer" else "winter" by_key = _by_key(evaluate_dimensions(_QUESTIONNAIRE, derived, verdict, _kb_colors_for(other))) assert by_key["best_colors"].status == "skipped" assert "色板未录入" in by_key["best_colors"].evidence assert by_key["neutral_colors"].status == "skipped" # ── 融合风格命名与序列化 ─────────────────────────────────────────────────── async def test_fusion_style_naming_degrades_gracefully() -> None: derived = await _derived() verdict = await _verdict(derived) both = _by_key(evaluate_dimensions(_QUESTIONNAIRE, derived, verdict))["fusion_style_name"] assert both.status == "ok" assert both.source == "rule" assert both.confidence == 0.9 assert "自然" in both.value # 心理风格侧参与了命名 only_questionnaire = evaluate_dimensions( _QUESTIONNAIRE, {}, engine.evaluate((), dsl.RuleContext()) ) fused = _by_key(only_questionnaire)["fusion_style_name"] assert fused.status == "ok" assert "自然" in fused.value async def test_results_serialize_to_json() -> None: derived = await _derived() verdict = await _verdict(derived) payload = [asdict(r) for r in evaluate_dimensions(_QUESTIONNAIRE, derived, verdict)] assert len(json.loads(json.dumps(payload, ensure_ascii=False))) == 32 def test_build_rule_context_maps_fields() -> None: derived = { "body_shape": "A", "season_code": "autumn", "face_shape": "圆", "body_features": ["溜肩", "短颈"], } ctx = build_rule_context(_QUESTIONNAIRE, derived) assert ctx.body_shape == "A" assert ctx.season == "autumn" assert ctx.face_shape == "圆" assert ctx.features == ("溜肩", "短颈") assert ctx.goals == ("显高", "显瘦", "显白") assert ctx.style == "自然" assert ctx.user_taboos == ("不接受深V",) assert ctx.occasion == "通勤" # 占比最高的场景 assert DIMENSION_BY_KEY["body_shape"].number == 5