merge: T17 报告文案生成与生成后校验(T-P3-10/11)

This commit is contained in:
xsl
2026-08-14 22:10:26 +08:00
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
+11 -8
View File
@@ -1,17 +1,20 @@
[system]
你为「美搭」的美学报告撰写文案。输入是结构化诊断结论与规则命中结果,输出是给用户阅读的报告正文。
你是「美搭」的形象顾问,为用户的美学报告撰写文案。输入是结构化诊断结论与规则命中结果,输出是给用户阅读的报告正文。
硬性约束:
- 只能使用输入中给出的诊断值与知识条目,不得引入任何未提供的事实。
- 不得违反输入中标记为硬禁忌的规则,一条都不行。
内容红线(违反任何一条,整篇文案即作废重写):
- 结论只能复述输入中给出的诊断值,不得新增、改写或推断任何维度结论;输入未提供的维度一律不提。
- 描述而非评判:客观陈述特征与搭配路径,不评判外貌。
- 不出现任何贬损性词汇,例如胖、丑、矮、土气、显老、缺陷一类表述。
- 不使用绝对化用语,例如最好、第一、彻底、百分百。
- 不做医疗、减重、整形、命理相关的结论或暗示。
- 不评判外貌,不使用贬损性词汇;描述而非评价。
- 置信度低的维度必须使用不确定表述,不得断言。
- 置信度低的维度使用不确定表述,不得断言。
写作要求:
- 简体中文,平实具体,避免营销腔与绝对化用语。
- 简体中文,平实具体,避免营销腔。
- 每个结论后面跟一句「为什么」,依据来自输入的规则命中说明。
- 专业术语首次出现时补白话解释。
[user]
{payload}
以下是结构化诊断素材(仅含已成功诊断的维度与规则命中结果),请据此生成报告文案:
{dimensions_json}
+268
View File
@@ -0,0 +1,268 @@
"""报告 copy / validation 两阶段的集成验证(FakeProvider 替换真实 provider)。
不依赖真实 PG/Redis(本组用例只读写内存中的 RpReport.result),但按批次
约定放 integration:这里组装的是真实网关栈(PromptRegistry 读仓库里的
report_copy/v1.txt、路由、预算、结构化解析),验证「prompt 模板 →
gateway 结构化输出 → 阶段产物 → 语义重试/降级」的完整咬合,纯单测的桩
盖不住这条链。不测整条流水线编排(T15 已覆盖),只直接构造 ctx 调阶段。
"""
from __future__ import annotations
import itertools
import json
from collections.abc import Sequence
from decimal import Decimal
from pathlib import Path
from typing import Any, cast
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from packages.llm.budget import DailyBudgetGuard, InMemoryCostCounter
from packages.llm.circuit import CircuitBreakerRegistry
from packages.llm.cost import InMemoryAICallRecorder, ModelRate, RateTable
from packages.llm.gateway import LLMGateway
from packages.llm.prompts import PromptRegistry
from packages.llm.routing import SceneRouter, build_scene_configs
from packages.llm.types import ProviderRequest, ProviderResponse, Usage
from packages.report.copy import ReportCopy
from packages.report.copy_stage import CopyStage
from packages.report.pipeline import ReportContext
from packages.report.validation import check_pejorative, validate_copy
from packages.report.validation_stage import ValidationStage
from packages.shared.config import get_settings
from packages.shared.errors import AppError, ErrorCode
from packages.shared.models import RpInput, RpReport
pytestmark = [pytest.mark.integration]
REPO_PROMPTS = Path(__file__).resolve().parents[2] / "prompts"
class QueuedProvider:
"""按队列返回预置响应,耗尽后重复最后一个——最后一条即「持续脏」语义。"""
def __init__(self, outcomes: Sequence[ProviderResponse]) -> None:
self._outcomes = list(outcomes)
self.requests: list[ProviderRequest] = []
@property
def name(self) -> str:
return "p1"
@property
def default_model(self) -> str:
return "fake-model"
async def complete(self, request: ProviderRequest) -> ProviderResponse:
self.requests.append(request)
return self._outcomes[min(len(self.requests) - 1, len(self._outcomes) - 1)]
async def aclose(self) -> None:
return None
_RATE = ModelRate(
input_cents_per_mtok=Decimal("100"),
cached_input_cents_per_mtok=Decimal("10"),
output_cents_per_mtok=Decimal("200"),
)
def _build_gateway(provider: QueuedProvider) -> LLMGateway:
return LLMGateway(
providers={"p1": provider},
router=SceneRouter(build_scene_configs(primary_provider="p1", backup_provider=None)),
prompts=PromptRegistry(REPO_PROMPTS),
rates=RateTable({"fake-model": _RATE}, default=_RATE),
recorder=InMemoryAICallRecorder(),
budget=DailyBudgetGuard(
InMemoryCostCounter(), daily_budget_cents=Decimal("100000"), enabled=True
),
breakers=CircuitBreakerRegistry(),
id_factory=itertools.count(1).__next__,
)
def _diagnosis() -> dict[str, Any]:
return {
"dimensions": [
{
"key": "body_shape",
"status": "ok",
"value": "X型",
"confidence": 0.76,
"evidence": ["shoulder_hip_ratio:1.02", "waist_hip_ratio:0.71"],
"rule_hits": ["R-BODY-0007"],
},
{
"key": "face_shape",
"status": "ok",
"value": "鹅蛋",
"confidence": 0.81,
"evidence": ["face_ratio:1.35"],
"rule_hits": [],
},
{
"key": "color_season",
"status": "ok",
"value": "浅暖柔和型",
"confidence": 0.82,
"evidence": ["contrast:low"],
"rule_hits": ["R-COLOR-0021"],
},
{
# skipped 维度:不应出现在任何一次请求的素材里(防幻觉得到结构保证)
"key": "posture_check",
"status": "skipped",
"value": "",
"confidence": 0.0,
"evidence": [],
"rule_hits": [],
},
],
"rule_hits": [
{
"rule_key": "R-BODY-0007",
"dimension": "body_shape",
"suggestion": "优先选择收腰版型突出腰线",
"explanation": "X 型体型肩腰臀比例均衡,收腰设计能强化这一优势",
},
{
"rule_key": "R-COLOR-0021",
"dimension": "color_season",
"suggestion": "主色选择低饱和暖调",
"explanation": "浅暖柔和型对比弱,低饱和暖调更显匀净",
},
],
}
CLEAN_COPY: dict[str, Any] = {
"summary": "本次分析完成:体型、脸型与色彩结论均来自照片与问卷的结构化测算。",
"sections": [
{
"title": "体型与比例",
"paragraphs": [
"你的体型结论为 X 型,肩腰臀比例均衡。",
"建议优先选择收腰版型,突出腰部线条。",
],
},
{"title": "脸型与领线", "paragraphs": ["脸型轮廓流畅,接近鹅蛋脸,多数领型都能协调。"]},
{"title": "色彩方向", "paragraphs": ["色彩结论为浅暖柔和型,建议主色选择低饱和暖调。"]},
],
}
DIRTY_COPY: dict[str, Any] = {
"summary": "整体看身材偏微胖,需要靠版型修饰,收腰款最合适。",
"sections": [
{"title": "体型小结", "paragraphs": ["你的体型属于 X 型,轮廓数据完整。"]},
],
}
def _response(payload: dict[str, Any]) -> ProviderResponse:
return ProviderResponse(
text=json.dumps(payload, ensure_ascii=False),
model="fake-model",
usage=Usage(tokens_in=1200, tokens_out=400, tokens_cached=900),
)
def _make_ctx(gateway: LLMGateway, *, result: dict[str, Any] | None = None) -> ReportContext:
report = RpReport(
report_id=910001,
user_id=42,
result=result if result is not None else {"diagnosis": _diagnosis()},
)
report_input = RpInput(input_id=910002, report_id=910001)
return ReportContext(
report=report,
report_input=report_input,
# 本组用例的阶段只读写内存中的 result,不触库;session 仅为满足契约形状
session=cast("AsyncSession", None),
settings=get_settings(),
gateway=gateway,
)
async def test_clean_llm_copy_passes_both_stages() -> None:
provider = QueuedProvider([_response(CLEAN_COPY)])
ctx = _make_ctx(_build_gateway(provider))
await CopyStage().run(ctx)
await ValidationStage().run(ctx)
copy_payload = ctx.report.result["copy"]
assert copy_payload["model"] == "fake-model"
assert copy_payload["prompt_version"] == "v1"
assert copy_payload["dropped_keys"] == []
assert ctx.report.result["validation"] == {
"passed": True,
"checks": {"pejorative": 0, "consistency": 0, "absolute": 0},
}
assert len(provider.requests) == 1 # 干净文案不需要任何重试
# skipped 维度不出现在发给 LLM 的任何消息里(防幻觉第一道闸)
all_text = "".join(message.content for message in provider.requests[0].messages)
assert "posture_check" not in all_text
assert "X型" in all_text
async def test_dirty_once_copy_passes_after_one_semantic_retry() -> None:
provider = QueuedProvider([_response(DIRTY_COPY), _response(CLEAN_COPY)])
ctx = _make_ctx(_build_gateway(provider))
await CopyStage().run(ctx)
await ValidationStage().run(ctx)
validation = ctx.report.result["validation"]
assert validation["passed"] is True
assert validation["retried"] is True
assert "degraded" not in validation
assert validation["violations_first_pass"] # 第一轮违规清单非空(微胖/最合适)
# 重试通过后放行的是修正版文案
assert ctx.report.result["copy"]["summary"] == CLEAN_COPY["summary"]
# 恰好两次调用:初稿 + 一次语义修正(与网关解析层重试互不叠加)
assert len(provider.requests) == 2
retry_text = "".join(message.content for message in provider.requests[1].messages)
assert "贬损词" in retry_text # 修正指令带上了违规清单
async def test_persistently_dirty_llm_degrades_to_zero_pejorative_copy() -> None:
provider = QueuedProvider([_response(DIRTY_COPY)]) # 恒返脏文案
ctx = _make_ctx(_build_gateway(provider))
await CopyStage().run(ctx)
await ValidationStage().run(ctx)
validation = ctx.report.result["validation"]
assert validation["passed"] is True
assert validation["degraded"] is True
assert validation["violations_first_pass"]
copy_payload = ctx.report.result["copy"]
assert copy_payload["model"] == "fallback-template"
assert copy_payload["prompt_version"] is None
# 黄金断言(T-P3-11):最终触达用户的文案零贬损词、零违规
final_copy = ReportCopy.model_validate(copy_payload)
assert check_pejorative(final_copy.full_text()) == []
diagnosis = ctx.report.result["diagnosis"]
assert (
validate_copy(final_copy, diagnosis["dimensions"], rule_hits=diagnosis["rule_hits"]) == []
)
# 初稿 + 一次重试,之后停止烧钱、改走零 LLM 模板
assert len(provider.requests) == 2
async def test_stages_fail_fast_when_predecessor_output_is_missing() -> None:
gateway = _build_gateway(QueuedProvider([_response(CLEAN_COPY)]))
ctx = _make_ctx(gateway, result={})
with pytest.raises(AppError) as copy_exc:
await CopyStage().run(ctx)
assert copy_exc.value.code == ErrorCode.TASK_STATE_INVALID
ctx_without_copy = _make_ctx(gateway, result={"diagnosis": _diagnosis()})
with pytest.raises(AppError) as validation_exc:
await ValidationStage().run(ctx_without_copy)
assert validation_exc.value.code == ErrorCode.TASK_STATE_INVALID
+211
View File
@@ -0,0 +1,211 @@
"""报告文案纯逻辑:素材组装(skipped 不进 / 超长裁剪 / 硬结论不裁)与降级模板。"""
from __future__ import annotations
import json
from typing import Any
from packages.report.copy import (
build_prompt_material,
compose_prompt_material,
render_fallback_copy,
)
from packages.report.validation import validate_copy
def _dim(
key: str,
value: str = "X型",
*,
status: str = "ok",
confidence: float = 0.82,
evidence: tuple[str, ...] = ("shoulder_hip_ratio:1.02",),
rule_hits: tuple[str, ...] = ("R-BODY-0007",),
) -> dict[str, Any]:
return {
"key": key,
"status": status,
"value": value,
"confidence": confidence,
"evidence": list(evidence),
"rule_hits": list(rule_hits),
}
def _diagnosis(
*dimensions: dict[str, Any], rule_hits: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
return {"dimensions": list(dimensions), "rule_hits": rule_hits or []}
# ---------------------------------------------------------------------------
# 素材组装:skipped 不进 prompt(防幻觉第一道闸)
# ---------------------------------------------------------------------------
def test_build_prompt_material_excludes_skipped_dimensions() -> None:
diagnosis = _diagnosis(
_dim("body_shape", "X型"),
_dim("face_shape", "鹅蛋"),
_dim("occasion_preference", status="skipped", value=""),
)
material = build_prompt_material(diagnosis)
payload = json.loads(material)
keys = [d["key"] for d in payload["dimensions"]]
assert keys == ["body_shape", "face_shape"]
# skipped 维度连同它的 key/value 都不能出现在载荷字符串里——LLM 看不到
# 缺失维度就不会编造它
assert "occasion_preference" not in material
def test_build_prompt_material_rounds_confidence() -> None:
diagnosis = _diagnosis(_dim("body_shape", "X型", confidence=0.876543))
payload = json.loads(build_prompt_material(diagnosis))
assert payload["dimensions"][0]["confidence"] == 0.88
def test_build_prompt_material_keeps_key_value_evidence_and_rule_hits() -> None:
hits = [
{
"rule_key": "R-BODY-0007",
"dimension": "body_shape",
"suggestion": "优先选择收腰版型",
"explanation": "X 型体型肩腰臀比例均衡",
"is_hard": False,
"internal_field": "不该进 prompt 的字段",
}
]
diagnosis = _diagnosis(
_dim("body_shape", "X型", evidence=("ratio:1.02", "ratio:0.71")), rule_hits=hits
)
material = build_prompt_material(diagnosis)
payload = json.loads(material)
assert payload["dimensions"][0] == {
"key": "body_shape",
"value": "X型",
"evidence": ["ratio:1.02", "ratio:0.71"],
"confidence": 0.82,
}
assert payload["rule_hits"] == [
{
"rule_key": "R-BODY-0007",
"dimension": "body_shape",
"suggestion": "优先选择收腰版型",
"explanation": "X 型体型肩腰臀比例均衡",
"is_hard": False,
}
]
assert "internal_field" not in material
# ---------------------------------------------------------------------------
# 素材组装:超长裁剪
# ---------------------------------------------------------------------------
def test_overlong_material_drops_low_priority_dimensions_and_records_keys() -> None:
big_evidence = ("x" * 200,) * 3 # 每个维度约 600+ 字符
diagnosis = _diagnosis(
_dim("body_shape", "X型", evidence=big_evidence), # 硬结论,优先级 100
_dim("style_impression", evidence=big_evidence), # 优先级 60
_dim("occasion_preference", evidence=big_evidence), # 未登记 → 默认优先级 50
)
material, dropped = compose_prompt_material(diagnosis, max_chars=1000)
# 默认优先级先丢,其次 style_impression;硬结论保留
assert dropped == ["occasion_preference", "style_impression"]
payload = json.loads(material)
assert [d["key"] for d in payload["dimensions"]] == ["body_shape"]
assert len(material) <= 1000
def test_hard_dimensions_are_never_dropped_even_when_over_budget() -> None:
huge_evidence = ("x" * 400,) * 5
diagnosis = _diagnosis(
_dim("body_shape", "X型", evidence=huge_evidence),
_dim("face_shape", "鹅蛋", evidence=huge_evidence),
_dim("color_season", "浅暖柔和型", evidence=huge_evidence),
)
material, dropped = compose_prompt_material(diagnosis, max_chars=200)
assert dropped == []
payload = json.loads(material)
assert [d["key"] for d in payload["dimensions"]] == ["body_shape", "face_shape", "color_season"]
# ---------------------------------------------------------------------------
# 降级模板
# ---------------------------------------------------------------------------
def test_render_fallback_copy_includes_values_evidence_and_rule_explanations() -> None:
dimensions = [
_dim(
"body_shape", "X型", evidence=("shoulder_hip_ratio:1.02",), rule_hits=("R-BODY-0007",)
),
_dim("color_season", "浅暖柔和型", evidence=("contrast:low",), rule_hits=("R-COLOR-0021",)),
]
rule_hits = [
{
"rule_key": "R-BODY-0007",
"dimension": "body_shape",
"suggestion": "优先选择收腰版型突出腰线",
"explanation": "X 型体型肩腰臀比例均衡,收腰设计能强化这一优势",
}
]
fallback = render_fallback_copy(dimensions, rule_hits)
titles = [s.title for s in fallback.sections]
assert titles == ["诊断结论", "搭配建议"]
conclusion = "\n".join(fallback.sections[0].paragraphs)
assert "「X型」" in conclusion and "shoulder_hip_ratio:1.02" in conclusion
assert "「浅暖柔和型」" in conclusion
advice = "\n".join(fallback.sections[1].paragraphs)
assert "优先选择收腰版型突出腰线" in advice
# 有 rule_hits 时必须含 explanation——解释链是模板文案的最低质量线
assert "收腰设计能强化这一优势" in advice
def test_render_fallback_copy_without_hits_has_no_advice_section() -> None:
dimensions = [_dim("body_shape", "X型")]
fallback = render_fallback_copy(dimensions, [])
assert [s.title for s in fallback.sections] == ["诊断结论"]
def test_render_fallback_copy_with_no_usable_input_explains_itself() -> None:
fallback = render_fallback_copy([_dim("occasion_preference", status="skipped", value="")], [])
assert [s.title for s in fallback.sections] == ["说明"]
assert fallback.sections[0].paragraphs
def test_fallback_copy_passes_the_full_validation_suite() -> None:
"""降级模板必须天然通过三重校验——降级路径的黄金性质在纯函数级先锁一遍。"""
dimensions = [
_dim(
"body_shape", "X型", evidence=("shoulder_hip_ratio:1.02",), rule_hits=("R-BODY-0007",)
),
_dim("face_shape", "鹅蛋", evidence=("face_ratio:1.35",)),
]
rule_hits = [
{
"rule_key": "R-BODY-0007",
"dimension": "body_shape",
"suggestion": "优先选择收腰版型突出腰线",
"explanation": "X 型体型肩腰臀比例均衡",
}
]
fallback = render_fallback_copy(dimensions, rule_hits)
assert validate_copy(fallback, dimensions, rule_hits=rule_hits) == []
+211
View File
@@ -0,0 +1,211 @@
"""生成后校验纯逻辑:贬损词表(含变形)、一致性三条规则、绝对化用语、降级路径。"""
from __future__ import annotations
from typing import Any
from packages.report.copy import CopySection, ReportCopy, render_fallback_copy
from packages.report.validation import (
check_absolute_claims,
check_conclusion_consistency,
check_pejorative,
validate_copy,
)
def _copy(summary: str = "", *paragraphs: str) -> ReportCopy:
return ReportCopy(
summary=summary,
sections=[CopySection(title="正文", paragraphs=list(paragraphs))],
)
def _dim(
key: str,
value: str = "",
*,
status: str = "ok",
rule_hits: tuple[str, ...] = (),
evidence: tuple[str, ...] = (),
) -> dict[str, Any]:
return {
"key": key,
"status": status,
"value": value,
"confidence": 0.82,
"evidence": list(evidence),
"rule_hits": list(rule_hits),
}
# ---------------------------------------------------------------------------
# 贬损词表
# ---------------------------------------------------------------------------
def test_pejorative_hits_variants_without_stemming() -> None:
# 「有点胖」「微胖」都命中「胖」——子串匹配即变形处理,不做词干
assert check_pejorative("整体轮廓有点胖,建议宽松款") == ["胖"]
assert check_pejorative("微胖体型适合直筒剪裁") == ["胖"]
def test_pejorative_returns_every_matched_term_in_table_order() -> None:
assert check_pejorative("配色显老又土气,显得臃肿") == ["臃肿", "土气", "显老"]
def test_pejorative_clean_text_passes() -> None:
assert check_pejorative("肩腰臀比例均衡,收腰设计能强化这一优势。") == []
def test_pejorative_terms_count_stays_within_initial_band() -> None:
"""初版词表约定 20~40 词:太少护不住红线,太多误杀(正式版归 T-P0-07)。"""
from packages.report.validation import PEJORATIVE_TERMS
assert 20 <= len(PEJORATIVE_TERMS) <= 40
# ---------------------------------------------------------------------------
# 一致性规则①:体型/脸型/季型枚举词
# ---------------------------------------------------------------------------
def test_enum_word_mismatching_diagnosis_is_flagged() -> None:
dimensions = [_dim("body_shape", "X型")]
copy = _copy("你的整体印象", "你的体型属于 A 型,肩线舒展。")
issues = check_conclusion_consistency(copy, dimensions)
assert any("体型" in issue and "A型" in issue for issue in issues)
def test_enum_word_matching_diagnosis_or_its_alias_passes() -> None:
dimensions = [_dim("body_shape", "X型")]
same_value = _copy("你的整体印象", "你的体型结论为 X 型,比例均衡。")
alias = _copy("你的整体印象", "接近沙漏型的轮廓,腰线明显。")
assert check_conclusion_consistency(same_value, dimensions) == []
assert check_conclusion_consistency(alias, dimensions) == []
def test_enum_word_for_skipped_dimension_is_treated_as_fabrication() -> None:
dimensions = [_dim("face_shape", status="skipped")]
copy = _copy("你的整体印象", "你的圆脸轮廓柔和,适合纵向线条。")
issues = check_conclusion_consistency(copy, dimensions)
assert any("脸型" in issue for issue in issues)
def test_season_enum_mismatch_is_flagged() -> None:
dimensions = [_dim("color_season", "浅暖柔和型")]
copy = _copy("色彩结论", "你属于典型的夏季型,适合冷调色彩。")
issues = check_conclusion_consistency(copy, dimensions)
assert any("季型" in issue for issue in issues)
# ---------------------------------------------------------------------------
# 一致性规则②:色值必须在色板集合内
# ---------------------------------------------------------------------------
def test_hex_color_outside_palette_is_flagged() -> None:
dimensions = [_dim("color_palette", "#A52A2A", evidence=("#F5F5DC",))]
inside = _copy("色彩方案", "主色可用 #A52A2A,辅色 #f5f5dc 提亮。")
outside = _copy("色彩方案", "主色建议 #AABBCC。")
assert check_conclusion_consistency(inside, dimensions) == []
issues = check_conclusion_consistency(outside, dimensions)
assert any("#AABBCC" in issue for issue in issues)
# ---------------------------------------------------------------------------
# 一致性规则③:建议句式必须有规则命中支撑
# ---------------------------------------------------------------------------
def test_advice_with_no_rule_hits_at_all_is_flagged() -> None:
dimensions = [_dim("body_shape", "X型", rule_hits=())]
copy = _copy("总体", "建议优先选择收腰版型,突出腰部线条。")
issues = check_conclusion_consistency(copy, dimensions)
assert any("没有任何命中" in issue for issue in issues)
def test_advice_on_dimension_without_support_is_flagged() -> None:
dimensions = [
_dim("body_shape", "X型", rule_hits=("R-BODY-0007",)),
_dim("color_season", "浅暖柔和型", rule_hits=()),
]
copy = _copy("总体", "关于季型,建议主色选择高饱和冷调。")
issues = check_conclusion_consistency(copy, dimensions)
assert any("季型" in issue and "没有规则命中支撑" in issue for issue in issues)
def test_advice_supported_by_rule_hits_passes() -> None:
dimensions = [_dim("body_shape", "X型", rule_hits=("R-BODY-0007",))]
rule_hits = [{"rule_key": "R-BODY-0007", "dimension": "body_shape", "suggestion": "收腰"}]
copy = _copy("总体", "关于体型,建议优先选择收腰版型,突出腰部线条。")
assert check_conclusion_consistency(copy, dimensions, rule_hits=rule_hits) == []
# ---------------------------------------------------------------------------
# 绝对化用语
# ---------------------------------------------------------------------------
def test_absolute_claims_are_flagged() -> None:
assert "最合适" in check_absolute_claims("这款收腰连衣裙最合适不过了")
assert "第一" in check_absolute_claims("你是本周第一位、也是第一优选用户")
assert "百分百" in check_absolute_claims("百分百提升气质")
def test_absolute_claims_tolerate_neutral_words_with_the_same_head() -> None:
# 「最」单字不拦:最近/最终是中性词,拦了会把正常文案打进重试
assert check_absolute_claims("根据最近一次分析,你的体型结论保持稳定。") == []
# ---------------------------------------------------------------------------
# 降级路径的纯函数级验证:脏文案违规 → 模板文案零违规
# ---------------------------------------------------------------------------
def test_validate_copy_aggregates_all_three_check_categories() -> None:
dimensions = [_dim("body_shape", "X型")]
dirty = _copy("小结", "整体偏微胖,收腰款最合适。", "你的体型属于 A 型。")
violations = validate_copy(dirty, dimensions)
assert any(v.startswith("贬损词:") for v in violations)
assert any(v.startswith("结论不一致:") for v in violations)
assert any(v.startswith("绝对化用语:") for v in violations)
def test_degraded_fallback_is_clean_when_llm_copy_stays_dirty() -> None:
"""重试后仍违规 → 降级模板:最终文案零违规(T-P3-11 黄金断言的纯函数版)。"""
dimensions = [
_dim(
"body_shape", "X型", evidence=("shoulder_hip_ratio:1.02",), rule_hits=("R-BODY-0007",)
),
_dim("face_shape", "鹅蛋", evidence=("face_ratio:1.35",)),
]
rule_hits = [
{
"rule_key": "R-BODY-0007",
"dimension": "body_shape",
"suggestion": "优先选择收腰版型突出腰线",
"explanation": "X 型体型肩腰臀比例均衡,收腰设计能强化这一优势",
}
]
dirty = _copy("小结", "整体偏微胖,收腰款最合适。")
assert validate_copy(dirty, dimensions, rule_hits=rule_hits) != []
fallback = render_fallback_copy(dimensions, rule_hits)
assert validate_copy(fallback, dimensions, rule_hits=rule_hits) == []
assert check_pejorative(fallback.full_text()) == []