v89-v91 模板增强 + 图源映射 + 多模态提示词可配置化

- 图源映射统一:热点采集与 Pinterest 模式均走 config.product.mark_dirs 配置,按任务序号随机抽模特图/平铺图
- 商品产地固定:统一为「中国大陆」+「产地省份=广东省」(不再读站点/字典映射)
- 模板 SKU 字段检测:按建议售价同一套路检测 SKU分类/SKU数量/SKU数量单位,必填时填入单品/1/件
- 多模态分析提示词可配置:prompts/pinterest_analyze_system.md + user.md,支持国家覆盖,不丢文件回退内置
- 自定义图片模式:新增 pinterest_custom_load_node,图片数量硬校验,选品清单 ≤ 有效图片数
- 模板导出优化:写入前按货号末 3 位升序排序,不再产生空白 xlsx
- 修复 v90 project review 10 项(503 致命终止、线程安全、原子写入等)
This commit is contained in:
2026-08-28 16:24:50 +08:00
parent 2a96ec0870
commit 71a48e4ed5
40 changed files with 780 additions and 200 deletions
+68 -21
View File
@@ -199,29 +199,50 @@ class PinterestPipeline:
return None
def _assign_models(self) -> Dict[str, Any]:
"""任务级模特分配:按 spu.mark 映射模特目录 → 过滤非 3:4 图片 → 每个任务随机抽
"""任务级图源分配:按每个任务 spu.mark 从可配置的「模特图/平铺图」文件夹间随机抽
每个产品任务(含同一 SPU 的多个款)都独立随机抽一个模特,保证同款多产品模特不重复。
每个任务独立随机抽一张:先合并该 mark 对应「有图的」模特/平铺目录的全部合格图,再从其中随机抽一张,
抽到哪个文件夹的图就返回对应 kindmodel/flat),供 product_node 用对应提示词合成。
某类目录无图则只用另一类;两类都无图则该任务无图源(跳过合成)。
返回 {task_key: {"img": Path, "kind": "model"|"flat", "prompts": {…}}}。
"""
model_assign: Dict[str, Any] = {}
import random as _random
assign: Dict[str, Any] = {}
pcfg = self.config.get("product") or {}
mark_dirs = pcfg.get("mark_dirs") or {}
try:
from graph.product import build_mark_model_map, find_model_images_for_mark
mark_map = build_mark_model_map(self._db_path, self._material_root)
except Exception: # noqa: BLE001
mark_map = {}
# 每个任务按序号绑定独立随机模特(同 SPU 多款也各自随机,不共用)
from graph.product import build_mark_sources
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 图源映射导入失败: {e}")
return assign
# 每个出现过的 mark 各建一个图源池,避免多 mark 错配
pool_by_mark: Dict[str, list] = {}
for _i, (spu, _skus) in enumerate(self._worklist):
mark = str(spu.get("mark") or "").strip() or "1"
if mark in pool_by_mark:
continue
try:
sources = build_mark_sources(self._material_root, mark_dirs, self._category, mark=mark)
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] mark={mark} 图源构建失败: {e}")
sources = {"model": [], "flat": []}
pool = []
for kind in ("model", "flat"):
for p in sources.get(kind) or []:
pool.append((p, kind))
pool_by_mark[mark] = pool
if pool:
print(f"[pinterest_pipeline] mark={mark} 图源池:{len(pool)} 张(模特/平铺)")
for _i, (spu, _skus) in enumerate(self._worklist):
key = f"task_{_i}"
mark = str(spu.get("mark") or "").strip() or "1"
folder = mark_map.get(mark, self._category)
try:
pool_imgs = find_model_images_for_mark(self._db_path, self._material_root,
mark, folder)
except Exception: # noqa: BLE001
pool_imgs = []
if pool_imgs:
model_assign[key] = random.choice(pool_imgs) # 过滤后随机抽
return model_assign
pool = pool_by_mark.get(mark) or []
if not pool:
continue
img, kind = _random.choice(pool) # 每任务独立随机抽一张(含 kind)
assign[key] = {"img": img, "kind": kind,
"prompts": (mark_dirs.get(mark) or mark_dirs.get("1") or {})}
return assign
def _load_materials(self) -> Dict[str, str]:
material_map: Dict[str, str] = {}
@@ -384,6 +405,17 @@ class PinterestPipeline:
self._cond.wait()
return not self._briefs and self._in_flight <= 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
# 异常路径也确保排空并释放线程池,避免 dispatcher/worker 泄漏
try:
self.finish()
except Exception: # noqa: BLE001
pass
return False
def finish(self) -> tuple:
"""排空简报池、等待全部产品完成,返回 (products, errors)。
@@ -507,13 +539,16 @@ class PinterestPipeline:
def _persist_product(self, prod: Dict[str, Any]) -> None:
"""把已完成产品追加写入 products_pending.jsonlJSONL 每行一个产品)。
并发安全:写入持 _products_lock,整行一次写(含换行),避免并发 append 交织;
落盘失败不阻塞主流程(仅告警);finish() 时读盘合并,保证已完成产品不丢。
"""
try:
import json as _json
self._pending_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._pending_file, "a", encoding="utf-8") as f:
f.write(_json.dumps(prod, ensure_ascii=False) + "\n")
line = _json.dumps(prod, ensure_ascii=False) + "\n"
with self._products_lock:
with open(self._pending_file, "a", encoding="utf-8") as f:
f.write(line)
except Exception as e: # noqa: BLE001
print(f"[pinterest_pipeline] 产品落盘失败(不影响流程): {e}")
@@ -630,6 +665,18 @@ class PinterestPipeline:
print(f"[pinterest_pipeline] 补充简报装配失败: {e}")
return None
def _model_source(self, task_idx: int, spu=None) -> Dict[str, Any]:
"""返回某任务给 _process_spu 的图源参数:model_img / model_kind / prompts。"""
src = self._model_assign.get(f"task_{task_idx}") or {}
img = src.get("img")
if img is None:
return {}
if spu is not None and int(spu.get("mark") or 0) != 1:
return {"model_img": img} # 非 mark=1 走旧逻辑(不传 kindproduct_node 按其 mark 自行判定)
return {"model_img": img,
"model_kind": src.get("kind", "model"),
"prompts": src.get("prompts") or {}}
def _process_spu(self, brief: Dict[str, Any], spu, skus: str, img_code: str,
design_path: str, task_idx: int = 0) -> Optional[Dict[str, Any]]:
from graph.nodes.product_node import _process_spu as _ps
@@ -642,8 +689,8 @@ class PinterestPipeline:
prod_dir, brief, self._ib, spu, skus, self.config.get("product") or {},
self._errors, design_path, self._title_backend, self.country,
img_code=img_code,
model_img=self._model_assign.get(f"task_{task_idx}"),
on_503=lambda: (self.record_503(), self.abort_unfinished()))
on_503=lambda: (self.record_503(), self.abort_unfinished()),
**self._model_source(task_idx, spu))
if r:
r["img_code"] = img_code
return r