"""深度诊断(全面报告)文案的纯逻辑:十二季色板规则映射、结构化输出 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, )