feat: 插件开发 ozon 端主体完成
This commit is contained in:
+86
-26
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -21,29 +22,26 @@ ALLOWED_KINDS = [
|
||||
"size_chart", "sku_collection", "custom",
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规划一套电商详情页/主图套图的出图方案。
|
||||
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(颜色/款式)时,每个 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),供生图模型使用。
|
||||
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 用英文描述构图要点。
|
||||
|
||||
## 输出格式(严格 JSON,不要多余文字)
|
||||
{
|
||||
"summary": "整体思路一句话",
|
||||
"items": [
|
||||
{"kind": "white_bg", "title": "主图·粉色", "detail": "粉色SKU白底主视觉", "prompt_hint": "front view on pure white background", "count": 1, "variant_name": "粉色"}
|
||||
]
|
||||
}"""
|
||||
## 输出示例(紧凑单行)
|
||||
{"summary":"三色收纳盒全套图","items":[{"kind":"white_bg","title":"主图·粉色","detail":"粉色SKU白底主视觉","prompt_hint":"front view on white background","count":1,"variant_name":"粉色"}]}"""
|
||||
|
||||
|
||||
def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
||||
@@ -76,6 +74,54 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
|
||||
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()
|
||||
# 剥离思考块(思考型模型会把推理过程放进 <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],
|
||||
@@ -94,7 +140,7 @@ async def generate_plan(
|
||||
"目标平台": platform, # ozon/wb/cn(决定图内文案语言)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
async with httpx.AsyncClient(timeout=60, verify=False) as client:
|
||||
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"},
|
||||
@@ -106,16 +152,30 @@ async def generate_plan(
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2000,
|
||||
"max_tokens": 8000,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
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 = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
log.error("规划器输出不是合法 JSON: %s", content[:200])
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user