merge: T16 确定性视觉与 32 维诊断装配(T-P3-01/06)

This commit is contained in:
xsl
2026-08-14 22:15:37 +08:00
8 changed files with 1947 additions and 0 deletions
+223
View File
@@ -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)