POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底
- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""流水线节点集合。"""
|
||||
from .compose_node import compose_node
|
||||
from .fetch_node import fetch_node
|
||||
from .filter_node import filter_node
|
||||
from .oss_upload_node import oss_upload_node
|
||||
from .product_node import product_node
|
||||
from .prompt_node import prompt_node
|
||||
from .score_node import score_node
|
||||
from .screen_node import screen_node
|
||||
from .seed_node import seed_node
|
||||
from .seed_shot_node import seed_shot_node
|
||||
from .template_export_node import template_export_node
|
||||
|
||||
__all__ = [
|
||||
"fetch_node",
|
||||
"filter_node",
|
||||
"score_node",
|
||||
"screen_node",
|
||||
"prompt_node",
|
||||
"product_node",
|
||||
"compose_node",
|
||||
"seed_node",
|
||||
"oss_upload_node",
|
||||
"seed_shot_node",
|
||||
"template_export_node",
|
||||
]
|
||||
@@ -0,0 +1,194 @@
|
||||
"""节点 5.5/6:生成印花设计稿 + 导出简报包(compose)。
|
||||
|
||||
流程位置:prompt_build → compose → product(compose 在 product 之前)。
|
||||
职责��
|
||||
1. 生成纯印花设计稿:对前 N 个 safe 简报(N=config.compose.design_count,默认 1),
|
||||
用 image_prompt 调图像后端 generate()(白底、可直接打印),产物存 output/<country>/designs/,
|
||||
设计稿路径写回 brief.design_path,并汇总返回 designs 列表供 product 节点使用(图2)。
|
||||
2. 导出简报包:design_briefs.json/md、composite_prompts.json/md、report.md。
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"}
|
||||
|
||||
|
||||
def _build_briefs_md(briefs: List[Dict[str, Any]], generated_at: str) -> str:
|
||||
lines = [
|
||||
"# POD 印花设计简报(LLM 合规筛选 + 生图提示词)",
|
||||
"",
|
||||
f"- 生成时间: {generated_at}",
|
||||
f"- 通过筛选: {len(briefs)} 条",
|
||||
"",
|
||||
"## 一、安全设计清单(按综合分排序)",
|
||||
"",
|
||||
"| 排名 | 国家 | 热点词 | 类别 | 风险 | 设计概念 |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for i, r in enumerate(briefs, 1):
|
||||
flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level"))
|
||||
lines.append(
|
||||
f"| {i} | {r.get('country','')} | {r.get('topic','')} | {r.get('design_category','')} | {flag} | {r.get('concept','')} |"
|
||||
)
|
||||
lines += ["", "## 二、设计要素 + 封装提示词", ""]
|
||||
lines.append("> 工作流:① `image_prompt` = 印花设计稿(白底,单独生图);② 上传平铺衣服底图(图1)后,")
|
||||
lines.append("> 用 `composite_prompt` + 图1 经 img2img 把设计印到衣服;规则写死:保留衣服、胸前居中印花、真实丝网质感。")
|
||||
lines.append("")
|
||||
for i, r in enumerate(briefs, 1):
|
||||
flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level"))
|
||||
lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')} ({flag})")
|
||||
lines.append(f"- 类别: {r.get('design_category','')}")
|
||||
lines.append(f"- 设计要素: 主体=「{r.get('motif','')}」 | 风格=「{r.get('art_style','')}」 | 配色=「{r.get('color_palette','')}」 | 构图=「{r.get('composition','')}」")
|
||||
lines.append(f"- 概念: {r.get('concept','')}")
|
||||
if r.get("risk_reasons"):
|
||||
lines.append(f"- 风险提示: {'; '.join(r['risk_reasons'])}")
|
||||
if r.get("design_path"):
|
||||
lines.append(f"- **设计稿**: {r['design_path']}")
|
||||
lines.append(f"- **设计稿 Prompt (image_prompt)**: {r.get('image_prompt','')}")
|
||||
lines.append(f"- **印到底图 Prompt (composite_prompt)**: {r.get('composite_prompt','')}")
|
||||
lines.append(f"- **Composite Negative**: {r.get('composite_negative','')}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_composite_md(briefs: List[Dict[str, Any]]) -> str:
|
||||
lines = [
|
||||
"# 封装提示词包(印到平铺衣服底图 图1)",
|
||||
"",
|
||||
f"- 共 {len(briefs)} 条,每条含 `composite_prompt`(印图指令)+ `composite_negative`。",
|
||||
"- 用法:将你的平铺衣服参考图作为图1,连同 `composite_prompt` 送入任意 img2img / inpaint 模型。",
|
||||
"",
|
||||
]
|
||||
for i, r in enumerate(briefs, 1):
|
||||
lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')}")
|
||||
lines.append(f"- composite_prompt: {r.get('composite_prompt','')}")
|
||||
lines.append(f"- composite_negative: {r.get('composite_negative','')}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_report_md(state: Dict[str, Any]) -> str:
|
||||
country = state.get("country", "")
|
||||
stats = state.get("stats") or {}
|
||||
errors = state.get("errors") or []
|
||||
lines = [
|
||||
f"# POD 热点抓取报告 - {country}",
|
||||
"",
|
||||
f"- 生成时间: {time.strftime('%Y-%m-%dT%H:%M:%S')}",
|
||||
"",
|
||||
"## 各阶段统计",
|
||||
"",
|
||||
"| 阶段 | 指标 |",
|
||||
"|---|---|",
|
||||
]
|
||||
for k, v in stats.items():
|
||||
lines.append(f"| {k} | {v} |")
|
||||
lines += ["", "## 兜底错误记录(节点级 fallback 捕获)", ""]
|
||||
if errors:
|
||||
for e in errors:
|
||||
lines.append(f"- [{e.get('node')}] {e.get('type')}: {e.get('message')}")
|
||||
else:
|
||||
lines.append("- 无(全部节点正常)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@with_fallback("compose")
|
||||
def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
briefs: List[Dict[str, Any]] = state.get("briefs") or []
|
||||
output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录)
|
||||
config = state["config"]
|
||||
country = state.get("country", "")
|
||||
|
||||
generated_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# 1) design_briefs.json(缓存 → 根目录,不进时间戳任务文件夹)
|
||||
(cache_dir / "design_briefs.json").write_text(
|
||||
json.dumps({"generated_at": generated_at, "total": len(briefs), "design_briefs": briefs},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# 2) design_briefs.md
|
||||
(cache_dir / "design_briefs.md").write_text(
|
||||
_build_briefs_md(briefs, generated_at), encoding="utf-8")
|
||||
|
||||
# 3) composite_prompts.json / .md
|
||||
(cache_dir / "composite_prompts.json").write_text(
|
||||
json.dumps({"generated_at": generated_at, "total": len(briefs), "composite_prompts": briefs},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
(cache_dir / "composite_prompts.md").write_text(
|
||||
_build_composite_md(briefs), encoding="utf-8")
|
||||
|
||||
# 4) report.md(本次任务报告 → 产物目录)
|
||||
(output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8")
|
||||
|
||||
# 5) 生成纯印花设计稿(图2):前 N 个 safe 简报用 image_prompt 文生图
|
||||
designs: List[Dict[str, Any]] = []
|
||||
compose_cfg = config.get("compose") or {}
|
||||
backend_name = compose_cfg.get("backend", "")
|
||||
ib = None
|
||||
if backend_name:
|
||||
try:
|
||||
from graph.backends import get_image_backend
|
||||
ib = get_image_backend(backend_name)
|
||||
if ib is not None:
|
||||
ib.bind_config(compose_cfg)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[compose] 图像后端 {backend_name} 不可用: {e}")
|
||||
if ib is None:
|
||||
print("[compose] 未配置 compose.backend(openai/mock),跳过印花设计稿生成。")
|
||||
else:
|
||||
# 设计稿覆盖所有简报(含 review):每个热点一张设计,避免 review 热点无设计
|
||||
# 导致 product 回退生成重复占位图;风险由 assign 层(allow_review)控制是否分配
|
||||
safe_briefs = briefs
|
||||
design_count = int(compose_cfg.get("design_count", 1))
|
||||
# 联动总任务数:每个产品一张设计 → 生成 扩展后 spu_tasks 总数 张设计
|
||||
task_n = int(len((state.get("config") or {}).get("product", {}).get("spu_tasks") or []))
|
||||
if task_n > design_count:
|
||||
design_count = task_n
|
||||
design_dir = output_dir / "designs"
|
||||
design_dir.mkdir(exist_ok=True)
|
||||
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _gen_one(i: int, b: Dict[str, Any]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
|
||||
try:
|
||||
img_prompt = sanitize_image_prompt(b.get("image_prompt", ""))
|
||||
img_prompt = ensure_rebrand_hint(b, img_prompt) # review → 原创化魔改引导
|
||||
out_path = ib.generate(
|
||||
img_prompt,
|
||||
str(design_dir / f"{country}_{i:02d}_design.png"),
|
||||
b.get("composite_negative", ""),
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
return i, b, out_path, None
|
||||
except Exception as e: # noqa: BLE001
|
||||
return i, b, None, e
|
||||
|
||||
targets = [(i, b) for i, b in enumerate(safe_briefs[:design_count], 1)]
|
||||
# 并发生成:每张设计一个线程(并行调图像网关),数量多时不串行等待
|
||||
workers = max(1, min(len(targets), int((config.get("compose") or {}).get("design_workers", 5))))
|
||||
print(f"[compose] 并发生成 {len(targets)} 张设计稿({workers} 线程)…")
|
||||
with ThreadPoolExecutor(max_workers=workers) as _ex:
|
||||
_futs = [_ex.submit(_gen_one, i, b) for i, b in targets]
|
||||
for _f in as_completed(_futs):
|
||||
i, b, out_path, err = _f.result()
|
||||
if err is not None:
|
||||
print(f"[compose] 设计稿生成失败 {b.get('topic', '')}: {err}")
|
||||
state.setdefault("errors", []).append({
|
||||
"node": "compose", "type": type(err).__name__,
|
||||
"message": f"设计稿生成失败 {b.get('topic','')}: {err}", "trace": ""})
|
||||
else:
|
||||
b["design_path"] = out_path
|
||||
designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path})
|
||||
print(f"[compose] 印花设计稿已生成: {out_path}")
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["compose"] = {"written": len(briefs), "designs": len(designs), "output_dir": str(output_dir)}
|
||||
return {"composite": briefs, "designs": designs, "stats": stats,
|
||||
"errors": state.get("errors") or []}
|
||||
@@ -0,0 +1,55 @@
|
||||
"""节点 1/6:抓取(fetch)。
|
||||
|
||||
按 config.sources 启用各可插拔数据源,汇总统一格式行。
|
||||
单源失败不影响其它源(内部逐个 try),整体再套 with_fallback 兜底。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.sources import get_source
|
||||
from graph.validate import validate_rows, with_fallback
|
||||
|
||||
|
||||
@with_fallback("fetch")
|
||||
def fetch_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
country = state["country"]
|
||||
config = state["config"]
|
||||
cc = state["country_config"]
|
||||
enabled = config.get("sources") or ["google_trends"]
|
||||
rows: List[Dict[str, Any]] = []
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
# 采集缓存优先:采集(fetch_keywords)成功后写入 output/<国>/collected_keywords.json,
|
||||
# 这里直接用(跳过 Google 重抓),避免重复撞限流;无缓存才走数据源抓取
|
||||
use_collected = (config.get("fetch") or {}).get("use_collected", True)
|
||||
if use_collected:
|
||||
try:
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
p = _Path(state.get("cache_dir") or state.get("output_dir", "")) / "collected_keywords.json"
|
||||
if p.exists():
|
||||
data = _json.loads(p.read_text(encoding="utf-8"))
|
||||
cached_rows = data.get("keywords") or []
|
||||
if cached_rows:
|
||||
rows = [dict(r) for r in cached_rows] # 已过滤去重的关键词
|
||||
print(f"[fetch] 使用采集缓存 {len(rows)} 条({country},跳过 Google 抓取)")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["fetch"] = {"raw_rows": len(rows), "sources": ["collected_cache"], "errors": 0}
|
||||
return {"raw_rows": rows, "errors": errors, "stats": stats}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[fetch] 读取采集缓存失败(回退数据源): {e}")
|
||||
|
||||
for name in enabled:
|
||||
try:
|
||||
src = get_source(name)
|
||||
rows.extend(src.fetch(country, cc, config))
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({
|
||||
"node": "fetch", "type": type(e).__name__,
|
||||
"message": f"source[{name}]: {e}", "trace": "",
|
||||
})
|
||||
print(f"[fetch] 数据源 {name} 失败(跳过): {e}")
|
||||
|
||||
rows = validate_rows(rows, "fetch")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["fetch"] = {"raw_rows": len(rows), "sources": enabled, "errors": len(errors)}
|
||||
return {"raw_rows": rows, "errors": errors, "stats": stats}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""节点 2/6:过滤(filter)。
|
||||
|
||||
三级过滤,全部带兜底、单级失败不影响其它级:
|
||||
1) 合规黑名单(全局 + 国家 extra)
|
||||
2) 真实人物(名单 + Firstname Lastname 模式,仅对 gt_trending 源,避免误删风格词)
|
||||
3) 设计相关性(剔除泛新闻/科技/赛事词)
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.scoring import apply_blacklist, filter_design_relevance, filter_person_names, filter_query_noise
|
||||
from graph.validate import validate_rows, with_fallback
|
||||
|
||||
|
||||
@with_fallback("filter")
|
||||
def filter_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rows: List[Dict[str, Any]] = state.get("raw_rows") or []
|
||||
config = state["config"]
|
||||
cc = state["country_config"]
|
||||
country = state.get("country", "")
|
||||
|
||||
# 黑名单:全局 + 国家专属
|
||||
bl = [str(b).lower() for b in (config.get("blacklist") or [])]
|
||||
bl += [str(b).lower() for b in (cc.get("extra_blacklist") or [])]
|
||||
bl = list(set(bl))
|
||||
|
||||
name_cfg = config.get("name_filter") or {}
|
||||
rel_cfg = config.get("relevance") or {}
|
||||
|
||||
dropped_total = 0
|
||||
|
||||
kept, dropped = apply_blacklist(rows, bl)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
if name_cfg.get("enabled", True):
|
||||
kept, dropped = filter_person_names(
|
||||
kept,
|
||||
extra_names=name_cfg.get("extra_names"),
|
||||
patterns=name_cfg.get("patterns"),
|
||||
exemptions=name_cfg.get("exemptions"),
|
||||
pattern_sources=set(name_cfg.get("pattern_sources") or ["gt_trending"]),
|
||||
)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
if rel_cfg.get("enabled", True):
|
||||
kept, dropped = filter_design_relevance(
|
||||
kept,
|
||||
drop_patterns=rel_cfg.get("drop_patterns"),
|
||||
keep_patterns=rel_cfg.get("keep_patterns"),
|
||||
)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
# ③ 新闻类热点(天气/灾害/政治/事故等突发新闻,非印花主题;按国家语言过滤)
|
||||
if kept:
|
||||
from graph.scoring import filter_news
|
||||
kept, dropped = filter_news(kept, country)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
# ④ 查询噪声(问句/命名清单/损坏碎片/模糊名词)—— 防止被 Mock 误标 safe
|
||||
qn_cfg = config.get("query_noise") or {}
|
||||
if qn_cfg.get("enabled", True):
|
||||
kept, dropped = filter_query_noise(kept, enabled=True)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
kept = validate_rows(kept, "filter")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["filter"] = {"kept": len(kept), "dropped": dropped_total}
|
||||
return {"filtered_rows": kept, "stats": stats}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""节点 7/7:压缩 + 上传阿里云 OSS(oss_upload)。
|
||||
|
||||
在 product 之后运行:把 product 生成的成品图(composite / printed / design / basemap)
|
||||
压缩为 3:4 / ≥1340×1785 / <2MB 的 JPEG,上传到 config.oss 图床。
|
||||
|
||||
上传 key(图床路径):{国家}/{任务时间戳}/{货号}_{4位随机}.jpg
|
||||
- 任务时间戳:任务开始记录(YYYYMMDDHHMMSS),state["task_timestamp"],缺失时取当前时间
|
||||
- 货号:用户自定义前缀(config.product.code_prefix,默认 DG)+ 3 位计数(000 起,最多 999)
|
||||
- 4 位随机:大小写英文 + 数字
|
||||
|
||||
压缩/上传均带兜底:单图失败不影响其它;未配置 oss 或 enabled=false 时静默跳过。
|
||||
"""
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
# design 设计稿是过程稿(不进模板),不压缩不上传;只传最终商品图
|
||||
KIND_ORDER = ["composite", "printed", "basemap"]
|
||||
MAX_CODE = 999 # 货号计数上限(000~998 共 999 张)
|
||||
|
||||
|
||||
def _gen_rand4() -> str:
|
||||
return "".join(random.choices(string.ascii_letters + string.digits, k=4))
|
||||
|
||||
|
||||
@with_fallback("oss_upload")
|
||||
def oss_upload_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
config = state["config"] or {}
|
||||
oss_cfg = config.get("oss") or {}
|
||||
country = state.get("country", "")
|
||||
|
||||
if not (oss_cfg.get("oss_bucket") and oss_cfg.get("oss_key_id")):
|
||||
print("[oss] 未配置 oss(config.oss),跳过压缩上传节点。")
|
||||
return {"oss": [], "stats": state.get("stats") or {}}
|
||||
if not bool(oss_cfg.get("enabled", True)):
|
||||
print("[oss] config.oss.enabled=false,跳过上传。")
|
||||
return {"oss": [], "stats": state.get("stats") or {}}
|
||||
|
||||
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
|
||||
|
||||
# 任务时间戳:任务开始记录;缺失则当前时间
|
||||
ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S"))
|
||||
# 货号前缀:config.product.code_prefix(默认 DG)
|
||||
prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip()
|
||||
# 序号从 state 续接(一次任务内跨多次节点调用不重号)
|
||||
seq = int(state.get("oss_seq") or 0)
|
||||
|
||||
uploaded: List[Dict[str, Any]] = []
|
||||
stats = dict(state.get("stats") or {})
|
||||
for r in products:
|
||||
spu = r.get("spu_code", "")
|
||||
sku = r.get("sku_code", "")
|
||||
for kind in KIND_ORDER:
|
||||
src = r.get(f"{kind}_path")
|
||||
if not src or not Path(src).exists():
|
||||
continue
|
||||
if seq >= MAX_CODE:
|
||||
print(f"[oss] 货号计数已达上限 999,停止上传后续图片({src})")
|
||||
break
|
||||
try:
|
||||
code = f"{prefix}{seq:03d}" # 货号:前缀 + 3 位计数(000 起)
|
||||
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
|
||||
key = build_oss_key(country, ts, code, _gen_rand4())
|
||||
url = upload_to_oss(oss_cfg, compressed, key)
|
||||
if url:
|
||||
r[f"{kind}_url"] = url
|
||||
r["oss_code"] = code
|
||||
uploaded.append({"spu_code": spu, "sku_code": sku, "kind": kind,
|
||||
"code": code, "url": url})
|
||||
seq += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[oss] 处理失败 {src}: {e}")
|
||||
|
||||
# 多色:color_composites 用于模板按颜色路由——首色用主图 url/code,额外色单独上传(独立货号)
|
||||
color_ups: List[Dict[str, Any]] = []
|
||||
comps = r.get("color_composites") or []
|
||||
if comps and r.get("composite_url"):
|
||||
color_ups.append({**comps[0], "url": r["composite_url"], "code": r.get("oss_code", "")})
|
||||
for cc in comps[1:]:
|
||||
src = cc.get("composite_path")
|
||||
if not src or not Path(src).exists():
|
||||
continue
|
||||
if seq >= MAX_CODE:
|
||||
print("[oss] 货号计数已达上限 999,停止上传颜色图")
|
||||
break
|
||||
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())
|
||||
url = upload_to_oss(oss_cfg, compressed, key)
|
||||
if url:
|
||||
cc["url"] = url
|
||||
cc["code"] = code
|
||||
color_ups.append(cc)
|
||||
uploaded.append({"spu_code": spu, "sku_code": cc.get("sku_code"),
|
||||
"kind": "composite_color", "code": code, "url": url})
|
||||
seq += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[oss] 颜色图上传失败 {src}: {e}")
|
||||
if color_ups:
|
||||
r["color_composites"] = color_ups
|
||||
|
||||
stats["oss"] = {"uploaded": len(uploaded), "timestamp": ts, "prefix": prefix, "seq": seq}
|
||||
return {"oss": uploaded, "product": products, "oss_seq": seq, "stats": stats}
|
||||
@@ -0,0 +1,584 @@
|
||||
"""节点 6.5:产品图生成(product)。
|
||||
|
||||
在 prompt_build 之后、compose 之前运行(prompt_build → product → compose):
|
||||
热点提示词 → SPU/颜色选品 → basemap 底图 → 纯印花设计稿 → 模特试穿合成图。
|
||||
产物写入 output/<country>/product/(底图拷贝 / *_design.png / *_model / *_composite.png / products.json)。
|
||||
|
||||
模板选择按 SPU.mark 驱动:
|
||||
mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1=模特实拍 / 图2=纯印花设计 / 图3=平铺底图)
|
||||
mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计 → 平铺服装图)
|
||||
|
||||
配置(config.yaml product 段):
|
||||
enabled 开关(默认 true)
|
||||
db_path SPU/SKU 数据库(默认 db/spu_sku.db,相对路径按运行根解析)
|
||||
basemap_dir 底图目录(默认 basemap)
|
||||
material_library_dir 模特图库(默认 material_library)
|
||||
model_category 模特品类子目录(T-shirt);为空/无图时取 material_library 第一个有图子目录
|
||||
brief_index 用第几个 safe 简报的提示词(0=第一个)
|
||||
spu_code / sku_code 指定款号/颜色编码(留空自动选第一个有本地底图的)
|
||||
spu_tasks [{"spu": "DG004", "skus": "DG004-BL01,..."}] 多款号批量选品(优先于 spu_code)
|
||||
spu_count 款号数量上限(0=不限;取任务清单前 N 个)
|
||||
spu_per_color true=每颜色一个 SPU 块;false=单 SPU 下挂所有颜色 SKU 变体
|
||||
backend 图像后端:openai(真生图,需 key) / mock(占位) / 留空=跳过生成仅存底图
|
||||
|
||||
缺底图/模特图时跳过对应步骤并提示,不中断流水线;多款号逐个处理,单个失败不影响其它。
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from graph.paths import project_root, runtime_root
|
||||
from graph.product import (
|
||||
find_basemap,
|
||||
find_first_model_folder,
|
||||
first_available_sku,
|
||||
list_colors,
|
||||
list_spus,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
_USED_LOCK = threading.Lock() # used_designs.json 并发写锁
|
||||
_MODEL_LOCK = threading.Lock() # 同款共用模特缓存并发锁
|
||||
_MODEL_CACHE: Dict[str, Any] = {} # 同款共用模特:spu_code → model 路径
|
||||
|
||||
|
||||
def _next_img_idx(prod_dir: Path, prefix: str) -> int:
|
||||
"""货号续号:扫 prod_dir 已有 {prefix}{数字}* 文件,返回下一个起始序号(不覆盖旧产物)。"""
|
||||
import re
|
||||
max_n = -1
|
||||
try:
|
||||
if prod_dir.exists():
|
||||
for f in prod_dir.iterdir():
|
||||
m = re.match(rf"{re.escape(prefix)}(\d+)", f.stem)
|
||||
if m:
|
||||
max_n = max(max_n, int(m.group(1)))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return max_n + 1
|
||||
|
||||
MODEL_WEAR_PROMPT = (
|
||||
"你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计印到图3的衣服底图上,"
|
||||
"并让图1的模特穿上这件带有图2印花设计的图3底图衣服。\n"
|
||||
"【图片角色,按提交顺序,不要弄反】\n"
|
||||
"第一张图(图1)=模特实拍图(基底,要被替换衣服图案和颜色的目标区域,保留原有背景/人物/光影);\n"
|
||||
"第二张图(图2)=纯印花设计稿(要印上去的图案内容,忽略其背景环境与无关元素,保留图案原始线条与色号);\n"
|
||||
"第三张图(图3)=平铺衣服底图(只提取衣服本身的底色与面料材质;忽略平铺图的背景、桌面、环境、场景阴影等一切与衣服无关的元素,只保留衣服面料的颜色、质感和纹理)。\n"
|
||||
"最终效果为图1的模特穿着一件“颜色为图3底色、印有图2图案”的衣服。\n"
|
||||
"【执行规则】\n"
|
||||
"1.底色锁定:从图3平铺衣服中提取衣服底色与面料属性,该底色在最终合成中必须100%保持不变,"
|
||||
"严禁偏色、混入图1原衣服颜色或图2背景色。\n"
|
||||
"2.印花提取与叠加:从图2中精准提取纯印花图案主体,保留原始线条、色号、比例关系;"
|
||||
"将印花叠加到图3底色衣服上,形成“图3底色+图2印花”的合成面料。\n"
|
||||
"3.印花尺寸适配:印花的整体尺寸必须与衣服(图3)的面料面积成合理比例——"
|
||||
"居中印在胸/背/衣身的主体区域,占衣身面积约30%-45%,四周保留自然留白与衣摆、领口、肩线余量;"
|
||||
"严禁印花过大(撑满整件衣服、溢出领口袖口下摆)或过小(占比低于20%)。\n"
|
||||
"4.主体识别与遮罩:识别图1模特的服装穿着区域,忽略皮肤、头发、背景、配饰;"
|
||||
"将该区域视为“空白画布”,用上述合成面料(图3底色+图2印花)完整覆盖。图1原有衣服颜色与图案全部清除。\n"
|
||||
"5.精准贴合:合成面料严格跟随图1衣服的立体结构——有褶皱、身体扭转时印花相应变形;"
|
||||
"印花与新底色须“沉入”褶皱中,保留布料原有明暗纹理与物理属性,杜绝“贴纸感”与“平面涂色感”。\n"
|
||||
"6.光影融合:提取图1的环境光方向,调整合成面料的亮度/对比度与环境光匹配;"
|
||||
"图3底色在阴影区须自然变暗,在高光区须有布料反光;印花色彩受环境光影响产生相应明暗变化,但色号本身不偏移。\n"
|
||||
"7.纯净输出:仅输出一张最终合成图;严禁文字/水印/额外装饰;"
|
||||
"图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]:
|
||||
"""选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。"""
|
||||
if sku_code:
|
||||
return sku_code
|
||||
s = first_available_sku(db_path, basemap_root, spu_code)
|
||||
if s:
|
||||
return s
|
||||
if colors:
|
||||
return colors[0]["sku_code"]
|
||||
return None
|
||||
|
||||
|
||||
def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
|
||||
"""模板输出路径:默认 {sku}_已填写.xlsx;若文件被其它程序占用(如已打开),自动换名加序号,避免导出失败。"""
|
||||
base = prod_dir / f"{chosen_sku}_已填写.xlsx"
|
||||
try:
|
||||
with open(base, "ab"):
|
||||
pass
|
||||
return base
|
||||
except OSError:
|
||||
pass
|
||||
for i in range(2, 100):
|
||||
cand = prod_dir / f"{chosen_sku}_已填写_{i}.xlsx"
|
||||
if not cand.exists():
|
||||
return cand
|
||||
return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"
|
||||
|
||||
|
||||
def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
|
||||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。"""
|
||||
import time as _t
|
||||
last = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
last = e
|
||||
if i < attempts - 1:
|
||||
_t.sleep(backoff[i])
|
||||
print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
|
||||
return None
|
||||
|
||||
|
||||
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,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
|
||||
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
|
||||
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
|
||||
(按货号命名,包含该货号对应的所有图片)。
|
||||
返回 result dict;内部异常已兜底,不中断。
|
||||
"""
|
||||
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
|
||||
prod_dir = prod_dir / img_code
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
colors = list_colors(db_path, spu["code"])
|
||||
valid_codes = {c["sku_code"] for c in colors}
|
||||
if sku_code:
|
||||
sku_codes = [s.strip() for s in sku_code.split(",") if s.strip() and s.strip() in valid_codes]
|
||||
if not sku_codes:
|
||||
print(f"[product] 颜色 {sku_code!r} 均不在款号 {spu['code']} 下,可选: {[c['sku_code'] for c in colors]}")
|
||||
return None
|
||||
else:
|
||||
first = _resolve_sku(db_path, basemap_root, spu["code"], "", colors)
|
||||
if first is None:
|
||||
print(f"[product] 款号 {spu['code']} 无颜色数据,可选: {[c['sku_code'] for c in colors]}")
|
||||
return None
|
||||
sku_codes = [first]
|
||||
|
||||
# 模板模式自动判定(UI 不再选择):单色=每色一SPU;多色=单SPU多色;CLI --single-spu 显式覆盖
|
||||
spu_per_color = pcfg.get("spu_per_color")
|
||||
if spu_per_color is None:
|
||||
spu_per_color = len(sku_codes) <= 1
|
||||
else:
|
||||
spu_per_color = bool(spu_per_color)
|
||||
|
||||
chosen_sku = sku_codes[0] # 生图用第一个颜色
|
||||
tag = f"[product/{img_code or chosen_sku}]" # 日志前缀用货号(DG000),失败/进度一眼定位
|
||||
basemap_img = find_basemap(basemap_root, spu["code"], chosen_sku)
|
||||
if basemap_img is None:
|
||||
print(f"{tag} 底图缺失({basemap_root}/{spu['code']}/{chosen_sku}/),将跳过印花/模特,仅导出模板")
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"spu_code": spu["code"],
|
||||
"sku_code": chosen_sku,
|
||||
"color": next((c["color"] for c in colors if c["sku_code"] == chosen_sku), ""),
|
||||
"topic": brief.get("topic", ""),
|
||||
"art_style": brief.get("art_style", ""),
|
||||
"color_palette": brief.get("color_palette", ""),
|
||||
"basemap": str(basemap_img) if basemap_img else "",
|
||||
"composite_prompt": brief.get("composite_prompt", ""),
|
||||
"markup_percent": pcfg.get("markup_percent", 0), # 加价%(后续定价用)
|
||||
}
|
||||
|
||||
# 拷贝底图(图3):命名 = 货号 + SKU code(如 DG003_DG004-BL01_basemap.jpg),
|
||||
# 同一 SKU 多个设计(数量 N)时底图文件也各自独立,不覆盖、不混淆
|
||||
if basemap_img is not None:
|
||||
base_copy = prod_dir / f"{img_code}_{chosen_sku}_basemap{basemap_img.suffix}"
|
||||
shutil.copy2(basemap_img, base_copy)
|
||||
result["basemap_copy"] = str(base_copy)
|
||||
print(f"{tag} 底图: {base_copy}")
|
||||
|
||||
if ib is None:
|
||||
print(f"{tag} 未配置 product.backend(openai/mock),跳过印花/模特生成。")
|
||||
elif basemap_img is None:
|
||||
print(f"{tag} 无底图,跳过印花/模特生成。")
|
||||
else:
|
||||
# 5) 纯印花设计稿(图2):直接用 compose 节点生成的共享设计稿(designs/ 已有,不拷贝)
|
||||
if shared_design and Path(shared_design).exists():
|
||||
design_path = shared_design
|
||||
result["design_path"] = design_path
|
||||
result["design_from"] = "compose"
|
||||
print(f"{tag} 设计稿(来自 compose 节点,designs/ 已有): {design_path}")
|
||||
else:
|
||||
design_path = str(prod_dir / f"{img_code}_design.png")
|
||||
try:
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", "")))
|
||||
ib.generate(prompt, design_path,
|
||||
brief.get("composite_negative", ""),
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
result["design_path"] = design_path
|
||||
result["design_from"] = "product"
|
||||
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
|
||||
print(f"{tag} 设计稿生成失败: {e}")
|
||||
|
||||
# 6) 模板选择按 SPU.mark 决定:
|
||||
# mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图)
|
||||
# mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计)
|
||||
if int(spu.get("mark") or 0) == 1:
|
||||
print(f"{tag} SPU {spu['code']} mark=1 → 使用三图合成模板(图1模特+图2印花+图3底图)")
|
||||
if model_img is not None:
|
||||
# 任务级模特分配(product_node 预分配:一个 SPU 一个模特,SPU 数>模特数循环兜底)
|
||||
model_copy = prod_dir / f"{img_code}_model{model_img.suffix}"
|
||||
shutil.copy2(model_img, model_copy)
|
||||
result["model_path"] = str(model_copy)
|
||||
result["model_folder"] = model_img.parent.name
|
||||
print(f"{tag} 模特图(任务级分配,{model_img.parent.name}/): {model_copy}")
|
||||
else:
|
||||
print(f"{tag} material_library 无模特图,回退两图合成(composite_prompt)")
|
||||
else:
|
||||
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
|
||||
|
||||
# 7) 合成:
|
||||
# 有模特图 → 三图合成(图1=模特 / 图2=印花设计 / 图3=底图)
|
||||
# 无模特图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
|
||||
if "design_path" not in result:
|
||||
print(f"{tag} 无设计稿,跳过合成")
|
||||
elif model_img is not None:
|
||||
composite_path = str(prod_dir / f"{img_code}_composite.png")
|
||||
try:
|
||||
# 三图合成:优先用简报的 composite_prompt(模板化三图文案),回退内置 MODEL_WEAR_PROMPT
|
||||
wear_prompt = (brief.get("composite_prompt") or "").strip() or MODEL_WEAR_PROMPT
|
||||
print(f"{tag} 三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…")
|
||||
t0 = time.time()
|
||||
ib.print(wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图
|
||||
size="1504x2000") # 三合一统一 1504x2000
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次)
|
||||
print(f"{tag} 三图合成失败,退避重试…: {e}")
|
||||
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], size="1504x2000")
|
||||
if retried is not None:
|
||||
result["composite_path"] = composite_path
|
||||
print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
return None
|
||||
else:
|
||||
printed_path = str(prod_dir / f"{img_code}_printed.png")
|
||||
try:
|
||||
# 两图合成(无模特):用平铺印图文案(wearable_prompt),回退旧 composite_prompt
|
||||
flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "")
|
||||
ib.print(flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], # 图2印花
|
||||
size="1504x2000") # 合成统一 1504x2000
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
|
||||
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], size="1504x2000")
|
||||
if retried is not None:
|
||||
result["printed_path"] = printed_path
|
||||
print(f"{tag} 平铺服装图重试成功: {printed_path}")
|
||||
else:
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
|
||||
print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}")
|
||||
return None
|
||||
|
||||
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
|
||||
color_composites: List[Dict[str, Any]] = []
|
||||
if model_img is not None and result.get("composite_path"):
|
||||
# 首色主图始终记录(单色/多色都走模板填充)
|
||||
color_composites.append({"sku_code": chosen_sku, "color": result.get("color", ""),
|
||||
"composite_path": result["composite_path"]})
|
||||
for sc in sku_codes[1:]:
|
||||
bm = find_basemap(basemap_root, spu["code"], sc)
|
||||
if bm is None:
|
||||
print(f"{tag} 颜色 {sc} 无底图,跳过该色三合一")
|
||||
continue
|
||||
cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
|
||||
try:
|
||||
ib.print(MODEL_WEAR_PROMPT, str(model_img), cp,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
|
||||
size="1504x2000") # 三合一统一 1504x2000
|
||||
col = next((c["color"] for c in colors if c["sku_code"] == sc), sc)
|
||||
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
|
||||
print(f"{tag} 颜色 {sc}({col})三合一已生成: {cp}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "product", "type": type(e).__name__,
|
||||
"message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
|
||||
result["color_composites"] = color_composites
|
||||
|
||||
# 7.5) 多模态标题生成:合成图/平铺图/设计稿 → 中英双语 SEO 标题(按国家路由模板)
|
||||
if title_backend is not None:
|
||||
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)
|
||||
if t.get("en_title") or t.get("cn_title") or t.get("ja_title"):
|
||||
result["en_title"] = t.get("en_title", "")
|
||||
result["cn_title"] = t.get("cn_title", "")
|
||||
result["ja_title"] = t.get("ja_title", "")
|
||||
print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... "
|
||||
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}...")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@with_fallback("product")
|
||||
def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
config = state["config"]
|
||||
pcfg = config.get("product") or {}
|
||||
stats = dict(state.get("stats") or {})
|
||||
if not pcfg.get("enabled", True):
|
||||
return {"product": [], "stats": stats}
|
||||
|
||||
country = state["country"]
|
||||
briefs = state.get("briefs") or []
|
||||
output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹)
|
||||
cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录)
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
# 路径解析:相对路径 → 优先运行根(exe 旁自定义数据),其次数据根(打包=_MEIPASS 内置)
|
||||
def _abs(key: str, default: str) -> Path:
|
||||
p = Path(pcfg.get(key, default))
|
||||
if p.is_absolute():
|
||||
return p
|
||||
for root in (runtime_root(), project_root()):
|
||||
cand = root / p
|
||||
if cand.exists():
|
||||
return cand
|
||||
return project_root() / p
|
||||
|
||||
db_path = _abs("db_path", "db/spu_sku.db")
|
||||
basemap_root = _abs("basemap_dir", "basemap")
|
||||
material_root = _abs("material_library_dir", "material_library")
|
||||
category = pcfg.get("model_category", "T-shirt")
|
||||
brief_index = int(pcfg.get("brief_index", 0))
|
||||
spu_code = (pcfg.get("spu_code") or "").strip()
|
||||
sku_code = (pcfg.get("sku_code") or "").strip()
|
||||
spu_tasks = pcfg.get("spu_tasks") or []
|
||||
spu_count = int(pcfg.get("spu_count") or 0)
|
||||
|
||||
# 1) 选简报(优先 safe)
|
||||
safe = [b for b in briefs if b.get("risk_level") == "safe"] or briefs
|
||||
if not safe:
|
||||
print("[product] 无可用简报,跳过产品图生成")
|
||||
return {"product": [], "stats": stats}
|
||||
brief = safe[brief_index] if brief_index < len(safe) else safe[0]
|
||||
|
||||
# 2) 图像后端
|
||||
backend_name = (pcfg.get("backend") or "").strip()
|
||||
ib = None
|
||||
if backend_name:
|
||||
from graph.backends import get_image_backend
|
||||
ib = get_image_backend(backend_name)
|
||||
if ib is not None:
|
||||
ib.bind_config(config.get("compose") or {}) # 复用 compose.api_key/model/size
|
||||
|
||||
# 3) 构造款号工作清单:spu_tasks(多款号)优先;否则 spu_code / 自动第一个
|
||||
spus = list_spus(db_path)
|
||||
worklist: List[tuple] = [] # (spu, skus, brief) —— 每个款号可绑定自己的热点简报
|
||||
by_topic = {str(b.get("topic", "")).lower(): b for b in briefs}
|
||||
if not spus:
|
||||
print(f"[product] db 无 SPU 数据({db_path}),跳过")
|
||||
return {"product": [], "stats": stats}
|
||||
if spu_tasks:
|
||||
for ti, t in enumerate(spu_tasks):
|
||||
code = (t.get("spu") or t.get("spu_code") or "").strip()
|
||||
spu = next((s for s in spus if s["code"] == code), None)
|
||||
if spu is None:
|
||||
print(f"[product] 任务款号 {code} 不在 db,跳过(可选: {[s['code'] for s in spus][:12]})")
|
||||
continue
|
||||
tb = None
|
||||
tp = (t.get("topic") or "").strip()
|
||||
if tp:
|
||||
tb = by_topic.get(tp.lower())
|
||||
if tb is None:
|
||||
print(f"[product] 任务热点「{tp}」不在简报中,回退按序号分配")
|
||||
if tb is None:
|
||||
# 未指定热点(完整流水线):按任务序号取不同简报,避免多个产品用同一个
|
||||
idx = min(ti, len(safe) - 1) if safe else brief_index
|
||||
tb = safe[idx] if safe else None
|
||||
if tb is None:
|
||||
print(f"[product] 无可用简报,跳过任务 {code}")
|
||||
continue
|
||||
worklist.append((spu, (t.get("skus") or "").strip(), tb))
|
||||
if not worklist:
|
||||
print("[product] 任务清单无有效款号,跳过产品图生成")
|
||||
return {"product": [], "stats": stats}
|
||||
elif spu_code:
|
||||
spu = next((s for s in spus if s["code"] == spu_code), None)
|
||||
if spu is None:
|
||||
print(f"[product] 款号 {spu_code} 不在 db,可选: {[s['code'] for s in spus][:12]}")
|
||||
return {"product": [], "stats": stats}
|
||||
worklist.append((spu, sku_code, brief))
|
||||
else:
|
||||
spu = next((s for s in spus if first_available_sku(db_path, basemap_root, s["code"])), None)
|
||||
if spu is None:
|
||||
print(f"[product] 没有任何款号存在本地底图({basemap_root}/<款号>/<SKU.code>/)")
|
||||
return {"product": [], "stats": stats}
|
||||
worklist.append((spu, sku_code, brief))
|
||||
|
||||
# 4) 不再按 spu_count 截断:worklist 已是扩展后的完整任务(数量 N = 每款设计数),
|
||||
# 全部任务进入队列处理(并发 5)。
|
||||
|
||||
prod_dir = output_dir / "product"
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 4.1) 任务级模特分配(material_library-<category>):
|
||||
# 一个 SPU 对应一个模特;SPU(不同款)数 > 模特数 → 从全部模特循环兜底(允许重复)
|
||||
model_assign: Dict[str, Any] = {}
|
||||
_all_models: List[str] = []
|
||||
try:
|
||||
_folder, _all_models = find_first_model_folder(material_root, category)
|
||||
except Exception: # noqa: BLE001
|
||||
_all_models = []
|
||||
if _all_models:
|
||||
seen_spu: Dict[str, str] = {}
|
||||
for _i, (_spu, _skus, _tb) in enumerate(worklist):
|
||||
code = _spu.get("code", "")
|
||||
if code not in seen_spu:
|
||||
seen_spu[code] = _all_models[_i % len(_all_models)] # SPU>模特数 → 循环兜底
|
||||
model_assign[code] = seen_spu[code]
|
||||
print(f"[product] 任务级模特分配:{len(seen_spu)} 个 SPU,模特池 {len(_all_models)} 张"
|
||||
f"{'(SPU>模特,循环兜底)' if len(seen_spu) > len(_all_models) else ''}")
|
||||
|
||||
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
|
||||
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
|
||||
designs_dir = output_dir / "designs"
|
||||
designs_dir.mkdir(parents=True, exist_ok=True)
|
||||
designs_map = {str(d.get("topic", "")).strip().lower(): d.get("path", "")
|
||||
for d in (state.get("designs") or []) if isinstance(d, dict)}
|
||||
for _d in (state.get("briefs") or []):
|
||||
if isinstance(_d, dict) and _d.get("design_path"):
|
||||
designs_map.setdefault(str(_d.get("topic", "")).strip().lower(), _d.get("design_path"))
|
||||
|
||||
def _resolve_design(tb) -> Optional[str]:
|
||||
"""任务绑定的简报 → 该热点自己的设计稿路径(按货号命名拷贝到 designs/)。"""
|
||||
topic = str(tb.get("topic", "")).strip().lower()
|
||||
src = designs_map.get(topic) or tb.get("design_path")
|
||||
if not src or not Path(src).exists():
|
||||
return None
|
||||
return src
|
||||
|
||||
# 6) 逐个款号处理(每款号用自己绑定的热点简报)
|
||||
title_backend = None
|
||||
ls_cfg = config.get("llm_screen") or {}
|
||||
if (ls_cfg.get("provider") or "") not in ("", "mock"):
|
||||
try:
|
||||
from graph.llms import get_backend as _glb
|
||||
_tb = _glb(ls_cfg.get("provider"))
|
||||
_tb.bind_config(ls_cfg)
|
||||
if _tb.has_key:
|
||||
title_backend = _tb
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[product] 标题后端初始化失败: {e}")
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
prefix = str(pcfg.get("code_prefix") or "DG").strip()
|
||||
# 并发:默认每个 SPU 一个独立线程(任务数即并发数,提速);
|
||||
# config.product.concurrency 显式配置可覆盖(如限流时设 3-5);
|
||||
# 默认并发上限 5:全量并发(任务数)会压垮图像网关(10 并发 → 全部超时),
|
||||
# 超出上限的任务在线程池排队,逐批处理
|
||||
concurrency = int(pcfg.get("concurrency") or 0) or min(len(worklist), 5)
|
||||
print(f"[product] 并发 {concurrency}(每 SPU 一线程,上限 {concurrency})处理 {len(worklist)} 个产品任务")
|
||||
|
||||
def _run_one(idx: int, spu, skus, tb):
|
||||
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
|
||||
img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…)
|
||||
try:
|
||||
# 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png)
|
||||
design_src = _resolve_design(tb)
|
||||
design_path = None
|
||||
if design_src:
|
||||
design_path = str(designs_dir / f"{img_code}_design.png")
|
||||
try:
|
||||
shutil.copy2(design_src, design_path)
|
||||
except Exception: # noqa: BLE001
|
||||
design_path = design_src
|
||||
tb = dict(tb)
|
||||
tb["design_path"] = design_path
|
||||
r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
|
||||
tb, ib, spu, skus, pcfg, errors, design_path, title_backend,
|
||||
country, img_code=img_code,
|
||||
model_img=model_assign.get(spu.get("code", "")))
|
||||
if r:
|
||||
r["img_code"] = img_code
|
||||
return r, img_code
|
||||
except Exception as e: # noqa: BLE001 # 单产品任何异常都不拖垮整体
|
||||
print(f"[product/{img_code}] 产品处理异常(跳过该产品): {e}")
|
||||
return None, img_code
|
||||
|
||||
import concurrent.futures
|
||||
# 货号自动续号:任务一开始全部按序分配(start_idx 起),不覆盖已生成的产物
|
||||
start_idx = _next_img_idx(prod_dir, prefix)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
|
||||
futures = [ex.submit(_run_one, start_idx + i, spu, skus, tb)
|
||||
for i, (spu, skus, tb) in enumerate(worklist)]
|
||||
for f in concurrent.futures.as_completed(futures):
|
||||
r, img_code = f.result()
|
||||
if r:
|
||||
results.append(r)
|
||||
try:
|
||||
_record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定
|
||||
_write_products(prod_dir, results)
|
||||
stats["product"] = {
|
||||
"spus": [r.get("spu_code") for r in results],
|
||||
"skus": [r.get("sku_code") for r in results],
|
||||
"topic": brief.get("topic", ""),
|
||||
"count": len(results),
|
||||
"composite": sum(1 for r in results if r.get("composite_path")),
|
||||
"printed": sum(1 for r in results if r.get("printed_path")),
|
||||
"templates": sum(1 for r in results if r.get("template_path")),
|
||||
"output_dir": str(prod_dir),
|
||||
}
|
||||
return {"product": results, "stats": stats, "errors": errors}
|
||||
|
||||
|
||||
def _record_used(output_dir: Path, r: Dict[str, Any]):
|
||||
"""记录已用 (热点-风格-配色),供后续去重:output/<国家>/used_designs.json。"""
|
||||
topic = r.get("topic", "")
|
||||
if not topic:
|
||||
return
|
||||
with _USED_LOCK: # 并发下 used_designs.json 读写互斥
|
||||
p = output_dir / "used_designs.json"
|
||||
used = []
|
||||
if p.exists():
|
||||
try:
|
||||
used = json.loads(p.read_text(encoding="utf-8")).get("used", []) or []
|
||||
except Exception:
|
||||
used = []
|
||||
entry = {
|
||||
"topic": topic,
|
||||
"art_style": r.get("art_style", ""),
|
||||
"color_palette": r.get("color_palette", ""),
|
||||
"spu_code": r.get("spu_code", ""),
|
||||
"sku_code": r.get("sku_code", ""),
|
||||
"date": time.strftime("%Y-%m-%d"),
|
||||
}
|
||||
# 同 (topic, art_style) 已记录则跳过,避免去重记录重复堆积
|
||||
if any(str(u.get("topic", "")).strip().lower() == str(entry["topic"]).strip().lower()
|
||||
and str(u.get("art_style", "")).strip().lower() == str(entry["art_style"]).strip().lower()
|
||||
for u in used):
|
||||
return
|
||||
used.append(entry)
|
||||
p.write_text(json.dumps(
|
||||
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "used": used},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_products(prod_dir: Path, products: List[Dict[str, Any]]):
|
||||
(prod_dir / "products.json").write_text(
|
||||
json.dumps({"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "products": products},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""节点 5/6:提示词构造(prompt_build)。
|
||||
|
||||
读取 prompts/<country>/ 的 extra 风格规则,用固定模板装配四种最终提示词
|
||||
(image_prompt / wearable_prompt / composite_prompt / composite_negative)。
|
||||
四要素缺失时用 derive_style_palette 动态兜底,保证每条提示词结构一致、有规则。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
import random
|
||||
|
||||
from graph.style_rules import derive_style_palette, derive_composition
|
||||
from graph.templates import assemble_prompts
|
||||
from graph.validate import validate_brief, with_fallback
|
||||
|
||||
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
|
||||
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
|
||||
_IMG_RISKY_SWAP = {
|
||||
"skull": "smiley mascot", "skeleton": "cute mascot", "blood": "red accents",
|
||||
"gore": "bold shapes", "gun": "star", "weapon": "tool", "bomb": "firework",
|
||||
"drug": "confetti", "demon": "cute monster", "devil": "mischievous imp",
|
||||
"occult": "mystic pattern", "satanic": "dark pattern", "nazi": "retro emblem",
|
||||
"hitler": "retro emblem", "zombie": "friendly ghoul", "horror": "spooky-cute",
|
||||
"vampire": "night owl", "politics": "abstract shapes", "political": "abstract",
|
||||
"president": "captain", "army": "team", "police": "officer",
|
||||
}
|
||||
|
||||
|
||||
def _safe_motif(motif: str) -> str:
|
||||
"""清洗 motif 中的图像策略敏感词(替换为安全等效描述),降低生图内容政策拦截率。"""
|
||||
low = motif.lower()
|
||||
for k, v in _IMG_RISKY_SWAP.items():
|
||||
if k in low:
|
||||
# 按词边界替换(避免误伤 "letterhead" 等)
|
||||
import re
|
||||
motif = re.sub(rf"\b{re.escape(k)}\b", v, motif, flags=re.IGNORECASE)
|
||||
low = motif.lower()
|
||||
return motif
|
||||
|
||||
|
||||
@with_fallback("prompt_build")
|
||||
def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
screened: List[Dict[str, Any]] = state.get("screened") or []
|
||||
config = state["config"]
|
||||
country = state["country"]
|
||||
cc = state["country_config"]
|
||||
extra_rules = cc.get("extra_style_rules") or []
|
||||
tpls = config.get("prompt_templates") or {}
|
||||
|
||||
briefs: List[Dict[str, Any]] = []
|
||||
for r in screened:
|
||||
r = validate_brief(r)
|
||||
art, pal = derive_style_palette(
|
||||
r["topic"], country, extra_rules=extra_rules, category=r.get("design_category")
|
||||
)
|
||||
motif = (r.get("motif") or "").strip() or r.get("topic", "")
|
||||
cleaned = _safe_motif(motif)
|
||||
if cleaned != motif:
|
||||
print(f"[prompt] motif 敏感词清洗: 「{motif}」→「{cleaned}」(降低生图内容政策拦截)")
|
||||
r["motif"] = cleaned
|
||||
motif = cleaned
|
||||
art_style = (r.get("art_style") or art).strip()
|
||||
palette = (r.get("color_palette") or pal).strip()
|
||||
composition = (r.get("composition") or derive_composition(r["topic"], r.get("design_category"))).strip()
|
||||
|
||||
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
|
||||
# 文字印花(约 30% 概率):简报有 slogan 时,随机注入文字段到设计稿提示词
|
||||
slogan = (r.get("slogan") or "").strip()
|
||||
if slogan and random.random() < float(config.get("prompt_templates", {}).get("text_ratio", 0.3)):
|
||||
text_seg = (f', with the text "{slogan}" rendered as bold retro typography, '
|
||||
f'lettering clean and correctly spelled, high contrast, as the focal text of the print')
|
||||
prompts["image_prompt"] = prompts["image_prompt"] + text_seg
|
||||
r["used_slogan"] = slogan
|
||||
# review(疑似商标/受保护主题)→ 动态注入「原创化魔改」引导:只做风格参考,禁止复刻品牌/商标/角色,
|
||||
# 换名换细节,生成通用非侵权的致敬式设计
|
||||
if str(r.get("risk_level", "")).strip().lower() == "review":
|
||||
prompts["image_prompt"] = (prompts["image_prompt"]
|
||||
+ " IMPORTANT: this theme is ONLY a loose stylistic reference. "
|
||||
"Do NOT reproduce any brand logo, trademark, character, mascot, copyrighted artwork or real person. "
|
||||
"Create a fully ORIGINAL design with a different name and distinct visual details and colors — "
|
||||
"a generic, non-infringing homage in the same mood, clearly distinct from the original.")
|
||||
print(f"[prompt] review 简报注入原创化魔改引导: 「{r['topic']}」")
|
||||
r.update(prompts)
|
||||
r["motif"] = motif
|
||||
r["art_style"] = art_style
|
||||
r["color_palette"] = palette
|
||||
r["composition"] = composition
|
||||
briefs.append(r)
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["prompt"] = {"briefs": len(briefs)}
|
||||
return {"briefs": briefs, "stats": stats}
|
||||
@@ -0,0 +1,26 @@
|
||||
"""节点 3/6:打分(score)。
|
||||
|
||||
归一化(按 source/kind 分组 min-max)-> 跨源融合(combine)-> 综合分阈值预筛。
|
||||
纯逻辑节点,with_fallback 兜底。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.scoring import combine, normalize
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("score")
|
||||
def score_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rows: List[Dict[str, Any]] = state.get("filtered_rows") or []
|
||||
config = state["config"]
|
||||
weights = config.get("weights") or {}
|
||||
llm_cfg = config.get("llm_screen") or {}
|
||||
min_score = float(llm_cfg.get("min_score", 0.0))
|
||||
|
||||
normalize(rows)
|
||||
combined = combine(rows, weights)
|
||||
combined = [c for c in combined if float(c.get("score", 0)) >= min_score]
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["score"] = {"combined": len(combined)}
|
||||
return {"scored_rows": combined, "stats": stats}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""节点 4/6:合规筛选(screen)。
|
||||
|
||||
调用可插拔 LLM 后端做合规筛查 + 结构化四要素;后端调用失败时降级 MockBackend。
|
||||
按 topic 把筛查结果映射回 scored 候选(补 score/sources/country),再做最终风险过滤
|
||||
(blocked 丢弃;review 按 keep_review 决定)。
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.loader import load_system_prompt
|
||||
from graph.llms import DEFAULT_SYSTEM_PROMPT, get_backend
|
||||
from graph.style_rules import COUNTRY_AESTHETICS
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("screen")
|
||||
def screen_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
country = state["country"]
|
||||
scored: List[Dict[str, Any]] = state.get("scored_rows") or []
|
||||
config = state["config"]
|
||||
cc = state["country_config"]
|
||||
llm_cfg = config.get("llm_screen") or {}
|
||||
provider = llm_cfg.get("provider", "mock")
|
||||
keep_review = bool(llm_cfg.get("keep_review", False))
|
||||
batch_size = int(llm_cfg.get("max_topics_per_call", 12))
|
||||
blacklist = [str(b).lower() for b in (config.get("blacklist") or [])]
|
||||
|
||||
prompts_dir = Path(state["prompts_dir"])
|
||||
system_prompt = load_system_prompt(prompts_dir, DEFAULT_SYSTEM_PROMPT)
|
||||
aesthetic_hint = cc.get("style_hint") or COUNTRY_AESTHETICS.get(country, {}).get("style_hint", "")
|
||||
|
||||
# 排除已用热点(去重生效:已用 topic 不再进入本次简报,每次跑都用新热点)
|
||||
try:
|
||||
import json as _json
|
||||
used_p = Path(state.get("cache_dir") or state.get("output_dir", "")) / "used_designs.json"
|
||||
if used_p.exists():
|
||||
ud = _json.loads(used_p.read_text(encoding="utf-8")).get("used", []) or []
|
||||
used_topics = {str(u.get("topic", "")).strip().lower() for u in ud}
|
||||
before = len(scored)
|
||||
scored = [c for c in scored if str(c.get("topic", "")).strip().lower() not in used_topics]
|
||||
if len(scored) < before:
|
||||
print(f"[screen] 排除已用热点 {before - len(scored)} 条(去重),剩余 {len(scored)} 条可选")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 简报数量 = 用多少生成多少:llm_screen.max_briefs 配置优先,否则按扩展后的总任务数
|
||||
#(每个产品一个热点;数量 N=每个款-颜色条目的设计数 → 总任务=条目数×N);
|
||||
# 前台显示不依赖简报(读 collected 完整池 + used_designs 剔除已用,用完即从前台消失)。
|
||||
pcfg = config.get("product") or {}
|
||||
ls_cfg = config.get("llm_screen") or {}
|
||||
limit = int(ls_cfg.get("max_briefs") or 0)
|
||||
if not limit and pcfg.get("spu_tasks"):
|
||||
limit = len(pcfg.get("spu_tasks") or []) # 扩展后总任务数(=产品数)
|
||||
if not limit:
|
||||
limit = int(pcfg.get("spu_count") or 0)
|
||||
if limit > 0:
|
||||
ordered = sorted(scored, key=lambda c: -(float(c.get("score") or 0)))
|
||||
scored_limited = ordered[:limit]
|
||||
print(f"[screen] 简报限量 {limit} → 筛前 {len(scored_limited)} 个高分热点(共 {len(scored)} 个)")
|
||||
else:
|
||||
scored_limited = scored
|
||||
print(f"[screen] 简报全量 {len(scored_limited)} 条(未限量,全部生成简报)")
|
||||
|
||||
topics = [c["topic"] for c in scored_limited]
|
||||
|
||||
backend = get_backend(provider)
|
||||
if provider != "mock":
|
||||
backend.bind_config(llm_cfg)
|
||||
if not backend.has_key:
|
||||
print("[screen] 未检测到 LLM api_key(请配置 llm_screen.api_key 或环境变量 "
|
||||
"LLM_API_KEY/OPENAI_API_KEY),降级 Mock 兜底。")
|
||||
backend = get_backend("mock")
|
||||
try:
|
||||
screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[screen] {provider} 调用失败,降级 Mock: {e}")
|
||||
backend = get_backend("mock")
|
||||
screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size)
|
||||
|
||||
# 映射回 scored 候选
|
||||
by_topic = {s.get("topic", "").lower(): s for s in screened}
|
||||
out: List[Dict[str, Any]] = []
|
||||
missing: List[Dict[str, Any]] = []
|
||||
for it in scored_limited:
|
||||
s = by_topic.get(it["topic"].lower())
|
||||
if s is None:
|
||||
missing.append(it)
|
||||
continue
|
||||
s = dict(s)
|
||||
s["country"] = country
|
||||
s["score"] = it.get("score", 0)
|
||||
s["sources"] = it.get("sources", "")
|
||||
out.append(s)
|
||||
|
||||
# LLM 漏判的主题用 Mock 单独补全,避免丢数据(而非整批降级)
|
||||
if missing:
|
||||
print(f"[screen] LLM 漏判 {len(missing)} 个主题,用 Mock 单独补全:"
|
||||
f"{[m['topic'] for m in missing]}")
|
||||
mock = get_backend("mock")
|
||||
m_res = mock.screen(
|
||||
[m["topic"] for m in missing], country, aesthetic_hint,
|
||||
system_prompt, blacklist, batch_size,
|
||||
)
|
||||
m_by = {r.get("topic", "").lower(): r for r in m_res}
|
||||
for it in missing:
|
||||
s = m_by.get(it["topic"].lower())
|
||||
if s is None:
|
||||
continue
|
||||
s = dict(s)
|
||||
s["country"] = country
|
||||
s["score"] = it.get("score", 0)
|
||||
s["sources"] = it.get("sources", "")
|
||||
out.append(s)
|
||||
|
||||
# 最终风险过滤:只滤 blocked(硬拦截);review(待复核)保留——
|
||||
# 由 assign_hotspots 的 allow_review 决定是否参与分配(openai 模式 review+concept 可用),
|
||||
# 避免 review 被静默丢弃导致"任务 N 个但简报不足、设计缺失"
|
||||
kept: List[Dict[str, Any]] = []
|
||||
for r in out:
|
||||
lvl = r.get("risk_level", "safe")
|
||||
if lvl == "blocked":
|
||||
continue
|
||||
kept.append(r)
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["screen"] = {"screened": len(out), "kept": len(kept)}
|
||||
return {"screened": kept, "stats": stats}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""节点 0/6:动态种子词(seed)。
|
||||
|
||||
在 fetch 之前运行:收集「trending 派生 + 历史 safe 热点 + 月份/节日」上下文,
|
||||
按 seed_provider 策略(static / mock / LLM)生成/合并种子词,注入 country_config 的
|
||||
style.seeds / related.seed_keywords,供后续 fetch 的 related_queries 展开使用。
|
||||
|
||||
关键机制:动态种子词按 (国家, provider, 日期) 缓存(.cache/seeds/)。
|
||||
同一天内多次运行使用同一套种子词 → related_queries 的 24h 缓存稳定命中,
|
||||
避免「history 每次跑完都变 → 种子词震荡 → Google 反复全量重抓 → 429 限流」。
|
||||
|
||||
带 with_fallback:任何异常都降级为"仅用 yaml 静态种子",不阻塞整图。
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from graph.llms import get_backend
|
||||
from graph.llms.mock_backend import COMMON_RISK_WORDS
|
||||
from graph.paths import runtime_root
|
||||
from graph.scoring import filter_person_names, filter_query_noise
|
||||
from graph.seeds import get_seed_strategy
|
||||
from graph.seeds.holidays import build_holiday_context
|
||||
from graph.sources.google_trends_source import fetch_trending
|
||||
from graph.validate import with_fallback
|
||||
|
||||
_CACHE_DIR = runtime_root() / ".cache" / "seeds"
|
||||
|
||||
|
||||
def _cache_key(country: str, provider: str, cfg: Dict[str, Any]) -> str:
|
||||
"""缓存键含「配置指纹」:改了种子相关参数(数量/上下文上限)即换新键重新生成,
|
||||
避免命中旧参数生成的种子;旧文件保留(不删缓存,取最新)。"""
|
||||
fp = hashlib.md5(
|
||||
json.dumps(
|
||||
{k: cfg.get(k) for k in ("max_style_seeds", "max_related_seeds",
|
||||
"trending_context_limit", "history_limit")},
|
||||
sort_keys=True, ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:8]
|
||||
return f"{country}-{provider}-{fp}-{datetime.date.today().isoformat()}"
|
||||
|
||||
|
||||
def _cache_get(key: str):
|
||||
try:
|
||||
p = _CACHE_DIR / f"{key}.json"
|
||||
if p.exists():
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(key: str, val: Dict[str, Any]):
|
||||
try:
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(_CACHE_DIR / f"{key}.json").write_text(
|
||||
json.dumps(val, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@with_fallback("seed")
|
||||
def seed_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
country = state["country"]
|
||||
config = state["config"]
|
||||
cc = dict(state.get("country_config") or {})
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
provider = (config.get("seed_provider") or "mock").strip().lower()
|
||||
cfg = config.get("seed_provider_cfg") or {}
|
||||
trending_limit = int(cfg.get("trending_context_limit", 15))
|
||||
history_limit = int(cfg.get("history_limit", 20))
|
||||
max_style = int(cfg.get("max_style_seeds", 12))
|
||||
max_related = int(cfg.get("max_related_seeds", 12))
|
||||
guard = COMMON_RISK_WORDS + [b.lower() for b in (config.get("blacklist") or [])]
|
||||
|
||||
ckey = _cache_key(country, provider, cfg)
|
||||
cached = _cache_get(ckey) if provider != "static" else None
|
||||
from_cache = cached is not None
|
||||
|
||||
if cached is not None:
|
||||
res = cached
|
||||
context: Dict[str, Any] = {
|
||||
"country": country,
|
||||
"max_style_seeds": max_style,
|
||||
"max_related_seeds": max_related,
|
||||
}
|
||||
else:
|
||||
# 1) 收集上下文
|
||||
context: Dict[str, Any] = {
|
||||
"country": country,
|
||||
"max_style_seeds": max_style,
|
||||
"max_related_seeds": max_related,
|
||||
}
|
||||
try:
|
||||
tl = int((cc.get("trending") or {}).get("limit", 40))
|
||||
rows = fetch_trending(geo=country, limit=min(tl, 40))
|
||||
# 保留 rows 自带的 source=gt_trending,filter_person_names 的人名模式仅对该源生效
|
||||
kept, _ = filter_query_noise(rows, enabled=True)
|
||||
kept, _ = filter_person_names(kept)
|
||||
kept = [r for r in kept if not any(w and w in r["topic"].lower() for w in guard)]
|
||||
context["trending_seeds"] = [r["topic"] for r in kept][:trending_limit]
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] trending 上下文收集失败(跳过): {e}")
|
||||
context["trending_seeds"] = []
|
||||
|
||||
try:
|
||||
p = os.path.join(state.get("output_dir", ""), "design_briefs.json")
|
||||
if os.path.exists(p):
|
||||
data = json.load(open(p, encoding="utf-8")).get("design_briefs", [])
|
||||
safe = [d for d in data if d.get("risk_level") == "safe"]
|
||||
safe.sort(key=lambda d: -(d.get("score") or 0))
|
||||
hrows = [{"topic": d["topic"], "source": "history"} for d in safe]
|
||||
hrows, _ = filter_person_names(hrows, pattern_sources={"history"})
|
||||
hrows = [r for r in hrows if not any(w and w in r["topic"].lower() for w in guard)]
|
||||
context["history_hotspots"] = [r["topic"] for r in hrows][:history_limit]
|
||||
else:
|
||||
context["history_hotspots"] = []
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] 历史热点读取失败(跳过): {e}")
|
||||
context["history_hotspots"] = []
|
||||
|
||||
# 月份/节日(按国家:各国节日表不同)
|
||||
hol = build_holiday_context(country)
|
||||
context["season"] = hol["season"]
|
||||
context["year"] = hol["year"]
|
||||
context["month"] = hol["month"]
|
||||
context["date"] = hol["date"]
|
||||
context["month_themes"] = hol["month_themes"]
|
||||
context["upcoming_holidays"] = hol["upcoming_holidays"]
|
||||
|
||||
# 2) 选策略 + LLM 后端
|
||||
strategy = get_seed_strategy(provider)
|
||||
llm_backend = None
|
||||
if provider != "static":
|
||||
llm_backend = get_backend(provider)
|
||||
# 注入 llm_screen 配置(api_key/base_url/model),否则 has_key 永远 False 降级 mock
|
||||
try:
|
||||
llm_backend.bind_config(config.get("llm_screen") or {})
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] LLM 配置绑定失败: {e}")
|
||||
if provider not in ("mock",) and not getattr(llm_backend, "has_key", False):
|
||||
print(f"[seed] {provider} 未配置 API key,降级 mock 规则生成种子词")
|
||||
llm_backend = get_backend("mock")
|
||||
|
||||
# 3) 生成/合并种子词
|
||||
try:
|
||||
res = strategy.resolve(country, cc, context, llm_backend)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] 策略解析失败,回退静态种子: {e}")
|
||||
res = {
|
||||
"style_seeds": list((cc.get("style", {}) or {}).get("seeds", []) or []),
|
||||
"related_seeds": list((cc.get("related", {}) or {}).get("seed_keywords", []) or []),
|
||||
"dynamic": False,
|
||||
}
|
||||
if provider != "static":
|
||||
_cache_set(ckey, res)
|
||||
|
||||
# 4) 注入 cc
|
||||
style_block = dict(cc.get("style") or {})
|
||||
related_block = dict(cc.get("related") or {})
|
||||
style_block["seeds"] = res["style_seeds"]
|
||||
related_block["seed_keywords"] = res["related_seeds"]
|
||||
cc["style"] = style_block
|
||||
cc["related"] = related_block
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["seed"] = {
|
||||
"provider": provider,
|
||||
"dynamic": res.get("dynamic", False),
|
||||
"from_cache": from_cache,
|
||||
"style_count": len(res["style_seeds"]),
|
||||
"related_count": len(res["related_seeds"]),
|
||||
"trending_ctx": len(context.get("trending_seeds", [])),
|
||||
"history_ctx": len(context.get("history_hotspots", [])),
|
||||
"holidays": context.get("upcoming_holidays", []),
|
||||
}
|
||||
print(
|
||||
f"[seed] provider={provider}{'(当日缓存命中)' if from_cache else ''} "
|
||||
f"种子词 style={len(res['style_seeds'])} related={len(res['related_seeds'])}"
|
||||
)
|
||||
|
||||
return {
|
||||
"country_config": cc,
|
||||
"seed_words": res,
|
||||
"stats": stats,
|
||||
"errors": errors,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""节点 8/8:种草图生成(seed_shot)——在 oss_upload 之后。
|
||||
|
||||
对每个 product 的合成图(图1),按 seed_shot_templates.yaml 模板 + model_features.yaml 随机模特特征
|
||||
生成 N 张种草图(config.seed_shot.count,默认 1):
|
||||
- [商品名称] ← product 的 cn_title(上一节点多模态生成)
|
||||
- [材质] ← 数据库 SPU.material 字段
|
||||
- [模特特征] ← model_features.yaml 随机一条
|
||||
种草图同样压缩上传到 OSS(货号计数与 oss_upload 共用 state["oss_seq"] 续接)。
|
||||
|
||||
未配置图像后端 / 无合成图 / count=0 时跳过,不中断。
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("seed_shot")
|
||||
def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
config = state["config"] or {}
|
||||
country = state.get("country", "")
|
||||
output_dir = Path(state["output_dir"])
|
||||
|
||||
ss_cfg = config.get("seed_shot") or {}
|
||||
count = int(ss_cfg.get("count", 1))
|
||||
if not bool(ss_cfg.get("enabled", True)) or count <= 0 or not products:
|
||||
return {"seed_shots": [], "stats": state.get("stats") or {}}
|
||||
|
||||
# 图像后端(复用 compose 配置)
|
||||
compose_cfg = config.get("compose") or {}
|
||||
ib = None
|
||||
if compose_cfg.get("backend"):
|
||||
from graph.backends import get_image_backend
|
||||
try:
|
||||
ib = get_image_backend(compose_cfg["backend"])
|
||||
if ib is not None:
|
||||
ib.bind_config(compose_cfg)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 图像后端不可用: {e}")
|
||||
if ib is None:
|
||||
print("[seed_shot] 未配置 compose.backend(openai/mock),跳过种草图生成")
|
||||
return {"seed_shots": [], "stats": state.get("stats") or {}}
|
||||
|
||||
# 材质映射:db SPU.material(清洗换行)
|
||||
material_map: Dict[str, str] = {}
|
||||
try:
|
||||
from graph.product import list_spus
|
||||
import yaml
|
||||
dbp = (config.get("product") or {}).get("db_path", "db/spu_sku.db")
|
||||
p = Path(dbp)
|
||||
if not p.is_absolute():
|
||||
from graph.paths import project_root, runtime_root
|
||||
for root in (runtime_root(), project_root()):
|
||||
if (root / p).exists():
|
||||
p = root / p
|
||||
break
|
||||
for s in list_spus(str(p)):
|
||||
m = " ".join(str(s.get("material", "")).replace("\r", " ").replace("\n", " ").split())
|
||||
material_map[s["code"]] = m
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 材质读取失败(用空): {e}")
|
||||
|
||||
from graph.seed_shot import generate_seed_shots
|
||||
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
|
||||
from graph.nodes.oss_upload_node import _gen_rand4, MAX_CODE
|
||||
|
||||
ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S"))
|
||||
prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip()
|
||||
seq = int(state.get("oss_seq") or 0)
|
||||
oss_cfg = config.get("oss") or {}
|
||||
oss_enabled = bool(oss_cfg.get("enabled", True)) and bool(oss_cfg.get("oss_bucket"))
|
||||
|
||||
all_shots: List[Dict[str, Any]] = []
|
||||
shot_dir = output_dir / "seed_shots"
|
||||
shot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import concurrent.futures
|
||||
import threading as _th
|
||||
seq_lock = _th.Lock() # seq(货号计数)跨线程共享,需加锁
|
||||
|
||||
def _shot_one(r: Dict[str, Any]):
|
||||
"""单个产品的种草图生成+上传(每产品独立线程)。"""
|
||||
nonlocal seq
|
||||
base = r.get("composite_path") or r.get("printed_path")
|
||||
if not base or not Path(base).exists():
|
||||
print(f"[seed_shot] {r.get('spu_code', '')} 无合成图,跳过种草图")
|
||||
return None
|
||||
cn = (r.get("cn_title") or "").strip() or r.get("topic", "")
|
||||
material = material_map.get(r.get("spu_code", ""), "")
|
||||
paths = generate_seed_shots(ib, base, cn, material, count, str(shot_dir),
|
||||
r.get("composite_negative", ""),
|
||||
size=str((config.get("seed_shot") or {}).get("size") or "1504x2000"),
|
||||
prefix=r.get("img_code") or r.get("oss_code") or "")
|
||||
if not paths:
|
||||
return None
|
||||
r["seed_shot_paths"] = paths
|
||||
urls: List[str] = []
|
||||
for pth in paths:
|
||||
with seq_lock:
|
||||
if seq >= MAX_CODE:
|
||||
print(f"[seed_shot] 货号计数达上限 999,停止上传种草图")
|
||||
break
|
||||
code = f"{prefix}{seq:03d}"
|
||||
seq += 1
|
||||
if oss_enabled:
|
||||
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()))
|
||||
if url:
|
||||
urls.append(url)
|
||||
r["seed_shot_urls"] = urls
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 种草图上传失败 {pth}: {e}")
|
||||
else:
|
||||
print(f"[seed_shot] oss 未启用,仅本地保存: {pth}")
|
||||
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
|
||||
if len(products) > 1:
|
||||
print(f"[seed_shot] 并发 {seed_concurrency} 生成种草图({len(products)} 个产品)")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=seed_concurrency) as _ex:
|
||||
for item in _ex.map(_shot_one, products):
|
||||
if item:
|
||||
all_shots.append(item)
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["seed_shot"] = {"count": len(all_shots), "seq": seq}
|
||||
return {"seed_shots": all_shots, "product": products, "oss_seq": seq, "stats": stats}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""节点 9/9:商品上传模板导出(template_export)——在 seed_shot 之后。
|
||||
|
||||
把最终结果导入模板:
|
||||
- SPU货号 / SKU货号 = 设计货号(oss_code,前缀+3位计数)
|
||||
- 商品名称 = cn_title(多模态标题生成)
|
||||
- 英文名称 = en_title
|
||||
- 商品轮播图1:SKU 行按颜色路由(该颜色三合一链接),SPU 行随机一张
|
||||
- 详情图文(SPU 行):全部三合一主图链接 + 种草图链接,| 分割
|
||||
|
||||
需在 oss_upload / seed_shot 之后运行(图床链接与货号已生成)。
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.paths import project_root, runtime_root
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
def _template_out_path(prod_dir: Path, tpl_name: str) -> Path:
|
||||
"""模板输出路径:默认 {tpl_name}_已填写.xlsx;已存在/被占用则自动换名加序号(同款号多产品不互相覆盖)。"""
|
||||
base = prod_dir / f"{tpl_name}_已填写.xlsx"
|
||||
try:
|
||||
with open(base, "ab"):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
if not base.exists():
|
||||
return base
|
||||
for i in range(2, 100):
|
||||
cand = prod_dir / f"{tpl_name}_已填写_{i}.xlsx"
|
||||
if not cand.exists():
|
||||
return cand
|
||||
return prod_dir / f"{tpl_name}_已填写_{int(time.time())}.xlsx"
|
||||
|
||||
|
||||
@with_fallback("template_export")
|
||||
def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
config = state["config"] or {}
|
||||
pcfg = config.get("product") or {}
|
||||
output_dir = Path(state["output_dir"])
|
||||
errors = list(state.get("errors") or [])
|
||||
stats = dict(state.get("stats") or {})
|
||||
|
||||
# 模板写入时机:所有集合/产品(含种草图、OSS)全部完成后才执行本节点
|
||||
print(f"[template] 全部集合({len(products)} 个产品)处理完成,开始统一写入模板…")
|
||||
|
||||
tp = (pcfg.get("template_path") or "").strip()
|
||||
if not tp:
|
||||
print("[template] 未配置 product.template_path,跳过模板导出")
|
||||
return {"stats": stats, "errors": errors}
|
||||
if not Path(tp).exists():
|
||||
cand = None
|
||||
for root in (runtime_root(), project_root()):
|
||||
c = root / tp
|
||||
if c.exists():
|
||||
cand = str(c)
|
||||
break
|
||||
if cand:
|
||||
tp = cand
|
||||
else:
|
||||
print(f"[template] 模板文件不存在: {tp}")
|
||||
return {"stats": stats, "errors": errors}
|
||||
|
||||
# db 路径
|
||||
dbp = (pcfg.get("db_path") or "db/spu_sku.db")
|
||||
db_path = Path(dbp)
|
||||
if not db_path.is_absolute():
|
||||
for root in (runtime_root(), project_root()):
|
||||
if (root / db_path).exists():
|
||||
db_path = root / db_path
|
||||
break
|
||||
|
||||
from graph.template_export import export_product
|
||||
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)
|
||||
|
||||
exported: List[str] = []
|
||||
skipped = 0
|
||||
merged_out: Optional[str] = None # 合并模式:一次任务所有产品填同一个模板
|
||||
is_first = True
|
||||
for r in products:
|
||||
# 失败跳过:合成图(composite/printed)与标题都失败的产品不写进模板
|
||||
has_img = bool(r.get("composite_path") or r.get("printed_path"))
|
||||
has_title = bool((r.get("cn_title") or "").strip())
|
||||
if not (has_img and has_title):
|
||||
skipped += 1
|
||||
print(f"[template] 跳过失败产品 {r.get('spu_code')}/{r.get('img_code','')}: "
|
||||
f"合成图={'有' if has_img else '无'} 标题={'有' if has_title else '无'}(不写入模板)")
|
||||
continue
|
||||
sku_codes = [cc.get("sku_code") for cc in (r.get("color_composites") or [])]
|
||||
if not sku_codes:
|
||||
sku_codes = [r.get("sku_code") or ""]
|
||||
try:
|
||||
if is_first:
|
||||
merged_out = str(_template_out_path(prod_dir, "商品上传"))
|
||||
out = export_product(
|
||||
db_path, r.get("spu_code", ""), sku_codes, tdir, tp,
|
||||
merged_out,
|
||||
images=[],
|
||||
spu_per_color=True, # 每颜色一个独立 SPU 块(单色多 SPU)
|
||||
oss_code=r.get("oss_code") or (r.get("color_composites") or [{}])[0].get("code", ""),
|
||||
cn_title=r.get("cn_title", ""),
|
||||
en_title=r.get("en_title", ""),
|
||||
ja_title=r.get("ja_title", ""),
|
||||
composite_urls=r.get("color_composites") or [],
|
||||
seed_shot_urls=r.get("seed_shot_urls") or [],
|
||||
append_to="" if is_first else merged_out, # 首个产品从模板创建,后续追加合并
|
||||
markup_percent=float(pcfg.get("markup_percent") or 0),
|
||||
)
|
||||
r["template_path"] = str(out)
|
||||
exported.append(str(out))
|
||||
print(f"[template] 商品上传模板已生成({len(exported)}/{len(products)} 合并): {out}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "template_export", "type": type(e).__name__,
|
||||
"message": f"模板导出失败 {r.get('spu_code')}: {e}", "trace": ""})
|
||||
print(f"[template] 模板导出失败 {r.get('spu_code')}: {e}")
|
||||
is_first = False
|
||||
|
||||
stats["template_export"] = {"exported": len(exported)}
|
||||
return {"product": products, "errors": errors, "stats": stats}
|
||||
Reference in New Issue
Block a user