Files
cmshoppe/app/product_suite.py
T

418 lines
15 KiB
Python
Raw Normal View History

"""Pure product-suite configuration and generation planning helpers."""
from __future__ import annotations
from collections import OrderedDict
import re
FIXED_CATEGORIES = ("白底图", "场景图", "模特场景图", "细节说明图", "卖点图")
FIXED_CATEGORY_HELPERS = {
"白底图": "白底主图,多角度呈现商品细节",
"场景图": "生活化场景展示商品使用方式",
"模特场景图": "模特或手持场景展示商品真实使用",
"细节说明图": "突出材质、做工和关键细节",
"卖点图": "突出核心卖点和差异化优势",
}
FIXED_CATEGORY_DESCRIPTIONS = {
"白底图": "生成 Shopee 台灣商品白底主圖,純白背景,商品清晰置中,不添加多餘文字。",
"场景图": "生成生活化使用場景圖,展示商品在真實情境中的用途與氛圍,畫面自然可信。",
"模特场景图": "生成模特或手持使用情境圖,畫面自然可信,商品為主角。",
"细节说明图": "生成商品細節特寫說明圖,突出材質、做工、接口、紋理或關鍵細節。",
"卖点图": "生成賣點詳解圖,使用繁體中文短文案呈現核心優勢,版面乾淨。",
}
DEFAULT_CATEGORY_COUNTS = OrderedDict(
(
("白底图", 1),
("场景图", 2),
("模特场景图", 0),
("细节说明图", 0),
("卖点图", 2),
)
)
PLATFORMS = ("Shopee", "Lazada", "TikTok Shop", "Amazon")
COUNTRIES = ("中国台湾", "新加坡", "马来西亚", "菲律宾", "泰国", "越南")
LANGUAGES = ("繁体中文", "简体中文", "英文", "泰文", "越南文")
RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16")
LAST_SETTING_KEYS = ("platform", "country", "language", "ratio")
MAX_CATEGORY_NAME_LENGTH = 10
MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16
PRODUCT_SUITE_PLACEHOLDERS = (
"生成目标",
"套图名称",
"补充描述",
"平台",
"国家地区",
"输出语言",
"图片比例",
"商品ID",
"主参考图序号",
"参考图规则",
"商品卖点与要求",
"尺寸与长图规则",
"禁用内容规则",
"价格信息规则",
"尺码信息规则",
)
PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS = (
"图片比例",
"参考图规则",
"商品卖点与要求",
"尺寸与长图规则",
"禁用内容规则",
"价格信息规则",
"尺码信息规则",
)
PRODUCT_SUITE_REQUIRED_PLACEHOLDERS = (
"生成目标",
*PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS,
)
PRODUCT_SUITE_LEGACY_REQUIRED_PLACEHOLDERS = (
"套图名称",
"补充描述",
*PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS,
)
PRODUCT_SUITE_READ_ONLY_RULE_PLACEHOLDERS = (
"尺寸与长图规则",
"禁用内容规则",
"价格信息规则",
"尺码信息规则",
)
PRODUCT_SUITE_SIZE_RULE = (
"重要尺寸要求:最终输出必须严格符合所选比例的单张完整构图电商图,"
"禁止海报长图、详情页长图和多宫格拼接版面。"
)
PRODUCT_SUITE_FORBIDDEN_CONTENT_RULE = (
"重要禁用内容:禁止在画面中出现任何国旗、旗帜、国徽、地图轮廓、"
"政治符号或类似国家/地区标识。"
)
PRODUCT_SUITE_PRICE_RULE = (
"价格信息规则:除非用户明确提供价格、折扣或活动价,否则禁止自行添加"
"价格、币别符号、折扣数字或促销金额。"
)
PRODUCT_SUITE_SIZE_INFO_RULE = (
"尺码信息规则:除非用户或参考图明确提供尺码、尺寸或规格,否则禁止自行"
"编造尺码、尺寸、适用身高体重等内容。"
)
_PRODUCT_SUITE_PLACEHOLDER_RE = re.compile(r"\{([^{}\r\n]+)\}")
class ProductSuitePromptError(ValueError):
"""Raised when a product-suite prompt template is invalid."""
def default_suite_settings():
return {
"platform": "Shopee",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "1:1",
"per_image_primary": False,
"categories": dict(DEFAULT_CATEGORY_COUNTS),
"custom_category_order": [],
}
def normalize_suite_settings(value=None):
raw = dict(value or {}) if isinstance(value, dict) else {}
normalized = default_suite_settings()
normalized["platform"] = _choice(raw.get("platform"), PLATFORMS, "Shopee")
normalized["country"] = _choice(raw.get("country"), COUNTRIES, "中国台湾")
normalized["language"] = _choice(raw.get("language"), LANGUAGES, "繁体中文")
normalized["ratio"] = _choice(raw.get("ratio"), RATIOS, "1:1")
normalized["per_image_primary"] = bool(raw.get("per_image_primary", False))
raw_categories = raw.get("categories") if isinstance(raw.get("categories"), dict) else {}
categories = OrderedDict()
for name, default_count in DEFAULT_CATEGORY_COUNTS.items():
categories[name] = _count(raw_categories.get(name, default_count))
requested_order = raw.get("custom_category_order")
requested_order = requested_order if isinstance(requested_order, list) else []
seen = set(FIXED_CATEGORIES)
custom_order = []
for candidate in list(requested_order) + list(raw_categories):
name = str(candidate or "")
if name in seen or suite_name_error(name, categories):
continue
seen.add(name)
custom_order.append(name)
categories[name] = _count(raw_categories.get(name, 1))
normalized["categories"] = dict(categories)
normalized["custom_category_order"] = custom_order
return normalized
def last_suite_settings(value=None):
normalized = normalize_suite_settings(value)
return {key: normalized[key] for key in LAST_SETTING_KEYS}
def suite_name_error(name, existing=None, old_name=""):
value = str(name or "")
if not value.strip():
return "分类名称不能为空"
if value != value.strip() or any(character.isspace() for character in value):
return "分类名称不能包含空格"
if len(value) > MAX_CATEGORY_NAME_LENGTH:
return "分类名称不能超过10个字"
names = set(existing or ())
if value in names and value != str(old_name or ""):
return "分类名称已存在"
return ""
def category_order(settings):
normalized = normalize_suite_settings(settings)
categories = normalized["categories"]
custom = [
name
for name in normalized["custom_category_order"]
if name in categories and name not in FIXED_CATEGORIES
]
return list(FIXED_CATEGORIES) + custom
def category_helper(category):
return FIXED_CATEGORY_HELPERS.get(str(category or ""), "")
def category_description(category):
name = str(category or "").strip()
fixed_description = FIXED_CATEGORY_DESCRIPTIONS.get(name)
if fixed_description:
return fixed_description
if name:
return "生成自定义分类图片:%s。" % name
return "生成自定义分类图片。"
def suite_total_count(settings, image_count):
normalized = normalize_suite_settings(settings)
categories = normalized["categories"]
base = sum(_count(categories.get(name, 0)) for name in category_order(normalized))
if not normalized["per_image_primary"]:
return base
white_count = _count(categories.get("白底图", 0))
other_count = max(0, base - white_count)
return white_count + other_count * max(1, int(image_count or 0))
def product_suite_prompt_errors(template_text):
text = str(template_text or "")
errors = []
if not text.strip():
return ["套图提示词模板不能为空"]
matches = list(_PRODUCT_SUITE_PLACEHOLDER_RE.finditer(text))
remainder = _PRODUCT_SUITE_PLACEHOLDER_RE.sub("", text)
if "{" in remainder or "}" in remainder:
errors.append("模板包含未闭合花括号或不支持的字面花括号")
names = [match.group(1) for match in matches]
unknown = sorted(set(names) - set(PRODUCT_SUITE_PLACEHOLDERS))
if unknown:
errors.append("模板包含未知变量:%s" % "、".join("{%s}" % name for name in unknown))
missing = [
name
for name in PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS
if name not in names
]
if missing:
errors.append("模板缺少必需变量:%s" % "、".join("{%s}" % name for name in missing))
has_new_target = "生成目标" in names
has_legacy_target = all(
name in names for name in ("套图名称", "补充描述")
)
if not has_new_target and not has_legacy_target:
errors.append(
"模板缺少必需变量:{生成目标}(旧模板需同时包含{套图名称}和{补充描述})"
)
invalid_rule_lines = []
for line in text.splitlines():
line_names = _PRODUCT_SUITE_PLACEHOLDER_RE.findall(line)
for name in line_names:
if (
name in PRODUCT_SUITE_READ_ONLY_RULE_PLACEHOLDERS
and line.strip() != "{%s}" % name
):
invalid_rule_lines.append(name)
if invalid_rule_lines:
errors.append(
"只读规则变量必须独占一行:%s"
% "、".join("{%s}" % name for name in sorted(set(invalid_rule_lines)))
)
return errors
def validate_product_suite_prompt(template_text):
errors = product_suite_prompt_errors(template_text)
if errors:
raise ProductSuitePromptError(";".join(errors))
return str(template_text)
def reference_asset_count(settings, source_image_count):
"""Return the effective reference-image count for a planned cmhub request."""
normalized = normalize_suite_settings(settings)
if normalized["per_image_primary"]:
return 0
try:
image_count = max(0, int(source_image_count or 0))
except (TypeError, ValueError):
image_count = 0
return min(7, max(0, image_count - 1))
def product_suite_reference_rule(settings, reference_asset_count=0):
normalized = normalize_suite_settings(settings)
if normalized["per_image_primary"]:
return (
"参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;"
"不编造用户与参考图均未提供的信息。"
)
return "参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。"
def product_suite_prompt_context(
base_prompt,
settings,
category,
item_id,
source_index=1,
reference_asset_count=0,
):
normalized = normalize_suite_settings(settings)
item_text = str(item_id or "").strip()
if not item_text or item_text.startswith("draft_"):
item_text = "未绑定商品"
reference_index = max(1, int(source_index or 1))
reference_rule = product_suite_reference_rule(
normalized,
reference_asset_count,
)
return {
"生成目标": category_description(category),
"套图名称": str(category or ""),
"补充描述": "," + category_description(category),
"平台": normalized["platform"],
"国家地区": normalized["country"],
"输出语言": normalized["language"],
"图片比例": normalized["ratio"],
"商品ID": item_text,
"主参考图序号": str(reference_index),
"参考图规则": reference_rule,
"商品卖点与要求": str(base_prompt or "").strip(),
"尺寸与长图规则": PRODUCT_SUITE_SIZE_RULE,
"禁用内容规则": PRODUCT_SUITE_FORBIDDEN_CONTENT_RULE,
"价格信息规则": PRODUCT_SUITE_PRICE_RULE,
"尺码信息规则": PRODUCT_SUITE_SIZE_INFO_RULE,
}
def render_product_suite_prompt(template_text, context):
validate_product_suite_prompt(template_text)
values = {
name: str((context or {}).get(name, ""))
for name in PRODUCT_SUITE_PLACEHOLDERS
}
context = context or {}
missing_context = [
name
for name in PRODUCT_SUITE_COMMON_REQUIRED_PLACEHOLDERS
if name not in context
]
has_new_target = "生成目标" in context
has_legacy_target = all(
name in context for name in ("套图名称", "补充描述")
)
if not has_new_target and not has_legacy_target:
missing_context.append("生成目标")
if missing_context:
raise ProductSuitePromptError(
"提示词上下文缺少变量:%s"
% "、".join("{%s}" % name for name in missing_context)
)
rendered = _PRODUCT_SUITE_PLACEHOLDER_RE.sub(
lambda match: values[match.group(1)],
str(template_text),
)
# 模板已在替换前校验完成;业务文本中的花括号不是模板变量。
return rendered.strip()
def build_suite_prompt(
base_prompt,
settings,
category,
item_id,
source_index=1,
reference_asset_count=0,
*,
template_text,
):
context = product_suite_prompt_context(
base_prompt,
settings,
category,
item_id,
source_index=source_index,
reference_asset_count=reference_asset_count,
)
return render_product_suite_prompt(template_text, context)
def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_text):
assets = list(source_assets or [])
if not assets:
return []
normalized = normalize_suite_settings(settings)
primary_asset_id = int(getattr(assets[0], "id", assets[0]))
reference_asset_ids = []
if not normalized["per_image_primary"]:
for asset in assets[1:8]:
asset_id = int(getattr(asset, "id", asset))
if asset_id != primary_asset_id and asset_id not in reference_asset_ids:
reference_asset_ids.append(asset_id)
specs = []
for category in category_order(normalized):
count = _count(normalized["categories"].get(category, 0))
if count <= 0:
continue
targets = assets if normalized["per_image_primary"] and category != "白底图" else assets[:1]
for source_index, asset in enumerate(targets, 1):
for category_index in range(1, count + 1):
specs.append(
{
"source_asset_id": int(getattr(asset, "id", asset)),
"reference_asset_ids": list(reference_asset_ids),
"job_type": str(category),
"category": str(category),
"category_index": category_index,
"source_index": source_index,
"prompt": build_suite_prompt(
base_prompt,
normalized,
category,
item_id,
source_index=source_index,
reference_asset_count=len(reference_asset_ids),
template_text=template_text,
),
}
)
return specs
def _count(value):
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def _choice(value, choices, default):
text = str(value or "").strip()
return text if text in choices else default