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。
212 lines
7.4 KiB
Python
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) == []
|