-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexample_interview_automation_script.py
More file actions
2475 lines (2116 loc) · 105 KB
/
Copy pathexample_interview_automation_script.py
File metadata and controls
2475 lines (2116 loc) · 105 KB
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
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Interview Automation Tool
Automates creation of Google Drive folders and documents from Granola notes + YouTube livestreams
"""
import os
import json
import threading
import webbrowser
from datetime import datetime
from flask import Flask, request, jsonify, redirect, url_for, session, Response
# Google OAuth
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
import requests as http_requests
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY")
if not app.secret_key:
raise RuntimeError("FLASK_SECRET_KEY environment variable is not set. See .env.example.")
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = False # localhost only
app.config["PERMANENT_SESSION_LIFETIME"] = 86400 # 24 hours
# ─── Config ──────────────────────────────────────────────────────────────────
MAIN_FOLDER_ID = os.environ.get("MAIN_FOLDER_ID")
RECORDINGS_FOLDER_ID = os.environ.get("RECORDINGS_FOLDER_ID")
YOUTUBE_CHANNEL_ID = os.environ.get("YOUTUBE_CHANNEL_ID")
GRANOLA_API_BASE = "https://api.granola.ai/v1"
SCOPES = [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/youtube.readonly",
]
ARCADE_TEAM = ["Alex", "Vivian", "Mariam", "Savannah", "Sarah"]
# ─── State ───────────────────────────────────────────────────────────────────
progress_log = []
current_job = {"running": False, "done": False, "error": None}
def log(msg, level="info"):
entry = {"msg": msg, "level": level, "time": datetime.now().strftime("%H:%M:%S")}
progress_log.append(entry)
print(f"[{level.upper()}] {msg}")
# ─── Google Auth ─────────────────────────────────────────────────────────────
# Global token store: populated from request context before thread launch
_g_token_data = {}
_g_anthropic_key = {}
def get_google_creds(token_data=None):
"""Build Google creds from explicit token_data, global store, or Flask session."""
td = token_data or _g_token_data.get("token") or None
if td is None:
try:
td = session.get("google_token")
except RuntimeError:
return None
if not td:
return None
return Credentials(
token=td.get("token"),
refresh_token=td.get("refresh_token"),
token_uri="https://oauth2.googleapis.com/token",
client_id=td.get("client_id"),
client_secret=td.get("client_secret"),
scopes=SCOPES,
)
def get_drive_service():
return build("drive", "v3", credentials=get_google_creds())
def get_docs_service():
return build("docs", "v1", credentials=get_google_creds())
def get_sheets_service():
return build("sheets", "v4", credentials=get_google_creds())
# ─── Granola API ─────────────────────────────────────────────────────────────
def fetch_granola_notes(api_key):
"""Fetch all notes from Granola API"""
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
try:
resp = http_requests.get(f"{GRANOLA_API_BASE}/documents", headers=headers, timeout=15)
if resp.status_code == 200:
return resp.json()
# Try alternative endpoint
resp2 = http_requests.get(f"{GRANOLA_API_BASE}/notes", headers=headers, timeout=15)
if resp2.status_code == 200:
return resp2.json()
return {"error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
except Exception as e:
return {"error": str(e)}
def fetch_granola_note_detail(api_key, note_id):
"""Fetch full detail for a single Granola note including transcript"""
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
try:
resp = http_requests.get(f"{GRANOLA_API_BASE}/documents/{note_id}", headers=headers, timeout=15)
if resp.status_code == 200:
return resp.json()
return None
except Exception:
return None
# ─── YouTube ─────────────────────────────────────────────────────────────────
def fetch_youtube_livestreams(api_key_or_cookies):
"""Fetch all videos from the channel using YouTube Data API (including unlisted)"""
if not api_key_or_cookies:
return []
try:
# Use OAuth credentials if available (needed for unlisted videos)
from google.oauth2.credentials import Credentials as GCreds
creds = get_google_creds()
if creds:
yt = build("youtube", "v3", credentials=creds)
else:
yt = build("youtube", "v3", developerKey=api_key_or_cookies)
videos = []
# First try: search for completed livestreams
try:
r1 = yt.search().list(
channelId=YOUTUBE_CHANNEL_ID,
part="snippet,id",
type="video",
eventType="completed",
maxResults=50,
order="date",
).execute()
for item in r1.get("items", []):
video_id = item["id"].get("videoId", "")
snippet = item.get("snippet", {})
if video_id:
videos.append({
"id": video_id,
"title": snippet.get("title", ""),
"description": snippet.get("description", ""),
"published_at": snippet.get("publishedAt", ""),
"thumbnail": snippet.get("thumbnails", {}).get("medium", {}).get("url", ""),
"url": f"https://www.youtube.com/watch?v={video_id}",
"type": "livestream",
})
except Exception as e:
log(f"Livestream search error: {e}", "warn")
# Second: get ALL uploads from the channel via playlist
try:
# Get the uploads playlist ID
channel_resp = yt.channels().list(
id=YOUTUBE_CHANNEL_ID,
part="contentDetails"
).execute()
uploads_playlist = channel_resp["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
# Page through all uploads
existing_ids = {v["id"] for v in videos}
next_page = None
while True:
pl_req = yt.playlistItems().list(
playlistId=uploads_playlist,
part="snippet",
maxResults=50,
pageToken=next_page,
)
pl_resp = pl_req.execute()
for item in pl_resp.get("items", []):
snippet = item.get("snippet", {})
video_id = snippet.get("resourceId", {}).get("videoId", "")
if video_id and video_id not in existing_ids:
existing_ids.add(video_id)
videos.append({
"id": video_id,
"title": snippet.get("title", ""),
"description": snippet.get("description", ""),
"published_at": snippet.get("publishedAt", ""),
"thumbnail": snippet.get("thumbnails", {}).get("medium", {}).get("url", ""),
"url": f"https://www.youtube.com/watch?v={video_id}",
"type": "upload",
})
next_page = pl_resp.get("nextPageToken")
if not next_page:
break
except Exception as e:
log(f"Uploads playlist error: {e}", "warn")
# Sort by date descending
videos.sort(key=lambda v: v.get("published_at", ""), reverse=True)
log(f"Found {len(videos)} total videos on channel")
return videos
except Exception as e:
log(f"YouTube API error: {e}", "warn")
return []
def get_youtube_transcript(video_id):
"""Legacy plain-text transcript (no timestamps). Kept for backward compat."""
segments = get_youtube_transcript_timestamped(video_id)
return " ".join(s["text"] for s in segments)
def get_youtube_transcript_timestamped(video_id):
"""Return YT auto-caption segments as [{'start': float_sec, 'text': str}, ...]
Preserving timestamps is what lets us correlate each line with a video frame
for speaker attribution.
"""
try:
import subprocess
subprocess.run(
["yt-dlp", "--skip-download", "--write-auto-sub", "--sub-format", "json3",
"--output", f"/tmp/yt_{video_id}", f"https://www.youtube.com/watch?v={video_id}"],
capture_output=True, text=True, timeout=120
)
import glob
files = glob.glob(f"/tmp/yt_{video_id}*.json3")
if not files:
return []
with open(files[0]) as f:
data = json.load(f)
segments = []
for ev in data.get("events", []):
start_ms = ev.get("tStartMs")
if start_ms is None:
continue
text = "".join(s.get("utf8", "") for s in ev.get("segs", [])).strip()
if not text:
continue
segments.append({"start": start_ms / 1000.0, "text": text})
return segments
except Exception as e:
log(f"Transcript fetch failed for {video_id}: {e}", "warn")
return []
def _format_timestamp(seconds):
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h:d}:{m:02d}:{s:02d}"
return f"{m:d}:{s:02d}"
# ─── Video-assisted speaker diarization ─────────────────────────────────────
def download_youtube_video(video_id, out_dir="/tmp"):
"""Download a low-res MP4 for frame extraction. Returns the file path."""
import subprocess
out_template = os.path.join(out_dir, f"vid_{video_id}.%(ext)s")
# 360p or lower — we only need faces, not pixels.
subprocess.run(
["yt-dlp", "-f", "bestvideo[height<=360][ext=mp4]+bestaudio[ext=m4a]/best[height<=360][ext=mp4]/best[height<=480]",
"--merge-output-format", "mp4", "-o", out_template,
f"https://www.youtube.com/watch?v={video_id}"],
capture_output=True, text=True, timeout=600, check=True,
)
import glob
matches = glob.glob(os.path.join(out_dir, f"vid_{video_id}.*"))
matches = [m for m in matches if not m.endswith(".part")]
if not matches:
raise RuntimeError("yt-dlp produced no video file")
return matches[0]
def extract_video_frames(video_path, frame_interval_sec=60, out_dir=None):
"""Extract 1 frame per `frame_interval_sec` using ffmpeg.
Returns a list of {"t": seconds_offset, "path": "/tmp/...jpg"}.
"""
import subprocess
import glob as _glob
if out_dir is None:
out_dir = f"/tmp/frames_{os.path.basename(video_path)}"
os.makedirs(out_dir, exist_ok=True)
# fps = 1/interval → one frame every N seconds.
fps_filter = f"fps=1/{frame_interval_sec}"
out_pattern = os.path.join(out_dir, "f_%04d.jpg")
subprocess.run(
["ffmpeg", "-y", "-i", video_path, "-vf", fps_filter,
"-q:v", "5", out_pattern],
capture_output=True, text=True, timeout=600, check=True,
)
paths = sorted(_glob.glob(os.path.join(out_dir, "f_*.jpg")))
# Frame i (0-indexed) represents timestamp i * interval.
return [{"t": i * frame_interval_sec, "path": p} for i, p in enumerate(paths)]
def diarize_transcript_with_video(video_id, segments, video_title, max_frames=60):
"""Use Claude vision to produce a speaker-attributed transcript.
Strategy:
1. Download video (low-res) and extract up to `max_frames` still frames
spread evenly across the interview.
2. Send frames + the timestamped transcript to Claude with instructions
to assign each transcript segment to a speaker based on who is
visibly speaking (mouth movement, active speaker focus) at that time.
Returns a plain-text transcript string with speaker prefixes like:
[0:12] Alex: Can you walk me through ...
[0:35] Participant (guest): Well, usually I ...
Falls back to the raw transcript on any error.
"""
if not segments:
return ""
# Enforce ENABLE_VIDEO_DIARIZATION env gate upstream; this function assumes
# caller decided to try.
api_key = _get_anthropic_key()
if not api_key:
log(" ⚠️ No Anthropic key; cannot run video diarization", "warn")
return "\n".join(f"[{_format_timestamp(s['start'])}] {s['text']}" for s in segments)
try:
log(f" 🎞️ Downloading video for diarization: {video_id}")
video_path = download_youtube_video(video_id)
except Exception as e:
log(f" ⚠️ Video download failed, falling back to text-only: {e}", "warn")
return "\n".join(f"[{_format_timestamp(s['start'])}] {s['text']}" for s in segments)
# Pick a frame interval that keeps us under max_frames.
duration = segments[-1]["start"] if segments else 0
interval = max(30, int(duration // max_frames) + 1) if duration > 0 else 60
try:
log(f" 🖼️ Extracting 1 frame per {interval}s from {_format_timestamp(duration)} video...")
frames = extract_video_frames(video_path, frame_interval_sec=interval)
except Exception as e:
log(f" ⚠️ Frame extraction failed, falling back to text-only: {e}", "warn")
try:
os.remove(video_path)
except Exception:
pass
return "\n".join(f"[{_format_timestamp(s['start'])}] {s['text']}" for s in segments)
# Cap frames just in case.
frames = frames[:max_frames]
log(f" 🖼️ {len(frames)} frames extracted; sending to Claude for diarization")
# Build the timestamped transcript block.
transcript_lines = [f"[{_format_timestamp(s['start'])}] {s['text']}" for s in segments]
transcript_block = "\n".join(transcript_lines)
transcript_block = _clip(transcript_block, _TRANSCRIPT_CHAR_CAP)
prompt_text = f"""You are analyzing a user-research interview to assign speaker labels to each line of transcript.
<video_title>{video_title}</video_title>
## Arcade team (interviewers)
The following people are Arcade team members and are interviewers: Alex, Vivian, Mariam, Savannah, Sarah. Anyone else is an interviewee / research participant.
## Input 1: Video frames
I am providing {len(frames)} still frames sampled one every {interval} seconds. Frame N corresponds to timestamp {interval * 0}s + N × {interval}s (i.e. frame 1 = 0:00, frame 2 = {_format_timestamp(interval)}, etc.). In each frame, identify which tile / person appears to be actively speaking (look for mouth movement, active-speaker highlight, or obvious focus). Track individuals consistently — a person in tile position X at 2:00 is likely the same person at 2:30.
## Input 2: Timestamped transcript
Each line is prefixed with its start time.
```
{transcript_block}
```
## Your task
Produce a speaker-attributed transcript. Rules:
1. For each transcript line, determine which speaker said it by looking at the nearest frame(s) in time and seeing who was speaking.
2. Use the Arcade team member's first name when you can identify them. Use "Participant" (or "Participant 2", "Participant 3" if there are multiple) for interviewees.
3. If you genuinely cannot tell who is speaking for a line, use "Unknown". Do NOT guess.
4. Merge consecutive lines from the same speaker into one paragraph, keeping the earliest timestamp.
5. Output format (plain text, no markdown, no preamble):
[mm:ss] SpeakerName: the spoken text
[mm:ss] SpeakerName: the next spoken text
Begin the output immediately with the first speaker line. Do not include any explanation before or after."""
# Build Anthropic multimodal message.
try:
from anthropic import Anthropic
import base64
client = Anthropic(api_key=api_key)
content = []
for f in frames:
try:
with open(f["path"], "rb") as fh:
b64 = base64.standard_b64encode(fh.read()).decode("ascii")
content.append({
"type": "image",
"source": {"type": "base64", "media_type": "image/jpeg", "data": b64},
})
except Exception as e:
log(f" ⚠️ Could not read frame {f['path']}: {e}", "warn")
content.append({"type": "text", "text": prompt_text})
msg = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=16000,
messages=[{"role": "user", "content": content}],
)
diarized = msg.content[0].text.strip()
log(f" ✅ Diarization produced {len(diarized)} chars")
return diarized
except Exception as e:
log(f" ⚠️ Video diarization call failed: {e}", "warn")
return "\n".join(transcript_lines)
finally:
# Best-effort cleanup of temp files
try:
os.remove(video_path)
except Exception:
pass
# ─── AI Synthesis ─────────────────────────────────────────────────────────────
CLAUDE_MODEL = "claude-sonnet-4-6"
def llm_synthesize(prompt, api_key, max_tokens=4000):
"""Call Claude API for synthesis tasks"""
import anthropic
client = anthropic.Anthropic(api_key=api_key)
msg = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}]
)
return msg.content[0].text
def _get_anthropic_key():
try:
return (
session.get("anthropic_api_key", "")
or _g_anthropic_key.get("key", "")
or os.environ.get("ANTHROPIC_API_KEY", "")
)
except RuntimeError:
return _g_anthropic_key.get("key", "") or os.environ.get("ANTHROPIC_API_KEY", "")
# Send the full transcript. Claude's context window is large enough to hold
# a multi-hour interview — truncation was the single biggest reason details
# and Q&A alignment were being dropped.
_TRANSCRIPT_CHAR_CAP = 400_000 # safety rail, not a quality knob
_SUMMARY_CHAR_CAP = 50_000
def _clip(text, cap):
if not text:
return ""
if len(text) <= cap:
return text
return text[:cap] + "\n\n[... truncated at safety cap ...]"
def extract_interview_data(transcript, granola_summary, video_title):
"""Parse a user research interview into strictly-aligned Q&A per participant.
Key correctness rules (to fix the debrief-sheet misalignment):
- Every response must be a VERBATIM or near-verbatim quote from the transcript.
- A response is only attributed to a named person if the transcript/summary
makes that attribution unambiguous. Otherwise use "Unattributed".
- Questions are returned in the order they were actually asked in the session.
- Do not invent questions that weren't asked. Do not merge distinct questions.
"""
transcript_clean = _clip(transcript, _TRANSCRIPT_CHAR_CAP)
summary_clean = _clip(granola_summary, _SUMMARY_CHAR_CAP)
prompt = f"""You are analyzing a user research interview transcript. Your job is to produce a faithful, strictly-aligned Q&A structure so it can be dropped into a debrief spreadsheet. Accuracy of attribution matters MORE than coverage.
<video_title>{video_title}</video_title>
<granola_summary>
{summary_clean if summary_clean else "(not available)"}
</granola_summary>
<transcript>
{transcript_clean if transcript_clean else "(not available)"}
</transcript>
## Team context
Arcade team members who may be interviewers: Alex, Vivian, Mariam, Savannah, Sarah.
Any other speaker is an interviewee (user research participant).
## Hard rules — READ CAREFULLY
1. **Preserve question order.** Return questions in the order they were actually asked in the session. Do NOT reorder, merge, or paraphrase into higher-level themes.
2. **Only include questions that were actually asked aloud** by someone in the session. Do not include questions from the interview guide that were skipped.
3. **Attribution must be unambiguous.** Only attribute a response to a specific named person if the transcript or summary clearly indicates who said it (e.g. speaker label, "Sarah said...", explicit context). If you cannot tell who said a response, use the key `"Unattributed"` — do NOT guess.
4. **Responses must be quote-grounded.** Each response value should be a direct quote or a very close paraphrase. If you need to shorten, wrap the quoted part in quotation marks and add ellipses. Never fabricate specifics.
5. **One row = one question.** If the same question was re-asked, merge into one row and combine responses per person.
6. **Don't over-cluster.** If an interviewer asked 12 distinct questions, return 12 rows. Don't collapse to 5.
7. **If a participant didn't answer a question, leave their cell out of `responses`.** Do not emit empty strings.
8. **If you cannot extract reliable Q&A at all** (e.g. transcript too noisy, no speaker labels, summary lacks detail), return an empty `questions` array and set `extraction_confidence` to `"low"` with a brief `extraction_notes` explanation. This is BETTER than making things up.
## Output format — return ONLY this JSON, no prose, no code fences
{{
"participants": ["Name1", "Name2", "..."],
"arcade_members": ["Name1"],
"interviewees": ["Name1"],
"extraction_confidence": "high" | "medium" | "low",
"extraction_notes": "One sentence explaining confidence (e.g., 'Transcript had clear speaker labels' or 'No speaker labels in YT transcript; most responses unattributed').",
"questions": [
{{
"question": "The verbatim (or near-verbatim) question asked.",
"asked_by": "Name of the interviewer who asked it, or 'Unknown'",
"responses": {{
"InterviewName": "Direct quote or tight paraphrase of their response.",
"Unattributed": "A response that was given but cannot be attributed to a specific person."
}}
}}
],
"interview_guide_questions": ["Q1", "Q2", "..."]
}}
"""
try:
from anthropic import Anthropic
api_key = _get_anthropic_key()
if not api_key:
return None
client = Anthropic(api_key=api_key)
msg = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=8000,
messages=[{"role": "user", "content": prompt}]
)
raw = msg.content[0].text.strip()
raw = raw.replace("```json", "").replace("```", "").strip()
data = json.loads(raw)
confidence = data.get("extraction_confidence", "unknown")
notes = data.get("extraction_notes", "")
log(f" 🧠 Extraction confidence: {confidence}. {notes}")
return data
except Exception as e:
log(f"AI synthesis error: {e}", "warn")
return None
def synthesize_high_level_summary(granola_summary, transcript, interview_data, video_title):
"""Generate a rich, structured product takeaways document.
Returns a single plain-text string with section headings, ready to drop into
a Google Doc. Designed to replace both the old verbatim Granola dump and the
old 1-paragraph strategist blurb — hence it intentionally includes the
Granola summary near the top.
"""
transcript_clean = _clip(transcript, _TRANSCRIPT_CHAR_CAP)
summary_clean = _clip(granola_summary, _SUMMARY_CHAR_CAP)
questions_json = json.dumps(interview_data.get("questions", []), indent=2) if interview_data else "N/A"
prompt = f"""You are a senior product strategist and user researcher working with the Arcade team. You have just watched a user interview and need to produce a single takeaways document that a PM, designer, or eng lead can read in 3 minutes and walk away with clear, evidence-backed product direction.
<interview_title>{video_title}</interview_title>
<granola_summary>
{summary_clean if summary_clean else "(not available)"}
</granola_summary>
<full_transcript>
{transcript_clean if transcript_clean else "(not available)"}
</full_transcript>
<structured_qa>
{questions_json}
</structured_qa>
## Output requirements
Produce a plain-text document using the EXACT section headings and order below. Use short paragraphs and bullet lists (start bullets with "• "). No markdown bold or italics — this will be rendered in a Google Doc as plain text.
Rules:
- **Ground every claim in evidence.** When possible, include a direct quote in quotation marks, attributed to a participant (e.g., "I just gave up at that point" — Participant).
- **Be specific.** Name the feature, the flow, the word the user used. Avoid vague language like "users struggled with onboarding" — instead say what specifically broke.
- **Distinguish signal from noise.** If only one participant said something, label it "(single data point)". If multiple said the same thing, say so.
- **No invented detail.** If the transcript doesn't say it, don't claim it. If you're uncertain, say "Unclear from the transcript".
- **Write for decision-makers.** The goal is: a reader should finish this doc knowing what to change in the product.
=== SECTIONS ===
TL;DR
One tight paragraph (3-4 sentences) naming the single most important thing the team should take from this session. This is the only section that should read as pure prose.
Key Insights
5-8 bullets. Each bullet should be a concrete insight (not a restatement of a question), followed by a supporting quote or observation. Format: "• Insight statement. Evidence: quote or paraphrase."
Pain Points & Friction
What specifically frustrated users, where in the product, and why. 3-6 bullets. Include the step or feature name and the specific failure mode.
What's Working
Things users liked or praised. Be honest — if there were none, say so. 2-5 bullets with evidence.
Feature Requests & Desires
What users explicitly asked for OR strongly implied they wanted. 2-5 bullets. Label each as (explicit ask) or (implied). Include a quote when available.
Surprising or Contrarian Moments
Anything that contradicted the team's assumptions, was counterintuitive, or would change a PM's roadmap. 1-4 bullets. If nothing qualifies, write "(nothing notable)".
Direct Quotes Worth Keeping
5-10 verbatim quotes that capture the user's actual voice. Format: "Quote text" — Participant name (or Participant if unattributed). Pick quotes a designer or marketer would actually use in a readout.
Recommended Next Steps
3-6 concrete, actionable next steps for the product/design/eng team. Each should be something a PM could turn into a ticket tomorrow. Format: "• [Team] Action — rationale."
Open Questions for Follow-up Research
2-5 questions that came out of this session but weren't answered. These feed the next round of research.
Begin the document now, starting with the line "TL;DR" (no preamble, no title)."""
try:
from anthropic import Anthropic
api_key = _get_anthropic_key()
if not api_key:
return "High-level summary could not be generated (no Anthropic API key)."
client = Anthropic(api_key=api_key)
msg = client.messages.create(
model=CLAUDE_MODEL,
max_tokens=6000,
messages=[{"role": "user", "content": prompt}]
)
return msg.content[0].text.strip()
except Exception as e:
return f"Summary generation failed: {e}"
# ─── Google Drive / Docs Helpers ──────────────────────────────────────────────
def create_drive_folder(name, parent_id):
drive = get_drive_service()
meta = {
"name": name,
"mimeType": "application/vnd.google-apps.folder",
"parents": [parent_id],
}
folder = drive.files().create(body=meta, fields="id").execute()
return folder["id"]
def create_google_doc(title, content_requests, parent_folder_id):
"""Create a Google Doc with title and batch update content"""
docs = get_docs_service()
drive = get_drive_service()
# Create empty doc
doc = docs.documents().create(body={"title": title}).execute()
doc_id = doc["documentId"]
# Move to folder
file = drive.files().get(fileId=doc_id, fields="parents").execute()
drive.files().update(
fileId=doc_id,
addParents=parent_folder_id,
removeParents=",".join(file.get("parents", [])),
fields="id, parents",
).execute()
# Apply content
if content_requests:
docs.documents().batchUpdate(
documentId=doc_id,
body={"requests": content_requests}
).execute()
return doc_id, f"https://docs.google.com/document/d/{doc_id}"
def create_google_sheet(title, headers, rows, parent_folder_id):
"""Create a Google Sheet with headers and data rows"""
sheets = get_sheets_service()
drive = get_drive_service()
spreadsheet = sheets.spreadsheets().create(body={"properties": {"title": title}}).execute()
sheet_id = spreadsheet["spreadsheetId"]
# Move to folder
file = drive.files().get(fileId=sheet_id, fields="parents").execute()
drive.files().update(
fileId=sheet_id,
addParents=parent_folder_id,
removeParents=",".join(file.get("parents", [])),
fields="id, parents",
).execute()
# Write data
all_rows = [headers] + rows
sheets.spreadsheets().values().update(
spreadsheetId=sheet_id,
range="Sheet1!A1",
valueInputOption="RAW",
body={"values": all_rows},
).execute()
# Bold the header row
sheets.spreadsheets().batchUpdate(
spreadsheetId=sheet_id,
body={"requests": [{
"repeatCell": {
"range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 1},
"cell": {"userEnteredFormat": {"textFormat": {"bold": True}, "backgroundColor": {"red": 0.9, "green": 0.9, "blue": 0.9}}},
"fields": "userEnteredFormat(textFormat,backgroundColor)"
}
}]}
).execute()
return sheet_id, f"https://docs.google.com/spreadsheets/d/{sheet_id}"
def copy_file_to_folder(file_id, new_name, dest_folder_id):
"""Copy a Drive file to another folder"""
drive = get_drive_service()
copied = drive.files().copy(
fileId=file_id,
body={"name": new_name, "parents": [dest_folder_id]}
).execute()
return copied["id"]
# ─── In-place update helpers (for regenerating existing folders) ────────────
def list_folder_contents(folder_id):
"""Return direct children of a Drive folder: [{id, name, mimeType}, ...]."""
drive = get_drive_service()
files = []
page_token = None
while True:
resp = drive.files().list(
q=f"'{folder_id}' in parents and trashed = false",
fields="nextPageToken, files(id, name, mimeType)",
pageSize=100,
pageToken=page_token,
).execute()
files.extend(resp.get("files", []))
page_token = resp.get("nextPageToken")
if not page_token:
break
return files
def get_folder_name(folder_id):
drive = get_drive_service()
meta = drive.files().get(fileId=folder_id, fields="id, name, parents").execute()
return meta.get("name", "")
def rename_drive_file(file_id, new_name):
drive = get_drive_service()
drive.files().update(fileId=file_id, body={"name": new_name}).execute()
def replace_google_doc_content(doc_id, new_text):
"""Clear a Google Doc's body and replace it with plain text.
The Docs API requires us to delete an existing range and then insert.
We use endIndex-1 because the trailing newline of the body is protected.
"""
docs = get_docs_service()
doc = docs.documents().get(documentId=doc_id).execute()
end_index = doc.get("body", {}).get("content", [{}])[-1].get("endIndex", 1)
requests_batch = []
if end_index > 2:
requests_batch.append({
"deleteContentRange": {
"range": {"startIndex": 1, "endIndex": end_index - 1}
}
})
if new_text:
requests_batch.append({
"insertText": {"location": {"index": 1}, "text": new_text}
})
if requests_batch:
docs.documents().batchUpdate(
documentId=doc_id,
body={"requests": requests_batch},
).execute()
def replace_google_sheet_content(sheet_id, headers, rows):
"""Clear the first sheet and write (headers + rows) starting at A1."""
sheets = get_sheets_service()
# 1. Clear everything on Sheet1
sheets.spreadsheets().values().clear(
spreadsheetId=sheet_id,
range="Sheet1",
body={},
).execute()
# 2. Write headers + rows
all_rows = [headers] + rows
sheets.spreadsheets().values().update(
spreadsheetId=sheet_id,
range="Sheet1!A1",
valueInputOption="RAW",
body={"values": all_rows},
).execute()
# 3. Bold header row
sheets.spreadsheets().batchUpdate(
spreadsheetId=sheet_id,
body={"requests": [{
"repeatCell": {
"range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 1},
"cell": {"userEnteredFormat": {"textFormat": {"bold": True}, "backgroundColor": {"red": 0.9, "green": 0.9, "blue": 0.9}}},
"fields": "userEnteredFormat(textFormat,backgroundColor)",
}
}]}
).execute()
def _parse_date_from_folder_name(name):
"""Extract an M/D/YY or M/D/YYYY date from a folder name like '(5/16/25) ...'.
Returns the date string as-is (preserving M/D/YY or M/D formatting) or ''.
"""
import re
m = re.match(r"\s*\(([^)]+)\)", name)
if not m:
return ""
inner = m.group(1).strip()
# Accept common date shapes
if re.match(r"^\d{1,2}/\d{1,2}(/\d{2,4})?$", inner):
return inner
return ""
def doc_text_requests(text):
"""Simple helper to insert plain text into a doc"""
return [{"insertText": {"location": {"index": 1}, "text": text}}]
def build_debrief_sheet(interview_data):
"""Turn extracted interview_data into (headers, rows) for the debrief sheet.
Always puts Arcade interviewers last (after interviewees). Includes an
"Unattributed" column if the extractor flagged unknown-speaker responses,
so the team knows that signal exists without attributing to the wrong name.
"""
if not interview_data or not interview_data.get("questions"):
return (
["Question", "Asked By", "(Response)", "Extraction notes"],
[[
"(Transcript not available or extraction confidence too low — add manually)",
"",
"",
(interview_data or {}).get("extraction_notes", ""),
]],
)
questions_data = interview_data.get("questions", [])
arcade_members = interview_data.get("arcade_members", []) or []
interviewees = interview_data.get("interviewees", []) or []
declared_participants = interview_data.get("participants", []) or []
# Discover any speaker names that actually appear in responses so nothing
# gets silently dropped — this is the core fix for misalignment.
seen_speakers = set()
for q in questions_data:
for name in (q.get("responses") or {}).keys():
seen_speakers.add(name)
ordered_speakers = []
# Interviewees first (most important column for a debrief).
for name in interviewees:
if name in seen_speakers and name not in ordered_speakers:
ordered_speakers.append(name)
# Then arcade interviewers who actually spoke.
for name in arcade_members:
if name in seen_speakers and name not in ordered_speakers:
ordered_speakers.append(name)
# Anyone from the declared participant list we haven't placed yet.
for name in declared_participants:
if name in seen_speakers and name not in ordered_speakers:
ordered_speakers.append(name)
# Anything else observed in responses (e.g., Participant 2, Unattributed).
for name in sorted(seen_speakers):
if name not in ordered_speakers:
ordered_speakers.append(name)
headers = ["#", "Question", "Asked By"] + ordered_speakers
rows = []
for i, q in enumerate(questions_data, 1):
responses = q.get("responses") or {}
row = [str(i), q.get("question", ""), q.get("asked_by", "")]
for name in ordered_speakers:
row.append(responses.get(name, ""))
rows.append(row)
return headers, rows
def build_combined_takeaways_text(date_str, granola_summary, hl_summary):
"""Build the text body for the merged Interview Summary & Takeaways doc.
Starts with a short title line, then the Granola AI summary (raw, as the
team trusts this), then the structured synthesized takeaways.
"""
parts = [f"({date_str}) Interview Summary & Takeaways", ""]
if granola_summary:
parts.append("── Granola AI Summary ──")
parts.append("")
parts.append(granola_summary.strip())
parts.append("")
parts.append("── Product Takeaways ──")
parts.append("")
parts.append((hl_summary or "(takeaways not generated)").strip())
parts.append("")
return "\n".join(parts)
# ─── Main Workflow ────────────────────────────────────────────────────────────
def run_workflow(config):
"""Main workflow: match YT streams to Granola notes, create folders + docs"""
global progress_log, current_job
progress_log = []
current_job = {"running": True, "done": False, "error": None, "results": []}
try:
granola_key = config["granola_api_key"]
yt_api_key = config.get("youtube_api_key", "")
manual_streams = config.get("manual_streams", []) # fallback if no YT API
log("🚀 Starting Interview Automation Workflow...")
# 1. Fetch Granola notes
log("📓 Fetching Granola notes...")
granola_data = fetch_granola_notes(granola_key)
if "error" in granola_data:
log(f"⚠️ Granola API issue: {granola_data['error']}. Will use manual matching.", "warn")
granola_notes = []
else:
granola_notes = granola_data if isinstance(granola_data, list) else granola_data.get("documents", granola_data.get("notes", []))
log(f"✅ Found {len(granola_notes)} Granola notes")
# 2. Fetch YouTube livestreams
log("🎬 Fetching YouTube livestreams...")
if yt_api_key:
yt_streams = fetch_youtube_livestreams(yt_api_key)
log(f"✅ Found {len(yt_streams)} YouTube livestreams")
else:
yt_streams = manual_streams
log(f"📋 Using {len(yt_streams)} manually provided streams")
if not yt_streams:
log("⚠️ No YouTube livestreams found. Please provide YouTube Data API key or add streams manually.", "warn")
current_job["done"] = True
current_job["running"] = False
return
# 3. Match streams to Granola notes
log("🔗 Matching YouTube streams to Granola notes...")
results = []
for stream in yt_streams:
stream_title = stream.get("title", "")
stream_date_raw = stream.get("published_at", "")
video_id = stream.get("id", "")
video_url = stream.get("url", f"https://www.youtube.com/watch?v={video_id}")
# Parse date
try:
if stream_date_raw:
dt = datetime.fromisoformat(stream_date_raw.replace("Z", "+00:00"))
date_str = dt.strftime("%-m/%-d/%y")
else:
date_str = stream.get("date", "Unknown Date")
except Exception:
date_str = stream.get("date", "Unknown Date")
log(f"\n📹 Processing: {stream_title} ({date_str})")
# Fetch timestamped transcript so we can run video-assisted diarization.
yt_segments = get_youtube_transcript_timestamped(video_id) if video_id else []
transcript = " ".join(s["text"] for s in yt_segments) if yt_segments else ""
stream_description = stream.get("description", "")
if not transcript and not stream_description and not stream_title:
log(f"⏭️ Skipping '{stream_title}' — no content found (interviewee likely didn't show)", "warn")
results.append({"stream": stream_title, "date": date_str, "status": "skipped_no_content"})
continue
# Find matching Granola note
matched_note = None
matched_note_detail = None
for note in granola_notes:
note_title = note.get("title", "") or note.get("name", "")
note_date = note.get("created_at", "") or note.get("date", "")
# Match by date proximity or title similarity
if date_str and date_str in note_title:
matched_note = note
break
if stream_title.lower()[:15] in note_title.lower():
matched_note = note
break
# Try date matching