- 自定义模式:分析模型输出 delta 唯一改动指令,生图模板 custom_image_prompt.md({delta} 占位符),不再使用负向提示词;generate_design 按 custom_mode 分支,Pinterest 模式保留原创化指令,两模式互不影响
- 多模态分析 response_format 三级回退(json_schema → json_object → none),兼容 DeepSeek
- 模板导出:details 扩展列(细节1/2/3)、target_audience 扩展列(适用人群1)、固定值风格1=休闲/风格2=运动
- 童装特征库更新 + 标题模板外部化 + 图源映射增强
950 lines
53 KiB
Python
950 lines
53 KiB
Python
"""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, Optional
|
||
|
||
import requests
|
||
import yaml
|
||
|
||
# 模型调用一律直连:用户常开 VPN(系统代理),LLM 网关多为国内/自建,走代理会被拦截或变慢。
|
||
# 仅请求级 proxies=NO_PROXY 直连,不设置进程级 NO_PROXY 环境变量(避免影响 Google Trends 等外部采集)。
|
||
from .base import LLMBackend
|
||
from graph.paths import project_root, 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 标题)——
|
||
# 提示词模板存 prompts/title_prompt_<编号>.md(可自由编辑,优先运行根 exe 旁,回退数据根);
|
||
# 国家 → 模板编号路由存 config.yaml 顶层 title_templates.route(可自由编辑)。
|
||
# 模板 1:英语市场(US/GB/AU/MX)→ en_title + cn_title
|
||
# 模板 2:日本市场(JP)→ en_title + cn_title + ja_title
|
||
# 模板 3:西班牙市场(ES)→ es_title + cn_title
|
||
# 以下为「内置默认」兜底:md/yaml 缺失或留空时回退,避免标题生成中断。
|
||
_TITLE_TEMPLATES_FALLBACK: 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": "日本語タイトル"}''',
|
||
"3": '''# Role
|
||
你是一位资深的跨境服装运营专家,精通西班牙语电商(Amazon ES, MercadoLibre)的SEO标题逻辑。你的任务是通过分析服装图片,生成高权重的西班牙语-中文商品标题。
|
||
|
||
# Task
|
||
请深度分析图片中的服装特征(品类、风格、材质、剪裁、细节、受众),生成符合西语电商搜索逻辑的中西文标题。
|
||
|
||
# 当前时间(标题须贴合当下,季节/年份词以此为准)
|
||
- **Current time**: {year}-{month}({season}),标题中的年份/季节等时效词必须使用以上时间。
|
||
|
||
# Analysis Focus (视觉分析重点)
|
||
- 品类识别:准确判断西班牙语核心词(如 Vestido, Blusa, Sudadera)和中文核心词(如 连衣裙, 卫衣)。
|
||
- 风格定位:判断风格流派(如 Boho, Vintage, Minimalista / 法式, 复古, 极简)。
|
||
- 设计细节:提取领型、袖型、裙长等(如 Escote en V, Manga abullonada / V领, 阔袖)。
|
||
- 适用场景:推断穿着场景(如 Playa, Oficina, Fiesta / 度假, 通勤, 约会)。
|
||
|
||
# Constraints (生成规则)
|
||
- Spanish Title: 遵循 Amazon ES/MercadoLibre 风格,核心词前置,包含材质、风格、场景等长尾词,符合西语搜索习惯。
|
||
- Chinese Title: 遵循淘宝/1688风格,关键词权重递减,包含年份/季节+风格+核心词+卖点+人群。
|
||
- Output: 必须严格返回 JSON 格式,不要包含 Markdown 代码块标记,格式如下:
|
||
{"es_title": "Título en español", "cn_title": "中文标题"}''',
|
||
}
|
||
|
||
# 内置默认路由(config.yaml title_templates.route 缺失/留空时回退;JP→模板2,ES→模板3,其余默认模板1)
|
||
_TITLE_ROUTE_FALLBACK: Dict[str, str] = {
|
||
"US": "1",
|
||
"GB": "1",
|
||
"JP": "2",
|
||
"AU": "1",
|
||
"MX": "1",
|
||
"ES": "3",
|
||
}
|
||
|
||
|
||
def _read_title_prompt(tpl_no: str) -> str:
|
||
"""读取 prompts/title_prompt_<编号>.md:优先运行根(exe 旁,可编辑),回退数据根;找不到/空返回空串。"""
|
||
for base in (runtime_root(), project_root()):
|
||
p = base / "prompts" / f"title_prompt_{tpl_no}.md"
|
||
if p.exists():
|
||
text = p.read_text(encoding="utf-8").strip()
|
||
if text:
|
||
return text
|
||
return ""
|
||
|
||
|
||
_TITLE_ROUTE_CACHE: Optional[Dict[str, str]] = None
|
||
_TITLE_ROUTE_CACHE_FILE: str = ""
|
||
|
||
|
||
def _load_title_route() -> Dict[str, str]:
|
||
"""从 config.yaml 顶层 title_templates.route 读取国家→模板编号路由;缺失/留空回退内置默认。
|
||
|
||
路由在运行期不变,模块级缓存避免并发多产品时每次重读 config.yaml(mtime 变化会刷新)。
|
||
"""
|
||
global _TITLE_ROUTE_CACHE, _TITLE_ROUTE_CACHE_FILE
|
||
cfg_file = ""
|
||
for base in (runtime_root(), project_root()):
|
||
p = base / "config.yaml"
|
||
if p.exists():
|
||
cfg_file = str(p)
|
||
break
|
||
if cfg_file == _TITLE_ROUTE_CACHE_FILE and _TITLE_ROUTE_CACHE is not None:
|
||
return dict(_TITLE_ROUTE_CACHE)
|
||
route: Dict[str, str] = {}
|
||
try:
|
||
data = yaml.safe_load(open(cfg_file, encoding="utf-8")) or {}
|
||
r = (data.get("title_templates") or {}).get("route") or {}
|
||
if isinstance(r, dict) and r:
|
||
route = {str(k): str(v) for k, v in r.items()}
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[titles] 读取 config.yaml title_templates.route 失败: {e}")
|
||
if not route:
|
||
route = dict(_TITLE_ROUTE_FALLBACK)
|
||
cfg_file = ""
|
||
_TITLE_ROUTE_CACHE = dict(route)
|
||
_TITLE_ROUTE_CACHE_FILE = cfg_file
|
||
return dict(route)
|
||
|
||
|
||
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 = "", category_path: str = "") -> str:
|
||
"""按国家解析标题生成提示词(自动注入当前时间 + 类目路径变量);未知国家/留空回退模板 1。
|
||
|
||
category_path:商品上传模版「类目」完整路径(如
|
||
服装、鞋靴和珠宝饰品>女童时尚>女童服装>女童上衣、T恤、衬衫>女童T恤),
|
||
模板含 {category_path} 占位符时替换;未提供/为空则替换为空串。"""
|
||
route = _load_title_route()
|
||
tpl_no = route.get(country or "", "1")
|
||
prompt = _read_title_prompt(tpl_no) or _TITLE_TEMPLATES_FALLBACK.get(tpl_no, _TITLE_TEMPLATES_FALLBACK["1"])
|
||
prompt = _inject_now(prompt)
|
||
return prompt.replace("{category_path}", str(category_path or ""))
|
||
|
||
|
||
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."
|
||
)
|
||
|
||
|
||
# —— Pinterest 参考模式:搜索词生成(json_schema 结构化 + 动态注入已用词防重复)——
|
||
PINTEREST_TERM_SYSTEM_PROMPT = """You are a Pinterest search-term generator for print-on-demand (POD) SHORT-SLEEVE T-SHIRT print design.
|
||
You turn seed words into diverse, visual, Pinterest-friendly search terms that will be used to scrape inspiration images
|
||
that are DIRECTLY usable as reference for a t-shirt print design.
|
||
|
||
RULES:
|
||
- Generate EXACTLY the requested number of search terms (usually 1 per call).
|
||
- Every term MUST be a "__SUFFIX__" style query: think of it as if the user typed "<concept>__SUFFIX__" on
|
||
Pinterest, so the scraped images are actual t-shirt graphics / flat print artworks, NOT lifestyle photos, scenery,
|
||
architecture, food plates, or anything that cannot become a clean chest print.
|
||
- Terms MUST be suitable for a SHORT-SLEEVE T-SHIRT PRINT: a flat, graphic, print-ready concept (illustration, mascot,
|
||
emblem, pattern, typography, slogan) that works as a chest print between about 15x18 cm and 26x32 cm.
|
||
- Prefer a clear central subject with a strong silhouette and balanced composition that reads well as a standalone print.
|
||
- AVOID terms that lead to full-scene photos, landscapes, architecture, food plates, or anything that cannot become a clean t-shirt print.
|
||
- Terms must be VISUAL / AESTHETIC concepts (style, motif, scene, color) suitable as T-shirt print inspiration.
|
||
- Terms must be DIVERSE and NON-OVERLAPPING: never repeat a concept, never give near-synonyms of each other.
|
||
- DO NOT repeat or closely paraphrase ANY of the "already used terms" provided in the user message.
|
||
- Use the country's local language where natural (e.g. Japanese for JP, Spanish for ES/MX), else English.
|
||
- Each term is 2-4 words, concise, no punctuation.
|
||
- COPYRIGHT-SAFE: no brands, no logos, no characters, no celebrities, no real persons, no franchises.
|
||
- AVOID: politics, religion, hate, violence, sexual content, alcohol, national flags.
|
||
|
||
Return JSON with the field "search_terms" (array of strings)."""
|
||
|
||
PINTEREST_TERM_SCHEMA = {
|
||
"name": "pinterest_search_terms",
|
||
"schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"search_terms": {
|
||
"type": "array",
|
||
"items": {"type": "string"},
|
||
"description": "Diverse, non-overlapping Pinterest search terms for short-sleeve t-shirt print design inspiration",
|
||
}
|
||
},
|
||
"required": ["search_terms"],
|
||
"additionalProperties": False,
|
||
},
|
||
}
|
||
|
||
|
||
def build_pinterest_term_user_prompt(context: Dict[str, Any]) -> str:
|
||
"""动态注入:种子词(灵感)+ 已用搜索词(禁止重复)+ 数量要求(按需每次 1 个)。"""
|
||
seeds = context.get("seeds", []) or []
|
||
used = context.get("used_terms", []) or []
|
||
count = int(context.get("count", 1))
|
||
lines = [
|
||
f"Country: {context.get('country', '')}",
|
||
f"Seed words (inspiration, may combine or extend): {', '.join(seeds)}",
|
||
"",
|
||
f"Already used terms — DO NOT repeat or paraphrase ANY of these: "
|
||
f"{', '.join(used) if used else '(none yet)'}",
|
||
"",
|
||
f"Generate {count} new, diverse, non-overlapping Pinterest search term(s) "
|
||
f"that are suitable for a SHORT-SLEEVE T-SHIRT PRINT design "
|
||
f"(flat, graphic, print-ready motif that works as a chest print). "
|
||
f"Each term should read like \"<concept>__SUFFIX__\" so Pinterest returns "
|
||
f"actual t-shirt graphics / flat print artwork as reference.",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
# —— Pinterest 参考模式:图片分析 → 原创设计简报(多模态)——
|
||
# image_prompt 由 LLM 直接输出完整的英文生图提示词(多模态对图片的描述拼接),
|
||
# 不再走「四要素 + 固定模板」装配;尺寸/白底等统一约束段由 prompt_node 自动追加。
|
||
PINTEREST_ANALYZE_SYSTEM_PROMPT = """You are a POD T-shirt design analyst. Given ONE Pinterest reference image,
|
||
judge whether it can inspire a T-shirt print, then write an ORIGINAL design brief
|
||
capturing its vibe WITHOUT copying.
|
||
Your image_prompt will be sent TOGETHER WITH this reference image to an image
|
||
generator, so it must actively override visual imitation.
|
||
|
||
RULES:
|
||
1. NO COPYING — never reproduce or closely imitate the reference's artwork,
|
||
characters, layout or text. Deliberately change motif, arrangement and/or
|
||
palette so the two read as clearly different works sharing only a general
|
||
style. Distill inspiration into generic style words (retro, y2k, minimal,
|
||
grunge, boho, kawaii...); never imitate an identifiable artist/studio/IP
|
||
style.
|
||
2. FORBIDDEN — brand logos, trademarks, slogans, mascots, copyrighted
|
||
characters, real people/celebrities, movie/game/anime/band IP, lyrics,
|
||
even stylized or silhouette versions. Avoid politics, religion, violence,
|
||
sexual content, alcohol, drugs, gambling, flags, death/occult themes.
|
||
3. FORM — ONE clear central subject with strong graphic composition;
|
||
print-ready standalone artwork. ANY colors are fine — rich palettes,
|
||
gradients and detailed shading are all acceptable. Photographic references
|
||
may be rendered as detailed full-color illustrations, retro badges or
|
||
vintage stickers.
|
||
|
||
TEXT: short ORIGINAL English wording (1-6 words) allowed; wrap exact words in
|
||
double quotes and demand exact spelling; integrate into composition. Never
|
||
reuse/translate reference text; no brand/band/movie names or famous slogans.
|
||
When unsure, omit.
|
||
|
||
suitable_for_print: DEFAULT TRUE for graphics, illustrations, badges, vector
|
||
art, typography posters, or prints on mockups (judge only the printed artwork).
|
||
FALSE only for: subjectless photo scenery, memes/screenshots/collages,
|
||
watermarked or very low-quality images, decor/food/candid photos with no
|
||
usable motif. Even when FALSE, still fill all fields so downstream never breaks.
|
||
|
||
image_prompt = two parts:
|
||
1) mandatory opener, e.g.: "Use the attached reference image only as loose
|
||
inspiration for overall mood, theme and era — do NOT reproduce, trace,
|
||
rearrange, recolor or closely imitate any element, character, layout or
|
||
text shown in it."
|
||
2) the new design: [central motif] + [style] + [color treatment] +
|
||
[composition] + [mood], plus quoted original text if used.
|
||
NEVER mention shirts, apparel, models, scenes, sizes, backgrounds or
|
||
watermarks — placement is handled externally.
|
||
|
||
OUTPUT — ONLY valid JSON, no fences:
|
||
{"designs":[{"suitable_for_print":<bool>,"image_prompt":"<str>"}]}"""
|
||
|
||
PINTEREST_ANALYZE_SCHEMA = {
|
||
"name": "pinterest_design_briefs",
|
||
"schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"designs": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"suitable_for_print": {"type": "boolean"},
|
||
"image_prompt": {"type": "string"},
|
||
"delta": {"type": "string"},
|
||
},
|
||
"required": ["suitable_for_print", "image_prompt"],
|
||
"additionalProperties": False,
|
||
},
|
||
}
|
||
},
|
||
"required": ["designs"],
|
||
"additionalProperties": False,
|
||
},
|
||
}
|
||
|
||
|
||
def _pinterest_analyze_prompt_file(country: str, filename: str) -> str:
|
||
"""定位多模态分析提示词文件:优先运行根 prompts(exe 旁,可编辑),回退数据根 prompts。
|
||
|
||
支持国家覆盖(prompts/<country>/<filename>)优先于全局(prompts/<filename>)。
|
||
找不到则返回空串,由调用方回退内置默认。
|
||
"""
|
||
for base in (runtime_root(), project_root()):
|
||
if country:
|
||
p = base / "prompts" / country / filename
|
||
if p.exists():
|
||
return p.read_text(encoding="utf-8").strip()
|
||
p = base / "prompts" / filename
|
||
if p.exists():
|
||
return p.read_text(encoding="utf-8").strip()
|
||
return ""
|
||
|
||
|
||
# 自定义模式(pinterest.mode=custom)多模态分析系统提示词内置默认:
|
||
# 输入是「现有爆款产品图」(模特实拍/平铺图),定位衣服上的印花并产出同风格、不同执行的非侵权原创设计。
|
||
# 该文本为「内置默认」,可被 prompts/custom_analyze_system.md 覆盖(缺失/留空回退这里)。
|
||
CUSTOM_ANALYZE_SYSTEM_PROMPT = """You are a POD T-shirt design analyst working in CUSTOM-REFERENCE mode. You are given ONE existing bestseller product photo — an on-model shot or a flat-lay shot of a printed garment. Your job:
|
||
1. Locate the printed artwork on the garment and judge whether that PRINT can inspire a new T-shirt design (ignore the shirt itself, the model, the background, watermarks and photo quality — judge only the printed artwork).
|
||
2. Write an ORIGINAL design brief that keeps the bestseller's general vibe (theme, style genre, era, mood) but is a clearly different, non-infringing work.
|
||
|
||
RULES:
|
||
0. REFERENCE HANDLING — the attached photo is a product mockup of an existing bestseller, not an artwork file. Use it ONLY to identify the printed artwork and distill its appeal into generic style keywords (retro, y2k, minimal, grunge, boho, kawaii, western, cottagecore, vintage cartoon...). Never reproduce, trace, rearrange, recolor or closely imitate the print, its layout, characters or text.
|
||
1. SAME SUBJECT FAMILY, NEW EXECUTION — the new design must belong to the same visual family as the bestseller print so a buyer would instantly see they are "same style, different shirt". LOCK these elements to the bestseller's: (a) general subject category (e.g., human/animal figure silhouette, celestial object, floral, vehicle, typography-led...), (b) art technique (e.g., grunge ink silhouette, clean line art, watercolor, distressed collage...), (c) palette (monochrome / muted / neon...). VARY these: the exact subject, the pose, the composition and layout, and any decorative framing. The result must be clearly original — no traced or rearranged reuse of the reference artwork — while staying recognizably in the same genre.
|
||
2. IP SCREENING — if the bestseller print contains brand logos, trademarks, slogans, mascots, copyrighted characters (including stylized or silhouette versions), real people/celebrities, movie/game/anime/band IP, or lyrics, do NOT imitate them; swap in fully generic equivalents (e.g., an unnamed cartoon animal instead of a recognizable mascot). Also avoid politics, religion, violence, sexual content, alcohol, drugs, gambling, flags, death/occult themes.
|
||
3. FORM — ONE clear central subject with strong graphic composition; print-ready standalone artwork. ANY colors are fine — rich palettes, gradients and detailed shading are all acceptable. Because the reference is a product photo, explicitly exclude the garment and the photo itself: never describe shirts, models, hangers, scenes or backgrounds. Photographic elements in the print may be re-rendered as detailed full-color illustrations, retro badges or vintage stickers.
|
||
4. TEXT — short ORIGINAL English wording (1-6 words) allowed; wrap exact words in double quotes and demand exact spelling; integrate into composition. Never reuse, translate or near-duplicate the bestseller print's wording; no brand/band/movie names or famous slogans. When unsure, omit.
|
||
|
||
suitable_for_print: judge ONLY the printed artwork extracted from the bestseller photo. DEFAULT TRUE for graphics, illustrations, badges, vector art, typography prints, or prints visible on mockups. FALSE only for: blank garments with no artwork, prints that are indiscernible due to watermark, blur or very low quality, pure subjectless photo scenery, memes/screenshots/collages with no usable motif. Even when FALSE, still fill all fields so downstream never breaks.
|
||
|
||
image_prompt = two parts:
|
||
1) mandatory opener: "First, carefully examine the printed artwork on the garment in the attached photo: zoom in mentally on the print area, identify its subject, technique, palette and layout, and base the new design on THOSE observed traits. Ignore the model, background and photo quality. Then: use the attached bestseller product photo only as loose inspiration for overall mood, theme, era and style genre — do NOT reproduce, trace, rearrange, recolor or closely imitate its printed artwork, characters, layout or text, and do NOT render a shirt, garment, model, hanger, photo scene, product mockup or background of any kind."
|
||
2) the new design: [same subject family as observed] + [same technique and palette family] + [changed exact subject, pose, composition and framing] + [mood], plus quoted original text if used. NEVER mention shirts, apparel, models, scenes, sizes, backgrounds or watermarks — placement is handled externally. Before output, verify the new design shares the locked elements (subject category, technique, palette) with the observed print; if not, revise it.
|
||
|
||
OUTPUT — ONLY valid JSON, no fences:
|
||
{"designs":[{"suitable_for_print":<bool>,"image_prompt":"<str>"}]}"""
|
||
|
||
|
||
def _custom_analyze_prompt_file(filename: str) -> str:
|
||
"""定位自定义模式多模态分析提示词文件:优先运行根 prompts(exe 旁,可编辑),回退数据根 prompts。
|
||
|
||
只读全局 prompts/<filename>(自定义模式不按国家区分)。找不到返回空串,由调用方回退内置默认。
|
||
"""
|
||
for base in (runtime_root(), project_root()):
|
||
p = base / "prompts" / filename
|
||
if p.exists():
|
||
return p.read_text(encoding="utf-8").strip()
|
||
return ""
|
||
|
||
|
||
def resolve_custom_analyze_system_prompt() -> str:
|
||
"""自定义模式多模态分析系统提示词(可配置):命中 prompts/custom_analyze_system.md,
|
||
否则回退内置 CUSTOM_ANALYZE_SYSTEM_PROMPT。"""
|
||
text = _custom_analyze_prompt_file("custom_analyze_system.md")
|
||
return text if text else CUSTOM_ANALYZE_SYSTEM_PROMPT
|
||
|
||
|
||
def build_custom_analyze_user_prompt() -> str:
|
||
"""自定义模式多模态分析用户提示词(可配置):命中 prompts/custom_analyze_user.md 否则内置默认。"""
|
||
text = _custom_analyze_prompt_file("custom_analyze_user.md")
|
||
if text:
|
||
return text
|
||
return (
|
||
"Analyze the attached bestseller product photo, locate its printed artwork, "
|
||
"and produce one ORIGINAL T-shirt print design brief that keeps its general vibe "
|
||
"without copying its printed artwork."
|
||
)
|
||
|
||
|
||
def resolve_pinterest_analyze_system_prompt(country: str = "") -> str:
|
||
"""多模态分析系统提示词(可配置):命中 prompts/pinterest_analyze_system.md(国家覆盖优先),
|
||
否则回退内置 PINTEREST_ANALYZE_SYSTEM_PROMPT。"""
|
||
text = _pinterest_analyze_prompt_file(country, "pinterest_analyze_system.md")
|
||
return text if text else PINTEREST_ANALYZE_SYSTEM_PROMPT
|
||
|
||
|
||
def build_pinterest_analyze_user_prompt(country: str = "") -> str:
|
||
"""多模态分析用户提示词(可配置):命中 prompts/pinterest_analyze_user.md 否则内置默认。"""
|
||
text = _pinterest_analyze_prompt_file(country, "pinterest_analyze_user.md")
|
||
if text:
|
||
return text
|
||
return (
|
||
"Analyze the attached image and produce one ORIGINAL T-shirt print design brief "
|
||
"that captures its visual vibe without copying it."
|
||
)
|
||
|
||
|
||
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 call_openai_compatible_structured(cfg, messages, json_schema, timeout=120):
|
||
"""调用 LLM 并返回结构化 JSON 文本。
|
||
|
||
优先 json_schema(strict 结构化输出);部分兼容厂商不支持 json_schema 时
|
||
自动回退 json_object(仍要求 JSON)。最终解析交给 _extract_json 兜底。
|
||
"""
|
||
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"
|
||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"temperature": float(cfg.get("temperature", 0.6)),
|
||
"response_format": {
|
||
"type": "json_schema",
|
||
"json_schema": {
|
||
"name": json_schema.get("name", "structured_output"),
|
||
"strict": True,
|
||
"schema": json_schema.get("schema", json_schema),
|
||
},
|
||
},
|
||
}
|
||
try:
|
||
resp = requests.post(url, json=payload, headers=headers, timeout=timeout, proxies=NO_PROXY)
|
||
resp.raise_for_status()
|
||
return resp.json()["choices"][0]["message"]["content"]
|
||
except Exception: # noqa: BLE001 兼容厂商不支持 json_schema → 回退 json_object
|
||
payload["response_format"] = {"type": "json_object"}
|
||
resp = requests.post(url, json=payload, headers=headers, timeout=timeout, proxies=NO_PROXY)
|
||
resp.raise_for_status()
|
||
return resp.json()["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:
|
||
# 找第一个 { 到与之平衡的 },逐字符跳过字符串内的花括号,避免贪婪匹配截断 JSON
|
||
start = text.find("{")
|
||
if start == -1:
|
||
raise
|
||
depth = 0
|
||
in_str = False
|
||
esc = False
|
||
for i in range(start, len(text)):
|
||
ch = text[i]
|
||
if in_str:
|
||
if esc:
|
||
esc = False
|
||
elif ch == "\\":
|
||
esc = True
|
||
elif ch == '"':
|
||
in_str = False
|
||
else:
|
||
if ch == '"':
|
||
in_str = True
|
||
elif ch == "{":
|
||
depth += 1
|
||
elif ch == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return json.loads(text[start:i + 1])
|
||
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_pinterest_terms(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""生成 Pinterest 搜索词(json_schema 结构化 + 动态注入已用词防重复)。
|
||
|
||
context 字段:country, seeds, used_terms, count。
|
||
返回 {"search_terms": [str]};失败抛异常由节点兜底(回退种子词)。
|
||
"""
|
||
cfg = self._cfg
|
||
# 防御性上限:已用词最多注入 100 个,防 token 超限(节点层已截断,这里双保险)
|
||
ctx = dict(context or {})
|
||
used = [str(u) for u in (ctx.get("used_terms") or []) if str(u)]
|
||
max_used = int((cfg or {}).get("max_used_terms_in_prompt", 100) or 100)
|
||
if max_used > 0:
|
||
ctx["used_terms"] = used[-max_used:]
|
||
# 后缀占位符:读 ctx 配置(允许为空=不追加后缀);仅在确有后缀时替换占位
|
||
suffix = str(ctx.get("search_term_suffix") or "").strip()
|
||
sys_prompt = PINTEREST_TERM_SYSTEM_PROMPT.replace("__SUFFIX__", f" {suffix}" if suffix else "")
|
||
user_prompt = build_pinterest_term_user_prompt(ctx).replace("__SUFFIX__", f" {suffix}" if suffix else "")
|
||
messages = [
|
||
{"role": "system", "content": sys_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
]
|
||
raw = _retry(lambda: call_openai_compatible_structured(cfg, messages, PINTEREST_TERM_SCHEMA, timeout=120))
|
||
parsed = _extract_json(raw)
|
||
terms = [str(x).strip() for x in (parsed.get("search_terms", []) or []) if str(x).strip()]
|
||
# 自动追加自定义后缀(config.pinterest.search_term_suffix,默认 t-shirt design):
|
||
# 让 Pinterest 返回真正的印花图(更适合作印花设计参考)
|
||
if suffix:
|
||
terms = [f"{t} {suffix}" if suffix not in t.lower() else t for t in terms]
|
||
return {"search_terms": terms}
|
||
|
||
def analyze_pinterest_images(self, image_paths: List[str], term: str = "", country: str = "",
|
||
on_400=None, custom_mode: bool = False) -> List[Dict[str, Any]]:
|
||
"""多模态分析 Pinterest 图片 → 原创设计简报列表。
|
||
|
||
图片输入失败/无有效图片时直接放弃(返回 [],不降级纯文本),由节点跳过该产品。
|
||
on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。
|
||
custom_mode: True=自定义模式,用独立的多模态提示词(CUSTOM-REFERENCE 版,
|
||
见 prompts/custom_analyze_system.md / custom_analyze_user.md),输入是现有爆款产品图。
|
||
"""
|
||
cfg = self._cfg
|
||
api_key = cfg.get("api_key", "")
|
||
if not api_key:
|
||
print("[pinterest_analyze] 未配置 LLM 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(多模态输入)
|
||
data_uris: List[str] = []
|
||
for p in image_paths:
|
||
try:
|
||
pk = Path(p)
|
||
raw = pk.read_bytes()
|
||
# 校验图片完整性:损坏/截断的图片会被火山方舟等多模态接口直接 400 拒绝,
|
||
# 必须滤掉后才能编码 base64(PIL 打开失败即视为损坏)。
|
||
if not raw or len(raw) < 100:
|
||
print(f"[pinterest_analyze] 图片文件过小/为空,跳过: {p} ({len(raw)}B)")
|
||
continue
|
||
try:
|
||
from PIL import Image
|
||
_im = Image.open(pk)
|
||
_im.verify() # 校验文件头/结构,不完整则抛异常
|
||
_im.close()
|
||
except Exception as _ve: # noqa: BLE001
|
||
print(f"[pinterest_analyze] 图片损坏/不完整,跳过: {p} ({_ve})")
|
||
continue
|
||
import base64 as b64
|
||
mime = "image/png"
|
||
if pk.suffix.lower() in (".jpg", ".jpeg"):
|
||
mime = "image/jpeg"
|
||
data_uris.append(f"data:{mime};base64,{b64.b64encode(raw).decode()}")
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[pinterest_analyze] 图片读取失败 {p}: {e}")
|
||
|
||
def _notify_400(exc) -> None:
|
||
if on_400 is None:
|
||
return
|
||
try:
|
||
from graph.pinterest import is_400_content_image
|
||
if is_400_content_image(exc):
|
||
on_400()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
def _call() -> str:
|
||
if custom_mode:
|
||
sys_prompt = resolve_custom_analyze_system_prompt()
|
||
user_prompt = build_custom_analyze_user_prompt()
|
||
else:
|
||
sys_prompt = resolve_pinterest_analyze_system_prompt(country)
|
||
user_prompt = build_pinterest_analyze_user_prompt(country)
|
||
user_content: List[Any] = [
|
||
{"type": "text", "text": user_prompt},
|
||
]
|
||
user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
|
||
base_payload = {
|
||
"model": model,
|
||
"messages": [
|
||
{"role": "system", "content": sys_prompt},
|
||
{"role": "user", "content": user_content},
|
||
],
|
||
"temperature": 0.5,
|
||
}
|
||
# 兼容厂商差异的尝试链:
|
||
# 1) json_schema(OpenAI 原生 strict 输出)
|
||
# 2) json_object(部分兼容厂商支持,但要求提示词含 "json",如 DeepSeek → 追加显式指令)
|
||
# 3) 无 response_format(依赖 _extract_json 兜底解析)
|
||
last_err: Optional[Exception] = None
|
||
for variant in ("json_schema", "json_object", "none"):
|
||
payload = dict(base_payload)
|
||
if variant == "json_schema":
|
||
payload["response_format"] = {
|
||
"type": "json_schema",
|
||
"json_schema": {
|
||
"name": PINTEREST_ANALYZE_SCHEMA["name"],
|
||
"strict": True,
|
||
"schema": PINTEREST_ANALYZE_SCHEMA["schema"],
|
||
},
|
||
}
|
||
elif variant == "json_object":
|
||
payload["response_format"] = {"type": "json_object"}
|
||
payload["messages"][1]["content"] = [
|
||
{"type": "text", "text": user_prompt + "\nReturn your answer as a JSON object."},
|
||
] + [{"type": "image_url", "image_url": {"url": u}} for u in data_uris]
|
||
try:
|
||
resp = requests.post(url, json=payload, headers=headers, timeout=180, proxies=NO_PROXY)
|
||
resp.raise_for_status()
|
||
return str(resp.json()["choices"][0]["message"].get("content") or "")
|
||
except Exception as e: # noqa: BLE001
|
||
last_err = e
|
||
_notify_400(e)
|
||
raise last_err
|
||
|
||
# 图片输入失败/无有效图片 → 直接放弃该产品(不降级纯文本),由节点跳过后续流程
|
||
if not data_uris:
|
||
print("[pinterest_analyze] 无有效图片输入,放弃该产品(不降级纯文本)")
|
||
return []
|
||
try:
|
||
raw = _call()
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[pinterest_analyze] 图片分析失败,放弃该产品(不降级纯文本): {e}")
|
||
return []
|
||
try:
|
||
parsed = _extract_json(raw)
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[pinterest_analyze] 解析失败: {e}")
|
||
return []
|
||
designs = []
|
||
# 兼容 LLM 返回裸数组([...])或 {designs: [...]} 两种结构
|
||
if isinstance(parsed, dict):
|
||
designs_raw = parsed.get("designs") or []
|
||
elif isinstance(parsed, list):
|
||
designs_raw = parsed
|
||
else:
|
||
designs_raw = []
|
||
# 自定义模式:分析模型按 custom_analyze_system.md 直接返回 {"delta": "..."}(无 designs 包装)
|
||
# → 包装成单条简报;suitable_for_print 缺省 True(下游 _brief_suitable 默认放行)
|
||
if not designs_raw and custom_mode and isinstance(parsed, dict) and str(parsed.get("delta") or "").strip():
|
||
designs_raw = [parsed]
|
||
for i, d in enumerate(designs_raw):
|
||
if not isinstance(d, dict):
|
||
continue
|
||
designs.append({
|
||
"topic": term,
|
||
"suitable_for_print": bool(d.get("suitable_for_print", True)),
|
||
"image_prompt": str(d.get("image_prompt", "")).strip(),
|
||
# 自定义模式分析模型产出的「唯一改动」指令,注入生图模板 {delta} 占位符
|
||
"delta": str(d.get("delta", "")).strip(),
|
||
# 生图参考:每条简报对应其来源爬取图(LLM 按图逐张产出简报,顺序一一对应)
|
||
"ref_images": [str(image_paths[i])] if i < len(image_paths) else [],
|
||
"source": "pinterest",
|
||
})
|
||
return designs
|
||
|
||
def generate_title(self, image_path: str, system_prompt: str = "", country: str = "",
|
||
category_path: str = "") -> Dict[str, Any]:
|
||
"""多模态:分析服装图片,生成商品标题(按国家路由模板)。
|
||
|
||
系统提示词:显式传入优先;否则按 country 经 config.yaml title_templates.route 路由到
|
||
prompts/title_prompt_<编号>.md 对应模板(缺失回退内置默认)。
|
||
category_path:模版「类目」完整路径,注入模板 {category_path} 占位符(可为空)。
|
||
模板 1(US/GB/AU/MX)返回 {"en_title","cn_title"};
|
||
模板 2(JP)额外返回 {"ja_title"};
|
||
模板 3(ES)返回 {"es_title","cn_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, category_path)},
|
||
{"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(),
|
||
"es_title": str(parsed.get("es_title", "")).strip(),
|
||
}
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[titles] 标题生成失败: {e}")
|
||
return {}
|