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。
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
"""报告文案的纯逻辑: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)
|
||||
Reference in New Issue
Block a user