feat: 插件开发 ozon 端主体完成
This commit is contained in:
+12
-2
@@ -7,7 +7,7 @@ from config import get_settings
|
||||
from db import get_db
|
||||
from models import Suite
|
||||
from schemas import (
|
||||
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, SuiteCreateResponse, TextMaterial,
|
||||
GenerateRequest, PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, SuiteCreateResponse, TextMaterial,
|
||||
PlanRequest, PlanResponse, PlanItemOut,
|
||||
)
|
||||
from services.generator import run_suite
|
||||
@@ -36,6 +36,10 @@ def texts_to_raw(texts: list[TextMaterial]) -> dict:
|
||||
raw["sellingPoints"] = t.content
|
||||
elif t.kind == "desc" and t.content:
|
||||
raw["desc"] = t.content
|
||||
elif t.kind == "sales" and t.content:
|
||||
raw["sales"] = t.content
|
||||
elif t.kind == "shop" and t.content:
|
||||
raw["shop"] = t.content
|
||||
return raw
|
||||
|
||||
|
||||
@@ -80,15 +84,21 @@ async def generate_suite(
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
provider_name = req.provider or settings.image_provider
|
||||
model = req.model
|
||||
if provider_name == "tongyi" and model and model not in TONGYI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {model}(tongyi 支持: {TONGYI_MODELS})")
|
||||
suite = Suite(
|
||||
product_id=None,
|
||||
style_set=req.style_set,
|
||||
style_prompt=req.style_prompt,
|
||||
platform=req.platform,
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
types=types,
|
||||
plan=jobs,
|
||||
provider=req.provider or settings.image_provider,
|
||||
provider=provider_name,
|
||||
model=model,
|
||||
context=texts_to_raw(req.texts),
|
||||
# 参考图池:main 组优先,其余组按序补充(variant 绑定靠 variant_name 匹配)
|
||||
ref_images=[
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config import get_settings
|
||||
from db import get_db
|
||||
from models import Product, ProductAsset, Suite, SuiteImage, STATUS_OK
|
||||
from schemas import PLATFORM_SPECS, SUPPORTED_TYPES, SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut
|
||||
from schemas import PLATFORM_SPECS, SUPPORTED_TYPES, TONGYI_MODELS, SuiteCreateRequest, SuiteCreateResponse, SuiteImageOut, SuiteOut
|
||||
from services import storage
|
||||
from services.generator import run_suite
|
||||
|
||||
@@ -35,6 +35,7 @@ async def _suite_out(db: AsyncSession, suite: Suite) -> SuiteOut:
|
||||
ratio=suite.ratio,
|
||||
types=list(suite.types or []),
|
||||
provider=suite.provider,
|
||||
model=suite.model,
|
||||
images=[
|
||||
SuiteImageOut(
|
||||
type_id=i.type_id, name=i.name, url=i.stored_url or "",
|
||||
@@ -75,6 +76,9 @@ async def create_suite(
|
||||
spec = PLATFORM_SPECS[req.platform]
|
||||
|
||||
settings = get_settings()
|
||||
provider_name = req.provider or settings.image_provider
|
||||
if provider_name == "tongyi" and req.model and req.model not in TONGYI_MODELS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模型: {req.model}(tongyi 支持: {TONGYI_MODELS})")
|
||||
suite = Suite(
|
||||
product_id=product.id,
|
||||
style_set=req.style_set,
|
||||
@@ -82,7 +86,8 @@ async def create_suite(
|
||||
lang=spec["lang"],
|
||||
ratio=spec["ratio"],
|
||||
types=req.types,
|
||||
provider=req.provider or settings.image_provider,
|
||||
provider=provider_name,
|
||||
model=req.model,
|
||||
)
|
||||
db.add(suite)
|
||||
await db.commit()
|
||||
|
||||
+11
-1
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -38,10 +39,19 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield session
|
||||
|
||||
|
||||
async def _migrate(conn) -> None:
|
||||
"""给旧库补充新增列(create_all 不会修改已存在的表)。"""
|
||||
rows = await conn.execute(text("PRAGMA table_info(suites)"))
|
||||
cols = {row[1] for row in rows}
|
||||
if "model" not in cols:
|
||||
await conn.execute(text("ALTER TABLE suites ADD COLUMN model VARCHAR(64)"))
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""启动时建表(MVP 不引 Alembic,模型变更删库重建即可)。"""
|
||||
"""启动时建表 + 轻量列迁移(MVP 不引 Alembic)。"""
|
||||
import models # noqa: F401 确保模型注册
|
||||
|
||||
engine = get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await _migrate(conn)
|
||||
|
||||
@@ -85,12 +85,14 @@ class Suite(Base):
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default=SUITE_PENDING, index=True)
|
||||
style_set: Mapped[int] = mapped_column(Integer, default=1) # 风格模板 1-5
|
||||
style_prompt: Mapped[str | None] = mapped_column(Text, nullable=True) # 用户改写的风格提示词(覆盖模板)
|
||||
platform: Mapped[str] = mapped_column(String(8), default="cn") # 目标平台 ozon | wb | cn
|
||||
lang: Mapped[str] = mapped_column(String(4), default="zh") # ru / zh(由平台推导)
|
||||
ratio: Mapped[str] = mapped_column(String(8), default="1:1") # 图片比例(由平台推导)
|
||||
types: Mapped[list | None] = mapped_column(JSON, nullable=True) # 图类型 id 列表(旧)
|
||||
plan: Mapped[list | None] = mapped_column(JSON, nullable=True) # 出图方案(展开后的逐张任务)
|
||||
provider: Mapped[str] = mapped_column(String(16), default="doubao")
|
||||
model: Mapped[str | None] = mapped_column(String(64), nullable=True) # 生图模型名(覆盖 provider 默认)
|
||||
# 工具化流程:请求自带的数据(生图上下文 + 参考图 URL 列表)
|
||||
context: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ref_images: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
+9
-2
@@ -9,6 +9,9 @@ SUPPORTED_TYPES = [
|
||||
"size_chart", "sku_collection", "custom",
|
||||
]
|
||||
|
||||
# 通义(DashScope)生图模型白名单:插件下拉可选的模型
|
||||
TONGYI_MODELS = ["qwen-image-3.0-pro", "wan2.7-image-pro"]
|
||||
|
||||
|
||||
# ── 采集上传 ──
|
||||
|
||||
@@ -60,10 +63,11 @@ PLATFORM_SPECS: dict[str, dict] = {
|
||||
|
||||
|
||||
class SuiteCreateRequest(BaseModel):
|
||||
style_set: int = Field(default=1, ge=1, le=5, description="风格模板 1-5")
|
||||
style_set: int = Field(default=1, ge=1, le=7, description="风格模板 1-7")
|
||||
types: list[str] = Field(default_factory=lambda: ["white_bg", "key_features", "lifestyle", "multi_scene"])
|
||||
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
|
||||
provider: str | None = Field(default=None, description="覆盖默认 provider(doubao | tongyi)")
|
||||
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro)")
|
||||
|
||||
|
||||
# ── 无状态套图生成(工具流程:请求自带采集数据)──
|
||||
@@ -87,11 +91,13 @@ class PlanItem(BaseModel):
|
||||
class GenerateRequest(BaseModel):
|
||||
texts: list[TextMaterial] = Field(default_factory=list, description="采集的文本素材")
|
||||
images: list[GenerateImageItem] = Field(default_factory=list, description="勾选的参考图")
|
||||
style_set: int = Field(default=1, ge=1, le=5)
|
||||
style_set: int = Field(default=1, ge=1, le=7)
|
||||
style_prompt: str | None = Field(default=None, description="用户改写的风格提示词(覆盖 style_set 模板)")
|
||||
types: list[str] = Field(default_factory=list, description="旧参数:无方案时按类型生成")
|
||||
plan: list[PlanItem] | None = Field(default=None, description="出图方案(优先于 types)")
|
||||
platform: str = Field(default="cn", description="目标平台:ozon | wb | cn")
|
||||
provider: str | None = Field(default=None, description="覆盖默认 provider(doubao | tongyi)")
|
||||
model: str | None = Field(default=None, description="覆盖默认生图模型(tongyi: qwen-image-3.0-pro / wan2.7-image-pro)")
|
||||
|
||||
|
||||
# ── 出图方案规划(DeepSeek)──
|
||||
@@ -139,6 +145,7 @@ class SuiteOut(BaseModel):
|
||||
ratio: str
|
||||
types: list[str]
|
||||
provider: str
|
||||
model: str | None = None
|
||||
images: list[SuiteImageOut]
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user