Files
cmshoppe/app/product_suite.py

418 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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