Files
image-suite-studio/server/services/planner.py
T

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