voiceapp1 / script_generator.py
aniketsirsikar's picture
Update script_generator.py
9e8ee53 verified
Raw
History Blame Contribute Delete
11.8 kB
"""
script_generator.py
LLM: openai/gpt-oss-20b via HuggingFace InferenceClient (chat completions)
Fallback: high-quality template engine
"""
from __future__ import annotations
import os
import re
from typing import Optional
HF_TOKEN = os.environ.get("HF_TOKEN", "")
MODEL = "openai/gpt-oss-20b"
# ---------------------------------------------------------------------------
# InferenceClient call (chat completions format)
# ---------------------------------------------------------------------------
def _call_llm(system_prompt: str, user_prompt: str) -> Optional[str]:
if not HF_TOKEN:
print("[ScriptGen] No HF_TOKEN β€” skipping LLM, using template.")
return None
import time
from huggingface_hub import InferenceClient
client = InferenceClient(token=HF_TOKEN, model=MODEL)
for attempt in range(1, 4):
try:
print(f"[ScriptGen] {MODEL} attempt {attempt}/3...")
response = client.chat_completion(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
max_tokens=1200,
temperature=0.72,
top_p=0.92,
)
text = response.choices[0].message.content.strip()
print(f"[ScriptGen] {MODEL} returned {len(text)} chars")
if len(text) > 200:
return text
print(f"[ScriptGen] Too short β€” model cold starting, waiting 8s...")
time.sleep(8)
except Exception as e:
print(f"[ScriptGen] Attempt {attempt} failed: {e}")
if attempt < 3:
time.sleep(5)
print("[ScriptGen] All retries exhausted β€” using template.")
return None
# ---------------------------------------------------------------------------
# Prompt builder
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """You are a professional podcast scriptwriter with years of experience.
Your scripts sound completely natural when read aloud β€” like real human conversation, not AI text.
You never copy source material verbatim. You always rewrite ideas in a warm, engaging, spoken style.
You never use bullet points, headers, markdown, or stage directions in your output."""
def _build_user_prompt(context: str, content_type: str, tone: str,
num_hosts: int, duration_target: float,
doc_name: str, custom_focus: str) -> str:
words = int(duration_target * 130)
focus = f"Focus specifically on: {custom_focus.strip()}." if custom_focus.strip() else ""
format_map = {
"Podcast (Single Host)":
"a single-host podcast episode. One narrator speaks throughout β€” warm, curious, engaging.",
"Podcast (Dual Host Debate)":
"a two-host podcast. Label EVERY line as either HOST_A: or HOST_B: β€” no exceptions. "
"The hosts have a real conversation: they ask each other questions, build on each other's points, "
"sometimes disagree, and bring in examples.",
"Educational Narration":
"a clear educational narration. One voice explains the topic step by step, simply and clearly.",
"Storytelling Episode":
"a narrative storytelling episode. Vivid, immersive, draws the listener in with a story arc.",
"News Bulletin":
"a professional news bulletin. Crisp, factual, authoritative anchor tone.",
"Rap / Song Mode":
"a rap song. Use Verse 1, Chorus, Verse 2, Outro structure. Rhythmic, punchy lines.",
"Executive Summary":
"a 90-second executive audio briefing. Dense, direct, every word earns its place.",
}
fmt = format_map.get(content_type, "a spoken audio piece")
cleaned_context = _pre_clean_context(context)
return f"""Write {fmt}
Topic: {doc_name}
Tone: {tone}
Target length: approximately {words} words
{focus}
STRICT RULES:
- Output ONLY the spoken script. No titles, headers, labels, stage directions, or markdown.
- Every sentence must be fluent, complete, and natural to speak aloud.
- Use contractions, rhetorical questions, and natural spoken transitions.
- Structure: compelling hook β†’ substantive body (3 main points) β†’ memorable closing.
- Base ALL facts only on the source material below. Do not invent anything not in the source.
- Do NOT copy sentences from the source β€” rewrite everything in your own spoken voice.
SOURCE MATERIAL:
\"\"\"
{cleaned_context[:4000]}
\"\"\""""
def _pre_clean_context(context: str) -> str:
"""Strip leftover PDF junk before sending to LLM."""
lines = context.splitlines()
good = []
for line in lines:
line = line.strip()
if not line:
good.append("")
continue
if re.search(
r"authorized for use|no part of|hbs no\.|s p jain|spjimr|"
r"^\d+\s*$|^page\s+\d+|from dec \d{4}|to jun \d{4}|"
r"all rights reserved|transmitted in any form",
line, re.IGNORECASE
):
continue
if len(line) < 20 and not line.endswith((".", "!", "?")):
continue
good.append(line)
return "\n".join(good).strip()
# ---------------------------------------------------------------------------
# Template fallback
# ---------------------------------------------------------------------------
def _extract_good_sentences(context: str, n: int = 15) -> list:
raw = re.split(r"(?<=[.!?])\s+", context)
good = []
for s in raw:
s = s.strip()
if (
len(s) > 55
and s[0].isupper()
and s[-1] in ".!?"
and not re.search(
r"hbs|authorized|spjimr|Β©|fig\.|table \d|"
r"^\d+\.|ibid|op\. cit|et al\.",
s, re.IGNORECASE
)
and len(re.findall(r"[A-Za-z]", s)) > 30
):
good.append(s)
seen, unique = set(), []
for s in good:
key = s[:50]
if key not in seen:
seen.add(key)
unique.append(s)
return unique[:n]
def _template_script(context: str, content_type: str, tone: str,
num_hosts: int, doc_name: str,
custom_focus: str, duration_target: float) -> str:
topic = (custom_focus.strip() if custom_focus.strip()
else re.sub(r"^\d+[\.\d]*\s*", "", doc_name).replace("_", " ").replace("-", " ").strip())
topic = topic or "this topic"
sents = _extract_good_sentences(context, n=15)
if len(sents) < 4:
raise ValueError(
"Not enough clean sentences to build a script. "
"The PDF may be scanned or heavily formatted. Try a different document."
)
intro = sents[:2]
body = sents[2:11]
outro = sents[11:13]
openers = {
"Engaging & Conversational":
f"Have you ever really thought about {topic}? It's one of those subjects that seems straightforward at first β€” but the deeper you go, the more there is to unpack. Let's get into it.",
"Professional & Formal":
f"Today we examine {topic} β€” a subject with significant practical implications for professionals in this field.",
"Playful & Fun":
f"Okay, buckle up β€” we're diving into {topic} today, and I promise it's way more interesting than it sounds!",
"Dramatic & Intense":
f"What if everything you assumed about {topic} was only half the story? Today, we fill in the gaps.",
"Calm & Reflective":
f"Let's take a moment β€” slow down, breathe β€” and really explore {topic}. It deserves more attention than we usually give it.",
}
opener = openers.get(tone, openers["Engaging & Conversational"])
closing = (
f"So here's what it all comes down to when we talk about {topic}: "
f"{' '.join(outro) if outro else 'the ideas we explored today are genuinely worth applying.'} "
f"Thank you for listening. Until next time."
)
transitions = [
"Let's start with the foundation.",
"Now here's where it gets really interesting.",
"And this is the piece most people overlook.",
]
body_paras = []
chunk = max(1, len(body) // 3)
for i in range(3):
group = body[i * chunk: (i + 1) * chunk]
if group:
body_paras.append(f"{transitions[i]} {' '.join(group)}")
if content_type == "Rap / Song Mode":
v1 = " / ".join(body[:4])
v2 = " / ".join(body[4:8])
return (
f"Verse one.\n{v1}\n\n"
f"Chorus.\nKnowledge drops, truth unlocks β€” {topic} around the clock.\n\n"
f"Verse two.\n{v2}\n\n"
f"Outro.\n{closing}"
)
if content_type == "News Bulletin":
return (
f"Good evening. Tonight's lead story: {topic}. "
f"{' '.join(intro)} "
f"{' '.join(body_paras)} "
f"Reporting complete."
)
if num_hosts == 2:
return "\n\n".join([
f"HOST_A: {opener}",
f"HOST_B: Exactly. And what really struck me is that {intro[0] if intro else ''}",
f"HOST_A: {body_paras[0] if body_paras else ''}",
f"HOST_B: That's such a key point. {body_paras[1] if len(body_paras) > 1 else ''}",
f"HOST_A: And building on that β€” {body_paras[2] if len(body_paras) > 2 else ''}",
f"HOST_B: Right. The real-world takeaway here is about applying this consistently.",
f"HOST_A: {closing}",
f"HOST_B: Couldn't agree more. See you next time.",
])
return f"{opener}\n\n{chr(10).join(body_paras)}\n\n{closing}"
# ---------------------------------------------------------------------------
# Post-process
# ---------------------------------------------------------------------------
def _clean_script(script: str) -> str:
script = re.sub(r"\*{1,3}(.*?)\*{1,3}", r"\1", script)
script = re.sub(r"#{1,4}\s*", "", script)
script = re.sub(r"\[(?!INST)[^\]]{0,40}\]", "", script)
script = re.sub(r"https?://\S+", "", script)
lines = [
l for l in script.splitlines()
if not re.search(
r"authorized for use|hbs no\.|spjimr|s p jain|Β©|all rights",
l, re.IGNORECASE
)
]
script = "\n".join(lines)
script = re.sub(r"\n{3,}", "\n\n", script)
return script.strip()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
class ScriptGenerator:
def generate(self, context: str, content_type: str, tone: str,
num_hosts: int, duration_target: float,
doc_name: str, custom_focus: str) -> str:
# Try LLM
script = _call_llm(
system_prompt=SYSTEM_PROMPT,
user_prompt=_build_user_prompt(
context, content_type, tone, num_hosts,
duration_target, doc_name, custom_focus
),
)
if script and len(script.strip()) > 200:
print("[ScriptGen] Using LLM-generated script.")
return _clean_script(script)
# Template fallback
print("[ScriptGen] Using template engine.")
script = _template_script(
context=context,
content_type=content_type,
tone=tone,
num_hosts=num_hosts,
doc_name=doc_name,
custom_focus=custom_focus,
duration_target=duration_target,
)
return _clean_script(script)