"""Strategist prompt builders (SPEC_NEW §7.5, §12.4, §15.5) — built in Phase 4. The prompt must state the guards verbatim: ignore creatives with < 2,000 impressions or < 1.5× target-CPA spend as an EXPLOIT basis (the SQL pre-filters; the prompt restates so the model never reasons from excluded data). It must also state the NO-SEED rule (§12.4): the strategist explores/exploits creative levers (angle, avatar, hook pattern, product) only — never a render seed. """ from __future__ import annotations import json from typing import Any # Verbatim guard text, shared so the system prompt and the SQL pre-filter agree. MIN_IMPRESSIONS = 2000 MIN_SPEND_CPA_MULTIPLE = 1.5 STRATEGIST_SYSTEM = f"""You are the strategist for an automated Swedish UGC video-ad \ pipeline. You decide WHAT creative to make next by explore/exploit over CREATIVE \ levers — the ad ANGLE, the AVATAR, the hook pattern, and which PRODUCT to feature. \ You learn from past ad performance, aggregated per creative attribute. You output a batch of briefs. Each brief is a single creative idea for the next \ video to produce. HARD RULES — follow every one: - EXPLORE vs EXPLOIT, 70/30. Roughly seventy percent of briefs must be EXPLOIT \ (double down on a creative attribute that is already proven in the stats) and \ roughly thirty percent must be EXPLORE (try a fresh angle, a new avatar, or a \ combination with little or no data yet). With five briefs that means about three \ or four exploit and one or two explore — and you MUST include at least one of each. - DATA GUARD. A creative attribute may only be used as an EXPLOIT basis if it has \ at least {MIN_IMPRESSIONS} impressions AND at least {MIN_SPEND_CPA_MULTIPLE}× the \ target CPA in spend. Anything with less data is too noisy to exploit — treat it as \ unproven and only ever reach for it as an EXPLORE. New angles and new avatars with \ no data are fair game for EXPLORE. The stats you are given have already excluded \ under-powered creatives from the exploit pool; do not invent performance for any \ attribute you cannot see. - NEVER reason about, mention, or emit a render SEED. The seed is a render-nuisance \ parameter, NOT a creative lever — two videos with the same angle/avatar/product but \ different seeds are the SAME creative. Your feature space is angle, avatar, product, \ hook pattern only. The word "seed" must never appear in your output. - Every brief picks a real avatar_id from the provided avatars list. Pick a \ product_id from the provided products list when one fits the angle (refresh a \ fatiguing product, scale a winner); use null only when no product fits. - hypothesis: one concrete, testable sentence — what you expect this creative to do \ and why (e.g. "social proof on the calm-skin angle with avatar Lina will beat the \ problem-agitate baseline because proven angles convert better for this audience"). \ - rationale: a short plain-Swedish-or-English justification grounded in the stats \ (cite the numbers you used: purchases, revenue, CPA, three-second rate). Output ONLY this JSON object — no prose, no markdown fences: {{"briefs": [{{"mode": "exploit"|"explore", "angle": "string", "avatar_id": "uuid", \ "product_id": "uuid"|null, "hypothesis": "string", "rationale": "string"}}, ...]}}""" def build_user_prompt( stats: list[dict[str, Any]], avatars: list[dict[str, Any]], products: list[dict[str, Any]], n: int, ) -> str: """Render the per-creative stats + the avatar/product menus + the requested brief count into the user message (SPEC_NEW §7.5).""" avatar_lines = "\n".join( f'- {a["id"]} name={a.get("name", "")} ' f'persona={json.dumps(a.get("persona") or {}, ensure_ascii=False)}' for a in avatars ) or "- (none)" product_lines = "\n".join( f'- {p["id"]} name={p.get("name", "")} ' f'knowledge={json.dumps(p.get("knowledge") or {}, ensure_ascii=False)}' for p in products ) or "- (none — set product_id to null)" if stats: stat_lines = "\n".join( f'- angle={s.get("angle") or "(none)"} avatar_id={s.get("avatar_id")} ' f'impressions={s.get("impressions", 0)} spend={s.get("spend", 0)} ' f'purchases={s.get("purchases", 0)} revenue={s.get("revenue", 0)} ' f'cpa={s.get("cpa")} three_s_rate={s.get("three_s_rate")} ' f'exploitable={s.get("exploitable")}' for s in stats ) else: stat_lines = ( "- (no performance data yet — COLD START. Produce EXPLORE briefs across " "fresh angles and the available avatars/products.)" ) return ( f"Produce exactly {n} briefs honoring the 70/30 exploit/explore split " f"(at least one of each).\n\n" f"AVATARS you may choose (pick a real avatar_id):\n{avatar_lines}\n\n" f"PRODUCTS you may choose (pick a real product_id or null):\n{product_lines}\n\n" f"PER-CREATIVE PERFORMANCE (aggregated; under-powered creatives already " f'excluded from the exploit pool; "exploitable" flags proven attributes):\n' f"{stat_lines}\n\n" f"Return the JSON object with the briefs array." )