Spaces:
Runtime error
Runtime error
| """Claude agents. | |
| - break_script: the MVP donor's script-chunking skill β split one free-form | |
| Swedish script into hook / body / cta chunks (SPEC_NEW Β§7.5). | |
| Each chunk is sized to fit a 4β8 s clip (β€ 16 words). | |
| - rephrase_spoken_line: SPEC_NEW Β§12.5 β used by the RAI policy in veo.py. | |
| - strategist / write_script: SPEC_NEW Β§7.5 β Phase 4 (stubs below). | |
| Both LLM helpers return JSON-or-text only; break_script parses defensively and | |
| retries once with the parse error appended (the contract). | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| import re | |
| from psycopg.types.json import Json | |
| from .config import get_settings | |
| from .duration import MAX_SPOKEN_WORDS, count_spoken_words | |
| # ββ break_script: free script β chunks (the MVP donor's clip-production skill) ββ | |
| BREAK_SCRIPT_SYSTEM = f"""You break a short Swedish UGC video script into spoken \ | |
| chunks for short vertical video clips. Each chunk becomes ONE 4β8 second clip in \ | |
| which the avatar says exactly that line to camera. | |
| Rules: | |
| - Output one hook (the opening line), zero to four body chunks, and one cta (the \ | |
| closing line). | |
| - EVERY chunk must be at most {MAX_SPOKEN_WORDS} spoken words β a hard limit. Aim \ | |
| for 6β14 words per body chunk; split any longer sentence into several body chunks \ | |
| rather than exceeding {MAX_SPOKEN_WORDS} words. | |
| - The number of body chunks follows the script length (aim 1β4). If the script is \ | |
| very short, return an empty body list (just hook + cta). | |
| - Keep natural, spoken Swedish (talsprΓ₯k β contractions, "assΓ₯", "typ" where it \ | |
| fits). Do not invent claims or add words the script does not imply. | |
| - The hook must front-load the main point in the first ~5 words. | |
| - Spell out every number or price in Swedish words ("trehundra kronor", never \ | |
| "300 kr"). No digits anywhere. | |
| - Do NOT use any quotation marks inside a chunk. | |
| - Output ONLY this JSON object β no prose, no markdown fences: | |
| {{"hook": "string", "body_segments": ["string", ...], "cta": "string"}}""" | |
| def _split_long(text: str, max_words: int = MAX_SPOKEN_WORDS) -> list[str]: | |
| """Safety net: greedily split a chunk that exceeds the word cap so a slightly | |
| disobedient model never blocks video creation.""" | |
| words = text.split() | |
| if len(words) <= max_words: | |
| return [text.strip()] | |
| return [" ".join(words[i:i + max_words]) for i in range(0, len(words), max_words)] | |
| def _parse_chunks(raw: str) -> dict: | |
| s = raw.strip() | |
| a, b = s.find("{"), s.rfind("}") | |
| if a == -1 or b == -1: | |
| raise ValueError("no JSON object in break_script output") | |
| obj = json.loads(s[a:b + 1]) | |
| hook = str(obj.get("hook") or "").strip() | |
| cta = str(obj.get("cta") or "").strip() | |
| body_raw = [str(x).strip() for x in (obj.get("body_segments") or []) if str(x).strip()] | |
| if not hook: | |
| raise ValueError("break_script returned no hook") | |
| # Guarantee every chunk fits a clip (hook/cta are short by nature; bodies may | |
| # need the safety split). | |
| body: list[str] = [] | |
| for chunk in body_raw: | |
| body.extend(_split_long(chunk)) | |
| if count_spoken_words(hook) > MAX_SPOKEN_WORDS: | |
| pieces = _split_long(hook) | |
| hook = pieces[0] | |
| body = pieces[1:] + body | |
| return {"hook": hook, "body_segments": body, "cta": cta} | |
| def _fallback_chunks(script_text: str) -> dict: | |
| """Key-free chunking: split into sentences, cap each at MAX_SPOKEN_WORDS, map to | |
| hook / body[] / cta. Keeps the manusβclips flow working with no ANTHROPIC_API_KEY | |
| (e.g. a free public demo). Mirrors the deterministic fallbacks of the other agents.""" | |
| cleaned = re.sub(r"[\"ββ']", "", script_text).strip() | |
| sentences = [p.strip() for p in re.split(r"(?<=[.!?])\s+|\n+", cleaned) if p.strip()] | |
| pieces: list[str] = [] | |
| for sent in sentences: | |
| pieces.extend(_split_long(sent)) | |
| if not pieces: | |
| raise ValueError("empty script") | |
| hook = pieces[0] | |
| if len(pieces) == 1: | |
| return {"hook": hook, "body_segments": [], "cta": ""} | |
| return {"hook": hook, "body_segments": pieces[1:-1], "cta": pieces[-1]} | |
| def break_script(script_text: str, avatar: dict | None = None) -> dict: | |
| """Split a free-form Swedish script into {hook, body_segments[], cta} sized to | |
| 4β8 s clips. Uses Claude when ANTHROPIC_API_KEY is set; otherwise a deterministic | |
| rule-based fallback (so the flow works key-free, e.g. a public demo).""" | |
| script_text = (script_text or "").strip() | |
| if not script_text: | |
| raise ValueError("empty script") | |
| s = get_settings() | |
| if not s.anthropic_api_key: | |
| return _fallback_chunks(script_text) | |
| import anthropic | |
| client = anthropic.Anthropic(api_key=s.anthropic_api_key) | |
| last_err: str | None = None | |
| for attempt in range(2): | |
| user = script_text if attempt == 0 else ( | |
| f"{script_text}\n\nDitt fΓΆrra svar gick inte att tolka som JSON " | |
| f"({last_err}). Svara ENBART med giltig JSON enligt formatet." | |
| ) | |
| resp = client.messages.create( | |
| model=s.anthropic_model, max_tokens=1024, | |
| system=BREAK_SCRIPT_SYSTEM, messages=[{"role": "user", "content": user}], | |
| ) | |
| text = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") | |
| try: | |
| return _parse_chunks(text) | |
| except Exception as exc: # noqa: BLE001 β retry once, then fail loud | |
| last_err = str(exc) | |
| raise RuntimeError(f"break_script could not produce valid JSON: {last_err}") | |
| # ββ RAI rephrase (SPEC_NEW Β§12.5) ββββββββββββββββββββββββββββββββββββββββββββ | |
| REPHRASE_SYSTEM = """You receive ONE spoken sentence in Swedish that an avatar says \ | |
| straight to camera in a short UGC ad. A video model's safety filter blocked it β \ | |
| almost always because the line sounds too imperative, makes a health claim, or reads \ | |
| as overt sales/marketing copy. | |
| Rewrite ONLY this one sentence. Make it softer, more conversational, more \ | |
| descriptive, less directly imperative β while preserving the original intent and the \ | |
| avatar's natural voice. Hard constraints: | |
| - Keep it in colloquial spoken Swedish (talsprΓ₯k). | |
| - At most 16 words. | |
| - No digits β spell any number or price out in Swedish words ("trehundra kronor"). | |
| - No quotation marks of any kind. | |
| Return ONLY the rewritten sentence as plain text. No prefix, no quotes, no commentary.""" | |
| def rephrase_spoken_line(spoken_text: str) -> str: | |
| """Minimally rewrite a single Swedish line so it is likelier to pass Veo's RAI | |
| filter on retry (SPEC_NEW Β§12.5). Returns the bare sentence.""" | |
| s = get_settings() | |
| s.require("ANTHROPIC_API_KEY") | |
| import anthropic | |
| client = anthropic.Anthropic(api_key=s.anthropic_api_key) | |
| response = client.messages.create( | |
| model=s.anthropic_model, max_tokens=512, | |
| system=REPHRASE_SYSTEM, messages=[{"role": "user", "content": spoken_text}], | |
| ) | |
| text = "".join(b.text for b in response.content if getattr(b, "type", None) == "text").strip() | |
| text = text.strip().strip('"').strip("β").strip("β").strip() | |
| if not text: | |
| raise RuntimeError("rephrase returned empty text") | |
| return text | |
| # ββ Phase 4: strategist + scriptwriter (the learning brain, SPEC_NEW Β§7.5) ββββ | |
| # | |
| # strategize(): aggregate the last N days of metrics per CREATIVE attribute | |
| # (angle + avatar), explore/exploit 70/30, pick a product (Β§15.5), insert briefs. | |
| # It NEVER sees, reasons about, or emits a render seed (Β§12.4): its SQL reads | |
| # videos.tags only, so a seed cannot enter its feature space. | |
| # write_script(): turn one brief into a validated {hook, body_segments[], cta} and | |
| # wire it through flow.create_video, then stamp brief/product/mode/hypothesis onto | |
| # the row β with NO seed key in videos.tags (Β§12.4). | |
| # EXPLOIT-basis data guards (mirror prompts.strategist; the SQL pre-filters, the | |
| # prompt restates). A creative needs real signal before we double down on it. | |
| MIN_EXPLOIT_IMPRESSIONS = 2000 | |
| MIN_EXPLOIT_SPEND_CPA_MULTIPLE = 1.5 | |
| def _aggregate_creative_stats(days: int, target_cpa: float) -> list[dict]: | |
| """Aggregate metrics_daily (last `days`) per CREATIVE attribute β angle | |
| (videos.tags->>'angle') + avatar_id β joining publishes β videos (SPEC_NEW Β§7.5). | |
| The strategist's feature space is creative levers ONLY: this SQL reads | |
| videos.tags, never videos.seed (Β§12.4). Each row carries the EXPLOIT guard | |
| verdict (`exploitable`) so the fallback and the prompt agree on what is proven. | |
| """ | |
| from . import db | |
| rows = db.fetch_all( | |
| """ | |
| select | |
| v.tags->>'angle' as angle, | |
| v.avatar_id as avatar_id, | |
| coalesce(sum(m.impressions), 0) as impressions, | |
| coalesce(sum(m.spend), 0) as spend, | |
| coalesce(sum(m.video_3s), 0) as video_3s, | |
| coalesce(sum(m.purchases), 0) as purchases, | |
| coalesce(sum(m.revenue), 0) as revenue | |
| from metrics_daily m | |
| join publishes p on p.ad_id = m.ad_id | |
| join videos v on v.id = p.video_id | |
| where m.date >= (current_date - %s::int) | |
| group by v.tags->>'angle', v.avatar_id | |
| order by purchases desc, revenue desc | |
| """, | |
| (days,), | |
| ) | |
| spend_floor = MIN_EXPLOIT_SPEND_CPA_MULTIPLE * float(target_cpa or 0.0) | |
| stats: list[dict] = [] | |
| for r in rows: | |
| impressions = int(r["impressions"] or 0) | |
| spend = float(r["spend"] or 0.0) | |
| video_3s = int(r["video_3s"] or 0) | |
| purchases = int(r["purchases"] or 0) | |
| revenue = float(r["revenue"] or 0.0) | |
| cpa = round(spend / purchases, 2) if purchases else None | |
| three_s_rate = round(video_3s / impressions, 4) if impressions else None | |
| exploitable = impressions >= MIN_EXPLOIT_IMPRESSIONS and spend >= spend_floor | |
| stats.append({ | |
| "angle": r["angle"], | |
| "avatar_id": str(r["avatar_id"]) if r["avatar_id"] else None, | |
| "impressions": impressions, | |
| "spend": round(spend, 2), | |
| "video_3s": video_3s, | |
| "purchases": purchases, | |
| "revenue": round(revenue, 2), | |
| "cpa": cpa, | |
| "three_s_rate": three_s_rate, | |
| "exploitable": exploitable, | |
| }) | |
| return stats | |
| # A small rotation of fresh angles for EXPLORE / cold start, so the strategist | |
| # always has something new to try even with zero data. | |
| _EXPLORE_ANGLES = [ | |
| "problem-agitate", "social-proof", "before-after", "founder-story", | |
| "myth-busting", "day-in-the-life", "unboxing-reaction", "comparison", | |
| ] | |
| def _round_robin(items: list, i: int): | |
| return items[i % len(items)] if items else None | |
| def _fallback_briefs( | |
| stats: list[dict], avatars: list[dict], products: list[dict], n: int, target_cpa: float, | |
| ) -> list[dict]: | |
| """Deterministic strategist for no-key / dry-run (SPEC_NEW Β§7.5). | |
| Exploit the best PROVEN angle+avatar (most purchases, then revenue); explore a | |
| fresh angle on an existing avatar; round-robin avatars/products across the rest. | |
| Never emits a seed (Β§12.4). Cold start (no proven stats) yields explore briefs. | |
| """ | |
| avatar_ids = [str(a["id"]) for a in avatars] | |
| product_ids = [str(p["id"]) for p in products] | |
| used_angles = {(s.get("angle") or "").strip() for s in stats if s.get("angle")} | |
| proven = [s for s in stats if s.get("exploitable") and s.get("avatar_id") in set(avatar_ids)] | |
| proven.sort(key=lambda s: (s.get("purchases", 0), s.get("revenue", 0.0)), reverse=True) | |
| # Target split: 70% exploit, 30% explore, but only as many exploits as we have | |
| # proven creatives to base them on (everything else becomes an explore). | |
| want_exploit = min(len(proven), round(n * 0.7)) if proven else 0 | |
| # Guarantee at least one explore when n >= 2 (the acceptance test wants a mix). | |
| if n >= 2 and want_exploit >= n: | |
| want_exploit = n - 1 | |
| briefs: list[dict] = [] | |
| pi = 0 | |
| # EXPLOIT briefs β double down on the best proven angle+avatar. | |
| for k in range(want_exploit): | |
| src = proven[k % len(proven)] | |
| product_id = _round_robin(product_ids, pi) if product_ids else None | |
| pi += 1 | |
| cpa = src.get("cpa") | |
| cpa_txt = f"{cpa} kr CPA" if cpa is not None else "an unknown CPA" | |
| briefs.append({ | |
| "mode": "exploit", | |
| "angle": src.get("angle") or "social-proof", | |
| "avatar_id": src["avatar_id"], | |
| "product_id": product_id, | |
| "hypothesis": ( | |
| f"Doubling down on the proven {src.get('angle') or 'social-proof'} angle " | |
| f"with this avatar should keep CPA at or below {cpa_txt} while scaling spend." | |
| ), | |
| "rationale": ( | |
| f"Proven creative: {src.get('purchases', 0)} purchases, " | |
| f"{src.get('revenue', 0.0)} revenue over {src.get('impressions', 0)} impressions " | |
| f"({cpa_txt}); clears the {MIN_EXPLOIT_IMPRESSIONS}-impression / " | |
| f"{MIN_EXPLOIT_SPEND_CPA_MULTIPLE}x-CPA-spend guard, so it is safe to exploit." | |
| ), | |
| "basis": src, | |
| }) | |
| # EXPLORE briefs β fresh angles on existing avatars, round-robined. | |
| fresh = [a for a in _EXPLORE_ANGLES if a not in used_angles] or list(_EXPLORE_ANGLES) | |
| ai, fa = 0, 0 | |
| while len(briefs) < n: | |
| avatar_id = _round_robin(avatar_ids, ai) | |
| ai += 1 | |
| angle = _round_robin(fresh, fa) | |
| fa += 1 | |
| product_id = _round_robin(product_ids, pi) if product_ids else None | |
| pi += 1 | |
| briefs.append({ | |
| "mode": "explore", | |
| "angle": angle, | |
| "avatar_id": avatar_id, | |
| "product_id": product_id, | |
| "hypothesis": ( | |
| f"A fresh {angle} angle on this avatar may open a new winning creative " | |
| f"lane that the current data does not yet cover." | |
| ), | |
| "rationale": ( | |
| "Explore lane: this angle/avatar pairing has little or no proven data " | |
| f"(under the {MIN_EXPLOIT_IMPRESSIONS}-impression guard), so it is a new " | |
| "bet rather than an exploit β worth a probe to widen the learning." | |
| if stats else | |
| "Cold start: no performance data yet, so every brief is an explore probe " | |
| "across fresh angles and the available avatars." | |
| ), | |
| "basis": {"cold_start": not stats, "explored_angle": angle}, | |
| }) | |
| return briefs[:n] | |
| def _parse_strategist_json(raw: str) -> list[dict]: | |
| """Defensive parse of the strategist's JSON (first '{' to last '}'). Returns the | |
| briefs list; raises ValueError on anything malformed.""" | |
| s = raw.strip() | |
| a, b = s.find("{"), s.rfind("}") | |
| if a == -1 or b == -1: | |
| raise ValueError("no JSON object in strategist output") | |
| obj = json.loads(s[a:b + 1]) | |
| briefs = obj.get("briefs") | |
| if not isinstance(briefs, list) or not briefs: | |
| raise ValueError("strategist JSON has no non-empty 'briefs' array") | |
| out: list[dict] = [] | |
| for item in briefs: | |
| if not isinstance(item, dict): | |
| continue | |
| mode = str(item.get("mode") or "").strip().lower() | |
| angle = str(item.get("angle") or "").strip() | |
| avatar_id = item.get("avatar_id") | |
| if mode not in ("exploit", "explore") or not angle or not avatar_id: | |
| raise ValueError(f"malformed brief: {item!r}") | |
| out.append({ | |
| "mode": mode, | |
| "angle": angle, | |
| "avatar_id": str(avatar_id), | |
| "product_id": str(item["product_id"]) if item.get("product_id") else None, | |
| "hypothesis": str(item.get("hypothesis") or "").strip(), | |
| "rationale": str(item.get("rationale") or "").strip(), | |
| }) | |
| if not out: | |
| raise ValueError("strategist returned no usable briefs") | |
| return out | |
| def strategize(days: int = 14, n: int | None = None) -> list[dict]: | |
| """Decide the next N creatives and insert them as pending briefs (SPEC_NEW Β§7.5). | |
| Aggregates the last `days` of metrics_daily per creative attribute (angle + | |
| avatar), honors a 70/30 exploit/explore split under the EXPLOIT data guard | |
| (< 2000 impressions or < 1.5Γ target-CPA spend is unproven), picks a product | |
| per brief (Β§15.5), and NEVER touches a render seed (Β§12.4). Uses Claude | |
| (STRATEGIST_SYSTEM) when ANTHROPIC_API_KEY is set; otherwise a deterministic | |
| fallback runs (so dry-run/tests work). Cold start still yields explore briefs. | |
| Returns the inserted brief dicts (uuids stringified). | |
| """ | |
| from . import db | |
| from .prompts import strategist as sp | |
| n = 5 if n is None else int(n) | |
| if n < 1: | |
| raise ValueError("strategize: n must be >= 1") | |
| s = get_settings() | |
| avatars = db.fetch_all( | |
| "select id, name, persona, prompt_template from avatars " | |
| "where coalesce(status,'active') = 'active' order by created_at" | |
| ) | |
| if not avatars: | |
| raise RuntimeError("strategize: no avatars exist β create at least one first") | |
| products = db.fetch_all( | |
| "select id, name, knowledge from products where active = true order by created_at" | |
| ) | |
| stats = _aggregate_creative_stats(days, s.target_cpa) | |
| briefs: list[dict] = [] | |
| if s.anthropic_api_key: | |
| import anthropic | |
| # The model never sees under-powered creatives as exploitable, and never a | |
| # seed: stats carry only creative attributes (Β§12.4). | |
| exploit_pool = [dict(st) for st in stats if st.get("exploitable")] | |
| prompt_stats = exploit_pool + [st for st in stats if not st.get("exploitable")] | |
| avatar_ids = {str(a["id"]) for a in avatars} | |
| product_ids = {str(p["id"]) for p in products} | |
| by_key = {(st.get("angle"), st.get("avatar_id")): st for st in stats} | |
| client = anthropic.Anthropic(api_key=s.anthropic_api_key) | |
| user = sp.build_user_prompt(prompt_stats, avatars, products, n) | |
| last_err: str | None = None | |
| for attempt in range(2): | |
| content = user if attempt == 0 else ( | |
| f"{user}\n\nYour previous reply could not be used ({last_err}). " | |
| f"Reply with ONLY valid JSON in the required format." | |
| ) | |
| resp = client.messages.create( | |
| model=s.anthropic_model, max_tokens=2048, | |
| system=sp.STRATEGIST_SYSTEM, messages=[{"role": "user", "content": content}], | |
| ) | |
| text = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") | |
| try: | |
| parsed = _parse_strategist_json(text) | |
| except Exception as exc: # noqa: BLE001 β retry once, then fall back | |
| last_err = str(exc) | |
| continue | |
| # Keep only briefs on a real avatar; attach the metric snapshot as basis. | |
| for bf in parsed: | |
| if bf["avatar_id"] not in avatar_ids: | |
| continue | |
| if bf["product_id"] not in product_ids: | |
| bf["product_id"] = None | |
| bf["basis"] = by_key.get((bf["angle"], bf["avatar_id"])) or { | |
| "angle": bf["angle"], "avatar_id": bf["avatar_id"], "novel": True, | |
| } | |
| briefs.append(bf) | |
| if briefs: | |
| break | |
| last_err = "no brief landed on a real avatar" | |
| if not briefs: # Claude failed twice β deterministic fallback (still works) | |
| briefs = _fallback_briefs(stats, avatars, products, n, s.target_cpa) | |
| else: | |
| briefs = _fallback_briefs(stats, avatars, products, n, s.target_cpa) | |
| # Persist each brief (source=strategist, status=pending, basis = the snapshot). | |
| inserted: list[dict] = [] | |
| with db.connect() as conn: | |
| for bf in briefs: | |
| basis = bf.get("basis") or {} | |
| row = conn.execute( | |
| """ | |
| insert into briefs | |
| (source, mode, angle, avatar_id, product_id, hypothesis, rationale, basis, status) | |
| values ('strategist', %s, %s, %s, %s, %s, %s, %s, 'pending') | |
| returning id, source, mode, angle, avatar_id, product_id, hypothesis, | |
| rationale, basis, status, created_at | |
| """, | |
| (bf["mode"], bf["angle"], bf["avatar_id"], bf.get("product_id"), | |
| bf.get("hypothesis") or "", bf.get("rationale") or "", | |
| Json(basis)), | |
| ).fetchone() | |
| d = dict(row) | |
| d["id"] = str(d["id"]) | |
| d["avatar_id"] = str(d["avatar_id"]) if d.get("avatar_id") else None | |
| d["product_id"] = str(d["product_id"]) if d.get("product_id") else None | |
| inserted.append(d) | |
| return inserted | |
| def _winning_hook_examples(limit: int = 3) -> list[str]: | |
| """Recent hooks from high-purchase videos (SPEC_NEW Β§7.5 winning-hook context). | |
| Best-effort β returns [] when there is no performance data yet.""" | |
| from . import db | |
| rows = db.fetch_all( | |
| """ | |
| select sc.hook, coalesce(sum(m.purchases), 0) as purchases | |
| from publishes p | |
| join videos v on v.id = p.video_id | |
| join scripts sc on sc.id = v.script_id | |
| join metrics_daily m on m.ad_id = p.ad_id | |
| where sc.hook is not null and sc.hook <> '' | |
| group by sc.hook | |
| order by purchases desc, max(p.published_at) desc nulls last | |
| limit %s | |
| """, | |
| (limit,), | |
| ) | |
| return [r["hook"] for r in rows if (r.get("purchases") or 0) > 0] | |
| def _validate_segments(hook: str, body_segments: list[str], cta: str) -> None: | |
| """Every segment must pass the Veo line validator AND fit a duration bucket | |
| (SPEC_NEW Β§7.5). Raises on the first offender.""" | |
| from .duration import decide_duration | |
| from .prompts.veo_segment import validate_spoken_text | |
| for text in [hook, *body_segments, cta]: | |
| if not text: | |
| continue | |
| validate_spoken_text(text) | |
| decide_duration(text) | |
| def _safety_split_script(hook: str, body_segments: list[str], cta: str) -> tuple[str, list[str], str]: | |
| """Final safety net (mirrors break_script): greedily split any over-cap line so a | |
| slightly disobedient model can never block video creation. Hook/CTA overflow | |
| spills into the body so they stay single, front-loaded lines.""" | |
| body: list[str] = [] | |
| for seg in body_segments: | |
| body.extend(p for p in _split_long(seg) if p) | |
| hook_pieces = _split_long(hook) | |
| hook, body = hook_pieces[0], hook_pieces[1:] + body | |
| if cta: | |
| cta_pieces = _split_long(cta) | |
| cta, body = cta_pieces[-1], body + cta_pieces[:-1] | |
| return hook, body, cta | |
| def _fallback_script(brief: dict, avatar: dict) -> dict: | |
| """Deterministic scriptwriter for no-key / dry-run (SPEC_NEW Β§7.5). | |
| Hand-built talsprΓ₯k lines that ALL pass validate_spoken_text + decide_duration | |
| (β€ 18 words, no digits, no quotes, numbers spelled out) β 10/10, every run. | |
| Front-loaded hook, concrete CTA. Personalized lightly from the brief angle. | |
| """ | |
| angle = (brief.get("angle") or "social-proof").strip() | |
| name = (avatar.get("name") or "").strip() | |
| intro = f"AssΓ₯, {name}, " if name else "AssΓ₯, " | |
| # Each line is short, colloquial, digit-free, quote-free, β€ 18 words. | |
| hook = "AssΓ₯ det hΓ€r bytte faktiskt allt fΓΆr mig" | |
| body = [ | |
| f"{intro}jag var helt skeptisk i bΓΆrjan, typ att det aldrig skulle funka.", | |
| "Men efter bara nΓ₯n vecka kΓ€nde jag verkligen skillnaden, helt Γ€rligt.", | |
| "Nu kan jag liksom inte tΓ€nka mig att vara utan den lΓ€ngre.", | |
| ] | |
| if angle in ("before-after", "comparison"): | |
| body[0] = "Innan var det jobbigt varje dag, assΓ₯ det tog typ all energi." | |
| body[1] = "Nu efterΓ₯t Γ€r det liksom en helt annan kΓ€nsla, mycket lugnare." | |
| elif angle in ("myth-busting",): | |
| body[0] = "Alla sa att sΓ₯nt hΓ€r aldrig funkar, men det stΓ€mmer faktiskt inte." | |
| cta = "Testa den sjΓ€lv idag, du kommer inte Γ₯ngra dig." | |
| hook, body, cta = _safety_split_script(hook, body, cta) | |
| return {"hook": hook, "body_segments": body, "cta": cta} | |
| def _parse_script_json(raw: str) -> dict: | |
| """Defensive parse of the scriptwriter's JSON (first '{' to last '}').""" | |
| s = raw.strip() | |
| a, b = s.find("{"), s.rfind("}") | |
| if a == -1 or b == -1: | |
| raise ValueError("no JSON object in scriptwriter output") | |
| obj = json.loads(s[a:b + 1]) | |
| hook = str(obj.get("hook") or "").strip() | |
| cta = str(obj.get("cta") or "").strip() | |
| body = [str(x).strip() for x in (obj.get("body_segments") or []) if str(x).strip()] | |
| if not hook: | |
| raise ValueError("scriptwriter returned no hook") | |
| return {"hook": hook, "body_segments": body, "cta": cta} | |
| def write_script(brief_id: str) -> dict: | |
| """Turn one brief into a validated Swedish UGC script and wire it into the | |
| render flow (SPEC_NEW Β§7.5). | |
| Loads the brief + its avatar + recent winning-hook examples, produces | |
| {hook, body_segments[], cta} where EVERY segment passes | |
| veo_segment.validate_spoken_text AND duration.decide_duration (β€ 18 words, no | |
| digits, no quotes, numbers spelled out). Claude (SCRIPTWRITER_SYSTEM) when keyed | |
| β parse + retry once with the validator error appended, final safety split; | |
| deterministic fallback otherwise (itself 10/10 on the validator). | |
| Persists via flow.create_video (NO seed key in videos.tags β Β§12.4), links the | |
| script.brief_id, stamps videos.product_id + tags {brief_id, mode, hypothesis}, | |
| and marks the brief 'scripted'. Returns | |
| {script_id, video_id, hook, body_segments, cta}. | |
| """ | |
| from . import db, flow | |
| from .prompts import scriptwriter as swp | |
| brief = db.fetch_one("select * from briefs where id = %s", (brief_id,)) | |
| if brief is None: | |
| raise ValueError(f"no brief {brief_id}") | |
| avatar = db.fetch_one("select * from avatars where id = %s", (brief["avatar_id"],)) | |
| if avatar is None: | |
| raise RuntimeError(f"brief {brief_id} has no avatar") | |
| s = get_settings() | |
| examples = _winning_hook_examples() | |
| script: dict | None = None | |
| if s.anthropic_api_key: | |
| import anthropic | |
| client = anthropic.Anthropic(api_key=s.anthropic_api_key) | |
| user = swp.build_user_prompt(brief, avatar, examples) | |
| last_err: str | None = None | |
| for attempt in range(2): | |
| content = user if attempt == 0 else ( | |
| f"{user}\n\nDitt fΓΆrra svar dΓΆg inte ({last_err}). Svara ENBART med " | |
| f"giltig JSON enligt formatet, och hΓ₯ll varje rad kort och korrekt." | |
| ) | |
| resp = client.messages.create( | |
| model=s.anthropic_model, max_tokens=1024, | |
| system=swp.SCRIPTWRITER_SYSTEM, messages=[{"role": "user", "content": content}], | |
| ) | |
| text = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") | |
| try: | |
| cand = _parse_script_json(text) | |
| # Final safety split, then assert every line is renderable. | |
| cand["hook"], cand["body_segments"], cand["cta"] = _safety_split_script( | |
| cand["hook"], cand["body_segments"], cand["cta"] | |
| ) | |
| _validate_segments(cand["hook"], cand["body_segments"], cand["cta"]) | |
| script = cand | |
| break | |
| except Exception as exc: # noqa: BLE001 β retry once with the error appended | |
| last_err = str(exc) | |
| if script is None: | |
| script = _fallback_script(brief, avatar) | |
| else: | |
| script = _fallback_script(brief, avatar) | |
| # Belt-and-suspenders: the fallback (and any accepted Claude output) must pass. | |
| _validate_segments(script["hook"], script["body_segments"], script["cta"]) | |
| # Wire into the existing render flow β create_video validates again + builds the | |
| # hook takes. It writes tags {source, angle} only; NO seed (Β§12.4). | |
| video_id = flow.create_video( | |
| brief["avatar_id"], script["hook"], script["body_segments"], script["cta"], | |
| angle=brief["angle"], | |
| ) | |
| # Link the script to its brief, stamp the creative attributes onto the video | |
| # (merging tags β still no seed key β Β§12.4), and advance the brief. | |
| extra_tags = { | |
| "brief_id": str(brief_id), | |
| "mode": brief["mode"], | |
| "hypothesis": brief.get("hypothesis") or "", | |
| } | |
| with db.connect() as conn: | |
| video = conn.execute("select script_id, tags from videos where id = %s", (video_id,)).fetchone() | |
| script_id = str(video["script_id"]) | |
| merged_tags = dict(video["tags"] or {}) | |
| merged_tags.update(extra_tags) | |
| merged_tags.pop("seed", None) # invariant: a seed can never live in tags (Β§12.4) | |
| conn.execute("update scripts set brief_id = %s where id = %s", (brief_id, video["script_id"])) | |
| conn.execute( | |
| "update videos set product_id = %s, tags = %s where id = %s", | |
| (brief.get("product_id"), Json(merged_tags), video_id), | |
| ) | |
| conn.execute("update briefs set status = 'scripted' where id = %s", (brief_id,)) | |
| return { | |
| "script_id": script_id, | |
| "video_id": str(video_id), | |
| "hook": script["hook"], | |
| "body_segments": script["body_segments"], | |
| "cta": script["cta"], | |
| } | |
| # ββ Phase 6 (B-roll, SPEC_NEW Β§15.4) βββββββββββββββββββββββββββββββββββββββββ | |
| BROLL_PLAN_SYSTEM = """You plan B-roll cutaways for a Swedish UGC video ad. You see a \ | |
| PRODUCT IMAGE and the avatar's spoken BODY lines β each line is one short clip where \ | |
| the avatar talks to camera. For some lines we cut away to a B-roll shot of the product \ | |
| (image-to-video from the product image) while the avatar keeps talking underneath. | |
| For each body line decide whether to overlay B-roll, and if so write a short \ | |
| image-to-video prompt for a product shot that VISUALLY matches what the line is about. | |
| Rules: | |
| - Overlay B-roll on roughly HALF the lines β the ones where showing the product helps. \ | |
| Never on every line (the avatar's face must return between cutaways). | |
| - The prompt describes THE PRODUCT FROM THE ATTACHED IMAGE in a natural real-world \ | |
| Swedish setting, vertical 9:16, soft daylight, authentic UGC, a slow gentle camera \ | |
| move, no people speaking, no on-screen text. Keep the product identical to the image. | |
| - Respect any forbidden claims β never depict them. | |
| - Output ONLY a JSON array, one object per body line in order: | |
| [{"segment_idx": <int>, "overlay": <bool>, "seedance_prompt": "<string, empty if overlay is false>"}]""" | |
| def _fallback_broll_prompt(product: dict, line: str) -> str: | |
| name = product.get("name") or "the product" | |
| return (f"A clean, authentic UGC product shot of {name} (the product from the attached image), " | |
| f"in a natural Swedish home setting, vertical 9:16, soft daylight, a slow gentle camera " | |
| f"move, no people speaking, no on-screen text. Keep the product identical to the image.") | |
| def _product_image_block(image: str | None) -> dict | None: | |
| if not image: | |
| return None | |
| try: | |
| if image.startswith(("http://", "https://")): | |
| import httpx | |
| r = httpx.get(image, timeout=60) | |
| r.raise_for_status() | |
| data, mime = r.content, (r.headers.get("content-type") or "image/jpeg").split(";")[0] | |
| else: | |
| from pathlib import Path | |
| p = Path(image) | |
| if not p.is_file(): | |
| return None | |
| data = p.read_bytes() | |
| mime = {".png": "image/png", ".webp": "image/webp"}.get(p.suffix.lower(), "image/jpeg") | |
| return {"type": "image", "source": {"type": "base64", "media_type": mime, | |
| "data": base64.standard_b64encode(data).decode()}} | |
| except Exception: # noqa: BLE001 β image unavailable β planner falls back | |
| return None | |
| def plan_broll(product: dict, segments: list[dict]) -> list[dict]: | |
| """Decide which body segments get a B-roll overlay + a Seedance prompt for each | |
| (SPEC_NEW Β§15.4). Claude content-match when an image + ANTHROPIC_API_KEY are | |
| available; otherwise a deterministic fallback (every other body line) so the | |
| pipeline stays demoable. Returns [{segment_idx, overlay, seedance_prompt}].""" | |
| body = [s for s in segments if s["kind"] == "body"] | |
| if not body: | |
| return [] | |
| s = get_settings() | |
| image_block = _product_image_block(product.get("image_path")) | |
| if not s.anthropic_api_key or image_block is None: | |
| return [ | |
| {"segment_idx": seg["idx"], "overlay": True, | |
| "seedance_prompt": _fallback_broll_prompt(product, seg["spoken_text"])} | |
| for i, seg in enumerate(body) if i % 2 == 0 | |
| ] | |
| import anthropic | |
| knowledge = product.get("knowledge") or {} | |
| lines = "\n".join(f'{seg["idx"]}: "{seg["spoken_text"]}"' for seg in body) | |
| user_text = ( | |
| f"Product: {product.get('name', '')}\n" | |
| f"Knowledge: {json.dumps(knowledge, ensure_ascii=False)}\n\n" | |
| f"Body lines (segment_idx: line):\n{lines}\n\n" | |
| f"Return the JSON array (one object per body line, in order)." | |
| ) | |
| client = anthropic.Anthropic(api_key=s.anthropic_api_key) | |
| resp = client.messages.create( | |
| model=s.anthropic_model, max_tokens=1500, system=BROLL_PLAN_SYSTEM, | |
| messages=[{"role": "user", "content": [image_block, {"type": "text", "text": user_text}]}], | |
| ) | |
| text = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text").strip() | |
| a, b = text.find("["), text.rfind("]") | |
| if a == -1 or b == -1: | |
| raise RuntimeError(f"plan_broll: no JSON array in response: {text[:200]}") | |
| plan = json.loads(text[a:b + 1]) | |
| body_idxs = {seg["idx"] for seg in body} | |
| return [ | |
| {"segment_idx": int(p["segment_idx"]), "overlay": bool(p.get("overlay")), | |
| "seedance_prompt": str(p.get("seedance_prompt") or "")} | |
| for p in plan if int(p.get("segment_idx", -1)) in body_idxs | |
| ] | |