"""缓存热点批量产品流程(用户新流程入口)。 流程: 1. 选定国家 → 直接加载最新缓存热点(output/<国家>/design_briefs.json,不重跑种子/抓取); 无缓存时回退跑一次完整流水线(run_country)生成缓存。 2. 按 SPU 数量(count)取 N 个「未用过」热点(safe 按分降序,跳过 used_designs.json 里已用的), 一个款号分配一个热点(spu_tasks 每项绑定 topic)。 3. 调 product_node:每款号用自己热点的 image_prompt 生成设计稿 → 三图合成 → 多模态标题 → 模板导出; product_node 内部成功后把 (热点-风格-配色) 写入 used_designs.json 去重。 """ import json import re from pathlib import Path from typing import Any, Dict, List, Optional from graph.paths import project_root, runtime_root # 全项目 review 兜底:不适合 T 恤印花的类目关键词(美甲/食谱/彩票/赛果/天气/比分/日程等) _UNSUITABLE = re.compile( r"\b(nails?|manicure|pedicure|recipes?|cooking|lottery|jackpot|results?|score|scores?|" r"fixtures?|forecast|weather|temperature|map|directions?|parking|opening hours?|" r"prices?|price|reviews?|jobs?|salary|mortgage|council tax|election|referendum|" r"stock market|exchange rate|gas prices?)\b", re.IGNORECASE, ) def load_cached_briefs(output_dir: Path) -> List[Dict[str, Any]]: """读缓存简报(design_briefs.json,含 image_prompt/composite_prompt 四要素)。""" p = output_dir / "design_briefs.json" if not p.exists(): return [] try: data = json.loads(p.read_text(encoding="utf-8")) return [b for b in (data.get("design_briefs") or []) if b.get("motif") and b.get("image_prompt")] except Exception: return [] def load_used(output_dir: Path) -> List[Dict[str, Any]]: p = output_dir / "used_designs.json" if not p.exists(): return [] try: return json.loads(p.read_text(encoding="utf-8")).get("used", []) or [] except Exception: return [] def fingerprint(b: Dict[str, Any]) -> str: """(热点-风格) 指纹,用于去重(不按配色,配色不影响主题唯一性)。""" return "|".join(str(b.get(k, "")).strip().lower() for k in ("topic", "art_style")) def assign_hotspots(briefs: List[Dict[str, Any]], used: List[Dict[str, Any]], count: int, allow_review: bool = False, exclude_topics: Optional[List[str]] = None) -> List[Dict[str, Any]]: """三级热点分配(严格 → 放宽 → 兜底): ① 热点去重:只用「没用过」的热点(topic 不在 used_designs) ② 风格去重:热点池不够时放宽——同一热点允许换风格((topic, art_style) 组合未用过) ③ 规则匹配:还不够时兜底——全部简报按分数/规则取(允许完全重复,mock 风格) allow_review=True(openai 模式,LLM 已安全改写):review 且带 concept 的也进候选池。 exclude_topics:不分配黑名单(如地名/美甲/版权剧名/真实人物等,来自国家配置 exclude_topics)。 每级内部按 score 降序 + 高分池随机(不总取第一个)。""" import random used_topics = {str(u.get("topic", "")).strip().lower() for u in used} used_fp = {fingerprint(u) for u in used} safe = sorted([b for b in briefs if b.get("risk_level") == "safe"], key=lambda b: -(b.get("score") or 0)) pool = list(safe) if allow_review: reviewed = [b for b in briefs if b.get("risk_level") == "review" and (b.get("concept") or "").strip()] pool += sorted(reviewed, key=lambda b: -(b.get("score") or 0)) # 黑名单过滤:不分配的热点(国家配置 exclude_topics,大小写不敏感) if exclude_topics: ban = {str(x).strip().lower() for x in exclude_topics if str(x).strip()} _before = len(pool) pool = [b for b in pool if str(b.get("topic", "")).strip().lower() not in ban] if len(pool) < _before: print(f"[batch] 黑名单过滤 {_before - len(pool)} 个不分配热点(exclude_topics)") # 全项目 review 兜底:剔除不适合 T 恤印花的类目(美甲/食谱/彩票/赛果/天气等通用识别) _before2 = len(pool) pool = [b for b in pool if not _UNSUITABLE.search(str(b.get("topic", "")))] if len(pool) < _before2: print(f"[batch] review 兜底剔除 {_before2 - len(pool)} 个不适合类目热点(美甲/食谱/彩票/赛果/天气等)") def _shuffle_top(stage: List[Dict[str, Any]], need: int) -> List[Dict[str, Any]]: k = max(need * 2, 4) top, rest = stage[:k], stage[k:] random.shuffle(top) return top + rest # ① 热点去重(topic 未用过) stage1 = [b for b in pool if str(b.get("topic", "")).strip().lower() not in used_topics] # ② 风格去重(topic 用过,但 热点-风格 指纹未用过) stage2 = [b for b in pool if str(b.get("topic", "")).strip().lower() in used_topics and fingerprint(b) not in used_fp] # ③ 规则匹配兜底(剩余未用指纹,允许低分热点;已用指纹一律不重复出) stage3 = [b for b in pool if fingerprint(b) not in used_fp] out: List[Dict[str, Any]] = [] out_fp: set = set() # 本批内 (topic, style) 指纹去重 for si, stage in enumerate((stage1, stage2, stage3)): for b in _shuffle_top(stage, count - len(out)): if len(out) >= count: break # 去重策略(用户指定): # ① 先热点去重:本批内优先不同 topic(stage1 全未用热点); # ② 热点用完后自动切风格去重:同一热点换新风格(topic 可重复,style 不同, # 即 热点1-风格1 → 热点1-风格2 → 热点2-风格2); # fingerprint(topic+art_style)本批内绝不重复,保证同热点同风格只出一次。 fp = fingerprint(b) if fp in out_fp: continue # 热点去重优先:本批已用过的 topic 只在「没有未用热点可挑」时放行(stage2/3) topic_used = any(str(x.get("topic", "")).strip().lower() == str(b.get("topic", "")).strip().lower() for x in out) if topic_used and si == 0: continue out_fp.add(fp) out.append(b) if len(out) >= count: break return out[:count] def _rebuild_briefs_from_cache(country: str, config: Dict[str, Any], project_root: Path, cache_dir: Path, need: int) -> List[Dict[str, Any]]: """简报不足时:直接用采集缓存热点(collected_keywords)生成简报。 跳过 seed/fetch/score(无需重新采集、无需种子词),screen(排除已用)+ prompt 组装即可。""" import json as _json import time as _tm cp = cache_dir / "collected_keywords.json" if not cp.exists(): return [] ck = _json.loads(cp.read_text(encoding="utf-8")).get("keywords") or [] if not ck: return [] from graph.loader import build_country_config from graph.nodes.screen_node import screen_node from graph.nodes.prompt_node import prompt_node cc = build_country_config(config, country, project_root) config.setdefault("product", {})["spu_count"] = need state: Dict[str, Any] = { "country": country, "config": config, "country_config": cc, "prompts_dir": str(project_root / "prompts" / country), "cache_dir": str(cache_dir), "output_dir": str(cache_dir), "scored_rows": [dict(r) for r in ck], "screened": [], "briefs": [], "errors": [], "stats": {}, } try: r1 = screen_node(state) r2 = prompt_node({**state, "screened": r1.get("screened", [])}) briefs = r2.get("briefs", []) or [] if briefs: (cache_dir / "design_briefs.json").write_text( _json.dumps({"generated_at": _tm.strftime("%Y-%m-%dT%H:%M:%S"), "total": len(briefs), "design_briefs": briefs}, ensure_ascii=False, indent=2), encoding="utf-8") return briefs except Exception as e: # noqa: BLE001 print(f"[batch] 采集缓存生成简报失败: {e}") return [] def run_product_batch(country: str, config: Dict[str, Any], project_root: Path, output_root: Optional[Path], tasks: List[Dict[str, Any]], count: int, log_q=None, task_timestamp: Optional[str] = None) -> Dict[str, Any]: """缓存模式入口:加载缓存热点 → 按量分配 → product_node 批量处理。 task_timestamp: 任务开始时间戳(YYYYMMDDHHMMSS),作为 OSS 路径段;缺失取当前时间。 """ if log_q: log_q.put(("log", f"\n===== 缓存热点产品流程 {country}(SPU 数量 {count})=====\n")) import time as _tm cache_dir = (output_root or project_root) / "output" / country # 缓存/去重(根目录) cache_dir.mkdir(parents=True, exist_ok=True) ts = task_timestamp or _tm.strftime("%Y%m%d_%H%M%S") _base = ts _i = 1 while (cache_dir / ts).exists(): # 时间戳文件夹唯一(同秒多任务防冲突/覆盖) ts = f"{_base}_{_i}" _i += 1 output_dir = cache_dir / ts # 本次任务产物(时间戳文件夹) output_dir.mkdir(parents=True, exist_ok=True) briefs = load_cached_briefs(cache_dir) # 任务扩展:每个「集合」(款-颜色集 + 独立数量 count)按自己数量复制 count 份; # 每份 skus 保留该款全部颜色集合(多色)→ 每个设计配全部颜色各出一张主图 picked_tasks: List[Dict[str, Any]] = [] if tasks: for t in tasks: n = int(t.get("count") or 0) or count or 1 for _ in range(n): tt = dict(t) tt.pop("count", None) picked_tasks.append(tt) else: picked_tasks = [dict(t) for t in (tasks or [])] if not briefs or len(briefs) < len(picked_tasks): # 简报不足:直接用采集缓存热点生成简报(无需重新采集/种子词) print(f"[batch] 简报 {len(briefs)} 条 < 需要 {len(picked_tasks)} 条 → 直接用采集缓存热点生成简报(无需重新采集)…") briefs = _rebuild_briefs_from_cache(country, config, project_root, cache_dir, len(picked_tasks)) if not briefs: print("[batch] 采集缓存无有效热点,无法生成简报") return {"product": [], "briefs": [], "errors": [{"node": "batch", "message": "无缓存热点"}]} used = load_used(cache_dir) used_topics = {str(u.get("topic", "")).strip().lower() for u in used} # 简报池未用数(design_briefs.json 旧简报里还没用过的) fresh_count = sum(1 for b in briefs if str(b.get("topic", "")).strip().lower() not in used_topics) total_needed = len(picked_tasks) or count # 采集池未用数(collected_keywords 全量里的未用热点——佐证热点池是否真的充足) pool_fresh = fresh_count try: import json as _json cp = cache_dir / "collected_keywords.json" if cp.exists(): ck = _json.loads(cp.read_text(encoding="utf-8")).get("keywords") or [] pool_fresh = sum(1 for k in ck if str(k.get("topic", "")).strip().lower() not in used_topics) except Exception: # noqa: BLE001 pass # 旧简报用完了(简报池未用 < 需要)→ 直接用采集缓存热点生成新简报(无需重新采集) if fresh_count < total_needed: print(f"[batch] 简报池未用 {fresh_count}/{len(briefs)} 条 < 需要 {total_needed} 条 → " f"直接用采集缓存热点生成新简报(采集池未用 {pool_fresh} 条充足,无需重新采集)…") briefs = _rebuild_briefs_from_cache(country, config, project_root, cache_dir, total_needed) # openai 模式:LLM 已安全改写,review(带 concept)也可用;mock 模式:review 留人工复核 allow_review = str((config.get("llm_screen") or {}).get("provider", "")).strip() != "mock" from graph.loader import build_country_config _cc = build_country_config(config, country, project_root) exclude_topics = list((_cc.get("exclude_topics") or []) or []) # 国家配置的黑名单热点 assigned = assign_hotspots(briefs, used, total_needed, allow_review=allow_review, exclude_topics=exclude_topics) if not assigned: print("[batch] 无可用热点分配") return {"product": [], "briefs": briefs, "errors": [{"node": "batch", "message": "无可用热点"}]} if len(assigned) < total_needed: print(f"[batch] ⚠ 热点不足:简报 {len(briefs)} 条,仅分配到 {len(assigned)}/{total_needed} 个热点(已用去重后剩余热点少,第 {len(assigned)+1} 个起无热点)") else: print(f"[batch] 缓存热点 {len(briefs)} 条 → 分配 {len(assigned)} 个热点") # 款号与热点一一绑定:第 i 个款号用第 i 个热点(缓存模式;完整流水线由 product 自行绑定) for i, t in enumerate(picked_tasks): if i < len(assigned): t["topic"] = assigned[i].get("topic", "") if log_q: for i, t in enumerate(picked_tasks): tp = t.get("topic", "") log_q.put(("log", f"[batch] 款号 {t.get('spu')} ← 热点「{tp}」\n")) # 直接构造 state 调 product_node(跳过 seed/fetch 等重跑) from graph.nodes.product_node import product_node from graph.loader import build_country_config import time as _time cc = build_country_config(config, country, project_root) config.setdefault("product", {})["spu_tasks"] = picked_tasks if count > 0: config.setdefault("product", {})["spu_count"] = count state: Dict[str, Any] = { "country": country, "config": config, "country_config": cc, "prompts_dir": str(project_root / "prompts" / country), "output_dir": str(output_dir), "briefs": briefs, "designs": [], "composite": [], "errors": [], "stats": {}, "task_timestamp": task_timestamp or _time.strftime("%Y%m%d%H%M%S"), "oss_seq": 0, } out = product_node(state) # 缓存模式也走压缩+上传+种草图节点(与 graph 全流程一致) if out.get("product"): from graph.nodes.oss_upload_node import oss_upload_node out2 = oss_upload_node({**state, "product": out.get("product"), "stats": out.get("stats") or {}, "errors": out.get("errors") or []}) out["oss"] = out2.get("oss") or [] out["product"] = out2.get("product") or out.get("product") out["stats"] = out2.get("stats") or out.get("stats") or {} if out2.get("oss_seq") is not None: state["oss_seq"] = out2["oss_seq"] from graph.nodes.seed_shot_node import seed_shot_node out3 = seed_shot_node({**state, "product": out.get("product"), "stats": out.get("stats") or {}, "errors": out.get("errors") or []}) out["seed_shots"] = out3.get("seed_shots") or [] out["product"] = out3.get("product") or out.get("product") out["stats"] = out3.get("stats") or out.get("stats") or {} if out3.get("oss_seq") is not None: state["oss_seq"] = out3["oss_seq"] from graph.nodes.template_export_node import template_export_node out4 = template_export_node({**state, "product": out.get("product"), "stats": out.get("stats") or {}, "errors": out.get("errors") or []}) out["product"] = out4.get("product") or out.get("product") out["stats"] = out4.get("stats") or out.get("stats") or {} return out