feat: AI 生图、图片下载优化

This commit is contained in:
Joey
2026-08-29 17:13:06 +08:00
parent a77ec26a02
commit 4ee667142b
20 changed files with 683 additions and 248 deletions
+88 -60
View File
@@ -22,23 +22,39 @@ ALLOWED_KINDS = [
"size_chart", "sku_collection", "custom",
]
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
PLAN_SAFETY_MAX = 15
PLAN_REQUIREMENTS_MAX = 24
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-15。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,不设硬性数量,但建议 8 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。
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 用英文描述构图要点。
1. 先保证 SKU 主图(规则 2),其余类型(场景/细节/尺寸/合集等)按商品特点自行取舍并分配张数,追求信息效率,不为凑数出图。
2. SKU 主图:每个带图 SKU 出 1 张独立主图(kind=white_bg),variant_name 填对应规格名;单 SKU 出 1 张(variant_name=null)。
3. 场景图(kind=lifestyle):按核心使用场景出 1-3 张,每张聚焦一个场景。
4. 细节图(kind=material 或 custom):按关键细节/材质/结构出 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":"粉色"}]}"""
@@ -78,9 +94,8 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
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
# 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):
@@ -173,55 +188,68 @@ async def generate_plan(
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"]
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:
log.error(
"规划器输出解析失败 finish_reason=%s content[:200]=%s",
finish_reason, content[:200],
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:
# 总量保护:只防跑飞不干预正常规划。生图要求指定「N 张」时按 N 放宽上限
budget = _total_budget(requirements)
total = sum(i["count"] for i in items)
while total > budget 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}
last_error = RuntimeError("规划器未返回有效方案项")
log.warning(
"规划器第 %d 次返回无有效方案项 finish_reason=%s content[:200]=%s",
attempt + 1, 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}
raise last_error or RuntimeError("规划器未返回有效方案项")