"""Creative director — turns a campaign dict into a structured creative spec. Calls Claude Haiku ONCE per campaign. Returns a dict with: - scene_prompt: Flux 2 Pro scene prompt (story-aligned visual, no text in image) - headlines: [(text, color_name), …] 2-3 lines, each ≤18 chars - badge: ≤12 char badge label - subtitle: ≤80 char sentence - badge_color: "RED" or "TEAL_DARK" The STYLE for scene prompts is baked into the system prompt so every campaign gets a visually consistent brand look (right-positioned small illustration, left 55% dark negative space, brand colors, no text). On any failure the caller catches and falls back — this module does not swallow errors. """ from __future__ import annotations import os import json import re from typing import Any from anthropic import Anthropic MODEL = "claude-haiku-4-5" SYSTEM_PROMPT = """You are a creative director for TiDB (cloud database product by PingCAP). You convert campaign ideas into structured ad creative specs that will be rendered by an image pipeline. For each campaign you produce a single JSON object with exactly these keys: 1. "scene_prompt": a Flux 2 Pro image prompt describing a VISUAL SCENE that tells the campaign's story. STYLE RULES (all mandatory): - "A stylized 3D cartoon illustration of …" (always open this way) - Small illustration positioned in the RIGHT THIRD of the frame - "Left 55% is clean pure black negative space" (always include this phrase verbatim) - Brand colors: translucent red, teal, occasional orange accents - Dark background, dramatic rim lighting, modern tech startup marketing illustration style - Sophisticated minimal composition - "No text no words no letters anywhere in the image" (always include this verbatim at the end) - The illustrated object must relate to the campaign story (e.g. memory card for "agent memory", rocket for "launch", stopwatch for "speed challenge") 2. "headlines": array of 2 or 3 short headline lines. Each line is [text, color]. - Each text ≤18 characters - Colors allowed: "WHITE", "RED", "TEAL", "ORANGE" - Exactly one accent-color line (RED/TEAL/ORANGE); the rest WHITE - Together the lines form a punchy headline that summarises the campaign angle 3. "badge": ≤12 char label describing the campaign type (e.g. "Open Source", "Show HN", "Live Demo", "Benchmark", "Challenge", "Tutorial", "5s Setup", "Get Started") 4. "subtitle": ≤80 char sentence describing the concrete value proposition for this specific campaign 5. "badge_color": "RED" for launches/product/code campaigns, "TEAL_DARK" for nurture/activation/tutorial campaigns OUTPUT: Respond with ONLY the JSON object, no markdown fences, no prose, no explanation. NOTE on event_meta: event metadata (kind/when/where/timezone) for webinar/in-person campaigns comes from the SPEC's `campaign.event` field — NOT from you. The compositor renders it directly. You don't need to emit it.""" def _client() -> Anthropic: key = os.environ.get("ANTHROPIC_API_KEY", "").strip() if not key: raise RuntimeError("ANTHROPIC_API_KEY not set in environment") return Anthropic(api_key=key) def _extract_json(text: str) -> dict: """Pull the first JSON object out of a model response, tolerant of stray prose or fences.""" # Strip markdown fences if present text = re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=re.MULTILINE) text = re.sub(r"\s*```\s*$", "", text.strip(), flags=re.MULTILINE) # Grab the first {...} block m = re.search(r"\{.*\}", text, re.DOTALL) if not m: raise ValueError(f"No JSON object found in model output: {text[:200]}") return json.loads(m.group(0)) VALID_HEADLINE_COLORS = ("WHITE", "RED", "TEAL", "ORANGE") VALID_BADGE_COLORS = ("RED", "TEAL_DARK") VALID_EVENT_KINDS = ("webinar", "in_person", "live_stream", "conference", "workshop") MAX_HEADLINE_CHARS = 22 MAX_BADGE_CHARS = 20 # bumped from 16 — Haiku tends to return 17-18 for multi-word badges MAX_SUBTITLE_CHARS = 130 MAX_EVENT_FIELD_CHARS = 32 # generous cap; compositor truncates further per pixel width def _coerce_creative(raw: Any) -> dict[str, Any]: """Best-effort normalize Haiku output into the schema the compositor needs. Forgiving by design: truncates over-long text, coerces single-element headline items into [text, 'WHITE'], etc. Hard-raises only on things the compositor cannot work around (missing scene_prompt, zero headlines). """ if not isinstance(raw, dict): raise ValueError(f"creative spec is not a dict: {type(raw).__name__}") spec: dict[str, Any] = {} # ── scene_prompt (hard required) ── sp = raw.get("scene_prompt") if not isinstance(sp, str) or not sp.strip(): raise ValueError(f"scene_prompt missing or empty: {raw}") # Ensure mandatory style phrases are present; append if missing rather than failing lower = sp.lower() if "negative space" not in lower: sp = sp.rstrip(". ") + ". Left 55% is clean pure black negative space." if "no text" not in sp.lower(): sp = sp.rstrip(". ") + ". No text no words no letters anywhere in the image." spec["scene_prompt"] = sp # ── headlines (coerce + truncate) ── raw_heads = raw.get("headlines") if not isinstance(raw_heads, list) or len(raw_heads) == 0: raise ValueError(f"headlines missing or empty: {raw_heads}") coerced_heads: list[list[str]] = [] for item in raw_heads: if isinstance(item, str): text_, color = item, "WHITE" elif isinstance(item, list) and len(item) == 1: text_, color = str(item[0]), "WHITE" elif isinstance(item, list) and len(item) >= 2: text_, color = str(item[0]), str(item[1]).upper() elif isinstance(item, dict) and "text" in item: text_, color = str(item["text"]), str(item.get("color", "WHITE")).upper() else: # Skip garbage entries instead of failing continue if not text_.strip(): continue if len(text_) > MAX_HEADLINE_CHARS: # Word-boundary cut: prefer the last space within the budget so # we don't mangle words like "pelo uso" → "pelo usad" (bug # seen on Portuguese banner 2026-06-05). Threshold 60% so an # impossibly-long single word still gets a hard cut rather than # disappearing entirely. Mirrors the subtitle logic below. cut = text_[:MAX_HEADLINE_CHARS] last_space = cut.rfind(" ") if last_space > MAX_HEADLINE_CHARS * 0.6: text_ = cut[:last_space].rstrip() else: text_ = cut.rstrip() if color not in VALID_HEADLINE_COLORS: color = "WHITE" coerced_heads.append([text_, color]) if not coerced_heads: raise ValueError(f"no usable headlines after coercion: {raw_heads}") # Cap to 3 lines if len(coerced_heads) > 3: coerced_heads = coerced_heads[:3] # Guarantee at least one accent color line (not WHITE) if not any(c != "WHITE" for _, c in coerced_heads): coerced_heads[0][1] = "RED" spec["headlines"] = coerced_heads # ── badge (truncate, word-boundary) ── badge = str(raw.get("badge") or "").strip() or "Learn More" if len(badge) > MAX_BADGE_CHARS: cut = badge[:MAX_BADGE_CHARS] last_space = cut.rfind(" ") if last_space > MAX_BADGE_CHARS * 0.6: badge = cut[:last_space].rstrip() else: badge = cut.rstrip() spec["badge"] = badge # ── subtitle (truncate) ── subtitle = str(raw.get("subtitle") or "").strip() if len(subtitle) > MAX_SUBTITLE_CHARS: # Prefer cutting at last word boundary cut = subtitle[:MAX_SUBTITLE_CHARS] last_space = cut.rfind(" ") if last_space > MAX_SUBTITLE_CHARS * 0.6: cut = cut[:last_space] subtitle = cut.rstrip(" ,.;:") + "…" spec["subtitle"] = subtitle # ── badge_color ── bc = str(raw.get("badge_color") or "").upper().strip() if bc not in VALID_BADGE_COLORS: bc = "RED" spec["badge_color"] = bc # event_meta no longer comes from Haiku — passed in from spec.campaign.event # by generate_campaign_images.py. Kept this comment as a reminder to anyone # wondering why Haiku doesn't emit it: dates are deterministic data, not # creative inference, so they don't need an LLM round-trip. return spec def _detect_campaign_language(name: str, desc: str) -> str | None: """Best-effort language detection from campaign name + description. Returns an ISO-style label ('Portuguese', 'Spanish', 'Japanese', 'Korean', 'Chinese') when one of the supported non-English languages is clearly dominant; returns None when the text reads as English or can't be classified. Used to inject a language hint into the Haiku prompt so headlines + badge text come out in the campaign's language instead of leaking English (verified 2026-06-05: "Auto-Scale" English badge on a Portuguese banner because no hint was passed). Cheap heuristic — Portuguese-specific tokens, accented vowels, CJK code-block ranges. Not a real NLP detector. If you want robust detection, drop in `langdetect` later. For now, false positives are cheap (Haiku gets a redundant hint) and false negatives degrade gracefully (LLM picks language from campaign name on its own). """ haystack = f"{name} {desc}".lower() if any(t in haystack for t in (" escale ", " sem ", " você ", " ociosos", " você", "recursos", " precisar", "aprenda")): return "Brazilian Portuguese" if any(t in haystack for t in (" ¿", " ñ", " usted ", "aprende", "gratis", " usted")): return "Spanish" # CJK: any character in the Han / Hiragana / Katakana / Hangul ranges for ch in (name + desc)[:200]: cp = ord(ch) if 0x3040 <= cp <= 0x309F or 0x30A0 <= cp <= 0x30FF: return "Japanese" if 0xAC00 <= cp <= 0xD7AF: return "Korean" if 0x4E00 <= cp <= 0x9FFF: return "Chinese" return None def direct_campaign(campaign: dict[str, Any]) -> dict[str, Any]: """Call Haiku for one campaign, return a validated creative spec dict. Forgiving: coerces/truncates Haiku output to match the compositor schema. Raises only on API failure or fundamentally broken output (no scene_prompt, no headlines) — the caller catches and falls back. """ # Haiku gets ONLY what it needs to do creative work — scene, headlines, # badge. event_meta (date/location/timezone) is structured data already # in the spec; we don't waste an LLM round-trip re-extracting it. payload = { "id": campaign.get("id", ""), "name": campaign.get("name", ""), "description": campaign.get("description", ""), "goal": campaign.get("goal", ""), "audience": campaign.get("targetAudience", campaign.get("audience", "")), "utm": campaign.get("utm", ""), } # Language hint — moved OUT of the JSON payload (2026-06-05 v2). # # Earlier version (v1) embedded `_language_rule` as a property INSIDE the # JSON payload. Empirically Haiku treated that as DATA describing the # campaign rather than an INSTRUCTION about output. Combined with a # SYSTEM_PROMPT densely seeded with English example badges ("Show HN", # "Live Demo", "Benchmark") and English style rules, the JSON-embedded # hint lost the tug-of-war and Haiku emitted English text on Portuguese # campaigns (verified: LATAM v06 banner came back "Scale Without the # Limits / MySQL scaling solved" / "Benchmark" badge despite the camp # name being fully Portuguese — and despite v03 having gotten it right # under an earlier prompt shape). # # New shape: keep `language` in the payload for parsability, but ALSO # emit a prose directive AFTER the JSON. Prose comes last in the user # message (closest to "Return the JSON spec now") and is framed as an # explicit override of the system prompt's English examples — the only # reliable way to flip Haiku's defaults. lang = _detect_campaign_language(payload["name"], payload["description"]) if lang: payload["language"] = lang user_msg = json.dumps(payload, indent=2, ensure_ascii=False) lang_directive = "" if lang: lang_directive = ( f"\n\nCRITICAL LANGUAGE REQUIREMENT (overrides every English example in the system prompt):\n" f"All output text — every line in `headlines`, the `badge` string, and the `subtitle` — " f"MUST be written in {lang}. The English badges and headlines shown in the system prompt " f"are STRUCTURAL TEMPLATES, not literal text — adapt their meaning to {lang}.\n" f"`scene_prompt` stays in English (Flux 2 Pro only understands English prompts reliably).\n" f"Do not mix English words into {lang} text. Language inconsistency is the #1 banner-quality " f"complaint we receive." ) # Anthropic API occasionally returns 529 Overloaded under load (esp. when # multiple campaigns ideate in parallel). Retry up to 3 times with # exponential backoff before giving up. Without this, a single transient # overload aborts the whole spec's image-gen and the user re-runs from # scratch. Anthropic's own SDK doesn't auto-retry 529s. import time as _time from anthropic import APIStatusError as _AnthAPIErr MAX_RETRIES = 3 backoff = [0, 4, 12] # seconds last_err = None for attempt in range(MAX_RETRIES): if backoff[attempt] > 0: _time.sleep(backoff[attempt]) try: resp = _client().messages.create( model=MODEL, max_tokens=1024, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": f"Campaign:\n{user_msg}{lang_directive}\n\nReturn the JSON spec now."}], ) text = resp.content[0].text if resp.content else "" raw = _extract_json(text) return _coerce_creative(raw) except _AnthAPIErr as e: # Retry on 5xx / overloaded; bail on 4xx (auth, validation, etc.) status = getattr(e, "status_code", 0) last_err = e if status >= 500 and attempt < MAX_RETRIES - 1: print(f" [haiku] {status} {e.__class__.__name__} — retry {attempt+1}/{MAX_RETRIES-1} in {backoff[attempt+1]}s") continue raise # Defensive — loop should have either returned or raised. if last_err: raise last_err