""" services/script_engine.py [NEW] Fills the Timestamp Breakdown from the TRANSCRIPT ALONE — no retention curve required. Why this exists: retention is owner-only AND YouTube suppresses it on low-view videos. A 491-sub channel may legitimately get zero retention rows for months. If the Coach can only work when retention exists, the Coach doesn't work. But the script is always there, and most retention deaths are visible in it before a single viewer shows up: • 30 seconds of "hey guys welcome back, before we start, smash that like" • time-to-value of 1:50 in an 8-minute video • asking for the subscribe before delivering anything • 4 minutes of monologue with no question, no "you", no pattern interrupt • filler density that makes the first minute unlistenable Every finding here carries a REAL timestamp and a REAL quote from the video, so the Timestamp Breakdown card is never empty and never generic. """ from __future__ import annotations import re from services.transcript_service import Transcript, fmt_ts FILLER = ("um", "uh", "erm", "like", "you know", "kind of", "sort of", "basically", "actually", "literally", "i mean", "so yeah", "right?", "okay so", "anyway") HOUSEKEEPING = ("welcome back", "welcome to my channel", "hey guys", "what's up guys", "my name is", "before we start", "before we begin", "before we get into", "don't forget to", "smash that like", "hit the like", "hit the bell", "subscribe to the channel", "if you're new here", "quick word", "today's video is sponsored", "let's get into it", "without further ado") VALUE_MARKERS = ("here's", "here is", "the first", "step one", "step 1", "the trick", "the reason", "turns out", "what most people", "the problem is", "let me show you", "look at this", "watch what happens", "the answer is") CTA = ("subscribe", "hit the bell", "like this video", "comment below", "link in the description", "join the channel", "smash that") ENGAGE = ("you", "your", "?", "imagine", "look", "watch", "notice", "here's the thing") def _words(text: str) -> int: return len(text.split()) def _filler_hits(text: str) -> list[str]: low = " " + text.lower() + " " return [f for f in FILLER if f" {f} " in low or low.count(f) >= 2] def analyse_script(t: Transcript, duration_sec: int, is_short: bool) -> list[dict]: """ Returns [{timestamp, t, issue, evidence, fix, severity}] — hard findings with receipts. Empty list if there's no speech transcript. """ if not t.is_speech or duration_sec < 15: return [] out: list[dict] = [] full = t.window(0, duration_sec, max_chars=100_000).lower() # ── 1. HOUSEKEEPING BEFORE VALUE ────────────────────────────────────── opener = t.window(0, 30, max_chars=800) hk = [h for h in HOUSEKEEPING if h in opener.lower()] if hk: out.append({ "t": 0.0, "timestamp": "0:00", "issue": f"You open with housekeeping, not the promise ({', '.join(hk[:2])})", "evidence": opener[:180], "fix": "Delete it. Open on the single most surprising sentence in the video — the one you " "currently say later. Intros, sub-begs and self-intros belong after the first payoff.", "severity": "high", }) # ── 2. TIME TO VALUE ────────────────────────────────────────────────── ttv = None for s in t.segments: if any(v in s.text.lower() for v in VALUE_MARKERS) or re.search(r"\b\d+\b", s.text): ttv = s.start break budget = 8 if is_short else 25 if ttv is None: out.append({ "t": 0.0, "timestamp": "0:00", "issue": "No concrete payoff anywhere in the script — no number, no claim, no demonstration", "evidence": t.window(0, 25, max_chars=140), "fix": "Every video needs one specific, checkable thing. Put it in the first 15 seconds " "and repeat it at the midpoint.", "severity": "high", }) elif ttv > budget: out.append({ "t": ttv, "timestamp": fmt_ts(ttv), "issue": f"Time-to-value is {int(ttv)}s. Viewers decide in the first {budget}s", "evidence": t.at(ttv, before=2, after=8)[:180], "fix": f"Cut everything before {fmt_ts(ttv)} and start the video there.", "severity": "high", }) else: out.append({ "t": ttv, "timestamp": fmt_ts(ttv), "issue": f"Payoff lands fast ({int(ttv)}s) — this is working", "evidence": t.at(ttv, before=1, after=7)[:180], "fix": "Keep it. Front-load the payoff like this every time.", "severity": "good", }) # ── 3. CTA POSITION ─────────────────────────────────────────────────── cta_at = next((s.start for s in t.segments if any(c in s.text.lower() for c in CTA)), None) if cta_at is None: out.append({ "t": max(0, duration_sec - 30), "timestamp": fmt_ts(max(0, duration_sec - 30)), "issue": "No call to action anywhere in the video", "evidence": t.window(max(0, duration_sec - 40), duration_sec, max_chars=140), "fix": "One line, after the payoff, tied to the content: " "'if this changed how you think about X, subscribe — the next one goes deeper.'", "severity": "medium", }) elif cta_at < duration_sec * 0.25 and not is_short: out.append({ "t": cta_at, "timestamp": fmt_ts(cta_at), "issue": "You ask for the subscribe before you've earned it", "evidence": t.at(cta_at, before=2, after=6)[:180], "fix": "Move the ask to after the first real payoff. Asking early trains viewers to leave.", "severity": "high", }) # ── 4. FILLER DENSITY, per 30s block ───────────────────────────────── worst = None step = 30 tt = 0.0 while tt < duration_sec: block = t.window(tt, tt + step, max_chars=1200) w = _words(block) if w >= 30: hits = _filler_hits(block) density = len(hits) / max(1, w / 100) # filler per 100 words if worst is None or density > worst[1]: worst = (tt, density, hits, block) tt += step if worst and worst[1] >= 3: tt0, density, hits, block = worst out.append({ "t": tt0, "timestamp": fmt_ts(tt0), "issue": f"Densest filler in the video ({density:.1f} per 100 words: {', '.join(hits[:3])})", "evidence": block[:180], "fix": "Cut the pauses in the edit. This block reads as thinking-out-loud — viewers hear " "'nothing is happening' and leave.", "severity": "medium", }) # ── 5. PACE ─────────────────────────────────────────────────────────── wpm = (t.word_count / (duration_sec / 60)) if duration_sec else 0 if wpm and wpm < 110 and not is_short: out.append({ "t": 0.0, "timestamp": "0:00", "issue": f"Delivery is slow — {int(wpm)} words/min (150–180 is the retention sweet spot)", "evidence": f"{t.word_count} words across {fmt_ts(duration_sec)}", "fix": "Tighten in the edit: cut every pause over 400ms. Same script, ~20% shorter runtime.", "severity": "medium", }) # ── 6. LONGEST MONOLOGUE (no question, no direct address) ──────────── gap_start, longest = 0.0, (0.0, 0.0) for s in t.segments: low = s.text.lower() if "?" in s.text or any(e in low for e in ENGAGE): span = s.start - gap_start if span > longest[1]: longest = (gap_start, span) gap_start = s.end tail = duration_sec - gap_start if tail > longest[1]: longest = (gap_start, tail) if longest[1] >= 90 and not is_short: out.append({ "t": longest[0], "timestamp": fmt_ts(longest[0]), "issue": f"{int(longest[1])}s with no question, no 'you', no pattern interrupt", "evidence": t.at(longest[0] + longest[1] / 2, before=5, after=5)[:180], "fix": "Break it. Every ~40s: a question, a 'here's why that matters to you', a cut, or a " "visual change. Flat stretches are where the graph falls.", "severity": "high", }) out.sort(key=lambda x: x["t"]) return out[:6]