135 lines
5.7 KiB
Python
135 lines
5.7 KiB
Python
"""出图方案规划器: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}
|