- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
413 lines
23 KiB
Python
413 lines
23 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
|
||
|
||
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 {}
|