Files
3218485270 f493bde8a9 POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底
- 缓存热点批量流程(有采集缓存不触发 Google)
- 简报不足直接从采集缓存生成(轻量补齐)
- 三图合成(模特/印花/底图)+ 底图压缩 <2MB
- 热点去重→风格去重自动切换 + 不适合类目 review 兜底
- 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述)
- 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
2026-08-22 14:14:01 +08:00

310 lines
16 KiB
Python
Raw Permalink 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.
"""缓存热点批量产品流程(用户新流程入口)。
流程:
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=Trueopenai 模式,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);
# fingerprinttopic+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