POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底
- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""POD 热点抓取 Agent(LangGraph 工程化版本)。
|
||||
|
||||
架构:
|
||||
- graph/state.py : 共享状态 AgentState
|
||||
- graph/validate.py : 节点级兜底(with_fallback)+ 数据校验
|
||||
- graph/sources/ : 数据源可插拔(GoogleTrends / Pinterest ...)
|
||||
- graph/llms/ : LLM 后端可插拔(Mock / OpenAI 兼容 ...)
|
||||
- graph/nodes/ : 6 个流水线节点(fetch/filter/score/screen/prompt_build/compose)
|
||||
- graph/agent.py : 构建并编译 StateGraph,提供 run_country()
|
||||
|
||||
每个国家独立处理:prompts/<country>/ 放该国专属提示词与审美规则,
|
||||
output/<country>/ 放该国产物。节点全部带兜底,单点失败不影响整图。
|
||||
"""
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
"""构建并编译 LangGraph,提供 run_country() 入口。
|
||||
|
||||
图结构(线性流水线,节点全部带兜底):
|
||||
START -> seed -> fetch -> filter -> score -> screen -> prompt_build
|
||||
-> compose(生成纯印花设计稿 + 导出简报)-> product(底图/模特/三图合成/模板)
|
||||
-> oss_upload(压缩 3:4 / ≥1340×1785 / <2MB + 上传阿里云 OSS)-> END
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from langgraph.graph import END, StateGraph
|
||||
|
||||
from graph.loader import build_country_config
|
||||
from graph.nodes import (
|
||||
compose_node,
|
||||
fetch_node,
|
||||
filter_node,
|
||||
oss_upload_node,
|
||||
product_node,
|
||||
prompt_node,
|
||||
score_node,
|
||||
screen_node,
|
||||
seed_node,
|
||||
seed_shot_node,
|
||||
template_export_node,
|
||||
)
|
||||
from graph.state import AgentState
|
||||
|
||||
|
||||
def build_graph():
|
||||
"""构建 StateGraph 并编译。"""
|
||||
builder = StateGraph(AgentState)
|
||||
builder.add_node("seed", seed_node)
|
||||
builder.add_node("fetch", fetch_node)
|
||||
builder.add_node("filter", filter_node)
|
||||
builder.add_node("score", score_node)
|
||||
builder.add_node("screen", screen_node)
|
||||
builder.add_node("prompt_build", prompt_node)
|
||||
builder.add_node("product", product_node)
|
||||
builder.add_node("compose", compose_node)
|
||||
builder.add_node("oss_upload", oss_upload_node)
|
||||
builder.add_node("seed_shot", seed_shot_node)
|
||||
builder.add_node("template_export", template_export_node)
|
||||
|
||||
builder.add_edge("__start__", "seed")
|
||||
builder.add_edge("seed", "fetch")
|
||||
builder.add_edge("fetch", "filter")
|
||||
builder.add_edge("filter", "score")
|
||||
builder.add_edge("score", "screen")
|
||||
builder.add_edge("screen", "prompt_build")
|
||||
builder.add_edge("prompt_build", "compose") # compose:生成纯印花设计稿(放前面)
|
||||
builder.add_edge("compose", "product") # product:底图/模特/三图合成/模板
|
||||
builder.add_edge("product", "oss_upload") # oss_upload:压缩 + 上传图床
|
||||
builder.add_edge("oss_upload", "seed_shot") # seed_shot:种草图生成(模板+模特特征 yaml)→ 上传
|
||||
builder.add_edge("seed_shot", "template_export") # template_export:最终结果导入商品上传模板
|
||||
builder.add_edge("template_export", END)
|
||||
return builder.compile()
|
||||
|
||||
|
||||
def run_country(
|
||||
country: str,
|
||||
global_config: Dict[str, Any],
|
||||
project_root: Path,
|
||||
output_root: Optional[Path] = None,
|
||||
base_image: Optional[str] = None,
|
||||
task_timestamp: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""运行单个国家的完整流水线,返回最终 state(含 errors / stats / briefs)。
|
||||
|
||||
project_root:数据文件根(configs / prompts,打包后为 _MEIPASS 只读目录)。
|
||||
output_root :产物输出根(默认=project_root;打包后传 exe 旁运行目录,
|
||||
避免把 output/ 写进临时解压目录导致重启丢失)。
|
||||
task_timestamp:任务时间戳(每次点击运行 = 一个任务);None 时自动生成。
|
||||
"""
|
||||
compiled = build_graph()
|
||||
cc = build_country_config(global_config, country, project_root)
|
||||
prompts_dir = project_root / "prompts" / country
|
||||
cache_dir = (output_root or project_root) / "output" / country # 缓存/去重(根目录)
|
||||
ts = task_timestamp or time.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 # 本次任务产物(时间戳文件夹)
|
||||
|
||||
state: Dict[str, Any] = {
|
||||
"country": country,
|
||||
"config": global_config,
|
||||
"country_config": cc,
|
||||
"prompts_dir": str(prompts_dir),
|
||||
"cache_dir": str(cache_dir),
|
||||
"output_dir": str(output_dir),
|
||||
"raw_rows": [],
|
||||
"filtered_rows": [],
|
||||
"scored_rows": [],
|
||||
"screened": [],
|
||||
"briefs": [],
|
||||
"composite": [],
|
||||
"designs": [],
|
||||
"errors": [],
|
||||
"stats": {},
|
||||
"task_timestamp": ts, # 任务开始时间戳(OSS 路径段 / 产物文件夹名)
|
||||
"oss_seq": 0, # 货号计数(000 起,最多 999)
|
||||
}
|
||||
if base_image:
|
||||
state["base_image"] = base_image
|
||||
|
||||
result = compiled.invoke(state)
|
||||
return result
|
||||
@@ -0,0 +1,22 @@
|
||||
"""图像后端注册表(可插拔:印到底图 / 模特试穿 / 占位)。
|
||||
|
||||
compose_node / product_node 在配置 backend 时调用。默认未配置则跳过,仅导出提示词。
|
||||
新增图像后端:实现 graph/backends/base.ImageBackend,在此登记。
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
from .base import ImageBackend
|
||||
from .openai_image_backend import OpenAIImageBackend
|
||||
from .mock_image_backend import MockImageBackend
|
||||
|
||||
IMAGE_BACKENDS: Dict[str, type] = {
|
||||
"openai": OpenAIImageBackend, # 真生图(images/edits img2img,需 api_key)
|
||||
"mock": MockImageBackend, # 占位图(Pillow,无 key 也能端到端演示)
|
||||
}
|
||||
|
||||
|
||||
def get_image_backend(name: str):
|
||||
cls = IMAGE_BACKENDS.get(name)
|
||||
if cls is None:
|
||||
return None
|
||||
return cls()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""图像后端抽象接口(可插拔)。
|
||||
|
||||
- print(prompt, base_image, out_path, negative, extra_images):
|
||||
以 base_image 为底图(+ extra_images 多参考图,按顺序追加)按 prompt 生成成品图。
|
||||
product 流水线用法:
|
||||
* 纯印花设计稿 → generate()
|
||||
* 平铺服装图(底图+印花)→ print(base=底图, extra=[设计稿])
|
||||
* 三图模特合成 → print(base=模特图, extra=[设计稿, 底图])(图1=模特, 图2=印花, 图3=底图)
|
||||
- generate(prompt, out_path, negative):纯文生图(无参考图),用于生成白底纯印花设计稿。
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Sequence
|
||||
|
||||
|
||||
class ImageBackend(ABC):
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
|
||||
extra_images: Optional[Sequence[str]] = None, size: str = "") -> str:
|
||||
"""返回生成的成品图路径。实现内部应处理调用失败/超时并抛异常由调用方兜底。
|
||||
size: 显式尺寸覆盖(如 "1504x2000");留空则用后端配置的 size。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
|
||||
"""纯文生图(无参考图),默认退化为 print 不支持则抛异常。
|
||||
size: 显式尺寸覆盖;留空则用后端配置的 size。"""
|
||||
raise NotImplementedError(f"{self.name} 后端不支持纯文生图(generate)")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Mock 图像后端:Pillow 生成占位图。
|
||||
|
||||
无 OpenAI key 时也能端到端演示产品流水线(选品 → 底图 → "印花图" → "模特合成" 产物齐全)。
|
||||
占位图 = 参考图尺寸 + 文字标注(提示词摘要),明确标识 [MOCK] 避免误用。
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .base import ImageBackend
|
||||
|
||||
|
||||
class MockImageBackend(ImageBackend):
|
||||
name = "mock"
|
||||
|
||||
def __init__(self):
|
||||
self._cfg: dict = {}
|
||||
|
||||
def bind_config(self, cfg: dict):
|
||||
self._cfg = cfg or {}
|
||||
|
||||
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
|
||||
extra_images=None, size: str = "") -> str:
|
||||
size_px = (1024, 1024)
|
||||
try:
|
||||
with Image.open(base_image) as im:
|
||||
size_px = im.size
|
||||
except Exception:
|
||||
pass
|
||||
if size:
|
||||
try:
|
||||
w, h = (int(x) for x in str(size).lower().split("x"))
|
||||
size_px = (w, h)
|
||||
except Exception:
|
||||
pass
|
||||
img = Image.new("RGB", size_px, (240, 240, 248))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([0, 0, size_px[0] - 1, size_px[1] - 1], outline=(180, 180, 200))
|
||||
d.text((24, 24), f"[MOCK] {Path(out_path).name}", fill=(50, 50, 80))
|
||||
d.text((24, 56), "(未配置 OpenAI key,占位图演示流程)", fill=(120, 120, 150))
|
||||
d.text((24, 96), (prompt or "")[:160], fill=(90, 90, 120))
|
||||
if extra_images:
|
||||
d.text((24, 136), f"参考图 {len(list(extra_images))} 张: " + ", ".join(Path(p).name[:24] for p in extra_images), fill=(90, 90, 120))
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(out_path)
|
||||
return out_path
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
|
||||
"""纯文生图(mock):白底 + 文字标注,模拟纯印花设计稿。"""
|
||||
size_px = (1024, 1024)
|
||||
if size:
|
||||
try:
|
||||
w, h = (int(x) for x in str(size).lower().split("x"))
|
||||
size_px = (w, h)
|
||||
except Exception:
|
||||
pass
|
||||
img = Image.new("RGB", size_px, (252, 252, 252))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([0, 0, size_px[0] - 1, size_px[1] - 1], outline=(200, 200, 210))
|
||||
d.text((24, 24), f"[MOCK DESIGN] {Path(out_path).name}", fill=(50, 50, 80))
|
||||
d.text((24, 56), "(纯印花设计稿占位,白底)", fill=(120, 120, 150))
|
||||
d.text((24, 96), (prompt or "")[:160], fill=(90, 90, 120))
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(out_path)
|
||||
return out_path
|
||||
@@ -0,0 +1,242 @@
|
||||
"""OpenAI 图像后端(生成设计稿 + 三图合成)。
|
||||
|
||||
支持 OpenAI images/generations(文生图)与 images/edits(img2img 多参考图),
|
||||
兼容两类网关返回:
|
||||
1) 同步:{"data": [{"b64_json" | "url"}]}
|
||||
2) 异步任务(ai-media.vip 等重负载自动转异步):
|
||||
提交返回 202 + {"object":"image.task","task_id","poll_url","poll_after_ms"},
|
||||
需轮询 GET {base}/images/tasks/{task_id} 直到 succeeded,再从成功响应取图。
|
||||
|
||||
所有请求跳过环境代理(NO_PROXY),适配用户挂 VPN 时直连国内/自建网关。
|
||||
"""
|
||||
import base64
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
import os
|
||||
|
||||
# 模型调用一律直连:用户常开 VPN(系统代理),网关多为国内/自建,走代理会被拦截或变慢。
|
||||
# 环境变量级 NO_PROXY 双保险(requests/urllib3 均读取),Google 采集(pytrends)不受影响。
|
||||
os.environ.setdefault('NO_PROXY', '*')
|
||||
os.environ.setdefault('no_proxy', '*')
|
||||
|
||||
from .base import ImageBackend
|
||||
|
||||
# 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求)
|
||||
NO_PROXY = {"http": None, "https": None}
|
||||
|
||||
_FINAL_STATUS = ("succeeded", "completed", "done")
|
||||
_FAIL_STATUS = ("failed", "error")
|
||||
|
||||
|
||||
def _shrink_blob_to_2mb(img_path: str, blob: bytes, max_bytes: int = 2 * 1024 * 1024) -> bytes:
|
||||
"""把大图压缩到 <max_bytes(默认 2MB)再上传:
|
||||
- 尺寸过大先缩放(合成输入 1600x2200 内足够);
|
||||
- 有透明通道 → PNG 优化;否则 → JPEG 循环降质。
|
||||
失败时返回原 blob(不阻塞流程)。"""
|
||||
try:
|
||||
import io
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(blob))
|
||||
max_w, max_h = 1600, 2200
|
||||
if img.width > max_w or img.height > max_h:
|
||||
img.thumbnail((max_w, max_h))
|
||||
has_alpha = img.mode in ("RGBA", "LA")
|
||||
if not has_alpha:
|
||||
img = img.convert("RGB")
|
||||
for q in (88, 70, 50, 35):
|
||||
buf = io.BytesIO()
|
||||
if has_alpha:
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
else:
|
||||
img.save(buf, format="JPEG", quality=q)
|
||||
if buf.tell() <= max_bytes:
|
||||
return buf.getvalue()
|
||||
# 保底:最小质量 PNG/JPEG
|
||||
buf = io.BytesIO()
|
||||
if has_alpha:
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
else:
|
||||
img.save(buf, format="JPEG", quality=30)
|
||||
return buf.getvalue()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[img] 图片压缩失败(用原图): {e}")
|
||||
return blob
|
||||
|
||||
|
||||
def _save_from_response(j: dict, out_path: str) -> str:
|
||||
"""从同步/异步最终响应提取图片(data[].b64_json 或 url)并保存。"""
|
||||
data_item = (j.get("data") or [{}])[0]
|
||||
b64 = data_item.get("b64_json")
|
||||
if b64:
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(out_path).write_bytes(base64.b64decode(b64))
|
||||
return out_path
|
||||
url = data_item.get("url")
|
||||
if not url:
|
||||
raise RuntimeError(f"图像 API 返回无 b64_json/url: {str(j)[:200]}")
|
||||
img_resp = requests.get(url, proxies=NO_PROXY, timeout=120)
|
||||
img_resp.raise_for_status()
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(out_path).write_bytes(img_resp.content)
|
||||
return out_path
|
||||
|
||||
|
||||
def _wait_task(base_url: str, headers: dict, task_id: str, poll_after_ms: int,
|
||||
timeout: int = 60) -> dict:
|
||||
"""轮询异步图像任务直到完成;uncertain(结果暂时不确定)继续等,不重复提交。
|
||||
timeout 默认 60s:异步路径不稳定(ai-media 网关常 uncertain/task not found),
|
||||
超时即抛异常由调用方重试同步提交。"""
|
||||
deadline = time.time() + timeout
|
||||
last = ""
|
||||
while time.time() < deadline:
|
||||
time.sleep(max(int(poll_after_ms or 2000), 2000) / 1000.0)
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/images/tasks/{task_id}", headers=headers,
|
||||
timeout=60, proxies=NO_PROXY)
|
||||
if resp.status_code >= 400:
|
||||
continue
|
||||
j = resp.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[img] 任务轮询异常(重试): {e}")
|
||||
continue
|
||||
st = j.get("status")
|
||||
if st != last:
|
||||
print(f"[img] 异步任务 {task_id[:20]}… status={st}")
|
||||
last = st
|
||||
if st in _FINAL_STATUS:
|
||||
return j
|
||||
if st in _FAIL_STATUS:
|
||||
raise RuntimeError(f"图像异步任务失败: {j.get('error')} {j.get('error_code')}")
|
||||
# queued / running / uncertain → 继续等
|
||||
raise RuntimeError(f"图像异步任务超时({timeout}s)")
|
||||
|
||||
|
||||
def _resolve_task_or_sync(j: dict, base_url: str, headers: dict, out_path: str) -> str:
|
||||
"""提交后的统一处理:异步任务则轮询,然后提取图片保存。"""
|
||||
if j.get("object") == "image.task" or j.get("task_id") or j.get("id", "").startswith("imgtask"):
|
||||
tid = j.get("task_id") or j.get("id") or ""
|
||||
if not tid:
|
||||
raise RuntimeError(f"异步任务无 task_id: {str(j)[:200]}")
|
||||
j = _wait_task(base_url, headers, tid, int(j.get("poll_after_ms") or 2000))
|
||||
return _save_from_response(j, out_path)
|
||||
|
||||
|
||||
class OpenAIImageBackend(ImageBackend):
|
||||
name = "openai"
|
||||
|
||||
def __init__(self):
|
||||
self._cfg: dict = {}
|
||||
|
||||
def bind_config(self, cfg: dict):
|
||||
self._cfg = cfg or {}
|
||||
|
||||
def _base_url(self) -> str:
|
||||
return str(self._cfg.get("base_url") or "https://api.openai.com/v1").rstrip("/")
|
||||
|
||||
def print(self, prompt: str, base_image: str, out_path: str, negative: str = "",
|
||||
extra_images=None, size: str = "") -> str:
|
||||
"""img2img 编辑:参考图 base_image(+ 可选 extra_images 多参考图)按 prompt 生成新图。
|
||||
|
||||
- 平铺服装图:base_image=平铺衣服底图(图3),extra_images=[印花设计稿(图2)]
|
||||
- 三图模特合成:base_image=模特图(图1),extra_images=[印花设计稿(图2), 平铺底图(图3)]
|
||||
提交顺序即图1→图2→图3,与提示词中的图片角色一一对应。
|
||||
size: 显式尺寸覆盖(如 "1504x2000");留空用配置 size(默认 1024x1024)。
|
||||
"""
|
||||
cfg = self._cfg
|
||||
api_key = cfg.get("api_key", "")
|
||||
model = cfg.get("model", "gpt-image-1")
|
||||
if not api_key:
|
||||
raise RuntimeError("compose.api_key 未配置,无法调用 OpenAI 图像后端。")
|
||||
|
||||
full_prompt = prompt + (f"\nNegative: {negative}" if negative else "")
|
||||
base_url = self._base_url()
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
# 必须把文件读成 bytes 再提交(句柄随 with 关闭会导致 "read of closed file");
|
||||
# 输入图(模特/设计/底图)先压缩校验:>2MB 压缩到 <2MB 再上传,避免大图拖垮网关导致超时
|
||||
file_payloads = []
|
||||
for img_path in [base_image] + list(extra_images or []):
|
||||
blob = Path(img_path).read_bytes()
|
||||
if len(blob) > 2 * 1024 * 1024:
|
||||
shrunk = _shrink_blob_to_2mb(img_path, blob)
|
||||
print(f"[img] 输入图压缩: {Path(img_path).name} {len(blob)//1024}KB → {len(shrunk)//1024}KB(<2MB)")
|
||||
blob = shrunk
|
||||
file_payloads.append((Path(img_path).name, blob))
|
||||
files = [("image", (name, blob, "image/png")) for name, blob in file_payloads]
|
||||
data = {
|
||||
"prompt": full_prompt,
|
||||
"n": 1,
|
||||
"size": size or cfg.get("size", "1024x1024"),
|
||||
"model": model,
|
||||
"execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定)
|
||||
}
|
||||
# 提交重试:异步路径不稳定 → 失败重试同步提交(最多 3 次);
|
||||
# 内容政策拦截(content_policy_violation)多为网关误判 → 等待后重试
|
||||
last_err: Optional[str] = None
|
||||
for attempt in range(3):
|
||||
resp = requests.post(f"{base_url}/images/edits", headers=headers, files=files,
|
||||
data=data, timeout=300, proxies=NO_PROXY)
|
||||
if resp.status_code >= 400:
|
||||
body = resp.text or ""
|
||||
if "content_policy" in body and attempt < 2:
|
||||
print(f"[img] 内容政策拦截(可能误判),等待后重试 {attempt + 2}/3")
|
||||
time.sleep(2)
|
||||
continue
|
||||
if attempt == 0:
|
||||
data.pop("execution_mode", None) # 网关不认识该参数(官方 OpenAI)→ 去掉重试
|
||||
continue
|
||||
raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}")
|
||||
try:
|
||||
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = str(e)
|
||||
print(f"[img] 第 {attempt + 1} 次提交异步失败,重试同步提交: {e}")
|
||||
raise RuntimeError(f"图像合成多次提交均失败: {last_err}")
|
||||
|
||||
def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str:
|
||||
"""纯文生图:生成白底纯印花设计稿(standalone pure print design)。
|
||||
size: 显式尺寸覆盖(印花设计统一 1024x1024);留空用配置 size。
|
||||
background: 配置 compose.background="transparent" 时传 background 参数 → 透明背景 PNG
|
||||
(gpt-image-1/2 等模型支持;网关不支持该参数时会被忽略或由网关兜底)。"""
|
||||
cfg = self._cfg
|
||||
api_key = cfg.get("api_key", "")
|
||||
model = cfg.get("model", "gpt-image-1")
|
||||
if not api_key:
|
||||
raise RuntimeError("compose.api_key 未配置,无法调用 OpenAI 图像后端。")
|
||||
|
||||
full_prompt = prompt + (f"\nNegative: {negative}" if negative else "")
|
||||
base_url = self._base_url()
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
data = {
|
||||
"prompt": full_prompt,
|
||||
"n": 1,
|
||||
"size": size or cfg.get("size", "1024x1024"),
|
||||
"model": model,
|
||||
"response_format": "b64_json",
|
||||
"execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定)
|
||||
}
|
||||
bg = str(cfg.get("background") or "").strip()
|
||||
if bg:
|
||||
data["background"] = bg # 如 "transparent"(透明背景 PNG)
|
||||
last_err: Optional[str] = None
|
||||
for attempt in range(3):
|
||||
resp = requests.post(f"{base_url}/images/generations", headers=headers, json=data,
|
||||
timeout=300, proxies=NO_PROXY)
|
||||
if resp.status_code >= 400:
|
||||
body = resp.text or ""
|
||||
if "content_policy" in body and attempt < 2:
|
||||
print(f"[img] 内容政策拦截(可能误判),等待后重试 {attempt + 2}/3")
|
||||
time.sleep(2)
|
||||
continue
|
||||
if attempt == 0:
|
||||
data.pop("execution_mode", None) # 网关不认识该参数(官方 OpenAI)→ 去掉重试
|
||||
continue
|
||||
raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}")
|
||||
try:
|
||||
return _resolve_task_or_sync(resp.json(), base_url, headers, out_path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = str(e)
|
||||
print(f"[img] 第 {attempt + 1} 次生成异步失败,重试同步提交: {e}")
|
||||
raise RuntimeError(f"图像生成多次提交均失败: {last_err}")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""热点类型分类(规则版)+ Prompt 灵感生成。
|
||||
|
||||
后续可把 classify() 换成一次 LLM 调用,产出更准的 Event/Meme/Style/Niche 标签。
|
||||
"""
|
||||
from typing import Dict, List
|
||||
|
||||
EVENT_WORDS: List[str] = [
|
||||
"eclipse", "olympic", "olympics", "christmas", "halloween", "election",
|
||||
"thanksgiving", "valentine", "new year", "festival", "concert", "super bowl",
|
||||
"world cup", "graduation", "wedding", "birthday", "2024", "2025", "2026",
|
||||
]
|
||||
MEME_WORDS: List[str] = [
|
||||
"meme", "funny", "lol", "joke", "relatable", "viral", "sarcasm",
|
||||
"hilarious", "pun", "cat", "dog",
|
||||
]
|
||||
STYLE_WORDS: List[str] = [
|
||||
"vintage", "retro", "kawaii", "minimalist", "anime", "grunge", "boho",
|
||||
"aesthetic", "streetwear", "gothic", "pastel", "vaporwave", "90s", "80s",
|
||||
"cottagecore", "y2k", "punk", "minimal",
|
||||
]
|
||||
|
||||
|
||||
def classify(topic: str) -> str:
|
||||
t = (topic or "").lower()
|
||||
if any(w in t for w in EVENT_WORDS):
|
||||
return "Event"
|
||||
if any(w in t for w in MEME_WORDS):
|
||||
return "Meme"
|
||||
if any(w in t for w in STYLE_WORDS):
|
||||
return "Style"
|
||||
return "Niche"
|
||||
|
||||
|
||||
PROMPT_TEMPLATES: Dict[str, str] = {
|
||||
"Event": "A vintage poster style design of {topic}, distressed texture, bold typography, vector style, isolated on white background.",
|
||||
"Meme": "A funny cartoon illustration of {topic}, bold comic style, high contrast, humorous pure print design, isolated on white background.",
|
||||
"Style": "A {topic} aesthetic illustration, trendy color palette, clean vector graphics, pure print design, isolated on white background.",
|
||||
"Niche": "A cute illustration of {topic}, kawaii style, flat design, pastel colors, high contrast, pure print design, isolated on white background.",
|
||||
}
|
||||
|
||||
# 通用负向约束:避免侵权与真实人物
|
||||
NEGATIVE = "no copyrighted characters, no real people, no brand logos, no trademarks, no politics, no religion, no hate, no violence, no sexual content, no readable text unless it is a short original English slogan, no gibberish text"
|
||||
|
||||
|
||||
def prompt_suggestion(topic: str, type_: str) -> str:
|
||||
tpl = PROMPT_TEMPLATES.get(type_, PROMPT_TEMPLATES["Niche"])
|
||||
return tpl.format(topic=topic) + " --no " + NEGATIVE
|
||||
@@ -0,0 +1,30 @@
|
||||
"""LLM 后端注册表(可插拔入口)。
|
||||
|
||||
config.yaml 的 llm_screen.provider 选择后端;OpenAI 兼容厂商(openai/deepseek/qwen/moonshot)
|
||||
统一映射到 openai_compat 实现。
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
from .base import LLMBackend
|
||||
from .mock_backend import MockBackend
|
||||
from .openai_compat_backend import OpenAICompatBackend, DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
LLM_BACKENDS: Dict[str, type] = {
|
||||
"mock": MockBackend,
|
||||
"openai_compat": OpenAICompatBackend,
|
||||
}
|
||||
# 厂商别名 -> openai_compat(它们都走 OpenAI 兼容协议)
|
||||
_ALIASES: Dict[str, str] = {
|
||||
"openai": "openai_compat",
|
||||
"deepseek": "openai_compat",
|
||||
"qwen": "openai_compat",
|
||||
"moonshot": "openai_compat",
|
||||
}
|
||||
|
||||
|
||||
def get_backend(name: str) -> LLMBackend:
|
||||
key = _ALIASES.get(name, name)
|
||||
cls = LLM_BACKENDS.get(key)
|
||||
if cls is None:
|
||||
raise ValueError(f"未知 LLM 后端: {name},可用: {list(LLM_BACKENDS)} (+别名 {list(_ALIASES)})")
|
||||
return cls()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""LLM 后端抽象接口(可插拔核心)。
|
||||
|
||||
新增一个 LLM 后端只需:① 继承 LLMBackend 实现 screen();② 在 graph/llms/__init__.py
|
||||
的 LLM_BACKENDS 注册表里登记。config 的 ``llm_screen.provider`` 选择用哪个。
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class LLMBackend(ABC):
|
||||
#: 注册名(与 config.llm_screen.provider 对应)
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def screen(
|
||||
self,
|
||||
topics: List[str],
|
||||
country: str,
|
||||
aesthetic_hint: str,
|
||||
system_prompt: str,
|
||||
blacklist: List[str],
|
||||
batch_size: int = 12,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""对一批话题做合规筛查 + 结构化四要素提取,返回列表。
|
||||
|
||||
每条结构(与旧 llm_screen.screen_combined 的 screened 一致):
|
||||
{
|
||||
"topic": <原始话题>,
|
||||
"safe_for_print": bool,
|
||||
"risk_level": "safe" | "review" | "blocked",
|
||||
"risk_reasons": [str],
|
||||
"suitable_for_print": bool,
|
||||
"design_category": "Style"|"Meme"|"Event"|"Niche"|"Pattern"|"Quote"|"Failed",
|
||||
"concept": <中文概念, 1 句>,
|
||||
"motif": <英文主体>,
|
||||
"art_style": <英文风格>,
|
||||
"color_palette": <英文配色>,
|
||||
"composition": <英文构图>,
|
||||
"negative_prompt": <负向>,
|
||||
"confidence": 0-1,
|
||||
}
|
||||
|
||||
实现内部必须处理调用失败/超时,失败时抛出异常由节点降级逻辑接管(或直接返回兜底结果)。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def generate_seeds(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""根据上下文(trending 派生 / 历史热点 / 月份节日)生成 Google Trends 相关查询种子词。
|
||||
|
||||
返回 {"style_seeds": [str], "related_seeds": [str]}。
|
||||
context 字段:country, trending_seeds, history_hotspots, season, month_themes, upcoming_holidays。
|
||||
实现内部必须处理调用失败/超时,失败时抛异常由 seed_node 降级逻辑接管。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Mock LLM 后端:启发式兜底(无 key 也能端到端跑通)。
|
||||
|
||||
逻辑:黑名单硬拦 -> 常识风险词标 review -> 动态风格/配色推导 -> 分类。
|
||||
这是生产环境 LLM 不可用时的安全降级路径,保证流水线永远能产出可用结果。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..classify import classify, prompt_suggestion
|
||||
from ..style_rules import derive_style_palette, derive_composition
|
||||
|
||||
|
||||
def _dedup_limit(items: List[str], limit: int) -> List[str]:
|
||||
"""去重(大小写不敏感)并限量,保留首次出现顺序。"""
|
||||
seen = set()
|
||||
out: List[str] = []
|
||||
for it in items:
|
||||
it = (it or "").strip()
|
||||
if not it:
|
||||
continue
|
||||
low = it.lower()
|
||||
if low in seen:
|
||||
continue
|
||||
seen.add(low)
|
||||
out.append(it)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# 常识风险词(兜底用;真实判定交给 LLM)。同时被 seed_node 复用为「种子护栏」,
|
||||
# 避免真人/IP/平台词作为 Google Trends 相关查询种子浪费抓取。
|
||||
COMMON_RISK_WORDS = [
|
||||
"disney", "marvel", "nike", "adidas", "apple", "iphone", "mcdonalds",
|
||||
"mcdonald", "starbucks", "coca", "pepsi", "pokemon", "mario", "hello kitty",
|
||||
"sanrio", "sonic", "minions", "barbie", "harry potter", "batman", "spiderman",
|
||||
"star wars", "fortnite", "roblox", "minecraft", "tiktok", "netflix", "pearl jam",
|
||||
"nirvana", "taylor swift", "trump", "biden", "kardashian", "lebron", "kanye",
|
||||
"kick", "gta", "ufc", "westmeath", "lottery", "prison break", "margot robbie",
|
||||
"dana white", "euro", "spotify", "youtube", "instagram", "xbox", "playstation",
|
||||
"noah kahan", "gina carano", "camry", "hurricanes", "eras tour",
|
||||
"springsteen", "reiner", "eliza lopes", "camilla", "h&m", "truck accident attorney",
|
||||
]
|
||||
|
||||
# 原创短标语池(mock 模式的 slogan;纯原创、无版权无品牌)
|
||||
# 原创短标语池(mock 模式的 slogan;纯原创、无版权无品牌)
|
||||
# 英语通用 + 各国语言(JP=日语短标语),按国家动态注入
|
||||
_STABLE_SLOGANS_EN = [
|
||||
"good vibes", "stay cozy", "happy place", "be kind", "dream big",
|
||||
"keep smiling", "sunshine", "peace love", "stay wild", "pet the cat",
|
||||
"coffee first", "tiny paws", "warm hugs", "soft life", "grow slowly",
|
||||
"lucky charm", "sweet dreams", "go outside", "mindful", "lazy days",
|
||||
]
|
||||
_STABLE_SLOGANS_JP = [
|
||||
"ゆめいっぱい", "やさしい気持ち", "ずっと元気", "おだやかな日", "きょうもハッピー",
|
||||
"ねこが好き", "いっしょにね", "ぽかぽか", "はるの風", "なつのおもいで",
|
||||
"きらきら", "わくわく", "のんびり", "しあわせ", "えがお",
|
||||
]
|
||||
|
||||
|
||||
def _stable_slogan(topic: str, country: str = "") -> str:
|
||||
"""按主题哈希稳定选一条原创标语(同一主题缓存一致;mock 兜底用)。
|
||||
country=JP → 日语短标语;其他国家 → 英语。"""
|
||||
import hashlib
|
||||
pool = _STABLE_SLOGANS_JP if str(country).upper() == "JP" else _STABLE_SLOGANS_EN
|
||||
h = int(hashlib.md5((topic or "").encode("utf-8")).hexdigest(), 16)
|
||||
return pool[h % len(pool)]
|
||||
|
||||
|
||||
class MockBackend:
|
||||
name = "mock"
|
||||
|
||||
def screen(
|
||||
self,
|
||||
topics: List[str],
|
||||
country: str,
|
||||
aesthetic_hint: str,
|
||||
system_prompt: str,
|
||||
blacklist: List[str],
|
||||
batch_size: int = 12,
|
||||
) -> List[Dict[str, Any]]:
|
||||
bl = [b.lower() for b in (blacklist or [])]
|
||||
out: List[Dict[str, Any]] = []
|
||||
for t in topics:
|
||||
tl = t.lower()
|
||||
hits = [b for b in bl if b and b in tl]
|
||||
blocked = bool(hits)
|
||||
soft_hits = [w for w in COMMON_RISK_WORDS if w in tl]
|
||||
if blocked:
|
||||
risk_level = "blocked"
|
||||
elif soft_hits:
|
||||
risk_level = "review"
|
||||
else:
|
||||
risk_level = "safe"
|
||||
cat = classify(t)
|
||||
art_style, palette = derive_style_palette(t, country, category=cat)
|
||||
# motif:从分类模板取核心描述,去掉配色/白底尾巴,保持干净可复用
|
||||
motif = prompt_suggestion(t, cat).split(" --no ")[0].split(",")[0].strip()
|
||||
composition = derive_composition(t, cat)
|
||||
negative = ("no real people, no likeness of any person, no copyrighted characters, "
|
||||
"no brand logos, no trademarks, no celebrity, no readable text unless safe")
|
||||
slogan = _stable_slogan(t, country) # 按国家语言(JP→日语短标语,其余英语)
|
||||
out.append({
|
||||
"topic": t,
|
||||
"safe_for_print": not blocked,
|
||||
"risk_level": risk_level,
|
||||
"risk_reasons": [f"命中黑名单: {hits}"] if hits
|
||||
else (["疑似受保护实体,需人工复核"] if soft_hits else []),
|
||||
"suitable_for_print": not blocked,
|
||||
"design_category": cat,
|
||||
"concept": f"(启发式兜底)围绕「{t}」做原创{art_style}风格印花",
|
||||
"motif": motif,
|
||||
"art_style": art_style,
|
||||
"color_palette": palette,
|
||||
"composition": composition,
|
||||
"slogan": slogan,
|
||||
"negative_prompt": negative,
|
||||
"confidence": 0.55 if risk_level == "safe" else 0.4,
|
||||
})
|
||||
return out
|
||||
|
||||
def generate_seeds(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""规则生成种子词(零 API 成本):借用月份主题、临近节日、trending 派生、历史热点。"""
|
||||
month_themes = context.get("month_themes", []) or []
|
||||
upcoming = context.get("upcoming_holidays", []) or []
|
||||
trending = context.get("trending_seeds", []) or []
|
||||
history = context.get("history_hotspots", []) or []
|
||||
|
||||
style: List[str] = []
|
||||
related: List[str] = []
|
||||
# 月份主题 + 临近节日 → 风格种子(带美学倾向)
|
||||
style += list(month_themes)
|
||||
style += [f"{h.lower()} aesthetic" for h in upcoming]
|
||||
style += trending[:4]
|
||||
# related:历史 safe 热点 + 剩余 trending(行业交叉验证)
|
||||
related += history[:6]
|
||||
related += trending[4:8]
|
||||
|
||||
max_style = int(context.get("max_style_seeds", 10) or 10)
|
||||
max_related = int(context.get("max_related_seeds", 10) or 10)
|
||||
return {
|
||||
"style_seeds": _dedup_limit(style, max_style),
|
||||
"related_seeds": _dedup_limit(related, max_related),
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
"""OpenAI 兼容 LLM 后端(可插拔实现)。
|
||||
|
||||
支持 OpenAI / DeepSeek / 通义千问 / Kimi 等 OpenAI 兼容协议。
|
||||
LLM 调用失败(网络/限流/解析)时抛出异常,由 screen_node 降级到 MockBackend,
|
||||
保证流水线不中断。内置默认 SYSTEM_PROMPT,国家可在 prompts/<country>/system_prompt.md 覆盖。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import requests
|
||||
|
||||
# 模型调用一律直连:用户常开 VPN(系统代理),LLM 网关多为国内/自建,走代理会被拦截或变慢。
|
||||
# 环境变量级 NO_PROXY 双保险(requests/urllib3 均读取),Google 采集(pytrends)不受影响。
|
||||
os.environ.setdefault("NO_PROXY", "*")
|
||||
os.environ.setdefault("no_proxy", "*")
|
||||
from .base import LLMBackend
|
||||
from graph.paths import runtime_root
|
||||
|
||||
# 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求)
|
||||
NO_PROXY = {"http": None, "https": None}
|
||||
|
||||
# —— 默认系统提示词(国家未提供 prompts/<country>/system_prompt.md 时使用)——
|
||||
DEFAULT_SYSTEM_PROMPT = '''You are a Print-On-Demand (POD) design compliance screener AND a prompt engineer.
|
||||
You will receive a batch of trending search topics for ONE country, plus that country's aesthetic preference.
|
||||
For EACH topic, you must decide (a) whether it suits a POD t-shirt/mug print design, and (b) whether using it would risk infringement.
|
||||
|
||||
OUTPUT WORKFLOW CONTEXT: The user will provide a flat-lay photo of a plain garment (t-shirt) as a REFERENCE IMAGE (图1).
|
||||
The final product is the print artwork placed ONTO that garment via img2img. The artwork itself must be a STANDALONE
|
||||
PURE PRINT DESIGN: flat, print-ready, crisp and high-resolution, fitting BETWEEN a MINIMUM PRINT AREA OF ABOUT 15x18 cm AND A MAXIMUM OF 26x32 cm (width x height).
|
||||
Any size within that range is acceptable: the model is FREE to choose the size that best suits the design - do NOT always default to the largest size.
|
||||
Keep proportions, scale naturally to the content, never stretch, never force full-bleed, leave balanced margins.
|
||||
Optional text: text is OPTIONAL - a SHORT original English slogan/words may be added ONLY when they fit the print style,
|
||||
or keep the design text-free. ANY text used must be SAFE: no politics, no religion, no hate, no violence, no sexual content,
|
||||
no brand names, no logos, no trademarked phrases, no real-person names, no long sentences, no gibberish.
|
||||
To keep every prompt consistent and rule-based,
|
||||
DO NOT write free-form image prompts. Instead, provide these STRUCTURED parts for each topic, and the system assembles
|
||||
the final image_prompt / wearable_prompt / composite_prompt from FIXED templates:
|
||||
- motif: the central subject/illustration (English, concrete — what the design depicts; a pure flat print design, NO garment, NO model, NO background scene). AVOID words that image-generation content filters may flag — e.g. demon, devil, occult, satanic, blood, gore, gun, weapon, bomb, drug, skeleton, skull, horror, zombie — use harmless, cute or neutral equivalents instead.
|
||||
- art_style: the visual technique (English, e.g. "clean flat vector graphics", "kawaii minimalist illustration")
|
||||
- color_palette: the colors (English, e.g. "muted retro palette of oxblood red, cream, distressed black")
|
||||
- composition: the layout (English, e.g. "centered emblem with balanced negative space")
|
||||
Never embed a real garment into motif; describe the artwork only.
|
||||
|
||||
INFRINGEMENT RULES — reject or downgrade anything that:
|
||||
- Uses a trademark, brand name, or logo (e.g. Nike, Disney, Marvel, Apple, NFL, NBA, LEGO, Starbucks...).
|
||||
- Uses copyrighted characters / franchises / artwork.
|
||||
- Depicts a REAL person (celebrity, politician, influencer, athlete) — this violates right of publicity, even in caricature.
|
||||
- Touches sensitive content: politics, religion, hate, violence, sexual content.
|
||||
NOTE: even "homage", "fan art", or AI "redraws" of protected IP are risky. Do NOT rely on rewording to escape these rules.
|
||||
|
||||
REFRAMING (important): when a topic is HOT but references a protected element, EXTRACT a SAFE, ORIGINAL design angle that captures the *vibe* without the protected element. Examples:
|
||||
- a celebrity name -> generic "music festival / stage lights / concert crowd" mood, NO likeness.
|
||||
- a movie franchise -> generic "retro sci-fi adventure / cosmic explorer" mood, NO characters.
|
||||
- a brand product -> the lifestyle/activity around it (e.g. "cozy reading nook", "outdoor adventure") with NO logo.
|
||||
RISK ASSIGNMENT after reframing:
|
||||
- Once you produce a clean safe original angle, mark "safe" and USE IT DIRECTLY — even if the reframed topic keeps a weak thematic echo of the original (e.g. a celebrity name reframed as a generic "music festival" mood is SAFE).
|
||||
- Mark "review" ONLY when the residual risk is truly sensitive and cannot be cleanly removed: politics, religion, real-person likeness, hate, violence, sexual content, or a strongly protected brand/IP with no viable original angle.
|
||||
- Mark "blocked" only for unmistakable core violations that cannot be reframed at all.
|
||||
|
||||
OUTPUT: Respond with ONLY a JSON object (no markdown, no prose) of this exact shape:
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"topic": "<original topic string, verbatim>",
|
||||
"safe_for_print": true | false,
|
||||
"risk_level": "safe" | "review" | "blocked",
|
||||
"risk_reasons": ["short reason if any"],
|
||||
"suitable_for_print": true | false,
|
||||
"design_category": "Style" | "Meme" | "Event" | "Niche" | "Pattern" | "Quote" | "Failed",
|
||||
"concept": "<short design concept in Chinese, 1 sentence>",
|
||||
"motif": "<central subject/illustration, English, concrete — what the design depicts>",
|
||||
"art_style": "<visual technique, English; derive it from the TOPIC's vibe, NOT a fixed per-country default>",
|
||||
"color_palette": "<colors, English>",
|
||||
"composition": "<layout, English>",
|
||||
"slogan": "<optional short original slogan 1-3 words for the print text, written in the language of the TARGET COUNTRY (JP target → short Japanese slogan like \"ゆめいっぱい\"; US/GB/AU → English like \"good vibes\"); MUST be original, no brand names, no trademarked phrases, no quotes by real people, no politics/religion/hate; if text does NOT fit this design at all, return an empty string \"\">",
|
||||
"negative_prompt": "<MUST include: no real people, no likeness of any person, no copyrighted characters, no brand logos, no trademarks, no celebrity, no politics, no religion, no hate, no violence, no sexual content, no readable text unless it is a short original English slogan; and for image_prompt also: no garment, no mannequin, no photo of clothing.>",
|
||||
"confidence": 0.0
|
||||
}
|
||||
]
|
||||
}
|
||||
- motif / art_style / color_palette / composition must be English and concrete. The final prompts are assembled from these by FIXED templates — do NOT include the white-background suffix or garment text yourself.
|
||||
- design_category "Failed" only when the topic cannot be made into any safe print design.
|
||||
- confidence: 0-1, your certainty in the compliance + suitability judgment.
|
||||
Process every topic in the batch exactly once.'''
|
||||
|
||||
|
||||
# —— 种子词生成(动态设立 Google Trends 相关查询种子)——
|
||||
SEED_SYSTEM_PROMPT = '''You are a POD (Print-On-Demand) trend strategist. Given a country's current context (denoised trending searches, past safe design hotspots, season, month themes, upcoming holidays), propose SEED KEYWORDS for Google Trends "related queries" exploration.
|
||||
|
||||
Output TWO lists of short English keyword PHRASES (2-4 words each), suitable as Google Trends related-queries seeds:
|
||||
- style_seeds: aesthetic / style / vibe oriented (e.g. "cottagecore", "retro grunge", "halloween goth")
|
||||
- related_seeds: niche / subject / product oriented for cross-checking commercial printability (e.g. "funny cat", "vintage car", "skull art")
|
||||
|
||||
Rules:
|
||||
- Prefer ORIGINAL, non-infringing angles. Avoid brand names, trademarks, real-person names, copyrighted franchises.
|
||||
- Lean into the provided season / month themes / upcoming holidays where relevant.
|
||||
- Use the trending + history signals to pick what is CURRENTLY relevant for THIS country.
|
||||
- Return ONLY JSON of shape: {"style_seeds": [...], "related_seeds": [...]}'''
|
||||
|
||||
|
||||
# —— 商品标题生成(多模态:分析服装图片 → 中英双语 SEO 标题)——
|
||||
# 模板字典按编号存放;TITLE_TEMPLATE_ROUTE 按国家路由到模板编号。
|
||||
# 模板 1:英语市场(US/GB/AU/MX)→ en_title + cn_title
|
||||
# 模板 2:日本市场(JP)→ en_title + cn_title + ja_title
|
||||
TITLE_TEMPLATES: Dict[str, str] = {
|
||||
"1": '''# Role
|
||||
你是一位资深的跨境服装运营专家,精通英语电商的SEO标题逻辑。你的任务是通过分析服装图片,生成高权重的英语-中文商品标题。
|
||||
|
||||
# Task
|
||||
请深度分析图片中的服装特征(品类、风格、材质、剪裁、细节、受众),生成符合英语电商搜索逻辑的中英双语标题。
|
||||
|
||||
# 当前时间(标题须贴合当下,季节/年份词以此为准)
|
||||
- **Current time**: {year}-{month}({season}),标题中的年份/季节等时效词必须使用以上时间。
|
||||
|
||||
# Analysis Focus (视觉分析重点)
|
||||
- **品类识别**:准确判断英语核心词(如 Dress, Blouse, Sweatshirt)和中文核心词(如 连衣裙, 卫衣)。
|
||||
- **风格定位**:判断风格流派(如 Boho, Vintage, Minimalist / 法式, 复古, 极简)。
|
||||
- **设计细节**:提取领型、袖型、裙长等(如 V-neck, Puff Sleeve / V领, 阔袖)。
|
||||
- **适用场景**:推断穿着场景(如 Beach, Office, Party / 度假, 通勤, 约会)。
|
||||
|
||||
# Constraints (生成规则)
|
||||
- **English Title**: 遵循 Amazon US/UK 风格,核心词前置,包含材质、风格、场景等长尾词,符合英语搜索习惯,简洁有力。
|
||||
- **Chinese Title**: 遵循淘宝/1688风格,关键词权重递减,包含季节+风格+核心词+卖点+人群。
|
||||
|
||||
- **Output**: 必须严格返回 JSON 格式,不要包含 Markdown 代码块标记,格式如下:
|
||||
{"en_title": "Title in English", "cn_title": "中文标题"}''',
|
||||
"2": '''# Role
|
||||
你是一位资深的跨境服装运营专家,精通日本电商(楽天市場・Amazon.co.jp・Yahoo!ショッピング)的SEO标题逻辑。你的任务是通过分析服装图片,生成面向日本市场的高权重英语-中文-日语三语商品标题。
|
||||
|
||||
# Task
|
||||
请深度分析图片中的服装特征(品类、风格、材质、剪裁、细节、受众),生成符合日本电商搜索逻辑的三语标题。
|
||||
|
||||
# 当前时间(标题须贴合当下,季节/年份词以此为准)
|
||||
- **現在の時刻**: {year}-{month}({season}),标题中的年份/季节等时效词必须使用以上时间。
|
||||
|
||||
# Analysis Focus (视觉分析重点)
|
||||
- **品类识别**:准确判断英语核心词(如 Dress, Blouse, Sweatshirt)、中文核心词(如 连衣裙, 卫衣)和日语核心词(如 ワンピース, ブラウス, スウェット)。
|
||||
- **风格定位**:判断风格流派(如 フェミニン, ヴィンテージ, ミニマル / 法式, 复古, 极简 / フェミニン, レトロ, シンプル)。
|
||||
- **设计细节**:提取领型、袖型、裙长等(如 Vネック, パフスリーブ / V领, 阔袖)。
|
||||
- **适用场景**:推断穿着场景(如 オフィス, デート, 旅行 / 通勤, 约会, 度假)。
|
||||
|
||||
# Constraints (生成规则)
|
||||
- **English Title**: 遵循 Amazon US/UK 风格,核心词前置,包含材质、风格、场景等长尾词,符合英语搜索习惯,简洁有力。
|
||||
- **Chinese Title**: 遵循淘宝/1688风格,关键词权重递减,包含季节+风格+核心词+卖点+人群。
|
||||
- **Japanese Title (ja_title)**: 遵循楽天市場/Amazon.co.jp 风格,核心词前置,使用自然日语(平假名/片假名/汉字混合),包含材质、风格、场景等长尾词与常用搜索标签(如 レディース, 春夏, 通勤),贴合日本人搜索习惯,简洁有力,不要机器翻译腔。
|
||||
|
||||
- **Output**: 必须严格返回 JSON 格式,不要包含 Markdown 代码块标记,格式如下:
|
||||
{"en_title": "Title in English", "cn_title": "中文标题", "ja_title": "日本語タイトル"}''',
|
||||
}
|
||||
|
||||
# 国家 → 标题模板编号(JP 路由到模板 2,其余默认模板 1;后续可按国家新增模板 3...)
|
||||
TITLE_TEMPLATE_ROUTE: Dict[str, str] = {
|
||||
"US": "1",
|
||||
"GB": "1",
|
||||
"JP": "2",
|
||||
"AU": "1",
|
||||
"MX": "1",
|
||||
}
|
||||
|
||||
|
||||
def _inject_now(prompt: str) -> str:
|
||||
"""把模板中的 {year}/{month}/{season} 替换为当前时间(用 replace 避免 JSON 花括号冲突)。"""
|
||||
import datetime
|
||||
now = datetime.datetime.now()
|
||||
m = now.month
|
||||
season = {12: "冬", 1: "冬", 2: "冬", 3: "春", 4: "春", 5: "春",
|
||||
6: "夏", 7: "夏", 8: "夏", 9: "秋", 10: "秋", 11: "秋"}[m]
|
||||
return (prompt.replace("{year}", str(now.year))
|
||||
.replace("{month}", str(m))
|
||||
.replace("{season}", season))
|
||||
|
||||
|
||||
def resolve_title_prompt(country: str = "") -> str:
|
||||
"""按国家解析标题生成提示词(自动注入当前时间变量);未知国家/留空回退模板 1。"""
|
||||
tpl_no = TITLE_TEMPLATE_ROUTE.get(country or "", "1")
|
||||
return _inject_now(TITLE_TEMPLATES.get(tpl_no, TITLE_TEMPLATES["1"]))
|
||||
|
||||
|
||||
def build_seed_user_prompt(context: Dict[str, Any]) -> str:
|
||||
trending = context.get("trending_seeds", []) or []
|
||||
history = context.get("history_hotspots", []) or []
|
||||
lines = [
|
||||
f"Country: {context.get('country', '')}",
|
||||
f"Current date: {context.get('date', '')} "
|
||||
f"(Year {context.get('year', '')}, Month {context.get('month', '')}, {context.get('season', '')})",
|
||||
f"Season: {context.get('season', '')}",
|
||||
f"Month themes: {', '.join(context.get('month_themes', []) or [])}",
|
||||
f"Upcoming holidays for {context.get('country', '')}: "
|
||||
f"{', '.join(context.get('upcoming_holidays', []) or [])} "
|
||||
f"— INCLUDE holiday-themed style seeds from the list above when any is close.",
|
||||
"",
|
||||
"Current trending searches (denoised):",
|
||||
]
|
||||
lines += [f"- {t}" for t in trending] or ["- (none)"]
|
||||
lines += ["", "Past safe design hotspots (for continuity):"]
|
||||
lines += [f"- {t}" for t in history] or ["- (none)"]
|
||||
lines += ["", "Return JSON with style_seeds and related_seeds (each 2-4 word English phrases)."]
|
||||
return "\n".join(lines)
|
||||
|
||||
CACHE_DIR = runtime_root() / ".cache" / "llm_screen"
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _cache_get(key):
|
||||
p = CACHE_DIR / f"{key}.json"
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(key, val):
|
||||
try:
|
||||
(CACHE_DIR / f"{key}.json").write_text(json.dumps(val, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_user_prompt(country, topics, aesthetic_hint):
|
||||
topic_lines = "\n".join(f"{i+1}. {t}" for i, t in enumerate(topics))
|
||||
return (
|
||||
f"Country: {country}\n"
|
||||
f"Country aesthetic preference: {aesthetic_hint}\n\n"
|
||||
f"Trending topics to screen (one per line):\n{topic_lines}\n\n"
|
||||
f"Return JSON with one result per topic, following the schema exactly."
|
||||
)
|
||||
|
||||
|
||||
def call_openai_compatible(cfg, messages, timeout=90):
|
||||
base_url = str(cfg.get("base_url", "https://api.openai.com/v1")).rstrip("/")
|
||||
api_key = cfg.get("api_key", "")
|
||||
model = cfg.get("model", "gpt-4o-mini")
|
||||
url = f"{base_url}/chat/completions"
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": float(cfg.get("temperature", 0.6)),
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=timeout, proxies=NO_PROXY)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def _retry(func, max_attempts=4, base_delay=4):
|
||||
last = None
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return func()
|
||||
except Exception as e: # noqa: BLE001
|
||||
last = e
|
||||
if attempt == max_attempts - 1:
|
||||
break
|
||||
time.sleep(base_delay * (2 ** attempt))
|
||||
raise last if last else RuntimeError("llm retry failed")
|
||||
|
||||
|
||||
def _extract_json(text):
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r"\{.*\}", text, re.S)
|
||||
if m:
|
||||
return json.loads(m.group(0))
|
||||
raise
|
||||
|
||||
|
||||
class OpenAICompatBackend(LLMBackend):
|
||||
name = "openai_compat"
|
||||
|
||||
def screen(self, topics, country, aesthetic_hint, system_prompt, blacklist, batch_size=12):
|
||||
# 注意:这里 blacklist 已由 screen_node 在更前置阶段过滤,此处仅透传信息给 LLM。
|
||||
# 实际硬过滤在 filter 阶段完成;LLM 主要做"热点但涉保护元素"的安全重构。
|
||||
cfg = self._cfg # 由 screen_node 注入
|
||||
batches = [topics[i:i + batch_size] for i in range(0, len(topics), batch_size)]
|
||||
all_results: List[Dict[str, Any]] = []
|
||||
for b_idx, batch in enumerate(batches):
|
||||
cache_key = hashlib.md5(
|
||||
f"{self.name}|{country}|{b_idx}|{','.join(batch)}".encode("utf-8")
|
||||
).hexdigest()
|
||||
screened = _cache_get(cache_key)
|
||||
if screened is None:
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": build_user_prompt(country, batch, aesthetic_hint)},
|
||||
]
|
||||
raw = _retry(lambda: call_openai_compatible(cfg, messages))
|
||||
parsed = _extract_json(raw)
|
||||
screened = parsed.get("results", [])
|
||||
_cache_set(cache_key, screened)
|
||||
all_results.extend(screened)
|
||||
return all_results
|
||||
|
||||
def bind_config(self, cfg):
|
||||
# 解析密钥/地址:配置值优先,其次环境变量(避免在 config.yaml 硬编码密钥)。
|
||||
resolved = dict(cfg or {})
|
||||
resolved["api_key"] = (
|
||||
(cfg or {}).get("api_key")
|
||||
or os.environ.get("LLM_API_KEY")
|
||||
or os.environ.get("OPENAI_API_KEY")
|
||||
or ""
|
||||
)
|
||||
resolved["base_url"] = (
|
||||
(cfg or {}).get("base_url")
|
||||
or os.environ.get("LLM_BASE_URL")
|
||||
or "https://api.openai.com/v1"
|
||||
)
|
||||
self._cfg = resolved
|
||||
|
||||
@property
|
||||
def has_key(self) -> bool:
|
||||
return bool((self._cfg or {}).get("api_key"))
|
||||
|
||||
def generate_seeds(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cfg = self._cfg # 由 seed_node 注入(含 env 解析后的 api_key/base_url)
|
||||
cache_key = hashlib.md5(
|
||||
f"seed|{self.name}|{json.dumps(context, sort_keys=True, ensure_ascii=False)}".encode("utf-8")
|
||||
).hexdigest()
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
messages = [
|
||||
{"role": "system", "content": SEED_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_seed_user_prompt(context)},
|
||||
]
|
||||
raw = _retry(lambda: call_openai_compatible(cfg, messages, timeout=90))
|
||||
parsed = _extract_json(raw)
|
||||
out = {
|
||||
"style_seeds": [str(x) for x in (parsed.get("style_seeds", []) or [])][:10],
|
||||
"related_seeds": [str(x) for x in (parsed.get("related_seeds", []) or [])][:10],
|
||||
}
|
||||
_cache_set(cache_key, out)
|
||||
return out
|
||||
|
||||
def generate_title(self, image_path: str, system_prompt: str = "", country: str = "",
|
||||
fallback_text: str = "") -> Dict[str, Any]:
|
||||
"""多模态标题生成;图片输入不被模型支持(如 qwen 纯文本模型 400)时,
|
||||
自动降级为纯文本生成(fallback_text 为商品描述/热点主题)。"""
|
||||
"""多模态:分析服装图片,生成商品标题(按国家路由模板)。
|
||||
|
||||
系统提示词:显式传入优先;否则按 country 经 TITLE_TEMPLATE_ROUTE 路由到对应模板。
|
||||
模板 1(US/GB/AU/MX)返回 {"en_title","cn_title"};
|
||||
模板 2(JP)额外返回 {"ja_title"}。
|
||||
无 key/调用失败返回 {}(调用方兜底不中断)。
|
||||
"""
|
||||
cfg = self._cfg
|
||||
api_key = cfg.get("api_key", "")
|
||||
if not api_key:
|
||||
print("[titles] 未配置 LLM api_key(llm_screen.api_key 或环境变量),跳过标题生成")
|
||||
return {}
|
||||
base_url = str(cfg.get("base_url") or "https://api.openai.com/v1").rstrip("/")
|
||||
model = cfg.get("model", "gpt-4o-mini")
|
||||
url = f"{base_url}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
|
||||
# 图片 → base64 data URI(多模态输入)
|
||||
try:
|
||||
import base64 as b64
|
||||
mime = "image/png"
|
||||
p = Path(image_path)
|
||||
if p.suffix.lower() in (".jpg", ".jpeg"):
|
||||
mime = "image/jpeg"
|
||||
data_uri = f"data:{mime};base64,{b64.b64encode(p.read_bytes()).decode()}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[titles] 图片读取失败: {e}")
|
||||
return {}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt or resolve_title_prompt(country)},
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "请分析这张服装图片,按规则输出标题,结果以 JSON 格式返回。"},
|
||||
{"type": "image_url", "image_url": {"url": data_uri}},
|
||||
]},
|
||||
],
|
||||
"temperature": 0.4,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=180, proxies=NO_PROXY)
|
||||
resp.raise_for_status()
|
||||
msg = resp.json()["choices"][0]["message"]
|
||||
content = str(msg.get("content") or "").strip()
|
||||
if not content:
|
||||
# qwen 等推理模型可能把输出放在 reasoning_content
|
||||
content = str(msg.get("reasoning_content") or "").strip()
|
||||
if not content:
|
||||
print("[titles] LLM 返回空内容,跳过标题生成")
|
||||
return {}
|
||||
parsed = _extract_json(content)
|
||||
return {
|
||||
"en_title": str(parsed.get("en_title", "")).strip(),
|
||||
"cn_title": str(parsed.get("cn_title", "")).strip(),
|
||||
"ja_title": str(parsed.get("ja_title", "")).strip(),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[titles] 标题生成失败: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,57 @@
|
||||
"""配置与提示词加载工具。
|
||||
|
||||
职责:
|
||||
- 读取 configs/countries/<country>.yaml(该国专属种子词/权重/limit 等覆盖)
|
||||
- 读取 prompts/<country>/aesthetics.yaml(该国审美 hint、风格-配色 extra 规则、额外黑名单)
|
||||
- 读取 prompts/<country>/system_prompt.md(该国 LLM 系统提示覆盖)
|
||||
- 把上述合并进 country_config,供节点使用
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml_safe(path: Path) -> Dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[loader] 解析失败 {path}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def load_text_safe(path: Path) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
def build_country_config(global_config: Dict[str, Any], country: str, project_root: Path) -> Dict[str, Any]:
|
||||
"""合并:全局配置 + 国家专属 yaml + 国家审美 yaml。"""
|
||||
cc: Dict[str, Any] = load_yaml_safe(project_root / "configs" / "countries" / f"{country}.yaml")
|
||||
|
||||
aesthetics = load_yaml_safe(project_root / "prompts" / country / "aesthetics.yaml")
|
||||
cc.setdefault("style_hint", aesthetics.get("style_hint", ""))
|
||||
cc.setdefault("extra_style_rules", aesthetics.get("extra_style_rules", []) or [])
|
||||
cc.setdefault("extra_blacklist", aesthetics.get("extra_blacklist", []) or [])
|
||||
# 国家专属趋势/风格/行业种子(若 aesthetics 里有也并入 cc 顶层,便于 source 读取)
|
||||
for k in ("trending", "style", "related", "timeframe"):
|
||||
if k in aesthetics and k not in cc:
|
||||
cc[k] = aesthetics[k]
|
||||
return cc
|
||||
|
||||
|
||||
def load_system_prompt(prompts_dir: Path, default: str) -> str:
|
||||
"""该国 prompts/<country>/system_prompt.md 作为「补充段」叠加到默认规则之后。
|
||||
|
||||
这样既能按国家定制口吻/合规重点,又不丢失内置核心合规规则。若该国未提供文件则用默认。
|
||||
"""
|
||||
text = load_text_safe(prompts_dir / "system_prompt.md")
|
||||
if not text:
|
||||
return default
|
||||
return default + "\n\n# 该国专属补充指令\n" + text
|
||||
@@ -0,0 +1,26 @@
|
||||
"""流水线节点集合。"""
|
||||
from .compose_node import compose_node
|
||||
from .fetch_node import fetch_node
|
||||
from .filter_node import filter_node
|
||||
from .oss_upload_node import oss_upload_node
|
||||
from .product_node import product_node
|
||||
from .prompt_node import prompt_node
|
||||
from .score_node import score_node
|
||||
from .screen_node import screen_node
|
||||
from .seed_node import seed_node
|
||||
from .seed_shot_node import seed_shot_node
|
||||
from .template_export_node import template_export_node
|
||||
|
||||
__all__ = [
|
||||
"fetch_node",
|
||||
"filter_node",
|
||||
"score_node",
|
||||
"screen_node",
|
||||
"prompt_node",
|
||||
"product_node",
|
||||
"compose_node",
|
||||
"seed_node",
|
||||
"oss_upload_node",
|
||||
"seed_shot_node",
|
||||
"template_export_node",
|
||||
]
|
||||
@@ -0,0 +1,194 @@
|
||||
"""节点 5.5/6:生成印花设计稿 + 导出简报包(compose)。
|
||||
|
||||
流程位置:prompt_build → compose → product(compose 在 product 之前)。
|
||||
职责��
|
||||
1. 生成纯印花设计稿:对前 N 个 safe 简报(N=config.compose.design_count,默认 1),
|
||||
用 image_prompt 调图像后端 generate()(白底、可直接打印),产物存 output/<country>/designs/,
|
||||
设计稿路径写回 brief.design_path,并汇总返回 designs 列表供 product 节点使用(图2)。
|
||||
2. 导出简报包:design_briefs.json/md、composite_prompts.json/md、report.md。
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"}
|
||||
|
||||
|
||||
def _build_briefs_md(briefs: List[Dict[str, Any]], generated_at: str) -> str:
|
||||
lines = [
|
||||
"# POD 印花设计简报(LLM 合规筛选 + 生图提示词)",
|
||||
"",
|
||||
f"- 生成时间: {generated_at}",
|
||||
f"- 通过筛选: {len(briefs)} 条",
|
||||
"",
|
||||
"## 一、安全设计清单(按综合分排序)",
|
||||
"",
|
||||
"| 排名 | 国家 | 热点词 | 类别 | 风险 | 设计概念 |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for i, r in enumerate(briefs, 1):
|
||||
flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level"))
|
||||
lines.append(
|
||||
f"| {i} | {r.get('country','')} | {r.get('topic','')} | {r.get('design_category','')} | {flag} | {r.get('concept','')} |"
|
||||
)
|
||||
lines += ["", "## 二、设计要素 + 封装提示词", ""]
|
||||
lines.append("> 工作流:① `image_prompt` = 印花设计稿(白底,单独生图);② 上传平铺衣服底图(图1)后,")
|
||||
lines.append("> 用 `composite_prompt` + 图1 经 img2img 把设计印到衣服;规则写死:保留衣服、胸前居中印花、真实丝网质感。")
|
||||
lines.append("")
|
||||
for i, r in enumerate(briefs, 1):
|
||||
flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level"))
|
||||
lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')} ({flag})")
|
||||
lines.append(f"- 类别: {r.get('design_category','')}")
|
||||
lines.append(f"- 设计要素: 主体=「{r.get('motif','')}」 | 风格=「{r.get('art_style','')}」 | 配色=「{r.get('color_palette','')}」 | 构图=「{r.get('composition','')}」")
|
||||
lines.append(f"- 概念: {r.get('concept','')}")
|
||||
if r.get("risk_reasons"):
|
||||
lines.append(f"- 风险提示: {'; '.join(r['risk_reasons'])}")
|
||||
if r.get("design_path"):
|
||||
lines.append(f"- **设计稿**: {r['design_path']}")
|
||||
lines.append(f"- **设计稿 Prompt (image_prompt)**: {r.get('image_prompt','')}")
|
||||
lines.append(f"- **印到底图 Prompt (composite_prompt)**: {r.get('composite_prompt','')}")
|
||||
lines.append(f"- **Composite Negative**: {r.get('composite_negative','')}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_composite_md(briefs: List[Dict[str, Any]]) -> str:
|
||||
lines = [
|
||||
"# 封装提示词包(印到平铺衣服底图 图1)",
|
||||
"",
|
||||
f"- 共 {len(briefs)} 条,每条含 `composite_prompt`(印图指令)+ `composite_negative`。",
|
||||
"- 用法:将你的平铺衣服参考图作为图1,连同 `composite_prompt` 送入任意 img2img / inpaint 模型。",
|
||||
"",
|
||||
]
|
||||
for i, r in enumerate(briefs, 1):
|
||||
lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')}")
|
||||
lines.append(f"- composite_prompt: {r.get('composite_prompt','')}")
|
||||
lines.append(f"- composite_negative: {r.get('composite_negative','')}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_report_md(state: Dict[str, Any]) -> str:
|
||||
country = state.get("country", "")
|
||||
stats = state.get("stats") or {}
|
||||
errors = state.get("errors") or []
|
||||
lines = [
|
||||
f"# POD 热点抓取报告 - {country}",
|
||||
"",
|
||||
f"- 生成时间: {time.strftime('%Y-%m-%dT%H:%M:%S')}",
|
||||
"",
|
||||
"## 各阶段统计",
|
||||
"",
|
||||
"| 阶段 | 指标 |",
|
||||
"|---|---|",
|
||||
]
|
||||
for k, v in stats.items():
|
||||
lines.append(f"| {k} | {v} |")
|
||||
lines += ["", "## 兜底错误记录(节点级 fallback 捕获)", ""]
|
||||
if errors:
|
||||
for e in errors:
|
||||
lines.append(f"- [{e.get('node')}] {e.get('type')}: {e.get('message')}")
|
||||
else:
|
||||
lines.append("- 无(全部节点正常)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@with_fallback("compose")
|
||||
def compose_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
briefs: List[Dict[str, Any]] = state.get("briefs") or []
|
||||
output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录)
|
||||
config = state["config"]
|
||||
country = state.get("country", "")
|
||||
|
||||
generated_at = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# 1) design_briefs.json(缓存 → 根目录,不进时间戳任务文件夹)
|
||||
(cache_dir / "design_briefs.json").write_text(
|
||||
json.dumps({"generated_at": generated_at, "total": len(briefs), "design_briefs": briefs},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# 2) design_briefs.md
|
||||
(cache_dir / "design_briefs.md").write_text(
|
||||
_build_briefs_md(briefs, generated_at), encoding="utf-8")
|
||||
|
||||
# 3) composite_prompts.json / .md
|
||||
(cache_dir / "composite_prompts.json").write_text(
|
||||
json.dumps({"generated_at": generated_at, "total": len(briefs), "composite_prompts": briefs},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
(cache_dir / "composite_prompts.md").write_text(
|
||||
_build_composite_md(briefs), encoding="utf-8")
|
||||
|
||||
# 4) report.md(本次任务报告 → 产物目录)
|
||||
(output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8")
|
||||
|
||||
# 5) 生成纯印花设计稿(图2):前 N 个 safe 简报用 image_prompt 文生图
|
||||
designs: List[Dict[str, Any]] = []
|
||||
compose_cfg = config.get("compose") or {}
|
||||
backend_name = compose_cfg.get("backend", "")
|
||||
ib = None
|
||||
if backend_name:
|
||||
try:
|
||||
from graph.backends import get_image_backend
|
||||
ib = get_image_backend(backend_name)
|
||||
if ib is not None:
|
||||
ib.bind_config(compose_cfg)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[compose] 图像后端 {backend_name} 不可用: {e}")
|
||||
if ib is None:
|
||||
print("[compose] 未配置 compose.backend(openai/mock),跳过印花设计稿生成。")
|
||||
else:
|
||||
# 设计稿覆盖所有简报(含 review):每个热点一张设计,避免 review 热点无设计
|
||||
# 导致 product 回退生成重复占位图;风险由 assign 层(allow_review)控制是否分配
|
||||
safe_briefs = briefs
|
||||
design_count = int(compose_cfg.get("design_count", 1))
|
||||
# 联动总任务数:每个产品一张设计 → 生成 扩展后 spu_tasks 总数 张设计
|
||||
task_n = int(len((state.get("config") or {}).get("product", {}).get("spu_tasks") or []))
|
||||
if task_n > design_count:
|
||||
design_count = task_n
|
||||
design_dir = output_dir / "designs"
|
||||
design_dir.mkdir(exist_ok=True)
|
||||
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _gen_one(i: int, b: Dict[str, Any]):
|
||||
"""单张设计稿生成(并发线程内调用,每设计一线程)。"""
|
||||
try:
|
||||
img_prompt = sanitize_image_prompt(b.get("image_prompt", ""))
|
||||
img_prompt = ensure_rebrand_hint(b, img_prompt) # review → 原创化魔改引导
|
||||
out_path = ib.generate(
|
||||
img_prompt,
|
||||
str(design_dir / f"{country}_{i:02d}_design.png"),
|
||||
b.get("composite_negative", ""),
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
return i, b, out_path, None
|
||||
except Exception as e: # noqa: BLE001
|
||||
return i, b, None, e
|
||||
|
||||
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))))
|
||||
print(f"[compose] 并发生成 {len(targets)} 张设计稿({workers} 线程)…")
|
||||
with ThreadPoolExecutor(max_workers=workers) as _ex:
|
||||
_futs = [_ex.submit(_gen_one, i, b) for i, b in targets]
|
||||
for _f in as_completed(_futs):
|
||||
i, b, out_path, err = _f.result()
|
||||
if err is not None:
|
||||
print(f"[compose] 设计稿生成失败 {b.get('topic', '')}: {err}")
|
||||
state.setdefault("errors", []).append({
|
||||
"node": "compose", "type": type(err).__name__,
|
||||
"message": f"设计稿生成失败 {b.get('topic','')}: {err}", "trace": ""})
|
||||
else:
|
||||
b["design_path"] = out_path
|
||||
designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path})
|
||||
print(f"[compose] 印花设计稿已生成: {out_path}")
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["compose"] = {"written": len(briefs), "designs": len(designs), "output_dir": str(output_dir)}
|
||||
return {"composite": briefs, "designs": designs, "stats": stats,
|
||||
"errors": state.get("errors") or []}
|
||||
@@ -0,0 +1,55 @@
|
||||
"""节点 1/6:抓取(fetch)。
|
||||
|
||||
按 config.sources 启用各可插拔数据源,汇总统一格式行。
|
||||
单源失败不影响其它源(内部逐个 try),整体再套 with_fallback 兜底。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.sources import get_source
|
||||
from graph.validate import validate_rows, with_fallback
|
||||
|
||||
|
||||
@with_fallback("fetch")
|
||||
def fetch_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
country = state["country"]
|
||||
config = state["config"]
|
||||
cc = state["country_config"]
|
||||
enabled = config.get("sources") or ["google_trends"]
|
||||
rows: List[Dict[str, Any]] = []
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
# 采集缓存优先:采集(fetch_keywords)成功后写入 output/<国>/collected_keywords.json,
|
||||
# 这里直接用(跳过 Google 重抓),避免重复撞限流;无缓存才走数据源抓取
|
||||
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"
|
||||
if p.exists():
|
||||
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] # 已过滤去重的关键词
|
||||
print(f"[fetch] 使用采集缓存 {len(rows)} 条({country},跳过 Google 抓取)")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["fetch"] = {"raw_rows": len(rows), "sources": ["collected_cache"], "errors": 0}
|
||||
return {"raw_rows": rows, "errors": errors, "stats": stats}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[fetch] 读取采集缓存失败(回退数据源): {e}")
|
||||
|
||||
for name in enabled:
|
||||
try:
|
||||
src = get_source(name)
|
||||
rows.extend(src.fetch(country, cc, config))
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({
|
||||
"node": "fetch", "type": type(e).__name__,
|
||||
"message": f"source[{name}]: {e}", "trace": "",
|
||||
})
|
||||
print(f"[fetch] 数据源 {name} 失败(跳过): {e}")
|
||||
|
||||
rows = validate_rows(rows, "fetch")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["fetch"] = {"raw_rows": len(rows), "sources": enabled, "errors": len(errors)}
|
||||
return {"raw_rows": rows, "errors": errors, "stats": stats}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""节点 2/6:过滤(filter)。
|
||||
|
||||
三级过滤,全部带兜底、单级失败不影响其它级:
|
||||
1) 合规黑名单(全局 + 国家 extra)
|
||||
2) 真实人物(名单 + Firstname Lastname 模式,仅对 gt_trending 源,避免误删风格词)
|
||||
3) 设计相关性(剔除泛新闻/科技/赛事词)
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.scoring import apply_blacklist, filter_design_relevance, filter_person_names, filter_query_noise
|
||||
from graph.validate import validate_rows, with_fallback
|
||||
|
||||
|
||||
@with_fallback("filter")
|
||||
def filter_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rows: List[Dict[str, Any]] = state.get("raw_rows") or []
|
||||
config = state["config"]
|
||||
cc = state["country_config"]
|
||||
country = state.get("country", "")
|
||||
|
||||
# 黑名单:全局 + 国家专属
|
||||
bl = [str(b).lower() for b in (config.get("blacklist") or [])]
|
||||
bl += [str(b).lower() for b in (cc.get("extra_blacklist") or [])]
|
||||
bl = list(set(bl))
|
||||
|
||||
name_cfg = config.get("name_filter") or {}
|
||||
rel_cfg = config.get("relevance") or {}
|
||||
|
||||
dropped_total = 0
|
||||
|
||||
kept, dropped = apply_blacklist(rows, bl)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
if name_cfg.get("enabled", True):
|
||||
kept, dropped = filter_person_names(
|
||||
kept,
|
||||
extra_names=name_cfg.get("extra_names"),
|
||||
patterns=name_cfg.get("patterns"),
|
||||
exemptions=name_cfg.get("exemptions"),
|
||||
pattern_sources=set(name_cfg.get("pattern_sources") or ["gt_trending"]),
|
||||
)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
if rel_cfg.get("enabled", True):
|
||||
kept, dropped = filter_design_relevance(
|
||||
kept,
|
||||
drop_patterns=rel_cfg.get("drop_patterns"),
|
||||
keep_patterns=rel_cfg.get("keep_patterns"),
|
||||
)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
# ③ 新闻类热点(天气/灾害/政治/事故等突发新闻,非印花主题;按国家语言过滤)
|
||||
if kept:
|
||||
from graph.scoring import filter_news
|
||||
kept, dropped = filter_news(kept, country)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
# ④ 查询噪声(问句/命名清单/损坏碎片/模糊名词)—— 防止被 Mock 误标 safe
|
||||
qn_cfg = config.get("query_noise") or {}
|
||||
if qn_cfg.get("enabled", True):
|
||||
kept, dropped = filter_query_noise(kept, enabled=True)
|
||||
dropped_total += len(dropped)
|
||||
|
||||
kept = validate_rows(kept, "filter")
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["filter"] = {"kept": len(kept), "dropped": dropped_total}
|
||||
return {"filtered_rows": kept, "stats": stats}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""节点 7/7:压缩 + 上传阿里云 OSS(oss_upload)。
|
||||
|
||||
在 product 之后运行:把 product 生成的成品图(composite / printed / design / basemap)
|
||||
压缩为 3:4 / ≥1340×1785 / <2MB 的 JPEG,上传到 config.oss 图床。
|
||||
|
||||
上传 key(图床路径):{国家}/{任务时间戳}/{货号}_{4位随机}.jpg
|
||||
- 任务时间戳:任务开始记录(YYYYMMDDHHMMSS),state["task_timestamp"],缺失时取当前时间
|
||||
- 货号:用户自定义前缀(config.product.code_prefix,默认 DG)+ 3 位计数(000 起,最多 999)
|
||||
- 4 位随机:大小写英文 + 数字
|
||||
|
||||
压缩/上传均带兜底:单图失败不影响其它;未配置 oss 或 enabled=false 时静默跳过。
|
||||
"""
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
# design 设计稿是过程稿(不进模板),不压缩不上传;只传最终商品图
|
||||
KIND_ORDER = ["composite", "printed", "basemap"]
|
||||
MAX_CODE = 999 # 货号计数上限(000~998 共 999 张)
|
||||
|
||||
|
||||
def _gen_rand4() -> str:
|
||||
return "".join(random.choices(string.ascii_letters + string.digits, k=4))
|
||||
|
||||
|
||||
@with_fallback("oss_upload")
|
||||
def oss_upload_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
config = state["config"] or {}
|
||||
oss_cfg = config.get("oss") or {}
|
||||
country = state.get("country", "")
|
||||
|
||||
if not (oss_cfg.get("oss_bucket") and oss_cfg.get("oss_key_id")):
|
||||
print("[oss] 未配置 oss(config.oss),跳过压缩上传节点。")
|
||||
return {"oss": [], "stats": state.get("stats") or {}}
|
||||
if not bool(oss_cfg.get("enabled", True)):
|
||||
print("[oss] config.oss.enabled=false,跳过上传。")
|
||||
return {"oss": [], "stats": state.get("stats") or {}}
|
||||
|
||||
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
|
||||
|
||||
# 任务时间戳:任务开始记录;缺失则当前时间
|
||||
ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S"))
|
||||
# 货号前缀:config.product.code_prefix(默认 DG)
|
||||
prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip()
|
||||
# 序号从 state 续接(一次任务内跨多次节点调用不重号)
|
||||
seq = int(state.get("oss_seq") or 0)
|
||||
|
||||
uploaded: List[Dict[str, Any]] = []
|
||||
stats = dict(state.get("stats") or {})
|
||||
for r in products:
|
||||
spu = r.get("spu_code", "")
|
||||
sku = r.get("sku_code", "")
|
||||
for kind in KIND_ORDER:
|
||||
src = r.get(f"{kind}_path")
|
||||
if not src or not Path(src).exists():
|
||||
continue
|
||||
if seq >= MAX_CODE:
|
||||
print(f"[oss] 货号计数已达上限 999,停止上传后续图片({src})")
|
||||
break
|
||||
try:
|
||||
code = f"{prefix}{seq:03d}" # 货号:前缀 + 3 位计数(000 起)
|
||||
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
|
||||
key = build_oss_key(country, ts, code, _gen_rand4())
|
||||
url = upload_to_oss(oss_cfg, compressed, key)
|
||||
if url:
|
||||
r[f"{kind}_url"] = url
|
||||
r["oss_code"] = code
|
||||
uploaded.append({"spu_code": spu, "sku_code": sku, "kind": kind,
|
||||
"code": code, "url": url})
|
||||
seq += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[oss] 处理失败 {src}: {e}")
|
||||
|
||||
# 多色:color_composites 用于模板按颜色路由——首色用主图 url/code,额外色单独上传(独立货号)
|
||||
color_ups: List[Dict[str, Any]] = []
|
||||
comps = r.get("color_composites") or []
|
||||
if comps and r.get("composite_url"):
|
||||
color_ups.append({**comps[0], "url": r["composite_url"], "code": r.get("oss_code", "")})
|
||||
for cc in comps[1:]:
|
||||
src = cc.get("composite_path")
|
||||
if not src or not Path(src).exists():
|
||||
continue
|
||||
if seq >= MAX_CODE:
|
||||
print("[oss] 货号计数已达上限 999,停止上传颜色图")
|
||||
break
|
||||
try:
|
||||
code = f"{prefix}{seq:03d}"
|
||||
compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg")))
|
||||
key = build_oss_key(country, ts, code, _gen_rand4())
|
||||
url = upload_to_oss(oss_cfg, compressed, key)
|
||||
if url:
|
||||
cc["url"] = url
|
||||
cc["code"] = code
|
||||
color_ups.append(cc)
|
||||
uploaded.append({"spu_code": spu, "sku_code": cc.get("sku_code"),
|
||||
"kind": "composite_color", "code": code, "url": url})
|
||||
seq += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[oss] 颜色图上传失败 {src}: {e}")
|
||||
if color_ups:
|
||||
r["color_composites"] = color_ups
|
||||
|
||||
stats["oss"] = {"uploaded": len(uploaded), "timestamp": ts, "prefix": prefix, "seq": seq}
|
||||
return {"oss": uploaded, "product": products, "oss_seq": seq, "stats": stats}
|
||||
@@ -0,0 +1,584 @@
|
||||
"""节点 6.5:产品图生成(product)。
|
||||
|
||||
在 prompt_build 之后、compose 之前运行(prompt_build → product → compose):
|
||||
热点提示词 → SPU/颜色选品 → basemap 底图 → 纯印花设计稿 → 模特试穿合成图。
|
||||
产物写入 output/<country>/product/(底图拷贝 / *_design.png / *_model / *_composite.png / products.json)。
|
||||
|
||||
模板选择按 SPU.mark 驱动:
|
||||
mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1=模特实拍 / 图2=纯印花设计 / 图3=平铺底图)
|
||||
mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计 → 平铺服装图)
|
||||
|
||||
配置(config.yaml product 段):
|
||||
enabled 开关(默认 true)
|
||||
db_path SPU/SKU 数据库(默认 db/spu_sku.db,相对路径按运行根解析)
|
||||
basemap_dir 底图目录(默认 basemap)
|
||||
material_library_dir 模特图库(默认 material_library)
|
||||
model_category 模特品类子目录(T-shirt);为空/无图时取 material_library 第一个有图子目录
|
||||
brief_index 用第几个 safe 简报的提示词(0=第一个)
|
||||
spu_code / sku_code 指定款号/颜色编码(留空自动选第一个有本地底图的)
|
||||
spu_tasks [{"spu": "DG004", "skus": "DG004-BL01,..."}] 多款号批量选品(优先于 spu_code)
|
||||
spu_count 款号数量上限(0=不限;取任务清单前 N 个)
|
||||
spu_per_color true=每颜色一个 SPU 块;false=单 SPU 下挂所有颜色 SKU 变体
|
||||
backend 图像后端:openai(真生图,需 key) / mock(占位) / 留空=跳过生成仅存底图
|
||||
|
||||
缺底图/模特图时跳过对应步骤并提示,不中断流水线;多款号逐个处理,单个失败不影响其它。
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
|
||||
_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():
|
||||
for f in prod_dir.iterdir():
|
||||
m = re.match(rf"{re.escape(prefix)}(\d+)", f.stem)
|
||||
if m:
|
||||
max_n = max(max_n, int(m.group(1)))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return max_n + 1
|
||||
|
||||
MODEL_WEAR_PROMPT = (
|
||||
"你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计印到图3的衣服底图上,"
|
||||
"并让图1的模特穿上这件带有图2印花设计的图3底图衣服。\n"
|
||||
"【图片角色,按提交顺序,不要弄反】\n"
|
||||
"第一张图(图1)=模特实拍图(基底,要被替换衣服图案和颜色的目标区域,保留原有背景/人物/光影);\n"
|
||||
"第二张图(图2)=纯印花设计稿(要印上去的图案内容,忽略其背景环境与无关元素,保留图案原始线条与色号);\n"
|
||||
"第三张图(图3)=平铺衣服底图(只提取衣服本身的底色与面料材质;忽略平铺图的背景、桌面、环境、场景阴影等一切与衣服无关的元素,只保留衣服面料的颜色、质感和纹理)。\n"
|
||||
"最终效果为图1的模特穿着一件“颜色为图3底色、印有图2图案”的衣服。\n"
|
||||
"【执行规则】\n"
|
||||
"1.底色锁定:从图3平铺衣服中提取衣服底色与面料属性,该底色在最终合成中必须100%保持不变,"
|
||||
"严禁偏色、混入图1原衣服颜色或图2背景色。\n"
|
||||
"2.印花提取与叠加:从图2中精准提取纯印花图案主体,保留原始线条、色号、比例关系;"
|
||||
"将印花叠加到图3底色衣服上,形成“图3底色+图2印花”的合成面料。\n"
|
||||
"3.印花尺寸适配:印花的整体尺寸必须与衣服(图3)的面料面积成合理比例——"
|
||||
"居中印在胸/背/衣身的主体区域,占衣身面积约30%-45%,四周保留自然留白与衣摆、领口、肩线余量;"
|
||||
"严禁印花过大(撑满整件衣服、溢出领口袖口下摆)或过小(占比低于20%)。\n"
|
||||
"4.主体识别与遮罩:识别图1模特的服装穿着区域,忽略皮肤、头发、背景、配饰;"
|
||||
"将该区域视为“空白画布”,用上述合成面料(图3底色+图2印花)完整覆盖。图1原有衣服颜色与图案全部清除。\n"
|
||||
"5.精准贴合:合成面料严格跟随图1衣服的立体结构——有褶皱、身体扭转时印花相应变形;"
|
||||
"印花与新底色须“沉入”褶皱中,保留布料原有明暗纹理与物理属性,杜绝“贴纸感”与“平面涂色感”。\n"
|
||||
"6.光影融合:提取图1的环境光方向,调整合成面料的亮度/对比度与环境光匹配;"
|
||||
"图3底色在阴影区须自然变暗,在高光区须有布料反光;印花色彩受环境光影响产生相应明暗变化,但色号本身不偏移。\n"
|
||||
"7.纯净输出:仅输出一张最终合成图;严禁文字/水印/额外装饰;"
|
||||
"图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]:
|
||||
"""选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。"""
|
||||
if sku_code:
|
||||
return sku_code
|
||||
s = first_available_sku(db_path, basemap_root, spu_code)
|
||||
if s:
|
||||
return s
|
||||
if colors:
|
||||
return colors[0]["sku_code"]
|
||||
return None
|
||||
|
||||
|
||||
def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
|
||||
"""模板输出路径:默认 {sku}_已填写.xlsx;若文件被其它程序占用(如已打开),自动换名加序号,避免导出失败。"""
|
||||
base = prod_dir / f"{chosen_sku}_已填写.xlsx"
|
||||
try:
|
||||
with open(base, "ab"):
|
||||
pass
|
||||
return base
|
||||
except OSError:
|
||||
pass
|
||||
for i in range(2, 100):
|
||||
cand = prod_dir / f"{chosen_sku}_已填写_{i}.xlsx"
|
||||
if not cand.exists():
|
||||
return cand
|
||||
return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"
|
||||
|
||||
|
||||
def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
|
||||
"""图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。"""
|
||||
import time as _t
|
||||
last = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
last = e
|
||||
if i < attempts - 1:
|
||||
_t.sleep(backoff[i])
|
||||
print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
|
||||
return None
|
||||
|
||||
|
||||
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,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
|
||||
shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。
|
||||
img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
|
||||
(按货号命名,包含该货号对应的所有图片)。
|
||||
返回 result dict;内部异常已兜底,不中断。
|
||||
"""
|
||||
# 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里
|
||||
prod_dir = prod_dir / img_code
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
colors = list_colors(db_path, spu["code"])
|
||||
valid_codes = {c["sku_code"] for c in colors}
|
||||
if sku_code:
|
||||
sku_codes = [s.strip() for s in sku_code.split(",") if s.strip() and s.strip() in valid_codes]
|
||||
if not sku_codes:
|
||||
print(f"[product] 颜色 {sku_code!r} 均不在款号 {spu['code']} 下,可选: {[c['sku_code'] for c in colors]}")
|
||||
return None
|
||||
else:
|
||||
first = _resolve_sku(db_path, basemap_root, spu["code"], "", colors)
|
||||
if first is None:
|
||||
print(f"[product] 款号 {spu['code']} 无颜色数据,可选: {[c['sku_code'] for c in colors]}")
|
||||
return None
|
||||
sku_codes = [first]
|
||||
|
||||
# 模板模式自动判定(UI 不再选择):单色=每色一SPU;多色=单SPU多色;CLI --single-spu 显式覆盖
|
||||
spu_per_color = pcfg.get("spu_per_color")
|
||||
if spu_per_color is None:
|
||||
spu_per_color = len(sku_codes) <= 1
|
||||
else:
|
||||
spu_per_color = bool(spu_per_color)
|
||||
|
||||
chosen_sku = sku_codes[0] # 生图用第一个颜色
|
||||
tag = f"[product/{img_code or chosen_sku}]" # 日志前缀用货号(DG000),失败/进度一眼定位
|
||||
basemap_img = find_basemap(basemap_root, spu["code"], chosen_sku)
|
||||
if basemap_img is None:
|
||||
print(f"{tag} 底图缺失({basemap_root}/{spu['code']}/{chosen_sku}/),将跳过印花/模特,仅导出模板")
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"spu_code": spu["code"],
|
||||
"sku_code": chosen_sku,
|
||||
"color": next((c["color"] for c in colors if c["sku_code"] == chosen_sku), ""),
|
||||
"topic": brief.get("topic", ""),
|
||||
"art_style": brief.get("art_style", ""),
|
||||
"color_palette": brief.get("color_palette", ""),
|
||||
"basemap": str(basemap_img) if basemap_img else "",
|
||||
"composite_prompt": brief.get("composite_prompt", ""),
|
||||
"markup_percent": pcfg.get("markup_percent", 0), # 加价%(后续定价用)
|
||||
}
|
||||
|
||||
# 拷贝底图(图3):命名 = 货号 + SKU code(如 DG003_DG004-BL01_basemap.jpg),
|
||||
# 同一 SKU 多个设计(数量 N)时底图文件也各自独立,不覆盖、不混淆
|
||||
if basemap_img is not None:
|
||||
base_copy = prod_dir / f"{img_code}_{chosen_sku}_basemap{basemap_img.suffix}"
|
||||
shutil.copy2(basemap_img, base_copy)
|
||||
result["basemap_copy"] = str(base_copy)
|
||||
print(f"{tag} 底图: {base_copy}")
|
||||
|
||||
if ib is None:
|
||||
print(f"{tag} 未配置 product.backend(openai/mock),跳过印花/模特生成。")
|
||||
elif basemap_img is None:
|
||||
print(f"{tag} 无底图,跳过印花/模特生成。")
|
||||
else:
|
||||
# 5) 纯印花设计稿(图2):直接用 compose 节点生成的共享设计稿(designs/ 已有,不拷贝)
|
||||
if shared_design and Path(shared_design).exists():
|
||||
design_path = shared_design
|
||||
result["design_path"] = design_path
|
||||
result["design_from"] = "compose"
|
||||
print(f"{tag} 设计稿(来自 compose 节点,designs/ 已有): {design_path}")
|
||||
else:
|
||||
design_path = str(prod_dir / f"{img_code}_design.png")
|
||||
try:
|
||||
from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
|
||||
prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", "")))
|
||||
ib.generate(prompt, design_path,
|
||||
brief.get("composite_negative", ""),
|
||||
size="1024x1024") # 印花设计统一 1024x1024
|
||||
result["design_path"] = design_path
|
||||
result["design_from"] = "product"
|
||||
print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
|
||||
print(f"{tag} 设计稿生成失败: {e}")
|
||||
|
||||
# 6) 模板选择按 SPU.mark 决定:
|
||||
# mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图)
|
||||
# mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计)
|
||||
if int(spu.get("mark") or 0) == 1:
|
||||
print(f"{tag} SPU {spu['code']} mark=1 → 使用三图合成模板(图1模特+图2印花+图3底图)")
|
||||
if model_img is not None:
|
||||
# 任务级模特分配(product_node 预分配:一个 SPU 一个模特,SPU 数>模特数循环兜底)
|
||||
model_copy = prod_dir / f"{img_code}_model{model_img.suffix}"
|
||||
shutil.copy2(model_img, model_copy)
|
||||
result["model_path"] = str(model_copy)
|
||||
result["model_folder"] = model_img.parent.name
|
||||
print(f"{tag} 模特图(任务级分配,{model_img.parent.name}/): {model_copy}")
|
||||
else:
|
||||
print(f"{tag} material_library 无模特图,回退两图合成(composite_prompt)")
|
||||
else:
|
||||
print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)")
|
||||
|
||||
# 7) 合成:
|
||||
# 有模特图 → 三图合成(图1=模特 / 图2=印花设计 / 图3=底图)
|
||||
# 无模特图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计)
|
||||
if "design_path" not in result:
|
||||
print(f"{tag} 无设计稿,跳过合成")
|
||||
elif model_img is not None:
|
||||
composite_path = str(prod_dir / f"{img_code}_composite.png")
|
||||
try:
|
||||
# 三图合成:优先用简报的 composite_prompt(模板化三图文案),回退内置 MODEL_WEAR_PROMPT
|
||||
wear_prompt = (brief.get("composite_prompt") or "").strip() or MODEL_WEAR_PROMPT
|
||||
print(f"{tag} 三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…")
|
||||
t0 = time.time()
|
||||
ib.print(wear_prompt, str(model_img), composite_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图
|
||||
size="1504x2000") # 三合一统一 1504x2000
|
||||
result["composite_path"] = composite_path
|
||||
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="1504x2000")
|
||||
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
|
||||
else:
|
||||
printed_path = str(prod_dir / f"{img_code}_printed.png")
|
||||
try:
|
||||
# 两图合成(无模特):用平铺印图文案(wearable_prompt),回退旧 composite_prompt
|
||||
flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "")
|
||||
ib.print(flat_prompt, str(basemap_img), printed_path,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path], # 图2印花
|
||||
size="1504x2000") # 合成统一 1504x2000
|
||||
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="1504x2000")
|
||||
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
|
||||
|
||||
# 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由
|
||||
color_composites: List[Dict[str, Any]] = []
|
||||
if model_img is not None and result.get("composite_path"):
|
||||
# 首色主图始终记录(单色/多色都走模板填充)
|
||||
color_composites.append({"sku_code": chosen_sku, "color": result.get("color", ""),
|
||||
"composite_path": result["composite_path"]})
|
||||
for sc in sku_codes[1:]:
|
||||
bm = find_basemap(basemap_root, spu["code"], sc)
|
||||
if bm is None:
|
||||
print(f"{tag} 颜色 {sc} 无底图,跳过该色三合一")
|
||||
continue
|
||||
cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
|
||||
try:
|
||||
ib.print(MODEL_WEAR_PROMPT, str(model_img), cp,
|
||||
brief.get("composite_negative", ""),
|
||||
extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图
|
||||
size="1504x2000") # 三合一统一 1504x2000
|
||||
col = next((c["color"] for c in colors if c["sku_code"] == sc), sc)
|
||||
color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
|
||||
print(f"{tag} 颜色 {sc}({col})三合一已生成: {cp}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "product", "type": type(e).__name__,
|
||||
"message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
|
||||
result["color_composites"] = color_composites
|
||||
|
||||
# 7.5) 多模态标题生成:合成图/平铺图/设计稿 → 中英双语 SEO 标题(按国家路由模板)
|
||||
if title_backend is not None:
|
||||
title_img = (result.get("composite_path") or result.get("printed_path")
|
||||
or result.get("design_path"))
|
||||
if title_img:
|
||||
t = title_backend.generate_title(title_img, country=country)
|
||||
if t.get("en_title") or t.get("cn_title") or t.get("ja_title"):
|
||||
result["en_title"] = t.get("en_title", "")
|
||||
result["cn_title"] = t.get("cn_title", "")
|
||||
result["ja_title"] = t.get("ja_title", "")
|
||||
print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... "
|
||||
f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}...")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@with_fallback("product")
|
||||
def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
config = state["config"]
|
||||
pcfg = config.get("product") or {}
|
||||
stats = dict(state.get("stats") or {})
|
||||
if not pcfg.get("enabled", True):
|
||||
return {"product": [], "stats": stats}
|
||||
|
||||
country = state["country"]
|
||||
briefs = state.get("briefs") or []
|
||||
output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹)
|
||||
cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录)
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
# 路径解析:相对路径 → 优先运行根(exe 旁自定义数据),其次数据根(打包=_MEIPASS 内置)
|
||||
def _abs(key: str, default: str) -> Path:
|
||||
p = Path(pcfg.get(key, default))
|
||||
if p.is_absolute():
|
||||
return p
|
||||
for root in (runtime_root(), project_root()):
|
||||
cand = root / p
|
||||
if cand.exists():
|
||||
return cand
|
||||
return project_root() / p
|
||||
|
||||
db_path = _abs("db_path", "db/spu_sku.db")
|
||||
basemap_root = _abs("basemap_dir", "basemap")
|
||||
material_root = _abs("material_library_dir", "material_library")
|
||||
category = pcfg.get("model_category", "T-shirt")
|
||||
brief_index = int(pcfg.get("brief_index", 0))
|
||||
spu_code = (pcfg.get("spu_code") or "").strip()
|
||||
sku_code = (pcfg.get("sku_code") or "").strip()
|
||||
spu_tasks = pcfg.get("spu_tasks") or []
|
||||
spu_count = int(pcfg.get("spu_count") or 0)
|
||||
|
||||
# 1) 选简报(优先 safe)
|
||||
safe = [b for b in briefs if b.get("risk_level") == "safe"] or briefs
|
||||
if not safe:
|
||||
print("[product] 无可用简报,跳过产品图生成")
|
||||
return {"product": [], "stats": stats}
|
||||
brief = safe[brief_index] if brief_index < len(safe) else safe[0]
|
||||
|
||||
# 2) 图像后端
|
||||
backend_name = (pcfg.get("backend") or "").strip()
|
||||
ib = None
|
||||
if backend_name:
|
||||
from graph.backends import get_image_backend
|
||||
ib = get_image_backend(backend_name)
|
||||
if ib is not None:
|
||||
ib.bind_config(config.get("compose") or {}) # 复用 compose.api_key/model/size
|
||||
|
||||
# 3) 构造款号工作清单:spu_tasks(多款号)优先;否则 spu_code / 自动第一个
|
||||
spus = list_spus(db_path)
|
||||
worklist: List[tuple] = [] # (spu, skus, brief) —— 每个款号可绑定自己的热点简报
|
||||
by_topic = {str(b.get("topic", "")).lower(): b for b in briefs}
|
||||
if not spus:
|
||||
print(f"[product] db 无 SPU 数据({db_path}),跳过")
|
||||
return {"product": [], "stats": stats}
|
||||
if spu_tasks:
|
||||
for ti, t in enumerate(spu_tasks):
|
||||
code = (t.get("spu") or t.get("spu_code") or "").strip()
|
||||
spu = next((s for s in spus if s["code"] == code), None)
|
||||
if spu is None:
|
||||
print(f"[product] 任务款号 {code} 不在 db,跳过(可选: {[s['code'] for s in spus][:12]})")
|
||||
continue
|
||||
tb = None
|
||||
tp = (t.get("topic") or "").strip()
|
||||
if tp:
|
||||
tb = by_topic.get(tp.lower())
|
||||
if tb is None:
|
||||
print(f"[product] 任务热点「{tp}」不在简报中,回退按序号分配")
|
||||
if tb is None:
|
||||
# 未指定热点(完整流水线):按任务序号取不同简报,避免多个产品用同一个
|
||||
idx = min(ti, len(safe) - 1) if safe else brief_index
|
||||
tb = safe[idx] if safe else None
|
||||
if tb is None:
|
||||
print(f"[product] 无可用简报,跳过任务 {code}")
|
||||
continue
|
||||
worklist.append((spu, (t.get("skus") or "").strip(), tb))
|
||||
if not worklist:
|
||||
print("[product] 任务清单无有效款号,跳过产品图生成")
|
||||
return {"product": [], "stats": stats}
|
||||
elif spu_code:
|
||||
spu = next((s for s in spus if s["code"] == spu_code), None)
|
||||
if spu is None:
|
||||
print(f"[product] 款号 {spu_code} 不在 db,可选: {[s['code'] for s in spus][:12]}")
|
||||
return {"product": [], "stats": stats}
|
||||
worklist.append((spu, sku_code, brief))
|
||||
else:
|
||||
spu = next((s for s in spus if first_available_sku(db_path, basemap_root, s["code"])), None)
|
||||
if spu is None:
|
||||
print(f"[product] 没有任何款号存在本地底图({basemap_root}/<款号>/<SKU.code>/)")
|
||||
return {"product": [], "stats": stats}
|
||||
worklist.append((spu, sku_code, brief))
|
||||
|
||||
# 4) 不再按 spu_count 截断:worklist 已是扩展后的完整任务(数量 N = 每款设计数),
|
||||
# 全部任务进入队列处理(并发 5)。
|
||||
|
||||
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] = []
|
||||
try:
|
||||
_folder, _all_models = find_first_model_folder(material_root, category)
|
||||
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 ''}")
|
||||
|
||||
# 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path),
|
||||
# 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个
|
||||
designs_dir = output_dir / "designs"
|
||||
designs_dir.mkdir(parents=True, exist_ok=True)
|
||||
designs_map = {str(d.get("topic", "")).strip().lower(): d.get("path", "")
|
||||
for d in (state.get("designs") or []) if isinstance(d, dict)}
|
||||
for _d in (state.get("briefs") or []):
|
||||
if isinstance(_d, dict) and _d.get("design_path"):
|
||||
designs_map.setdefault(str(_d.get("topic", "")).strip().lower(), _d.get("design_path"))
|
||||
|
||||
def _resolve_design(tb) -> Optional[str]:
|
||||
"""任务绑定的简报 → 该热点自己的设计稿路径(按货号命名拷贝到 designs/)。"""
|
||||
topic = str(tb.get("topic", "")).strip().lower()
|
||||
src = designs_map.get(topic) or tb.get("design_path")
|
||||
if not src or not Path(src).exists():
|
||||
return None
|
||||
return src
|
||||
|
||||
# 6) 逐个款号处理(每款号用自己绑定的热点简报)
|
||||
title_backend = None
|
||||
ls_cfg = config.get("llm_screen") or {}
|
||||
if (ls_cfg.get("provider") or "") not in ("", "mock"):
|
||||
try:
|
||||
from graph.llms import get_backend as _glb
|
||||
_tb = _glb(ls_cfg.get("provider"))
|
||||
_tb.bind_config(ls_cfg)
|
||||
if _tb.has_key:
|
||||
title_backend = _tb
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[product] 标题后端初始化失败: {e}")
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
prefix = str(pcfg.get("code_prefix") or "DG").strip()
|
||||
# 并发:默认每个 SPU 一个独立线程(任务数即并发数,提速);
|
||||
# config.product.concurrency 显式配置可覆盖(如限流时设 3-5);
|
||||
# 默认并发上限 5:全量并发(任务数)会压垮图像网关(10 并发 → 全部超时),
|
||||
# 超出上限的任务在线程池排队,逐批处理
|
||||
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):
|
||||
"""并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
|
||||
img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…)
|
||||
try:
|
||||
# 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png)
|
||||
design_src = _resolve_design(tb)
|
||||
design_path = None
|
||||
if design_src:
|
||||
design_path = str(designs_dir / f"{img_code}_design.png")
|
||||
try:
|
||||
shutil.copy2(design_src, design_path)
|
||||
except Exception: # noqa: BLE001
|
||||
design_path = design_src
|
||||
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,
|
||||
country, img_code=img_code,
|
||||
model_img=model_assign.get(spu.get("code", "")))
|
||||
if r:
|
||||
r["img_code"] = img_code
|
||||
return r, img_code
|
||||
except Exception as e: # noqa: BLE001 # 单产品任何异常都不拖垮整体
|
||||
print(f"[product/{img_code}] 产品处理异常(跳过该产品): {e}")
|
||||
return None, img_code
|
||||
|
||||
import concurrent.futures
|
||||
# 货号自动续号:任务一开始全部按序分配(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)
|
||||
for i, (spu, skus, tb) in enumerate(worklist)]
|
||||
for f in concurrent.futures.as_completed(futures):
|
||||
r, img_code = f.result()
|
||||
if r:
|
||||
results.append(r)
|
||||
try:
|
||||
_record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定
|
||||
_write_products(prod_dir, results)
|
||||
stats["product"] = {
|
||||
"spus": [r.get("spu_code") for r in results],
|
||||
"skus": [r.get("sku_code") for r in results],
|
||||
"topic": brief.get("topic", ""),
|
||||
"count": len(results),
|
||||
"composite": sum(1 for r in results if r.get("composite_path")),
|
||||
"printed": sum(1 for r in results if r.get("printed_path")),
|
||||
"templates": sum(1 for r in results if r.get("template_path")),
|
||||
"output_dir": str(prod_dir),
|
||||
}
|
||||
return {"product": results, "stats": stats, "errors": errors}
|
||||
|
||||
|
||||
def _record_used(output_dir: Path, r: Dict[str, Any]):
|
||||
"""记录已用 (热点-风格-配色),供后续去重:output/<国家>/used_designs.json。"""
|
||||
topic = r.get("topic", "")
|
||||
if not topic:
|
||||
return
|
||||
with _USED_LOCK: # 并发下 used_designs.json 读写互斥
|
||||
p = output_dir / "used_designs.json"
|
||||
used = []
|
||||
if p.exists():
|
||||
try:
|
||||
used = json.loads(p.read_text(encoding="utf-8")).get("used", []) or []
|
||||
except Exception:
|
||||
used = []
|
||||
entry = {
|
||||
"topic": topic,
|
||||
"art_style": r.get("art_style", ""),
|
||||
"color_palette": r.get("color_palette", ""),
|
||||
"spu_code": r.get("spu_code", ""),
|
||||
"sku_code": r.get("sku_code", ""),
|
||||
"date": time.strftime("%Y-%m-%d"),
|
||||
}
|
||||
# 同 (topic, art_style) 已记录则跳过,避免去重记录重复堆积
|
||||
if any(str(u.get("topic", "")).strip().lower() == str(entry["topic"]).strip().lower()
|
||||
and str(u.get("art_style", "")).strip().lower() == str(entry["art_style"]).strip().lower()
|
||||
for u in used):
|
||||
return
|
||||
used.append(entry)
|
||||
p.write_text(json.dumps(
|
||||
{"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "used": used},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_products(prod_dir: Path, products: List[Dict[str, Any]]):
|
||||
(prod_dir / "products.json").write_text(
|
||||
json.dumps({"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "products": products},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""节点 5/6:提示词构造(prompt_build)。
|
||||
|
||||
读取 prompts/<country>/ 的 extra 风格规则,用固定模板装配四种最终提示词
|
||||
(image_prompt / wearable_prompt / composite_prompt / composite_negative)。
|
||||
四要素缺失时用 derive_style_palette 动态兜底,保证每条提示词结构一致、有规则。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
import random
|
||||
|
||||
from graph.style_rules import derive_style_palette, derive_composition
|
||||
from graph.templates import assemble_prompts
|
||||
from graph.validate import validate_brief, with_fallback
|
||||
|
||||
# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif,
|
||||
# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多")
|
||||
_IMG_RISKY_SWAP = {
|
||||
"skull": "smiley mascot", "skeleton": "cute mascot", "blood": "red accents",
|
||||
"gore": "bold shapes", "gun": "star", "weapon": "tool", "bomb": "firework",
|
||||
"drug": "confetti", "demon": "cute monster", "devil": "mischievous imp",
|
||||
"occult": "mystic pattern", "satanic": "dark pattern", "nazi": "retro emblem",
|
||||
"hitler": "retro emblem", "zombie": "friendly ghoul", "horror": "spooky-cute",
|
||||
"vampire": "night owl", "politics": "abstract shapes", "political": "abstract",
|
||||
"president": "captain", "army": "team", "police": "officer",
|
||||
}
|
||||
|
||||
|
||||
def _safe_motif(motif: str) -> str:
|
||||
"""清洗 motif 中的图像策略敏感词(替换为安全等效描述),降低生图内容政策拦截率。"""
|
||||
low = motif.lower()
|
||||
for k, v in _IMG_RISKY_SWAP.items():
|
||||
if k in low:
|
||||
# 按词边界替换(避免误伤 "letterhead" 等)
|
||||
import re
|
||||
motif = re.sub(rf"\b{re.escape(k)}\b", v, motif, flags=re.IGNORECASE)
|
||||
low = motif.lower()
|
||||
return motif
|
||||
|
||||
|
||||
@with_fallback("prompt_build")
|
||||
def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
screened: List[Dict[str, Any]] = state.get("screened") or []
|
||||
config = state["config"]
|
||||
country = state["country"]
|
||||
cc = state["country_config"]
|
||||
extra_rules = cc.get("extra_style_rules") or []
|
||||
tpls = config.get("prompt_templates") or {}
|
||||
|
||||
briefs: List[Dict[str, Any]] = []
|
||||
for r in screened:
|
||||
r = validate_brief(r)
|
||||
art, pal = derive_style_palette(
|
||||
r["topic"], country, extra_rules=extra_rules, category=r.get("design_category")
|
||||
)
|
||||
motif = (r.get("motif") or "").strip() or r.get("topic", "")
|
||||
cleaned = _safe_motif(motif)
|
||||
if cleaned != motif:
|
||||
print(f"[prompt] motif 敏感词清洗: 「{motif}」→「{cleaned}」(降低生图内容政策拦截)")
|
||||
r["motif"] = cleaned
|
||||
motif = cleaned
|
||||
art_style = (r.get("art_style") or art).strip()
|
||||
palette = (r.get("color_palette") or pal).strip()
|
||||
composition = (r.get("composition") or derive_composition(r["topic"], r.get("design_category"))).strip()
|
||||
|
||||
prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country)
|
||||
# 文字印花(约 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.")
|
||||
print(f"[prompt] review 简报注入原创化魔改引导: 「{r['topic']}」")
|
||||
r.update(prompts)
|
||||
r["motif"] = motif
|
||||
r["art_style"] = art_style
|
||||
r["color_palette"] = palette
|
||||
r["composition"] = composition
|
||||
briefs.append(r)
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["prompt"] = {"briefs": len(briefs)}
|
||||
return {"briefs": briefs, "stats": stats}
|
||||
@@ -0,0 +1,26 @@
|
||||
"""节点 3/6:打分(score)。
|
||||
|
||||
归一化(按 source/kind 分组 min-max)-> 跨源融合(combine)-> 综合分阈值预筛。
|
||||
纯逻辑节点,with_fallback 兜底。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.scoring import combine, normalize
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("score")
|
||||
def score_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rows: List[Dict[str, Any]] = state.get("filtered_rows") or []
|
||||
config = state["config"]
|
||||
weights = config.get("weights") or {}
|
||||
llm_cfg = config.get("llm_screen") or {}
|
||||
min_score = float(llm_cfg.get("min_score", 0.0))
|
||||
|
||||
normalize(rows)
|
||||
combined = combine(rows, weights)
|
||||
combined = [c for c in combined if float(c.get("score", 0)) >= min_score]
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["score"] = {"combined": len(combined)}
|
||||
return {"scored_rows": combined, "stats": stats}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""节点 4/6:合规筛选(screen)。
|
||||
|
||||
调用可插拔 LLM 后端做合规筛查 + 结构化四要素;后端调用失败时降级 MockBackend。
|
||||
按 topic 把筛查结果映射回 scored 候选(补 score/sources/country),再做最终风险过滤
|
||||
(blocked 丢弃;review 按 keep_review 决定)。
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.loader import load_system_prompt
|
||||
from graph.llms import DEFAULT_SYSTEM_PROMPT, get_backend
|
||||
from graph.style_rules import COUNTRY_AESTHETICS
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("screen")
|
||||
def screen_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
country = state["country"]
|
||||
scored: List[Dict[str, Any]] = state.get("scored_rows") or []
|
||||
config = state["config"]
|
||||
cc = state["country_config"]
|
||||
llm_cfg = config.get("llm_screen") or {}
|
||||
provider = llm_cfg.get("provider", "mock")
|
||||
keep_review = bool(llm_cfg.get("keep_review", False))
|
||||
batch_size = int(llm_cfg.get("max_topics_per_call", 12))
|
||||
blacklist = [str(b).lower() for b in (config.get("blacklist") or [])]
|
||||
|
||||
prompts_dir = Path(state["prompts_dir"])
|
||||
system_prompt = load_system_prompt(prompts_dir, DEFAULT_SYSTEM_PROMPT)
|
||||
aesthetic_hint = cc.get("style_hint") or COUNTRY_AESTHETICS.get(country, {}).get("style_hint", "")
|
||||
|
||||
# 排除已用热点(去重生效:已用 topic 不再进入本次简报,每次跑都用新热点)
|
||||
try:
|
||||
import json as _json
|
||||
used_p = Path(state.get("cache_dir") or state.get("output_dir", "")) / "used_designs.json"
|
||||
if used_p.exists():
|
||||
ud = _json.loads(used_p.read_text(encoding="utf-8")).get("used", []) or []
|
||||
used_topics = {str(u.get("topic", "")).strip().lower() for u in ud}
|
||||
before = len(scored)
|
||||
scored = [c for c in scored if str(c.get("topic", "")).strip().lower() not in used_topics]
|
||||
if len(scored) < before:
|
||||
print(f"[screen] 排除已用热点 {before - len(scored)} 条(去重),剩余 {len(scored)} 条可选")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 简报数量 = 用多少生成多少:llm_screen.max_briefs 配置优先,否则按扩展后的总任务数
|
||||
#(每个产品一个热点;数量 N=每个款-颜色条目的设计数 → 总任务=条目数×N);
|
||||
# 前台显示不依赖简报(读 collected 完整池 + used_designs 剔除已用,用完即从前台消失)。
|
||||
pcfg = config.get("product") or {}
|
||||
ls_cfg = config.get("llm_screen") or {}
|
||||
limit = int(ls_cfg.get("max_briefs") or 0)
|
||||
if not limit and pcfg.get("spu_tasks"):
|
||||
limit = len(pcfg.get("spu_tasks") or []) # 扩展后总任务数(=产品数)
|
||||
if not limit:
|
||||
limit = int(pcfg.get("spu_count") or 0)
|
||||
if limit > 0:
|
||||
ordered = sorted(scored, key=lambda c: -(float(c.get("score") or 0)))
|
||||
scored_limited = ordered[:limit]
|
||||
print(f"[screen] 简报限量 {limit} → 筛前 {len(scored_limited)} 个高分热点(共 {len(scored)} 个)")
|
||||
else:
|
||||
scored_limited = scored
|
||||
print(f"[screen] 简报全量 {len(scored_limited)} 条(未限量,全部生成简报)")
|
||||
|
||||
topics = [c["topic"] for c in scored_limited]
|
||||
|
||||
backend = get_backend(provider)
|
||||
if provider != "mock":
|
||||
backend.bind_config(llm_cfg)
|
||||
if not backend.has_key:
|
||||
print("[screen] 未检测到 LLM api_key(请配置 llm_screen.api_key 或环境变量 "
|
||||
"LLM_API_KEY/OPENAI_API_KEY),降级 Mock 兜底。")
|
||||
backend = get_backend("mock")
|
||||
try:
|
||||
screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[screen] {provider} 调用失败,降级 Mock: {e}")
|
||||
backend = get_backend("mock")
|
||||
screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size)
|
||||
|
||||
# 映射回 scored 候选
|
||||
by_topic = {s.get("topic", "").lower(): s for s in screened}
|
||||
out: List[Dict[str, Any]] = []
|
||||
missing: List[Dict[str, Any]] = []
|
||||
for it in scored_limited:
|
||||
s = by_topic.get(it["topic"].lower())
|
||||
if s is None:
|
||||
missing.append(it)
|
||||
continue
|
||||
s = dict(s)
|
||||
s["country"] = country
|
||||
s["score"] = it.get("score", 0)
|
||||
s["sources"] = it.get("sources", "")
|
||||
out.append(s)
|
||||
|
||||
# LLM 漏判的主题用 Mock 单独补全,避免丢数据(而非整批降级)
|
||||
if missing:
|
||||
print(f"[screen] LLM 漏判 {len(missing)} 个主题,用 Mock 单独补全:"
|
||||
f"{[m['topic'] for m in missing]}")
|
||||
mock = get_backend("mock")
|
||||
m_res = mock.screen(
|
||||
[m["topic"] for m in missing], country, aesthetic_hint,
|
||||
system_prompt, blacklist, batch_size,
|
||||
)
|
||||
m_by = {r.get("topic", "").lower(): r for r in m_res}
|
||||
for it in missing:
|
||||
s = m_by.get(it["topic"].lower())
|
||||
if s is None:
|
||||
continue
|
||||
s = dict(s)
|
||||
s["country"] = country
|
||||
s["score"] = it.get("score", 0)
|
||||
s["sources"] = it.get("sources", "")
|
||||
out.append(s)
|
||||
|
||||
# 最终风险过滤:只滤 blocked(硬拦截);review(待复核)保留——
|
||||
# 由 assign_hotspots 的 allow_review 决定是否参与分配(openai 模式 review+concept 可用),
|
||||
# 避免 review 被静默丢弃导致"任务 N 个但简报不足、设计缺失"
|
||||
kept: List[Dict[str, Any]] = []
|
||||
for r in out:
|
||||
lvl = r.get("risk_level", "safe")
|
||||
if lvl == "blocked":
|
||||
continue
|
||||
kept.append(r)
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["screen"] = {"screened": len(out), "kept": len(kept)}
|
||||
return {"screened": kept, "stats": stats}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""节点 0/6:动态种子词(seed)。
|
||||
|
||||
在 fetch 之前运行:收集「trending 派生 + 历史 safe 热点 + 月份/节日」上下文,
|
||||
按 seed_provider 策略(static / mock / LLM)生成/合并种子词,注入 country_config 的
|
||||
style.seeds / related.seed_keywords,供后续 fetch 的 related_queries 展开使用。
|
||||
|
||||
关键机制:动态种子词按 (国家, provider, 日期) 缓存(.cache/seeds/)。
|
||||
同一天内多次运行使用同一套种子词 → related_queries 的 24h 缓存稳定命中,
|
||||
避免「history 每次跑完都变 → 种子词震荡 → Google 反复全量重抓 → 429 限流」。
|
||||
|
||||
带 with_fallback:任何异常都降级为"仅用 yaml 静态种子",不阻塞整图。
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from graph.llms import get_backend
|
||||
from graph.llms.mock_backend import COMMON_RISK_WORDS
|
||||
from graph.paths import runtime_root
|
||||
from graph.scoring import filter_person_names, filter_query_noise
|
||||
from graph.seeds import get_seed_strategy
|
||||
from graph.seeds.holidays import build_holiday_context
|
||||
from graph.sources.google_trends_source import fetch_trending
|
||||
from graph.validate import with_fallback
|
||||
|
||||
_CACHE_DIR = runtime_root() / ".cache" / "seeds"
|
||||
|
||||
|
||||
def _cache_key(country: str, provider: str, cfg: Dict[str, Any]) -> str:
|
||||
"""缓存键含「配置指纹」:改了种子相关参数(数量/上下文上限)即换新键重新生成,
|
||||
避免命中旧参数生成的种子;旧文件保留(不删缓存,取最新)。"""
|
||||
fp = hashlib.md5(
|
||||
json.dumps(
|
||||
{k: cfg.get(k) for k in ("max_style_seeds", "max_related_seeds",
|
||||
"trending_context_limit", "history_limit")},
|
||||
sort_keys=True, ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:8]
|
||||
return f"{country}-{provider}-{fp}-{datetime.date.today().isoformat()}"
|
||||
|
||||
|
||||
def _cache_get(key: str):
|
||||
try:
|
||||
p = _CACHE_DIR / f"{key}.json"
|
||||
if p.exists():
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(key: str, val: Dict[str, Any]):
|
||||
try:
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(_CACHE_DIR / f"{key}.json").write_text(
|
||||
json.dumps(val, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@with_fallback("seed")
|
||||
def seed_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
country = state["country"]
|
||||
config = state["config"]
|
||||
cc = dict(state.get("country_config") or {})
|
||||
errors = list(state.get("errors") or [])
|
||||
|
||||
provider = (config.get("seed_provider") or "mock").strip().lower()
|
||||
cfg = config.get("seed_provider_cfg") or {}
|
||||
trending_limit = int(cfg.get("trending_context_limit", 15))
|
||||
history_limit = int(cfg.get("history_limit", 20))
|
||||
max_style = int(cfg.get("max_style_seeds", 12))
|
||||
max_related = int(cfg.get("max_related_seeds", 12))
|
||||
guard = COMMON_RISK_WORDS + [b.lower() for b in (config.get("blacklist") or [])]
|
||||
|
||||
ckey = _cache_key(country, provider, cfg)
|
||||
cached = _cache_get(ckey) if provider != "static" else None
|
||||
from_cache = cached is not None
|
||||
|
||||
if cached is not None:
|
||||
res = cached
|
||||
context: Dict[str, Any] = {
|
||||
"country": country,
|
||||
"max_style_seeds": max_style,
|
||||
"max_related_seeds": max_related,
|
||||
}
|
||||
else:
|
||||
# 1) 收集上下文
|
||||
context: Dict[str, Any] = {
|
||||
"country": country,
|
||||
"max_style_seeds": max_style,
|
||||
"max_related_seeds": max_related,
|
||||
}
|
||||
try:
|
||||
tl = int((cc.get("trending") or {}).get("limit", 40))
|
||||
rows = fetch_trending(geo=country, limit=min(tl, 40))
|
||||
# 保留 rows 自带的 source=gt_trending,filter_person_names 的人名模式仅对该源生效
|
||||
kept, _ = filter_query_noise(rows, enabled=True)
|
||||
kept, _ = filter_person_names(kept)
|
||||
kept = [r for r in kept if not any(w and w in r["topic"].lower() for w in guard)]
|
||||
context["trending_seeds"] = [r["topic"] for r in kept][:trending_limit]
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] trending 上下文收集失败(跳过): {e}")
|
||||
context["trending_seeds"] = []
|
||||
|
||||
try:
|
||||
p = os.path.join(state.get("output_dir", ""), "design_briefs.json")
|
||||
if os.path.exists(p):
|
||||
data = json.load(open(p, encoding="utf-8")).get("design_briefs", [])
|
||||
safe = [d for d in data if d.get("risk_level") == "safe"]
|
||||
safe.sort(key=lambda d: -(d.get("score") or 0))
|
||||
hrows = [{"topic": d["topic"], "source": "history"} for d in safe]
|
||||
hrows, _ = filter_person_names(hrows, pattern_sources={"history"})
|
||||
hrows = [r for r in hrows if not any(w and w in r["topic"].lower() for w in guard)]
|
||||
context["history_hotspots"] = [r["topic"] for r in hrows][:history_limit]
|
||||
else:
|
||||
context["history_hotspots"] = []
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] 历史热点读取失败(跳过): {e}")
|
||||
context["history_hotspots"] = []
|
||||
|
||||
# 月份/节日(按国家:各国节日表不同)
|
||||
hol = build_holiday_context(country)
|
||||
context["season"] = hol["season"]
|
||||
context["year"] = hol["year"]
|
||||
context["month"] = hol["month"]
|
||||
context["date"] = hol["date"]
|
||||
context["month_themes"] = hol["month_themes"]
|
||||
context["upcoming_holidays"] = hol["upcoming_holidays"]
|
||||
|
||||
# 2) 选策略 + LLM 后端
|
||||
strategy = get_seed_strategy(provider)
|
||||
llm_backend = None
|
||||
if provider != "static":
|
||||
llm_backend = get_backend(provider)
|
||||
# 注入 llm_screen 配置(api_key/base_url/model),否则 has_key 永远 False 降级 mock
|
||||
try:
|
||||
llm_backend.bind_config(config.get("llm_screen") or {})
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] LLM 配置绑定失败: {e}")
|
||||
if provider not in ("mock",) and not getattr(llm_backend, "has_key", False):
|
||||
print(f"[seed] {provider} 未配置 API key,降级 mock 规则生成种子词")
|
||||
llm_backend = get_backend("mock")
|
||||
|
||||
# 3) 生成/合并种子词
|
||||
try:
|
||||
res = strategy.resolve(country, cc, context, llm_backend)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] 策略解析失败,回退静态种子: {e}")
|
||||
res = {
|
||||
"style_seeds": list((cc.get("style", {}) or {}).get("seeds", []) or []),
|
||||
"related_seeds": list((cc.get("related", {}) or {}).get("seed_keywords", []) or []),
|
||||
"dynamic": False,
|
||||
}
|
||||
if provider != "static":
|
||||
_cache_set(ckey, res)
|
||||
|
||||
# 4) 注入 cc
|
||||
style_block = dict(cc.get("style") or {})
|
||||
related_block = dict(cc.get("related") or {})
|
||||
style_block["seeds"] = res["style_seeds"]
|
||||
related_block["seed_keywords"] = res["related_seeds"]
|
||||
cc["style"] = style_block
|
||||
cc["related"] = related_block
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["seed"] = {
|
||||
"provider": provider,
|
||||
"dynamic": res.get("dynamic", False),
|
||||
"from_cache": from_cache,
|
||||
"style_count": len(res["style_seeds"]),
|
||||
"related_count": len(res["related_seeds"]),
|
||||
"trending_ctx": len(context.get("trending_seeds", [])),
|
||||
"history_ctx": len(context.get("history_hotspots", [])),
|
||||
"holidays": context.get("upcoming_holidays", []),
|
||||
}
|
||||
print(
|
||||
f"[seed] provider={provider}{'(当日缓存命中)' if from_cache else ''} "
|
||||
f"种子词 style={len(res['style_seeds'])} related={len(res['related_seeds'])}"
|
||||
)
|
||||
|
||||
return {
|
||||
"country_config": cc,
|
||||
"seed_words": res,
|
||||
"stats": stats,
|
||||
"errors": errors,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""节点 8/8:种草图生成(seed_shot)——在 oss_upload 之后。
|
||||
|
||||
对每个 product 的合成图(图1),按 seed_shot_templates.yaml 模板 + model_features.yaml 随机模特特征
|
||||
生成 N 张种草图(config.seed_shot.count,默认 1):
|
||||
- [商品名称] ← product 的 cn_title(上一节点多模态生成)
|
||||
- [材质] ← 数据库 SPU.material 字段
|
||||
- [模特特征] ← model_features.yaml 随机一条
|
||||
种草图同样压缩上传到 OSS(货号计数与 oss_upload 共用 state["oss_seq"] 续接)。
|
||||
|
||||
未配置图像后端 / 无合成图 / count=0 时跳过,不中断。
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.validate import with_fallback
|
||||
|
||||
|
||||
@with_fallback("seed_shot")
|
||||
def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
config = state["config"] or {}
|
||||
country = state.get("country", "")
|
||||
output_dir = Path(state["output_dir"])
|
||||
|
||||
ss_cfg = config.get("seed_shot") or {}
|
||||
count = int(ss_cfg.get("count", 1))
|
||||
if not bool(ss_cfg.get("enabled", True)) or count <= 0 or not products:
|
||||
return {"seed_shots": [], "stats": state.get("stats") or {}}
|
||||
|
||||
# 图像后端(复用 compose 配置)
|
||||
compose_cfg = config.get("compose") or {}
|
||||
ib = None
|
||||
if compose_cfg.get("backend"):
|
||||
from graph.backends import get_image_backend
|
||||
try:
|
||||
ib = get_image_backend(compose_cfg["backend"])
|
||||
if ib is not None:
|
||||
ib.bind_config(compose_cfg)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 图像后端不可用: {e}")
|
||||
if ib is None:
|
||||
print("[seed_shot] 未配置 compose.backend(openai/mock),跳过种草图生成")
|
||||
return {"seed_shots": [], "stats": state.get("stats") or {}}
|
||||
|
||||
# 材质映射:db SPU.material(清洗换行)
|
||||
material_map: Dict[str, str] = {}
|
||||
try:
|
||||
from graph.product import list_spus
|
||||
import yaml
|
||||
dbp = (config.get("product") or {}).get("db_path", "db/spu_sku.db")
|
||||
p = Path(dbp)
|
||||
if not p.is_absolute():
|
||||
from graph.paths import project_root, runtime_root
|
||||
for root in (runtime_root(), project_root()):
|
||||
if (root / p).exists():
|
||||
p = root / p
|
||||
break
|
||||
for s in list_spus(str(p)):
|
||||
m = " ".join(str(s.get("material", "")).replace("\r", " ").replace("\n", " ").split())
|
||||
material_map[s["code"]] = m
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 材质读取失败(用空): {e}")
|
||||
|
||||
from graph.seed_shot import generate_seed_shots
|
||||
from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss
|
||||
from graph.nodes.oss_upload_node import _gen_rand4, MAX_CODE
|
||||
|
||||
ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S"))
|
||||
prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip()
|
||||
seq = int(state.get("oss_seq") or 0)
|
||||
oss_cfg = config.get("oss") or {}
|
||||
oss_enabled = bool(oss_cfg.get("enabled", True)) and bool(oss_cfg.get("oss_bucket"))
|
||||
|
||||
all_shots: List[Dict[str, Any]] = []
|
||||
shot_dir = output_dir / "seed_shots"
|
||||
shot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import concurrent.futures
|
||||
import threading as _th
|
||||
seq_lock = _th.Lock() # seq(货号计数)跨线程共享,需加锁
|
||||
|
||||
def _shot_one(r: Dict[str, Any]):
|
||||
"""单个产品的种草图生成+上传(每产品独立线程)。"""
|
||||
nonlocal seq
|
||||
base = r.get("composite_path") or r.get("printed_path")
|
||||
if not base or not Path(base).exists():
|
||||
print(f"[seed_shot] {r.get('spu_code', '')} 无合成图,跳过种草图")
|
||||
return None
|
||||
cn = (r.get("cn_title") or "").strip() or r.get("topic", "")
|
||||
material = material_map.get(r.get("spu_code", ""), "")
|
||||
paths = generate_seed_shots(ib, base, cn, material, count, str(shot_dir),
|
||||
r.get("composite_negative", ""),
|
||||
size=str((config.get("seed_shot") or {}).get("size") or "1504x2000"),
|
||||
prefix=r.get("img_code") or r.get("oss_code") or "")
|
||||
if not paths:
|
||||
return None
|
||||
r["seed_shot_paths"] = paths
|
||||
urls: List[str] = []
|
||||
for pth in paths:
|
||||
with seq_lock:
|
||||
if seq >= MAX_CODE:
|
||||
print(f"[seed_shot] 货号计数达上限 999,停止上传种草图")
|
||||
break
|
||||
code = f"{prefix}{seq:03d}"
|
||||
seq += 1
|
||||
if oss_enabled:
|
||||
try:
|
||||
compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg")))
|
||||
url = upload_to_oss(oss_cfg, compressed,
|
||||
build_oss_key(country, ts, code, _gen_rand4()))
|
||||
if url:
|
||||
urls.append(url)
|
||||
r["seed_shot_urls"] = urls
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 种草图上传失败 {pth}: {e}")
|
||||
else:
|
||||
print(f"[seed_shot] oss 未启用,仅本地保存: {pth}")
|
||||
return {"spu_code": r.get("spu_code"), "sku_code": r.get("sku_code"),
|
||||
"paths": paths, "urls": urls}
|
||||
|
||||
# 并发:每个产品一个独立线程(默认);config.seed_shot.concurrency 可覆盖
|
||||
seed_concurrency = int((config.get("seed_shot") or {}).get("concurrency") or 0) or len(products) or 1
|
||||
if len(products) > 1:
|
||||
print(f"[seed_shot] 并发 {seed_concurrency} 生成种草图({len(products)} 个产品)")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=seed_concurrency) as _ex:
|
||||
for item in _ex.map(_shot_one, products):
|
||||
if item:
|
||||
all_shots.append(item)
|
||||
|
||||
stats = dict(state.get("stats") or {})
|
||||
stats["seed_shot"] = {"count": len(all_shots), "seq": seq}
|
||||
return {"seed_shots": all_shots, "product": products, "oss_seq": seq, "stats": stats}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""节点 9/9:商品上传模板导出(template_export)——在 seed_shot 之后。
|
||||
|
||||
把最终结果导入模板:
|
||||
- SPU货号 / SKU货号 = 设计货号(oss_code,前缀+3位计数)
|
||||
- 商品名称 = cn_title(多模态标题生成)
|
||||
- 英文名称 = en_title
|
||||
- 商品轮播图1:SKU 行按颜色路由(该颜色三合一链接),SPU 行随机一张
|
||||
- 详情图文(SPU 行):全部三合一主图链接 + 种草图链接,| 分割
|
||||
|
||||
需在 oss_upload / seed_shot 之后运行(图床链接与货号已生成)。
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from graph.paths import project_root, runtime_root
|
||||
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
|
||||
for i in range(2, 100):
|
||||
cand = prod_dir / f"{tpl_name}_已填写_{i}.xlsx"
|
||||
if not cand.exists():
|
||||
return cand
|
||||
return prod_dir / f"{tpl_name}_已填写_{int(time.time())}.xlsx"
|
||||
|
||||
|
||||
@with_fallback("template_export")
|
||||
def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
products: List[Dict[str, Any]] = state.get("product") or []
|
||||
config = state["config"] or {}
|
||||
pcfg = config.get("product") or {}
|
||||
output_dir = Path(state["output_dir"])
|
||||
errors = list(state.get("errors") or [])
|
||||
stats = dict(state.get("stats") or {})
|
||||
|
||||
# 模板写入时机:所有集合/产品(含种草图、OSS)全部完成后才执行本节点
|
||||
print(f"[template] 全部集合({len(products)} 个产品)处理完成,开始统一写入模板…")
|
||||
|
||||
tp = (pcfg.get("template_path") or "").strip()
|
||||
if not tp:
|
||||
print("[template] 未配置 product.template_path,跳过模板导出")
|
||||
return {"stats": stats, "errors": errors}
|
||||
if not Path(tp).exists():
|
||||
cand = None
|
||||
for root in (runtime_root(), project_root()):
|
||||
c = root / tp
|
||||
if c.exists():
|
||||
cand = str(c)
|
||||
break
|
||||
if cand:
|
||||
tp = cand
|
||||
else:
|
||||
print(f"[template] 模板文件不存在: {tp}")
|
||||
return {"stats": stats, "errors": errors}
|
||||
|
||||
# db 路径
|
||||
dbp = (pcfg.get("db_path") or "db/spu_sku.db")
|
||||
db_path = Path(dbp)
|
||||
if not db_path.is_absolute():
|
||||
for root in (runtime_root(), project_root()):
|
||||
if (root / db_path).exists():
|
||||
db_path = root / db_path
|
||||
break
|
||||
|
||||
from graph.template_export import export_product
|
||||
tdir = (pcfg.get("template_dir") or "").strip() or str(Path(tp).parent)
|
||||
prod_dir = output_dir / "product"
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
exported: List[str] = []
|
||||
skipped = 0
|
||||
merged_out: Optional[str] = None # 合并模式:一次任务所有产品填同一个模板
|
||||
is_first = True
|
||||
for r in products:
|
||||
# 失败跳过:合成图(composite/printed)与标题都失败的产品不写进模板
|
||||
has_img = bool(r.get("composite_path") or r.get("printed_path"))
|
||||
has_title = bool((r.get("cn_title") or "").strip())
|
||||
if not (has_img and has_title):
|
||||
skipped += 1
|
||||
print(f"[template] 跳过失败产品 {r.get('spu_code')}/{r.get('img_code','')}: "
|
||||
f"合成图={'有' if has_img else '无'} 标题={'有' if has_title else '无'}(不写入模板)")
|
||||
continue
|
||||
sku_codes = [cc.get("sku_code") for cc in (r.get("color_composites") or [])]
|
||||
if not sku_codes:
|
||||
sku_codes = [r.get("sku_code") or ""]
|
||||
try:
|
||||
if is_first:
|
||||
merged_out = str(_template_out_path(prod_dir, "商品上传"))
|
||||
out = export_product(
|
||||
db_path, r.get("spu_code", ""), sku_codes, tdir, tp,
|
||||
merged_out,
|
||||
images=[],
|
||||
spu_per_color=True, # 每颜色一个独立 SPU 块(单色多 SPU)
|
||||
oss_code=r.get("oss_code") or (r.get("color_composites") or [{}])[0].get("code", ""),
|
||||
cn_title=r.get("cn_title", ""),
|
||||
en_title=r.get("en_title", ""),
|
||||
ja_title=r.get("ja_title", ""),
|
||||
composite_urls=r.get("color_composites") or [],
|
||||
seed_shot_urls=r.get("seed_shot_urls") or [],
|
||||
append_to="" if is_first else merged_out, # 首个产品从模板创建,后续追加合并
|
||||
markup_percent=float(pcfg.get("markup_percent") or 0),
|
||||
)
|
||||
r["template_path"] = str(out)
|
||||
exported.append(str(out))
|
||||
print(f"[template] 商品上传模板已生成({len(exported)}/{len(products)} 合并): {out}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({"node": "template_export", "type": type(e).__name__,
|
||||
"message": f"模板导出失败 {r.get('spu_code')}: {e}", "trace": ""})
|
||||
print(f"[template] 模板导出失败 {r.get('spu_code')}: {e}")
|
||||
is_first = False
|
||||
|
||||
stats["template_export"] = {"exported": len(exported)}
|
||||
return {"product": products, "errors": errors, "stats": stats}
|
||||
@@ -0,0 +1,97 @@
|
||||
"""阿里云 OSS 图床:图片压缩(3:4、≥1340×1785、<2MB)+ 上传。
|
||||
|
||||
- compress_for_oss(image_path, out_path):
|
||||
中心裁剪到 3:4 → 缩放/放大到 1340×1785 → JPEG quality 迭代压到 <2MB。
|
||||
- upload_to_oss(cfg, local_path, object_key):
|
||||
用 oss2 上传到 config.oss 指定的 bucket,返回可访问 URL。
|
||||
- build_oss_key(country, timestamp, code, rand4):
|
||||
key = {国家}/{时间戳}/{货号}_{4位随机}.jpg(货号=前缀+3位计数,000 起最多 999)
|
||||
- oss 配置(config.yaml oss 段):
|
||||
oss_bucket / oss_endpoint / oss_key_id / oss_key_secret(或环境变量 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET)
|
||||
"""
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# 目标规格:3:4 宽高比,最小 1340×1785,文件 < 2MB
|
||||
TARGET_W, TARGET_H = 1340, 1785
|
||||
MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def compress_for_oss(image_path: str, out_path: str, max_bytes: int = MAX_BYTES) -> str:
|
||||
"""压缩图片到 3:4 / ≥1340×1785 / <2MB(JPEG)。返回输出路径。"""
|
||||
with Image.open(image_path) as im:
|
||||
im = im.convert("RGB")
|
||||
|
||||
# 1) 中心裁剪到 3:4
|
||||
w, h = im.size
|
||||
if w / h > 3 / 4: # 太宽 → 裁左右
|
||||
new_w = int(h * 3 / 4)
|
||||
x0 = (w - new_w) // 2
|
||||
im = im.crop((x0, 0, x0 + new_w, h))
|
||||
elif w / h < 3 / 4: # 太高 → 裁上下
|
||||
new_h = int(w * 4 / 3)
|
||||
y0 = (h - new_h) // 2
|
||||
im = im.crop((0, y0, w, y0 + new_h))
|
||||
|
||||
# 2) 缩放到目标尺寸(≥1340×1785,正好 3:4)
|
||||
im = im.resize((TARGET_W, TARGET_H), Image.LANCZOS)
|
||||
|
||||
# 3) JPEG quality 迭代,保证 <2MB
|
||||
out = Path(out_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
for quality in (92, 85, 78, 70, 62, 55, 48, 40):
|
||||
buf = io.BytesIO()
|
||||
im.save(buf, "JPEG", quality=quality, optimize=True, progressive=True)
|
||||
if buf.tell() <= max_bytes:
|
||||
out.write_bytes(buf.getvalue())
|
||||
return str(out)
|
||||
# 全部超限 → 用最低质量兜底(仍可能 >2MB,打印警告)
|
||||
out.write_bytes(buf.getvalue())
|
||||
print(f"[oss] 警告: {Path(image_path).name} 压缩后仍 {buf.tell()/1024/1024:.1f}MB > 2MB(质量 {quality})")
|
||||
return str(out)
|
||||
|
||||
|
||||
def upload_to_oss(cfg: dict, local_path: str, object_key: str) -> Optional[str]:
|
||||
"""上传本地文件到 OSS,返回 URL;配置缺失/失败返回 None(不中断)。"""
|
||||
bucket = (cfg or {}).get("oss_bucket") or ""
|
||||
endpoint = (cfg or {}).get("oss_endpoint") or ""
|
||||
key_id = (cfg or {}).get("oss_key_id") or ""
|
||||
key_secret = (cfg or {}).get("oss_key_secret") or ""
|
||||
if not (bucket and endpoint and key_id and key_secret):
|
||||
print("[oss] 配置缺失(config.oss),跳过上传")
|
||||
return None
|
||||
try:
|
||||
import oss2
|
||||
# 直连 session:忽略环境代理(挂 VPN 时代理会拦截国内 OSS);
|
||||
# oss2.Bucket 的 session 必须是 oss2.Session(内部封装 requests),设其底层 trust_env=False
|
||||
_session = oss2.Session()
|
||||
try:
|
||||
_session.session.trust_env = False
|
||||
except AttributeError:
|
||||
pass
|
||||
auth = oss2.Auth(key_id, key_secret)
|
||||
bkt = oss2.Bucket(auth, endpoint, bucket, session=_session)
|
||||
with open(local_path, "rb") as f:
|
||||
bkt.put_object(object_key, f)
|
||||
url = f"https://{bucket}.{endpoint}/{object_key}"
|
||||
print(f"[oss] 已上传: {url}")
|
||||
return url
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[oss] 上传失败 {local_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def build_oss_key(country: str, timestamp: str, code: str, rand4: str, ext: str = ".jpg") -> str:
|
||||
"""对象 key:{国家}/{时间戳}/{货号}_{4位随机}.jpg(如 GB/20260820150945/DG000_Ab3x.jpg)。"""
|
||||
safe = lambda s: "".join(c for c in (s or "") if c.isalnum() or c in "-_").strip()
|
||||
return f"{safe(country)}/{safe(timestamp)}/{safe(code)}_{safe(rand4)}{ext}"
|
||||
|
||||
|
||||
def random_code4() -> str:
|
||||
"""4 位随机:大小写英文 + 数字。"""
|
||||
import random
|
||||
import string
|
||||
return "".join(random.choices(string.ascii_letters + string.digits, k=4))
|
||||
@@ -0,0 +1,23 @@
|
||||
"""路径工具:区分「数据根(只读)」与「运行根(可写)」。
|
||||
|
||||
开发模式:两者都是项目根(pod_trend_agent/)。
|
||||
打包模式(PyInstaller -F):
|
||||
- 数据根 = sys._MEIPASS(解压的临时目录,只读;config.yaml / configs / prompts 在这里)
|
||||
- 运行根 = exe 所在目录(可写;output/ 产物与 .cache/ 缓存写到这,避免重启丢失)
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
"""数据根:开发=graph 上级目录;打包=_MEIPASS(数据文件解压处)。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def runtime_root() -> Path:
|
||||
"""运行根(可写):打包=exe 旁;开发=项目根。output/.cache 写这里。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent.parent
|
||||
@@ -0,0 +1,102 @@
|
||||
"""SPU/SKU 数据库查询 + 底图/模特图资源查找(产品图生成流水线的数据层)。
|
||||
|
||||
数据关系(已核实 spu_sku.db):
|
||||
- SPU.code = 款号(如 DG004)
|
||||
- SKU.code = "款号-颜色编码"(如 DG004-BL01),SKU.color = 中文色名(黑/灰/...)
|
||||
- basemap 目录 = basemap/<款号>/<SKU.code>/xxx.jpg
|
||||
- material_library/<品类>/ 存放模特图
|
||||
- SKU.img_url_2~5 = CDN 图 URL(底图/细节/模特图,仅作参考字段)
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# 支持的图片格式:模特图/底图均按此识别(png/jpg 等常见格式全覆盖;AVIF/GIF/TIFF 亦支持)
|
||||
IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".avif", ".gif", ".tiff", ".tif"}
|
||||
|
||||
|
||||
def _connect(db_path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def list_spus(db_path, country: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""全部 SPU(可选按国家过滤)。"""
|
||||
conn = _connect(db_path)
|
||||
sql = "SELECT id, code, style, material, printing_type, target_audience, pattern, country, mark FROM SPU"
|
||||
params: list = []
|
||||
if country:
|
||||
sql += " WHERE country = ?"
|
||||
params.append(country)
|
||||
sql += " ORDER BY code"
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def list_colors(db_path, spu_code: str) -> List[Dict[str, Any]]:
|
||||
"""款号 → 颜色列表(SKU.code 去重,附中文色名、CDN 底图 URL、最低价)。"""
|
||||
conn = _connect(db_path)
|
||||
rows = conn.execute(
|
||||
"""SELECT s.code AS sku_code, s.color, s.img_url_2 AS img_url, MIN(s.price) AS price
|
||||
FROM SKU s JOIN SPU p ON s.spu_id = p.id
|
||||
WHERE p.code = ?
|
||||
GROUP BY s.code, s.color ORDER BY s.code""", (spu_code,)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def find_basemap(basemap_root, spu_code: str, sku_code: str) -> Optional[Path]:
|
||||
"""basemap/<款号>/<SKU.code>/ 下第一张图片;无返回 None。"""
|
||||
d = Path(basemap_root) / spu_code / sku_code
|
||||
if not d.exists():
|
||||
return None
|
||||
for f in sorted(d.iterdir()):
|
||||
if f.is_file() and f.suffix.lower() in IMG_EXTS:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def list_model_images(material_root, category: str = "T-shirt") -> List[Path]:
|
||||
"""material_library/<品类>/ 下所有图片;无返回空列表。"""
|
||||
d = Path(material_root) / category
|
||||
if not d.exists():
|
||||
return []
|
||||
return [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS]
|
||||
|
||||
|
||||
def find_first_model_folder(material_root, preferred: Optional[str] = None):
|
||||
"""material_library 下「第一个有图片的子目录」及其图片列表。
|
||||
|
||||
- preferred(如 config 的 model_category)优先:该目录有图就直接用;
|
||||
- 否则按子目录名排序,取第一个有图的目录;
|
||||
- 全空返回 (None, [])。
|
||||
返回 (dir_name or None, images: List[Path])。
|
||||
"""
|
||||
root = Path(material_root)
|
||||
if not root.exists():
|
||||
return None, []
|
||||
candidates = []
|
||||
if preferred:
|
||||
d = root / preferred
|
||||
if d.is_dir():
|
||||
candidates.append(d)
|
||||
candidates += [d for d in sorted(root.iterdir()) if d.is_dir()]
|
||||
seen = set()
|
||||
for d in candidates:
|
||||
if d in seen:
|
||||
continue
|
||||
seen.add(d)
|
||||
imgs = [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS]
|
||||
if imgs:
|
||||
return d.name, imgs
|
||||
return None, []
|
||||
|
||||
|
||||
def first_available_sku(db_path, basemap_root, spu_code: str) -> Optional[str]:
|
||||
"""返回该款号下第一个「本地有底图」的 SKU.code;无则 None。"""
|
||||
for c in list_colors(db_path, spu_code):
|
||||
if find_basemap(basemap_root, spu_code, c["sku_code"]) is not None:
|
||||
return c["sku_code"]
|
||||
return None
|
||||
@@ -0,0 +1,309 @@
|
||||
"""缓存热点批量产品流程(用户新流程入口)。
|
||||
|
||||
流程:
|
||||
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
|
||||
@@ -0,0 +1,253 @@
|
||||
"""归一化、跨源融合、合规黑名单过滤、人名过滤(从原 src/scoring.py 迁移到 graph 包)。
|
||||
|
||||
所有函数纯逻辑、无 IO,便于节点内调用与单元测试。
|
||||
"""
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
DEFAULT_NAME_PATTERNS = [r"^[A-Z][a-z]+(?: [A-Z][a-z]+){1,2}$"]
|
||||
DEFAULT_EXTRA_NAMES = [
|
||||
"taylor swift", "trump", "biden", "kardashian", "lebron", "charlie sheen",
|
||||
"bernie sanders", "elon musk", "beyonce", "drake", "rihanna", "justin bieber",
|
||||
"ariana grande", "selena gomez", "eminem", "kanye", "travis scott", "messi",
|
||||
"ronaldo", "harry styles", "bts", "blackpink", "pewdiepie", "mrbeast",
|
||||
"kamala harris", "joe biden", "donald trump", "kim kardashian", "pearl jam",
|
||||
"nirvana", "michael jackson", "madonna", "britney spears", "lady gaga",
|
||||
"justin timberlake", "tom cruise", "brad pitt", "keanu reeves", "robert downey",
|
||||
"cristiano ronaldo", "lionel messi", "billie eilish", "the weeknd", "post malone",
|
||||
"kendrick lamar", "joe rogan", "andrew tate", "elon", "musk", "obama", "clinton",
|
||||
"springsteen", "reiner", "eliza lopes", "camilla", "noah kahan", "gina carano",
|
||||
# —— 常见人名扩充(歌手/演员/运动员/政客/企业家/网红/王室,子串匹配,避免真人印花)——
|
||||
"ed sheeran", "dua lipa", "adele", "bruno mars", "shakira", "elton john",
|
||||
"david bowie", "freddie mercury", "whitney houston", "celine dion", "olivia rodrigo",
|
||||
"sabrina carpenter", "chappell roan", "ice spice", "nicki minaj", "cardi b",
|
||||
"doja cat", "sza", "lil nas x", "bad bunny", "shawn mendes", "zayn malik",
|
||||
"dwayne johnson", "johnny depp", "leonardo dicaprio", "chris hemsworth", "chris evans",
|
||||
"tom holland", "zendaya", "jennifer lawrence", "emma watson", "scarlett johansson",
|
||||
"miley cyrus", "hugh jackman", "nicole kidman", "cate blanchett", "steve irwin",
|
||||
"kylie minogue", "morgan freeman", "will smith", "denzel washington", "angelina jolie",
|
||||
"jennifer aniston", "george clooney", "robert pattinson", "daniel radcliffe", "emma stone",
|
||||
"ryan reynolds", "ryan gosling", "kobe bryant", "michael jordan", "serena williams",
|
||||
"venus williams", "tiger woods", "usain bolt", "tom brady", "patrick mahomes",
|
||||
"stephen curry", "kevin durant", "lewis hamilton", "max verstappen", "novak djokovic",
|
||||
"rafael nadal", "roger federer", "conor mcgregor", "putin", "zelensky",
|
||||
"boris johnson", "rishi sunak", "narendra modi", "justin trudeau", "emmanuel macron",
|
||||
"olaf scholz", "bill gates", "jeff bezos", "mark zuckerberg", "logan paul", "jake paul",
|
||||
"ksi", "charli damelio", "addison rae", "kylie jenner", "kendall jenner",
|
||||
"queen elizabeth", "king charles", "prince william", "prince harry", "meghan markle",
|
||||
"princess diana",
|
||||
]
|
||||
DEFAULT_EXEMPTIONS = [
|
||||
"new album", "best seller", "top gear", "red cross", "black cat", "blue moon",
|
||||
"green day", "red hot chili peppers", "cold play", "one direction", "little mix",
|
||||
"west life", "back street", "new york", "los angeles", "san diego", "new orleans",
|
||||
"san francisco", "las vegas", "white house", "high school", "middle earth",
|
||||
]
|
||||
|
||||
|
||||
def filter_person_names(
|
||||
rows: List[Dict],
|
||||
extra_names: Optional[List[str]] = None,
|
||||
patterns: Optional[List[str]] = None,
|
||||
exemptions: Optional[List[str]] = None,
|
||||
pattern_sources: Optional[set] = None,
|
||||
) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""剔除真实人物(明星/政客/名人),避免肖像权风险。返回 (kept, dropped)。"""
|
||||
extra = [e.lower() for e in (extra_names if extra_names is not None else DEFAULT_EXTRA_NAMES)]
|
||||
pats = patterns if patterns is not None else DEFAULT_NAME_PATTERNS
|
||||
exempt = [e.lower() for e in (exemptions if exemptions is not None else DEFAULT_EXEMPTIONS)]
|
||||
compiled = [re.compile(p) for p in pats]
|
||||
pattern_sources = set(pattern_sources) if pattern_sources is not None else {"gt_trending"}
|
||||
|
||||
kept, dropped = [], []
|
||||
for r in rows:
|
||||
topic = str(r.get("topic", "")).strip()
|
||||
tl = topic.lower()
|
||||
if any(e and e in tl for e in exempt):
|
||||
kept.append(r)
|
||||
continue
|
||||
reason = None
|
||||
hit_name = [n for n in extra if n and n in tl]
|
||||
if hit_name:
|
||||
reason = f"命中人名名单: {hit_name}"
|
||||
elif r.get("source") in pattern_sources and any(p.search(topic) for p in compiled):
|
||||
reason = "匹配人名模式(疑似真实人物)"
|
||||
if reason:
|
||||
r2 = dict(r)
|
||||
r2["drop_reason"] = reason
|
||||
dropped.append(r2)
|
||||
else:
|
||||
kept.append(r)
|
||||
return kept, dropped
|
||||
|
||||
|
||||
def filter_design_relevance(
|
||||
rows: List[Dict],
|
||||
drop_patterns: Optional[List[str]] = None,
|
||||
keep_patterns: Optional[List[str]] = None,
|
||||
) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""丢弃不可作印花主体的泛新闻/科技/赛事词。返回 (kept, dropped)。"""
|
||||
drop = [re.compile(p, re.I) for p in (drop_patterns or [])]
|
||||
keep = [re.compile(p, re.I) for p in (keep_patterns or [])]
|
||||
kept, dropped = [], []
|
||||
for r in rows:
|
||||
topic = str(r.get("topic", "")).strip()
|
||||
tl = topic.lower()
|
||||
if keep and not any(p.search(tl) for p in keep):
|
||||
r2 = dict(r)
|
||||
r2["drop_reason"] = "未命中设计相关性白名单"
|
||||
dropped.append(r2)
|
||||
continue
|
||||
if drop and any(p.search(tl) for p in drop):
|
||||
r2 = dict(r)
|
||||
r2["drop_reason"] = "非印花设计主体(泛新闻/科技/赛事)"
|
||||
dropped.append(r2)
|
||||
continue
|
||||
kept.append(r)
|
||||
return kept, dropped
|
||||
|
||||
|
||||
# 查询噪声:非“可印花设计概念”的检索问句 / 命名清单 / 损坏碎片,应直接丢弃而非标 safe。
|
||||
_QUERY_NOISE_LEAD = re.compile(
|
||||
r"^(what|who|how|why|when|where|which|is|are|was|were|do|does|did|can|will|"
|
||||
r"should|would|may|might|has|have|whose|whom)\b", re.I)
|
||||
# 任意位置的疑问词:覆盖 "punk sprite what does it do" 这类词序在中的问句
|
||||
_QUERY_NOISE_WH_ANY = re.compile(r"\b(what|who|how|why|when|where|which)\b", re.I)
|
||||
# names/surnames:覆盖 "cottagecore surnames" 这类变体
|
||||
_QUERY_NOISE_NAMES = re.compile(
|
||||
r"\b((?:boy|girl|baby|pet|dog|cat|last|first|middle)?\s*names?|surnames)"
|
||||
r"(?:\s+(?:ideas|list))?\b$", re.I)
|
||||
# 损坏/拼接碎片:数字前缀可选,覆盖 "gothic remake review"(无数字)与 "gothic remake metacritic"
|
||||
_QUERY_NOISE_CORRUPT = re.compile(
|
||||
r"\b(?:\d{1,2}\s+)?(remake|review|version|copy|edit|replica|metacritic)\b", re.I)
|
||||
_QUERY_NOISE_WORDS = [re.compile(p, re.I) for p in
|
||||
[r"\bstory\b", r"\bmeaning\b", r"\bdefinition\b",
|
||||
r"\btutorial\b", r"\bguide\b", r"\bquests?\b"]]
|
||||
|
||||
|
||||
# —— 新闻类热点过滤(突发新闻不适合做印花主题,各国语言词表)——
|
||||
NEWS_WORDS_GLOBAL = [
|
||||
"weather", "forecast", "typhoon", "earthquake", "tsunami", "hurricane",
|
||||
"missile", "election", "vote", "prime minister", "president", "minister",
|
||||
"cabinet", "senate", "congress", "parliament", "shooting", "ceasefire",
|
||||
"nuclear", "summit", "hostage", "emergency", "warning", "breaking news",
|
||||
"stock market", "oil price", "inflation", "deadline", "live update",
|
||||
]
|
||||
NEWS_WORDS_JP = [
|
||||
"天気", "台風", "気象", "地震", "津波", "ミサイル", "首相", "大臣",
|
||||
"会見", "速報", "選挙", "防衛", "自衛隊", "警報", "注意報", "ニュース",
|
||||
"報道", "豪雨", "猛暑", "熱中症", "株価", "円相場", "物価", "国会",
|
||||
"衆院", "参院", "裁判", "逮捕", "捜査", "事故", "死亡", "追悼", "慰霊",
|
||||
]
|
||||
|
||||
|
||||
def filter_news(rows: List[Dict], country: str = "") -> Tuple[List[Dict], List[Dict]]:
|
||||
"""丢弃新闻类热点(天气/灾害/政治/事故等突发新闻,非印花主题)。按国家语言补充词表。"""
|
||||
words = list(NEWS_WORDS_GLOBAL)
|
||||
if str(country).upper() == "JP":
|
||||
words += NEWS_WORDS_JP
|
||||
elif str(country).upper() == "US":
|
||||
words += ["weather alert", "live coverage", "breaking"]
|
||||
kept, dropped = [], []
|
||||
for r in rows:
|
||||
tl = str(r.get("topic", "")).lower()
|
||||
hit = next((w for w in words if w in tl), None)
|
||||
if hit:
|
||||
r2 = dict(r)
|
||||
r2["drop_reason"] = f"新闻类热点(非印花主题): {hit}"
|
||||
dropped.append(r2)
|
||||
else:
|
||||
kept.append(r)
|
||||
return kept, dropped
|
||||
|
||||
|
||||
def filter_query_noise(
|
||||
rows: List[Dict],
|
||||
enabled: bool = True,
|
||||
) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""丢弃“查询噪声/非设计概念”词(问句、命名清单、损坏碎片、模糊名词)。
|
||||
|
||||
返回 (kept, dropped)。这些词不是可印花主体,进入 screen 会被 Mock 误标 safe,
|
||||
故在过滤阶段就剔除,避免污染生图环节。
|
||||
"""
|
||||
if not enabled:
|
||||
return rows, []
|
||||
kept, dropped = [], []
|
||||
for r in rows:
|
||||
topic = str(r.get("topic", "")).strip()
|
||||
tl = topic.lower()
|
||||
reason = None
|
||||
if _QUERY_NOISE_WH_ANY.search(tl) or _QUERY_NOISE_LEAD.search(tl):
|
||||
reason = "查询问句(非设计概念)"
|
||||
elif _QUERY_NOISE_NAMES.search(tl):
|
||||
reason = "命名清单类查询(非设计概念)"
|
||||
elif _QUERY_NOISE_CORRUPT.search(tl):
|
||||
reason = "损坏/拼接的查询碎片"
|
||||
elif any(p.search(tl) for p in _QUERY_NOISE_WORDS):
|
||||
reason = "模糊名词(非设计概念)"
|
||||
if reason:
|
||||
r2 = dict(r)
|
||||
r2["drop_reason"] = reason
|
||||
dropped.append(r2)
|
||||
else:
|
||||
kept.append(r)
|
||||
return kept, dropped
|
||||
|
||||
|
||||
def normalize(rows: List[Dict], key: str = "raw_score") -> List[Dict]:
|
||||
"""min-max 归一化到 0-1,按 (source, kind) 分组分别归一化。"""
|
||||
if not rows:
|
||||
return rows
|
||||
groups = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[(r.get("source", "_"), r.get("kind", "_"))].append(r)
|
||||
for grp in groups.values():
|
||||
vals = [r[key] for r in grp if r.get(key) is not None]
|
||||
if not vals:
|
||||
for r in grp:
|
||||
r["norm"] = 0.0
|
||||
continue
|
||||
lo, hi = min(vals), max(vals)
|
||||
span = (hi - lo) or 1.0
|
||||
for r in grp:
|
||||
v = r.get(key)
|
||||
r["norm"] = (v - lo) / span if v is not None else 0.0
|
||||
return rows
|
||||
|
||||
|
||||
def apply_blacklist(rows: List[Dict], blacklist: List[str]) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""命中黑名单的词丢弃,返回 (保留, 丢弃)。"""
|
||||
if not blacklist:
|
||||
return rows, []
|
||||
bl = [b.lower() for b in blacklist]
|
||||
kept, dropped = [], []
|
||||
for r in rows:
|
||||
text = f"{r.get('topic', '')} {r.get('seed', '')}".lower()
|
||||
if any(b in text for b in bl):
|
||||
dropped.append(r)
|
||||
else:
|
||||
kept.append(r)
|
||||
return kept, dropped
|
||||
|
||||
|
||||
def combine(rows: List[Dict], weights: Dict[str, float]) -> List[Dict]:
|
||||
"""按 topic 跨源融合,权重来自 config。"""
|
||||
agg = {}
|
||||
for r in rows:
|
||||
t = r["topic"].lower().strip()
|
||||
if t not in agg:
|
||||
agg[t] = {"topic": r["topic"], "countries": set(), "sources": set(), "score": 0.0}
|
||||
w = weights.get(r["source"], 0.5)
|
||||
agg[t]["score"] += r.get("norm", 0.0) * w
|
||||
if r.get("country"):
|
||||
agg[t]["countries"].add(r["country"])
|
||||
agg[t]["sources"].add(r["source"])
|
||||
out = []
|
||||
for o in agg.values():
|
||||
o["countries"] = ",".join(sorted(o["countries"])) or "GLOBAL"
|
||||
o["sources"] = ",".join(sorted(o["sources"]))
|
||||
out.append(o)
|
||||
out.sort(key=lambda x: x["score"], reverse=True)
|
||||
return out
|
||||
@@ -0,0 +1,103 @@
|
||||
"""种草图(Seed Shot)生成。
|
||||
|
||||
- 模板:configs/seed_shot_templates.yaml(可自定义,占位符 [商品名称]/[材质]/[模特特征])
|
||||
- 模特特征:configs/model_features.yaml(可自定义,随机取一条)
|
||||
- 生成:以 product 合成图(图1)为参考,img2img 生成 N 张种草图(保留衣服外观、换场景/模特)
|
||||
- 占位替换:[商品名称]→cn_title(缺省回退 topic);[材质]→SPU.material;[模特特征]→随机
|
||||
"""
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from graph.paths import project_root
|
||||
|
||||
|
||||
def _load_yaml(rel: str) -> Dict[str, Any]:
|
||||
for root in (project_root(),):
|
||||
p = root / rel
|
||||
if p.exists():
|
||||
try:
|
||||
return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 读取 {rel} 失败: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def load_templates() -> List[Dict[str, str]]:
|
||||
"""种草图提示词模板列表(无配置时给内置兜底)。"""
|
||||
data = _load_yaml("configs/seed_shot_templates.yaml")
|
||||
tpls = data.get("seed_shot_templates") or []
|
||||
if not tpls:
|
||||
tpls = [{
|
||||
"name": "default",
|
||||
"prompt": (
|
||||
"【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、"
|
||||
"印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、"
|
||||
"重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。"
|
||||
"全身动态抓拍构图,行走在阳光斑驳的城市林荫道上,微微低头微笑,凸显[材质]的透气与百搭。"
|
||||
"徕卡Q2摄影质感,高对比度色彩,35mm镜头,f/1.7大光圈,8k分辨率。"
|
||||
),
|
||||
}]
|
||||
return [{"name": str(t.get("name", "default")), "prompt": str(t.get("prompt", ""))}
|
||||
for t in tpls if t.get("prompt")]
|
||||
|
||||
|
||||
def load_model_features() -> List[str]:
|
||||
"""模特特征列表(无配置时给内置兜底)。"""
|
||||
data = _load_yaml("configs/model_features.yaml")
|
||||
feats = [str(f) for f in (data.get("model_features") or []) if str(f).strip()]
|
||||
if not feats:
|
||||
feats = ["20岁清新少女,素颜通透感", "25岁都市职场女性,干练气质"]
|
||||
return feats
|
||||
|
||||
|
||||
def load_style_features() -> List[str]:
|
||||
"""服装风格列表(style_features.yaml,随机取一条替换 [服装风格];无配置时内置兜底)。"""
|
||||
data = _load_yaml("configs/style_features.yaml")
|
||||
feats = [str(f) for f in (data.get("style_features") or []) if str(f).strip()]
|
||||
if not feats:
|
||||
feats = ["极简基础款风格,干净纯粹,无过多繁复装饰",
|
||||
"日系City Boy/Girl风,微宽松版型,注重舒适度与层次感"]
|
||||
return feats
|
||||
|
||||
|
||||
def render_prompt(template_prompt: str, cn_title: str, material: str, model_feature: str,
|
||||
style_feature: str = "") -> str:
|
||||
"""占位替换:[商品名称]/[材质]/[模特特征]/[服装风格]"""
|
||||
out = template_prompt.replace("[商品名称]", (cn_title or "").strip() or "这件衣服")
|
||||
out = out.replace("[材质]", (material or "").strip() or "面料")
|
||||
out = out.replace("[模特特征]", (model_feature or "").strip() or "模特")
|
||||
out = out.replace("[服装风格]", (style_feature or "").strip() or "日常休闲风")
|
||||
return out
|
||||
|
||||
|
||||
def generate_seed_shots(image_backend, base_image: str, cn_title: str, material: str,
|
||||
count: int, out_dir: str, negative: str = "",
|
||||
size: str = "1504x2000", prefix: str = "") -> List[str]:
|
||||
"""生成 count 张种草图(img2img,图1=合成图)。返回产物路径列表。
|
||||
size: 种草图统一 1504x2000。
|
||||
prefix: 货号前缀(对应产品货号,命名 {prefix}_seedshot_{n}.png,不覆盖旧文件)。
|
||||
占位符 [商品名称]/[材质]/[模特特征]/[服装风格] 均随机组合(模板/模特/服装风格各随机取一条)。"""
|
||||
templates = load_templates()
|
||||
features = load_model_features()
|
||||
style_features = load_style_features()
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
paths: List[str] = []
|
||||
for i in range(count):
|
||||
tpl = random.choice(templates)
|
||||
feat = random.choice(features)
|
||||
style_feat = random.choice(style_features)
|
||||
prompt = render_prompt(tpl["prompt"], cn_title, material, feat, style_feat)
|
||||
out_path = str(out / f"{prefix}_seedshot_{i + 1:02d}.png" if prefix
|
||||
else out / f"seed_shot_{i + 1:02d}.png")
|
||||
try:
|
||||
image_backend.print(prompt, base_image, out_path, negative, size=size)
|
||||
paths.append(out_path)
|
||||
print(f"[seed_shot] 已生成种草图 {i + 1}/{count}: {out_path}"
|
||||
f"(模板={tpl['name']},模特={feat[:14]}…,风格={style_feat[:14]}…)")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed_shot] 种草图 {i + 1} 生成失败: {e}")
|
||||
return paths
|
||||
@@ -0,0 +1,27 @@
|
||||
"""graph/seeds 可插拔种子词策略注册表。
|
||||
|
||||
seed_provider 取值:
|
||||
- static : 仅用 yaml 写死种子,零动态
|
||||
- mock : 规则生成(借用 trending/历史/月份节日),零 API 成本
|
||||
- openai_compat : 真 LLM 生成(OpenAI / DeepSeek / Qwen / Kimi 等兼容协议)
|
||||
- openai / deepseek / qwen / moonshot : 同 openai_compat,仅别名
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
from .base import SeedStrategy
|
||||
from .static_strategy import StaticStrategy
|
||||
from .dynamic_strategy import DynamicStrategy
|
||||
|
||||
SEED_STRATEGIES: Dict[str, SeedStrategy] = {
|
||||
"static": StaticStrategy(),
|
||||
"mock": DynamicStrategy(),
|
||||
"openai_compat": DynamicStrategy(),
|
||||
"openai": DynamicStrategy(),
|
||||
"deepseek": DynamicStrategy(),
|
||||
"qwen": DynamicStrategy(),
|
||||
"moonshot": DynamicStrategy(),
|
||||
}
|
||||
|
||||
|
||||
def get_seed_strategy(name: str) -> SeedStrategy:
|
||||
return SEED_STRATEGIES.get((name or "static").strip().lower(), StaticStrategy())
|
||||
@@ -0,0 +1,31 @@
|
||||
"""种子词策略基类(可插拔核心)。
|
||||
|
||||
新增一个种子词策略只需:① 继承 SeedStrategy 实现 resolve();② 在 __init__.py
|
||||
的 SEED_STRATEGIES 注册表里登记。config 的 ``seed_provider`` 选择用哪个。
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class SeedStrategy:
|
||||
#: 注册名(与 config.seed_provider 对应)
|
||||
name: str = "base"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
country: str,
|
||||
cc: Dict[str, Any],
|
||||
context: Dict[str, Any],
|
||||
llm_backend: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""产出种子词。
|
||||
|
||||
返回至少包含:
|
||||
- "style_seeds": [str] 风格/美学向种子
|
||||
- "related_seeds": [str] 行业/主体向种子(行业交叉验证)
|
||||
- "dynamic": bool 是否经过 LLM 动态生成
|
||||
可选附带 "llm_style_seeds" / "llm_related_seeds" 便于观测。
|
||||
|
||||
cc 为合并后的国家配置(含 yaml 静态种子);context 为 seed_node 收集的
|
||||
trending/历史/月份节日上下文;llm_backend 为 LLM 后端实例(可能 None)。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,111 @@
|
||||
"""动态策略:以 yaml 静态种子为基础,叠加动态种子(统一池 + 加权随机 + 用完全用)。
|
||||
|
||||
种子词机制(v49 起):
|
||||
1. 全部类型放一起(统一池):静态 style + 静态 related + 月份主题 + 节日 + LLM 动态
|
||||
——合并去重(跨类型同词只保留一个,权重累加 = 多来源更受重视);
|
||||
2. 节日种子词提供权重:节日权重 3.0 > 月份主题 2.0 > 静态/动态 1.0,随机抽取时加权;
|
||||
3. 每次随机取:从池中按权重随机抽取(不重复),limit 内数量;
|
||||
4. 用完全用:池中种子数 ≤ 需要数时全部使用(不再随机限量/截断);
|
||||
5. 每个国家独立配置:configs/countries/<country>.yaml 的 style.seeds / related.seed_keywords。
|
||||
|
||||
limit 由 seed_node 从 context 注入(max_style_seeds / max_related_seeds,0 或缺失=不限)。
|
||||
LLM 后端生成失败时自动回退到静态+节日主题,保证不中断。
|
||||
"""
|
||||
import random
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import SeedStrategy
|
||||
|
||||
|
||||
def _weighted_sample(pool: List[Dict[str, Any]], k: int) -> List[Dict[str, Any]]:
|
||||
"""按权重随机不重复取 k 个;池数量 ≤ k(或用完)时全部返回(不随机限量)。"""
|
||||
if k <= 0 or len(pool) <= k:
|
||||
return list(pool)
|
||||
out: List[Dict[str, Any]] = []
|
||||
rest = list(pool)
|
||||
for _ in range(k):
|
||||
weights = [max(float(it["weight"]), 0.0) for it in rest]
|
||||
if sum(weights) <= 0:
|
||||
out.extend(rest)
|
||||
break
|
||||
idx = random.choices(range(len(rest)), weights=weights)[0]
|
||||
out.append(rest.pop(idx))
|
||||
return out
|
||||
|
||||
|
||||
class DynamicStrategy(SeedStrategy):
|
||||
name = "dynamic"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
country: str,
|
||||
cc: Dict[str, Any],
|
||||
context: Dict[str, Any],
|
||||
llm_backend: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
base_style = list((cc.get("style", {}) or {}).get("seeds", []) or [])
|
||||
base_related = list((cc.get("related", {}) or {}).get("seed_keywords", []) or [])
|
||||
month_style = list(context.get("month_themes", []) or [])
|
||||
holidays = list(context.get("upcoming_holidays", []) or [])
|
||||
holiday_style = [f"{h.lower()} aesthetic" for h in holidays]
|
||||
# 节日主题同时扩充 related 源(提高节日权重 + 增加 related 扩展)
|
||||
holiday_related = [f"{h.lower()} tee" if not h.lower().endswith("day") else f"{h.lower()} gift"
|
||||
for h in holidays]
|
||||
|
||||
# LLM 动态种子(失败回退,不影响静态/节日)
|
||||
dyn_style: List[str] = []
|
||||
dyn_related: List[str] = []
|
||||
if llm_backend is not None and hasattr(llm_backend, "generate_seeds"):
|
||||
try:
|
||||
res = llm_backend.generate_seeds(context) or {}
|
||||
dyn_style = list(res.get("style_seeds", []) or [])
|
||||
dyn_related = list(res.get("related_seeds", []) or [])
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[seed] LLM 生成种子失败,仅用静态+节日主题: {e}")
|
||||
|
||||
# 1) 统一池:全部类型合并,跨类型去重(同词权重累加 = 多来源更受重视)
|
||||
pool: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def add(items: List[str], weight: float, src: str) -> None:
|
||||
for it in items:
|
||||
it = (it or "").strip()
|
||||
if not it:
|
||||
continue
|
||||
key = it.lower()
|
||||
if key in pool:
|
||||
pool[key]["weight"] += weight
|
||||
pool[key]["sources"].append(src)
|
||||
else:
|
||||
pool[key] = {"word": it, "weight": weight, "sources": [src]}
|
||||
|
||||
add(base_style, 1.0, "static")
|
||||
add(base_related, 1.0, "static")
|
||||
add(month_style, 2.0, "month")
|
||||
add(holiday_style, 3.0, "holiday")
|
||||
add(holiday_related, 3.0, "holiday")
|
||||
add(dyn_style, 1.0, "dynamic")
|
||||
add(dyn_related, 1.0, "dynamic")
|
||||
|
||||
items = list(pool.values())
|
||||
limit_style = int(context.get("max_style_seeds") or 0)
|
||||
limit_related = int(context.get("max_related_seeds") or 0)
|
||||
|
||||
# 2) 每次随机取(加权,不重复);池不足 → 全部用
|
||||
style_pick = _weighted_sample(items, limit_style)
|
||||
style_keys = {id(it) for it in style_pick}
|
||||
remaining = [it for it in items if id(it) not in style_keys]
|
||||
related_pick = _weighted_sample(remaining, limit_related)
|
||||
|
||||
return {
|
||||
"style_seeds": [it["word"] for it in style_pick],
|
||||
"related_seeds": [it["word"] for it in related_pick],
|
||||
"dynamic": True,
|
||||
"pool_size": len(items),
|
||||
"pool": [it["word"] for it in items],
|
||||
"llm_style_seeds": dyn_style,
|
||||
"llm_related_seeds": dyn_related,
|
||||
"holiday_style_seeds": holiday_style,
|
||||
"holiday_related_seeds": holiday_related,
|
||||
"static_style_seeds": base_style,
|
||||
"static_related_seeds": base_related,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""月份 / 季节 / 临近节日上下文(按国家),供动态种子词生成的 LLM 上下文使用。
|
||||
|
||||
提供 build_holiday_context(country, now=None) -> dict:
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"year": 2026,
|
||||
"month": 8,
|
||||
"season": "Summer",
|
||||
"month_themes": ["back to school", "late summer", "outdoor adventure"],
|
||||
"upcoming_holidays": ["Back to School", "Summer Solstice"]
|
||||
}
|
||||
- month_themes:固定月度灵感,作为种子词生成的稳定基础。
|
||||
- upcoming_holidays:按国家节日表,用窗口计算临近(含刚过的)固定/浮动节日,
|
||||
让 LLM 注入"当前该国的可能节日",生成对应节日主题种子。
|
||||
"""
|
||||
import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# 北半球季节(南半球可反向扩展)
|
||||
SEASON_BY_MONTH = {
|
||||
12: "Winter", 1: "Winter", 2: "Winter",
|
||||
3: "Spring", 4: "Spring", 5: "Spring",
|
||||
6: "Summer", 7: "Summer", 8: "Summer",
|
||||
9: "Autumn", 10: "Autumn", 11: "Autumn",
|
||||
}
|
||||
|
||||
# 月度主题词(通用印花设计灵感)
|
||||
MONTH_THEMES: Dict[int, List[str]] = {
|
||||
1: ["new year", "winter cozy", "resolution"],
|
||||
2: ["valentine", "love", "heart"],
|
||||
3: ["spring bloom", "st patrick", "fresh start"],
|
||||
4: ["easter", "spring garden", "pastel"],
|
||||
5: ["mother day", "flower", "spring outdoor"],
|
||||
6: ["pride", "summer start", "beach"],
|
||||
7: ["summer vibe", "travel", "festival"],
|
||||
8: ["back to school", "late summer", "outdoor adventure"],
|
||||
9: ["autumn equinox", "harvest", "cozy"],
|
||||
10: ["halloween", "autumn goth", "spooky"],
|
||||
11: ["thanksgiving", "gratitude", "autumn warm"],
|
||||
12: ["christmas", "winter holiday", "cozy festive"],
|
||||
}
|
||||
|
||||
# (name, month, day, rule, window_days)
|
||||
# rule: None=固定日; "mother"=第2周日; "father"=第3周日; "thanks"=第4周四; "easter"=Computus; "bf"=thanks+1
|
||||
_HOLIDAYS_BY_COUNTRY: Dict[str, List[tuple]] = {
|
||||
"US": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("St Patrick's Day", 3, 17, None, 14),
|
||||
("Easter", 0, 0, "easter", 21),
|
||||
("Mother's Day", 5, 0, "mother", 14),
|
||||
("Father's Day", 6, 0, "father", 14),
|
||||
("Pride Month", 6, 1, None, 7),
|
||||
("Independence Day", 7, 4, None, 21),
|
||||
("Summer Solstice", 6, 21, None, 14),
|
||||
("Back to School", 8, 15, None, 30),
|
||||
("Labor Day", 9, 0, "labor", 14),
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Thanksgiving (US)", 11, 0, "thanks", 21),
|
||||
("Black Friday", 11, 0, "bf", 14),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
("Winter Solstice", 12, 21, None, 14),
|
||||
],
|
||||
"GB": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("St Patrick's Day", 3, 17, None, 14),
|
||||
("Easter", 0, 0, "easter", 21),
|
||||
("Mother's Day (UK)", 3, 0, "mother_uk", 14),
|
||||
("Father's Day", 6, 0, "father", 14),
|
||||
("Summer Bank Holiday", 8, 25, None, 14),
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Bonfire Night", 11, 5, None, 21),
|
||||
("Remembrance Day", 11, 11, None, 14),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
("Boxing Day", 12, 26, None, 14),
|
||||
],
|
||||
"JP": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("Hinamatsuri", 3, 3, None, 14), # 雏祭
|
||||
("Hanami", 4, 1, None, 21), # 花见(樱花季)
|
||||
("Golden Week", 4, 29, None, 21),
|
||||
("Children's Day", 5, 5, None, 14), # 子供の日
|
||||
("Tanabata", 7, 7, None, 14), # 七夕
|
||||
("Fireworks Season", 8, 1, None, 30), # 花火大会
|
||||
("Obon", 8, 13, None, 21), # お盆
|
||||
("Halloween", 10, 31, None, 30),
|
||||
("Christmas", 12, 25, None, 30),
|
||||
("New Year Eve", 12, 31, None, 14), # 大晦日
|
||||
],
|
||||
"AU": [
|
||||
("New Year", 1, 1, None, 14),
|
||||
("Australia Day", 1, 26, None, 21),
|
||||
("Valentine's Day", 2, 14, None, 21),
|
||||
("Easter", 0, 0, "easter", 21),
|
||||
("Anzac Day", 4, 25, None, 21),
|
||||
("Mother's Day (AU)", 5, 0, "mother", 14),
|
||||
("Father's Day (AU)", 9, 0, "father", 14),
|
||||
("Summer Christmas", 12, 25, None, 30),
|
||||
("Boxing Day", 12, 26, None, 21),
|
||||
("Halloween", 10, 31, None, 21),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _easter(year: int) -> datetime.date:
|
||||
a = year % 19
|
||||
b = year // 100
|
||||
c = year % 100
|
||||
d = b // 4
|
||||
e = b % 4
|
||||
f = (b + 8) // 25
|
||||
g = (b - f + 1) // 3
|
||||
h = (19 * a + b - d - g + 15) % 30
|
||||
i = c // 4
|
||||
k = c % 4
|
||||
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||
m = (a + 11 * h + 22 * l) // 451
|
||||
month = (h + l - 7 * m + 114) // 31
|
||||
day = ((h + l - 7 * m + 114) % 31) + 1
|
||||
return datetime.date(year, month, day)
|
||||
|
||||
|
||||
def _resolve(name: str, month: int, day: int, rule, year: int) -> Optional[datetime.date]:
|
||||
if rule is None:
|
||||
return datetime.date(year, month, day)
|
||||
if rule == "easter":
|
||||
return _easter(year)
|
||||
if rule in ("mother", "mother_uk"):
|
||||
# 第2个周日(UK 用"母亲节"但实际 3 月第4周日前的第4大斋期周日——简化为 3 月第2周日)
|
||||
if rule == "mother_uk":
|
||||
month, week = 3, 2
|
||||
else:
|
||||
month, week = month, 2
|
||||
first = datetime.date(year, month, 1)
|
||||
return first + datetime.timedelta(days=(6 - first.weekday()) % 7 + (week - 1) * 7)
|
||||
if rule == "father":
|
||||
first = datetime.date(year, month, 1)
|
||||
return first + datetime.timedelta(days=(6 - first.weekday()) % 7 + 2 * 7)
|
||||
if rule == "thanks":
|
||||
first = datetime.date(year, month, 1)
|
||||
return first + datetime.timedelta(days=(3 - first.weekday()) % 7 + 3 * 7)
|
||||
if rule == "bf":
|
||||
t = _resolve("", 11, 0, "thanks", year)
|
||||
return t + datetime.timedelta(days=1) if t else None
|
||||
if rule == "labor":
|
||||
first = datetime.date(year, month, 1)
|
||||
return first + datetime.timedelta(days=(0 - first.weekday()) % 7)
|
||||
return None
|
||||
|
||||
|
||||
def holidays_for(country: str = "US") -> List[tuple]:
|
||||
key = (country or "US").upper()
|
||||
return _HOLIDAYS_BY_COUNTRY.get(key, _HOLIDAYS_BY_COUNTRY["US"])
|
||||
|
||||
|
||||
def upcoming_holidays(now: Optional[datetime.date] = None, country: str = "US",
|
||||
lower: int = -10) -> List[str]:
|
||||
"""返回该国临近(未来 window 内,或刚过 lower 天内)的节日名。"""
|
||||
now = now or datetime.date.today()
|
||||
out: List[str] = []
|
||||
for name, month, day, rule, window in holidays_for(country):
|
||||
try:
|
||||
d = _resolve(name, month, day, rule, now.year)
|
||||
except Exception:
|
||||
continue
|
||||
if d is None:
|
||||
continue
|
||||
delta = (d - now).days
|
||||
if lower <= delta <= window:
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def build_holiday_context(country: str = "US",
|
||||
now: Optional[datetime.date] = None) -> Dict[str, object]:
|
||||
now = now or datetime.date.today()
|
||||
return {
|
||||
"date": now.isoformat(),
|
||||
"year": now.year,
|
||||
"month": now.month,
|
||||
"season": SEASON_BY_MONTH.get(now.month, ""),
|
||||
"month_themes": MONTH_THEMES.get(now.month, []),
|
||||
"upcoming_holidays": upcoming_holidays(now, country),
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""静态策略:直接使用 configs/countries/<country>.yaml 里写死的种子词,不做任何动态生成。"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import SeedStrategy
|
||||
|
||||
|
||||
class StaticStrategy(SeedStrategy):
|
||||
name = "static"
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
country: str,
|
||||
cc: Dict[str, Any],
|
||||
context: Dict[str, Any],
|
||||
llm_backend: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
style = list((cc.get("style", {}) or {}).get("seeds", []) or [])
|
||||
related = list((cc.get("related", {}) or {}).get("seed_keywords", []) or [])
|
||||
return {
|
||||
"style_seeds": style,
|
||||
"related_seeds": related,
|
||||
"dynamic": False,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""数据源注册表(可插拔入口)。
|
||||
|
||||
config.yaml 的 ``sources: [google_trends, pinterest]`` 决定启用哪些。
|
||||
新增数据源:实现 graph/sources/base.DataSource,在此登记即可。
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
from .base import DataSource
|
||||
from .google_trends_source import GoogleTrendsSource
|
||||
from .pinterest_source import PinterestSource
|
||||
|
||||
SOURCES: Dict[str, type] = {
|
||||
"google_trends": GoogleTrendsSource,
|
||||
"pinterest": PinterestSource,
|
||||
}
|
||||
|
||||
|
||||
def get_source(name: str) -> DataSource:
|
||||
cls = SOURCES.get(name)
|
||||
if cls is None:
|
||||
raise ValueError(f"未知数据源: {name},可用: {list(SOURCES)}")
|
||||
return cls()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""数据源抽象接口(可插拔核心)。
|
||||
|
||||
新增一个数据源只需:① 继承 DataSource 实现 fetch();② 在 graph/sources/__init__.py
|
||||
的 SOURCES 注册表里登记。config.yaml 通过 ``sources: [google_trends, pinterest]`` 决定启用哪些。
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class DataSource(ABC):
|
||||
#: 注册名(与 config.sources 中的字符串对应)
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def fetch(
|
||||
self,
|
||||
country: str,
|
||||
country_config: Dict[str, Any],
|
||||
global_config: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""抓取该国热点,返回统一格式行。
|
||||
|
||||
每行字段:``country, topic, seed, source, kind, raw_score``
|
||||
- source 标签用于后续权重与归一化(如 gt_trending / gt_style / gt_related / pinterest)
|
||||
- kind:trending / rising / top(用于按组归一化)
|
||||
- raw_score:Google Trends 相对指数 0-100,或 RSS 排名分
|
||||
|
||||
实现内部必须自行处理限流/重试/异常,返回空列表也不应抛异常到上层。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Google Trends 数据源(可插拔实现)。
|
||||
|
||||
封装 pytrends + 官方 RSS,带本地缓存、指数退避重试、urllib3 兼容补丁。
|
||||
- gt_trending:国家实时趋势榜(RSS,稳定)
|
||||
- gt_style:按国家风格种子词抓 related_queries(设计灵感)
|
||||
- gt_related:按 POD 行业种子词抓 related_queries(行业交叉验证)
|
||||
|
||||
注意:related_queries 是「单关键词」接口,一次传多个词会触发 Google /sorry(429),
|
||||
因此逐词串行 + 节流 + 快速失败。缓存按 (key, 日期) 分文件:不删除历史文件,
|
||||
24h 内读最新;超过 24h 重新抓取写当日新文件;抓取失败回退最新历史缓存兜底。
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from urllib3.util.retry import Retry as _Retry
|
||||
|
||||
# pytrends 4.x 仍用 method_whitelist;urllib3>=2 已改名 allowed_methods。做兼容补丁。
|
||||
if "method_whitelist" not in _Retry.__init__.__code__.co_varnames:
|
||||
_orig_retry_init = _Retry.__init__
|
||||
|
||||
def _patched_retry_init(self, *args, **kwargs):
|
||||
if "method_whitelist" in kwargs:
|
||||
kwargs["allowed_methods"] = kwargs.pop("method_whitelist")
|
||||
_orig_retry_init(self, *args, **kwargs)
|
||||
|
||||
_Retry.__init__ = _patched_retry_init
|
||||
|
||||
from pytrends.request import TrendReq
|
||||
|
||||
from graph.paths import runtime_root
|
||||
from .base import DataSource
|
||||
|
||||
CACHE_DIR = runtime_root() / ".cache" / "google_trends"
|
||||
CACHE_TTL = 24 * 3600
|
||||
CACHE_VERSION = "v3"
|
||||
|
||||
|
||||
def _cache_fname(key: str, date_suffix: str = "") -> str:
|
||||
digest = hashlib.md5((CACHE_VERSION + "|" + key).encode("utf-8")).hexdigest()
|
||||
return f"{digest}.{date_suffix}.json" if date_suffix else f"{digest}.json"
|
||||
|
||||
|
||||
def _cache_date(p: Path) -> datetime.date:
|
||||
"""解析文件名里的 YYYYMMDD;无日期后缀则用 mtime。"""
|
||||
for token in p.name.split("."):
|
||||
if len(token) == 8 and token.isdigit():
|
||||
try:
|
||||
return datetime.datetime.strptime(token, "%Y%m%d").date()
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return datetime.date.fromtimestamp(p.stat().st_mtime)
|
||||
except Exception:
|
||||
return datetime.date.min
|
||||
|
||||
|
||||
def _cache_paths(key: str) -> List[Path]:
|
||||
"""该 key 的所有缓存文件(含旧版无日期后缀),按日期新旧降序。"""
|
||||
digest = hashlib.md5((CACHE_VERSION + "|" + key).encode("utf-8")).hexdigest()
|
||||
files = list(CACHE_DIR.glob(f"{digest}.*.json"))
|
||||
legacy = CACHE_DIR / f"{digest}.json"
|
||||
if legacy.exists():
|
||||
files.append(legacy)
|
||||
files.sort(key=_cache_date, reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
def _cache_get(key: str):
|
||||
"""返回 24h 内有效的最新缓存;无则 None。"""
|
||||
for p in _cache_paths(key):
|
||||
try:
|
||||
fresh = (time.time() - p.stat().st_mtime) < CACHE_TTL
|
||||
except Exception:
|
||||
fresh = False
|
||||
if fresh:
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _cache_latest(key: str):
|
||||
"""取最新缓存文件内容(不限时效),用于抓取失败时的兜底(不删除缓存,取最新)。"""
|
||||
for p in _cache_paths(key):
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(key: str, data) -> None:
|
||||
"""写当日新文件(保留历史,不覆盖)。"""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
today = datetime.datetime.now().strftime("%Y%m%d")
|
||||
path = CACHE_DIR / _cache_fname(key, today)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _retry(func, max_attempts=3, base_delay=3):
|
||||
last = None
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return func()
|
||||
except Exception as e: # noqa: BLE001
|
||||
last = e
|
||||
if attempt == max_attempts - 1:
|
||||
break
|
||||
time.sleep(base_delay * (2 ** attempt))
|
||||
raise last if last else RuntimeError("retry failed")
|
||||
|
||||
|
||||
def fetch_related(keywords, geo="US", timeframe="today 3-m"):
|
||||
"""逐关键词串行请求 related_queries(单关键词接口,避免 429)。"""
|
||||
merged = {}
|
||||
for kw in keywords:
|
||||
time.sleep(3) # 节流
|
||||
|
||||
def _call(kw=kw):
|
||||
# timeout=(connect, read):pytrends 默认 connect=2s 太短,网络波动即全挂,放宽到 10/30s
|
||||
pytrends = TrendReq(hl="en-US", tz=360, retries=2, backoff_factor=0.5, timeout=(10, 30))
|
||||
pytrends.build_payload(kw_list=[kw], timeframe=timeframe, geo=geo)
|
||||
return pytrends.related_queries()
|
||||
|
||||
try:
|
||||
data = _retry(_call, max_attempts=2, base_delay=1)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[GoogleTrends] {geo} 种子「{kw}」抓取失败(跳过): {e}")
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
merged.update(data)
|
||||
return merged
|
||||
|
||||
|
||||
def parse_related(raw, geo, source="gt_related"):
|
||||
rows = []
|
||||
for kw, payload in raw.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
for kind in ("rising", "top"):
|
||||
df = payload.get(kind)
|
||||
if df is None or getattr(df, "empty", True):
|
||||
continue
|
||||
for _, r in df.iterrows():
|
||||
val = r["value"]
|
||||
if isinstance(val, str) and val.strip().lower() == "breakout":
|
||||
num = 100.0
|
||||
else:
|
||||
try:
|
||||
num = float(val)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
rows.append({
|
||||
"country": geo,
|
||||
"topic": str(r["query"]).strip(),
|
||||
"seed": kw,
|
||||
"source": source,
|
||||
"kind": kind,
|
||||
"raw_score": num,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_traffic(desc):
|
||||
m = re.search(r"([\d,]+)\+?\s*searches", desc or "", re.I)
|
||||
if m:
|
||||
try:
|
||||
return float(m.group(1).replace(",", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def fetch_trending(geo="US", limit=40):
|
||||
key = f"trending|{geo}|{limit}"
|
||||
cached = _cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
url = f"https://trends.google.com/trending/rss?geo={geo}"
|
||||
try:
|
||||
resp = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
|
||||
resp.raise_for_status()
|
||||
root = ET.fromstring(resp.content)
|
||||
rows = []
|
||||
for idx, it in enumerate(root.findall(".//item")[:limit]):
|
||||
title = (it.findtext("title") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
score = _parse_traffic(it.findtext("description"))
|
||||
if score is None:
|
||||
score = float(limit - idx)
|
||||
rows.append({
|
||||
"country": geo, "topic": title, "seed": "",
|
||||
"source": "gt_trending", "kind": "trending", "raw_score": score,
|
||||
})
|
||||
_cache_set(key, rows)
|
||||
return rows
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[GoogleTrends 趋势] {geo} 抓取失败: {e}")
|
||||
latest = _cache_latest(key)
|
||||
if latest is not None:
|
||||
print(f"[GoogleTrends 趋势] {geo} 回退最新缓存({len(latest)}条)")
|
||||
return latest
|
||||
return []
|
||||
|
||||
|
||||
def get_rows(keywords, geo="US", timeframe="today 3-m", source="gt_related"):
|
||||
key = f"{','.join(keywords)}|{geo}|{timeframe}|{source}|rows"
|
||||
cached = _cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
raw = fetch_related(keywords, geo=geo, timeframe=timeframe)
|
||||
rows = parse_related(raw, geo, source=source)
|
||||
if raw: # 有结果才写当日新缓存
|
||||
_cache_set(key, rows)
|
||||
return rows
|
||||
# 抓取无果(429/超时):回退最新历史缓存,保证流水线不中断
|
||||
latest = _cache_latest(key)
|
||||
if latest is not None:
|
||||
print(f"[GoogleTrends] {geo} 种子「{','.join(keywords)}」抓取无结果,回退最新缓存({len(latest)}行)")
|
||||
return latest
|
||||
return rows
|
||||
|
||||
|
||||
class GoogleTrendsSource(DataSource):
|
||||
name = "google_trends"
|
||||
|
||||
def fetch(self, country, country_config, global_config):
|
||||
cc = country_config or {}
|
||||
trending_cfg = cc.get("trending", {})
|
||||
style_cfg = cc.get("style", {})
|
||||
related_cfg = cc.get("related", {})
|
||||
tf = cc.get("timeframe", "today 3-m")
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
|
||||
# 1) 国家实时趋势榜(主源)
|
||||
if trending_cfg.get("enabled", True):
|
||||
limit = int(trending_cfg.get("limit", 40))
|
||||
rows.extend(fetch_trending(geo=country, limit=limit))
|
||||
|
||||
# 2) 风格种子词
|
||||
if style_cfg.get("enabled", True):
|
||||
seeds = style_cfg.get("seeds", []) or []
|
||||
if seeds:
|
||||
rows.extend(get_rows(seeds, geo=country, timeframe=tf, source="gt_style"))
|
||||
|
||||
# 3) 行业种子词
|
||||
if related_cfg.get("enabled", True):
|
||||
seeds = related_cfg.get("seed_keywords", []) or []
|
||||
if seeds:
|
||||
rows.extend(get_rows(seeds, geo=country, timeframe=tf, source="gt_related"))
|
||||
|
||||
return rows
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Pinterest 数据源(可插拔实现,默认不启用)。
|
||||
|
||||
Pinterest 官方 API v5 需要商业账号 + access token(pins:read scope),且 App 需通过 API Review。
|
||||
未配置或 Review 未过时直接返回空列表(不抛异常),由 config 的 sources 列表控制是否启用。
|
||||
|
||||
要启用:在 config.yaml 的 sources 里加入 "pinterest",并填 pinterest.token / board / query。
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import DataSource
|
||||
|
||||
|
||||
class PinterestSource(DataSource):
|
||||
name = "pinterest"
|
||||
|
||||
def fetch(self, country, country_config, global_config):
|
||||
cfg = (global_config or {}).get("pinterest", {}) or {}
|
||||
if not cfg.get("enabled", False):
|
||||
return []
|
||||
token = cfg.get("token", "")
|
||||
if not token:
|
||||
print("[Pinterest] 未配置 token(pinterest.enabled=true 但未填 token),跳过。")
|
||||
return []
|
||||
|
||||
# 官方 API v5 抓取逻辑(需商业账号 + Review 通过)。
|
||||
# 这里保留可插拔接口骨架;实际调用示例:
|
||||
# headers = {"Authorization": f"Bearer {token}"}
|
||||
# r = requests.get("https://api.pinterest.com/v5/pins/?query=...", headers=headers)
|
||||
# 因多数项目难以通过 Review,默认返回空,避免阻塞主流程。
|
||||
print("[Pinterest] 已配置但抓取逻辑未启用(需商业 Review)。返回空。")
|
||||
return []
|
||||
@@ -0,0 +1,30 @@
|
||||
"""LangGraph 共享状态定义。
|
||||
|
||||
所有节点都读写 AgentState。键全部 optional(total=False),
|
||||
因此单个节点崩溃 / 返回空时不会破坏整图,下游可用上一节点的残余数据继续。
|
||||
"""
|
||||
from typing import TypedDict, List, Dict, Any, Optional
|
||||
|
||||
|
||||
class AgentState(TypedDict, total=False):
|
||||
# —— 路由 / 配置 ——
|
||||
country: str # 当前处理的国家代码(US/GB/JP/AU)
|
||||
config: Dict[str, Any] # 全局配置(config.yaml)
|
||||
country_config: Dict[str, Any] # 该国合并后的配置(全局 + configs/countries/*.yaml + prompts/<country>/aesthetics.yaml)
|
||||
prompts_dir: str # prompts/<country> 绝对路径
|
||||
output_dir: str # output/<country> 绝对路径
|
||||
|
||||
# —— 流水线数据(逐节点累积)——
|
||||
raw_rows: List[Dict[str, Any]] # fetch 产出:各源原始行(统一格式)
|
||||
filtered_rows: List[Dict[str, Any]] # filter 产出:去黑名单/人名/泛词后
|
||||
scored_rows: List[Dict[str, Any]] # score 产出:归一化 + 融合 + 综合分
|
||||
screened: List[Dict[str, Any]] # screen 产出:合规风险 + 结构化四要素
|
||||
briefs: List[Dict[str, Any]] # prompt_build 产出:含最终 image/wearable/composite 提示词
|
||||
composite: List[Dict[str, Any]] # compose 产出:封装提示词包(= briefs 子集,便于下游印图)
|
||||
designs: List[Dict[str, Any]] # compose 产出:纯印花设计稿 [{topic, path}](product 用它做图2)
|
||||
product: List[Dict[str, Any]] # product 产出:产品图生成(SPU/SKU/底图/印花/模特合成)
|
||||
seed_words: Dict[str, Any] # seed 产出:动态种子词(含 llm_style_seeds / llm_related_seeds)
|
||||
|
||||
# —— 可观测性 ——
|
||||
errors: List[Dict[str, Any]] # 各节点兜底捕获的错误:{node, type, message, trace}
|
||||
stats: Dict[str, Any] # 各阶段统计:{fetch, filter, score, screen, prompt, compose}
|
||||
@@ -0,0 +1,340 @@
|
||||
"""动态风格 / 配色推导(领域知识,可插拔)。
|
||||
|
||||
关键:art_style 与 color_palette 不再按国家写死,而是根据「热点词本身语义」动态推导:
|
||||
① 该国专属 extra 规则(prompts/<country>/aesthetics.yaml,最具体优先)
|
||||
② 全局关键词规则 STYLE_PALETTE_RULES(覆盖更全、配色更丰富、风格更具体新颖)
|
||||
③ 确定性风格微调 STYLE_TWISTS(按主题哈希选,同词稳定、跨词不同,打破千篇一律)
|
||||
④ 按 classify 类别兜底
|
||||
⑤ 国家基调最末兜底(极少走到)
|
||||
|
||||
关键词匹配用「单词边界」,避免 eco 误中 cottagecore、kit 误中 jacket 等子串歧义。
|
||||
具体规则排在通用 retro/vintage 之前,避免 retro games / retro trainers 被笼统归为复古风。
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# 规则按「优先级」排列:越靠前越具体,命中任一关键词即采用该条(取第一个命中)。
|
||||
# 关键词匹配为单词边界。配色统一 5 色,艺术风格尽量具体+新颖。
|
||||
STYLE_PALETTE_RULES: List[Tuple[tuple, str, str]] = [
|
||||
(("skull", "death", "gothic", "horror", "zombie", "vampire", "spooky", "occult"),
|
||||
"dark gothic engraving with ornate filigree",
|
||||
"oxblood red, charcoal black, antique silver, deep purple, bone white"),
|
||||
(("metal", "rock", "band", "grunge", "punk", "emo", "anarchist"),
|
||||
"gritty risograph grunge zine",
|
||||
"muted olive, rust orange, dirty cream, faded black, safety-pin silver"),
|
||||
(("kawaii", "cute", "chibi", "sanrio"),
|
||||
"kawaii chibi sticker with thick outlines",
|
||||
"pastel pink, baby blue, mint, butter yellow, cream"),
|
||||
(("cat", "kitten"),
|
||||
"cute cat cartoon with bold outlines",
|
||||
"warm cream, soft pink, charcoal, peach, sky blue"),
|
||||
(("dog", "puppy"),
|
||||
"playful dog cartoon with wagging tail",
|
||||
"warm brown, cream, navy, tan, rust"),
|
||||
(("coffee", "cafe", "tea", "latte", "brew"),
|
||||
"cozy drink line illustration with steam curls",
|
||||
"espresso brown, cream, caramel, sage, terracotta"),
|
||||
(("mountain", "nature", "forest", "camping", "hiking", "outdoor", "wilderness", "woodland"),
|
||||
"minimal outdoor landscape with layered ridgelines",
|
||||
"forest green, slate gray, sand, cream, pine"),
|
||||
(("beach", "surf", "ocean", "summer", "tropical", "sun", "seaside"),
|
||||
"bright tropical screen-print",
|
||||
"sky blue, coral, sand, sun-bleached white, turquoise"),
|
||||
(("synthwave", "vaporwave", "retrowave"),
|
||||
"synthwave neon with grid horizon",
|
||||
"neon purple, magenta, cyan, deep navy, hot pink"),
|
||||
(("anime", "manga", "otaku", "waifu"),
|
||||
"anime-inspired flat cel with speed lines",
|
||||
"vivid cyan, magenta, white, ink black, lemon"),
|
||||
(("space", "galaxy", "star", "astro", "cosmic", "moon", "universe", "nebula"),
|
||||
"cosmic vector with nebula glow",
|
||||
"deep navy, violet, silver, starlight white, magenta"),
|
||||
(("heart", "love", "valentine", "romance", "couple"),
|
||||
"romantic badge with hand-lettered flourish",
|
||||
"rose red, blush pink, cream, gold, burgundy"),
|
||||
(("book", "reading", "library", "bookish", "novel"),
|
||||
"cozy bookish line art with marginalia",
|
||||
"warm brown, cream, forest green, oxblood, gold"),
|
||||
(("music", "song", "concert", "festival", "dj", "gig"),
|
||||
"dynamic gig poster with spotlight beams",
|
||||
"electric purple, magenta, black, neon lime, silver"),
|
||||
(("food", "pizza", "burger", "baking", "donut", "taco"),
|
||||
"appetizing flat food illustration",
|
||||
"warm red, cheese yellow, leaf green, cream, tomato"),
|
||||
(("cyber", "cyberpunk", "neon", "tech", "robot", "mecha", "glitch"),
|
||||
"neon digital with glitch grid",
|
||||
"neon magenta, cyan, electric purple, black, lime"),
|
||||
(("steam", "steampunk", "gear", "cog", "airship", "mechanical"),
|
||||
"detailed mechanical engraving with brass",
|
||||
"brass, copper, aged brown, sepia, gunmetal"),
|
||||
(("solar", "eco", "green", "sustainable", "earth", "climate"),
|
||||
"hopeful solarpunk eco with clean lines",
|
||||
"leaf green, solar gold, sky blue, terracotta, cream"),
|
||||
(("cottage", "cottagecore", "pastoral", "farm", "rustic"),
|
||||
"soft storybook watercolor with wildflowers",
|
||||
"sage green, butter yellow, dusty rose, cream, moss"),
|
||||
(("car", "automotive", "vehicle", "classic", "racer"),
|
||||
"retro automotive poster with chrome sheen",
|
||||
"cherry red, cream, chrome silver, navy, tan"),
|
||||
(("game", "gaming", "arcade", "pixel", "8-bit"),
|
||||
"8-bit pixel-art with scanlines",
|
||||
"neon green, magenta, cyan, black, yellow"),
|
||||
(("shoe", "sneaker", "trainer", "footwear", "boots"),
|
||||
"retro product sneaker illustration",
|
||||
"white, red, navy, gum-sole tan, court grey"),
|
||||
(("watch", "clock", "timepiece", "chronograph"),
|
||||
"elegant engraving with roman numerals",
|
||||
"antique gold, navy, cream, burgundy, slate"),
|
||||
(("lion", "shield", "crest", "heraldic", "crown", "queen", "king", "royal"),
|
||||
"heraldic emblem with original rampant beast",
|
||||
"royal blue, crimson, gold, cream, navy"),
|
||||
(("bee", "animal", "wildlife", "bird", "fox", "bear", "rabbit"),
|
||||
"charming zoological illustration",
|
||||
"honey gold, charcoal, leaf green, cream, rust"),
|
||||
(("map", "city", "london", "travel", "trip", "skyline", "landmark"),
|
||||
"mid-century travel poster with skyline",
|
||||
"royal red, teal, navy, cream, mustard"),
|
||||
(("rain", "weather", "cloud", "moody", "storm", "fog"),
|
||||
"moody rain illustration with droplets",
|
||||
"slate blue, pewter, cream, oxblood, charcoal"),
|
||||
(("witch", "magic", "halloween", "wizard", "spell"),
|
||||
"whimsical witchy illustration with moon",
|
||||
"deep purple, black, moss green, gold, amethyst"),
|
||||
(("christmas", "xmas", "santa", "snowflake", "festive"),
|
||||
"festive christmas with needle-felt texture",
|
||||
"pine green, berry red, cream, gold, ice blue"),
|
||||
(("sport", "gym", "football", "soccer", "baseball", "workout", "jersey", "kit", "athletic", "england"),
|
||||
"bold athletic emblem with motion streaks",
|
||||
"navy, white, athletic red, silver, volt green"),
|
||||
(("patriotic", "flag", "america", "freedom", "usa"),
|
||||
"bold patriotic emblem with stars",
|
||||
"navy, red, cream, gold, slate"),
|
||||
(("retro", "80s", "90s", "y2k", "memphis"),
|
||||
"1980s Memphis pop with geometric confetti",
|
||||
"faded navy, cream, burnt orange, hot pink, teal"),
|
||||
(("vintage", "antique", "distressed", "classic", "aged"),
|
||||
"aged letterpress vintage with halftone",
|
||||
"faded sepia, cream, muted teal, oxblood, distressed black"),
|
||||
]
|
||||
|
||||
# 预编译单词边界正则,避免 eco 误中 cottagecore、kit 误中 jacket 等子串歧义。
|
||||
# 每个关键词附加可选复数 (?:s)?,覆盖 games/cats 等复数形态。
|
||||
def _pattern_from(kws: List[str]) -> "re.Pattern":
|
||||
return re.compile(r"\b(?:" + "|".join(re.escape(k) + "(?:s)?" for k in kws) + r")\b")
|
||||
|
||||
|
||||
_RULE_PATTERNS = [
|
||||
(_pattern_from(list(kws)), art, pal)
|
||||
for kws, art, pal in STYLE_PALETTE_RULES
|
||||
]
|
||||
|
||||
|
||||
def _kw_pattern(kws: List[str]) -> "re.Pattern":
|
||||
return _pattern_from(kws)
|
||||
|
||||
|
||||
# 风格微调池:确定性地给每条设计追加一个“技法/质感”修饰,提升新颖度与差异度。
|
||||
# 选择基于主题的稳定哈希,保证「同词稳定、跨词不同」(不依赖进程随机种子)。
|
||||
STYLE_TWISTS: List[str] = [
|
||||
"with subtle risograph grain and slight misregistration",
|
||||
"with bold halftone dot shading",
|
||||
"with hand-drawn imperfect ink edges",
|
||||
"with limited-palette screen-print separation",
|
||||
"with fine stipple and engraving texture",
|
||||
"with paper-cut layered depth",
|
||||
"with art-deco geometric framing",
|
||||
"with Memphis-style confetti shapes",
|
||||
"with soft watercolor bleed at the edges",
|
||||
"with iridescent foil accent",
|
||||
]
|
||||
|
||||
|
||||
def _stable_hash(text: str) -> int:
|
||||
"""跨进程稳定的字符串哈希(不依赖 PYTHONHASHSEED)。"""
|
||||
return int(hashlib.md5(text.encode("utf-8")).hexdigest(), 16)
|
||||
|
||||
|
||||
# 类别兜底:关键词都没命中时,按 classify 类别给一个合理风格/配色(含 5 色)
|
||||
STYLE_BY_CATEGORY: Dict[str, str] = {
|
||||
"Event": "festive badge illustration",
|
||||
"Meme": "bold comic meme illustration",
|
||||
"Style": "trendy flat vector illustration",
|
||||
"Niche": "clean modern vector illustration",
|
||||
"Pattern": "seamless pattern tile illustration",
|
||||
"Quote": "bold typographic illustration",
|
||||
}
|
||||
PALETTE_BY_CATEGORY: Dict[str, str] = {
|
||||
"Event": "festive multi-color: red, gold, forest green, cream, berry",
|
||||
"Meme": "bold high-contrast: black, white, pop yellow, magenta, cyan",
|
||||
"Style": "trendy balanced modern: navy, coral, cream, sage, slate",
|
||||
"Niche": "versatile balanced: teal, sand, charcoal, blush, white",
|
||||
"Pattern": "harmonious repeating: terracotta, olive, cream, rust, gold",
|
||||
"Quote": "high-contrast typographic: ink black, off-white, accent red, gold",
|
||||
}
|
||||
|
||||
# 国家基调最末兜底(代码内置;真正按国家定制走 prompts/<country>/aesthetics.yaml)
|
||||
COUNTRY_AESTHETICS: Dict[str, Dict[str, str]] = {
|
||||
"US": {
|
||||
"label": "美国",
|
||||
"style_hint": "Bold vintage / retro Americana, humorous and punchy, high-contrast poster style.",
|
||||
"art_style": "bold vintage retro Americana poster, clean vector",
|
||||
"palette": "muted retro Americana palette: faded navy, cream, burnt orange, distressed black, mustard",
|
||||
},
|
||||
"GB": {
|
||||
"label": "英国",
|
||||
"style_hint": "Witty, self-deprecating British humor; punk / bold lettering; tea-and-rain mood.",
|
||||
"art_style": "witty punk zine illustration, bold hand-lettered",
|
||||
"palette": "punk-zine palette: high-contrast black, off-white, safety-orange, oxblood red, slate",
|
||||
},
|
||||
"JP": {
|
||||
"label": "日本",
|
||||
"style_hint": "Kawaii / minimalist / anime-inspired; clean lines, Tokyo street edge, original kanji accents.",
|
||||
"art_style": "kawaii minimalist flat illustration, clean lines",
|
||||
"palette": "soft kawaii palette: pastel pink, mint, butter yellow, soft lavender, cream",
|
||||
},
|
||||
"AU": {
|
||||
"label": "澳大利亚",
|
||||
"style_hint": "Sunny, laid-back coastal vibe; surf / beach / BBQ; relaxed and warm.",
|
||||
"art_style": "sunny laid-back coastal illustration, relaxed",
|
||||
"palette": "sunny coastal palette: sky blue, sand beige, coral, sun-bleached white, turquoise",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def derive_style_palette(
|
||||
topic: str,
|
||||
country: str,
|
||||
extra_rules: Optional[List[Dict[str, str]]] = None,
|
||||
category: Optional[str] = None,
|
||||
apply_twist: bool = True,
|
||||
) -> Tuple[str, str]:
|
||||
"""根据热点词语义确定性地推导 (art_style, color_palette)。
|
||||
|
||||
优先级:① 国家 extra 规则(最具体)→ ② 全局关键词规则 → ③ 类别兜底 → ④ 国家基调最末兜底。
|
||||
结果与具体词一一对应,跨词不同、同词稳定。apply_twist=True 时追加确定性风格微调。
|
||||
"""
|
||||
from .classify import classify
|
||||
|
||||
tl = (topic or "").lower()
|
||||
cat = category or classify(topic)
|
||||
|
||||
# ① 国家专属 extra 规则(来自 prompts/<country>/aesthetics.yaml)
|
||||
for rule in (extra_rules or []):
|
||||
kws = [k.lower() for k in (rule.get("keywords") or [])]
|
||||
if kws and _kw_pattern(kws).search(tl):
|
||||
art = rule.get("art_style", "clean vector illustration")
|
||||
pal = rule.get("color_palette", "balanced modern palette")
|
||||
if apply_twist:
|
||||
art = _apply_twist(art, topic)
|
||||
return art, pal
|
||||
|
||||
# ② 全局关键词规则(单词边界匹配,具体规则优先于通用 retro/vintage)
|
||||
for pat, art, pal in _RULE_PATTERNS:
|
||||
if pat.search(tl):
|
||||
if apply_twist:
|
||||
art = _apply_twist(art, topic)
|
||||
return art, pal
|
||||
|
||||
# ③ 类别兜底
|
||||
art = STYLE_BY_CATEGORY.get(cat, "clean vector illustration")
|
||||
pal = PALETTE_BY_CATEGORY.get(cat, "balanced modern color palette")
|
||||
|
||||
# ④ 国家基调最末兜底(极少走到)
|
||||
if art == "clean vector illustration":
|
||||
a = COUNTRY_AESTHETICS.get(country, {})
|
||||
art = a.get("art_style", art)
|
||||
pal = a.get("palette", pal)
|
||||
|
||||
if apply_twist:
|
||||
art = _apply_twist(art, topic)
|
||||
return art, pal
|
||||
|
||||
|
||||
def _apply_twist(art: str, topic: str) -> str:
|
||||
"""确定性地给 art_style 追加一个技法微调(同词稳定、跨词不同)。"""
|
||||
twist = STYLE_TWISTS[_stable_hash(topic) % len(STYLE_TWISTS)]
|
||||
return f"{art}, {twist}"
|
||||
|
||||
|
||||
# 构图变体池:与风格 twist 同理,确定性地为每条设计选一个差异化构图,打破千篇一律。
|
||||
COMPOSITION_VARIANTS: List[str] = [
|
||||
"centered circular emblem with balanced negative space",
|
||||
"all-over repeat pattern with a centered focal badge",
|
||||
"side-profile hero with motion lines",
|
||||
"symmetrical mandala emblem, centered",
|
||||
"scattered botanical border framing a centered wreath",
|
||||
"bold central emblem with spray texture",
|
||||
"layered landscape with a centered focal subject",
|
||||
"centered badge inside a decorative ring",
|
||||
"dynamic diagonal composition with speed streaks",
|
||||
"tiled geometric grid with a centered motif",
|
||||
"vertical stack emblem with a banner ribbon",
|
||||
"framed portrait window with ornate border",
|
||||
]
|
||||
|
||||
|
||||
def derive_composition(topic: str, category: Optional[str] = None) -> str:
|
||||
"""确定性地推导构图(同词稳定、跨词不同),避免所有设计共用同一句构图。"""
|
||||
return COMPOSITION_VARIANTS[_stable_hash(topic) % len(COMPOSITION_VARIANTS)]
|
||||
|
||||
|
||||
# 图像生成策略敏感词 → 安全等效描述(生成提示词前清洗,降低内容政策拦截)
|
||||
_IMG_RISKY_SWAP = {
|
||||
"skull": "smiley mascot", "skeleton": "cute mascot", "blood": "red accents",
|
||||
"gore": "bold shapes", "gun": "star", "weapon": "tool", "bomb": "firework",
|
||||
"drug": "confetti", "demon": "cute monster", "devil": "mischievous imp",
|
||||
"occult": "mystic pattern", "satanic": "dark pattern", "nazi": "retro emblem",
|
||||
"hitler": "retro emblem", "zombie": "friendly ghoul", "horror": "spooky-cute",
|
||||
"vampire": "night owl", "politics": "abstract shapes", "political": "abstract",
|
||||
"president": "captain", "army": "team", "police": "officer",
|
||||
}
|
||||
|
||||
|
||||
def sanitize_image_prompt(prompt: str) -> str:
|
||||
"""清洗生图提示词:
|
||||
1) 删除一切背景描述(官方要求:透明背景由 background="transparent" 参数控制,
|
||||
提示词中不得提到背景,否则无法正常生成透明背景);
|
||||
2) 敏感词替换为安全等效描述(避免内容政策拦截)。
|
||||
"""
|
||||
import re
|
||||
out = prompt or ""
|
||||
# 1) 删除背景短语(中英文都处理)
|
||||
for pat in (r",\s*isolated on (transparent|pure white|white) background\b",
|
||||
r"\s+isolated on (transparent|pure white|white) background\b",
|
||||
r",\s*(transparent|white) background\b",
|
||||
r"\s+on a (transparent|white|pure white) background\b",
|
||||
r",\s*no background scene\b", r",\s*no background\b", r",\s*plain (transparent|white) background\b"):
|
||||
out = re.sub(pat, "", out, flags=re.IGNORECASE)
|
||||
# 1.5) 删除内容策略触发段(旧模板残留的安全规则说明:no politics/religion/hate/violence/sexual 等
|
||||
# 一旦出现在生图提示词中,图像 API 直接 content_policy_violation)
|
||||
out = re.sub(r';?\s*any text must be safe[^;]*?(?:no gibberish|gibberish|\.[^,;]*)', '', out, flags=re.IGNORECASE)
|
||||
out = re.sub(r'no (politics|religion|hate|violence|sexual|nude|nudity|bikini|nsfw|racist|profanity|swearing|adult content)s?,?', '', out, flags=re.IGNORECASE)
|
||||
out = re.sub(r'(politics|religion|hate|violence|sexual content|nudity|nsfw)', '', out, flags=re.IGNORECASE)
|
||||
# 2) 敏感词替换
|
||||
low = out.lower()
|
||||
for k, v in _IMG_RISKY_SWAP.items():
|
||||
if k in low:
|
||||
out = re.sub(rf"\b{re.escape(k)}\b", v, out, flags=re.IGNORECASE)
|
||||
low = out.lower()
|
||||
# 3) 清理多余空格/逗号
|
||||
out = re.sub(r",\s*,+", ",", out)
|
||||
out = re.sub(r"\s{2,}", " ", out).strip(" ,")
|
||||
return out
|
||||
|
||||
|
||||
# review(疑似商标/受保护主题)简报的「原创化魔改」引导:只做风格参考,禁止复刻商标/品牌/角色
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
def ensure_rebrand_hint(brief: dict, prompt: str) -> str:
|
||||
"""review 简报生成设计时兜底追加原创化魔改引导(旧缓存简报未注入时补上)。"""
|
||||
if str(brief.get("risk_level", "")).strip().lower() == "review" \
|
||||
and "IMPORTANT: this theme is ONLY" not in (prompt or ""):
|
||||
return (prompt or "") + REVIEW_REBRAND_HINT
|
||||
return prompt or ""
|
||||
@@ -0,0 +1,442 @@
|
||||
"""商品上传模板导出:从 db 读 SPU/SKU → 调 template_router 路由填入上传模板 Excel。
|
||||
|
||||
流程(product_node 生成产品图后调用):
|
||||
1. 从 spu_sku.db 读 SPU(款号)+ 该款选定颜色的全部尺码 SKU;
|
||||
2. 用 model/template_router.py 的 TemplateRouter:
|
||||
- insert 一行 SPU(SPU货号 + 商品属性字段)
|
||||
- 每个尺码 insert 一行 SKU(路由到 SPU 行下方,SKU货号 = 款号-颜色编码-尺码,填尺码表)
|
||||
- 商品轮播图1~N 填生成的产品图路径(底图/印花/模特/合成)
|
||||
3. save 输出 <模板名>_已填写.xlsx 到指定目录。
|
||||
|
||||
字段映射:db 字段名 → 上传模板列名(见 SPU_MAP / SKU_MAP)。
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from graph.product import _connect
|
||||
|
||||
# 商品轮播图列名关键词(模板存在中/英/日变体,如 商品轮播图1 / Product Carousel Image 1 / 商品カルーセル画像1)
|
||||
_CAROUSEL_KW = ("轮播", "carousel", "カルーセル")
|
||||
|
||||
|
||||
def _carousel_col(router, idx: int) -> Optional[int]:
|
||||
"""定位「商品轮播图{idx}」列号:先精确匹配(中文列名),失败则按中/英/日关键词模糊匹配序号。"""
|
||||
try:
|
||||
return router.resolve_col(f"商品轮播图{idx}")
|
||||
except KeyError:
|
||||
pass
|
||||
for name, col in router.column_map.items():
|
||||
low = str(name).lower().replace(" ", "").replace(" ", "")
|
||||
if not any(k in low for k in _CAROUSEL_KW):
|
||||
continue
|
||||
m = re.search(r"(\d+)$", low)
|
||||
if m and int(m.group(1)) == idx:
|
||||
return col
|
||||
return None
|
||||
|
||||
|
||||
def _detail_col(router) -> Optional[int]:
|
||||
"""定位「详情图文」列:优先英语(详情图文-英语),回退日语(详情图文-日语),再回退任意详情图文。"""
|
||||
for name in ("详情图文-英语", "详情图文-英文", "详情图文-EN"):
|
||||
try:
|
||||
return router.resolve_col(name)
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
return router.resolve_col("详情图文-日语")
|
||||
except KeyError:
|
||||
pass
|
||||
for name, col in router.column_map.items():
|
||||
if "详情图文" in str(name):
|
||||
return col
|
||||
return None
|
||||
|
||||
|
||||
def _ja_col(router) -> Optional[int]:
|
||||
"""定位「日语名称」列(基础信息组,如 日语名称/日语标题)。"""
|
||||
try:
|
||||
return router.resolve_col("日语名称")
|
||||
except KeyError:
|
||||
pass
|
||||
for name, col in router.column_map.items():
|
||||
low = str(name)
|
||||
if "日语" in low and "详情图文" not in low and "轮播图" not in low and "名称" in low:
|
||||
return col
|
||||
return None
|
||||
|
||||
|
||||
def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_title: str,
|
||||
ja_title: str, composite_by_sku: Dict[str, Any],
|
||||
all_composite_urls: List[str], seed_shot_urls: List[str],
|
||||
only_rows: Optional[List[int]] = None) -> None:
|
||||
"""按用户要求填充设计联动字段:
|
||||
- SPU 行:SPU货号=设计货号、SKU货号=设计货号、商品名称=cn_title、英文名称=en_title、
|
||||
日语名称=ja_title、商品轮播图1=随机一张三合一主图、详情图文=全部主图+种草图链接 | 分割
|
||||
- SKU 行:SPU货号=设计货号、SKU货号=该颜色货号、商品轮播图1=该颜色三合一链接、
|
||||
商品名称/英文名称/日语名称 与 SPU 一致
|
||||
only_rows:合并模式下只填充本产品块的行(None=该款全部行)
|
||||
"""
|
||||
import random
|
||||
try:
|
||||
color_col = router.resolve_col("色值(主规格)")
|
||||
except KeyError:
|
||||
color_col = None
|
||||
try:
|
||||
spu_col = router.resolve_col("SPU货号")
|
||||
except KeyError:
|
||||
spu_col = None
|
||||
try:
|
||||
name_col = router.resolve_col("商品名称")
|
||||
except KeyError:
|
||||
name_col = None
|
||||
try:
|
||||
en_col = router.resolve_col("英文名称")
|
||||
except KeyError:
|
||||
en_col = None
|
||||
try:
|
||||
ja_col = _ja_col(router)
|
||||
except KeyError:
|
||||
ja_col = None
|
||||
try:
|
||||
sku_code_col = router.resolve_col("SKU货号")
|
||||
except KeyError:
|
||||
sku_code_col = None
|
||||
car1 = _carousel_col(router, 1)
|
||||
detail_col = _detail_col(router)
|
||||
|
||||
for row in router.find_spu_rows(spu_code):
|
||||
if only_rows is not None and row not in only_rows:
|
||||
continue # 合并模式:只填本产品块的行
|
||||
lvl = str(router.ws.cell(row, 1).value or "").strip().lower()
|
||||
color = str(router.ws.cell(row, color_col).value or "").strip() if color_col else ""
|
||||
if lvl == "spu":
|
||||
if spu_col and oss_code:
|
||||
router.ws.cell(row, spu_col, oss_code)
|
||||
if sku_code_col and oss_code:
|
||||
router.ws.cell(row, sku_code_col, oss_code)
|
||||
if name_col and cn_title:
|
||||
router.ws.cell(row, name_col, cn_title)
|
||||
if en_col and en_title:
|
||||
router.ws.cell(row, en_col, en_title)
|
||||
if ja_col and ja_title:
|
||||
router.ws.cell(row, ja_col, ja_title)
|
||||
if car1 is not None and all_composite_urls:
|
||||
router.ws.cell(row, car1, random.choice(all_composite_urls)) # SPU 轮播图1 随机
|
||||
if detail_col is not None:
|
||||
links = [u for u in (all_composite_urls + list(seed_shot_urls or [])) if u]
|
||||
if links:
|
||||
router.ws.cell(row, detail_col, "|".join(links)) # 详情图文 | 分割
|
||||
else:
|
||||
if spu_col and oss_code:
|
||||
router.ws.cell(row, spu_col, oss_code)
|
||||
# SKU 行与 SPU 一致:商品名称/英文名称/日语名称
|
||||
if name_col and cn_title:
|
||||
router.ws.cell(row, name_col, cn_title)
|
||||
if en_col and en_title:
|
||||
router.ws.cell(row, en_col, en_title)
|
||||
if ja_col and ja_title:
|
||||
router.ws.cell(row, ja_col, ja_title)
|
||||
cc = composite_by_sku.get(color) or composite_by_sku.get("") # 按色值匹配该颜色主图
|
||||
if sku_code_col and oss_code:
|
||||
router.ws.cell(row, sku_code_col, oss_code) # SKU货号=SPU货号(同一货号)
|
||||
if car1 is not None and cc and cc.get("url"):
|
||||
router.ws.cell(row, car1, cc["url"]) # 该颜色轮播图1
|
||||
|
||||
|
||||
def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str,
|
||||
color: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=经营站点;多颜色时用色值列区分)。"""
|
||||
row: Dict[str, Any] = {
|
||||
"基础信息-商品层级": "spu",
|
||||
"SKC货号": spu_code, # code 路由为 SKC货号(用户要求)
|
||||
"风格": "休闲", # style 路由为"休闲"(用户要求)
|
||||
"商品产地": origin_province, # 产地省份不用填,经营站点填到「商品产地」
|
||||
}
|
||||
if color:
|
||||
row["色值(主规格)"] = color
|
||||
for dbk, header in SPU_MAP.items():
|
||||
v = spu.get(dbk)
|
||||
if v not in (None, ""):
|
||||
row[header] = v
|
||||
return row
|
||||
|
||||
|
||||
def _find_price_header(router) -> str:
|
||||
"""定位价格列表头:任意含「申报价格」的列(美站/日站/英站…模糊匹配);找不到回退默认。"""
|
||||
for k in router.column_map:
|
||||
if "申报价格" in str(k):
|
||||
return str(k)
|
||||
return "申报价格-日本站"
|
||||
|
||||
|
||||
def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: str,
|
||||
warehouses: List[str], markup_percent: float = 0.0,
|
||||
multi: bool = True, price_header: str = "申报价格-日本站") -> Dict[str, Any]:
|
||||
"""构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。
|
||||
价格(price_header 列,如 申报价格-美国站/日本站,模糊匹配)= SKU.price × (1+markup/100),预先填好。
|
||||
规格类型2 统一填「尺码」两个字(不是 size 参数值)。"""
|
||||
row: Dict[str, Any] = {
|
||||
"基础信息-商品层级": "sku",
|
||||
"SPU货号": spu_code,
|
||||
"SKC货号": sk.get("code") or sc, # SKC货号 = SKU 的 code(款号-颜色编码)
|
||||
"色值(主规格)": color,
|
||||
"规格类型2": "尺码", # 规格类型2 统一填「尺码」(不填 size 值)
|
||||
"币种": "CNY",
|
||||
}
|
||||
for j, w in enumerate(warehouses, start=1):
|
||||
row[f"发货仓{j}"] = w
|
||||
row[f"发货仓{j}库存"] = 200
|
||||
for dbk, header in SKU_MAP.items():
|
||||
if dbk == "color":
|
||||
continue
|
||||
if dbk == "price":
|
||||
header = price_header # 模糊匹配的实际价格列(申报价格-美站/日站/英站…)
|
||||
v = sk.get(dbk)
|
||||
if dbk == "price" and v not in (None, ""):
|
||||
v = round(float(v) * (1 + markup_percent / 100), 2) # 申报价格 = price × (1+加价%)
|
||||
if v not in (None, ""):
|
||||
row[header] = v
|
||||
return row
|
||||
|
||||
|
||||
def _fill_sku_carousel(router, spu_code: str, color: str, color_col: int,
|
||||
first: Dict[str, Any], sku_imgs: List[str]) -> None:
|
||||
"""SKU 行商品轮播图2~5:db img_url_2~5 优先,无则回退生成图;按色值列匹配所属 SKU 行。"""
|
||||
for j in range(2, 6):
|
||||
url = first.get(f"img_url_{j}") # db CDN url(该颜色 SKU 的 img_url_2~5)
|
||||
img = url if url not in (None, "") else (sku_imgs[j - 2] if j - 2 < len(sku_imgs) else None)
|
||||
if not img:
|
||||
continue
|
||||
col = _carousel_col(router, j)
|
||||
if col is None:
|
||||
continue
|
||||
for row in router.find_spu_rows(spu_code):
|
||||
if (str(router.ws.cell(row, 1).value or "").strip().lower() == "sku"
|
||||
and str(router.ws.cell(row, color_col).value or "").strip() == color):
|
||||
router.ws.cell(row, col, str(img))
|
||||
|
||||
# db SPU 字段 -> 上传模板列名
|
||||
SPU_MAP: Dict[str, str] = {
|
||||
"code": "SPU货号",
|
||||
"material": "材质",
|
||||
"component_1": "成分1",
|
||||
"component_proportion_1": "成分1成分比例",
|
||||
"component_2": "成分2",
|
||||
"component_proportion_2": "成分2成分比例",
|
||||
"component_3": "成分3",
|
||||
"component_proportion_3": "成分3成分比例",
|
||||
"pattern": "图案",
|
||||
"details": "细节",
|
||||
"collar_style": "领型",
|
||||
"care_Instructions": "护理说明",
|
||||
"fabric": "面料",
|
||||
"target_audience": "适用人群",
|
||||
"season": "季节",
|
||||
"is_transparent": "是否透明",
|
||||
"layout": "版型",
|
||||
"weaving_method": "织造方式",
|
||||
"printing_type": "印花类型",
|
||||
"fabric_texture_1": "面料纹理1",
|
||||
"fabric_weight_1": "面料克重1(g/m²)",
|
||||
"fabric_weight_unit_1": "面料克重1(g/m²)单位",
|
||||
"lining_texture": "里料纹理",
|
||||
}
|
||||
|
||||
# db SKU 字段 -> 上传模板列名
|
||||
SKU_MAP: Dict[str, str] = {
|
||||
"color": "色值(主规格)",
|
||||
"size": "尺码",
|
||||
"size_group": "尺码组别",
|
||||
"size_type": "尺码类型",
|
||||
"shoulder_width": "肩宽(cm)",
|
||||
"bust": "胸围全围(cm)",
|
||||
"clothing_length": "衣长(cm)",
|
||||
"sleeve_length": "袖长(cm)",
|
||||
"longest_side": "最长边(cm)",
|
||||
"secondary_long_side": "次长边(cm)",
|
||||
"shortest_side": "最短边(cm)",
|
||||
"package_weight": "重量(g)", # 包装重量:从 db 提取
|
||||
"price": "申报价格-日本站",
|
||||
}
|
||||
|
||||
|
||||
def _read_spu(db_path, spu_code: str) -> Optional[Dict[str, Any]]:
|
||||
conn = _connect(db_path)
|
||||
row = conn.execute("SELECT * FROM SPU WHERE code = ?", (spu_code,)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _read_meta(router) -> tuple:
|
||||
"""读模板顶头元信息:经营站点(第2行第1列)、发货仓(第2行第2列)。
|
||||
|
||||
返回 (origin_province, warehouses):
|
||||
- origin_province:经营站点去掉末尾「站」(如「日本站」→「日本」)
|
||||
- warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个)
|
||||
"""
|
||||
ws = router.ws
|
||||
site = str(ws.cell(2, 1).value or "").strip()
|
||||
origin_province = site[:-1] if site.endswith("站") else site
|
||||
raw = str(ws.cell(2, 2).value or "").strip()
|
||||
warehouses = [w.strip() for w in raw.split("、") if w.strip()]
|
||||
return origin_province, warehouses
|
||||
|
||||
|
||||
def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]:
|
||||
"""该款该颜色的全部尺码 SKU。"""
|
||||
conn = _connect(db_path)
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, p.code AS spu_code FROM SKU s
|
||||
JOIN SPU p ON s.spu_id = p.id
|
||||
WHERE p.code = ? AND s.code = ?
|
||||
ORDER BY s.size""", (spu_code, sku_code)).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def export_product(
|
||||
db_path,
|
||||
spu_code: str,
|
||||
sku_code, # str | List[str]:单颜色或多个颜色
|
||||
template_dir: str,
|
||||
template_path: str,
|
||||
out_path: str,
|
||||
images: Optional[List[str]] = None,
|
||||
spu_per_color: bool = True,
|
||||
oss_code: str = "",
|
||||
cn_title: str = "",
|
||||
en_title: str = "",
|
||||
ja_title: str = "",
|
||||
composite_urls: Optional[List[Dict[str, Any]]] = None,
|
||||
seed_shot_urls: Optional[List[str]] = None,
|
||||
append_to: str = "",
|
||||
markup_percent: float = 0.0,
|
||||
) -> Path:
|
||||
"""生成商品上传"已填写"模板(支持多产品合并到同一文件)。
|
||||
|
||||
sku_code :SKU 颜色编码,支持单个 str 或多个(list/tuple/逗号分隔字符串)。
|
||||
spu_per_color :True(默认)= 每个颜色导出一个 SPU 块;False = 单 SPU 下挂所有颜色 SKU 变体。
|
||||
template_dir :template_router.py 所在目录(用于 import)
|
||||
template_path :商品上传模版 xlsx 路径
|
||||
images :生成的产品图路径列表(仅作用于第一个颜色块:SPU 行轮播图1 + SKU 行回退)
|
||||
oss_code :设计货号(前缀+3位计数),SPU货号/SKU货号 列均填它
|
||||
cn_title :商品名称(中文标题)
|
||||
en_title :英文名称(英文标题)
|
||||
ja_title :日语名称(日语标题,JP 模板生成)
|
||||
composite_urls:[{"sku_code","color","url","code"}] 每色三合一主图(含图床链接与货号)
|
||||
seed_shot_urls :种草图图床链接列表(详情图文 | 拼接用)
|
||||
append_to :已有输出文件路径;提供则在其基础上追加本产品块(一次任务多产品合并一个模板)
|
||||
markup_percent :加价百分比,申报价格 = SKU.price × (1+markup/100) 预填
|
||||
返回输出文件路径。
|
||||
"""
|
||||
# 1) 读 db(支持单/多颜色)
|
||||
spu = _read_spu(db_path, spu_code)
|
||||
if spu is None:
|
||||
raise ValueError(f"SPU {spu_code} 不存在于 db")
|
||||
if isinstance(sku_code, str) and "," in sku_code:
|
||||
sku_codes = [s.strip() for s in sku_code.split(",") if s.strip()]
|
||||
elif isinstance(sku_code, (list, tuple)):
|
||||
sku_codes = list(sku_code)
|
||||
else:
|
||||
sku_codes = [sku_code]
|
||||
skus_by_color: List[tuple] = []
|
||||
for sc in sku_codes:
|
||||
skus = _read_skus(db_path, spu_code, sc)
|
||||
if not skus:
|
||||
raise ValueError(f"SKU {sc} 不存在于 db(款号 {spu_code})")
|
||||
skus_by_color.append((sc, skus))
|
||||
images = [str(i) for i in (images or []) if i]
|
||||
|
||||
# 2) import template_router(优先 config 的 template_dir;打包后回退 _MEIPASS/model)
|
||||
tdir = Path(template_dir)
|
||||
candidates = [tdir]
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
candidates.append(Path(meipass) / "model")
|
||||
# 用户上传的模板可能在任意目录(无 template_router.py),兜底项目自带 templates/
|
||||
from graph.paths import project_root as _proj_root
|
||||
candidates.append(_proj_root() / "templates")
|
||||
for d in candidates:
|
||||
if d.exists() and str(d) not in sys.path:
|
||||
sys.path.insert(0, str(d))
|
||||
from template_router import TemplateRouter # noqa: E402
|
||||
|
||||
# append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板)
|
||||
router = TemplateRouter(append_to if append_to else template_path)
|
||||
try:
|
||||
origin_province, warehouses = _read_meta(router)
|
||||
price_header = _find_price_header(router) # 申报价格列(美站/日站/英站…模糊匹配)
|
||||
multi = len(skus_by_color) > 1
|
||||
color_col = router.resolve_col("色值(主规格)")
|
||||
block_rows: List[int] = [] # 本产品块插入的所有行号(_fill_design_fields 只填这些行)
|
||||
|
||||
if spu_per_color:
|
||||
# 3) 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充)
|
||||
# + 全部颜色尺码 SKU 行(色值在 SKU 行区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province),
|
||||
match="exact",
|
||||
))
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
first = skus[0]
|
||||
color = first.get("color") or sc
|
||||
|
||||
# 该颜色全部尺码 SKU(SKU 行 SPU货号/SKU货号=spu_code,色值区分)
|
||||
for i, sk in enumerate(skus):
|
||||
size = sk.get("size") or f"{i+1}"
|
||||
block_rows.append(router.insert(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=True,
|
||||
price_header=price_header),
|
||||
spu_code=spu_code, match="exact",
|
||||
))
|
||||
|
||||
# 3.3) 轮播图:首色 SKU 行轮播图1 = 生成首图;SKU 行按色值填 db url/生成图
|
||||
if ci == 0 and images:
|
||||
col1 = _carousel_col(router, 1)
|
||||
if col1 is not None:
|
||||
sku_rows = router.find_sku_rows(spu_code)
|
||||
if sku_rows:
|
||||
router.ws.cell(min(sku_rows), col1, str(images[0]))
|
||||
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
|
||||
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
|
||||
else:
|
||||
# 4) 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分)
|
||||
block_rows.append(router.insert(
|
||||
_build_spu_row(spu, spu_code, origin_province), match="exact"))
|
||||
multi_variant = len(skus_by_color) > 1
|
||||
for ci, (sc, skus) in enumerate(skus_by_color):
|
||||
first = skus[0]
|
||||
color = first.get("color") or sc
|
||||
for i, sk in enumerate(skus):
|
||||
size = sk.get("size") or f"{i+1}"
|
||||
block_rows.append(router.insert(
|
||||
_build_sku_row(spu_code, sc, sk, size, color, warehouses,
|
||||
markup_percent=markup_percent, multi=multi_variant,
|
||||
price_header=price_header),
|
||||
))
|
||||
sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else []
|
||||
_fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs)
|
||||
# 无 SPU 行:首图(轮播图1)由 _fill_design_fields 按 SKU 行填充
|
||||
|
||||
# 5) 设计联动字段:货号/标题/轮播图路由/详情图文(图床链接,| 分割)
|
||||
if oss_code or cn_title or en_title or ja_title or composite_urls:
|
||||
by_sku: Dict[str, Any] = {}
|
||||
all_urls: List[str] = []
|
||||
for cc in (composite_urls or []):
|
||||
if cc.get("color"):
|
||||
by_sku[str(cc["color"]).strip()] = cc
|
||||
if cc.get("url"):
|
||||
all_urls.append(str(cc["url"]))
|
||||
_fill_design_fields(router, spu_code, oss_code, cn_title, en_title, ja_title,
|
||||
by_sku, all_urls, seed_shot_urls or [], only_rows=block_rows)
|
||||
|
||||
out = router.save(out_path)
|
||||
return Path(out)
|
||||
finally:
|
||||
try:
|
||||
router.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,144 @@
|
||||
"""固定提示词模板(规则写死,保证每条一致)+ 装配函数。
|
||||
|
||||
所有最终提示词都由四要素(motif / art_style / color_palette / composition)
|
||||
用下面的模板确定性拼出,LLM 不再自由发挥,因此结构永远一致、可复用于 img2img。
|
||||
|
||||
v3:模板按国家区分(COUNTRY_TEMPLATES),每国有自己的设计风格引导段;
|
||||
顶层 DEFAULT_TEMPLATES 作为兜底。文字规则统一:英文可加可不加、适配印花即可,
|
||||
任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物等敏感内容。
|
||||
"""
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# —— 通用固定段(所有国家共用,保证结构一致)——
|
||||
# 尺寸规则:最小约 15×18cm ~ 最大 26×32cm 之间自由选择(防模型默认出满幅大图)
|
||||
SIZE_RULE = (
|
||||
"size: choose freely between a MINIMUM print area of about 15x18 cm "
|
||||
"and a MAXIMUM of 26x32 cm, any size in this range fits, "
|
||||
"pick the one that best suits the design, keep proportions, "
|
||||
"scale naturally to the content, do NOT stretch, "
|
||||
"do NOT fill the entire canvas, do NOT force full-bleed, "
|
||||
"leave balanced margins around the artwork"
|
||||
)
|
||||
# 排除段:无衣服/模特/场景/水印
|
||||
NEG_FIXED = (
|
||||
"no garment, no shirt, no model, no mannequin, no background scene, no watermark"
|
||||
)
|
||||
# 文字规则(v3):可加可不加、适配印花即可;任何文字严禁敏感内容
|
||||
# 注意:正向提示词不写敏感词(no politics/no hate/no violence/no sexual…会被图像审核误判),
|
||||
# 负向约束统一由 negative_prompt 承担
|
||||
TEXT_RULE = (
|
||||
"text: optional - add short original English words or a small slogan ONLY if they fit "
|
||||
"the print, or keep it text-free; any text must be safe, short and original; "
|
||||
"no brand names, no logos, no trademarked phrases, no real people names"
|
||||
)
|
||||
|
||||
# —— 国家风格引导段(每国不同:US 大胆高对比 / GB 英式自嘲与复古 / JP 卡哇伊极简 / AU 海滨户外)——
|
||||
COUNTRY_STYLE_HINT: Dict[str, str] = {
|
||||
"US": "US-market aesthetic: bold confident statement graphic, high contrast, "
|
||||
"clean modern vector, sporty or humorous mood",
|
||||
"GB": "UK-market aesthetic: witty understated British charm, heritage-inspired motifs, "
|
||||
"retro sportswear or punk-zine mood",
|
||||
"JP": "JP-market aesthetic: kawaii cute or clean minimal, soft pastel-friendly, "
|
||||
"polished neat lines, small cute mascot mood",
|
||||
"AU": "AU-market aesthetic: laid-back coastal and outdoor vibe, nature-inspired, "
|
||||
"bright fresh energy",
|
||||
"MX": "MX-market aesthetic: vibrant mexican folk art, sugar skull / loteria / aztec motifs, "
|
||||
"fiesta colors, festive cultural pride mood",
|
||||
}
|
||||
|
||||
|
||||
def _image_prompt_for(country: str) -> str:
|
||||
hint = COUNTRY_STYLE_HINT.get(country, COUNTRY_STYLE_HINT["US"])
|
||||
return (
|
||||
"{motif}, {art_style}, {color_palette}, {composition}, "
|
||||
"standalone pure print design, print-ready artwork, "
|
||||
"isolated on pure white background, flat vector-like graphic, "
|
||||
"crisp clean edges, high resolution, ultra sharp, high contrast, "
|
||||
f"{hint}, {SIZE_RULE}, "
|
||||
f"{NEG_FIXED}, {TEXT_RULE}"
|
||||
)
|
||||
|
||||
|
||||
# 顶层默认(兜底:国家未配置时使用,内容等同 US 风格基线)
|
||||
DEFAULT_TEMPLATES: Dict[str, str] = {
|
||||
"image_prompt": _image_prompt_for("US"),
|
||||
# 预览图:直接印在平铺白T上(无真人),用于快速看效果
|
||||
"wearable_prompt": (
|
||||
"{motif}, {art_style}, {color_palette}, {composition}, "
|
||||
"printed centered on the chest of a flat-lay plain white t-shirt, "
|
||||
"print sized freely between about 15x18 cm and a max of 26x32 cm, "
|
||||
"scaled naturally to the artwork, not stretched, not full-bleed, "
|
||||
"studio lighting, e-commerce product photo, no human model"
|
||||
),
|
||||
# 复合提示词(三图模特合成):图1=模特 / 图2=纯印花设计稿 / 图3=平铺底图 → 模特穿着成品
|
||||
"composite_prompt": (
|
||||
"【图片角色,按提交顺序】图1=模特实拍图(基底);图2=纯印花设计稿;"
|
||||
"图3=平铺衣服底图(颜色/面料来源)。\n"
|
||||
"TASK: 把图2的印花设计印到图3底色的衣服上,并让图1的模特穿上"
|
||||
"“图3底色+图2印花”的衣服。\n"
|
||||
"RULES:\n"
|
||||
"1.底色锁定:从图3提取衣服底色与面料,最终合成中必须100%保持不变,严禁偏色。\n"
|
||||
"2.印花提取:从图2精准提取纯印花图案(线条/色号/比例),叠加到图3底色上形成合成面料。\n"
|
||||
"3.主体遮罩:识别图1模特服装穿着区域(忽略皮肤/头发/背景/配饰),"
|
||||
"用合成面料完整覆盖,清除原衣服颜色与图案。\n"
|
||||
"4.精准贴合:合成面料严格跟随图1衣服立体结构,褶皱/扭转处印花相应变形,"
|
||||
"杜绝“贴纸感”与“平面涂色感”。\n"
|
||||
"5.光影融合:按图1环境光方向调整亮度/对比度,印花受光影响产生明暗变化但色号不偏移。\n"
|
||||
"6.纯净输出:仅输出一张最终合成图;图1背景/人物/构图/光影100%不变,"
|
||||
"仅替换衣服印花与底色。\n"
|
||||
"DESIGN CONTENT: {motif}, {art_style}, {color_palette}, {composition}."
|
||||
),
|
||||
# 复合负向(印图专用)
|
||||
"composite_negative": (
|
||||
"garment changed, wrong color, distorted print, blurry, low-res, "
|
||||
"human model, body, extra objects, watermark, glow, 3d render, "
|
||||
"text unless part of design"
|
||||
),
|
||||
}
|
||||
|
||||
# —— 按国家覆盖:目前仅 image_prompt 有国家差异化;wearable/composite 共用顶层默认 ——
|
||||
COUNTRY_TEMPLATES: Dict[str, Dict[str, str]] = {
|
||||
cc: {"image_prompt": _image_prompt_for(cc)} for cc in COUNTRY_STYLE_HINT
|
||||
}
|
||||
|
||||
|
||||
def resolve_templates(tpls: Optional[Dict[str, Any]], country: Optional[str] = None) -> Dict[str, str]:
|
||||
"""解析最终模板:DEFAULT_TEMPLATES 兜底 → config 顶层覆盖 → config countries.<country> 覆盖。
|
||||
|
||||
config 结构示例:
|
||||
prompt_templates:
|
||||
image_prompt: "..." # 顶层默认
|
||||
countries:
|
||||
GB:
|
||||
image_prompt: "..." # 国家专属
|
||||
"""
|
||||
t = dict(DEFAULT_TEMPLATES)
|
||||
if tpls:
|
||||
t.update({k: v for k, v in tpls.items() if k in DEFAULT_TEMPLATES})
|
||||
countries = tpls.get("countries") or {}
|
||||
if country and isinstance(countries, dict):
|
||||
cc_tpls = countries.get(country) or {}
|
||||
if isinstance(cc_tpls, dict):
|
||||
t.update({k: v for k, v in cc_tpls.items() if k in DEFAULT_TEMPLATES})
|
||||
elif country and country in COUNTRY_TEMPLATES:
|
||||
t.update(COUNTRY_TEMPLATES[country])
|
||||
return t
|
||||
|
||||
|
||||
def assemble_prompts(
|
||||
motif: str,
|
||||
art_style: str,
|
||||
palette: str,
|
||||
composition: str,
|
||||
tpls: Optional[Dict[str, Any]] = None,
|
||||
country: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""用固定模板确定性装配三种提示词。tpls 可来自 config 覆盖(按国家优先)。"""
|
||||
t = resolve_templates(tpls, country)
|
||||
out = {}
|
||||
for key in ("image_prompt", "wearable_prompt", "composite_prompt"):
|
||||
out[key] = t[key].format(
|
||||
motif=motif, art_style=art_style, color_palette=palette, composition=composition
|
||||
)
|
||||
out["composite_negative"] = t["composite_negative"]
|
||||
return out
|
||||
@@ -0,0 +1,94 @@
|
||||
"""节点级兜底校验工具。
|
||||
|
||||
设计目标:LangGraph 流水线里每个节点都必须"失败不影响整体"。
|
||||
提供两类兜底:
|
||||
1. with_fallback(node_name):装饰器,节点函数抛异常时捕获,把错误写入 state['errors'],
|
||||
并返回最小更新(不破坏其它字段),整图继续往下走。
|
||||
2. 数据校验函数:validate_rows / validate_brief,对节点产出的数据进行结构校验,
|
||||
剔除非法记录并记录原因,保证下游拿到的数据"形状正确"。
|
||||
"""
|
||||
import functools
|
||||
import traceback
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def with_fallback(node_name: str):
|
||||
"""装饰器:捕获节点异常,转为 state['errors'] 中的一条记录,返回空更新。
|
||||
|
||||
节点内部仍建议自己做精细兜底(降级/默认),with_fallback 是最后一道保险:
|
||||
任何未预料的异常都不会让整张图中断。
|
||||
"""
|
||||
|
||||
def deco(fn):
|
||||
@functools.wraps(fn)
|
||||
def wrapper(state: Dict[str, Any]):
|
||||
try:
|
||||
return fn(state)
|
||||
except Exception as e: # noqa: BLE001
|
||||
tb = traceback.format_exc(limit=3)
|
||||
err = {
|
||||
"node": node_name,
|
||||
"type": type(e).__name__,
|
||||
"message": str(e)[:300],
|
||||
"trace": tb[-400:],
|
||||
}
|
||||
errors = list(state.get("errors") or [])
|
||||
errors.append(err)
|
||||
# 只更新 errors,其它字段保持上一节点结果,下游继续
|
||||
return {"errors": errors}
|
||||
|
||||
return wrapper
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def validate_rows(rows: List[Dict[str, Any]], node: str) -> List[Dict[str, Any]]:
|
||||
"""校验抓取/过滤后的行结构,剔除缺 topic 或非法记录,返回干净列表。
|
||||
|
||||
同时保证每个 row 至少含 country/topic/source/kind/raw_score,缺失时给默认。
|
||||
"""
|
||||
clean: List[Dict[str, Any]] = []
|
||||
dropped = 0
|
||||
for r in rows or []:
|
||||
if not isinstance(r, dict):
|
||||
dropped += 1
|
||||
continue
|
||||
topic = (r.get("topic") or "").strip()
|
||||
if not topic:
|
||||
dropped += 1
|
||||
continue
|
||||
r.setdefault("country", "")
|
||||
r.setdefault("source", "unknown")
|
||||
r.setdefault("kind", "unknown")
|
||||
r.setdefault("raw_score", 0.0)
|
||||
if r.get("raw_score") is None:
|
||||
r["raw_score"] = 0.0
|
||||
clean.append(r)
|
||||
if dropped:
|
||||
# 简单记录到返回数据的副作用里(调用方会再汇总到 stats)
|
||||
pass
|
||||
return clean
|
||||
|
||||
|
||||
def validate_brief(b: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""校验单条设计简报结构,补齐缺失字段,保证下游 compose 不会因缺键崩溃。"""
|
||||
b = dict(b)
|
||||
b.setdefault("topic", "")
|
||||
b.setdefault("country", "")
|
||||
b.setdefault("design_category", "Niche")
|
||||
b.setdefault("risk_level", "safe")
|
||||
b.setdefault("motif", b.get("topic", ""))
|
||||
b.setdefault("art_style", "clean vector illustration")
|
||||
b.setdefault("color_palette", "balanced modern palette")
|
||||
b.setdefault("composition", "centered emblem with balanced negative space")
|
||||
b.setdefault("concept", b.get("topic", ""))
|
||||
b.setdefault("negative_prompt", "")
|
||||
b.setdefault("image_prompt", "")
|
||||
b.setdefault("wearable_prompt", "")
|
||||
b.setdefault("composite_prompt", "")
|
||||
b.setdefault("composite_negative", "")
|
||||
return b
|
||||
|
||||
|
||||
def safe_get(state: Dict[str, Any], key: str, default=None):
|
||||
return state.get(key, default)
|
||||
Reference in New Issue
Block a user