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
+250
View File
@@ -0,0 +1,250 @@
"""报告文案的纯逻辑:LLM 素材组装与降级模板渲染(无 IO、无 LLM 调用)。
对应架构 §7.1 步骤 17/18 中可独立单测的两块:
- 素材组装:把 T16 的结构化诊断裁剪成 prompt 载荷。**skipped 维度不进
prompt**——LLM 看不到缺失的维度就不会编造它,这是防幻觉的第一道闸
(第二道闸在 validation.py 的生成后校验)。
- 降级模板:持续违规时用规则直出拼一份零 LLM 的合规文案,是 T-P3-11
「绝不放行违规内容」的最终出口——有降级路径,fail-close 才不会变成
「永远失败」。
"""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from typing import Any
from pydantic import BaseModel
from packages.shared.errors import AppError, ErrorCode
#: 素材总长上限(字符)。REPORT_COPY 场景 max_tokens=3000,输入侧按架构
#: §7.2 的裁剪思想控制:低重要度维度先丢,硬结论永不丢。
MAX_MATERIAL_CHARS = 6000
#: 硬结论维度:体/脸/季三大枚举是下游(记忆、Agent、换装推荐)共同依赖的
#: 画像锚点,任何预算压力下都不裁——宁可 prompt 变长,不可结论缺失。
HARD_DIMENSION_KEYS: frozenset[str] = frozenset({"body_shape", "face_shape", "color_season"})
#: 维度裁剪优先级:数值越大越后裁。未登记的维度取默认优先级,新增维度
#: 无需改这张表即可获得「先被裁」的保守待遇。
_DIMENSION_PRIORITY: dict[str, int] = {
"body_shape": 100,
"face_shape": 100,
"color_season": 100,
"skin_tone": 80,
"color_palette": 80,
"style_impression": 60,
"figure_balance": 60,
}
_DEFAULT_PRIORITY = 50
#: 外层 JSON 键({"dimensions":[],"rule_hits":[]})的固定序列化开销估计。
_SERIALIZATION_OVERHEAD = 64
class CopySection(BaseModel):
"""报告正文章节。title 短语即可,文案主体放 paragraphs。"""
title: str
paragraphs: list[str]
class ReportCopy(BaseModel):
"""REPORT_COPY 场景的结构化输出 schema(宽进严出的取舍)。
字段越少,provider 的 JSON 约束成功率越高——解析失败会触发网关的
结构化重试,每次都是真金白银;文案的自由度全部放进 sections 的段落
文本,不靠 schema 枚举去硬卡。语义正确性不靠 schema:那属于生成后
校验(validation.py),两层各管各的。
"""
summary: str
sections: list[CopySection]
def full_text(self) -> str:
"""summary + 全部章节标题与段落,供校验器做全文扫描。"""
parts = [self.summary]
for section in self.sections:
parts.append(section.title)
parts.extend(section.paragraphs)
return "\n".join(parts)
def coerce_parsed_copy(parsed: Any) -> ReportCopy:
"""gateway ``Completion.parsed`` → ReportCopy;类型不符即网关契约破裂。
正常不会走到 raise(gateway 带 schema 调用成功即保证已过模型校验),
留这道闸是为了把意外的 TypeError 统一成 AppError,方便编排层按
错误码落任务失败与退款。
"""
if not isinstance(parsed, ReportCopy):
msg = "REPORT_COPY 场景的 completion 未携带 ReportCopy 结构"
raise AppError(ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED, message=msg)
return parsed
def _compact_dimension(dim: Mapping[str, Any]) -> dict[str, Any]:
"""维度瘦身到 prompt 需要的字段:key/value/evidence + 两位小数置信度。
confidence 保留(低置信维度要用不确定表述,模型必须能看到它),只把
视觉层带来的浮点尾数舍掉,省字符且避免无意义的精度噪音。
"""
confidence = dim.get("confidence")
compact: dict[str, Any] = {
"key": dim.get("key"),
"value": dim.get("value"),
"evidence": list(dim.get("evidence") or []),
}
if isinstance(confidence, (int, float)):
compact["confidence"] = round(float(confidence), 2)
return compact
def _compact_rule_hits(rule_hits: Sequence[Any]) -> list[dict[str, Any]]:
"""规则命中瘦身:保留 key/维度/建议/解释/硬禁忌标记,其余字段不进 prompt。"""
compacted: list[dict[str, Any]] = []
for hit in rule_hits:
if not isinstance(hit, Mapping):
continue
compact: dict[str, Any] = {"rule_key": hit.get("rule_key") or hit.get("key")}
for field in ("dimension", "suggestion", "explanation", "is_hard"):
if hit.get(field) is not None:
compact[field] = hit[field]
compacted.append(compact)
return compacted
def _compact_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def select_prompt_dimensions(
result_diagnosis: Mapping[str, Any],
*,
max_chars: int = MAX_MATERIAL_CHARS,
) -> tuple[list[dict[str, Any]], list[str]]:
"""取 status=ok 的维度并做超长裁剪;返回 (保留维度, 被裁掉的 key 列表)。
- skipped/error 维度直接不进素材(防幻觉第一道闸,见模块 docstring);
- 超长时按优先级升序丢非硬结论维度(低重要度先丢,硬结论永不丢);
- 若保留的全是硬结论仍超长,不再裁:硬结论缺位的代价远大于长 prompt,
由 provider 侧 max_tokens 兜底。此时 dropped 可能为空但载荷超长。
"""
raw_dimensions = [
d for d in (result_diagnosis.get("dimensions") or []) if isinstance(d, Mapping)
]
kept = [_compact_dimension(d) for d in raw_dimensions if d.get("status") == "ok"]
hits_len = len(_compact_json(_compact_rule_hits(result_diagnosis.get("rule_hits") or [])))
budget = max(max_chars - hits_len - _SERIALIZATION_OVERHEAD, 0)
droppable = sorted(
(d for d in kept if str(d.get("key")) not in HARD_DIMENSION_KEYS),
key=lambda d: (
_DIMENSION_PRIORITY.get(str(d.get("key")), _DEFAULT_PRIORITY),
str(d.get("key")),
),
)
dropped: list[str] = []
while droppable and len(_compact_json(kept)) > budget:
victim = droppable.pop(0)
kept.remove(victim)
dropped.append(str(victim["key"]))
return kept, dropped
def compose_prompt_material(
result_diagnosis: Mapping[str, Any],
*,
max_chars: int = MAX_MATERIAL_CHARS,
) -> tuple[str, list[str]]:
"""组装 {dimensions_json} 载荷,连同被裁掉的维度 key 一起返回。
CopyStage 一次调用拿全两者:载荷进 prompt,dropped_keys 落进
result["copy"] 供查询侧与排查用(用户看到的报告少了哪些维度)。
"""
kept, dropped = select_prompt_dimensions(result_diagnosis, max_chars=max_chars)
payload = {
"dimensions": kept,
"rule_hits": _compact_rule_hits(result_diagnosis.get("rule_hits") or []),
}
return _compact_json(payload), dropped
def build_prompt_material(result_diagnosis: Mapping[str, Any]) -> str:
"""任务契约签名:只返回素材字符串(dropped_keys 用 compose_prompt_material 取)。"""
material, _dropped = compose_prompt_material(result_diagnosis)
return material
#: 维度 key → 中文标签。模板文案与校验器共用的最小字典;T16 若在维度里
#: 自带 label 字段则优先用它的。
_KEY_LABELS: dict[str, str] = {
"body_shape": "体型",
"face_shape": "脸型",
"color_season": "季型",
"skin_tone": "肤色",
"color_palette": "色板",
}
_FALLBACK_SUMMARY = (
"以下内容根据本次分析的结构化结论整理,每条结论都来自规则引擎对诊断值的直接匹配。"
)
_FALLBACK_EMPTY_NOTE = "本次分析暂无可用结论,请稍后重新生成报告。"
def dimension_label(dim: Mapping[str, Any]) -> str:
"""维度的中文标签:优先维度自带 label,其次按 key 查表,兜底返回 key。"""
label = dim.get("label")
if isinstance(label, str) and label:
return label
key = str(dim.get("key") or "")
return _KEY_LABELS.get(key, key or "维度")
def _evidence_text(dim: Mapping[str, Any]) -> str:
evidence = dim.get("evidence")
if isinstance(evidence, Sequence) and not isinstance(evidence, str) and evidence:
return "判定依据:" + ";".join(str(item) for item in evidence)
if isinstance(evidence, str) and evidence:
return f"判定依据:{evidence}"
return ""
def render_fallback_copy(
dimensions: Sequence[Any],
rule_hits: Sequence[Any],
) -> ReportCopy:
"""降级模板:按维度 value/evidence + 规则 suggestion/explanation 拼文案,零 LLM。
持续违规(重试一次仍不过)时的最终出口。模板只做受控字符串拼接、
逐字复述诊断值与规则说明,不含任何自由生成文本,因此天然通过词表/
一致性/绝对化三重校验——ValidationStage 仍会对它跑一遍校验做
fail-close 自证(见 validation_stage.py)。
"""
ok_dims = [d for d in dimensions if isinstance(d, Mapping) and d.get("status") == "ok"]
conclusion_paragraphs = []
for dim in ok_dims:
value = dim.get("value") or ""
para = f"你的{dimension_label(dim)}结论为「{value}」。{_evidence_text(dim)}".rstrip()
conclusion_paragraphs.append(para)
advice_paragraphs = []
for hit in rule_hits:
if not isinstance(hit, Mapping):
continue
parts = [str(hit[field]) for field in ("suggestion", "explanation") if hit.get(field)]
if parts:
advice_paragraphs.append("· " + "——".join(parts))
sections: list[CopySection] = []
if conclusion_paragraphs:
sections.append(CopySection(title="诊断结论", paragraphs=conclusion_paragraphs))
if advice_paragraphs:
sections.append(CopySection(title="搭配建议", paragraphs=advice_paragraphs))
if not sections:
sections.append(CopySection(title="说明", paragraphs=[_FALLBACK_EMPTY_NOTE]))
return ReportCopy(summary=_FALLBACK_SUMMARY, sections=sections)
+63
View File
@@ -0,0 +1,63 @@
"""流水线阶段:LLM 文案生成(架构 §7.1 步骤 17)。
只生成、不校验——生成后校验是下一阶段(步骤 18)的独立职责。把两者
塞进同一个阶段会让「校验不可跳过」失去结构保证:跳过一个阶段是配置
错误(会被装配层拦下),跳过半段代码不是。
"""
from __future__ import annotations
from packages.llm.types import Scene
from packages.report.copy import ReportCopy, coerce_parsed_copy, compose_prompt_material
from packages.report.pipeline import ReportContext
from packages.shared.errors import AppError, ErrorCode
from packages.shared.logging import get_logger
logger = get_logger(__name__)
class CopyStage:
"""name="copy":调 REPORT_COPY 场景生成结构化文案,产物落 result["copy"]。
产物键:summary / sections / model / prompt_version / dropped_keys。
dropped_keys 记录因超长被裁出 prompt 的维度——查询侧据此知道报告
少了哪些维度,避免「报告里没有」被当成「诊断说没有」。
"""
name = "copy"
async def run(self, ctx: ReportContext) -> None:
diagnosis = ctx.report.result.get("diagnosis")
if not isinstance(diagnosis, dict):
# 没有 diagnosis 产物说明流水线装配顺序错了(copy 排在了 diagnosis 前面)
raise AppError(
ErrorCode.TASK_STATE_INVALID,
message="copy 阶段缺少 diagnosis 产物(流水线阶段顺序错误)",
)
if ctx.gateway is None:
raise AppError(ErrorCode.CONFIG_ERROR, message="报告流水线缺少 LLM 网关")
material, dropped_keys = compose_prompt_material(diagnosis)
completion = await ctx.gateway.complete(
scene=Scene.REPORT_COPY,
prompt_vars={"dimensions_json": material},
schema=ReportCopy,
user_id=ctx.report.user_id,
ref_type="report",
ref_id=str(ctx.report.report_id),
)
copy = coerce_parsed_copy(completion.parsed)
ctx.report.result["copy"] = {
"summary": copy.summary,
"sections": [section.model_dump() for section in copy.sections],
"model": completion.model,
"prompt_version": completion.prompt_version,
"dropped_keys": dropped_keys,
}
logger.info(
"report_copy_generated",
report_id=ctx.report.report_id,
model=completion.model,
dropped_dimensions=len(dropped_keys),
)
+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]
]
+173
View File
@@ -0,0 +1,173 @@
"""流水线阶段:生成后校验(架构 §7.1 步骤 18,T-P3-10 / T-P3-11 🔴)。
出口只有两种:``passed=true``(干净通过 / 重试后通过 / 降级模板),
或抛错让整条流水线失败。**绝不放行未过校验的文案**——宁可任务失败退款,
也不让一条贬损文案到达用户(T-P3-11 的硬性禁忌违反数必须为 0)。
重试语义:这里的「重试一次」是**语义层**修正(把违规清单作为修正指令
追加进 messages 重新生成),与 gateway 的结构化重试(解析层,JSON 合不
合法)互不共享计数——解析成功但内容违规,网关不会替我们发现。
"""
from __future__ import annotations
from typing import Any
from pydantic import ValidationError
from packages.llm.types import Completion, Message, Scene
from packages.report.copy import (
ReportCopy,
coerce_parsed_copy,
compose_prompt_material,
render_fallback_copy,
)
from packages.report.pipeline import ReportContext
from packages.report.validation import validate_copy
from packages.shared.errors import AppError, ErrorCode
from packages.shared.logging import get_logger
logger = get_logger(__name__)
#: 语义层修正的最大重试次数(架构 §7.1:校验失败最多重试后降级)。
#: 再多就是花钱买降级——持续违规说明模型在该素材上不可救。
_MAX_SEMANTIC_RETRIES = 1
#: 降级模板的 model 标记:查询侧/排查侧凭它知道这份文案没经过 LLM。
_FALLBACK_MODEL = "fallback-template"
def _clean_checks() -> dict[str, int]:
"""全过时的 checks 计数(最终放行文案的违规数必然全零)。"""
return {"pejorative": 0, "consistency": 0, "absolute": 0}
def _copy_payload(
copy: ReportCopy, base: dict[str, Any], model: str, prompt_version: str | None
) -> dict[str, Any]:
"""组装 result["copy"]:文案主体换新,dropped_keys 等诊断类元数据沿用。"""
return {
**base,
"summary": copy.summary,
"sections": [section.model_dump() for section in copy.sections],
"model": model,
"prompt_version": prompt_version,
}
class ValidationStage:
"""name="validation":三重校验 → 违规则重试一次 → 仍违规则降级模板。
产物落 result["validation"]:passed 恒为 true(或本阶段抛错),
degraded=true 表示最终文案是规则直出模板;violations_first_pass
留存第一轮违规清单,供报告查询侧与质量分析回看降级原因。
"""
name = "validation"
async def run(self, ctx: ReportContext) -> None:
result = ctx.report.result
diagnosis = result.get("diagnosis")
copy_payload = result.get("copy")
if not isinstance(diagnosis, dict) or not isinstance(copy_payload, dict):
raise AppError(
ErrorCode.TASK_STATE_INVALID,
message="validation 阶段缺少 copy/diagnosis 产物(流水线阶段顺序错误)",
)
try:
copy = ReportCopy.model_validate(copy_payload)
except ValidationError as exc:
raise AppError(
ErrorCode.TASK_STATE_INVALID,
message=f"copy 产物结构不合法:{exc.error_count()} 处校验错误",
) from exc
dimensions = [d for d in (diagnosis.get("dimensions") or []) if isinstance(d, dict)]
rule_hits = [h for h in (diagnosis.get("rule_hits") or []) if isinstance(h, dict)]
violations = validate_copy(copy, dimensions, rule_hits=rule_hits)
if not violations:
result["validation"] = {"passed": True, "checks": _clean_checks()}
return
logger.warning(
"report_copy_violations_found",
report_id=ctx.report.report_id,
violations=violations,
)
retry = await self._retry_with_fixes(ctx, diagnosis, violations)
if retry is not None:
retry_copy, retry_completion = retry
retry_violations = validate_copy(retry_copy, dimensions, rule_hits=rule_hits)
if not retry_violations:
result["copy"] = _copy_payload(
retry_copy,
copy_payload,
model=retry_completion.model,
prompt_version=retry_completion.prompt_version,
)
result["validation"] = {
"passed": True,
"retried": True,
"violations_first_pass": violations,
"checks": _clean_checks(),
}
return
logger.warning(
"report_copy_retry_still_violating",
report_id=ctx.report.report_id,
violations=retry_violations,
)
# 降级:规则直出模板,零 LLM。模板由受控拼接生成,理论必过校验;
# 仍不过(词表演进/诊断值自带违规词)则 fail-close:抛错让流水线
# 失败并退款——这正是「任何出口要么 passed 要么抛错」的含义。
fallback = render_fallback_copy(dimensions, rule_hits)
residual = validate_copy(fallback, dimensions, rule_hits=rule_hits)
if residual:
raise AppError(
ErrorCode.INTERNAL_ERROR,
message=f"降级模板文案未通过校验,拒绝放行:{residual}",
)
result["copy"] = _copy_payload(
fallback, copy_payload, model=_FALLBACK_MODEL, prompt_version=None
)
result["validation"] = {
"passed": True,
"degraded": True,
"violations_first_pass": violations,
"checks": _clean_checks(),
}
logger.warning(
"report_copy_degraded_to_template",
report_id=ctx.report.report_id,
first_pass_violations=len(violations),
)
async def _retry_with_fixes(
self,
ctx: ReportContext,
diagnosis: dict[str, Any],
violations: list[str],
) -> tuple[ReportCopy, Completion] | None:
"""带修正指令重新生成一次;网关层故障原样抛出(属基础设施错误,
不该被降级模板掩盖——那是内容问题出口,不是可用性出口)。"""
if ctx.gateway is None:
raise AppError(ErrorCode.CONFIG_ERROR, message="报告流水线缺少 LLM 网关")
material, _dropped = compose_prompt_material(diagnosis)
correction = (
"上一版报告文案未通过内容校验,违规清单如下:\n"
+ "\n".join(f"- {item}" for item in violations)
+ "\n请严格遵守系统提示词的内容红线,重新生成整份文案:只复述给定诊断值,"
"删除全部违规表述,不要解释修改过程。"
)
completion = await ctx.gateway.complete(
scene=Scene.REPORT_COPY,
messages=[Message(role="user", content=correction)],
prompt_vars={"dimensions_json": material},
schema=ReportCopy,
user_id=ctx.report.user_id,
ref_type="report",
ref_id=str(ctx.report.report_id),
)
return coerce_parsed_copy(completion.parsed), completion