Files
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
7.4 KiB
Python

"""报告文案纯逻辑:素材组装(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) == []