v110-v112 自定义模式完善 + 模板导出增强 + 多模态兼容优化

- 自定义模式:分析模型输出 delta 唯一改动指令,生图模板 custom_image_prompt.md({delta} 占位符),不再使用负向提示词;generate_design 按 custom_mode 分支,Pinterest 模式保留原创化指令,两模式互不影响
- 多模态分析 response_format 三级回退(json_schema → json_object → none),兼容 DeepSeek
- 模板导出:details 扩展列(细节1/2/3)、target_audience 扩展列(适用人群1)、固定值风格1=休闲/风格2=运动
- 童装特征库更新 + 标题模板外部化 + 图源映射增强
This commit is contained in:
2026-09-03 18:28:39 +08:00
parent d68cc3b9e3
commit 5ab5cf6586
40 changed files with 1967 additions and 149 deletions
+2
View File
@@ -188,6 +188,8 @@ class MockBackend:
"image_prompt": (f"{motif}, {art_style}, {palette}, {composition}, "
f"original {art_style} t-shirt print design, "
f"no brand logo, no trademark, no character, no watermark"),
# 自定义模式 mock 兜底:给一个具体改动指令(模板 {delta} 占位符替换用)
"delta": "change the main subject to face the opposite direction",
# 生图参考:每条简报对应其来源爬取图(mock 按图逐张产出简报,顺序一一对应)
"ref_images": [str(paths[i])] if i < len(paths) else [],
"source": "pinterest",
+79 -36
View File
@@ -10,7 +10,7 @@ import os
import re
import time
from pathlib import Path
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import requests
import yaml
@@ -197,20 +197,38 @@ def _read_title_prompt(tpl_no: str) -> str:
return ""
_TITLE_ROUTE_CACHE: Optional[Dict[str, str]] = None
_TITLE_ROUTE_CACHE_FILE: str = ""
def _load_title_route() -> Dict[str, str]:
"""从 config.yaml 顶层 title_templates.route 读取国家→模板编号路由;缺失/留空回退内置默认。"""
"""从 config.yaml 顶层 title_templates.route 读取国家→模板编号路由;缺失/留空回退内置默认。
路由在运行期不变,模块级缓存避免并发多产品时每次重读 config.yaml(mtime 变化会刷新)。
"""
global _TITLE_ROUTE_CACHE, _TITLE_ROUTE_CACHE_FILE
cfg_file = ""
for base in (runtime_root(), project_root()):
p = base / "config.yaml"
if not p.exists():
continue
try:
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
route = (data.get("title_templates") or {}).get("route") or {}
if isinstance(route, dict) and route:
return {str(k): str(v) for k, v in route.items()}
except Exception as e: # noqa: BLE001
print(f"[titles] 读取 config.yaml title_templates.route 失败: {e}")
return dict(_TITLE_ROUTE_FALLBACK)
if p.exists():
cfg_file = str(p)
break
if cfg_file == _TITLE_ROUTE_CACHE_FILE and _TITLE_ROUTE_CACHE is not None:
return dict(_TITLE_ROUTE_CACHE)
route: Dict[str, str] = {}
try:
data = yaml.safe_load(open(cfg_file, encoding="utf-8")) or {}
r = (data.get("title_templates") or {}).get("route") or {}
if isinstance(r, dict) and r:
route = {str(k): str(v) for k, v in r.items()}
except Exception as e: # noqa: BLE001
print(f"[titles] 读取 config.yaml title_templates.route 失败: {e}")
if not route:
route = dict(_TITLE_ROUTE_FALLBACK)
cfg_file = ""
_TITLE_ROUTE_CACHE = dict(route)
_TITLE_ROUTE_CACHE_FILE = cfg_file
return dict(route)
def _inject_now(prompt: str) -> str:
@@ -225,12 +243,17 @@ def _inject_now(prompt: str) -> str:
.replace("{season}", season))
def resolve_title_prompt(country: str = "") -> str:
"""按国家解析标题生成提示词(自动注入当前时间变量);未知国家/留空回退模板 1。"""
def resolve_title_prompt(country: str = "", category_path: str = "") -> str:
"""按国家解析标题生成提示词(自动注入当前时间 + 类目路径变量);未知国家/留空回退模板 1。
category_path:商品上传模版「类目」完整路径(如
服装、鞋靴和珠宝饰品>女童时尚>女童服装>女童上衣、T恤、衬衫>女童T恤),
模板含 {category_path} 占位符时替换;未提供/为空则替换为空串。"""
route = _load_title_route()
tpl_no = route.get(country or "", "1")
prompt = _read_title_prompt(tpl_no) or _TITLE_TEMPLATES_FALLBACK.get(tpl_no, _TITLE_TEMPLATES_FALLBACK["1"])
return _inject_now(prompt)
prompt = _inject_now(prompt)
return prompt.replace("{category_path}", str(category_path or ""))
def build_seed_user_prompt(context: Dict[str, Any]) -> str:
@@ -409,6 +432,7 @@ PINTEREST_ANALYZE_SCHEMA = {
"properties": {
"suitable_for_print": {"type": "boolean"},
"image_prompt": {"type": "string"},
"delta": {"type": "string"},
},
"required": ["suitable_for_print", "image_prompt"],
"additionalProperties": False,
@@ -777,32 +801,43 @@ class OpenAICompatBackend(LLMBackend):
{"type": "text", "text": user_prompt},
]
user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
payload = {
base_payload = {
"model": model,
"messages": [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_content},
],
"temperature": 0.5,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": PINTEREST_ANALYZE_SCHEMA["name"],
"strict": True,
"schema": PINTEREST_ANALYZE_SCHEMA["schema"],
},
},
}
try:
resp = requests.post(url, json=payload, headers=headers, timeout=180, proxies=NO_PROXY)
resp.raise_for_status()
return str(resp.json()["choices"][0]["message"].get("content") or "")
except Exception as e: # noqa: BLE001 兼容厂商不支持 json_schema
_notify_400(e)
payload["response_format"] = {"type": "json_object"}
resp = requests.post(url, json=payload, headers=headers, timeout=180, proxies=NO_PROXY)
resp.raise_for_status()
return str(resp.json()["choices"][0]["message"].get("content") or "")
# 兼容厂商差异的尝试链:
# 1) json_schemaOpenAI 原生 strict 输出)
# 2) json_object(部分兼容厂商支持,但要求提示词含 "json",如 DeepSeek → 追加显式指令)
# 3) 无 response_format(依赖 _extract_json 兜底解析)
last_err: Optional[Exception] = None
for variant in ("json_schema", "json_object", "none"):
payload = dict(base_payload)
if variant == "json_schema":
payload["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": PINTEREST_ANALYZE_SCHEMA["name"],
"strict": True,
"schema": PINTEREST_ANALYZE_SCHEMA["schema"],
},
}
elif variant == "json_object":
payload["response_format"] = {"type": "json_object"}
payload["messages"][1]["content"] = [
{"type": "text", "text": user_prompt + "\nReturn your answer as a JSON object."},
] + [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
try:
resp = requests.post(url, json=payload, headers=headers, timeout=180, proxies=NO_PROXY)
resp.raise_for_status()
return str(resp.json()["choices"][0]["message"].get("content") or "")
except Exception as e: # noqa: BLE001
last_err = e
_notify_400(e)
raise last_err
# 图片输入失败/无有效图片 → 直接放弃该产品(不降级纯文本),由节点跳过后续流程
if not data_uris:
@@ -826,6 +861,10 @@ class OpenAICompatBackend(LLMBackend):
designs_raw = parsed
else:
designs_raw = []
# 自定义模式:分析模型按 custom_analyze_system.md 直接返回 {"delta": "..."}(无 designs 包装)
# → 包装成单条简报;suitable_for_print 缺省 True(下游 _brief_suitable 默认放行)
if not designs_raw and custom_mode and isinstance(parsed, dict) and str(parsed.get("delta") or "").strip():
designs_raw = [parsed]
for i, d in enumerate(designs_raw):
if not isinstance(d, dict):
continue
@@ -833,17 +872,21 @@ class OpenAICompatBackend(LLMBackend):
"topic": term,
"suitable_for_print": bool(d.get("suitable_for_print", True)),
"image_prompt": str(d.get("image_prompt", "")).strip(),
# 自定义模式分析模型产出的「唯一改动」指令,注入生图模板 {delta} 占位符
"delta": str(d.get("delta", "")).strip(),
# 生图参考:每条简报对应其来源爬取图(LLM 按图逐张产出简报,顺序一一对应)
"ref_images": [str(image_paths[i])] if i < len(image_paths) else [],
"source": "pinterest",
})
return designs
def generate_title(self, image_path: str, system_prompt: str = "", country: str = "") -> Dict[str, Any]:
def generate_title(self, image_path: str, system_prompt: str = "", country: str = "",
category_path: str = "") -> Dict[str, Any]:
"""多模态:分析服装图片,生成商品标题(按国家路由模板)。
系统提示词:显式传入优先;否则按 country 经 config.yaml title_templates.route 路由到
prompts/title_prompt_<编号>.md 对应模板(缺失回退内置默认)。
category_path:模版「类目」完整路径,注入模板 {category_path} 占位符(可为空)。
模板 1US/GB/AU/MX)返回 {"en_title","cn_title"}
模板 2(JP)额外返回 {"ja_title"}
模板 3ES)返回 {"es_title","cn_title"}。
@@ -874,7 +917,7 @@ class OpenAICompatBackend(LLMBackend):
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt or resolve_title_prompt(country)},
{"role": "system", "content": system_prompt or resolve_title_prompt(country, category_path)},
{"role": "user", "content": [
{"type": "text", "text": "请分析这张服装图片,按规则输出标题,结果以 JSON 格式返回。"},
{"type": "image_url", "image_url": {"url": data_uri}},
+22 -12
View File
@@ -125,7 +125,8 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
seed: Optional[int] = None,
on_400=None,
on_503=None,
size: str = "1024x1024") -> Optional[str]:
size: str = "1024x1024",
custom_mode: bool = False) -> Optional[str]:
"""生成单张纯印花设计稿(图2)。返回设计稿路径;失败 / 全局 MD5 重复返回 None。
out_stem: 输出文件名主干(不含扩展名),最终文件 = {out_stem}_design.png。
@@ -146,16 +147,24 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
ref_images = [str(p) for p in (brief.get("ref_images") or []) if str(p)]
if ref_images and hasattr(ib, "print"):
try:
# 图生图:以爬取图为参考,按分析简报生成原创设计(不复制原图)
ref_prompt = img_prompt + (
" Create an ORIGINAL, non-copying flat print design inspired ONLY by "
"the reference image's style and mood. Do NOT reproduce the reference "
"image, its characters, logos, or any text.")
out_path = ib.print(
ref_prompt, ref_images[0], out_path,
brief.get("composite_negative", ""),
extra_images=ref_images[1:] or None,
size=size, seed=seed) # 设计稿尺寸按 config compose.design_size
if custom_mode:
# 自定义模式:按 image_prompt 图生图(模板已内置防复制/防服装约束,不追加原创化指令)
out_path = ib.print(
img_prompt, ref_images[0], out_path,
brief.get("composite_negative", ""),
extra_images=ref_images[1:] or None,
size=size, seed=seed) # 设计稿尺寸按 config compose.design_size
else:
# Pinterest 模式:追加原创化指令(防止复制原图,保持原有行为)
ref_prompt = img_prompt + (
" Create an ORIGINAL, non-copying flat print design inspired ONLY by "
"the reference image's style and mood. Do NOT reproduce the reference "
"image, its characters, logos, or any text.")
out_path = ib.print(
ref_prompt, ref_images[0], out_path,
brief.get("composite_negative", ""),
extra_images=ref_images[1:] or None,
size=size, seed=seed) # 设计稿尺寸按 config compose.design_size
except Exception as e: # noqa: BLE001
print(f"[compose] 图生图(参考图)失败,回退文生图 {brief.get('topic','')}: {e}")
_notify_400(on_400, e)
@@ -255,7 +264,8 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
out_path = generate_design(ib, b, design_dir, f"{country}_{i:02d}",
_safe_errors, seed=_seed,
size=compose_cfg.get("design_size", "1024x1024"))
size=compose_cfg.get("design_size", "1024x1024"),
custom_mode=bool(state.get("custom_mode")))
if out_path is None:
return i, b, None, None
return i, b, out_path, None
+3 -3
View File
@@ -63,9 +63,9 @@ def oss_upload_node(state: Dict[str, Any]) -> Dict[str, Any]:
print(f"[oss] 货号计数已达上限 999,停止上传后续图片({src}")
break
try:
code = f"{prefix}{seq:03d}" # 货号:前缀 + 3 位计数(000 起)
code = str(r.get("img_code") or "") or f"{prefix}{seq:03d}" # 货号:优先用 product 生成的 img_code,缺省自增
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(country, ts, code, _gen_rand4())
key = build_oss_key(country, ts, code, _gen_rand4(), local=compressed)
url = upload_to_oss(oss_cfg, compressed, key)
if url:
r[f"{kind}_url"] = url
@@ -91,7 +91,7 @@ def oss_upload_node(state: Dict[str, Any]) -> Dict[str, Any]:
try:
code = f"{prefix}{seq:03d}"
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(country, ts, code, _gen_rand4())
key = build_oss_key(country, ts, code, _gen_rand4(), local=compressed)
url = upload_to_oss(oss_cfg, compressed, key)
if url:
cc["url"] = url
+1
View File
@@ -75,6 +75,7 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str,
"design_category": classify(term),
"concept": f"围绕「{term}」的原创印花设计",
"image_prompt": str(b.get("image_prompt") or "").strip(),
"delta": str(b.get("delta") or "").strip(),
"ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)],
"source_md5": str(b.get("source_md5") or "").strip().lower(),
"brief_id": str(b.get("brief_id") or "").strip(),
+14 -2
View File
@@ -195,7 +195,7 @@ def _process_spu(
db_path, basemap_root, material_root, category, prod_dir, brief, ib,
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
on_503=None, model_kind="model",
on_503=None, model_kind="model", category_path="",
) -> Optional[Dict[str, Any]]:
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
@@ -434,7 +434,7 @@ def _process_spu(
title_img = (result.get("composite_path") or result.get("printed_path")
or result.get("design_path"))
if title_img:
t = title_backend.generate_title(title_img, country=country)
t = title_backend.generate_title(title_img, country=country, category_path=category_path)
if t.get("en_title") or t.get("cn_title") or t.get("ja_title") or t.get("es_title"):
result["en_title"] = t.get("en_title", "")
result["cn_title"] = t.get("cn_title", "")
@@ -482,6 +482,17 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
spu_tasks = pcfg.get("spu_tasks") or []
spu_count = int(pcfg.get("spu_count") or 0)
# 标题生成用:模版「类目」完整路径(如 服装、鞋靴和珠宝饰品>女童时尚>…>女童T恤),
# 注入标题提示词 {category_path} 占位符;模板未配置/读取失败为空串
category_path = ""
try:
_tp = str(pcfg.get("template_path") or "").strip()
if _tp:
from graph.seed_shot import read_template_category
category_path = read_template_category(_tp)
except Exception as _e: # noqa: BLE001
print(f"[product] 读取模版类目路径失败(标题 category_path 留空): {_e}")
# 1) 选简报(优先 safe
safe = [b for b in briefs if b.get("risk_level") == "safe"] or briefs
if not safe:
@@ -634,6 +645,7 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
country, img_code=img_code,
model_img=_src.get("img"), # 按任务序号取独立随机图源
model_kind=_src.get("kind", "model"),
category_path=category_path,
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
if r:
+33 -18
View File
@@ -39,9 +39,9 @@ REVIEW_REBRAND_HINT = (
"a generic, non-infringing homage in the same mood, clearly distinct from the original."
)
# —— 自定义模式(custom)生图模板:固定前缀 + 分析模型 image_prompt + 固定负向 ——
# 自定义模式分析模型产出的是「新设计描述」,生图时套用这套固定模板(含防复制/防服装约束),
# 负向用固定文本(写入 composite_negativecompose 生图时作为负向参数传给图像后端)
# —— 自定义模式(custom)生图模板:固定前缀 + 分析模型 image_prompt + delta 改动指令 ——
# 自定义模式分析模型产出的是「新设计描述 + 唯一改动指令 delta」,生图时套用这套固定模板
# (含防复制/防服装约束),不再使用负向提示词——约束已全部内置进模板
CUSTOM_IMAGE_PROMPT_TEMPLATE = (
"Use the attached bestseller product photo only as loose inspiration for "
"overall mood, theme, era and style genre — do NOT reproduce, trace, "
@@ -53,11 +53,6 @@ CUSTOM_IMAGE_PROMPT_TEMPLATE = (
"base the new design on THOSE observed traits. Ignore the model, background "
"and photo quality. Then: {image_prompt}"
)
CUSTOM_NEGATIVE_PROMPT = (
"copy of reference artwork, lookalike of the bestseller print, characters, "
"mascots, likenesses, logos, trademarks, watermark, photorealistic shirt, "
"apparel, product mockup, model, garment, hanger, busy background"
)
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
@@ -84,6 +79,18 @@ def _safe_motif(motif: str) -> str:
return motif
def _read_custom_prompt_md(filename: str) -> str:
"""读取 prompts/<filename>(自定义模式生图模板),优先运行根 exe 旁、回退数据根;无/空返回空串。"""
from graph.paths import project_root, runtime_root
for base in (runtime_root(), project_root()):
p = base / "prompts" / filename
if p.exists():
t = p.read_text(encoding="utf-8").strip()
if t:
return t
return ""
@with_fallback("prompt_build")
def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
screened: List[Dict[str, Any]] = state.get("screened") or []
@@ -101,8 +108,11 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
print_suffix = (pp.get("print_suffix") or "").strip() or PINTEREST_PRINT_SUFFIX
spelling_rule = (pp.get("spelling_rule") or "").strip() or SPELLING_RULE
review_rebrand_hint = (pp.get("review_rebrand_hint") or "").strip() or REVIEW_REBRAND_HINT
custom_ip_tpl = (pp.get("image_prompt_template") or "").strip() or CUSTOM_IMAGE_PROMPT_TEMPLATE
custom_neg = (pp.get("negative_prompt") or "").strip() or CUSTOM_NEGATIVE_PROMPT
# 自定义模式生图模板优先读 prompts/custom_image_prompt.md(可编辑),
# 其次 config.custom.prompt_pieces,最后回退代码内置默认。
# 自定义模式不再使用负向提示词(约束已内置进模板),不再读取 custom_negative_prompt.md。
custom_ip_tpl = _read_custom_prompt_md("custom_image_prompt.md") \
or (pp.get("image_prompt_template") or "").strip() or CUSTOM_IMAGE_PROMPT_TEMPLATE
briefs: List[Dict[str, Any]] = []
for r in screened:
@@ -122,15 +132,20 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
llm_ip = (r.get("image_prompt") or "").strip()
if r.get("source") == "pinterest" and llm_ip:
delta = (r.get("delta") or "").strip()
if r.get("source") == "pinterest":
if bool(state.get("custom_mode")):
# —— 自定义模式:固定模板(前缀 + 分析 image_prompt),负向用固定文本 ——
# 模板含防复制/防服装约束,不再追加 print_suffix;负向写入 composite_negative
# compose 生图时作为负向参数传给图像后端。
prompts["image_prompt"] = custom_ip_tpl.replace("{image_prompt}", llm_ip)
prompts["composite_negative"] = custom_neg
print(f"[prompt] 自定义模式按固定模板拼 image_prompt(含固定负向): 「{r['topic']}")
else:
# —— 自定义模式:固定模板(前缀 + 分析 image_prompt + delta 改动指令),负向留空 ——
# 新分析模板只产出 delta(无 image_prompt),生图模板也只引用 {delta};
# 只要 delta 或 image_prompt 任一存在即可装配,不再使用负向提示词
# (约束已全部内置进模板),composite_negative 置空避免后端追加 Negative 段。
if llm_ip or delta:
prompts["image_prompt"] = (
custom_ip_tpl.replace("{delta}", delta).replace("{image_prompt}", llm_ip)
)
prompts["composite_negative"] = ""
print(f"[prompt] 自定义模式按固定模板拼 image_prompt(含 delta 改动指令,无负向): 「{r['topic']}")
elif llm_ip:
# —— Pinterest 参考模式:跳过四要素模板,按 3 段结构拼 image_prompt ——
# ① image_prompt(分析模型产出) + 固定输出形态后缀 PINTEREST_PRINT_SUFFIX
# ② 拼写锁定句 SPELLING_RULE(仅当 LLM image_prompt 已含引号文字段时)
+5 -3
View File
@@ -164,7 +164,7 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
try:
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
url = upload_to_oss(oss_cfg, compressed,
build_oss_key(country, ts, code, _gen_rand4()))
build_oss_key(country, ts, code, _gen_rand4(), local=compressed))
if url:
urls.append(url)
r["seed_shot_urls"] = urls
@@ -175,8 +175,10 @@ def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
return {"spu_code": r.get("spu_code"), "sku_code": r.get("sku_code"),
"paths": paths, "urls": urls}
# 并发:每个产品一个独立线程默认);config.seed_shot.concurrency 可覆盖
seed_concurrency = int((config.get("seed_shot") or {}).get("concurrency") or 0) or len(products) or 1
# 并发:每个产品一个独立线程默认上限 5(与 product 一致,避免多产品压垮图像网关),
# config.seed_shot.concurrency 可显式覆盖(含 0/留空→默认 5)
seed_concurrency = int((config.get("seed_shot") or {}).get("concurrency") or 0) or 5
seed_concurrency = min(seed_concurrency, len(products)) if products else 1
if len(products) > 1:
print(f"[seed_shot] 并发 {seed_concurrency} 生成种草图({len(products)} 个产品)")
with concurrent.futures.ThreadPoolExecutor(max_workers=seed_concurrency) as _ex:
+3 -1
View File
@@ -101,7 +101,8 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
from graph.template_export import (export_products, _resolve_component_map,
_resolve_season_map, _resolve_pattern_map,
_resolve_target_audience_map, _resolve_kids_type_map)
_resolve_target_audience_map, _resolve_kids_type_map,
_resolve_kids_pattern_map)
tdir = (pcfg.get("template_dir") or "").strip() or str(Path(tp).parent)
prod_dir = output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
@@ -159,6 +160,7 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
pattern_map=_resolve_pattern_map(config),
target_audience_map=_resolve_target_audience_map(config),
kids_type_map=_resolve_kids_type_map(config),
kids_pattern_map=_resolve_kids_pattern_map(config),
)
for r in products:
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():
+10 -2
View File
@@ -84,7 +84,15 @@ def upload_to_oss(cfg: dict, local_path: str, object_key: str) -> Optional[str]:
return None
def build_oss_key(country: str, timestamp: str, code: str, rand4: str, ext: str = ".jpg") -> str:
"""对象 key{国家}/{时间戳}/{货号}_{4位随机}.jpg(如 GB/20260820150945/DG000_Ab3x.jpg)。"""
def build_oss_key(country: str, timestamp: str, code: str, rand4: str,
ext: str = "", local: Optional[str] = None) -> str:
"""对象 key{国家}/{时间戳}/{货号}_{4位随机}.jpg(如 GB/20260820150945/DG000_Ab3x.jpg)。
ext 未显式指定时,优先从 local(实际待上传文件)的真实后缀推导——
文件可能是 png/jpg 而非固定 jpg;local 也未提供时回退 ".jpg"
"""
if not ext.strip() and local:
ext = Path(local).suffix if Path(local).suffix else ".jpg"
ext = ext.strip() or ".jpg"
safe = lambda s: "".join(c for c in (s or "") if c.isalnum() or c in "-_").strip()
return f"{safe(country)}/{safe(timestamp)}/{safe(code)}_{safe(rand4)}{ext}"
+3
View File
@@ -189,6 +189,9 @@ def list_valid_images(folder: Any) -> List[Path]:
return []
out: List[Path] = []
for f in sorted(d.rglob("*")):
# 跳过隐藏/缓存子目录(如 .compressed 压缩缓存),避免把压缩产物当有效图片
if any(part.startswith(".") for part in f.relative_to(d).parts[:-1]):
continue
if f.is_file() and f.suffix.lower() in _IMG_EXTS:
out.append(f)
return out
+40 -16
View File
@@ -20,6 +20,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from graph.paths import project_root, runtime_root
from graph.validate import ThreadSafeErrors
class PinterestPipeline:
@@ -83,6 +84,12 @@ class PinterestPipeline:
self._country_config = state.get("country_config") or {}
# 补充重试次数:设计生成失败/侵权时,从图池取新图重新分析的最多尝试次数
self._supply_attempts = int((self.config.get("pinterest") or {}).get("supply_attempts", 3))
# 自定义模式标志:pinterest_init 先于 pinterest_custom_load 执行(state.custom_mode 尚未置位),
# 故从 config 判断(mode=custom 或 custom_image_dir 非空),供 generate_design 决定是否追加原创化指令
_pcfg = self.config.get("pinterest") or {}
self.custom_mode = bool(state.get("custom_mode")) \
or (str(_pcfg.get("mode") or "").strip().lower() == "custom") \
or bool(str(_pcfg.get("custom_image_dir") or "").strip())
# 400 计数(per 种子词):多模态 + 生图模型合计,超限放弃当前种子词
self._err400_lock = threading.Lock()
@@ -470,6 +477,9 @@ class PinterestPipeline:
# 单条简报完整链路:设计 → 三合一 → OSS → 种草图
# ------------------------------------------------------------------ #
def _process_one(self, brief: Dict[str, Any], idx: int) -> None:
# 每个 worker 用独立线程安全错误收集器,后台调用链(generate_design/_process_spu
# 的 append 与 finish() 的 list(self._errors) 读取不再互相竞态,结束时统一合并。
errs = ThreadSafeErrors()
try:
if self.is_fatal_503_aborted():
return
@@ -480,7 +490,7 @@ class PinterestPipeline:
spu, skus = self._worklist[idx]
img_code = f"{self._prefix}{idx:03d}"
# 1) 生成设计(compose)——直接按货号命名 designs/{img_code}_design.png
design_path = self._gen_design(brief, img_code)
design_path = self._gen_design(brief, img_code, errs)
if self.is_fatal_503_aborted():
return
if self.is_400_aborted():
@@ -496,7 +506,7 @@ class PinterestPipeline:
if new_brief is None:
return
brief = new_brief
design_path = self._gen_design(brief, img_code)
design_path = self._gen_design(brief, img_code, errs)
if self.is_fatal_503_aborted():
return
if design_path:
@@ -505,7 +515,7 @@ class PinterestPipeline:
return
brief["design_path"] = design_path
# 2) 三合一(product)——同一货号;task_idx=本次简报序号,模特按任务独立随机
prod = self._process_spu(brief, spu, skus, img_code, design_path, task_idx=idx)
prod = self._process_spu(brief, spu, skus, img_code, design_path, task_idx=idx, errors=errs)
if self.is_fatal_503_aborted():
return
if not prod:
@@ -532,6 +542,9 @@ class PinterestPipeline:
"message": f"简报 {idx} 处理失败: {e}", "trace": ""})
print(f"[pinterest_pipeline] 简报 {idx} 处理失败: {e}")
finally:
# 合并本 worker 收集的错误到共享 _errors(持锁,供 finish() 安全读取)
with self._errors_lock:
self._errors.extend(list(errs))
with self._cond:
self._in_flight = max(0, self._in_flight - 1)
self._cond.notify_all()
@@ -571,7 +584,7 @@ class PinterestPipeline:
print(f"[pinterest_pipeline] 读回落盘产品失败: {e}")
return out
def _gen_design(self, brief: Dict[str, Any], img_code: str) -> Optional[str]:
def _gen_design(self, brief: Dict[str, Any], img_code: str, errors: ThreadSafeErrors) -> Optional[str]:
if self._ib is None:
return None
try:
@@ -587,18 +600,18 @@ class PinterestPipeline:
self.record_503()
self.abort_unfinished()
return generate_design(self._ib, brief, design_dir, img_code, self._errors,
return generate_design(self._ib, brief, design_dir, img_code, errors,
on_400=_on_400, on_503=_on_503,
size=str((self.config.get("compose") or {}).get("design_size") or "1024x1024"))
size=str((self.config.get("compose") or {}).get("design_size") or "1024x1024"),
custom_mode=self.custom_mode)
except Exception as e: # noqa: BLE001
if self.is_fatal_503(e):
self.record_503()
self.abort_unfinished()
return None
with self._errors_lock:
self._errors.append({"node": "compose", "type": type(e).__name__,
"message": f"设计稿生成失败 {brief.get('topic', '')}: {e}",
"trace": ""})
errors.append({"node": "compose", "type": type(e).__name__,
"message": f"设计稿生成失败 {brief.get('topic', '')}: {e}",
"trace": ""})
return None
def _supply_from_pool(self, reason: str) -> Optional[Dict[str, Any]]:
@@ -677,18 +690,29 @@ class PinterestPipeline:
"model_kind": src.get("kind", "model")}
def _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str,
design_path: str, task_idx: int = 0) -> Optional[Dict[str, Any]]:
design_path: str, task_idx: int = 0, errors: Optional[ThreadSafeErrors] = None) -> Optional[Dict[str, Any]]:
from graph.nodes.product_node import _process_spu as _ps
prod_dir = self.output_dir / "product"
prod_dir.mkdir(parents=True, exist_ok=True)
# 设计稿已按货号命名(designs/{img_code}_design.png),直接复用,无需再拷贝
brief = dict(brief)
brief["design_path"] = design_path
# 标题生成用:模版「类目」完整路径(注入标题提示词 {category_path}),懒加载缓存一次
if not hasattr(self, "_category_path"):
self._category_path = ""
try:
_tp = str((self.config.get("product") or {}).get("template_path") or "").strip()
if _tp:
from graph.seed_shot import read_template_category
self._category_path = read_template_category(_tp)
except Exception as _e: # noqa: BLE001
print(f"[pinterest_pipeline] 读取模版类目路径失败(标题 category_path 留空): {_e}")
r = _ps(self._db_path, self._basemap_root, self._material_root, self._category,
prod_dir, brief, self._ib, spu, skus, self.config.get("product") or {},
self._errors, design_path, self._title_backend, self.country,
errors, design_path, self._title_backend, self.country,
img_code=img_code,
on_503=lambda: (self.record_503(), self.abort_unfinished()),
on_503=lambda: (self.record_503(), self.abort_unfinished()),
category_path=self._category_path,
**self._model_source(task_idx, spu))
if r:
r["img_code"] = img_code
@@ -710,7 +734,7 @@ class PinterestPipeline:
continue
try:
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(self.country, self.task_timestamp, base_code, _gen_rand4())
key = build_oss_key(self.country, self.task_timestamp, base_code, _gen_rand4(), local=compressed)
url = upload_to_oss(self._oss_cfg, compressed, key)
if url:
r[f"{kind}_url"] = url
@@ -728,7 +752,7 @@ class PinterestPipeline:
continue
try:
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
key = build_oss_key(self.country, self.task_timestamp, base_code, _gen_rand4())
key = build_oss_key(self.country, self.task_timestamp, base_code, _gen_rand4(), local=compressed)
url = upload_to_oss(self._oss_cfg, compressed, key)
if url:
cc["url"] = url
@@ -796,7 +820,7 @@ class PinterestPipeline:
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
url = upload_to_oss(self._oss_cfg, compressed,
build_oss_key(self.country, self.task_timestamp,
code, _gen_rand4()))
code, _gen_rand4(), local=compressed))
if url:
urls.append(url)
r["seed_shot_urls"] = urls
+1
View File
@@ -30,6 +30,7 @@ class AgentState(TypedDict, total=False):
seed_words: Dict[str, Any] # seed 产出:动态种子词(含 llm_style_seeds / llm_related_seeds
# —— Pinterest 参考模式(独立于 Google Trends 采集链路)——
custom_mode: bool # 自定义模式标志(pinterest_custom_load 置 TrueLangGraph 仅合并声明键,必须显式声明否则丢失)
pinterest_search_terms: List[str] # pinterest_search 产出:LLM 生成的搜索词
pinterest_images: Dict[str, List[str]] # pinterest_scrape 产出:搜索词 → 爬取图片路径列表
pinterest_briefs: List[Dict[str, Any]] # pinterest_analyze 产出:LLM 分析图片的原始设计简报
+91 -2
View File
@@ -108,6 +108,48 @@ def _map_pattern(value: Any, gender: Optional[str], pattern_map: Optional[Dict[s
return mapping.get(v, value)
# 女童图案值字典映射:db pattern 值 → 女童模板下拉框选项(男童保留原值)。
# 女童模板遇到 pattern="印花" 时映射为「卡通」;该字典为「内置默认」,
# 运行时可被 config.yaml 顶层 pattern_map.girl_kids 覆盖(缺失/留空回退这里)。
_PATTERN_KIDS_MAP = {
"girl_kids": {"印花": "卡通"},
}
def _resolve_kids_pattern_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置女童图案映射(config.pattern_map.girl_kids),缺失/留空回退内置默认。
仅作用于 spu.pattern 字段(gender=girl_kids 时映射;男童/成人/None 保留原值)。
"""
if not config:
return {g: dict(m) for g, m in _PATTERN_KIDS_MAP.items()}
kids = None
try:
pm = config.get("pattern_map") or {}
kids = pm if isinstance(pm, dict) else {}
except Exception: # noqa: BLE001
kids = {}
if not isinstance(kids, dict) or not kids:
return {g: dict(m) for g, m in _PATTERN_KIDS_MAP.items()}
merged = {g: dict(m) for g, m in _PATTERN_KIDS_MAP.items()}
for g, m in kids.items():
if isinstance(m, dict):
merged.setdefault(str(g), {}).update(
{str(k): v for k, v in m.items() if v not in (None, "")})
return merged
def _map_kids_pattern(value: Any, gender: Optional[str],
kids_pattern_map: Optional[Dict[str, Any]] = None) -> Any:
"""图案值按童装性别映射(仅 spu.pattern 字段):gender 为 girl_kids 时
查女童图案映射表(默认内置、可由 config 覆盖,找不到保留原值),如「印花」→「卡通」。
男童/成人保留原值。"""
if gender != "girl_kids" or value is None:
return value
mapping = kids_pattern_map if kids_pattern_map is not None else _PATTERN_KIDS_MAP
return mapping.get("girl_kids", {}).get(str(value).strip(), value)
def _resolve_season_map(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""从 config 读取可配置季节映射(config.season_map.female),缺失/留空回退内置默认。
@@ -236,8 +278,9 @@ def _size_rank(size) -> tuple:
return (9, 0, "")
if s in ("ONESIZE", "ONE SIZE", "FREESIZE", "FREE SIZE", "均码"):
return (8, 0, s)
if s == "XS":
return (2, 0, s)
m = re.fullmatch(r"(X+)S", s) # XS/XXS/XXXS(童装常见 XXS
if m:
return (2, -len(m.group(1)), s)
if s in ("S", "M", "L"):
return (2, {"S": 1, "M": 2, "L": 3}[s], s)
m = re.fullmatch(r"(X+)(L)", s) # XL/XXL/XXXL/XXXXL/XXXXXL
@@ -409,12 +452,15 @@ def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_
def _build_spu_row(spu: Dict[str, Any], spu_code: str,
color: Optional[str] = None,
fabric_headers: Optional[List[str]] = None,
details_headers: Optional[List[str]] = None,
target_audience_headers: Optional[List[str]] = None,
gender: Optional[str] = None,
component_map: Optional[Dict[str, Any]] = None,
season_map: Optional[Dict[str, Any]] = None,
pattern_map: Optional[Dict[str, Any]] = None,
target_audience_map: Optional[Dict[str, Any]] = None,
kids_type_map: Optional[Dict[str, Any]] = None,
kids_pattern_map: Optional[Dict[str, Any]] = None,
oss_code: str = "",
model_info: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""构造一行 SPU(固定字段:SKC货号=当前生成货号、风格=休闲、商品产地=中国大陆、产地省份=广东省;多颜色时用色值区分)。
@@ -431,6 +477,8 @@ def _build_spu_row(spu: Dict[str, Any], spu_code: str,
"基础信息-商品层级": "spu",
"SKC货号": oss_code or spu_code, # SKC货号 = 当前生成货号 oss_code(无则回退 spu_code
"风格": "休闲", # style 路由为"休闲"(用户要求)
"风格1": "休闲", # 新增:匹配到「风格1」列填休闲
"风格2": "运动", # 新增:匹配到「风格2」列填运动
"商品产地": "中国大陆", # 所有国家统一「中国大陆」,不读经营站点/不做字典匹配(用户要求)
"产地省份": "广东省", # 新增:精确匹配「产地省份」列,统一填「广东省」(用户要求)
"款式来源": "现货款", # SPU商品属性-款式来源 统一填「现货款」(用户要求)
@@ -445,6 +493,7 @@ def _build_spu_row(spu: Dict[str, Any], spu_code: str,
v = _map_season(v, gender, season_map)
elif dbk == "pattern":
v = _map_pattern(v, gender, pattern_map)
v = _map_kids_pattern(v, gender, kids_pattern_map) # 女童:印花→卡通(男童/成人保留)
elif dbk == "target_audience":
v = _map_target_audience(v, gender, target_audience_map)
if v not in (None, ""):
@@ -453,6 +502,15 @@ def _build_spu_row(spu: Dict[str, Any], spu_code: str,
if fabric not in (None, "") and fabric_headers:
for h in fabric_headers:
row[h] = fabric
details = spu.get("details")
if details not in (None, "") and details_headers:
for h in details_headers:
row[h] = details
ta = spu.get("target_audience")
if ta not in (None, "") and target_audience_headers:
ta = _map_target_audience(ta, gender, target_audience_map)
for h in target_audience_headers:
row[h] = ta
if model_info:
if model_info.get("model"):
row["试穿模特"] = model_info["model"]
@@ -489,6 +547,16 @@ def _find_fabric_headers(router) -> List[str]:
return [str(k) for k in router.column_map if "面料弹性" in str(k)]
def _find_details_headers(router) -> List[str]:
"""定位「细节」扩展列(细节1/细节2/细节3 精确列名匹配);匹配到多个时全部填 spu.details。"""
return [str(k) for k in router.column_map if str(k).strip() in ("细节1", "细节2", "细节3")]
def _find_target_audience_headers(router) -> List[str]:
"""定位「适用人群」扩展列(适用人群1 精确列名匹配);匹配到则填 target_audience 映射后的值。"""
return [str(k) for k in router.column_map if str(k).strip() in ("适用人群1",)]
def _find_sa_size_headers(router) -> List[str]:
"""定位所有「沙特阿拉伯码」列(尺码表-沙特阿拉伯码…模糊匹配);匹配到多个时全部填 sku.size。"""
return [str(k) for k in router.column_map if "沙特阿拉伯码" in str(k)]
@@ -919,6 +987,8 @@ def _insert_product_block(
seed_shot_urls: Optional[List[str]] = None,
bust_headers: Optional[List[str]] = None,
fabric_headers: Optional[List[str]] = None,
details_headers: Optional[List[str]] = None,
target_audience_headers: Optional[List[str]] = None,
sa_size_headers: Optional[List[str]] = None,
gender: Optional[str] = None,
component_map: Optional[Dict[str, Any]] = None,
@@ -926,6 +996,7 @@ def _insert_product_block(
pattern_map: Optional[Dict[str, Any]] = None,
target_audience_map: Optional[Dict[str, Any]] = None,
kids_type_map: Optional[Dict[str, Any]] = None,
kids_pattern_map: Optional[Dict[str, Any]] = None,
suggested_price_ratio: float = 0.0,
suggested_price_headers: Optional[List[str]] = None,
suggested_unit_headers: Optional[List[str]] = None,
@@ -965,10 +1036,13 @@ def _insert_product_block(
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
block_rows.append(router.insert(
_build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
details_headers=details_headers,
target_audience_headers=target_audience_headers,
gender=gender, component_map=component_map,
season_map=season_map, pattern_map=pattern_map,
target_audience_map=target_audience_map,
kids_type_map=kids_type_map,
kids_pattern_map=kids_pattern_map,
oss_code=oss_code, model_info=model_info),
match="exact",
))
@@ -1008,10 +1082,13 @@ def _insert_product_block(
# 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
block_rows.append(router.insert(
_build_spu_row(spu, spu_code, fabric_headers=fabric_headers,
details_headers=details_headers,
target_audience_headers=target_audience_headers,
gender=gender, component_map=component_map,
season_map=season_map, pattern_map=pattern_map,
target_audience_map=target_audience_map,
kids_type_map=kids_type_map,
kids_pattern_map=kids_pattern_map,
oss_code=oss_code, model_info=model_info), match="exact"))
multi_variant = len(skus_by_color) > 1
for ci, (sc, skus) in enumerate(skus_by_color):
@@ -1077,6 +1154,7 @@ def export_product(
pattern_map: Optional[Dict[str, Any]] = None,
target_audience_map: Optional[Dict[str, Any]] = None,
kids_type_map: Optional[Dict[str, Any]] = None,
kids_pattern_map: Optional[Dict[str, Any]] = None,
) -> Path:
"""生成商品上传"已填写"模板(支持多产品合并到同一文件)。
@@ -1106,6 +1184,8 @@ def export_product(
price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填)
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填)
details_headers = _find_details_headers(router) # 细节扩展列(细节1/细节2/细节3…精确匹配)
target_audience_headers = _find_target_audience_headers(router) # 适用人群扩展列(适用人群1…精确匹配)
sa_size_headers = _find_sa_size_headers(router) # 沙特阿拉伯码列(尺码表-沙特阿拉伯码…检测到才填)
gender = _template_gender(template_path) # 类目含「男」→male /「女」→female(成分值映射用)
suggested_price_headers = _find_suggested_price_headers(router) # 建议售价列(不含单位)
@@ -1124,6 +1204,8 @@ def export_product(
seed_shot_urls=seed_shot_urls,
bust_headers=bust_headers,
fabric_headers=fabric_headers,
details_headers=details_headers,
target_audience_headers=target_audience_headers,
sa_size_headers=sa_size_headers,
gender=gender,
component_map=component_map,
@@ -1131,6 +1213,7 @@ def export_product(
pattern_map=pattern_map,
target_audience_map=target_audience_map,
kids_type_map=kids_type_map,
kids_pattern_map=kids_pattern_map,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,
@@ -1160,6 +1243,7 @@ def export_products(
pattern_map: Optional[Dict[str, Any]] = None,
target_audience_map: Optional[Dict[str, Any]] = None,
kids_type_map: Optional[Dict[str, Any]] = None,
kids_pattern_map: Optional[Dict[str, Any]] = None,
) -> Path:
"""批量合并导出:所有产品一次性写入同一模板,只打开/保存一次。
@@ -1176,6 +1260,8 @@ def export_products(
price_headers = _find_price_headers(router) # 申报价格列(美站/日站/英站…模糊匹配,多个全填)
bust_headers = _find_bust_headers(router) # 胸围列(基码表-胸围(cm)/胸围全围(cm)…多个全填)
fabric_headers = _find_fabric_headers(router) # 面料弹性列(SPU商品属性-面料弹性…检测到才填)
details_headers = _find_details_headers(router) # 细节扩展列(细节1/细节2/细节3…精确匹配)
target_audience_headers = _find_target_audience_headers(router) # 适用人群扩展列(适用人群1…精确匹配)
sa_size_headers = _find_sa_size_headers(router) # 沙特阿拉伯码列(尺码表-沙特阿拉伯码…检测到才填)
gender = _template_gender(template_path) # 类目含「男」→male /「女」→female(成分值映射用)
suggested_price_headers = _find_suggested_price_headers(router) # 建议售价列(不含单位)
@@ -1202,6 +1288,8 @@ def export_products(
seed_shot_urls=r.get("seed_shot_urls"),
bust_headers=bust_headers,
fabric_headers=fabric_headers,
details_headers=details_headers,
target_audience_headers=target_audience_headers,
sa_size_headers=sa_size_headers,
gender=gender,
component_map=component_map,
@@ -1209,6 +1297,7 @@ def export_products(
pattern_map=pattern_map,
target_audience_map=target_audience_map,
kids_type_map=kids_type_map,
kids_pattern_map=kids_pattern_map,
suggested_price_ratio=suggested_price_ratio,
suggested_price_headers=suggested_price_headers,