Files
meida_backend/packages/report/copy.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

251 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""报告文案的纯逻辑:LLM 素材组装与降级模板渲染(无 IO、无 LLM 调用)。
对应架构 §7.1 步骤 17/18 中可独立单测的两块:
- 素材组装:把 T16 的结构化诊断裁剪成 prompt 载荷。**skipped 维度不进
prompt**——LLM 看不到缺失的维度就不会编造它,这是防幻觉的第一道闸
(第二道闸在 validation.py 的生成后校验)。
- 降级模板:持续违规时用规则直出拼一份零 LLM 的合规文案,是 T-P3-11
「绝不放行违规内容」的最终出口——有降级路径,fail-close 才不会变成
「永远失败」。
"""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from typing import Any
from pydantic import BaseModel
from packages.shared.errors import AppError, ErrorCode
#: 素材总长上限(字符)。REPORT_COPY 场景 max_tokens=3000,输入侧按架构
#: §7.2 的裁剪思想控制:低重要度维度先丢,硬结论永不丢。
MAX_MATERIAL_CHARS = 6000
#: 硬结论维度:体/脸/季三大枚举是下游(记忆、Agent、换装推荐)共同依赖的
#: 画像锚点,任何预算压力下都不裁——宁可 prompt 变长,不可结论缺失。
HARD_DIMENSION_KEYS: frozenset[str] = frozenset({"body_shape", "face_shape", "color_season"})
#: 维度裁剪优先级:数值越大越后裁。未登记的维度取默认优先级,新增维度
#: 无需改这张表即可获得「先被裁」的保守待遇。
_DIMENSION_PRIORITY: dict[str, int] = {
"body_shape": 100,
"face_shape": 100,
"color_season": 100,
"skin_tone": 80,
"color_palette": 80,
"style_impression": 60,
"figure_balance": 60,
}
_DEFAULT_PRIORITY = 50
#: 外层 JSON 键({"dimensions":[],"rule_hits":[]})的固定序列化开销估计。
_SERIALIZATION_OVERHEAD = 64
class CopySection(BaseModel):
"""报告正文章节。title 短语即可,文案主体放 paragraphs。"""
title: str
paragraphs: list[str]
class ReportCopy(BaseModel):
"""REPORT_COPY 场景的结构化输出 schema(宽进严出的取舍)。
字段越少,provider 的 JSON 约束成功率越高——解析失败会触发网关的
结构化重试,每次都是真金白银;文案的自由度全部放进 sections 的段落
文本,不靠 schema 枚举去硬卡。语义正确性不靠 schema:那属于生成后
校验(validation.py),两层各管各的。
"""
summary: str
sections: list[CopySection]
def full_text(self) -> str:
"""summary + 全部章节标题与段落,供校验器做全文扫描。"""
parts = [self.summary]
for section in self.sections:
parts.append(section.title)
parts.extend(section.paragraphs)
return "\n".join(parts)
def coerce_parsed_copy(parsed: Any) -> ReportCopy:
"""gateway ``Completion.parsed`` → ReportCopy;类型不符即网关契约破裂。
正常不会走到 raise(gateway 带 schema 调用成功即保证已过模型校验),
留这道闸是为了把意外的 TypeError 统一成 AppError,方便编排层按
错误码落任务失败与退款。
"""
if not isinstance(parsed, ReportCopy):
msg = "REPORT_COPY 场景的 completion 未携带 ReportCopy 结构"
raise AppError(ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED, message=msg)
return parsed
def _compact_dimension(dim: Mapping[str, Any]) -> dict[str, Any]:
"""维度瘦身到 prompt 需要的字段:key/value/evidence + 两位小数置信度。
confidence 保留(低置信维度要用不确定表述,模型必须能看到它),只把
视觉层带来的浮点尾数舍掉,省字符且避免无意义的精度噪音。
"""
confidence = dim.get("confidence")
compact: dict[str, Any] = {
"key": dim.get("key"),
"value": dim.get("value"),
"evidence": list(dim.get("evidence") or []),
}
if isinstance(confidence, (int, float)):
compact["confidence"] = round(float(confidence), 2)
return compact
def _compact_rule_hits(rule_hits: Sequence[Any]) -> list[dict[str, Any]]:
"""规则命中瘦身:保留 key/维度/建议/解释/硬禁忌标记,其余字段不进 prompt。"""
compacted: list[dict[str, Any]] = []
for hit in rule_hits:
if not isinstance(hit, Mapping):
continue
compact: dict[str, Any] = {"rule_key": hit.get("rule_key") or hit.get("key")}
for field in ("dimension", "suggestion", "explanation", "is_hard"):
if hit.get(field) is not None:
compact[field] = hit[field]
compacted.append(compact)
return compacted
def _compact_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def select_prompt_dimensions(
result_diagnosis: Mapping[str, Any],
*,
max_chars: int = MAX_MATERIAL_CHARS,
) -> tuple[list[dict[str, Any]], list[str]]:
"""取 status=ok 的维度并做超长裁剪;返回 (保留维度, 被裁掉的 key 列表)。
- skipped/error 维度直接不进素材(防幻觉第一道闸,见模块 docstring);
- 超长时按优先级升序丢非硬结论维度(低重要度先丢,硬结论永不丢);
- 若保留的全是硬结论仍超长,不再裁:硬结论缺位的代价远大于长 prompt,
由 provider 侧 max_tokens 兜底。此时 dropped 可能为空但载荷超长。
"""
raw_dimensions = [
d for d in (result_diagnosis.get("dimensions") or []) if isinstance(d, Mapping)
]
kept = [_compact_dimension(d) for d in raw_dimensions if d.get("status") == "ok"]
hits_len = len(_compact_json(_compact_rule_hits(result_diagnosis.get("rule_hits") or [])))
budget = max(max_chars - hits_len - _SERIALIZATION_OVERHEAD, 0)
droppable = sorted(
(d for d in kept if str(d.get("key")) not in HARD_DIMENSION_KEYS),
key=lambda d: (
_DIMENSION_PRIORITY.get(str(d.get("key")), _DEFAULT_PRIORITY),
str(d.get("key")),
),
)
dropped: list[str] = []
while droppable and len(_compact_json(kept)) > budget:
victim = droppable.pop(0)
kept.remove(victim)
dropped.append(str(victim["key"]))
return kept, dropped
def compose_prompt_material(
result_diagnosis: Mapping[str, Any],
*,
max_chars: int = MAX_MATERIAL_CHARS,
) -> tuple[str, list[str]]:
"""组装 {dimensions_json} 载荷,连同被裁掉的维度 key 一起返回。
CopyStage 一次调用拿全两者:载荷进 prompt,dropped_keys 落进
result["copy"] 供查询侧与排查用(用户看到的报告少了哪些维度)。
"""
kept, dropped = select_prompt_dimensions(result_diagnosis, max_chars=max_chars)
payload = {
"dimensions": kept,
"rule_hits": _compact_rule_hits(result_diagnosis.get("rule_hits") or []),
}
return _compact_json(payload), dropped
def build_prompt_material(result_diagnosis: Mapping[str, Any]) -> str:
"""任务契约签名:只返回素材字符串(dropped_keys 用 compose_prompt_material 取)。"""
material, _dropped = compose_prompt_material(result_diagnosis)
return material
#: 维度 key → 中文标签。模板文案与校验器共用的最小字典;T16 若在维度里
#: 自带 label 字段则优先用它的。
_KEY_LABELS: dict[str, str] = {
"body_shape": "体型",
"face_shape": "脸型",
"color_season": "季型",
"skin_tone": "肤色",
"color_palette": "色板",
}
_FALLBACK_SUMMARY = (
"以下内容根据本次分析的结构化结论整理,每条结论都来自规则引擎对诊断值的直接匹配。"
)
_FALLBACK_EMPTY_NOTE = "本次分析暂无可用结论,请稍后重新生成报告。"
def dimension_label(dim: Mapping[str, Any]) -> str:
"""维度的中文标签:优先维度自带 label,其次按 key 查表,兜底返回 key。"""
label = dim.get("label")
if isinstance(label, str) and label:
return label
key = str(dim.get("key") or "")
return _KEY_LABELS.get(key, key or "维度")
def _evidence_text(dim: Mapping[str, Any]) -> str:
evidence = dim.get("evidence")
if isinstance(evidence, Sequence) and not isinstance(evidence, str) and evidence:
return "判定依据:" + ";".join(str(item) for item in evidence)
if isinstance(evidence, str) and evidence:
return f"判定依据:{evidence}"
return ""
def render_fallback_copy(
dimensions: Sequence[Any],
rule_hits: Sequence[Any],
) -> ReportCopy:
"""降级模板:按维度 value/evidence + 规则 suggestion/explanation 拼文案,零 LLM。
持续违规(重试一次仍不过)时的最终出口。模板只做受控字符串拼接、
逐字复述诊断值与规则说明,不含任何自由生成文本,因此天然通过词表/
一致性/绝对化三重校验——ValidationStage 仍会对它跑一遍校验做
fail-close 自证(见 validation_stage.py)。
"""
ok_dims = [d for d in dimensions if isinstance(d, Mapping) and d.get("status") == "ok"]
conclusion_paragraphs = []
for dim in ok_dims:
value = dim.get("value") or ""
para = f"你的{dimension_label(dim)}结论为「{value}」。{_evidence_text(dim)}".rstrip()
conclusion_paragraphs.append(para)
advice_paragraphs = []
for hit in rule_hits:
if not isinstance(hit, Mapping):
continue
parts = [str(hit[field]) for field in ("suggestion", "explanation") if hit.get(field)]
if parts:
advice_paragraphs.append("· " + "——".join(parts))
sections: list[CopySection] = []
if conclusion_paragraphs:
sections.append(CopySection(title="诊断结论", paragraphs=conclusion_paragraphs))
if advice_paragraphs:
sections.append(CopySection(title="搭配建议", paragraphs=advice_paragraphs))
if not sections:
sections.append(CopySection(title="说明", paragraphs=[_FALLBACK_EMPTY_NOTE]))
return ReportCopy(summary=_FALLBACK_SUMMARY, sections=sections)