Files

459 lines
19 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.
"""深度诊断(全面报告)文案的纯逻辑:十二季色板规则映射、结构化输出 schema 与降级模板。
与 basic 报告共用同一条「LLM 不可信任」纪律,分工如下:
- **色板不出 LLM**:季型 → 色板的映射是本模块的预定义常量(规则引擎口径,
与 kb_color 同源的确定性数据),LLM 只写季型解析文案。色值交给模型编
是幻觉高发区,校验器拦得住但每次都多花一次重试——结构上不让它碰更省。
- **LLM 产出四块结构化内容**:总评 summary + 正文章节(与 basic 同构,保证
ReportResultPage / PDF 导出的现有渲染零改动)、季型解析、4 场景穿搭、
30 天计划。
- **降级模板**:持续违规时按规则直出拼全量内容(含场景与计划的兜底版本),
对齐 validation_stage 的「任何出口要么 passed 要么抛错」。
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
from pydantic import BaseModel
from packages.report.copy import CopySection, ReportCopy, dimension_label
# ── 十二季色板规则映射(常量,色值不交给 LLM) ─────────────────────────────
#: 十二季自研描述性命名(TD-18:对外不得使用「四季色彩 / CMB」商标名)。
TWELVE_SEASONS: tuple[str, ...] = (
"浅春",
"暖春",
"亮春",
"浅夏",
"柔夏",
"冷夏",
"柔秋",
"暖秋",
"深秋",
"亮冬",
"冷冬",
"深冬",
)
#: 四季粗判(season_code)→ 十二季默认细分:视觉缺 season_detailed 时的兜底,
#: 取该季最中位的细分型,宁保守不冒进。
SEASON_CODE_FALLBACK: Mapping[str, str] = {
"spring": "暖春",
"summer": "柔夏",
"autumn": "暖秋",
"winter": "冷冬",
}
#: 中性色按冷暖倾向分两档,各季直接引用(白/黑/灰/米/驼/卡其口径,需求 §2.3 篇章二)。
_NEUTRAL_WARM: tuple[tuple[str, str], ...] = (
("米白", "#F5EFE0"),
("象牙白", "#F8F0DC"),
("燕麦色", "#D9C9A8"),
("浅驼", "#C8A97E"),
("卡其", "#B89B6A"),
("暖灰", "#A89F8D"),
("深棕", "#5C4633"),
)
_NEUTRAL_COOL: tuple[tuple[str, str], ...] = (
("纯白", "#FFFFFF"),
("浅灰", "#D9D9D9"),
("中灰", "#9AA0A6"),
("炭灰", "#4A4A4A"),
("藏蓝", "#1F2A4A"),
("灰米", "#CFC9BD"),
("蓝黑", "#16213A"),
)
#: 季型 → (最佳色≥12, 避雷色≥6),中性色按季型冷暖引用上面两档。
#: 色值为内测期规则引擎录入口径,后续统一迁至 kb_color 后此处仅作兜底。
_SEASON_COLORS: Mapping[str, tuple[tuple[tuple[str, str], ...], tuple[tuple[str, str], ...]]] = {
"浅春": (
(
("蜜桃粉", "#FFC8B4"), ("浅杏色", "#FFE0C7"), ("薄荷绿", "#B8E6C8"),
("浅水蓝", "#AFDDE9"), ("鹅黄", "#FFE9A8"), ("浅珊瑚", "#FFB59E"),
("嫩草绿", "#CFE8A9"), ("浅金棕", "#E8C79A"), ("樱花粉", "#FFD1DC"),
("浅松石", "#A3E0D4"), ("淡奶油", "#FFF3D6"), ("浅橙", "#FFCF9E"),
),
(
("纯黑", "#1A1A1A"), ("炭灰", "#3A3A3A"), ("藏青", "#14213D"),
("酒红", "#6E1423"), ("深紫", "#3B1F47"), ("墨绿", "#1F3D2B"),
),
),
"暖春": (
(
("珊瑚橙", "#FF8C69"), ("番茄红", "#E8553D"), ("金盏菊黄", "#FFB92E"),
("橄榄绿", "#7A8B3D"), ("暖松石", "#2EB8A6"), ("焦糖", "#B9713B"),
("杏色", "#F2B880"), ("苹果绿", "#8FC45A"), ("暖粉", "#F49AC1"),
("蜂蜜金", "#D9A441"), ("铜色", "#B0653A"), ("暖米白", "#F7E7CE"),
),
(
("冷灰蓝", "#708090"), ("粉紫", "#C8A2C8"), ("冰粉", "#F2D7E0"),
("银灰", "#C0C0C0"), ("藏蓝", "#1B2A4A"), ("冷白", "#F4F8FB"),
),
),
"亮春": (
(
("明黄", "#FFD93B"), ("亮橙", "#FF7A1A"), ("西瓜红", "#FF4D5E"),
("翠绿", "#1FB573"), ("湖蓝", "#1FA8C9"), ("亮松石", "#14C4B2"),
("亮粉", "#FF5E9C"), ("紫罗兰", "#7A4FD0"), ("柠檬黄", "#F5E642"),
("亮苹果绿", "#7AC74F"), ("朱红", "#E63946"), ("宝蓝", "#2B5FCE"),
),
(
("暗驼", "#A68A64"), ("灰绿", "#8A9A5B"), ("暗玫瑰", "#A76A73"),
("深棕", "#4A3525"), ("灰蓝", "#6B7B8C"), ("暗紫", "#5A4A6B"),
),
),
"浅夏": (
(
("雾粉", "#F3D5DC"), ("浅薰衣草", "#D8CBE8"), ("粉蓝", "#BFD7EA"),
("浅玫瑰", "#E8B4C4"), ("珍珠白", "#F2F0EB"), ("浅灰绿", "#C9D8C5"),
("淡紫", "#CDBFE0"), ("浅青", "#BFE0E0"), ("雾蓝", "#C3D2E0"),
("浅桃粉", "#F6D2D8"), ("灰粉", "#E3C6CE"), ("浅蓝紫", "#BFC6E8"),
),
(
("亮橙", "#FF7A1A"), ("明黄", "#FFD93B"), ("番茄红", "#E8553D"),
("翠绿", "#1FB573"), ("焦糖", "#B9713B"), ("纯黑", "#1A1A1A"),
),
),
"柔夏": (
(
("雾霾蓝", "#8FA8BF"), ("豆沙粉", "#C4909A"), ("灰玫瑰", "#B08E96"),
("鼠尾草绿", "#9CAF88"), ("灰紫", "#9B8AA5"), ("燕麦", "#C7BBA8"),
("灰松石", "#7FA8A0"), ("烟粉", "#CFA9B2"), ("蓝灰", "#7C93A8"),
("藕荷", "#BFA4BC"), ("灰卡其", "#A8A18C"), ("深灰玫瑰", "#96757F"),
),
(
("明黄", "#FFD93B"), ("亮橙", "#FF7A1A"), ("西瓜红", "#FF4D5E"),
("翠绿", "#1FB573"), ("纯黑", "#1A1A1A"), ("亮粉", "#FF5E9C"),
),
),
"冷夏": (
(
("玫瑰粉", "#D46A92"), ("蓝调正红", "#C23B4E"), ("覆盆子", "#A62651"),
("天蓝", "#5B9BD5"), ("皇家蓝", "#3B5FC0"), ("冷薄荷", "#9FD8D8"),
("薰衣草", "#A78BC9"), ("松绿", "#2E7D6B"), ("玉兰白", "#F6F4F0"),
("冷粉", "#E08AAF"), ("钢蓝", "#4F7396"), ("深玫瑰", "#8E3A5B"),
),
(
("珊瑚橙", "#FF8C69"), ("金盏菊黄", "#FFB92E"), ("焦糖", "#B9713B"),
("橄榄绿", "#7A8B3D"), ("番茄红", "#E8553D"), ("暖驼", "#C19A6B"),
),
),
"柔秋": (
(
("陶土", "#B4714F"), ("橄榄", "#8A8B4D"), ("芥末黄", "#C9A227"),
("砖红", "#A65B43"), ("灰绿", "#8FA382"), ("驼棕", "#A98A63"),
("暗松石", "#5F8B7E"), ("南瓜", "#C07840"), ("栗棕", "#7C5540"),
("暗玫瑰", "#A97E7E"), ("麦色", "#CBB37A"), ("苔绿", "#75804F"),
),
(
("冰粉", "#F2D7E0"), ("亮蓝", "#2B5FCE"), ("纯黑", "#1A1A1A"),
("亮绿", "#35D07F"), ("冰紫", "#D8CBE8"), ("亮粉", "#FF5E9C"),
),
),
"暖秋": (
(
("铁锈红", "#A6472E"), ("南瓜橙", "#C77B2E"), ("深橄榄", "#6B7038"),
("芥末", "#B9932B"), ("深驼", "#8F6B45"), ("番茄棕", "#9C4A32"),
("森林绿", "#3E5E3E"), ("蜂蜜", "#C99A3C"), ("赤陶", "#B2593D"),
("暖棕", "#7A5236"), ("暗金", "#A8842F"), ("深松石", "#3F7A6B"),
),
(
("冰蓝", "#BFE3F0"), ("冷粉", "#E08AAF"), ("银灰", "#C0C0C0"),
("薰衣草", "#A78BC9"), ("冰白", "#F4F8FB"), ("紫罗兰", "#7A4FD0"),
),
),
"深秋": (
(
("深棕", "#4A3525"), ("黑橄榄", "#4A4A26"), ("深铁锈", "#7E3B26"),
("墨绿", "#274435"), ("深驼", "#6B4E30"), ("深橙棕", "#8A4A24"),
("深酒红", "#5E2432"), ("深松绿", "#2E4A3B"), ("焦糖棕", "#7A4A28"),
("深芥末", "#8A7220"), ("深铜", "#6E452A"), ("黑棕", "#33261C"),
),
(
("冰粉", "#F2D7E0"), ("浅蓝", "#BFD7EA"), ("浅薰衣草", "#D8CBE8"),
("珍珠白", "#F2F0EB"), ("浅灰绿", "#C9D8C5"), ("浅桃粉", "#F6D2D8"),
),
),
"亮冬": (
(
("洋红", "#D01A7A"), ("宝蓝", "#2040C8"), ("翠绿", "#0E9F6E"),
("正红", "#DE1A3A"), ("亮松石", "#0FA8B8"), ("柠檬黄", "#F2E13C"),
("紫红", "#A8126B"), ("亮蓝", "#2B7DE0"), ("纯黑", "#1A1A1A"),
("纯白", "#FFFFFF"), ("银灰", "#C0C0C0"), ("冰粉", "#F2A7C8"),
),
(
("驼色", "#C19A6B"), ("芥末黄", "#C9A227"), ("橄榄绿", "#7A8B3D"),
("陶土", "#B4714F"), ("南瓜橙", "#C77B2E"), ("暖棕", "#7A5236"),
),
),
"冷冬": (
(
("纯黑", "#1A1A1A"), ("纯白", "#FFFFFF"), ("正红", "#C8102E"),
("宝石蓝", "#1D4ED8"), ("深紫红", "#7A1245"), ("冰蓝", "#A8D8F0"),
("冷粉", "#D86AA8"), ("翡翠绿", "#0E7A5F"), ("藏蓝", "#14264A"),
("银灰", "#C0C0C0"), ("紫罗兰", "#6D28A8"), ("樱桃红", "#A8123B"),
),
(
("珊瑚橙", "#FF8C69"), ("金盏菊黄", "#FFB92E"), ("驼色", "#C19A6B"),
("焦糖", "#B9713B"), ("橄榄绿", "#7A8B3D"), ("暖杏", "#F2B880"),
),
),
"深冬": (
(
("黑棕", "#2B201A"), ("深酒红", "#571C2B"), ("深宝石蓝", "#1A2E6E"),
("深紫", "#3F1D5E"), ("墨绿", "#1E3A2E"), ("炭黑", "#141414"),
("深樱桃", "#6E1230"), ("深松绿", "#1F4438"), ("深灰", "#2E2E2E"),
("深钴蓝", "#16295C"), ("深紫红", "#4A0E33"), ("深橄榄黑", "#2E2E1A"),
),
(
("浅杏色", "#FFE0C7"), ("鹅黄", "#FFE9A8"), ("蜜桃粉", "#FFC8B4"),
("浅珊瑚", "#FFB59E"), ("薄荷绿", "#B8E6C8"), ("浅金棕", "#E8C79A"),
),
),
}
#: 暖调季型集合(决定中性色引用哪一档)。
_WARM_SEASONS: frozenset[str] = frozenset(
{"浅春", "暖春", "亮春", "柔秋", "暖秋", "深秋"}
)
def resolve_season(derived_features: Mapping[str, Any]) -> str | None:
"""派生特征 → 十二季细分名;无细分值时按四季粗判取默认细分(见 SEASON_CODE_FALLBACK)。"""
detailed = derived_features.get("season_detailed")
if isinstance(detailed, str) and detailed in TWELVE_SEASONS:
return detailed
code = derived_features.get("season_code")
if isinstance(code, str):
return SEASON_CODE_FALLBACK.get(code)
return None
def palette_for(season_detailed: str) -> dict[str, list[dict[str, str]]]:
"""季型 → 结构化色板(best/neutral/avoid,每色 name+hex)。未收录季型给空板。"""
if season_detailed not in _SEASON_COLORS:
return {"best": [], "neutral": [], "avoid": []}
best, avoid = _SEASON_COLORS[season_detailed]
neutral = _NEUTRAL_WARM if season_detailed in _WARM_SEASONS else _NEUTRAL_COOL
def entries(pairs: Sequence[tuple[str, str]]) -> list[dict[str, str]]:
return [{"name": name, "hex": hex_value} for name, hex_value in pairs]
return {"best": entries(best), "neutral": entries(neutral), "avoid": entries(avoid)}
# ── LLM 结构化输出 schema ──────────────────────────────────────────────────
#: 场景固定四个(需求 §2.3 篇章六:通勤职场/休闲日常/约会聚会/正式场合)。
SCENE_DEFINITIONS: tuple[tuple[str, str], ...] = (
("commute", "日常通勤"),
("weekend", "休闲周末"),
("formal", "正式场合"),
("date", "约会聚会"),
)
class FullOutfit(BaseModel):
"""一套搭配:名称 + 要点列表(上装/下装/鞋/配饰、配色、材质都在要点里)。"""
name: str
points: list[str]
class FullScene(BaseModel):
"""一个场景的穿搭方案包:2~3 套搭配。"""
key: str
title: str
outfits: list[FullOutfit]
class PlanWeek(BaseModel):
"""30 天计划的一周:3~5 条可执行任务。"""
week: int
title: str
tasks: list[str]
class FullReportCopy(BaseModel):
"""FULL_REPORT_COPY 场景的结构化输出。
summary/sections 与 basic 的 ReportCopy 同构——ReportResultPage 与 PDF
导出按 result.copy 渲染的部分对两种 tier 零改动;深度诊断的增量内容全在
后三个字段,落库时进 result.full(色板由规则映射补齐,不经 LLM)。
"""
summary: str
sections: list[CopySection]
season_analysis: str
scenes: list[FullScene]
plan30: list[PlanWeek]
def as_report_copy(self) -> ReportCopy:
"""折成 ReportCopy 视图供生成后校验:全部自由文本都进 full_text 扫描范围。"""
sections = list(self.sections)
sections.append(CopySection(title="季型解析", paragraphs=[self.season_analysis]))
for scene in self.scenes:
paragraphs = [scene.title]
for outfit in scene.outfits:
paragraphs.append(outfit.name)
paragraphs.extend(outfit.points)
sections.append(CopySection(title=scene.title, paragraphs=paragraphs))
for week in self.plan30:
sections.append(CopySection(title=week.title, paragraphs=list(week.tasks)))
return ReportCopy(summary=self.summary, sections=sections)
def build_full_result(
copy: FullReportCopy,
*,
season_detailed: str | None,
season_code: str | None,
palette: Mapping[str, list[dict[str, str]]] | None,
) -> dict[str, Any]:
"""组装 result["full"]:LLM 内容 + 规则色板(App 按此结构渲染三个新区块)。"""
season: dict[str, Any] = {
"code": season_code,
"detailed": season_detailed,
"name_cn": f"{season_detailed}型" if season_detailed else None,
"analysis": copy.season_analysis,
}
return {
"season": season,
"palette": dict(palette) if palette else {"best": [], "neutral": [], "avoid": []},
"scenes": [scene.model_dump() for scene in copy.scenes],
"plan30": [week.model_dump() for week in copy.plan30],
}
# ── 降级模板(持续违规时的零 LLM 出口) ────────────────────────────────────
_FALLBACK_FULL_SUMMARY = (
"以下为深度诊断内容,根据本次分析的结构化结论整理,"
"每条结论都来自规则引擎对诊断值的直接匹配。"
)
#: 各场景的兜底搭配要点模板:{best}=最佳色名前三个、{neutral}=中性色名前两个。
#: 措辞刻意避开「建议/推荐」句式(校验规则③要求建议句有规则命中支撑,
#: 降级模板没有命中链,干脆不用建议句式——受控拼接不做超出数据的断言)。
_FALLBACK_SCENE_POINTS: Mapping[str, tuple[str, ...]] = {
"commute": (
"上装选{best}之一的衬衫或针织衫,下装配{neutral}直筒裤",
"整体以{neutral}为基调,{best}之一用于内搭提亮",
"鞋包选{neutral},保持通勤场合的利落感",
),
"weekend": (
"上装选{best}之一的卫衣或休闲衬衫,下装配{neutral}休闲裤",
"可用{best}之一的单品做主色,其余用{neutral}压住",
"鞋选{neutral}休闲鞋,包袋同色系呼应",
),
"formal": (
"外套选{neutral}的西装或大衣,内搭{best}之一的衬衫",
"下装与外套同色系,形成纵向线条",
"配饰控制在{neutral}范围内,点缀可用{best}之一",
),
"date": (
"上装选{best}之一的针织衫或连衣裙,突出柔和气色",
"下装配{neutral}半身裙或长裤,鞋选同色系",
"配饰以小巧精致为先,颜色落在{best}范围内",
),
}
_FALLBACK_PLAN: tuple[tuple[str, tuple[str, ...]], ...] = (
("第 1 周:整理衣柜", (
"把衣柜里的单品按适合色、中性色、避雷色分成三堆",
"挑 2 件中性色基础款作为本周固定搭配底版",
"记录本周每天穿的配色,对照色板找偏差",
)),
("第 2 周:引入适合色", (
"用适合色中的一件上衣替换惯常穿的避雷色上衣",
"尝试「中性色为主、适合色点缀」的配色方式 3 次",
"对照镜子确认提亮效果,记下表现出色的一件",
)),
("第 3 周:场景演练", (
"按报告里的通勤方案完整穿一次,记录感受",
"按报告里的休闲方案穿一次周末出行搭配",
"从配饰入手,为一套固定搭配加一件色板内的配饰",
)),
("第 4 周:固化习惯", (
"把前 3 周效果好的 3 套搭配拍照存档",
"补齐衣柜里缺位的中性色基础款清单",
"对照报告复盘整月变化,圈出下个月想尝试的新色",
)),
)
def _fallback_outfit_points(key: str, palette: Mapping[str, list[dict[str, str]]]) -> list[str]:
best = "、".join(c["name"] for c in palette.get("best", [])[:3]) or "适合色"
neutral = "、".join(c["name"] for c in palette.get("neutral", [])[:2]) or "中性色"
return [
point.format(best=best, neutral=neutral)
for point in _FALLBACK_SCENE_POINTS.get(key, ())
]
def render_fallback_full_copy(
dimensions: Sequence[Any],
*,
season_detailed: str | None,
palette: Mapping[str, list[dict[str, str]]] | None,
) -> FullReportCopy:
"""深度诊断降级模板:全量内容规则直出(含场景与 30 天计划),零 LLM。
措辞纪律与 render_fallback_copy 相同——只做受控拼接、逐字复述诊断值,
天然通过三重校验;FullValidationStage 仍会再校验一遍做 fail-close 自证。
"""
ok_dims = [d for d in dimensions if isinstance(d, Mapping) and d.get("status") == "ok"]
board = dict(palette) if palette else {"best": [], "neutral": [], "avoid": []}
conclusion_paragraphs = []
for dim in ok_dims:
value = dim.get("value") or ""
if isinstance(value, (list, dict)):
continue # 复合值不复述进段落(色板等结构化值由 full.palette 呈现)
conclusion_paragraphs.append(f"你的{dimension_label(dim)}结论为「{value}」。")
sections: list[CopySection] = []
if conclusion_paragraphs:
sections.append(CopySection(title="诊断结论", paragraphs=conclusion_paragraphs))
else:
sections.append(CopySection(title="说明", paragraphs=["本次分析暂无可用结论。"]))
if season_detailed:
season_analysis = (
f"你的十二季色彩定位为「{season_detailed}型」。"
f"适合色板共 {len(board.get('best', []))} 色,"
f"中性色 {len(board.get('neutral', []))} 色,"
f"避雷色 {len(board.get('avoid', []))} 色,完整色板见下方色卡区。"
)
else:
season_analysis = "本次分析未产出十二季定位,色板区暂无内容。"
scenes = [
FullScene(
key=key,
title=title,
outfits=[
FullOutfit(name=f"{title} · 色板方案", points=_fallback_outfit_points(key, board))
],
)
for key, title in SCENE_DEFINITIONS
]
plan30 = [
PlanWeek(week=index + 1, title=title, tasks=list(tasks))
for index, (title, tasks) in enumerate(_FALLBACK_PLAN)
]
return FullReportCopy(
summary=_FALLBACK_FULL_SUMMARY,
sections=sections,
season_analysis=season_analysis,
scenes=scenes,
plan30=plan30,
)