alisaadhq commited on
Commit
be3f38d
·
verified ·
1 Parent(s): 59c010c

Update core/analyze.py

Browse files
Files changed (1) hide show
  1. core/analyze.py +197 -47
core/analyze.py CHANGED
@@ -1,6 +1,5 @@
1
- # analyze.py — Full fixed version
2
-
3
  import os
 
4
  import time
5
  import json
6
  import logging
@@ -12,13 +11,17 @@ load_dotenv()
12
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
13
  logger = logging.getLogger(__name__)
14
 
15
- api_key = os.getenv("GROQ_API_KEY")
16
  MODEL_NAME = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
17
- client = Groq(api_key=api_key)
 
 
 
 
18
 
19
- MIN_DURATION = 60 # seconds
20
- MAX_DURATION = 180 # seconds
21
- TARGET_DURATION = 90 # ✅ NEW: ideal segment length for extending short ones
22
 
23
 
24
  def validate_segments(segments, video_duration=None):
@@ -35,31 +38,35 @@ def validate_segments(segments, video_duration=None):
35
 
36
  # ── Too long: hard discard ────────────────────────────────────────────
37
  if dur > MAX_DURATION:
38
- logger.warning(f"⚠️ Skipped long segment: {dur:.1f}s [{start}s–{end}s] ({seg.get('title','')})")
 
 
 
39
  continue
40
 
41
  # ── Too short: try to extend ──────────────────────────────────────────
42
  if dur < MIN_DURATION:
43
- needed = MIN_DURATION - dur
44
- pad_pre = needed / 2
45
  pad_post = needed / 2
46
 
47
- new_start = max(0, start - pad_pre)
48
  new_end = end + pad_post
49
 
50
  # Clamp to video duration if known
51
  if video_duration:
52
  new_end = min(video_duration, new_end)
53
- # If we couldn't get enough at the end, steal from the front
54
  actual_dur = new_end - new_start
55
  if actual_dur < MIN_DURATION and new_start > 0:
56
- new_start = max(0, new_end - MIN_DURATION)
57
 
58
  actual_dur = new_end - new_start
59
  if actual_dur < MIN_DURATION:
60
  logger.warning(
61
- f"⚠️ Skipped unextendable segment: {dur:.1f}s→{actual_dur:.1f}s "
62
- f"[{start}s–{end}s] ({seg.get('title','')})"
 
63
  )
64
  continue
65
 
@@ -80,15 +87,12 @@ def validate_segments(segments, video_duration=None):
80
 
81
  def _fallback_segments_from_transcript(transcript: str, video_duration: float) -> list:
82
  """
83
- NEW FALLBACK: If AI returns nothing useful, generate evenly-spaced
84
- segments from the transcript based on timestamp markers [start - end].
85
- Tries to pick the most text-dense windows as a heuristic for 'interesting'.
86
  """
87
- import re
88
-
89
  logger.warning("⚠️ Using fallback segment generator from transcript timestamps")
90
 
91
- # Extract all timestamped lines
92
  lines = []
93
  for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
94
  lines.append({
@@ -100,42 +104,40 @@ def _fallback_segments_from_transcript(transcript: str, video_duration: float) -
100
  if not lines:
101
  return []
102
 
103
- # Build 90-second windows every 60 seconds, score by word count
104
  candidates = []
105
- step = 60
106
  window = TARGET_DURATION
107
-
108
- t = lines[0]["start"]
109
- max_t = lines[-1]["end"]
110
 
111
  while t + MIN_DURATION <= max_t:
112
- w_end = min(t + window, max_t)
113
  in_window = [l for l in lines if l["start"] >= t and l["end"] <= w_end]
114
  word_count = sum(len(l["text"].split()) for l in in_window)
115
  candidates.append({
116
  "start_time": round(t, 2),
117
  "end_time": round(w_end, 2),
118
  "word_count": word_count,
119
- "title": f"Highlight at {int(t//60)}m{int(t%60):02d}s",
120
  "description": "Auto-detected highlight segment",
121
  "reason": "Fallback: highest word-density window",
122
- "viral_score": word_count, # proxy score
123
  })
124
  t += step
125
 
126
  if not candidates:
127
  return []
128
 
129
- # Sort by word density, pick top 3, sort back by time
130
  candidates.sort(key=lambda x: x["word_count"], reverse=True)
131
  top = candidates[:3]
132
  top.sort(key=lambda x: x["start_time"])
133
 
134
- # Deduplicate overlapping windows (keep higher-scored one)
135
  deduped = []
136
  for c in top:
137
  if deduped and c["start_time"] < deduped[-1]["end_time"] - 20:
138
- continue # overlaps with previous, skip
139
  deduped.append(c)
140
 
141
  logger.info(f"🔧 Fallback generated {len(deduped)} segment(s)")
@@ -147,17 +149,19 @@ def analyze_transcript(transcript, video_duration=None):
147
  Analyze transcript using Groq API.
148
  ✅ FIX: Passes video_duration to validate_segments for smarter extension.
149
  ✅ FIX: Falls back to _fallback_segments_from_transcript on 0 valid results.
 
 
150
  """
151
 
152
  prompt = f"""
153
  You are a viral short-form video editor specializing in TikTok, Reels, and YouTube Shorts.
154
  Your job is to find COMPLETE, PUBLISH-READY segments — not just funny lines or punchlines.
155
 
156
- ⚠️ CRITICAL DURATION RULE — VIOLATIONS WILL BE REJECTED:
157
  - end_time - start_time MUST be between {MIN_DURATION} and {MAX_DURATION} seconds
158
  - Segments shorter than {MIN_DURATION}s will be AUTOMATICALLY DISCARDED
159
- - If a funny moment is only 15s, you MUST expand it: go back ~45s for context and forward ~30s for reaction
160
- - There is NO exception to this rule
161
 
162
  THINKING PROCESS — follow these steps for every segment:
163
  1. Spot an interesting or funny moment in the transcript
@@ -167,10 +171,10 @@ THINKING PROCESS — follow these steps for every segment:
167
 
168
  EXAMPLE OF CORRECT THINKING:
169
  - You notice a funny moment at 150s
170
- - The story/setup started at 95s use that as start_time
171
- - The conclusion/reaction ends at 220s use that as end_time
172
- - Duration = 220 - 95 = 125 seconds (within 60-180)
173
- - WRONG → start_time: 145, end_time: 165 (20 seconds, just the punchline)
174
 
175
  A PUBLISH-READY segment must have ALL of these:
176
  - A hook in the first 5 seconds that makes viewers want to keep watching
@@ -187,7 +191,7 @@ OUTPUT — raw JSON only, no markdown, no explanation:
187
  "end_time": <float, where the CONCLUSION ends — NOT just the punchline>,
188
  "title": "<punchy YouTube Shorts title, max 60 chars>",
189
  "description": "<1-2 sentences describing the full story>",
190
- "reason": "<setup peak conclusion arc, max 25 words>"
191
  }}
192
  ]
193
  }}
@@ -212,10 +216,10 @@ TRANSCRIPT:
212
  f"You are a JSON-only assistant. "
213
  f"Output raw JSON only — no markdown, no code blocks, no explanation. "
214
  f"EVERY segment MUST be between {MIN_DURATION} and {MAX_DURATION} seconds. "
215
- f"Think carefully: always include setup + peak + conclusion."
216
- )
217
  },
218
- {"role": "user", "content": prompt}
219
  ],
220
  temperature = 0.3,
221
  )
@@ -223,7 +227,153 @@ TRANSCRIPT:
223
  content = response.choices[0].message.content.strip()
224
  logger.info(f"🤖 AI Raw Response (first 300 chars): {content[:300]}...")
225
 
226
- if "```json" in content:
227
- content = content.split("```json")[1].split("```")[0].strip()
228
- elif "```" in content:
229
- content = content.split("`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import re
3
  import time
4
  import json
5
  import logging
 
11
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
12
  logger = logging.getLogger(__name__)
13
 
14
+ api_key = os.getenv("GROQ_API_KEY")
15
  MODEL_NAME = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
16
+ client = Groq(api_key=api_key)
17
+
18
+ MIN_DURATION = 60 # seconds
19
+ MAX_DURATION = 180 # seconds
20
+ TARGET_DURATION = 90 # ideal segment length for extending short ones
21
 
22
+ # ── Backtick fence markers (defined as variables to avoid markdown issues) ──
23
+ _FENCE_JSON = "```json"
24
+ _FENCE = "```"
25
 
26
 
27
  def validate_segments(segments, video_duration=None):
 
38
 
39
  # ── Too long: hard discard ────────────────────────────────────────────
40
  if dur > MAX_DURATION:
41
+ logger.warning(
42
+ f"⚠️ Skipped long segment: {dur:.1f}s "
43
+ f"[{start}s–{end}s] ({seg.get('title', '')})"
44
+ )
45
  continue
46
 
47
  # ── Too short: try to extend ──────────────────────────────────────────
48
  if dur < MIN_DURATION:
49
+ needed = MIN_DURATION - dur
50
+ pad_pre = needed / 2
51
  pad_post = needed / 2
52
 
53
+ new_start = max(0.0, start - pad_pre)
54
  new_end = end + pad_post
55
 
56
  # Clamp to video duration if known
57
  if video_duration:
58
  new_end = min(video_duration, new_end)
59
+ # If still not long enough, steal more from the front
60
  actual_dur = new_end - new_start
61
  if actual_dur < MIN_DURATION and new_start > 0:
62
+ new_start = max(0.0, new_end - MIN_DURATION)
63
 
64
  actual_dur = new_end - new_start
65
  if actual_dur < MIN_DURATION:
66
  logger.warning(
67
+ f"⚠️ Skipped unextendable segment: "
68
+ f"{dur:.1f}s {actual_dur:.1f}s "
69
+ f"[{start}s–{end}s] ({seg.get('title', '')})"
70
  )
71
  continue
72
 
 
87
 
88
  def _fallback_segments_from_transcript(transcript: str, video_duration: float) -> list:
89
  """
90
+ ✅ FALLBACK: If AI returns nothing useful, generate evenly-spaced segments
91
+ from the transcript based on timestamp markers [start - end].
92
+ Picks the most text-dense windows as a heuristic for 'interesting'.
93
  """
 
 
94
  logger.warning("⚠️ Using fallback segment generator from transcript timestamps")
95
 
 
96
  lines = []
97
  for match in re.finditer(r'\[(\d+\.?\d*)\s*-\s*(\d+\.?\d*)\]\s*(.*)', transcript):
98
  lines.append({
 
104
  if not lines:
105
  return []
106
 
 
107
  candidates = []
108
+ step = 60
109
  window = TARGET_DURATION
110
+ t = lines[0]["start"]
111
+ max_t = lines[-1]["end"]
 
112
 
113
  while t + MIN_DURATION <= max_t:
114
+ w_end = min(t + window, max_t)
115
  in_window = [l for l in lines if l["start"] >= t and l["end"] <= w_end]
116
  word_count = sum(len(l["text"].split()) for l in in_window)
117
  candidates.append({
118
  "start_time": round(t, 2),
119
  "end_time": round(w_end, 2),
120
  "word_count": word_count,
121
+ "title": f"Highlight at {int(t // 60)}m{int(t % 60):02d}s",
122
  "description": "Auto-detected highlight segment",
123
  "reason": "Fallback: highest word-density window",
124
+ "viral_score": word_count,
125
  })
126
  t += step
127
 
128
  if not candidates:
129
  return []
130
 
131
+ # Top 3 by word density, then sort back by time
132
  candidates.sort(key=lambda x: x["word_count"], reverse=True)
133
  top = candidates[:3]
134
  top.sort(key=lambda x: x["start_time"])
135
 
136
+ # Deduplicate overlapping windows
137
  deduped = []
138
  for c in top:
139
  if deduped and c["start_time"] < deduped[-1]["end_time"] - 20:
140
+ continue
141
  deduped.append(c)
142
 
143
  logger.info(f"🔧 Fallback generated {len(deduped)} segment(s)")
 
149
  Analyze transcript using Groq API.
150
  ✅ FIX: Passes video_duration to validate_segments for smarter extension.
151
  ✅ FIX: Falls back to _fallback_segments_from_transcript on 0 valid results.
152
+ ✅ FIX: Backtick fence strings stored in module-level variables to prevent
153
+ SyntaxError when the source file is copy-pasted through markdown.
154
  """
155
 
156
  prompt = f"""
157
  You are a viral short-form video editor specializing in TikTok, Reels, and YouTube Shorts.
158
  Your job is to find COMPLETE, PUBLISH-READY segments — not just funny lines or punchlines.
159
 
160
+ WARNING CRITICAL DURATION RULE — VIOLATIONS WILL BE REJECTED:
161
  - end_time - start_time MUST be between {MIN_DURATION} and {MAX_DURATION} seconds
162
  - Segments shorter than {MIN_DURATION}s will be AUTOMATICALLY DISCARDED
163
+ - If a funny moment is only 15s, you MUST expand it: go back ~45s for setup and forward ~30s for reaction
164
+ - There are NO exceptions to this rule
165
 
166
  THINKING PROCESS — follow these steps for every segment:
167
  1. Spot an interesting or funny moment in the transcript
 
171
 
172
  EXAMPLE OF CORRECT THINKING:
173
  - You notice a funny moment at 150s
174
+ - The story/setup started at 95s use that as start_time
175
+ - The conclusion/reaction ends at 220s use that as end_time
176
+ - Duration = 220 - 95 = 125 seconds (within 60-180) CORRECT
177
+ - WRONG: start_time=145, end_time=165 (20 seconds, just the punchline)
178
 
179
  A PUBLISH-READY segment must have ALL of these:
180
  - A hook in the first 5 seconds that makes viewers want to keep watching
 
191
  "end_time": <float, where the CONCLUSION ends — NOT just the punchline>,
192
  "title": "<punchy YouTube Shorts title, max 60 chars>",
193
  "description": "<1-2 sentences describing the full story>",
194
+ "reason": "<setup to peak to conclusion arc, max 25 words>"
195
  }}
196
  ]
197
  }}
 
216
  f"You are a JSON-only assistant. "
217
  f"Output raw JSON only — no markdown, no code blocks, no explanation. "
218
  f"EVERY segment MUST be between {MIN_DURATION} and {MAX_DURATION} seconds. "
219
+ f"Always include setup + peak + conclusion, never just the punchline."
220
+ ),
221
  },
222
+ {"role": "user", "content": prompt},
223
  ],
224
  temperature = 0.3,
225
  )
 
227
  content = response.choices[0].message.content.strip()
228
  logger.info(f"🤖 AI Raw Response (first 300 chars): {content[:300]}...")
229
 
230
+ # ── Strip markdown fences if model ignored system prompt ──────────
231
+ # Using module-level variables instead of inline literals to prevent
232
+ # SyntaxError when this file is copy-pasted through markdown editors.
233
+ if _FENCE_JSON in content:
234
+ content = content.split(_FENCE_JSON)[1].split(_FENCE)[0].strip()
235
+ elif _FENCE in content:
236
+ content = content.split(_FENCE)[1].split(_FENCE)[0].strip()
237
+
238
+ data = json.loads(content)
239
+ raw_segments = data.get("segments", [])
240
+
241
+ # ✅ Pass video_duration so extension logic has a ceiling
242
+ valid_segments = validate_segments(raw_segments, video_duration=video_duration)
243
+
244
+ # ✅ FALLBACK: if AI returned nothing valid, use heuristic generator
245
+ if not valid_segments:
246
+ logger.warning("⚠️ AI returned 0 valid segments — trying fallback generator")
247
+ fallback = _fallback_segments_from_transcript(
248
+ transcript, video_duration or 0
249
+ )
250
+ valid_segments = validate_segments(fallback, video_duration=video_duration)
251
+
252
+ data["segments"] = valid_segments
253
+ content = json.dumps(data)
254
+ logger.info(
255
+ f"🤖 Parsed: {len(raw_segments)} raw → {len(valid_segments)} valid segments"
256
+ )
257
+ return {"content": content}
258
+
259
+ except Exception as e:
260
+ logger.error(f"❌ Error in Groq analysis (attempt {attempt + 1}): {e}")
261
+ if attempt < max_retries - 1:
262
+ wait = base_delay * (2 ** attempt)
263
+ logger.warning(f"⚠️ Retrying in {wait}s...")
264
+ time.sleep(wait)
265
+
266
+ logger.error("❌ All retry attempts failed.")
267
+ return {"content": '{"segments": []}'}
268
+
269
+
270
+ def smart_chunk_transcript(transcript, max_tokens=4000):
271
+ """
272
+ Split transcript into coherent chunks at sentence boundaries.
273
+ Adds overlap between chunks so stories that span chunk boundaries aren't lost.
274
+ """
275
+ sentences = transcript.replace('\n', ' ').split('. ')
276
+ chunks = []
277
+ current_chunk = []
278
+ current_length = 0
279
+ overlap_sentences = []
280
+
281
+ for sentence in sentences:
282
+ sentence_length = len(sentence.split())
283
+
284
+ if current_length + sentence_length > max_tokens and current_chunk:
285
+ chunk_text = '. '.join(current_chunk) + '.'
286
+ chunks.append(chunk_text.strip())
287
+ overlap_sentences = current_chunk[-5:]
288
+ current_chunk = overlap_sentences + [sentence]
289
+ current_length = sum(len(s.split()) for s in current_chunk)
290
+ else:
291
+ current_chunk.append(sentence)
292
+ current_length += sentence_length
293
+
294
+ if current_chunk:
295
+ chunk_text = '. '.join(current_chunk) + '.'
296
+ chunks.append(chunk_text.strip())
297
+
298
+ return chunks
299
+
300
+
301
+ def analyze_transcript_with_chunking(transcript, video_duration=None):
302
+ """
303
+ Analyze transcript using smart chunking for long content.
304
+ Processes each chunk separately and merges + deduplicates results.
305
+ """
306
+ if len(transcript.split()) > 3000:
307
+ logger.info("📦 Transcript too long, using smart chunking...")
308
+ chunks = smart_chunk_transcript(transcript, max_tokens=3000)
309
+ all_segments = []
310
+
311
+ for i, chunk in enumerate(chunks):
312
+ logger.info(f"🔄 Processing chunk {i+1}/{len(chunks)}...")
313
+ result = analyze_transcript(chunk, video_duration=video_duration)
314
+
315
+ try:
316
+ data = json.loads(result["content"])
317
+ if "segments" in data:
318
+ all_segments.extend(data["segments"])
319
+ except Exception as e:
320
+ logger.warning(f"⚠️ Failed to parse chunk {i+1}: {e}")
321
+ continue
322
+
323
+ if all_segments:
324
+ unique_segments = []
325
+ seen_times = set()
326
+
327
+ for seg in all_segments:
328
+ time_key = (
329
+ f"{round(seg.get('start_time', 0) / 10) * 10}-"
330
+ f"{round(seg.get('end_time', 0) / 10) * 10}"
331
+ )
332
+ if time_key not in seen_times:
333
+ unique_segments.append(seg)
334
+ seen_times.add(time_key)
335
+
336
+ logger.info(f"📊 Total unique valid segments: {len(unique_segments)}")
337
+ return {"content": json.dumps({"segments": unique_segments[:10]})}
338
+
339
+ logger.warning("⚠️ No valid segments found across all chunks.")
340
+ return {"content": '{"segments": []}'}
341
+
342
+ return analyze_transcript(transcript, video_duration=video_duration)
343
+
344
+
345
+ # ── Testing ───────────────────────────────────────────────────────────────────
346
+ if __name__ == "__main__":
347
+ test_transcript = """
348
+ [0.0 - 5.0] Welcome to today's video about productivity hacks that actually work.
349
+ [5.0 - 15.0] The first hack is something I call the 2-minute rule. If something takes less than 2 minutes, do it immediately.
350
+ [15.0 - 30.0] This simple rule has transformed my life. I used to procrastinate on small tasks, but now I handle them right away.
351
+ [30.0 - 45.0] The second hack is batching similar tasks together. Instead of checking email 20 times a day, I check it twice.
352
+ [45.0 - 60.0] This has saved me hours every week. I batch my emails, phone calls, and even errands.
353
+ [60.0 - 90.0] The third hack is the Pomodoro Technique. Work for 25 minutes, then take a 5-minute break.
354
+ [90.0 - 120.0] This technique helps me stay focused and avoid burnout. I get more done in less time.
355
+ [120.0 - 150.0] The fourth hack is to eliminate distractions completely. Turn off notifications, close tabs, and focus.
356
+ [150.0 - 180.0] When you eliminate distractions, your productivity skyrockets. I finish in 2 hours what used to take 6.
357
+ [180.0 - 210.0] The fifth and final hack is to review your day every evening. Spend 5 minutes planning tomorrow.
358
+ [210.0 - 240.0] This evening review changed everything for me. I wake up knowing exactly what to do and I never waste morning time figuring out priorities.
359
+ """
360
+
361
+ logger.info("🧪 Testing AI Analysis...")
362
+ result = analyze_transcript_with_chunking(test_transcript, video_duration=240.0)
363
+
364
+ try:
365
+ data = json.loads(result["content"])
366
+ segments = data.get("segments", [])
367
+ logger.info(f"✅ Found {len(segments)} publish-ready segments:\n")
368
+
369
+ for i, seg in enumerate(segments):
370
+ duration = seg["end_time"] - seg["start_time"]
371
+ logger.info(
372
+ f"#{i+1} [{seg['start_time']:.0f}s – {seg['end_time']:.0f}s] ({duration:.0f}s)\n"
373
+ f" 📌 Title: {seg.get('title', 'N/A')}\n"
374
+ f" 📝 Description: {seg.get('description', 'N/A')}\n"
375
+ f" 💡 Story Arc: {seg.get('reason', 'N/A')}\n"
376
+ )
377
+ except Exception as e:
378
+ logger.error(f"❌ Error parsing result: {e}")
379
+ logger.info(f"Raw result: {result}")