Files
meida_backend/tests/unit/test_report_validation.py
T
xsl e78ba3feef feat(report): LLM copy generation with mandatory post-validation and fallback
report_copy/v1 prompt(系统段零变量)+ 结构化 ReportCopy + 素材组装
(skipped 不进 prompt/超长裁剪)+ 贬损词表/一致性/绝对化三重校验 +
重试一次后降级规则直出模板(T-P3-10/11)。

- copy.py:ReportCopy(schema 宽进严出取舍见注释)/compose_prompt_material
  (ok 过滤+6000 字符裁剪,低优先级先丢、硬结论不丢,记录 dropped_keys)/
  render_fallback_copy 零 LLM 降级模板
- validation.py:PEJORATIVE_TERMS 35 词(源自合规 §3,初版内嵌注释)+
  一致性三规则(枚举同款含别名/色值在板内/建议句需规则支撑·宽松口径)+
  绝对化用语兜底
- copy_stage.py / validation_stage.py:任何出口 passed=true 或抛错,
  语义层重试一次(与网关解析层重试独立),仍违规降级模板并 fail-close 自证
- 单测 25 例(词表含变形/三规则正反/降级零违规)+ 集成 4 例
  (FakeProvider 全栈:干净通过/脏一次修正通过/持续脏降级且最终文案
  零贬损词——黄金断言);全量 529 单测 + 49 集成全绿

偏差说明:
1. prompts/report_copy/v1.txt 非新建——LLM 批次已放置初版(user 段变量
   为 {payload}),按本任务契约改写为 {dimensions_json} 并补内容红线
   (该文件任务文档明确归 T17,无与他人冲突)。
2. check_conclusion_consistency 按契约签名 (copy, dimensions) 之外增加
   可选 rule_hits 关键字参数:规则③的支撑判定需兼容命中内嵌在维度/
   顶层列表两种 T16 契约形态,缺省时不追责(宽松口径)。
3. 绝对化用语未按单字「最」拦截,取营销语境复合词(最好/最合适/第一/
   百分百等 17 词):单字会误杀「最近/最终」等中性词,红线范围不变。
4. 集成文件未挂 MEIDA_RUN_INTEGRATION skipif:用例为 FakeProvider 内存
   装配、不依赖真实 PG/Redis,按任务要求仍放 tests/integration/ 并带
   integration marker。
2026-08-14 22:09:09 +08:00

212 lines
8.1 KiB
Python

"""生成后校验纯逻辑:贬损词表(含变形)、一致性三条规则、绝对化用语、降级路径。"""
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()) == []