Files
pod_trend_agent/graph/nodes/compose_node.py
T

218 lines
11 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
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.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 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]):
"""单张设计稿生成(并发线程内调用,每设计一线程)。
Pinterest 参考模式:简报带 ref_images(爬取图)→ 用 ib.print() 图生图,
把爬取图 + 多模态分析简报(已封装进 image_prompt)一起发给生图模型;
无参考图或图生图失败 → 回退 ib.generate() 纯文生图。
"""
try:
img_prompt = sanitize_image_prompt(b.get("image_prompt", ""))
img_prompt = ensure_rebrand_hint(b, img_prompt) # review → 原创化魔改引导
out_path = str(design_dir / f"{country}_{i:02d}_design.png")
ref_images = [str(p) for p in (b.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,
b.get("composite_negative", ""),
extra_images=ref_images[1:] or None,
size="1024x1024") # 印花设计统一 1024x1024
except Exception as e: # noqa: BLE001
print(f"[compose] 图生图(参考图)失败,回退文生图 {b.get('topic','')}: {e}")
out_path = ib.generate(
img_prompt, str(design_dir / f"{country}_{i:02d}_design.png"),
b.get("composite_negative", ""), size="1024x1024")
else:
out_path = ib.generate(
img_prompt, str(design_dir / f"{country}_{i:02d}_design.png"),
b.get("composite_negative", ""), size="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 []}