dimensions.py 逐行编码需求文档 §4(32 项四要素、缺失显式 skipped)+ VisionProvider 接口位与确定性实现 + 规则引擎联动 rule_hits(T-P3-01/06)。 实现: - dimensions.py:DimensionSpec 恰 32 项(number 1~32 连续,33/34 永不复用), basic_coverage 口径机器可读:full 10 / label 3 / partial 8 / none 11, 初级覆盖 21/32(BASIC_FULL_KEYS + BASIC_COVERED_KEYS)。 - vision.py:VisionProvider Protocol(派生特征形状写进 docstring,真实 RTMPose/BiRefNet 实现后续按此替换)+ DeterministicVisionProvider(问卷 BMI/自评驱动体型与肤色,sha256(object_key+canonical问卷) 驱动其余枚举, 同输入同输出)+ build_vision_provider(未知名 CONFIG_ERROR)。 - vision_stage.py:derived_features 已存在 → skipped/reused(T-P3-09); 否则分析 → 写中间态并 flush;photo_meta.quality_flags 透传进产物(T-P3-01)。 - diagnosis_stage.py:32 维恒全键,缺输入显式 skipped+原因(T-P3-06 红线); 每维四要素 value/confidence/source/evidence/rule_hits + status;构造 RuleContext → RuleService 载入 published 规则 → evaluate,body_shape/color 类命中回落 #5/#19/#21,全量命中聚合进 #28/#30 的 rule_hits 与 evidence; 色板 #19/#20/#21 从 kb_color 按季型读取,库空 → skipped「色板未录入」。 测试:单测 29 项(32 项完整性/编号连续/确定性/工厂拒绝未知名;四要素齐全 性——故意缺问卷+缺视觉断言 32 键全在且全部 skipped 带原因;规则命中进 rule_hits 用内置最小规则集不连库);集成 1 项(种 2 条 published 规则 → VisionStage → DiagnosisStage:中间态落库与复用 skipped/reused、32 维全键、 rule_hits 出现、kb_color 空时色板 skipped、coverage basic_full=10/32)。 全绿:ruff check/format、mypy packages/report、lint-imports;单测 536 通过 (3 处既有 skip)、集成 46 通过。 偏差说明: 1. 任务文档写 RulesService,实际类名为 RuleService(packages/rules/service.py),按实际代码调用。 2. kb_color 联表键采用四季代码 spring/summer/autumn/winter(仓库无既定口径),已在 vision.py 注释声明。 3. 任务文档只给 basic_coverage 四枚举,需求表格中的「粗判/8色/3色」归入 partial、「Top1」归入 label。
263 lines
11 KiB
Python
263 lines
11 KiB
Python
"""诊断装配: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
|