feat: 插件开发 ozon 端主体完成

This commit is contained in:
Joey
2026-08-16 17:32:43 +08:00
parent 1591d5e35a
commit b57933e983
32 changed files with 1960 additions and 512 deletions
+30 -10
View File
@@ -23,6 +23,18 @@ from services.prompt import build_prompt, build_context, type_name
log = logging.getLogger("suite.generator")
def _raise_api_error(resp, provider: str):
"""HTTP 错误时抛出带 API 错误码/信息的异常(响应体里有真正的失败原因)。"""
if resp.is_success:
return
try:
body = resp.json()
detail = f"{body.get('code', '')}: {body.get('message', '')}".strip(': ')
except Exception: # noqa: BLE001
detail = resp.text[:200]
raise RuntimeError(f"{provider} API HTTP {resp.status_code}{detail or '无错误详情'}")
# 参考图选择:material 用第 2 张(背面/细节),其余用第 1 张(正面)
TYPE_REF_INDEX = {
"material": 1,
@@ -81,12 +93,12 @@ async def _resolve_ref(url: str) -> str:
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048") -> bytes:
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
s = get_settings()
if not s.ark_api_key:
raise RuntimeError("未配置 ARK_API_KEY.env")
body = {
"model": s.ark_image_model,
"model": model or s.ark_image_model,
"prompt": prompt.rstrip(". ") + ". " + _DOUBAO_ANTI_AI,
"size": size,
"response_format": "url",
@@ -101,7 +113,7 @@ async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x
headers={"Authorization": f"Bearer {s.ark_api_key}", "Content-Type": "application/json"},
json=body,
)
resp.raise_for_status()
_raise_api_error(resp, "豆包")
img_url = resp.json()["data"][0]["url"]
dl = await client.get(img_url, timeout=s.request_timeout)
dl.raise_for_status()
@@ -140,11 +152,12 @@ async def _tongyi_poll_task(client: httpx.AsyncClient, key: str, task_id: str, m
raise TimeoutError(f"通义异步任务超时 ({max_wait}s): task_id={task_id}")
async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*2048") -> bytes:
async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*2048", model: str | None = None) -> bytes:
s = get_settings()
if not s.dashscope_api_key:
raise RuntimeError("未配置 DASHSCOPE_API_KEY.env")
is_wan = _is_wan_model(s.dashscope_model)
model = model or s.dashscope_model
is_wan = _is_wan_model(model)
url = s.dashscope_base_url or (
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation"
if is_wan
@@ -163,11 +176,11 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
if is_wan:
headers["X-DashScope-Async"] = "enable"
body = {"model": s.dashscope_model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params}
body = {"model": model, "input": {"messages": [{"role": "user", "content": content}]}, "parameters": params}
async with httpx.AsyncClient(timeout=s.request_timeout, verify=False) as client:
resp = await client.post(url, headers=headers, json=body)
resp.raise_for_status()
_raise_api_error(resp, "通义")
data = resp.json()
if is_wan:
task_id = data.get("output", {}).get("task_id", "")
@@ -267,7 +280,11 @@ async def run_suite(suite_id: str) -> None:
raw = suite.context if not product else (product.raw or {})
ctx = build_context(raw or {}, fallback_name=product.name if product else "")
size = _image_size(provider_name, suite.ratio, is_wan=_is_wan_model(settings.dashscope_model))
model = suite.model or (
settings.dashscope_model if provider_name == "tongyi" else settings.ark_image_model
)
is_wan = provider_name == "tongyi" and _is_wan_model(model)
size = _image_size(provider_name, suite.ratio, is_wan=is_wan)
# 任务列表:方案(逐张)优先,旧路径按 types
if suite.plan:
@@ -290,12 +307,15 @@ async def run_suite(suite_id: str) -> None:
db.add(image_row)
await db.flush()
try:
prompt = build_prompt(type_id, ctx, suite.style_set, suite.lang, extra=job)
prompt = build_prompt(
type_id, ctx, suite.style_set, suite.lang,
extra=job, style_prompt=suite.style_prompt,
)
if product:
refs = await _select_ref_images(db, product.id, type_id)
else:
refs = _refs_for_job(list(suite.ref_images or []), job)
data = await generator(prompt, refs, size=size)
data = await generator(prompt, refs, size=size, model=model)
key = storage.write_bytes(data, key_prefix=f"suites/{suite.id}", ext=".jpg")
image_row.stored_url = storage.public_url(key)
image_row.status = STATUS_OK
+86 -26
View File
@@ -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)
+47 -25
View File
@@ -12,38 +12,53 @@ from __future__ import annotations
import re
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应)─────────────────────────────
# ── 风格模板(与插件端 STYLE_SET_OPTIONS 对应;提示词可被用户在插件里改写覆盖)───
# 提示词用中文:生图 provider(通义万相/豆包)均为国产模型,中文理解一流,且便于用户自行改写。
STYLE_SETS: dict[int, dict] = {
1: {
"name": "经典商拍",
"tone": "premium commercial e-commerce photography, clean soft studio lighting, "
"gentle gradient background, catalog-grade presentation, refined and trustworthy",
"bg": "light neutral studio backdrop with soft vignette",
"name": "高级质感大片",
"tone": "高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,"
"色彩层次高级克制,商业画册级品质,构图干净、留白充足",
"bg": "",
},
2: {
"name": "生活杂志",
"tone": "editorial lifestyle magazine aesthetic, natural window light, "
"cozy lived-in atmosphere, muted film tones, candid storytelling",
"bg": "warm lifestyle home setting with plants and textured fabrics",
"name": "清新生活场景",
"tone": "明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,"
"真实自然的氛围感,绿植与暖色织物点缀,温馨有人气",
"bg": "",
},
3: {
"name": "极简高冷",
"tone": "minimalist high-end aesthetic, vast negative space, single directional light, "
"cool grey palette, architectural calm, quiet luxury",
"bg": "seamless light grey studio background with subtle shadow",
"name": "极简白底规范",
"tone": "极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,"
"安静的高级感,画面只保留轻微的自然接触投影",
"bg": "",
},
4: {
"name": "活力爆款",
"tone": "vibrant high-conversion e-commerce style, punchy saturated accents, "
"energetic composition, bold contrast, promotional poster energy",
"bg": "bright colorful gradient backdrop with dynamic geometric shapes",
"name": "炫彩促销风",
"tone": "高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,"
"构图抢眼、视觉冲击力强",
"bg": "",
},
5: {
"name": "暗调质感",
"tone": "dark moody premium product photography, dramatic rim lighting, "
"deep charcoal background, rich texture detail, luxurious atmosphere",
"bg": "matte black background with soft spotlight and subtle smoke haze",
"name": "暗调轻奢",
"tone": "暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,"
"如美术馆展陈般的呈现",
"bg": "",
},
6: {
"name": "俄式风情",
"tone": "俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,"
"深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,"
"带一丝巴洛克式的华丽细节,适合俄语区市场",
"bg": "",
},
7: {
"name": "北欧极简",
"tone": "北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,"
"浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,"
"舒适宁静的氛围",
"bg": "",
},
}
@@ -192,9 +207,10 @@ def _prompt_material(ctx: dict, style: dict, lang: str) -> str:
)
def _prompt_lifestyle(ctx: dict, style: dict, lang: str) -> str:
bg = f" ({style['bg']})" if style.get("bg") else ""
return (
f"Lifestyle in-context scene for product \"{ctx['title']}\": the product is naturally "
f"used / placed in a real environment ({style['bg']}), realistic human-scale surroundings, "
f"used / placed in a real environment{bg}, realistic human-scale surroundings, "
f"soft daylight, authentic candid mood, product remains the clear visual focus. "
f"{style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
@@ -242,6 +258,7 @@ def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
hint = (extra.get("prompt_hint") or "").strip()
purpose = extra.get("title") or ""
detail = extra.get("detail") or ""
bg = f" {style['bg']} as environment." if style.get("bg") else ""
composed = (
f"E-commerce marketing image for product \"{ctx['title']}\""
+ (f"{purpose}" if purpose else "")
@@ -250,7 +267,7 @@ def _prompt_custom(ctx: dict, style: dict, lang: str, extra: dict) -> str:
)
if hint:
composed += f" Composition: {hint}."
return f"{composed} {style['tone']}. {style['bg']} as environment. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
return f"{composed}{bg} {style['tone']}. {TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
_PROMPT_BUILDERS = {
"white_bg": _prompt_white_bg,
@@ -265,13 +282,18 @@ _PROMPT_BUILDERS = {
}
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None) -> str:
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None) -> str:
"""构造指定图类型的完整生图 prompt。
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
预设类型也会把 prompt_hint 作为构图补充注入。
style_prompt: 用户改写的风格提示词,覆盖 style_set 内置模板(tone/bg 整体替换)。
"""
style = STYLE_SETS.get(style_set, STYLE_SETS[1])
if style_prompt and style_prompt.strip():
style = {"name": "custom", "tone": style_prompt.strip(), "bg": ""}
else:
style = STYLE_SETS.get(style_set, STYLE_SETS[1])
extra = extra or {}
if type_id == "custom":
prompt = _prompt_custom(ctx, style, lang, extra)