"""OpenAI 兼容 LLM 后端(可插拔实现)。 支持 OpenAI / DeepSeek / 通义千问 / Kimi 等 OpenAI 兼容协议。 LLM 调用失败(网络/限流/解析)时抛出异常,由 screen_node 降级到 MockBackend, 保证流水线不中断。内置默认 SYSTEM_PROMPT,国家可在 prompts//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 网关多为国内/自建,走代理会被拦截或变慢。 # 仅请求级 proxies=NO_PROXY 直连,不设置进程级 NO_PROXY 环境变量(避免影响 Google Trends 等外部采集)。 from .base import LLMBackend from graph.paths import runtime_root # 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求) NO_PROXY = {"http": None, "https": None} # —— 默认系统提示词(国家未提供 prompts//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": "", "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": "", "motif": "", "art_style": "", "color_palette": "", "composition": "", "slogan": "", "negative_prompt": "", "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 # 模板 3:西班牙市场(ES)→ es_title + cn_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": "日本語タイトル"}''', "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": "中文标题"}''', } # 国家 → 标题模板编号(JP 路由到模板 2,ES 路由到模板 3,其余默认模板 1;后续可按国家新增模板) TITLE_TEMPLATE_ROUTE: Dict[str, str] = { "US": "1", "GB": "1", "JP": "2", "AU": "1", "MX": "1", "ES": "3", } 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." ) # —— 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 "t-shirt design" style query: think of it as if the user typed " t-shirt design" 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 \" t-shirt design\" 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. negative_prompt: copy of reference artwork, likenesses, characters, logos, trademarks, watermark, photorealistic shirt/product mockups, busy background; add garbled-lettering terms only if your design includes text. OUTPUT — ONLY valid JSON, no fences: {"designs":[{"suitable_for_print":,"negative_prompt":"","image_prompt":""}]}""" PINTEREST_ANALYZE_SCHEMA = { "name": "pinterest_design_briefs", "schema": { "type": "object", "properties": { "designs": { "type": "array", "items": { "type": "object", "properties": { "suitable_for_print": {"type": "boolean"}, "negative_prompt": {"type": "string"}, "image_prompt": {"type": "string"}, }, "required": ["suitable_for_print", "negative_prompt", "image_prompt"], "additionalProperties": False, }, } }, "required": ["designs"], "additionalProperties": False, }, } def build_pinterest_analyze_user_prompt() -> str: 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:] messages = [ {"role": "system", "content": PINTEREST_TERM_SYSTEM_PROMPT}, {"role": "user", "content": build_pinterest_term_user_prompt(ctx)}, ] 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()] # 自动追加 " t-shirt design":让 Pinterest 返回真正的 T 恤印花图(更适合作印花设计参考) terms = [f"{t} t-shirt design" if "t-shirt design" 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) -> List[Dict[str, Any]]: """多模态分析 Pinterest 图片 → 原创设计简报列表。 图片输入失败/无有效图片时直接放弃(返回 [],不降级纯文本),由节点跳过该产品。 on_400: 每次 HTTP 400(且含「内容/图片」)时回调(供调用方累计放弃计数)。 """ 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: user_content: List[Any] = [ {"type": "text", "text": build_pinterest_analyze_user_prompt()}, ] user_content += [{"type": "image_url", "image_url": {"url": u}} for u in data_uris] payload = { "model": model, "messages": [ {"role": "system", "content": PINTEREST_ANALYZE_SYSTEM_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)), "negative_prompt": str(d.get("negative_prompt", "")).strip(), "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 经 TITLE_TEMPLATE_ROUTE 路由到对应模板。 模板 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)}, {"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 {}