-
Notifications
You must be signed in to change notification settings - Fork 14
feat: add YouTube knowledge source ingestion and retrieval #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
e363e83
7ec9ec3
2b5e994
094ad98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,7 +56,7 @@ | |
| # ── Retrieval ────────────────────────────────────────────────────────────────── | ||
| RETRIEVAL_K = 8 # final chunks returned per collection | ||
| RETRIEVAL_FETCH_K = 30 # MMR candidate pool | ||
| MAX_CONTEXT_DOCS = 40 # hard cap on total docs passed to LLM (prevents context overflow) | ||
| MAX_CONTEXT_DOCS = 10 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
Per Consider either a higher cap or per-source minimums (e.g., reserve N slots per collection before the global truncation) so YouTube doesn't crowd out existing sources. 🤖 Prompt for AI Agents |
||
|
|
||
| # Cosine similarity (1 = identical). Above this → treat as duplicate question. | ||
| DEDUP_THRESHOLD = 0.88 | ||
|
|
@@ -179,5 +179,12 @@ | |
| JIRA_FILE = DATA_DIR / "mosip_jira.json" | ||
| JIRA_COLLECTION = "mosip_jira" | ||
|
|
||
| # ── YouTube crawler ─────────────────────────────────────────────────────────── | ||
| YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY", "") | ||
| YOUTUBE_CHANNEL_HANDLE = os.getenv("YOUTUBE_CHANNEL_HANDLE", "mosip16") | ||
|
|
||
| YOUTUBE_FILE = DATA_DIR / "mosip_youtube.json" | ||
| YOUTUBE_COLLECTION = "mosip_youtube" | ||
|
|
||
| # ── HTTP headers ─────────────────────────────────────────────────────────────── | ||
| HTTP_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; MosipNexusBot/1.0)"} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| """ | ||
| YouTube crawler for MOSIP videos. | ||
| """ | ||
| import sys | ||
| import json | ||
| from pathlib import Path | ||
| from googleapiclient.discovery import build | ||
| sys.path.insert(0, str(Path(__file__).parent.parent)) | ||
|
|
||
| from config.settings import ( | ||
| YOUTUBE_API_KEY, | ||
| YOUTUBE_CHANNEL_HANDLE, | ||
| YOUTUBE_FILE, | ||
| ) | ||
|
|
||
|
|
||
| def get_youtube_client(): | ||
| """Create a YouTube Data API client.""" | ||
| return build( | ||
| "youtube", | ||
| "v3", | ||
| developerKey=YOUTUBE_API_KEY, | ||
| ) | ||
|
|
||
|
|
||
| def get_channel_id(youtube): | ||
| """Resolve the channel ID from the handle.""" | ||
| response = youtube.channels().list( | ||
| part="id", | ||
| forHandle=YOUTUBE_CHANNEL_HANDLE, | ||
| ).execute() | ||
|
|
||
| items = response.get("items", []) | ||
|
|
||
| if not items: | ||
| raise RuntimeError( | ||
| f"Channel not found: @{YOUTUBE_CHANNEL_HANDLE}" | ||
| ) | ||
|
|
||
| return items[0]["id"] | ||
| def get_uploads_playlist_id(youtube, channel_id): | ||
| """Return the uploads playlist ID for a channel.""" | ||
| response = youtube.channels().list( | ||
| part="contentDetails", | ||
| id=channel_id, | ||
| ).execute() | ||
|
|
||
| items = response.get("items", []) | ||
|
|
||
| if not items: | ||
| raise RuntimeError("Channel not found.") | ||
|
|
||
| return ( | ||
| items[0]["contentDetails"] | ||
| ["relatedPlaylists"] | ||
| ["uploads"] | ||
| ) | ||
| def get_all_videos(youtube, playlist_id): | ||
| """Fetch all public videos from the uploads playlist.""" | ||
|
|
||
| videos = [] | ||
| next_page_token = None | ||
|
|
||
| while True: | ||
| response = youtube.playlistItems().list( | ||
| part="snippet", | ||
| playlistId=playlist_id, | ||
| maxResults=50, | ||
| pageToken=next_page_token, | ||
| ).execute() | ||
|
|
||
| for item in response.get("items", []): | ||
| snippet = item["snippet"] | ||
|
|
||
| videos.append( | ||
| { | ||
| "video_id": snippet["resourceId"]["videoId"], | ||
| "title": snippet["title"], | ||
| "published_at": snippet["publishedAt"], | ||
| } | ||
| ) | ||
|
|
||
| next_page_token = response.get("nextPageToken") | ||
|
|
||
| if not next_page_token: | ||
| break | ||
|
|
||
| return videos | ||
| from youtube_transcript_api import YouTubeTranscriptApi | ||
| from youtube_transcript_api._errors import ( | ||
| NoTranscriptFound, | ||
| TranscriptsDisabled, | ||
| IpBlocked, | ||
| ) | ||
|
|
||
| def fetch_transcript(video_id: str): | ||
| """ | ||
| Fetch transcript for a video. | ||
|
|
||
| Preference: | ||
| 1. Manual English captions | ||
| 2. Auto-generated English | ||
| 3. Any available language | ||
| """ | ||
| api = YouTubeTranscriptApi() | ||
|
|
||
| try: | ||
| return api.fetch( | ||
| video_id, | ||
| languages=["en"], | ||
| ) | ||
|
|
||
| except NoTranscriptFound: | ||
| pass | ||
|
|
||
| except TranscriptsDisabled: | ||
| print(f"WARN: transcripts disabled for {video_id}") | ||
| return None | ||
|
|
||
| except IpBlocked: | ||
| print(f"WARN: YouTube blocked transcript requests for {video_id}") | ||
| return None | ||
|
|
||
| except Exception as e: | ||
| print(f"WARN: transcript fetch failed for {video_id}: {e}") | ||
| return None | ||
|
|
||
| try: | ||
| return api.fetch(video_id) | ||
|
|
||
| except Exception as e: | ||
| print(f"WARN: transcript fetch failed for {video_id}: {e}") | ||
| return None | ||
| def transcript_to_segments(transcript): | ||
| """Convert transcript snippets into plain dictionaries.""" | ||
| segments = [] | ||
|
|
||
| for snippet in transcript: | ||
| segments.append( | ||
| { | ||
| "text": snippet.text, | ||
| "start": snippet.start, | ||
| "duration": snippet.duration, | ||
| } | ||
| ) | ||
|
|
||
| return segments | ||
| def build_video_document(video: dict): | ||
| """ | ||
| Build one structured document for a YouTube video. | ||
| """ | ||
|
|
||
| transcript = fetch_transcript(video["video_id"]) | ||
|
|
||
| if transcript is None: | ||
| print(f"WARN: no transcript for {video['title']} ({video['video_id']})") | ||
| return None | ||
|
|
||
| return { | ||
| "video_id": video["video_id"], | ||
| "title": video["title"], | ||
| "published_at": video["published_at"], | ||
| "channel_name": "MOSIP", | ||
| "transcript": transcript_to_segments(transcript), | ||
| } | ||
| def crawl_youtube(): | ||
| """Fetch all YouTube videos and save them.""" | ||
|
|
||
| youtube = get_youtube_client() | ||
|
|
||
| channel_id = get_channel_id(youtube) | ||
|
|
||
| playlist_id = get_uploads_playlist_id( | ||
| youtube, | ||
| channel_id, | ||
| ) | ||
|
|
||
| videos = get_all_videos( | ||
| youtube, | ||
| playlist_id, | ||
| ) | ||
|
|
||
| docs = [] | ||
|
|
||
| print(f"Found {len(videos)} videos") | ||
|
|
||
| for i, video in enumerate(videos, start=1): | ||
|
|
||
| print(f"[{i}/{len(videos)}] {video['title']}") | ||
|
|
||
| doc = build_video_document(video) | ||
|
|
||
| if doc: | ||
| docs.append(doc) | ||
|
|
||
| with open( | ||
| YOUTUBE_FILE, | ||
| "w", | ||
| encoding="utf-8", | ||
| ) as f: | ||
|
|
||
| json.dump( | ||
| docs, | ||
| f, | ||
| indent=2, | ||
| ensure_ascii=False, | ||
| ) | ||
|
|
||
| print(f"\nSaved {len(docs)} videos") | ||
|
|
||
| return docs | ||
|
|
||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| crawl_youtube() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Fix the syntax error.
The missing comma after the
valueargument causes aSyntaxErrorthat crashes the application. Such service outages violate MOSIP operational availability and compliance baselines.🐛 Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 Ruff (0.15.21)
[warning] 254-254: Expected
,, foundtype(invalid-syntax)
🤖 Prompt for AI Agents
Source: Linters/SAST tools