File size: 11,756 Bytes
898ab1a
 
 
025f791
 
898ab1a
 
 
 
 
 
 
 
025f791
898ab1a
 
bb342d2
025f791
bb342d2
898ab1a
025f791
bb342d2
025f791
 
9e8ee53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
898ab1a
 
bb342d2
025f791
bb342d2
898ab1a
025f791
 
 
 
 
 
 
 
 
bb342d2
 
025f791
bb342d2
 
025f791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb342d2
 
 
025f791
bb342d2
025f791
898ab1a
bb342d2
 
025f791
bb342d2
 
025f791
 
bb342d2
025f791
 
 
 
bb342d2
025f791
 
 
 
bb342d2
 
 
025f791
bb342d2
 
 
 
 
 
 
 
 
025f791
 
bb342d2
 
 
025f791
bb342d2
 
 
898ab1a
 
 
025f791
898ab1a
 
bb342d2
025f791
bb342d2
 
 
 
025f791
 
 
 
bb342d2
 
 
 
025f791
bb342d2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
025f791
 
bb342d2
 
 
 
025f791
 
bb342d2
 
025f791
 
 
bb342d2
 
 
025f791
bb342d2
025f791
bb342d2
025f791
bb342d2
025f791
bb342d2
025f791
898ab1a
bb342d2
 
025f791
 
bb342d2
 
 
 
 
025f791
 
bb342d2
 
 
 
 
 
 
 
 
 
 
 
 
025f791
bb342d2
 
 
 
 
025f791
bb342d2
025f791
bb342d2
 
 
 
 
025f791
bb342d2
 
 
025f791
bb342d2
 
 
898ab1a
bb342d2
898ab1a
 
 
025f791
898ab1a
 
bb342d2
025f791
 
 
 
bb342d2
 
 
 
 
 
 
 
 
 
898ab1a
 
 
bb342d2
898ab1a
 
 
 
bb342d2
 
 
898ab1a
025f791
 
 
 
 
 
 
bb342d2
 
 
 
 
 
025f791
bb342d2
 
898ab1a
 
 
 
 
 
bb342d2
898ab1a
bb342d2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
"""
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)