Files

368 lines
14 KiB
Python
Raw Permalink 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.
"""生成后校验的纯逻辑(架构 §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_dimensions(
dimensions: Sequence[Mapping[str, Any]], key_tokens: Sequence[str]
) -> list[Mapping[str, Any]]:
"""按 key 识别串收齐全部匹配维度(季型有 season_code 与 season_detailed 两个)。"""
return [
dim
for dim in dimensions
if any(token in str(dim.get("key") or "") for token in key_tokens)
]
def _check_enum_consistency(
copy_text_normalized: str, dimensions: Sequence[Mapping[str, Any]]
) -> list[str]:
"""规则①:体型/脸型/季型枚举词必须与诊断值同款(含同义别名)。
维度 skipped/无值时,文案出现该类任何枚举词都算编造——LLM 没看到
这个维度却写出了结论,正是素材组装要防的幻觉从校验侧兜底。
每类只报第一条命中,避免同一问题刷屏冲淡修正指令。
两类放行:诊断值的「X型」写法(如「柔夏」→「柔夏型」,深度诊断的
十二季文案必经此路径);词表 token 是某个放行词的子串时不再单独追责
(「夏型」⊂「柔夏型」——十二季细分命名无法全量枚举,见 TD-18)。
"""
issues: list[str] = []
for category in _ENUM_CATEGORIES:
matched = _find_dimensions(dimensions, category.key_tokens)
if not matched:
continue
allowed: set[str] = set()
for dim in matched:
value = dim.get("value")
if dim.get("status") == "ok" and isinstance(value, str) and value:
canonical = value
candidates = {canonical, f"{canonical}型"}
candidates.update(category.aliases.get(canonical, ())) # 值本身可能就是别名键
# 诊断值也可能是别名列里的写法(如存了「沙漏型」),反查一次
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
):
candidates.add(canonical_key)
candidates.update(alias_values)
allowed.update(_normalize_for_match(c) for c in candidates)
for token in category.vocabulary:
normalized_token = _normalize_for_match(token)
if normalized_token not in copy_text_normalized:
continue
if normalized_token in allowed:
continue
if any(normalized_token in entry for entry in allowed):
continue
observed = next(
(str(d.get("value")) for d in matched if d.get("status") == "ok"),
"未诊断",
)
issues.append(f"{category.label}用词「{token}」与诊断值「{observed}」不一致")
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]
]