File size: 9,086 Bytes
ee62fc1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
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]