"""出图方案规划器:DeepSeek 根据采集的商品信息生成套图方案。 方案每项 = 一类图(标题 + 说明 + 生图提示 + 张数 + 可选 SKU 绑定), 生成时按方案逐张出图;参考图可按 variant_name 精确绑定到对应 SKU 图。 """ from __future__ import annotations import json import logging import httpx from config import get_settings log = logging.getLogger("suite.planner") # 规划器可选用的图类型(与 prompt.py 的 builder 对应) ALLOWED_KINDS = [ "white_bg", "key_features", "selling_pt", "material", "lifestyle", "multi_scene", "ecommerce_detail", "size_chart", "sku_collection", "custom", ] SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商详情页/主图套图的出图方案。 ## 规划规则 1. SKU 主图:商品有多个带图 SKU(颜色/款式)时,每个 SKU 出 1 张独立主图(kind=white_bg), 并在 variant_name 里填对应的 SKU 规格名(必须来自「SKU规格」列表,原样照抄); 单 SKU 商品出 1 张主图即可(variant_name 留空)。 2. 场景图(kind=lifestyle):按商品的核心使用场景出 2-4 张,每张聚焦一个场景,场景从描述/参数里提取。 3. 细节图(kind=material 或 custom):按商品的关键细节/材质/结构出 2-3 张,每张聚焦一个卖点细节。 4. 尺寸标注图(kind=size_chart):参数里有长宽高/尺寸数据时出 1 张。 5. SKU 合集图(kind=sku_collection):SKU 数量 >1 时出 1 张,同款多色整齐排列。 6. 可用 kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。其他创意图用 custom。 7. 总张数控制在 8-15 张;每项 count 为 1-3。 8. title 用中文短语(≤8字,如「主图·粉色」「浴室壁挂场景」);detail 用中文说明这张图要展示什么(≤40字); prompt_hint 用英文描述构图(角度/布局/光线要点,≤60 words),供生图模型使用。 ## 输出格式(严格 JSON,不要多余文字) { "summary": "整体思路一句话", "items": [ {"kind": "white_bg", "title": "主图·粉色", "detail": "粉色SKU白底主视觉", "prompt_hint": "front view on pure white background", "count": 1, "variant_name": "粉色"} ] }""" def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]: """清洗模型输出:kind 白名单、count 钳制、variant 必须真实存在。""" items: list[dict] = [] for it in raw_items: if not isinstance(it, dict): continue kind = str(it.get("kind") or "custom") if kind not in ALLOWED_KINDS: kind = "custom" title = str(it.get("title") or "").strip()[:20] if not title: continue try: count = max(0, min(3, int(it.get("count", 1)))) except (TypeError, ValueError): count = 1 variant = str(it.get("variant_name") or "").strip() or None if variant and variant not in sku_variants: variant = None # 幻觉规格:丢弃绑定,回退主图 items.append({ "kind": kind, "title": title, "detail": str(it.get("detail") or "").strip()[:80], "prompt_hint": str(it.get("prompt_hint") or "").strip()[:300], "count": count, "variant_name": variant, }) return items async def generate_plan( product_info: dict, sku_variants: list[str], image_stats: dict, platform: str, ) -> dict: """调用 DeepSeek 生成方案。返回 {summary, items}。""" s = get_settings() if not s.deepseek_api_key: raise RuntimeError("未配置 DEEPSEEK_API_KEY(.env)") user_content = json.dumps({ "商品信息": product_info, # {title, desc, params:[{key,value}], sellingPoints, price} "SKU规格": sku_variants, # 带图的 SKU 规格名(variant_name 只能从中选) "图片统计": image_stats, # {main: n, sku: n, detail: n} "目标平台": platform, # ozon/wb/cn(决定图内文案语言) }, ensure_ascii=False) async with httpx.AsyncClient(timeout=60, verify=False) as client: resp = await client.post( f"{s.deepseek_base_url.rstrip('/')}/chat/completions", headers={"Authorization": f"Bearer {s.deepseek_api_key}", "Content-Type": "application/json"}, json={ "model": s.deepseek_model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_content}, ], "response_format": {"type": "json_object"}, "temperature": 0.3, "max_tokens": 2000, }, ) resp.raise_for_status() content = resp.json()["choices"][0]["message"]["content"] try: data = json.loads(content) except json.JSONDecodeError as exc: log.error("规划器输出不是合法 JSON: %s", content[:200]) raise RuntimeError("规划器输出解析失败") from exc items = _normalize_items(data.get("items") or [], sku_variants) if not items: raise RuntimeError("规划器未返回有效方案项") # 总量保护:超过 18 张时按比例截断 total = sum(i["count"] for i in items) while total > 18 and items: last = items[-1] if last["count"] > 1: last["count"] -= 1 else: items.pop() total = sum(i["count"] for i in items) return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}