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。
353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""生成后校验的纯逻辑(架构 §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]
|
||
]
|