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 排除测试产物
This commit is contained in:
@@ -12,7 +12,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from graph.validate import with_fallback
|
||||
from graph.validate import ThreadSafeErrors, with_fallback
|
||||
|
||||
RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"}
|
||||
|
||||
@@ -29,6 +29,18 @@ def _notify_400(on_400, exc) -> None:
|
||||
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 合规筛选 + 生图提示词)",
|
||||
@@ -112,6 +124,7 @@ 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。
|
||||
|
||||
@@ -123,6 +136,7 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
无参考图或图生图失败 → 回退 ib.generate() 纯文生图。
|
||||
seed: 随机种子(None=不传,网关随机;固定值=可复现,网关支持才生效)。
|
||||
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
|
||||
on_503: 致命图像服务错误(503/账户不可用)时回调(供调用方提前终止任务)。
|
||||
"""
|
||||
try:
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
@@ -145,6 +159,7 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
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)
|
||||
@@ -161,6 +176,7 @@ def generate_design(ib, brief: Dict[str, Any], design_dir: Path, out_stem: str,
|
||||
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": ""})
|
||||
@@ -238,12 +254,13 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def _gen_one(i: int, b: Dict[str, Any]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
|
||||
out_path = generate_design(ib, b, design_dir, f"{country}_{i:02d}",
|
||||
state.get("errors"), seed=_seed,
|
||||
_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))))
|
||||
@@ -263,6 +280,9 @@ def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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)}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
1. 先用种子词走数据源抓取(Google Trends 等),成功即用新数据;
|
||||
2. 抓取失败/无结果才回退 output/<国>/collected_keywords.json 旧缓存,保证流水线不中断。
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.sources import get_source
|
||||
@@ -44,11 +46,9 @@ def fetch_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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"
|
||||
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"))
|
||||
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] # 已过滤去重的关键词
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
2) 真实人物(名单 + Firstname Lastname 模式,仅对 gt_trending 源,避免误删风格词)
|
||||
3) 设计相关性(剔除泛新闻/科技/赛事词)
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.scoring import apply_blacklist, filter_design_relevance, filter_person_names, filter_query_noise
|
||||
@@ -70,15 +73,13 @@ def filter_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 而不是只显示简报(design_briefs 仅含本次限量生成的热点)。
|
||||
if kept:
|
||||
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"
|
||||
p = Path(state.get("cache_dir") or state.get("output_dir", "")) / "collected_keywords.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
kws = [{"topic": r.get("topic", ""), "source": r.get("source", ""),
|
||||
"kind": r.get("kind", ""), "raw_score": r.get("raw_score")} for r in kept]
|
||||
p.write_text(_json.dumps({"country": country,
|
||||
"collected_at": _datetime_now(),
|
||||
"keywords": kws}, ensure_ascii=False, indent=2),
|
||||
p.write_text(json.dumps({"country": country,
|
||||
"collected_at": _datetime_now(),
|
||||
"keywords": kws}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
print(f"[filter] 已写入采集缓存 {len(kws)} 条(collected_keywords.json)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -88,5 +89,4 @@ def filter_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _datetime_now() -> str:
|
||||
import time
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
图池机制:
|
||||
- 从持久化图池(image_pool.json)取「未消费」图片(md5 不在 used_images.json)。
|
||||
- 大图先压缩(内存占用过大 → 缩放/重编码)再送 LLM。
|
||||
- 多并发分析(每批 analyze_per_term 张,并发 analyze_concurrency 线程)。
|
||||
- 多并发分析(每批 1 张,并发 analyze_concurrency 线程;一次 API 请求一张图,利于 LLM 注意力)。
|
||||
- 每张被分析的图片 md5 一律拉黑(used_images.json)——合适→产出简报→生成设计(设计 md5 全局拉黑见 compose);
|
||||
不合适→图片 md5 已拉黑→下一轮自动取下一张,不重复分析。
|
||||
- 图池无未消费图片时返回空,由路由触发新一轮搜索。
|
||||
|
||||
兜底链:LLM 多模态 → 纯文本降级(后端内部)→ mock 规则简报 → 空列表(下游跳过)。
|
||||
兜底链:LLM 多模态分析 → 失败/无图直接放弃本轮(不降级纯文本、不做 mock 兜底)。
|
||||
带 with_fallback:任何异常都不中断。
|
||||
"""
|
||||
import concurrent.futures
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -25,25 +25,14 @@ from graph.pinterest import (
|
||||
pool_unused_images,
|
||||
save_used_images,
|
||||
)
|
||||
from graph.validate import with_fallback
|
||||
|
||||
# 明显不适合 T 恤印花的简报主体(启发式过滤;真实判定交给 LLM 搜索词引导)
|
||||
_BRIEF_UNSUITABLE = re.compile(
|
||||
r"\b(landscape|panorama|scenery|cityscape|street scene|interior|room decor|"
|
||||
r"food photography|meal|dinner plate|recipe|makeup|nails|manicure|"
|
||||
r"weather forecast|map|directions|photorealistic scene|realistic portrait)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
from graph.validate import ThreadSafeErrors, with_fallback
|
||||
|
||||
|
||||
def _brief_suitable(b: Dict[str, Any]) -> bool:
|
||||
"""简报是否适合做 T 恤印花:非侵权(blocked 拦截)+ 有主体 + 非明显非印花概念。"""
|
||||
"""简报是否适合做 T 恤印花:非侵权(blocked 拦截)+ LLM 判定适合印花(suitable_for_print)。"""
|
||||
if str(b.get("risk_level") or "").strip().lower() == "blocked":
|
||||
return False
|
||||
motif = str(b.get("motif") or "").strip()
|
||||
if not motif:
|
||||
return False
|
||||
if _BRIEF_UNSUITABLE.search(motif):
|
||||
if not bool(b.get("suitable_for_print", True)):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -75,25 +64,21 @@ def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str,
|
||||
n = seen_topics.get(base.lower(), 0)
|
||||
seen_topics[base.lower()] = n + 1
|
||||
topic = base if n == 0 else f"{base} #{n + 1}"
|
||||
motif = str(b.get("motif") or "").strip() or term
|
||||
if not motif:
|
||||
continue
|
||||
if not bool(b.get("suitable_for_print", True)):
|
||||
continue # LLM 判定不适合做印花 → 丢弃(md5 已拉黑,下轮取新图)
|
||||
out.append({
|
||||
"country": country,
|
||||
"topic": topic,
|
||||
"risk_level": str(b.get("risk_level") or "safe").strip().lower() or "safe",
|
||||
"safe_for_print": bool(b.get("safe_for_print", True)),
|
||||
"suitable_for_print": bool(b.get("suitable_for_print", True)),
|
||||
"suitable_for_print": True,
|
||||
"design_category": classify(term),
|
||||
"concept": str(b.get("concept") or "").strip() or f"围绕「{term}」的原创印花设计",
|
||||
"motif": motif,
|
||||
"art_style": str(b.get("art_style") or "").strip(),
|
||||
"color_palette": str(b.get("color_palette") or "").strip(),
|
||||
"composition": str(b.get("composition") or "").strip(),
|
||||
"concept": f"围绕「{term}」的原创印花设计",
|
||||
"negative_prompt": str(b.get("negative_prompt") or "").strip(),
|
||||
"image_prompt": str(b.get("image_prompt") or "").strip(),
|
||||
"ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)],
|
||||
"source_md5": str(b.get("source_md5") or "").strip().lower(),
|
||||
"brief_id": str(b.get("brief_id") or "").strip(),
|
||||
"slogan": "",
|
||||
"score": 1.0,
|
||||
"confidence": 1.0,
|
||||
@@ -110,23 +95,25 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
pcfg = config.get("pinterest") or {}
|
||||
analyze_per_term = int(pcfg.get("analyze_per_term", 1))
|
||||
analyze_per_term = 1 # 固定一次 API 请求分析 1 张图(利于 LLM 注意力;不再用 analyze_per_term 配置)
|
||||
concurrency = int(pcfg.get("analyze_concurrency", 3))
|
||||
max_designs = int(pcfg.get("max_designs", 10))
|
||||
n_ref = max(1, int(pcfg.get("ref_images_per_design", 1)))
|
||||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||||
|
||||
# 按需分析:只取补齐到目标所需的图片数(batch_size 为上限,不超额分析),并按 md5 去重,
|
||||
# 保证同一图片内容(md5)不会同时被多条简报使用
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
existing = state.get("briefs") or []
|
||||
remaining = max(0, target - len(existing))
|
||||
# 简报池还有待处理/在途简报 → 不分析新图(等后台消化完再由路由决定下一步)
|
||||
pipe = state.get("pinterest_pipeline")
|
||||
if pipe is not None and hasattr(pipe, "pending_count") and pipe.pending_count() > 0:
|
||||
print(f"[pinterest_analyze] 简报池还有 {pipe.pending_count()} 条在途,本轮不分析")
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
|
||||
# 按需分析:一次把图池当前未消费图片全部分析成简报(搜集完一批→生成一批简报),
|
||||
# 不再按目标任务数截断(不够用了才由路由触发新采集)。analyze_batch 可设上限保护配额。
|
||||
batch_size = int(pcfg.get("analyze_batch", 0))
|
||||
if batch_size <= 0:
|
||||
# 自动:一次分析补齐到「目标所需」或「每词简报上限」的较小值(每张图→1条简报),
|
||||
# 让 pipeline 队列一次有足够任务,时刻保持并发生成(避免每轮只推 6 条导致线程空转)
|
||||
batch_size = min(remaining, max_designs)
|
||||
need = min(batch_size, remaining) if remaining > 0 else 0
|
||||
batch_size = max_designs # 自动:一次最多分析 max_designs 张(每张图→1条简报)
|
||||
need = batch_size
|
||||
|
||||
# 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索
|
||||
pool = load_image_pool(output_dir, country)
|
||||
@@ -137,10 +124,6 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
|
||||
if need <= 0:
|
||||
print("[pinterest_analyze] 简报已达标,无需分析")
|
||||
return {"pinterest_briefs": [], "briefs": state.get("briefs") or [],
|
||||
"errors": errors}
|
||||
seen_md5: set = set()
|
||||
batch: List[Dict[str, Any]] = []
|
||||
for img in unused:
|
||||
@@ -148,34 +131,27 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if m and m in seen_md5:
|
||||
continue # 同一图片内容(md5)不重复分析
|
||||
seen_md5.add(m)
|
||||
img["brief_id"] = str(uuid.uuid4()) # md5 校验后分配全局唯一 id,用于图-简报对应校验
|
||||
batch.append(img)
|
||||
if len(batch) >= need:
|
||||
break
|
||||
print(f"[pinterest_analyze] 图池取 {len(batch)} 张未消费图片分析(按需 {need},"
|
||||
print(f"[pinterest_analyze] 图池取 {len(batch)} 张未消费图片分析(本批上限 {need},"
|
||||
f"池剩余未消费 {len(unused) - len(batch)} 张,已消费 {len(used)} 张)")
|
||||
|
||||
def _assign_refs(res: List[Dict[str, Any]], chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""按简报的 image_index(LLM 返回)匹配它实际分析的图,写入 source_md5 + ref_images。
|
||||
"""每批只分析 1 张图:简报按顺序对应 chunk 里唯一一张图,写入 source_md5 + ref_images + brief_id。
|
||||
|
||||
全局 id 校验:简报必须带 image_index(对应输入第几张图,0-based);
|
||||
无 image_index(mock 兜底)→ 回退按顺序;无效/越界/重复 → 丢弃该简报(避免错位)。
|
||||
这样 analyze_per_term 可 >1 一次分析多张图提速,简报仍严格对应各自的图。
|
||||
全局 id 校验:分析前已给每张图分配 uuid4(brief_id),简报回填同一 brief_id,
|
||||
保证图-简报严格对应(一次 API 请求一张图,无 image_index 错位问题)。
|
||||
"""
|
||||
chunk_paths = [img["path"] for img in chunk]
|
||||
chunk_md5s = [str(img.get("md5") or "").strip().lower() for img in chunk]
|
||||
used_idx: set = set()
|
||||
chunk_ids = [str(img.get("brief_id") or "") for img in chunk]
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(res):
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
try:
|
||||
idx = int(b.get("image_index"))
|
||||
except (TypeError, ValueError):
|
||||
idx = i # 无 image_index → 回退按顺序
|
||||
if idx < 0 or idx >= len(chunk_paths) or idx in used_idx:
|
||||
print(f"[pinterest_analyze] 简报 image_index={idx} 无效/重复,丢弃(避免图-简报错位)")
|
||||
continue
|
||||
used_idx.add(idx)
|
||||
idx = i if i < len(chunk_paths) else 0 # 一次一张:简报按顺序对应唯一图
|
||||
refs: List[str] = []
|
||||
for k in range(n_ref):
|
||||
src = chunk_paths[(idx + k) % len(chunk_paths)]
|
||||
@@ -183,6 +159,7 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
refs.append(src)
|
||||
b["ref_images"] = refs
|
||||
b["source_md5"] = chunk_md5s[idx] if idx < len(chunk_md5s) else ""
|
||||
b["brief_id"] = chunk_ids[idx] if idx < len(chunk_ids) else ""
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
@@ -210,6 +187,7 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
# 4) 多并发分析(每线程分析一个 chunk;LLM 后端只读 self._cfg,线程安全)
|
||||
raw_briefs: List[Dict[str, Any]] = []
|
||||
_safe_errors = ThreadSafeErrors()
|
||||
|
||||
def _analyze_chunk(chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
term = str(chunk[0].get("term") or "")
|
||||
@@ -225,33 +203,27 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 把每条简报的来源图路径回填为原始图(压缩图仅用于分析,参考图用原图)
|
||||
return _assign_refs(res, chunk)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "pinterest_analyze", "type": type(e).__name__,
|
||||
"message": f"term[{term}] batch@{len(chunk)}: {e}", "trace": ""})
|
||||
_safe_errors.append({"node": "pinterest_analyze", "type": type(e).__name__,
|
||||
"message": f"term[{term}] batch@{len(chunk)}: {e}", "trace": ""})
|
||||
print(f"[pinterest_analyze] 「{term}」分析失败: {e}")
|
||||
return []
|
||||
return []
|
||||
|
||||
workers = max(1, min(concurrency, len(chunks)))
|
||||
if len(chunks) > 1:
|
||||
print(f"[pinterest_analyze] 并发分析 {len(chunks)} 批({workers} 线程,每批 {analyze_per_term} 张)…")
|
||||
print(f"[pinterest_analyze] 并发分析 {len(chunks)} 批({workers} 线程,每批 1 张)…")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as _ex:
|
||||
_futs = [_ex.submit(_analyze_chunk, c) for c in chunks]
|
||||
for _f in concurrent.futures.as_completed(_futs):
|
||||
raw_briefs.extend(_f.result())
|
||||
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
|
||||
if len(_safe_errors):
|
||||
errors.extend(list(_safe_errors))
|
||||
|
||||
# 5) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)
|
||||
# 5) 真实 LLM 图片分析无结果(失败/无有效图片)→ 直接放弃本轮,跳过后续流程
|
||||
# 不再 mock 兜底生成设计(用户要求:分析失败即放弃该产品)
|
||||
if not raw_briefs:
|
||||
try:
|
||||
mock = get_backend("mock")
|
||||
for chunk in chunks:
|
||||
term = str(chunk[0].get("term") or "")
|
||||
paths = [compressed_map.get(img["path"], img["path"]) for img in chunk]
|
||||
res = mock.analyze_pinterest_images(paths, term, country) or []
|
||||
_assign_refs(res, chunk)
|
||||
raw_briefs.extend(res)
|
||||
print(f"[pinterest_analyze] 兜底:mock 规则简报 {len(raw_briefs)} 条")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[pinterest_analyze] mock 兜底失败: {e}")
|
||||
print("[pinterest_analyze] 图片分析无结果,放弃本轮简报(跳过后续流程)")
|
||||
|
||||
# 6) 本批所有图片 md5 一律拉黑(已消费,不再复用)——合适/不合适都拉黑
|
||||
for img in batch:
|
||||
@@ -267,19 +239,20 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if len(kept) < len(raw_briefs):
|
||||
print(f"[pinterest_analyze] 简报过滤:{len(raw_briefs)} → {len(kept)} 条适合印花")
|
||||
|
||||
# 8) 上限 + 去重(同 motif+style 指纹只留一条)
|
||||
# 8) 上限 + 去重(同 brief_id 只留一条;brief_id 为每张图 uuid4,天然唯一)
|
||||
kept = kept[:max_designs]
|
||||
seen: set = set()
|
||||
uniq: List[Dict[str, Any]] = []
|
||||
for b in kept:
|
||||
fp = f"{str(b.get('motif', '')).strip().lower()}|{str(b.get('art_style', '')).strip().lower()}"
|
||||
if fp in seen:
|
||||
fp = str(b.get("brief_id") or b.get("image_prompt") or "").strip().lower()
|
||||
if not fp or fp in seen:
|
||||
continue
|
||||
seen.add(fp)
|
||||
uniq.append(b)
|
||||
kept = uniq
|
||||
|
||||
# 9) 富化 → screened → prompt_node 装配提示词 → 标准 briefs(追加到累计,按需截断到目标数)
|
||||
# 9) 富化 → screened → prompt_node 装配提示词 → 标准 briefs(追加到累计)
|
||||
# 不再按目标任务数截断:搜集完一批→生成一批简报,不够用了才由路由触发新采集
|
||||
existing_topics = [str(b.get("topic", "")).strip() for b in (state.get("briefs") or [])]
|
||||
screened = _enrich_briefs(kept, country, existing_topics)
|
||||
new_briefs: List[Dict[str, Any]] = []
|
||||
@@ -290,10 +263,6 @@ def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
old_count = len(state.get("briefs") or [])
|
||||
accumulated = list(state.get("briefs") or [])
|
||||
accumulated.extend(new_briefs)
|
||||
target = int(state.get("pinterest_target") or 0)
|
||||
if target > 0 and len(accumulated) > target:
|
||||
accumulated = accumulated[:target]
|
||||
print(f"[pinterest_analyze] 简报已达目标 {target} 条,截断多余部分")
|
||||
|
||||
# 推送实际新增且保留的简报到并发生成流水线(简报池):边分析边生成设计/三合一/种草图
|
||||
pushed = accumulated[old_count:]
|
||||
|
||||
@@ -55,6 +55,31 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
headless = bool(pcfg.get("headless", False))
|
||||
proxy = pcfg.get("proxy") or None
|
||||
search_mode = str(pcfg.get("search_mode") or "direct").strip().lower()
|
||||
login_check = bool(pcfg.get("login_check", True))
|
||||
login_wait = bool(pcfg.get("login_wait", False))
|
||||
|
||||
# 爬取前静态检测 Pinterest 登录态(不启动 Chrome,只读 .chrome_session cookies):
|
||||
# 未登录/无会话 → 跳过本轮爬取并告警,避免每个搜索词都启动 Chrome 后才发现未登录。
|
||||
login_state: Dict[str, Any] = {"status": "unknown"}
|
||||
if login_check:
|
||||
try:
|
||||
from pinterest_scraper.pinterest_image_capture import check_login_state
|
||||
login_state = check_login_state()
|
||||
except Exception as e: # noqa: BLE001
|
||||
login_state = {"status": "unknown", "detail": f"登录态检测失败: {e}"}
|
||||
status = login_state.get("status")
|
||||
if status in ("logged_out", "no_session"):
|
||||
print(f"[pinterest_scrape] ⚠️ 未检测到 Pinterest 登录态({login_state.get('detail')})。"
|
||||
f"跳过本轮 {len(terms)} 个搜索词爬取。")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["pinterest_scrape"] = {
|
||||
"terms": len(terms), "scraped": 0, "skipped": 0, "failed": 0,
|
||||
"images": 0, "pool": len((load_image_pool(output_dir, country).get("images")) or []),
|
||||
"login_status": status,
|
||||
}
|
||||
return {"pinterest_images": {}, "pinterest_attempted": state.get("pinterest_attempted") or [],
|
||||
"pinterest_login": login_state, "stats": stats, "errors": errors}
|
||||
print(f"[pinterest_scrape] 登录态检测:{status}({login_state.get('detail')})")
|
||||
|
||||
# 所有搜索词共享同一个 .chrome_session 登录态目录,Chrome 对同一 user-data-dir 是单例,
|
||||
# 并发启动会互相抢占导致 "browser has been closed",必须串行爬取。
|
||||
@@ -62,7 +87,8 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
print(f"[pinterest_scrape] 共享登录态目录不支持并发,scrape_concurrency 强制为 1(原 {concurrency})")
|
||||
concurrency = 1
|
||||
|
||||
# 一次性探测并校验代理(Pinterest 需代理才能访问;代理失效时给出明确警告,避免逐词静默失败)
|
||||
# 默认走代理:config.pinterest.proxy 未配置时自动探测(环境变量/系统代理/本地常见端口),
|
||||
# 本地 VPN 已开启时探测到的代理即可访问 Pinterest;代理失效时给出明确警告,避免逐词静默失败。
|
||||
if proxy is None:
|
||||
try:
|
||||
from pinterest_scraper.pinterest_image_capture import detect_proxy, get_system_proxy, _validate_proxy
|
||||
@@ -91,7 +117,8 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
from pinterest_scraper.scraper import scrape_pinterest
|
||||
files = scrape_pinterest(term, count=images_per_term,
|
||||
save_dir=str(term_dir), proxy=proxy, headless=headless)
|
||||
save_dir=str(term_dir), proxy=proxy, headless=headless,
|
||||
login_wait=login_wait)
|
||||
results[term] = files
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed.append(term)
|
||||
@@ -150,8 +177,9 @@ def pinterest_scrape_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
stats["pinterest_scrape"] = {
|
||||
"terms": len(terms), "scraped": len(results), "skipped": len(skipped),
|
||||
"failed": len(failed), "images": total, "pool": len(pool.get("images") or []),
|
||||
"login_status": login_state.get("status", "unknown"),
|
||||
}
|
||||
print(f"[pinterest_scrape] 完成:{len(results)} 个搜索词,共 {total} 张图(跳过 {len(skipped)},失败 {len(failed)})")
|
||||
|
||||
return {"pinterest_images": results, "pinterest_attempted": attempted,
|
||||
"stats": stats, "errors": errors}
|
||||
"pinterest_login": login_state, "stats": stats, "errors": errors}
|
||||
|
||||
+103
-47
@@ -34,22 +34,18 @@ 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
|
||||
from graph.validate import ThreadSafeErrors, 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():
|
||||
@@ -116,17 +112,30 @@ def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
|
||||
return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"
|
||||
|
||||
|
||||
def _is_fatal_50x(e) -> bool:
|
||||
"""致命图像服务错误(503 / No available compatible accounts)→ 不重试,提前终止。"""
|
||||
try:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
return PinterestPipeline.is_fatal_503(e)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
|
||||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。"""
|
||||
import time as _t
|
||||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。
|
||||
|
||||
致命 503(账户不可用)重试无效 → 直接抛出,交由调用方终止任务。
|
||||
"""
|
||||
last = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _is_fatal_50x(e):
|
||||
raise
|
||||
last = e
|
||||
if i < attempts - 1:
|
||||
_t.sleep(backoff[i])
|
||||
time.sleep(backoff[i])
|
||||
print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
|
||||
return None
|
||||
|
||||
@@ -135,16 +144,31 @@ def _process_spu(
|
||||
db_path, basemap_root, material_root, category, prod_dir, brief, ib,
|
||||
spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
|
||||
img_code="", model_img=None, design_size="1024x1024", compose_size="1536x2048",
|
||||
on_503=None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
|
||||
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
|
||||
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
|
||||
(按货号命名,包含该货号对应的所有图片)。
|
||||
on_503: 致命图像服务错误(503/账户不可用)回调(供调用方提前终止任务)。
|
||||
返回 result dict;内部异常已兜底,不中断。
|
||||
"""
|
||||
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
|
||||
prod_dir = prod_dir / img_code
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _fatal(e) -> bool:
|
||||
"""致命图像服务错误(503/账户不可用)→ 通知 on_503 并返回 True(调用方应立即终止)。"""
|
||||
try:
|
||||
from graph.pinterest_pipeline import PinterestPipeline
|
||||
if PinterestPipeline.is_fatal_503(e):
|
||||
if on_503 is not None:
|
||||
on_503()
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
colors = list_colors(db_path, spu["code"])
|
||||
valid_codes = {c["sku_code"] for c in colors}
|
||||
if sku_code:
|
||||
@@ -220,6 +244,9 @@ def _process_spu(
|
||||
result["design_from"] = "product"
|
||||
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
|
||||
print(f"{tag} 设计稿生成失败: {e}")
|
||||
|
||||
@@ -262,17 +289,26 @@ def _process_spu(
|
||||
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=compose_size)
|
||||
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}")
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
print(f"{tag} 三图合成失败,退避重试…: {e}")
|
||||
try:
|
||||
retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], size=compose_size)
|
||||
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
|
||||
except Exception as e2: # noqa: BLE001
|
||||
if _fatal(e2):
|
||||
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
|
||||
return None
|
||||
raise
|
||||
else:
|
||||
# mark=1 无模特图 → 统一只做三合一,不做印花+底图两图合成
|
||||
if int(spu.get("mark") or 0) == 1:
|
||||
@@ -289,17 +325,26 @@ def _process_spu(
|
||||
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=compose_size)
|
||||
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}")
|
||||
if _fatal(e):
|
||||
print(f"{tag} 图像服务 503,终止: {e}")
|
||||
return None
|
||||
print(f"{tag} 平铺服装图失败,退避重试…: {e}")
|
||||
try:
|
||||
retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], size=compose_size)
|
||||
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
|
||||
except Exception as e2: # noqa: BLE001
|
||||
if _fatal(e2):
|
||||
print(f"{tag} 图像服务 503(重试时),终止: {e2}")
|
||||
return None
|
||||
raise
|
||||
|
||||
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
|
||||
color_composites: List[Dict[str, Any]] = []
|
||||
@@ -322,6 +367,9 @@ def _process_spu(
|
||||
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
|
||||
print(f"{tag} 颜色 {sc}({col})三合一已生成: {cp}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
if _fatal(e):
|
||||
print(f"{tag} 颜色 {sc} 三合一遇 503,终止: {e}")
|
||||
return None
|
||||
errors.append({"node": "product", "type": type(e).__name__,
|
||||
"message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
|
||||
result["color_composites"] = color_composites
|
||||
@@ -445,23 +493,25 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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] = []
|
||||
# 4.1) 任务级模特分配(按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽取):
|
||||
# 每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复
|
||||
model_assign: Dict[int, Any] = {}
|
||||
try:
|
||||
_folder, _all_models = find_first_model_folder(material_root, category)
|
||||
from graph.product import build_mark_model_map, find_model_images_for_mark
|
||||
mark_map = build_mark_model_map(db_path, material_root)
|
||||
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 ''}")
|
||||
mark_map = {}
|
||||
for _i, (_spu, _skus, _tb) in enumerate(worklist):
|
||||
mark = str(_spu.get("mark") or "").strip() or "1"
|
||||
folder = mark_map.get(mark, category)
|
||||
try:
|
||||
pool_imgs = find_model_images_for_mark(db_path, material_root, mark, folder)
|
||||
except Exception: # noqa: BLE001
|
||||
pool_imgs = []
|
||||
if pool_imgs:
|
||||
model_assign[_i] = random.choice(pool_imgs) # 过滤后随机抽(按任务序号)
|
||||
if model_assign:
|
||||
print(f"[product] 任务级模特分配:{len(model_assign)} 个产品任务(按 mark 过滤 3:4 后随机抽取)")
|
||||
|
||||
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
|
||||
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
|
||||
@@ -503,8 +553,11 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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):
|
||||
_safe_errors = ThreadSafeErrors()
|
||||
|
||||
def _run_one(wi: int, spu, skus, tb):
|
||||
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
|
||||
idx = start_idx + wi # 实际货号序号(start_idx 起自动续号)
|
||||
img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…)
|
||||
try:
|
||||
# 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png)
|
||||
@@ -519,9 +572,9 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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,
|
||||
tb, ib, spu, skus, pcfg, _safe_errors, design_path, title_backend,
|
||||
country, img_code=img_code,
|
||||
model_img=model_assign.get(spu.get("code", "")),
|
||||
model_img=model_assign.get(wi), # 按任务序号取独立随机模特
|
||||
design_size=str((config.get("compose") or {}).get("design_size") or "1024x1024"),
|
||||
compose_size=str((config.get("compose") or {}).get("size") or "1536x2048"))
|
||||
if r:
|
||||
@@ -535,7 +588,7 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 货号自动续号:任务一开始全部按序分配(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)
|
||||
futures = [ex.submit(_run_one, 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()
|
||||
@@ -545,6 +598,9 @@ def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
_record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加)
|
||||
if len(_safe_errors):
|
||||
errors.extend(list(_safe_errors))
|
||||
|
||||
results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定
|
||||
_write_products(prod_dir, results)
|
||||
|
||||
+44
-18
@@ -22,6 +22,27 @@ PINTEREST_PRINT_SUFFIX = (
|
||||
"no garment, no shirt, no model, no mannequin, no watermark"
|
||||
)
|
||||
|
||||
# Pinterest 生图提示词 4 段结构中第 3 段的引导前缀:把 LLM 产出的 negative_prompt
|
||||
# 转成一条正向「Strictly avoid: ...」条款拼进 image_prompt,让防复制/防商标约束落到生成指令
|
||||
NEG_LEAD = "Strictly avoid: "
|
||||
|
||||
# Pinterest 生图提示词 4 段结构中第 4 段(仅当设计含文字时追加):
|
||||
# 要求模型把引号内的文字按原文逐字正确拼写,避免乱码/拼错
|
||||
SPELLING_RULE = (
|
||||
"Render every phrase shown in quotes exactly as written, "
|
||||
"correctly spelled."
|
||||
)
|
||||
|
||||
# review(疑似商标/受保护主题)简报统一追加的「原创化魔改」引导段:
|
||||
# 只做风格参考,禁复刻品牌/商标/角色,换名换细节,生成通用非侵权致敬式设计。
|
||||
# 两个模式(热点采集 / Pinterest 参考)共用同一文本,避免不一致。
|
||||
REVIEW_REBRAND_HINT = (
|
||||
"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."
|
||||
)
|
||||
|
||||
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
|
||||
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
|
||||
_IMG_RISKY_SWAP = {
|
||||
@@ -73,27 +94,32 @@ def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
composition = (r.get("composition") or derive_composition(r["topic"], r.get("design_category"))).strip()
|
||||
|
||||
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
|
||||
# Pinterest 简报:直接用 LLM 多模态对图片的描述拼接的 image_prompt(跳过四要素模板),
|
||||
# 仅追加统一的「小印花 + 纯白底」约束段;wearable/composite 仍用模板装配。
|
||||
llm_ip = (r.get("image_prompt") or "").strip()
|
||||
llm_neg = (r.get("negative_prompt") or "").strip()
|
||||
if r.get("source") == "pinterest" and llm_ip:
|
||||
prompts["image_prompt"] = llm_ip + PINTEREST_PRINT_SUFFIX
|
||||
print(f"[prompt] Pinterest 简报用 LLM 多模态描述作为 image_prompt(跳过四要素模板): 「{r['topic']}」")
|
||||
# 文字印花(约 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(疑似商标/受保护主题)→ 动态注入「原创化魔改」引导:只做风格参考,禁止复刻品牌/商标/角色,
|
||||
# 换名换细节,生成通用非侵权的致敬式设计
|
||||
# —— Pinterest 参考模式:跳过四要素模板,按 4 段结构拼 image_prompt ——
|
||||
# ① image_prompt(分析模型产出) + 固定输出形态后缀 PINTEREST_PRINT_SUFFIX
|
||||
# ② 负向条款(由 LLM negative_prompt 经 NEG_LEAD 引导,转化进正向指令)
|
||||
# ③ 拼写锁定句 SPELLING_RULE(仅当 LLM image_prompt 已含引号文字段时)
|
||||
# 是否含文字、拼写与否均由分析模型产出决定,本模式不注入 slogan。
|
||||
seg: List[str] = [llm_ip, PINTEREST_PRINT_SUFFIX.strip()]
|
||||
if llm_neg:
|
||||
seg.append(NEG_LEAD + llm_neg)
|
||||
if '"' in llm_ip:
|
||||
seg.append(SPELLING_RULE)
|
||||
prompts["image_prompt"] = ", ".join(seg)
|
||||
print(f"[prompt] Pinterest 简报按 4 段结构拼 image_prompt(跳过四要素模板): 「{r['topic']}」")
|
||||
else:
|
||||
# —— 热点采集模式:四要素模板装配 + 文字印花(约 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.")
|
||||
prompts["image_prompt"] = prompts["image_prompt"] + " " + REVIEW_REBRAND_HINT
|
||||
print(f"[prompt] review 简报注入原创化魔改引导: 「{r['topic']}」")
|
||||
r.update(prompts)
|
||||
r["motif"] = motif
|
||||
|
||||
@@ -20,14 +20,8 @@ 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
|
||||
if not base.exists():
|
||||
return base
|
||||
for i in range(2, 100):
|
||||
cand = prod_dir / f"{tpl_name}_已填写_{i}.xlsx"
|
||||
if not cand.exists():
|
||||
@@ -95,6 +89,7 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sku_codes = [r.get("sku_code") or ""]
|
||||
batch.append({
|
||||
"spu_code": r.get("spu_code", ""),
|
||||
"img_code": r.get("img_code", ""),
|
||||
"sku_codes": sku_codes,
|
||||
"images": [],
|
||||
"spu_per_color": True, # 每颜色一个独立 SPU 块(单色多 SPU)
|
||||
@@ -107,13 +102,24 @@ def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"seed_shot_urls": r.get("seed_shot_urls") or [],
|
||||
})
|
||||
|
||||
# 写入模板前按货号(前端自定义前缀+3位计数,如 DG001)最后3位从小到大排序,按顺序插入
|
||||
def _tail_num(rec: Dict[str, Any]) -> tuple:
|
||||
code = str(rec.get("img_code") or rec.get("oss_code") or "")
|
||||
try:
|
||||
return (0, int(code[-3:]))
|
||||
except ValueError:
|
||||
return (1, 0)
|
||||
batch.sort(key=_tail_num)
|
||||
|
||||
exported: List[str] = []
|
||||
if batch:
|
||||
out = _template_out_path(prod_dir, "商品上传")
|
||||
# 输出文件名 = 模板原文件名 + _已填写(如 NEW-波兰男黑T恤_已填写.xlsx)
|
||||
out = _template_out_path(prod_dir, Path(tp).stem)
|
||||
try:
|
||||
out = export_products(
|
||||
db_path, batch, tdir, tp, str(out),
|
||||
markup_percent=float(pcfg.get("markup_percent") or 0),
|
||||
suggested_price_ratio=float(pcfg.get("suggested_price_ratio") or 0),
|
||||
)
|
||||
for r in products:
|
||||
if (r.get("composite_path") or r.get("printed_path")) and (r.get("en_title") or "").strip():
|
||||
|
||||
Reference in New Issue
Block a user