Files
pod_trend_agent/graph/llms/openai_compat_backend.py
T
3218485270 d68cc3b9e3 v106-v109 童装支持 + 标题模板外部化 + 模板导出增强
- 新增男童/女童检测(gender_from_category 优先判童装)与童装场景图生成
  (configs/kids_features.yaml:模特/场景/服装风格,同一商品固定同一组)
- 童装 SPU 字段映射:kids_type→SPU商品属性-类型、kids_age→适用年龄段、
  target_audience 按性别映射、kids_type_map 女童「上衣」→「针织上衣」
- 标题生成提示词外部化:prompts/title_prompt_{1,2,3}.md + config.yaml 路由表
- 模板多站点匹配:经营站点可配多个,命中任意一个即匹配
- 种草图生成失败自动重试(seed_shot.retries,换场景/模特/风格)
- 模板导出新增 Preview 文件夹(成功产品 _composite.oss.jpg + result.xlsx)
- 修复 SKU 尺码未按从小到大排序(_size_rank 支持单一年龄码 6Y/10Y)
2026-09-01 17:24:23 +08:00

907 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
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 ""
def _load_title_route() -> Dict[str, str]:
"""从 config.yaml 顶层 title_templates.route 读取国家→模板编号路由;缺失/留空回退内置默认。"""
for base in (runtime_root(), project_root()):
p = base / "config.yaml"
if not p.exists():
continue
try:
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
route = (data.get("title_templates") or {}).get("route") or {}
if isinstance(route, dict) and route:
return {str(k): str(v) for k, v in route.items()}
except Exception as e: # noqa: BLE001
print(f"[titles] 读取 config.yaml title_templates.route 失败: {e}")
return dict(_TITLE_ROUTE_FALLBACK)
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。"""
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"])
return _inject_now(prompt)
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"},
},
"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_schemastrict 结构化输出);部分兼容厂商不支持 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]
payload = {
"model": model,
"messages": [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_content},
],
"temperature": 0.5,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": PINTEREST_ANALYZE_SCHEMA["name"],
"strict": True,
"schema": PINTEREST_ANALYZE_SCHEMA["schema"],
},
},
}
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 兼容厂商不支持 json_schema
_notify_400(e)
payload["response_format"] = {"type": "json_object"}
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 "")
# 图片输入失败/无有效图片 → 直接放弃该产品(不降级纯文本),由节点跳过后续流程
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 = []
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(),
# 生图参考:每条简报对应其来源爬取图(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 = "") -> Dict[str, Any]:
"""多模态:分析服装图片,生成商品标题(按国家路由模板)。
系统提示词:显式传入优先;否则按 country 经 config.yaml title_templates.route 路由到
prompts/title_prompt_<编号>.md 对应模板(缺失回退内置默认)。
模板 1US/GB/AU/MX)返回 {"en_title","cn_title"}
模板 2JP)额外返回 {"ja_title"}
模板 3ES)返回 {"es_title","cn_title"}。
无 key/调用失败返回 {}(调用方兜底不中断)。
"""
cfg = self._cfg
api_key = cfg.get("api_key", "")
if not api_key:
print("[titles] 未配置 LLM api_keyllm_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(),
"es_title": str(parsed.get("es_title", "")).strip(),
}
except Exception as e: # noqa: BLE001
print(f"[titles] 标题生成失败: {e}")
return {}