"""报告文案纯逻辑:素材组装(skipped 不进 / 超长裁剪 / 硬结论不裁)与降级模板。""" from __future__ import annotations import json from typing import Any from packages.report.copy import ( build_prompt_material, compose_prompt_material, render_fallback_copy, ) from packages.report.validation import validate_copy def _dim( key: str, value: str = "X型", *, status: str = "ok", confidence: float = 0.82, evidence: tuple[str, ...] = ("shoulder_hip_ratio:1.02",), rule_hits: tuple[str, ...] = ("R-BODY-0007",), ) -> dict[str, Any]: return { "key": key, "status": status, "value": value, "confidence": confidence, "evidence": list(evidence), "rule_hits": list(rule_hits), } def _diagnosis( *dimensions: dict[str, Any], rule_hits: list[dict[str, Any]] | None = None ) -> dict[str, Any]: return {"dimensions": list(dimensions), "rule_hits": rule_hits or []} # --------------------------------------------------------------------------- # 素材组装:skipped 不进 prompt(防幻觉第一道闸) # --------------------------------------------------------------------------- def test_build_prompt_material_excludes_skipped_dimensions() -> None: diagnosis = _diagnosis( _dim("body_shape", "X型"), _dim("face_shape", "鹅蛋"), _dim("occasion_preference", status="skipped", value=""), ) material = build_prompt_material(diagnosis) payload = json.loads(material) keys = [d["key"] for d in payload["dimensions"]] assert keys == ["body_shape", "face_shape"] # skipped 维度连同它的 key/value 都不能出现在载荷字符串里——LLM 看不到 # 缺失维度就不会编造它 assert "occasion_preference" not in material def test_build_prompt_material_rounds_confidence() -> None: diagnosis = _diagnosis(_dim("body_shape", "X型", confidence=0.876543)) payload = json.loads(build_prompt_material(diagnosis)) assert payload["dimensions"][0]["confidence"] == 0.88 def test_build_prompt_material_keeps_key_value_evidence_and_rule_hits() -> None: hits = [ { "rule_key": "R-BODY-0007", "dimension": "body_shape", "suggestion": "优先选择收腰版型", "explanation": "X 型体型肩腰臀比例均衡", "is_hard": False, "internal_field": "不该进 prompt 的字段", } ] diagnosis = _diagnosis( _dim("body_shape", "X型", evidence=("ratio:1.02", "ratio:0.71")), rule_hits=hits ) material = build_prompt_material(diagnosis) payload = json.loads(material) assert payload["dimensions"][0] == { "key": "body_shape", "value": "X型", "evidence": ["ratio:1.02", "ratio:0.71"], "confidence": 0.82, } assert payload["rule_hits"] == [ { "rule_key": "R-BODY-0007", "dimension": "body_shape", "suggestion": "优先选择收腰版型", "explanation": "X 型体型肩腰臀比例均衡", "is_hard": False, } ] assert "internal_field" not in material # --------------------------------------------------------------------------- # 素材组装:超长裁剪 # --------------------------------------------------------------------------- def test_overlong_material_drops_low_priority_dimensions_and_records_keys() -> None: big_evidence = ("x" * 200,) * 3 # 每个维度约 600+ 字符 diagnosis = _diagnosis( _dim("body_shape", "X型", evidence=big_evidence), # 硬结论,优先级 100 _dim("style_impression", evidence=big_evidence), # 优先级 60 _dim("occasion_preference", evidence=big_evidence), # 未登记 → 默认优先级 50 ) material, dropped = compose_prompt_material(diagnosis, max_chars=1000) # 默认优先级先丢,其次 style_impression;硬结论保留 assert dropped == ["occasion_preference", "style_impression"] payload = json.loads(material) assert [d["key"] for d in payload["dimensions"]] == ["body_shape"] assert len(material) <= 1000 def test_hard_dimensions_are_never_dropped_even_when_over_budget() -> None: huge_evidence = ("x" * 400,) * 5 diagnosis = _diagnosis( _dim("body_shape", "X型", evidence=huge_evidence), _dim("face_shape", "鹅蛋", evidence=huge_evidence), _dim("color_season", "浅暖柔和型", evidence=huge_evidence), ) material, dropped = compose_prompt_material(diagnosis, max_chars=200) assert dropped == [] payload = json.loads(material) assert [d["key"] for d in payload["dimensions"]] == ["body_shape", "face_shape", "color_season"] # --------------------------------------------------------------------------- # 降级模板 # --------------------------------------------------------------------------- def test_render_fallback_copy_includes_values_evidence_and_rule_explanations() -> None: dimensions = [ _dim( "body_shape", "X型", evidence=("shoulder_hip_ratio:1.02",), rule_hits=("R-BODY-0007",) ), _dim("color_season", "浅暖柔和型", evidence=("contrast:low",), rule_hits=("R-COLOR-0021",)), ] rule_hits = [ { "rule_key": "R-BODY-0007", "dimension": "body_shape", "suggestion": "优先选择收腰版型突出腰线", "explanation": "X 型体型肩腰臀比例均衡,收腰设计能强化这一优势", } ] fallback = render_fallback_copy(dimensions, rule_hits) titles = [s.title for s in fallback.sections] assert titles == ["诊断结论", "搭配建议"] conclusion = "\n".join(fallback.sections[0].paragraphs) assert "「X型」" in conclusion and "shoulder_hip_ratio:1.02" in conclusion assert "「浅暖柔和型」" in conclusion advice = "\n".join(fallback.sections[1].paragraphs) assert "优先选择收腰版型突出腰线" in advice # 有 rule_hits 时必须含 explanation——解释链是模板文案的最低质量线 assert "收腰设计能强化这一优势" in advice def test_render_fallback_copy_without_hits_has_no_advice_section() -> None: dimensions = [_dim("body_shape", "X型")] fallback = render_fallback_copy(dimensions, []) assert [s.title for s in fallback.sections] == ["诊断结论"] def test_render_fallback_copy_with_no_usable_input_explains_itself() -> None: fallback = render_fallback_copy([_dim("occasion_preference", status="skipped", value="")], []) assert [s.title for s in fallback.sections] == ["说明"] assert fallback.sections[0].paragraphs def test_fallback_copy_passes_the_full_validation_suite() -> None: """降级模板必须天然通过三重校验——降级路径的黄金性质在纯函数级先锁一遍。""" dimensions = [ _dim( "body_shape", "X型", evidence=("shoulder_hip_ratio:1.02",), rule_hits=("R-BODY-0007",) ), _dim("face_shape", "鹅蛋", evidence=("face_ratio:1.35",)), ] rule_hits = [ { "rule_key": "R-BODY-0007", "dimension": "body_shape", "suggestion": "优先选择收腰版型突出腰线", "explanation": "X 型体型肩腰臀比例均衡", } ] fallback = render_fallback_copy(dimensions, rule_hits) assert validate_copy(fallback, dimensions, rule_hits=rule_hits) == []