Files
meida_backend/tests/unit/test_report_diagnosis.py
xsl e7076c9c01 feat(report): deterministic vision provider and 32-dimension diagnosis assembly
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。
2026-08-14 22:14:54 +08:00

263 lines
11 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.
"""诊断装配: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