afa67's picture
deploy dry-run demo
257a670 verified
Raw
History Blame
34.4 kB
"""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
]