Files
pod_trend_agent/graph/nodes/compose_node.py
T
3218485270 2a96ec0870 v88 功能增强:产品落盘持久化 + 生图网关适配 + 模板导出优化
- 产品持久化:每完成一个产品立即追加写入 products_pending.jsonl,崩溃不丢已完成产品,finish 读盘合并后统一写模板
- 503 致命错误提前终止:compose/product/seed_shot 端到端识别,提前终止搜索分析,丢弃未完成简报,保留已完成落盘产品直接合成模板
- 模特分配:material_library 合格模特图按任务序号独立随机,同 SPU 多款不再共用同一模特
- 图像网关适配:execution_mode/background 默认不再传入 yunfei 等标准网关,base_url 需带 /v1;429/5xx/空响应退避重试
- Pinterest 分析:删除 term 注入与纯文本降级,失败直接放弃;图片上传前 PIL 完整性校验;suitable_for_print=False 过滤丢弃
- 模板导出:不再产生空白 xlsx,文件名=模板原文件名_已填写;写入前按货号末 3 位升序排序
- 删除对接文档.md,更新 README,gitignore 排除测试产物
2026-08-28 10:28:35 +08:00

291 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""节点 5.5/6:生成印花设计稿 + 导出简报包(compose)。
流程位置:prompt_build → compose → productcompose 在 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, Optional
from graph.validate import ThreadSafeErrors, with_fallback
RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"}
def _notify_400(on_400, exc) -> None:
"""HTTP 400(且含「内容/图片」)时触发 on_400 回调(供调用方累计放弃计数)。"""
if on_400 is None:
return
try:
from graph.pinterest import is_400_content_image
if is_400_content_image(exc):
on_400()
except Exception: # noqa: BLE001
pass
def _notify_503(on_503, exc) -> None:
"""致命图像服务错误(503 / 账户不可用)时触发 on_503 回调(供调用方提前终止任务)。"""
if on_503 is None:
return
try:
from graph.pinterest_pipeline import PinterestPipeline
if PinterestPipeline.is_fatal_503(exc):
on_503()
except Exception: # noqa: BLE001
pass
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)
def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
errors: List[Dict[str, Any]] = None,
seed: Optional[int] = None,
on_400=None,
on_503=None,
size: str = "1024x1024") -> Optional[str]:
"""生成单张纯印花设计稿(图2)。返回设计稿路径;失败 / 全局 MD5 重复返回 None。
out_stem: 输出文件名主干(不含扩展名),最终文件 = {out_stem}_design.png。
货号模式传 img_code(如 DG000)→ designs/DG000_design.png
旧 compose 模式传 {country}_{idx:02d}(如 JP_01)→ designs/JP_01_design.png。
Pinterest 参考模式:简报带 ref_images(爬取图)→ 用 ib.print() 图生图,
把爬取图 + 多模态分析简报(已封装进 image_prompt)一起发给生图模型;
无参考图或图生图失败 → 回退 ib.generate() 纯文生图。
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
on_503: 致命图像服务错误(503/账户不可用)时回调(供调用方提前终止任务)。
"""
try:
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
img_prompt = sanitize_image_prompt(brief.get("image_prompt", ""))
img_prompt = ensure_rebrand_hint(brief, img_prompt) # review → 原创化魔改引导
out_path = str(design_dir / f"{out_stem}_design.png")
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
except Exception as e: # noqa: BLE001
print(f"[compose] 图生图(参考图)失败,回退文生图 {brief.get('topic','')}: {e}")
_notify_400(on_400, e)
_notify_503(on_503, e)
out_path = ib.generate(
img_prompt, str(design_dir / f"{out_stem}_design.png"),
brief.get("composite_negative", ""), size=size, seed=seed)
else:
out_path = ib.generate(
img_prompt, str(design_dir / f"{out_stem}_design.png"),
brief.get("composite_negative", ""), size=size, seed=seed)
# 全局 MD5 去重:生成了设计后,把 MD5 加入全局过滤(对所有国家生效);
# 已存在的重复设计 → 跳过(不用于产品),避免跨国家重复使用同一设计
from graph.pinterest import design_md5_ok
if not design_md5_ok(out_path):
print(f"[compose] 设计稿 MD5 全局重复,跳过(不用于产品): {out_path}")
return None
return out_path
except Exception as e: # noqa: BLE001
_notify_400(on_400, e)
_notify_503(on_503, e)
if errors is not None:
errors.append({"node": "compose", "type": type(e).__name__,
"message": f"设计稿生成失败 {brief.get('topic','')}: {e}", "trace": ""})
print(f"[compose] 设计稿生成失败 {brief.get('topic', '')}: {e}")
return None
def write_compose_reports(state: Dict[str, Any], briefs: List[Dict[str, Any]]) -> None:
"""写 compose 阶段简报报告(design_briefs / composite_prompts / report.md)。
Pinterest 并发生成模式下 compose_node 不再整体执行,由收尾节点调用本函数补写报告。
"""
output_dir = Path(state["output_dir"])
output_dir.mkdir(parents=True, exist_ok=True)
cache_dir = Path(state.get("cache_dir") or output_dir)
generated_at = time.strftime("%Y-%m-%dT%H:%M:%S")
(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")
(cache_dir / "design_briefs.md").write_text(
_build_briefs_md(briefs, generated_at), encoding="utf-8")
(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")
(output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8")
@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", "")
# 1-4) 简报报告(design_briefs / composite_prompts / report.md
write_compose_reports(state, briefs)
# 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.backendopenai/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 concurrent.futures import ThreadPoolExecutor, as_completed
# 随机种子:config compose.seed >0 时固定(可复现,网关支持才生效);0/留空=每次随机
_seed = int(compose_cfg.get("seed") or 0)
_seed = _seed if _seed > 0 else None
def _gen_one(i: int, b: 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"))
if out_path is None:
return i, b, None, None
return i, b, out_path, None
_safe_errors = ThreadSafeErrors()
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": ""})
elif out_path is None:
print(f"[compose] 设计稿跳过(MD5 全局去重): {b.get('topic', '')}")
else:
b["design_path"] = out_path
designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path})
print(f"[compose] 印花设计稿已生成: {out_path}")
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
if len(_safe_errors):
state.setdefault("errors", []).extend(list(_safe_errors))
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 []}