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:
xsl
2026-08-14 22:09:09 +08:00
parent d974526904
commit e78ba3feef
8 changed files with 1539 additions and 8 deletions
+352
View File
@@ -0,0 +1,352 @@
"""生成后校验的纯逻辑(架构 §7.1 步骤 18,T-P3-10 / T-P3-11 🔴)。
prompt 红线管「生成前」,本模块管「生成后」:LLM 输出永远不可信任,
三重校验(贬损词表 / 文案-结论一致性 / 绝对化用语)全过才放行,
ValidationStage 据此决定重试与降级。与 gateway 的结构化重试是两层独立
机制:那层管 JSON 解析合不合法,这层管内容合不合规,互不共享计数——
解析成功但内容违规,网关不会替我们发现。
"""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
from packages.report.copy import ReportCopy, dimension_label
#: 贬损词表(初版内嵌版,正式版归合规词库任务 T-P0-07,届时改为读库+后台可维护)。
#: 来源:《合规与安全方案》§3「内容伦理红线」——把「描述而非评判」「不使用
#: 缺陷框架」翻译成具体词表。命中按子串匹配:「有点胖」「微胖」都命中「胖」,
#: 不做词干(中文无明显词干,初版宁多命中不放过)。
PEJORATIVE_TERMS: tuple[str, ...] = (
# 身材/外貌贬损(§3 反例:「你的脸型有点大,不好看」「腿短是你的缺点」)
"胖",
"肥",
"臃肿",
"丑",
"难看",
"土气",
"显老",
"老气",
"矮",
"腿短",
"短腿",
"水桶腰",
"大饼脸",
"虎背熊腰",
# 缺陷框架(§3:不使用缺陷框架——特征是比例事实,不是缺点)
"缺陷",
"缺点",
"瑕疵",
"短板",
"硬伤",
"失败",
"悲剧",
"灾难",
# 评判性观感
"廉价",
"地摊货",
"邋遢",
"呆板",
"死板",
"笨拙",
# 医疗/身材干预越界(§3:不做医疗判断 → 一律转向穿搭与色彩建议)
"减肥",
"减重",
"瘦身",
"整形",
"整容",
"就医",
"皮肤病",
)
#: 绝对化用语(广告法红线;system prompt 已禁,这里是兜底——prompt 不是
#: 约束保证,只是愿望)。「最」不取单字:「最近/最终」等中性词误杀率高,
#: 取营销语境的高频复合词,误报代价见 check_unsupported_advice 的取舍注释。
ABSOLUTE_CLAIM_TERMS: tuple[str, ...] = (
"最好",
"最佳",
"最优",
"最合适",
"最适合",
"最完美",
"最显瘦",
"第一",
"唯一",
"彻底",
"百分百",
"百分之百",
"绝对",
"完美",
"永不过时",
"无可替代",
"独一无二",
)
#: 校验类别 → 展平违规清单时的人读前缀。
CHECK_PREFIXES: dict[str, str] = {
"pejorative": "贬损词",
"consistency": "结论不一致",
"absolute": "绝对化用语",
}
_HEX_COLOR_RE = re.compile(r"#[0-9a-fA-F]{6}\b")
_ADVICE_MARKERS = ("建议", "推荐")
@dataclass(frozen=True, slots=True)
class EnumCategory:
"""体型/脸型/季型三类枚举维度:key 识别串 + 全量枚举词 + 同义别名。
一致性规则①的依据:这三类的结论词是全量枚举(需求文档 §4 维度字典),
文案里出现「同类的另一个枚举值」即可断定与诊断矛盾。
"""
label: str
key_tokens: tuple[str, ...]
vocabulary: tuple[str, ...]
#: 诊断值 → 同义说法(诊断说 X 型、文案写沙漏型不算矛盾)。
aliases: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
_ENUM_CATEGORIES: tuple[EnumCategory, ...] = (
EnumCategory(
label="体型",
key_tokens=("body_shape", "体型"),
vocabulary=(
"A型",
"H型",
"O型",
"T型",
"X型",
"苹果型",
"梨型",
"沙漏型",
"倒三角型",
"矩形型",
),
aliases={
"O型": ("苹果型",),
"A型": ("梨型",),
"X型": ("沙漏型",),
"T型": ("倒三角型",),
"H型": ("矩形型", "直筒型"),
},
),
EnumCategory(
label="脸型",
key_tokens=("face_shape", "脸型"),
# 只收「X脸」复合词:单字(圆/方/长)会把「轮廓偏圆润」这类合规
# 描述性用语(合规 §3 的正例)误杀。
vocabulary=(
"鹅蛋脸",
"圆形脸",
"圆脸",
"方形脸",
"方脸",
"长形脸",
"长脸",
"心形脸",
"菱形脸",
"梨形脸",
),
aliases={
"鹅蛋": ("鹅蛋脸", "鹅蛋型"),
"圆": ("圆脸", "圆形脸"),
"方": ("方脸", "方形脸"),
"长": ("长脸", "长形脸"),
"心形": ("心形脸",),
"菱形": ("菱形脸",),
"梨形": ("梨形脸",),
},
),
EnumCategory(
label="季型",
key_tokens=("color_season", "season", "季型"),
# 十二季细分是自研描述性命名(TD-18),无法全量枚举,只收四季
# 粗分词;细分红利是值本身进 allowed 集合,复述永远合法。
vocabulary=("春季型", "夏季型", "秋季型", "冬季型", "春型", "夏型", "秋型", "冬型"),
aliases={
"春": ("春型", "春季型"),
"夏": ("夏型", "夏季型"),
"秋": ("秋型", "秋季型"),
"冬": ("冬型", "冬季型"),
},
),
)
def _normalize_for_match(text: str) -> str:
"""去空白 + 拉丁字母大写:「x 型」「X型」归一成同一写法再比较。"""
return re.sub(r"\s+", "", text).upper()
def check_pejorative(text: str) -> list[str]:
"""全文扫描贬损词表,返回命中的词(按词表顺序)。"""
return [term for term in PEJORATIVE_TERMS if term in text]
def check_absolute_claims(text: str) -> list[str]:
"""绝对化用语兜底检查,返回命中的词。"""
return [term for term in ABSOLUTE_CLAIM_TERMS if term in text]
def _find_dimension(
dimensions: Sequence[Mapping[str, Any]], key_tokens: Sequence[str]
) -> Mapping[str, Any] | None:
for dim in dimensions:
key = str(dim.get("key") or "")
if any(token in key for token in key_tokens):
return dim
return None
def _check_enum_consistency(
copy_text_normalized: str, dimensions: Sequence[Mapping[str, Any]]
) -> list[str]:
"""规则①:体型/脸型/季型枚举词必须与诊断值同款(含同义别名)。
维度 skipped/无值时,文案出现该类任何枚举词都算编造——LLM 没看到
这个维度却写出了结论,正是素材组装要防的幻觉从校验侧兜底。
每类只报第一条命中,避免同一问题刷屏冲淡修正指令。
"""
issues: list[str] = []
for category in _ENUM_CATEGORIES:
dim = _find_dimension(dimensions, category.key_tokens)
if dim is None:
continue
value = dim.get("value")
if dim.get("status") == "ok" and value:
canonical = str(value)
allowed = {_normalize_for_match(canonical)}
for alias in category.aliases.get(canonical, ()): # 值本身可能就是别名键
allowed.add(_normalize_for_match(alias))
# 诊断值也可能是别名列里的写法(如存了「沙漏型」),反查一次
for canonical_key, alias_values in category.aliases.items():
if _normalize_for_match(canonical) == _normalize_for_match(canonical_key) or any(
_normalize_for_match(canonical) == _normalize_for_match(a) for a in alias_values
):
allowed.add(_normalize_for_match(canonical_key))
allowed.update(_normalize_for_match(a) for a in alias_values)
else:
allowed = set()
for token in category.vocabulary:
normalized_token = _normalize_for_match(token)
if normalized_token in copy_text_normalized and normalized_token not in allowed:
issues.append(
f"{category.label}用词「{token}」与诊断值「{value or '未诊断'}」不一致"
)
break
return issues
def _check_hex_colors(copy_text: str, dimensions: Sequence[Mapping[str, Any]]) -> list[str]:
"""规则②:文案中的 #RRGGBB 必须来自诊断维度(色板)的取值集合。
色值集合从全部维度的 value/evidence 里递归收集而非只认某个 key——
T16 的色板维度 key 未定死,宽收集合只可能放宽校验,不会误杀合规色值。
"""
used = {m.group(0).upper() for m in _HEX_COLOR_RE.finditer(copy_text)}
if not used:
return []
dimension_blob = str([dict(d) for d in dimensions])
allowed = {m.group(0).upper() for m in _HEX_COLOR_RE.finditer(dimension_blob)}
return [f"色值「{color}」不在诊断给出的色板取值范围内" for color in sorted(used - allowed)]
def _check_unsupported_advice(
copy_text: str,
dimensions: Sequence[Mapping[str, Any]],
rule_hits: Sequence[Mapping[str, Any]] | None,
) -> list[str]:
"""规则③(宽松口径):建议/推荐句式必须有规则命中支撑。
取舍:误报的代价是一次无谓重试甚至降级到模板(文案质量明显变差、
多花一次 LLM 调用),漏报的底线由贬损词表与枚举一致性兜住,因此只
标记两种可确定的情形:
① 文案给出建议但规则引擎零命中(建议完全无据);
② 建议句点名了某维度,而该维度既无内嵌 rule_hits、也无顶层命中指向它。
其余(句式识别不全、支撑关系模糊)一律放行——宁漏勿误杀。
"""
sentences = re.split(r"[。!?!?;\n]", copy_text)
advice_sentences = [s for s in sentences if any(marker in s for marker in _ADVICE_MARKERS)]
if not advice_sentences:
return []
top_level_dims = {
str(hit.get("dimension")) for hit in (rule_hits or []) if hit.get("dimension")
}
embedded_exists = any(dim.get("rule_hits") for dim in dimensions)
if not top_level_dims and not embedded_exists:
return ["文案包含建议/推荐表述,但规则引擎没有任何命中来支撑该建议"]
issues: list[str] = []
for sentence in advice_sentences:
snippet = sentence.strip()[:30]
for dim in dimensions:
if dim.get("status") != "ok":
continue
key = str(dim.get("key") or "")
label = dimension_label(dim)
if not label or label not in sentence:
continue
supported = bool(dim.get("rule_hits")) or key in top_level_dims
if not supported:
issues.append(f"建议句「{snippet}」提到{label},但该维度没有规则命中支撑")
return issues
def check_conclusion_consistency(
copy: ReportCopy,
dimensions: list[dict[str, Any]],
*,
rule_hits: Sequence[Mapping[str, Any]] | None = None,
) -> list[str]:
"""文案-结论一致性(T-P3-10,初版三条规则)。返回不一致清单。
① 体型/脸型/季型枚举词必须与诊断值同款;维度未诊断时不得出现该类词;
② 具体色值 #RRGGBB 必须在诊断(色板维度)取值集合内;
③ 建议/推荐句式必须有规则命中支撑(宽松口径,见 _check_unsupported_advice)。
``rule_hits`` 为可选参数:规则③的支撑判定兼容两种契约形态(命中内嵌
在维度里 / 顶层列表),两者都没有时按宽松口径不追责。
"""
dict_dimensions = [d for d in dimensions if isinstance(d, Mapping)]
full_text = copy.full_text()
issues = _check_enum_consistency(_normalize_for_match(full_text), dict_dimensions)
issues.extend(_check_hex_colors(full_text, dict_dimensions))
issues.extend(_check_unsupported_advice(full_text, dict_dimensions, rule_hits))
return issues
def collect_violations(
copy: ReportCopy,
dimensions: list[dict[str, Any]],
*,
rule_hits: Sequence[Mapping[str, Any]] | None = None,
) -> dict[str, list[str]]:
"""三重校验的汇总视图,键固定为 pejorative / consistency / absolute。"""
full_text = copy.full_text()
return {
"pejorative": check_pejorative(full_text),
"consistency": check_conclusion_consistency(copy, dimensions, rule_hits=rule_hits),
"absolute": check_absolute_claims(full_text),
}
def validate_copy(
copy: ReportCopy,
dimensions: list[dict[str, Any]],
*,
rule_hits: Sequence[Mapping[str, Any]] | None = None,
) -> list[str]:
"""展平的违规清单,条目带类别前缀——可直接作为重试的修正指令。"""
breakdown = collect_violations(copy, dimensions, rule_hits=rule_hits)
return [
f"{CHECK_PREFIXES[key]}:{item}"
for key in ("pejorative", "consistency", "absolute")
for item in breakdown[key]
]