alisaadhq commited on
Commit
6dda67c
·
verified ·
1 Parent(s): 3b06b5f

Update core/analyze.py

Browse files
Files changed (1) hide show
  1. core/analyze.py +142 -184
core/analyze.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import os
2
  import time
3
  import json
@@ -7,68 +9,168 @@ from dotenv import load_dotenv
7
 
8
  load_dotenv()
9
 
10
- # Setup Logger
11
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
12
  logger = logging.getLogger(__name__)
13
 
14
- # Configure Groq Client
15
  api_key = os.getenv("GROQ_API_KEY")
16
  MODEL_NAME = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
17
-
18
  client = Groq(api_key=api_key)
19
 
20
- MIN_DURATION = 60 # seconds
21
- MAX_DURATION = 180 # seconds
 
22
 
23
 
24
- def validate_segments(segments):
25
  """
26
- Remove segments that don't meet duration requirements (60-180 seconds).
27
- Recalculates duration from start/end times never trusts AI's duration field.
 
28
  """
29
  valid = []
30
  for seg in segments:
31
  start = seg.get("start_time", 0)
32
- end = seg.get("end_time", 0)
33
- duration = end - start
34
 
35
- if duration < MIN_DURATION:
36
- print(f"⚠️ Skipped short segment: {duration:.1f}s → [{start}s–{end}s] ({seg.get('title', '')})")
 
37
  continue
38
 
39
- if duration > MAX_DURATION:
40
- print(f"⚠️ Skipped long segment: {duration:.1f}s → [{start}s–{end}s] ({seg.get('title', '')})")
41
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- seg["duration"] = round(duration, 2)
 
 
 
 
 
 
 
 
44
  valid.append(seg)
45
 
46
- print(f"✅ Valid segments after duration filter: {len(valid)}/{len(segments)}")
47
  return valid
48
 
49
 
50
- def analyze_transcript(transcript):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  """
52
  Analyze transcript using Groq API.
53
- Forces AI to find complete publish-ready stories, not just punchlines.
 
54
  """
55
 
56
  prompt = f"""
57
  You are a viral short-form video editor specializing in TikTok, Reels, and YouTube Shorts.
58
  Your job is to find COMPLETE, PUBLISH-READY segments — not just funny lines or punchlines.
59
 
 
 
 
 
 
 
60
  THINKING PROCESS — follow these steps for every segment:
61
  1. Spot an interesting or funny moment in the transcript
62
  2. Go BACKWARDS to find where the setup or context begins (usually 30–90 seconds before the peak)
63
  3. Go FORWARDS to find where the natural conclusion or audience reaction ends (usually 15–40 seconds after)
64
- 4. The full segment = setup + build-up + peak + conclusion = 60 to 180 seconds total
65
 
66
  EXAMPLE OF CORRECT THINKING:
67
  - You notice a funny moment at 150s
68
- - The story/setup started at 95s
69
- - The conclusion/reaction ends at 178s
70
- - Correct segment start_time: 95, end_time: 178 (83 seconds)
71
- - WRONG → start_time: 145, end_time: 158 (13 seconds, just the punchline) ❌
72
 
73
  A PUBLISH-READY segment must have ALL of these:
74
  - A hook in the first 5 seconds that makes viewers want to keep watching
@@ -77,22 +179,15 @@ A PUBLISH-READY segment must have ALL of these:
77
  - A satisfying payoff or conclusion — not an abrupt cut
78
  - Standalone: makes complete sense without watching anything before or after
79
 
80
- STRICT RULES:
81
- - end_time - start_time MUST be between 60 and 180 seconds
82
- - Never return just a punchline or a reaction — always include the full story arc
83
- - If you cannot find a complete story that is at least 60 seconds, skip it entirely
84
- - Natural start: beginning of a thought, scene, or story — never mid-sentence
85
- - Natural end: after the payoff, conclusion, or audience reaction — never mid-sentence
86
-
87
  OUTPUT — raw JSON only, no markdown, no explanation:
88
  {{
89
  "segments": [
90
  {{
91
- "start_time": <float, where the setup begins>,
92
- "end_time": <float, where the conclusion ends>,
93
- "title": "<punchy YouTube Shorts title, max 60 chars, no clickbait>",
94
- "description": "<1-2 sentences describing the full story, include relevant keywords for YouTube SEO>",
95
- "reason": "<explain the full story arc: setup → peak → conclusion, max 25 words>"
96
  }}
97
  ]
98
  }}
@@ -104,168 +199,31 @@ TRANSCRIPT:
104
  """
105
 
106
  max_retries = 3
107
- base_delay = 5
108
 
109
  for attempt in range(max_retries):
110
  try:
111
  response = client.chat.completions.create(
112
- model=MODEL_NAME,
113
- messages=[
114
  {
115
- "role": "system",
116
  "content": (
117
- "You are a JSON-only assistant. "
118
- "Output raw JSON only — no markdown, no code blocks, no explanation. "
119
- "Think carefully about complete story arcs before selecting any segment."
 
120
  )
121
  },
122
  {"role": "user", "content": prompt}
123
  ],
124
- temperature=0.3,
125
  )
126
 
127
  content = response.choices[0].message.content.strip()
128
- print(f"🤖 AI Raw Response (First 300 chars): {content[:300]}...")
129
 
130
- # Strip markdown if model ignores system prompt
131
  if "```json" in content:
132
  content = content.split("```json")[1].split("```")[0].strip()
133
  elif "```" in content:
134
- content = content.split("```")[1].split("```")[0].strip()
135
-
136
- # Parse and validate
137
- data = json.loads(content)
138
- raw_segments = data.get("segments", [])
139
- valid_segments = validate_segments(raw_segments)
140
- data["segments"] = valid_segments
141
-
142
- content = json.dumps(data)
143
- print(f"🤖 Parsed: {len(raw_segments)} raw → {len(valid_segments)} valid segments.")
144
- return {"content": content}
145
-
146
- except Exception as e:
147
- print(f"❌ Error in Groq analysis (attempt {attempt + 1}): {e}")
148
- if attempt < max_retries - 1:
149
- wait_time = base_delay * (2 ** attempt)
150
- print(f"⚠️ Retrying in {wait_time}s...")
151
- time.sleep(wait_time)
152
- else:
153
- break
154
-
155
- print("❌ All retry attempts failed.")
156
- return {"content": '{"segments": []}'}
157
-
158
-
159
- def smart_chunk_transcript(transcript, max_tokens=4000):
160
- """
161
- Split transcript into coherent chunks at sentence boundaries.
162
- Adds overlap between chunks so stories that span chunk boundaries aren't lost.
163
- """
164
- sentences = transcript.replace('\n', ' ').split('. ')
165
- chunks = []
166
- current_chunk = []
167
- current_length = 0
168
- overlap_sentences = [] # last N sentences of previous chunk for context
169
-
170
- for sentence in sentences:
171
- sentence_length = len(sentence.split())
172
-
173
- if current_length + sentence_length > max_tokens and current_chunk:
174
- chunk_text = '. '.join(current_chunk) + '.'
175
- chunks.append(chunk_text.strip())
176
-
177
- # Keep last 5 sentences as overlap for next chunk
178
- overlap_sentences = current_chunk[-5:]
179
- current_chunk = overlap_sentences + [sentence]
180
- current_length = sum(len(s.split()) for s in current_chunk)
181
- else:
182
- current_chunk.append(sentence)
183
- current_length += sentence_length
184
-
185
- if current_chunk:
186
- chunk_text = '. '.join(current_chunk) + '.'
187
- chunks.append(chunk_text.strip())
188
-
189
- return chunks
190
-
191
-
192
- def analyze_transcript_with_chunking(transcript):
193
- """
194
- Analyze transcript using smart chunking for long content.
195
- Processes each chunk separately and merges + deduplicates results.
196
- """
197
- if len(transcript.split()) > 3000:
198
- logger.info("📦 Transcript too long, using smart chunking...")
199
- chunks = smart_chunk_transcript(transcript, max_tokens=3000)
200
- all_segments = []
201
-
202
- for i, chunk in enumerate(chunks):
203
- logger.info(f"🔄 Processing chunk {i+1}/{len(chunks)}...")
204
- result = analyze_transcript(chunk)
205
-
206
- try:
207
- data = json.loads(result['content'])
208
- if 'segments' in data:
209
- all_segments.extend(data['segments'])
210
- except Exception as e:
211
- logger.warning(f"⚠️ Failed to parse chunk {i+1}: {e}")
212
- continue
213
-
214
- if all_segments:
215
- # Deduplicate by time (allow 10s tolerance for overlap chunks)
216
- unique_segments = []
217
- seen_times = set()
218
-
219
- for seg in all_segments:
220
- # Round to nearest 10s to catch near-duplicates from overlap
221
- time_key = f"{round(seg.get('start_time', 0) / 10) * 10}-{round(seg.get('end_time', 0) / 10) * 10}"
222
- if time_key not in seen_times:
223
- unique_segments.append(seg)
224
- seen_times.add(time_key)
225
-
226
- # Keep AI's original order — AI already ranks by importance (best first)
227
-
228
- logger.info(f"📊 Total unique valid segments: {len(unique_segments)}")
229
- return {"content": json.dumps({"segments": unique_segments[:10]})}
230
-
231
- logger.warning("⚠️ No valid segments found across all chunks.")
232
- return {"content": '{"segments": []}'}
233
-
234
- return analyze_transcript(transcript)
235
-
236
-
237
- # Testing
238
- if __name__ == "__main__":
239
- test_transcript = """
240
- [0.0 - 5.0] Welcome to today's video about productivity hacks that actually work.
241
- [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.
242
- [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.
243
- [30.0 - 45.0] The second hack is batching similar tasks together. Instead of checking email 20 times a day, I check it twice.
244
- [45.0 - 60.0] This has saved me hours every week. I batch my emails, phone calls, and even errands.
245
- [60.0 - 90.0] The third hack is the Pomodoro Technique. Work for 25 minutes, then take a 5-minute break.
246
- [90.0 - 120.0] This technique helps me stay focused and avoid burnout. I get more done in less time.
247
- [120.0 - 150.0] The fourth hack is to eliminate distractions completely. Turn off notifications, close tabs, and focus.
248
- [150.0 - 180.0] When you eliminate distractions, your productivity skyrockets. I finish in 2 hours what used to take 6.
249
- [180.0 - 210.0] The fifth and final hack is to review your day every evening. Spend 5 minutes planning tomorrow.
250
- [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.
251
- """
252
-
253
- logger.info("🧪 Testing AI Analysis...")
254
- result = analyze_transcript_with_chunking(test_transcript)
255
-
256
- try:
257
- data = json.loads(result['content'])
258
- segments = data.get('segments', [])
259
- logger.info(f"✅ Found {len(segments)} publish-ready segments:\n")
260
-
261
- for i, seg in enumerate(segments):
262
- duration = seg['end_time'] - seg['start_time']
263
- logger.info(
264
- f"#{i+1} [{seg['start_time']:.0f}s – {seg['end_time']:.0f}s] ({duration:.0f}s)\n"
265
- f" 📌 Title: {seg.get('title', 'N/A')}\n"
266
- f" 📝 Description: {seg.get('description', 'N/A')}\n"
267
- f" 💡 Story Arc: {seg.get('reason', 'N/A')}\n"
268
- )
269
- except Exception as e:
270
- logger.error(f"❌ Error parsing result: {e}")
271
- logger.info(f"Raw result: {result}")
 
1
+ # analyze.py — Full fixed version
2
+
3
  import os
4
  import time
5
  import json
 
9
 
10
  load_dotenv()
11
 
 
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):
25
  """
26
+ FIX: Instead of discarding short segments, try to EXTEND them
27
+ symmetrically (pad before + after) to reach MIN_DURATION.
28
+ Only discard if extension is impossible or duration > MAX_DURATION.
29
  """
30
  valid = []
31
  for seg in segments:
32
  start = seg.get("start_time", 0)
33
+ end = seg.get("end_time", 0)
34
+ dur = end - start
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
 
66
+ logger.info(
67
+ f"🔧 Extended short segment {dur:.1f}s → {actual_dur:.1f}s "
68
+ f"[{start:.1f}s–{end:.1f}s] → [{new_start:.1f}s–{new_end:.1f}s]"
69
+ )
70
+ seg["start_time"] = round(new_start, 2)
71
+ seg["end_time"] = round(new_end, 2)
72
+ dur = actual_dur
73
+
74
+ seg["duration"] = round(dur, 2)
75
  valid.append(seg)
76
 
77
+ logger.info(f"✅ Valid segments after filter: {len(valid)}/{len(segments)}")
78
  return valid
79
 
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({
95
+ "start": float(match.group(1)),
96
+ "end": float(match.group(2)),
97
+ "text": match.group(3).strip(),
98
+ })
99
+
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)")
142
+ return deduped
143
+
144
+
145
+ def analyze_transcript(transcript, video_duration=None):
146
  """
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
164
  2. Go BACKWARDS to find where the setup or context begins (usually 30–90 seconds before the peak)
165
  3. Go FORWARDS to find where the natural conclusion or audience reaction ends (usually 15–40 seconds after)
166
+ 4. The full segment = setup + build-up + peak + conclusion = {MIN_DURATION} to {MAX_DURATION} seconds total
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
 
179
  - A satisfying payoff or conclusion — not an abrupt cut
180
  - Standalone: makes complete sense without watching anything before or after
181
 
 
 
 
 
 
 
 
182
  OUTPUT — raw JSON only, no markdown, no explanation:
183
  {{
184
  "segments": [
185
  {{
186
+ "start_time": <float, where the SETUP begins — NOT the funny moment itself>,
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
  }}
 
199
  """
200
 
201
  max_retries = 3
202
+ base_delay = 5
203
 
204
  for attempt in range(max_retries):
205
  try:
206
  response = client.chat.completions.create(
207
+ model = MODEL_NAME,
208
+ messages = [
209
  {
210
+ "role": "system",
211
  "content": (
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
  )
222
 
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("`