Files
meida_backend/packages/report/validation.py
T
xsl e78ba3feef 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。
2026-08-14 22:09:09 +08:00

353 lines
13 KiB
Python
Raw 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_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]
]