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。
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""报告流水线视觉/诊断两阶段集成用例(真实 PostgreSQL)——只测 T16 的两个阶段。
|
||||
|
||||
覆盖任务契约:种 2 条 published 规则 → VisionStage → DiagnosisStage,断言
|
||||
derived_features 落库、32 维全键、规则 rule_hits 出现、kb_color 为空时
|
||||
色板维度 skipped。整条流水线的编排(装配/失败退款)归 T15 的集成测试。
|
||||
|
||||
确定性保证:问卷带 skin_tone_self=黄一白 → 暖+亮 → 季型恒为 spring,
|
||||
因此色板断言不依赖哈希;body 规则的条件值先用 provider 离线算出再种入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
_PREFIX = "R-IT16-"
|
||||
#: 问卷肤色自评钉死的季型(黄一白 → 暖+亮 → spring),见 vision.py 派生规则。
|
||||
_SEASON = "spring"
|
||||
|
||||
_QUESTIONNAIRE: dict[str, Any] = {
|
||||
"gender": "女",
|
||||
"height": 165,
|
||||
"weight": 50,
|
||||
"birthday": "1998-06-01",
|
||||
"occupation": "设计师",
|
||||
"core_goals": ["显高", "显瘦"],
|
||||
"occasion_weights": {"通勤": 0.7, "休闲": 0.3},
|
||||
"psyche_style": "自然",
|
||||
"preferred_colors": ["白色"],
|
||||
"taboos": ["不接受深V"],
|
||||
"skin_tone_self": "黄一白",
|
||||
}
|
||||
|
||||
|
||||
def _key(suffix: str) -> str:
|
||||
return f"{_PREFIX}{suffix}-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session() -> AsyncIterator[Any]:
|
||||
"""每个用例独立 engine:pytest-asyncio 每用例一个事件循环(同 test_rules_engine)。"""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from packages.shared.config import get_settings
|
||||
from packages.shared.db import create_engine
|
||||
|
||||
engine = create_engine(get_settings())
|
||||
factory = async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
|
||||
try:
|
||||
async with factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _cleanup(report_id: int, input_id: int) -> None:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from packages.rules import service as rules_service
|
||||
from packages.shared.models import KbColor, KbRule, RpInput, RpReport
|
||||
|
||||
rules_service.invalidate_published_cache()
|
||||
async with _session() as session:
|
||||
await session.execute(delete(KbRule).where(KbRule.rule_key.like(f"{_PREFIX}%")))
|
||||
await session.execute(delete(RpInput).where(RpInput.input_id == input_id))
|
||||
await session.execute(delete(RpReport).where(RpReport.report_id == report_id))
|
||||
await session.execute(delete(KbColor).where(KbColor.season_code == _SEASON))
|
||||
|
||||
|
||||
def _body_payload(rule_key: str, body_shape: str) -> dict[str, Any]:
|
||||
return {
|
||||
"rule_key": rule_key,
|
||||
"category": "body_shape",
|
||||
"priority": 90,
|
||||
"confidence": 0.95,
|
||||
"conditions": {"body_shape": body_shape},
|
||||
"effects": {
|
||||
"recommend": [{"slot": "top", "attrs": {"shoulder": ["IT16垫肩"]}}],
|
||||
"explanation": "IT16 体型策略",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _color_payload(rule_key: str, season: str) -> dict[str, Any]:
|
||||
return {
|
||||
"rule_key": rule_key,
|
||||
"category": "color",
|
||||
"priority": 85,
|
||||
"confidence": 0.9,
|
||||
"conditions": {"season": season},
|
||||
"effects": {
|
||||
"avoid": [{"slot": "dress", "attrs": {"color": ["IT16荧光绿"]}}],
|
||||
"explanation": "IT16 季型避雷",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_vision_then_diagnosis_with_published_rules() -> None:
|
||||
from packages.domain.ids import next_id
|
||||
from packages.report.diagnosis_stage import DiagnosisStage
|
||||
from packages.report.pipeline import ReportContext
|
||||
from packages.report.vision import DeterministicVisionProvider
|
||||
from packages.report.vision_stage import VisionStage
|
||||
from packages.rules.service import RuleService
|
||||
from packages.shared.config import get_settings
|
||||
from packages.shared.models import RpInput, RpReport
|
||||
|
||||
report_id, input_id = next_id(), next_id()
|
||||
await _cleanup(report_id, input_id)
|
||||
body_key, color_key = _key("BODY"), _key("COLOR")
|
||||
try:
|
||||
# 视觉结果是确定性的:先离线算出派生特征,用它的值种规则条件。
|
||||
photo_meta = {
|
||||
"object_key": f"{_PREFIX}photo.jpg",
|
||||
"quality_flags": ["backlit"],
|
||||
}
|
||||
derived = await DeterministicVisionProvider().analyze(photo_meta, _QUESTIONNAIRE)
|
||||
assert derived["season_code"] == _SEASON # 问卷自评钉死季型,断言不依赖哈希
|
||||
|
||||
async with _session() as session:
|
||||
svc = RuleService(session)
|
||||
await svc.create(_body_payload(body_key, derived["body_shape"]), author="it16")
|
||||
await svc.create(_color_payload(color_key, derived["season_code"]), author="it16")
|
||||
await svc.publish(body_key)
|
||||
await svc.publish(color_key)
|
||||
|
||||
async with _session() as session:
|
||||
report = RpReport(
|
||||
report_id=report_id,
|
||||
user_id=next_id(),
|
||||
tier="basic",
|
||||
status="generating",
|
||||
result={},
|
||||
)
|
||||
session.add(report)
|
||||
await session.flush() # rp_input.report_id 有外键,先落主表
|
||||
rp_input = RpInput(
|
||||
input_id=input_id,
|
||||
report_id=report_id,
|
||||
questionnaire=_QUESTIONNAIRE,
|
||||
photo_meta=photo_meta,
|
||||
derived_features={},
|
||||
)
|
||||
session.add(rp_input)
|
||||
await session.flush()
|
||||
|
||||
ctx = ReportContext(
|
||||
report=report,
|
||||
report_input=rp_input,
|
||||
session=session,
|
||||
settings=get_settings(),
|
||||
)
|
||||
|
||||
# -- VisionStage:首跑产出中间态 + 产物 --------------------------
|
||||
await VisionStage().run(ctx)
|
||||
assert rp_input.derived_features == derived
|
||||
assert report.result["vision"]["status"] == "ok"
|
||||
assert report.result["vision"]["quality_flags"] == ["backlit"] # T-P3-01 透传
|
||||
assert "body_shape" in report.result["vision"]["dimensions_touched"]
|
||||
|
||||
# -- VisionStage:中间态已存在 → skipped/reused(T-P3-09) --------
|
||||
await VisionStage().run(ctx)
|
||||
assert report.result["vision"] == {"skipped": True, "reused": True}
|
||||
|
||||
# -- DiagnosisStage:32 维全键 + 规则联动 + 空色板 skipped --------
|
||||
await DiagnosisStage().run(ctx)
|
||||
diagnosis = report.result["diagnosis"]
|
||||
dimensions = diagnosis["dimensions"]
|
||||
assert [d["number"] for d in dimensions] == list(range(1, 33))
|
||||
by_key = {d["key"]: d for d in dimensions}
|
||||
for dim in dimensions:
|
||||
for field in ("value", "confidence", "source", "evidence", "rule_hits", "status"):
|
||||
assert field in dim
|
||||
|
||||
# 体型规则 → #5;色彩规则 → #21/#28/#30(聚合口径见 diagnosis_stage)
|
||||
assert body_key in by_key["body_shape"]["rule_hits"]
|
||||
assert color_key in by_key["avoid_colors"]["rule_hits"]
|
||||
for key in ("core_goals", "preferences_taboos"):
|
||||
assert {body_key, color_key} <= set(by_key[key]["rule_hits"])
|
||||
|
||||
# kb_color 为空 → 色板维度显式 skipped + 原因(显式缺失优于编造)
|
||||
assert by_key["best_colors"]["status"] == "skipped"
|
||||
assert "色板未录入" in by_key["best_colors"]["evidence"]
|
||||
assert by_key["neutral_colors"]["status"] == "skipped"
|
||||
|
||||
# 本场景问卷完整 + 视觉已跑:初级 full 10/10。
|
||||
assert diagnosis["coverage"] == {"basic_full": 10, "total": 32}
|
||||
|
||||
# -- 落库验证:新会话读回中间态与产物 -------------------------------
|
||||
async with _session() as session:
|
||||
saved_input = await session.get(RpInput, input_id)
|
||||
assert saved_input is not None
|
||||
assert saved_input.derived_features == derived
|
||||
saved_report = await session.get(RpReport, report_id)
|
||||
assert saved_report is not None
|
||||
assert set(saved_report.result) == {"vision", "diagnosis"}
|
||||
assert saved_report.result["vision"]["reused"] is True
|
||||
assert saved_report.result["diagnosis"]["coverage"]["total"] == 32
|
||||
finally:
|
||||
await _cleanup(report_id, input_id)
|
||||
Reference in New Issue
Block a user