"""生成后校验纯逻辑:贬损词表(含变形)、一致性三条规则、绝对化用语、降级路径。""" from __future__ import annotations from typing import Any from packages.report.copy import CopySection, ReportCopy, render_fallback_copy from packages.report.validation import ( check_absolute_claims, check_conclusion_consistency, check_pejorative, validate_copy, ) def _copy(summary: str = "", *paragraphs: str) -> ReportCopy: return ReportCopy( summary=summary, sections=[CopySection(title="正文", paragraphs=list(paragraphs))], ) def _dim( key: str, value: str = "", *, status: str = "ok", rule_hits: tuple[str, ...] = (), evidence: tuple[str, ...] = (), ) -> dict[str, Any]: return { "key": key, "status": status, "value": value, "confidence": 0.82, "evidence": list(evidence), "rule_hits": list(rule_hits), } # --------------------------------------------------------------------------- # 贬损词表 # --------------------------------------------------------------------------- def test_pejorative_hits_variants_without_stemming() -> None: # 「有点胖」「微胖」都命中「胖」——子串匹配即变形处理,不做词干 assert check_pejorative("整体轮廓有点胖,建议宽松款") == ["胖"] assert check_pejorative("微胖体型适合直筒剪裁") == ["胖"] def test_pejorative_returns_every_matched_term_in_table_order() -> None: assert check_pejorative("配色显老又土气,显得臃肿") == ["臃肿", "土气", "显老"] def test_pejorative_clean_text_passes() -> None: assert check_pejorative("肩腰臀比例均衡,收腰设计能强化这一优势。") == [] def test_pejorative_terms_count_stays_within_initial_band() -> None: """初版词表约定 20~40 词:太少护不住红线,太多误杀(正式版归 T-P0-07)。""" from packages.report.validation import PEJORATIVE_TERMS assert 20 <= len(PEJORATIVE_TERMS) <= 40 # --------------------------------------------------------------------------- # 一致性规则①:体型/脸型/季型枚举词 # --------------------------------------------------------------------------- def test_enum_word_mismatching_diagnosis_is_flagged() -> None: dimensions = [_dim("body_shape", "X型")] copy = _copy("你的整体印象", "你的体型属于 A 型,肩线舒展。") issues = check_conclusion_consistency(copy, dimensions) assert any("体型" in issue and "A型" in issue for issue in issues) def test_enum_word_matching_diagnosis_or_its_alias_passes() -> None: dimensions = [_dim("body_shape", "X型")] same_value = _copy("你的整体印象", "你的体型结论为 X 型,比例均衡。") alias = _copy("你的整体印象", "接近沙漏型的轮廓,腰线明显。") assert check_conclusion_consistency(same_value, dimensions) == [] assert check_conclusion_consistency(alias, dimensions) == [] def test_enum_word_for_skipped_dimension_is_treated_as_fabrication() -> None: dimensions = [_dim("face_shape", status="skipped")] copy = _copy("你的整体印象", "你的圆脸轮廓柔和,适合纵向线条。") issues = check_conclusion_consistency(copy, dimensions) assert any("脸型" in issue for issue in issues) def test_season_enum_mismatch_is_flagged() -> None: dimensions = [_dim("color_season", "浅暖柔和型")] copy = _copy("色彩结论", "你属于典型的夏季型,适合冷调色彩。") issues = check_conclusion_consistency(copy, dimensions) assert any("季型" in issue for issue in issues) # --------------------------------------------------------------------------- # 一致性规则②:色值必须在色板集合内 # --------------------------------------------------------------------------- def test_hex_color_outside_palette_is_flagged() -> None: dimensions = [_dim("color_palette", "#A52A2A", evidence=("#F5F5DC",))] inside = _copy("色彩方案", "主色可用 #A52A2A,辅色 #f5f5dc 提亮。") outside = _copy("色彩方案", "主色建议 #AABBCC。") assert check_conclusion_consistency(inside, dimensions) == [] issues = check_conclusion_consistency(outside, dimensions) assert any("#AABBCC" in issue for issue in issues) # --------------------------------------------------------------------------- # 一致性规则③:建议句式必须有规则命中支撑 # --------------------------------------------------------------------------- def test_advice_with_no_rule_hits_at_all_is_flagged() -> None: dimensions = [_dim("body_shape", "X型", rule_hits=())] copy = _copy("总体", "建议优先选择收腰版型,突出腰部线条。") issues = check_conclusion_consistency(copy, dimensions) assert any("没有任何命中" in issue for issue in issues) def test_advice_on_dimension_without_support_is_flagged() -> None: dimensions = [ _dim("body_shape", "X型", rule_hits=("R-BODY-0007",)), _dim("color_season", "浅暖柔和型", rule_hits=()), ] copy = _copy("总体", "关于季型,建议主色选择高饱和冷调。") issues = check_conclusion_consistency(copy, dimensions) assert any("季型" in issue and "没有规则命中支撑" in issue for issue in issues) def test_advice_supported_by_rule_hits_passes() -> None: dimensions = [_dim("body_shape", "X型", rule_hits=("R-BODY-0007",))] rule_hits = [{"rule_key": "R-BODY-0007", "dimension": "body_shape", "suggestion": "收腰"}] copy = _copy("总体", "关于体型,建议优先选择收腰版型,突出腰部线条。") assert check_conclusion_consistency(copy, dimensions, rule_hits=rule_hits) == [] # --------------------------------------------------------------------------- # 绝对化用语 # --------------------------------------------------------------------------- def test_absolute_claims_are_flagged() -> None: assert "最合适" in check_absolute_claims("这款收腰连衣裙最合适不过了") assert "第一" in check_absolute_claims("你是本周第一位、也是第一优选用户") assert "百分百" in check_absolute_claims("百分百提升气质") def test_absolute_claims_tolerate_neutral_words_with_the_same_head() -> None: # 「最」单字不拦:最近/最终是中性词,拦了会把正常文案打进重试 assert check_absolute_claims("根据最近一次分析,你的体型结论保持稳定。") == [] # --------------------------------------------------------------------------- # 降级路径的纯函数级验证:脏文案违规 → 模板文案零违规 # --------------------------------------------------------------------------- def test_validate_copy_aggregates_all_three_check_categories() -> None: dimensions = [_dim("body_shape", "X型")] dirty = _copy("小结", "整体偏微胖,收腰款最合适。", "你的体型属于 A 型。") violations = validate_copy(dirty, dimensions) assert any(v.startswith("贬损词:") for v in violations) assert any(v.startswith("结论不一致:") for v in violations) assert any(v.startswith("绝对化用语:") for v in violations) def test_degraded_fallback_is_clean_when_llm_copy_stays_dirty() -> None: """重试后仍违规 → 降级模板:最终文案零违规(T-P3-11 黄金断言的纯函数版)。""" 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 型体型肩腰臀比例均衡,收腰设计能强化这一优势", } ] dirty = _copy("小结", "整体偏微胖,收腰款最合适。") assert validate_copy(dirty, dimensions, rule_hits=rule_hits) != [] fallback = render_fallback_copy(dimensions, rule_hits) assert validate_copy(fallback, dimensions, rule_hits=rule_hits) == [] assert check_pejorative(fallback.full_text()) == []