"""生成后校验的纯逻辑(架构 §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] ]