"""出图方案规划器:DeepSeek 根据采集的商品信息生成套图方案。 方案每项 = 一类图(标题 + 说明 + 生图提示 + 张数 + 可选 SKU 绑定), 生成时按方案逐张出图;参考图可按 variant_name 精确绑定到对应 SKU 图。 """ from __future__ import annotations import json import logging import re 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. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。 2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。 3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。 4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数 8-15。 5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。 ## 规划规则 1. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。 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。 7. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。 ## 输出示例(紧凑单行) {"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}""" def _system_prompt_with_requirements(requirements: str | None) -> str: """把生图要求作为最高优先级约束注入 system prompt(置于规划规则之前)。 不仅声明优先级,还明确要求把要求落地到每个方案项的 prompt_hint, 避免模型只把要求当作背景信息而不影响输出。 """ if not (requirements and requirements.strip()): return SYSTEM_PROMPT marker = "\n## 输出硬性约束" idx = SYSTEM_PROMPT.find(marker) if idx < 0: return SYSTEM_PROMPT req = requirements.strip() block = ( "\n## 生图要求(最高优先级,硬性约束,覆盖下方所有规划规则与约束)\n" + req + "\n\n" + "规划方案时,必须把上述生图要求落地到每一项:\n" + "1. 每个方案项的 prompt_hint 必须融入上述要求的关键约束(如要求纯黑背景,则每个 prompt_hint 都要写明 black background);\n" + "2. title / detail 措辞不得与上述要求矛盾;\n" + "3. 任何规划规则与上述要求冲突时,一律以本生图要求为准。\n" ) return SYSTEM_PROMPT[:idx] + block + SYSTEM_PROMPT[idx:] 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 def _repair_truncated(s: str) -> dict | None: """截断修复:从最后一个完整的 '}' 处截断,剥尾逗号后按括号配平补全闭合。 适用于「items 数组中途被 max_tokens 截断」的场景——截断点在完整对象边界, 此前的字符串必然已闭合,简单计数配平即可。 """ for cut in (m.end() for m in reversed(list(re.finditer(r'\}', s)))): cand = s[:cut].rstrip().rstrip(',') opens: list[str] = [] for ch in cand: if ch in '{[': opens.append(ch) elif ch == '}' and opens and opens[-1] == '{': opens.pop() elif ch == ']' and opens and opens[-1] == '[': opens.pop() suffix = ''.join('}' if o == '{' else ']' for o in reversed(opens)) try: data = json.loads(cand + suffix) if isinstance(data, dict): return data except json.JSONDecodeError: continue return None def _extract_json(text: str) -> dict: """从模型输出提取 JSON:剥离思考块/markdown 围栏,截断时尝试修复。""" s = (text or '').strip() # 剥离思考块(思考型模型会把推理过程放进 ) s = re.sub(r'.*?', '', s, flags=re.S).strip() # 剥离 markdown 代码围栏 m = re.search(r'```(?:json)?\s*(.*?)```', s, flags=re.S) if m: s = m.group(1).strip() try: return json.loads(s) except json.JSONDecodeError: pass start = s.find('{') if start >= 0: repaired = _repair_truncated(s[start:]) if repaired is not None: log.warning("规划器输出疑似被截断,已自动截断修复(可能丢失末尾部分方案项)") return repaired raise ValueError("模型输出无法解析为 JSON") async def generate_plan( product_info: dict, sku_variants: list[str], image_stats: dict, platform: str, requirements: str | None = None, ) -> dict: """调用 DeepSeek 生成方案。返回 {summary, items}。 requirements:生图要求,最高优先级注入 system prompt,规划方案必须遵循。 """ s = get_settings() if not s.deepseek_api_key: raise RuntimeError("未配置 DEEPSEEK_API_KEY(.env)") user_payload: dict = { "商品信息": 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(决定图内文案语言) } # 生图要求同时在 user 侧强调(与 system prompt 双重约束),确保模型真正遵循 if requirements and requirements.strip(): user_payload["生图要求(最高优先级,必须体现在每个方案项中)"] = requirements.strip() user_content = json.dumps(user_payload, ensure_ascii=False) async with httpx.AsyncClient(timeout=90, 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_with_requirements(requirements)}, {"role": "user", "content": user_content}, ], "response_format": {"type": "json_object"}, "temperature": 0.3, "max_tokens": 8000, }, ) resp.raise_for_status() body = resp.json() message = body["choices"][0]["message"] finish_reason = body["choices"][0].get("finish_reason", "") usage = body.get("usage") or {} log.info( "规划器 token 用量: prompt=%s completion=%s finish=%s", usage.get("prompt_tokens", "?"), usage.get("completion_tokens", "?"), finish_reason, ) content = message.get("content") or "" # 思考型输出:content 为空时从 reasoning_content 里捞 if not content.strip() and message.get("reasoning_content"): content = message["reasoning_content"] try: data = _extract_json(content) except ValueError as exc: log.error( "规划器输出解析失败 finish_reason=%s content[:200]=%s", finish_reason, 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}