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:
@@ -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:]
|
||||
|
||||
Reference in New Issue
Block a user