Files

395 lines
18 KiB
Python
Raw Permalink 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 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",
]
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
PLAN_SAFETY_MAX = 15
PLAN_REQUIREMENTS_MAX = 24
MAX_VARIANT_MAIN_IMAGES = 2
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
# 因此均视作同一类覆盖,而不是强迫模型只使用 material。
SCENE_KINDS = {"lifestyle", "multi_scene"}
DETAIL_KINDS = {"material", "key_features", "selling_pt", "ecommerce_detail"}
_DIMENSION_PATTERN = re.compile(
r"尺寸|长宽高|直径|厚度|(?:长|宽|高)\s*[::×x*]?\s*\d+|\d+(?:\.\d+)?\s*(?:厘米|毫米|cm|mm|inch|in)\b",
re.IGNORECASE,
)
def _total_budget(requirements: str | None) -> int:
"""从生图要求提取「N 张」作为总张数预算;未指定时用默认安全上限。"""
if requirements:
m = re.search(r"(\d+)\s*张", requirements)
if m:
return min(max(int(m.group(1)), 1), PLAN_REQUIREMENTS_MAX)
return PLAN_SAFETY_MAX
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 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时必须出 1 张;无可靠尺寸参数时不要虚构尺寸。
6. SKU 合集图(kind=sku_collection):仅在 SKU >1 且确有必要展示差异时出 1 张,优先级低于上述四类。
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
8. 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 缺失不再丢弃条目:从 detail / kind 兜底合成,避免整份方案被判空
title = str(it.get("title") or "").strip()[:20] or str(it.get("detail") or "").strip()[:12] or kind
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 _has_dimensions(product_info: dict) -> bool:
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。"""
for param in product_info.get("params") or []:
if not isinstance(param, dict):
continue
text = f"{param.get('key') or ''} {param.get('value') or ''}"
if _DIMENSION_PATTERN.search(text):
return True
return False
def _requires_every_sku(requirements: str | None) -> bool:
"""用户显式要求逐 SKU 出图时,保留其优先级,不施加代表款上限。"""
if not requirements:
return False
return bool(re.search(
r"(?:每个|全部|所有|逐个|分别).{0,10}(?:sku|SKU|规格|款式|颜色|变体)",
requirements,
))
def _coverage_group(kind: str) -> str:
if kind == "white_bg":
return "main"
if kind in SCENE_KINDS:
return "scene"
if kind in DETAIL_KINDS:
return "detail"
if kind == "size_chart":
return "size"
return "other"
def _total(items: list[dict]) -> int:
return sum(item["count"] for item in items)
def _group_total(items: list[dict], group: str) -> int:
return sum(item["count"] for item in items if _coverage_group(item["kind"]) == group)
def _remove_one(items: list[dict], predicate) -> bool:
"""从后往前删除一个生成槽位,优先保留模型较早规划的构图。"""
for index in range(len(items) - 1, -1, -1):
if not predicate(items[index]):
continue
if items[index]["count"] > 1:
items[index]["count"] -= 1
else:
items.pop(index)
return True
return False
def _make_room(items: list[dict], budget: int, minimums: dict[str, int]) -> bool:
"""为必需图类腾出一张,先去除非核心图或某类的冗余张数。"""
candidates: list[tuple[int, int]] = []
for index, item in enumerate(items):
group = _coverage_group(item["kind"])
if _group_total(items, group) <= minimums.get(group, 0):
continue
# other 最低优先;同类冗余时从后面的项开始删。
priority = 0 if group == "other" else 1
candidates.append((priority, -index))
if not candidates:
return False
_, neg_index = min(candidates)
index = -neg_index
item = items[index]
if item["count"] > 1:
item["count"] -= 1
else:
items.pop(index)
return True
def _balanced_items(
items: list[dict],
sku_variants: list[str],
product_info: dict,
requirements: str | None,
budget: int,
) -> list[dict]:
"""将 LLM 方案约束为信息均衡的商品套图。
提示词能改善规划倾向,但不能保证每次输出。这里把 SKU 主图总量限制为
代表款数量,并在预算允许时补齐核心图类,避免 SKU 多时挤掉场景/细节/尺寸图。
"""
balanced = [dict(item) for item in items if item["count"] > 0]
if not _requires_every_sku(requirements):
max_mains = 1 if len(sku_variants) <= 1 else MAX_VARIANT_MAIN_IMAGES
while sum(item["count"] for item in balanced if item["kind"] == "white_bg") > max_mains:
_remove_one(balanced, lambda item: item["kind"] == "white_bg")
required: list[tuple[str, dict]] = [
("main", {
"kind": "white_bg", "title": "白底主图", "detail": "商品白底主视觉",
"prompt_hint": "front view on pure white background", "count": 1,
"variant_name": sku_variants[0] if sku_variants else None,
}),
("scene", {
"kind": "lifestyle", "title": "使用场景", "detail": "展示商品实际使用场景",
"prompt_hint": "product in a natural everyday use setting", "count": 1,
"variant_name": None,
}),
("detail", {
"kind": "material", "title": "细节展示", "detail": "突出材质与结构细节",
"prompt_hint": "close-up of authentic material and construction details", "count": 1,
"variant_name": None,
}),
]
if _has_dimensions(product_info):
required.append(("size", {
"kind": "size_chart", "title": "尺寸标注", "detail": "展示已采集的商品尺寸",
"prompt_hint": "clear size chart using known product dimensions", "count": 1,
"variant_name": None,
}))
# 用户明确要求较小张数时,优先保留前面的核心图类;不突破其指定预算。
required = required[:budget]
minimums = {group: 1 for group, _ in required}
for group, fallback in required:
if _group_total(balanced, group) > 0:
continue
while _total(balanced) >= budget and _make_room(balanced, budget, minimums):
pass
if _total(balanced) < budget:
balanced.append(fallback)
while _total(balanced) > budget and _make_room(balanced, budget, minimums):
pass
return balanced
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()
# 剥离思考块(思考型模型会把推理过程放进 <think>)
s = re.sub(r'<think>.*?</think>', '', 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)
last_error: RuntimeError | ValueError | None = None
for attempt in range(2): # 解析失败/空方案自动重试一次
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,
# deepseek-v4 是思维链模型:不禁用 thinking 会把 max_tokens 烧在推理上,
# 正文为空 → 只能拿 reasoning_content 兜底解析 → 频发「解析失败/未返回有效方案项」
"thinking": {"type": "disabled"},
},
)
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:
last_error = exc
log.error(
"规划器第 %d 次输出解析失败 finish_reason=%s content[:200]=%s",
attempt + 1, finish_reason, content[:200],
)
continue
items = _normalize_items(data.get("items") or [], sku_variants)
if items:
# 先压缩多 SKU 的重复主图、补齐核心视觉类别,再执行总量保护。
# 生图要求指定「N 张」时按 N 放宽上限,并始终不突破该预算。
budget = _total_budget(requirements)
items = _balanced_items(items, sku_variants, product_info, requirements, budget)
return {"summary": str(data.get("summary") or "").strip()[:100], "items": items}
last_error = RuntimeError("规划器未返回有效方案项")
log.warning(
"规划器第 %d 次返回无有效方案项 finish_reason=%s content[:200]=%s",
attempt + 1, finish_reason, content[:200],
)
raise last_error or RuntimeError("规划器未返回有效方案项")