merge: T16 确定性视觉与 32 维诊断装配(T-P3-01/06)
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