Files
pod_trend_agent/graph/nodes/compose_node.py
T
3218485270 5ab5cf6586 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=运动
- 童装特征库更新 + 标题模板外部化 + 图源映射增强
2026-09-03 18:28:39 +08:00

301 lines
16 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",
custom_mode: bool = False) -> 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:
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)
_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"),
custom_mode=bool(state.get("custom_mode")))
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 []}