videovoice / tools_api /scene_writer.py
github-actions[bot]
deploy: switch to chatterbox requirements @ 1d8a28e
b828530
"""
Hook β†’ DramaBox scene drafting via an LLM.
The frontend's "Adapt to my niche" step POSTs to /api/tools/scene; this turns a
viral-hook structure plus the creator's niche into a directed DramaBox scene.
Provider chain: AWS Bedrock (primary) β†’ Pollinations (fallback). If both fail,
the endpoint errors and the frontend falls back to its offline template engine.
"""
from __future__ import annotations
import json
import os
import urllib.request
from steps.lang._shared import bedrock_converse, log_llm_call
# Bedrock model for scene drafting. Defaults to the BEDROCK_MODEL env the rest
# of the backend already uses β€” the dub pipeline's translation fallback runs on
# it, so it's a known-good id on this Space. (A hardcoded default here shadowed
# that env and hit Bedrock "Operation not allowed".) Override: SCENE_BEDROCK_MODEL.
SCENE_MODEL = os.getenv("SCENE_BEDROCK_MODEL") or os.getenv("BEDROCK_MODEL")
# Pollinations fallback uses the keyless anonymous text endpoint. The keyed
# gen.pollinations.ai route (build_client) blocks the Space's requests; the
# anonymous text route stays open. Override the model via env POLLEN_TEXT_MODEL.
POLLEN_TEXT_URL = "https://text.pollinations.ai/openai"
POLLEN_TEXT_MODEL = os.getenv("POLLEN_TEXT_MODEL", "openai")
_TEMPERATURE = 0.8
_SYSTEM_PROMPT = """You are a scriptwriter for DramaBox, a text-to-speech engine that performs short, emotionally directed monologues.
Write ONE scene from the given viral hook and the creator's niche.
FORMAT β€” follow exactly:
- Structure: <speaker description>, "<dialogue>" <stage action> "<more dialogue>"
- Text inside double quotes is SPOKEN aloud, verbatim.
- Text outside quotes is a STAGE DIRECTION, never spoken (e.g. She sighs deeply. A long pause.).
- Phonetic sounds may go inside quotes: "Hahaha", "Mmmmm", "Ugh".
- NEVER put Ahem, Pfft, Sigh, Gasp or Cough inside quotes β€” use a stage direction.
CONTENT:
- Follow the hook's structure, rewritten specifically for the creator's niche β€” do not just slot the topic in.
- About 3 short sentences β€” one performable take, roughly 10-20 seconds spoken.
- Output ONLY the scene text. No preamble, no markdown, no surrounding quotes."""
def _build_user_prompt(
hook_pattern: str,
hook_title: str,
register: str,
register_label: str,
niche: str,
) -> str:
return (
f'Viral hook: "{hook_title}"\n'
f"Hook structure: {hook_pattern}\n"
f"Emotional register: {register_label} ({register})\n"
f"Creator's niche / topic: {niche}\n\n"
"Write the DramaBox scene now."
)
def _clean(scene: str) -> str:
"""Strip whitespace and a markdown fence if the model wrapped its output."""
s = (scene or "").strip()
if s.startswith("```"):
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
return s
def _bedrock_scene(user_prompt: str) -> str:
"""Primary provider β€” AWS Bedrock."""
raw = bedrock_converse(
_SYSTEM_PROMPT,
user_prompt,
temperature=_TEMPERATURE,
step="dramabox_scene",
model_id=SCENE_MODEL,
)
return _clean(raw)
def _pollinations_scene(user_prompt: str) -> str:
"""Fallback provider β€” Pollinations' keyless anonymous text endpoint."""
payload = json.dumps({
"model": POLLEN_TEXT_MODEL,
"temperature": _TEMPERATURE,
"messages": [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
}).encode("utf-8")
req = urllib.request.Request(
POLLEN_TEXT_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode("utf-8"))
raw = (data["choices"][0]["message"]["content"] or "").strip()
log_llm_call(
step="dramabox_scene",
provider="pollinations-text",
model=POLLEN_TEXT_MODEL,
system_prompt=_SYSTEM_PROMPT,
user_prompt=user_prompt,
response=raw,
temperature=_TEMPERATURE,
)
return _clean(raw)
def write_scene(
*,
hook_pattern: str,
hook_title: str,
register: str,
register_label: str,
niche: str,
) -> str:
"""Draft a directed DramaBox scene β€” Bedrock primary, Pollinations fallback.
Raises if both providers fail; the caller surfaces a 500 and the frontend
falls back to its offline template engine.
"""
user_prompt = _build_user_prompt(
hook_pattern, hook_title, register, register_label, niche
)
# Primary β€” Bedrock.
bedrock_err = "unknown error"
try:
scene = _bedrock_scene(user_prompt)
if scene:
return scene
bedrock_err = "returned an empty scene"
except Exception as e: # noqa: BLE001
bedrock_err = str(e)
print(f"[scene] Bedrock unavailable ({bedrock_err}) β€” trying Pollinations.")
# Fallback β€” Pollinations.
try:
scene = _pollinations_scene(user_prompt)
except Exception as e: # noqa: BLE001
print(f"[scene] Pollinations also failed ({e}).")
raise RuntimeError(
f"Bedrock failed [{bedrock_err}]; Pollinations failed [{e}]"
)
if not scene:
raise RuntimeError(
f"Bedrock failed [{bedrock_err}]; Pollinations returned an empty scene"
)
return scene