pkheria commited on
Commit
9707a84
·
1 Parent(s): 7dcc090

Replace YouTube ingestion with Medium extraction

Browse files
README.md CHANGED
@@ -14,11 +14,11 @@ short_description: AI knowledge hub for groups, powered by Nvidia
14
 
15
  BuildSmall KnowledgeHub is a modular Gradio app for loading knowledge from:
16
 
17
- - YouTube links with public transcripts/captions
18
  - arXiv links or IDs
19
  - PDF documents
20
 
21
- It extracts text, chunks it, embeds chunks locally with the configured NVIDIA Nemotron embedding model, uploads vectors into Qdrant, and generates grounded answers with NVIDIA's OpenAI-compatible chat API.
22
 
23
  ## NVIDIA Usage
24
 
@@ -92,18 +92,15 @@ HF_TOKEN=<token-if-needed-for-gated-model-downloads>
92
 
93
  Use a hosted Qdrant instance for Hugging Face Spaces. `localhost:6333` only works for local development.
94
 
95
- ## Hosted YouTube Transcript Note
96
 
97
- YouTube transcript fetching can fail on hosted platforms such as Hugging Face Spaces because YouTube may block transcript requests from datacenter IP addresses.
98
 
99
- If a YouTube URL fails on the hosted app:
100
-
101
- 1. Open the video on YouTube.
102
- 2. Copy the transcript manually.
103
- 3. Paste it into the **YouTube transcript fallback** box.
104
- 4. Keep the YouTube URL in the URL field and run ingestion again.
105
 
106
- This bypasses hosted transcript fetching while still storing the content as a YouTube source in Qdrant.
107
 
108
  ## Qdrant Collection Name
109
 
@@ -141,10 +138,10 @@ The app binds to `0.0.0.0:7860`, which is suitable for Hugging Face Spaces and c
141
  ```text
142
  app/
143
  core/ settings and shared models
144
- extractors/ PDF, arXiv, and YouTube extraction
145
  services/ chunking, embeddings, Qdrant, retrieval, ingestion orchestration
146
  ui/ Gradio Blocks UI
147
  utils/ source detection helpers
148
  ```
149
 
150
- YouTube extraction requires captions/transcripts to be available for the video. arXiv ingestion downloads the paper PDF and parses it with `pypdf`.
 
14
 
15
  BuildSmall KnowledgeHub is a modular Gradio app for loading knowledge from:
16
 
17
+ - Medium article links through Freedium
18
  - arXiv links or IDs
19
  - PDF documents
20
 
21
+ It extracts text, captures Medium image references/captions when available, chunks the content, embeds chunks locally with the configured NVIDIA Nemotron embedding model, uploads vectors into Qdrant, and generates grounded answers with NVIDIA's OpenAI-compatible chat API.
22
 
23
  ## NVIDIA Usage
24
 
 
92
 
93
  Use a hosted Qdrant instance for Hugging Face Spaces. `localhost:6333` only works for local development.
94
 
95
+ ## Medium Article Extraction
96
 
97
+ Medium articles are fetched through Freedium:
98
 
99
+ ```text
100
+ https://freedium-mirror.cfd/
101
+ ```
 
 
 
102
 
103
+ Pass a Medium article URL into the app. The extractor builds a Freedium mirror URL, extracts the readable article text, collects image URLs and alt/caption text when available, then sends that combined content through the same chunking, embedding, and Qdrant upload pipeline.
104
 
105
  ## Qdrant Collection Name
106
 
 
138
  ```text
139
  app/
140
  core/ settings and shared models
141
+ extractors/ PDF, arXiv, and Medium extraction
142
  services/ chunking, embeddings, Qdrant, retrieval, ingestion orchestration
143
  ui/ Gradio Blocks UI
144
  utils/ source detection helpers
145
  ```
146
 
147
+ Medium extraction uses `freedium-mirror.cfd`. arXiv ingestion downloads the paper PDF and parses it with `pypdf`.
app/core/models.py CHANGED
@@ -7,7 +7,7 @@ from typing import Any
7
  class SourceType(str, Enum):
8
  PDF = "pdf"
9
  ARXIV = "arxiv"
10
- YOUTUBE = "youtube"
11
 
12
 
13
  @dataclass(frozen=True)
 
7
  class SourceType(str, Enum):
8
  PDF = "pdf"
9
  ARXIV = "arxiv"
10
+ MEDIUM = "medium"
11
 
12
 
13
  @dataclass(frozen=True)
app/extractors/medium.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from urllib.parse import quote, urljoin, urlparse
2
+
3
+ import requests
4
+ from bs4 import BeautifulSoup
5
+
6
+ from app.core.models import Document, SourceType
7
+
8
+
9
+ FREEDIUM_BASE = "https://freedium-mirror.cfd"
10
+ USER_AGENT = (
11
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
12
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
13
+ )
14
+
15
+
16
+ def extract_medium(url: str) -> Document:
17
+ source_url = url.strip()
18
+ html, mirror_url = _fetch_freedium_html(source_url)
19
+ soup = BeautifulSoup(html, "html.parser")
20
+
21
+ for tag in soup(["script", "style", "noscript", "svg", "form", "nav", "header", "footer"]):
22
+ tag.decompose()
23
+
24
+ title = _extract_title(soup) or "Medium Article"
25
+ body = soup.find("article") or soup.find("main") or soup.body
26
+ if body is None:
27
+ raise ValueError("Freedium returned a page without readable article content.")
28
+
29
+ text_parts = _extract_text_parts(body)
30
+ image_parts = _extract_images(body, mirror_url)
31
+ combined = "\n\n".join([*text_parts, *image_parts]).strip()
32
+
33
+ if len(combined) < 300:
34
+ raise ValueError(
35
+ "Could not extract enough readable content from the Medium article through Freedium. "
36
+ "Check that the article URL is public and try again."
37
+ )
38
+
39
+ return Document(
40
+ source_type=SourceType.MEDIUM,
41
+ title=title,
42
+ text=combined,
43
+ source=source_url,
44
+ metadata={
45
+ "mirror_url": mirror_url,
46
+ "images": len(image_parts),
47
+ "extractor": "freedium-mirror.cfd",
48
+ },
49
+ )
50
+
51
+
52
+ def _fetch_freedium_html(source_url: str) -> tuple[str, str]:
53
+ candidates = _freedium_candidates(source_url)
54
+ errors: list[str] = []
55
+ for candidate in candidates:
56
+ try:
57
+ response = requests.get(
58
+ candidate,
59
+ headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml"},
60
+ timeout=45,
61
+ )
62
+ response.raise_for_status()
63
+ if response.text.strip():
64
+ return response.text, response.url
65
+ except requests.RequestException as exc:
66
+ errors.append(f"{candidate}: {exc}")
67
+ raise ValueError("Could not fetch the Medium article through Freedium. " + " | ".join(errors[-2:]))
68
+
69
+
70
+ def _freedium_candidates(source_url: str) -> list[str]:
71
+ parsed = urlparse(source_url)
72
+ if "freedium" in parsed.netloc:
73
+ return [source_url]
74
+ return [
75
+ f"{FREEDIUM_BASE}/{source_url}",
76
+ f"{FREEDIUM_BASE}/{quote(source_url, safe='')}",
77
+ ]
78
+
79
+
80
+ def _extract_title(soup: BeautifulSoup) -> str:
81
+ for selector in ['meta[property="og:title"]', 'meta[name="twitter:title"]']:
82
+ tag = soup.select_one(selector)
83
+ if tag and tag.get("content"):
84
+ return tag["content"].strip()
85
+ heading = soup.find("h1")
86
+ if heading:
87
+ return heading.get_text(" ", strip=True)
88
+ if soup.title:
89
+ return soup.title.get_text(" ", strip=True)
90
+ return ""
91
+
92
+
93
+ def _extract_text_parts(body) -> list[str]:
94
+ parts: list[str] = []
95
+ seen: set[str] = set()
96
+ for tag in body.find_all(["h1", "h2", "h3", "p", "li", "blockquote", "pre", "figcaption"]):
97
+ text = tag.get_text(" ", strip=True)
98
+ if not text or text in seen:
99
+ continue
100
+ seen.add(text)
101
+ if tag.name in {"h1", "h2", "h3"}:
102
+ parts.append(f"## {text}")
103
+ elif tag.name == "blockquote":
104
+ parts.append(f"> {text}")
105
+ else:
106
+ parts.append(text)
107
+ return parts
108
+
109
+
110
+ def _extract_images(body, base_url: str) -> list[str]:
111
+ images: list[str] = []
112
+ seen: set[str] = set()
113
+ for image in body.find_all("img"):
114
+ src = image.get("src") or image.get("data-src") or image.get("data-original")
115
+ if not src:
116
+ continue
117
+ absolute_src = urljoin(base_url, src)
118
+ if absolute_src in seen:
119
+ continue
120
+ seen.add(absolute_src)
121
+ alt = image.get("alt", "").strip()
122
+ if alt:
123
+ images.append(f"Image: {alt}\nURL: {absolute_src}")
124
+ else:
125
+ images.append(f"Image URL: {absolute_src}")
126
+ return images
app/extractors/youtube.py DELETED
@@ -1,58 +0,0 @@
1
- from urllib.parse import parse_qs, urlparse
2
-
3
- from youtube_transcript_api import YouTubeTranscriptApi
4
- from youtube_transcript_api._errors import YouTubeTranscriptApiException
5
-
6
- from app.core.models import Document, SourceType
7
-
8
-
9
- def _extract_video_id(url: str) -> str:
10
- parsed = urlparse(url.strip())
11
- if parsed.netloc.endswith("youtu.be"):
12
- return parsed.path.strip("/")
13
- if "youtube.com" in parsed.netloc:
14
- query = parse_qs(parsed.query)
15
- if "v" in query:
16
- return query["v"][0]
17
- if parsed.path.startswith("/shorts/"):
18
- return parsed.path.split("/")[2]
19
- raise ValueError("Could not find a YouTube video ID in the URL.")
20
-
21
-
22
- def extract_youtube(url: str) -> Document:
23
- video_id = _extract_video_id(url)
24
- api = YouTubeTranscriptApi()
25
- try:
26
- if hasattr(api, "fetch"):
27
- transcript = api.fetch(video_id)
28
- transcript_items = transcript.to_raw_data()
29
- else:
30
- transcript_items = YouTubeTranscriptApi.get_transcript(video_id)
31
- except YouTubeTranscriptApiException as exc:
32
- raise ValueError(
33
- "Could not fetch the YouTube transcript. On hosted environments such as "
34
- "Hugging Face Spaces, YouTube often blocks transcript requests from datacenter IPs. "
35
- "Paste the transcript into the YouTube transcript fallback box and retry."
36
- ) from exc
37
-
38
- if not transcript_items:
39
- raise ValueError(
40
- "No transcript was available for this YouTube video. Paste the transcript into "
41
- "the YouTube transcript fallback box and retry."
42
- )
43
-
44
- lines = []
45
- for item in transcript_items:
46
- timestamp = int(item.get("start", 0))
47
- minutes, seconds = divmod(timestamp, 60)
48
- text = item.get("text", "").strip()
49
- if text:
50
- lines.append(f"[{minutes:02d}:{seconds:02d}] {text}")
51
-
52
- return Document(
53
- source_type=SourceType.YOUTUBE,
54
- title=f"YouTube Transcript {video_id}",
55
- text="\n".join(lines).strip(),
56
- source=url,
57
- metadata={"video_id": video_id, "segments": len(transcript_items)},
58
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/ingestion.py CHANGED
@@ -3,8 +3,8 @@ from pathlib import Path
3
  from app.core.config import settings
4
  from app.core.models import Document, IngestionResult, SourceType
5
  from app.extractors.arxiv import extract_arxiv
 
6
  from app.extractors.pdf import extract_pdf
7
- from app.extractors.youtube import extract_youtube
8
  from app.services.chat import NvidiaChatClient
9
  from app.services.chunking import chunk_document
10
  from app.services.embeddings import get_embedding_client
@@ -18,23 +18,14 @@ EXPORT_DIR = Path("data/exports")
18
  def extract_document(
19
  url: str | None = None,
20
  pdf_path: str | None = None,
21
- manual_transcript: str | None = None,
22
  ) -> Document:
23
  source_type = detect_source(url, pdf_path)
24
  if source_type == SourceType.PDF:
25
  return extract_pdf(str(pdf_path))
26
  if source_type == SourceType.ARXIV:
27
  return extract_arxiv(str(url))
28
- if source_type == SourceType.YOUTUBE:
29
- if manual_transcript and manual_transcript.strip():
30
- return Document(
31
- source_type=SourceType.YOUTUBE,
32
- title="YouTube Transcript",
33
- text=manual_transcript.strip(),
34
- source=str(url),
35
- metadata={"transcript_source": "manual"},
36
- )
37
- return extract_youtube(str(url))
38
  raise ValueError(f"Unsupported source type: {source_type}")
39
 
40
 
@@ -69,9 +60,8 @@ def ingest_source(
69
  chunk_size: int | None = None,
70
  chunk_overlap: int | None = None,
71
  collection_name: str | None = None,
72
- manual_transcript: str | None = None,
73
  ) -> IngestionResult:
74
- document = extract_document(url=url, pdf_path=pdf_path, manual_transcript=manual_transcript)
75
  chunks = chunk_document(
76
  document,
77
  chunk_size=chunk_size or settings.CHUNK_SIZE,
 
3
  from app.core.config import settings
4
  from app.core.models import Document, IngestionResult, SourceType
5
  from app.extractors.arxiv import extract_arxiv
6
+ from app.extractors.medium import extract_medium
7
  from app.extractors.pdf import extract_pdf
 
8
  from app.services.chat import NvidiaChatClient
9
  from app.services.chunking import chunk_document
10
  from app.services.embeddings import get_embedding_client
 
18
  def extract_document(
19
  url: str | None = None,
20
  pdf_path: str | None = None,
 
21
  ) -> Document:
22
  source_type = detect_source(url, pdf_path)
23
  if source_type == SourceType.PDF:
24
  return extract_pdf(str(pdf_path))
25
  if source_type == SourceType.ARXIV:
26
  return extract_arxiv(str(url))
27
+ if source_type == SourceType.MEDIUM:
28
+ return extract_medium(str(url))
 
 
 
 
 
 
 
 
29
  raise ValueError(f"Unsupported source type: {source_type}")
30
 
31
 
 
60
  chunk_size: int | None = None,
61
  chunk_overlap: int | None = None,
62
  collection_name: str | None = None,
 
63
  ) -> IngestionResult:
64
+ document = extract_document(url=url, pdf_path=pdf_path)
65
  chunks = chunk_document(
66
  document,
67
  chunk_size=chunk_size or settings.CHUNK_SIZE,
app/ui/gradio_app.py CHANGED
@@ -37,7 +37,6 @@ def _format_metadata(metadata: dict) -> str:
37
  def _ingest(
38
  url: str,
39
  pdf_file: str | None,
40
- manual_transcript: str,
41
  collection_name: str,
42
  ):
43
  logger.info(
@@ -57,7 +56,6 @@ def _ingest(
57
  chunk_size=CHUNK_SIZE,
58
  chunk_overlap=CHUNK_OVERLAP,
59
  collection_name=collection_name,
60
- manual_transcript=manual_transcript,
61
  )
62
  document = result.document
63
  status = (
@@ -174,7 +172,7 @@ def build_app() -> gr.Blocks:
174
  gr.Markdown(
175
  f"""
176
  # {settings.PROJECT_NAME}
177
- Turn papers, PDFs, and videos into a searchable vector memory.
178
 
179
  Extract text, chunk it cleanly, embed locally, and use NVIDIA chat for grounded answers.
180
  """,
@@ -187,7 +185,7 @@ Extract text, chunk it cleanly, embed locally, and use NVIDIA chat for grounded
187
  <div class="kh-chip">Parser <code>{settings.NEMOTRON_PARSE_MODEL}</code></div>
188
  <div class="kh-chip">Chat <code>{settings.NVIDIA_CHAT_MODEL}</code></div>
189
  <div class="kh-chip">Collection <code>{settings.QDRANT_COLLECTION_NAME}</code></div>
190
- <div class="kh-chip">Sources PDF · arXiv · YouTube</div>
191
  </div>
192
  """,
193
  )
@@ -200,8 +198,8 @@ Extract text, chunk it cleanly, embed locally, and use NVIDIA chat for grounded
200
  "### Source Intake\n<div class='kh-subhead'>Upload a PDF or paste one link. The pipeline handles extraction, chunking, local embeddings, and Qdrant upload.</div>"
201
  )
202
  source_url = gr.Textbox(
203
- label="YouTube or arXiv input",
204
- placeholder="Paste a YouTube URL, arXiv URL, or arXiv ID",
205
  lines=2,
206
  )
207
  pdf_file = gr.File(
@@ -209,11 +207,6 @@ Extract text, chunk it cleanly, embed locally, and use NVIDIA chat for grounded
209
  file_types=[".pdf"],
210
  type="filepath",
211
  )
212
- manual_transcript = gr.Textbox(
213
- label="YouTube transcript fallback",
214
- placeholder="If hosted YouTube transcript extraction is blocked, paste the transcript here and keep the YouTube URL above.",
215
- lines=5,
216
- )
217
  collection_name_ingest = gr.Textbox(
218
  label="Collection Name",
219
  value=settings.QDRANT_COLLECTION_NAME,
@@ -263,7 +256,6 @@ Extract text, chunk it cleanly, embed locally, and use NVIDIA chat for grounded
263
  inputs=[
264
  source_url,
265
  pdf_file,
266
- manual_transcript,
267
  collection_name_ingest,
268
  ],
269
  outputs=[
 
37
  def _ingest(
38
  url: str,
39
  pdf_file: str | None,
 
40
  collection_name: str,
41
  ):
42
  logger.info(
 
56
  chunk_size=CHUNK_SIZE,
57
  chunk_overlap=CHUNK_OVERLAP,
58
  collection_name=collection_name,
 
59
  )
60
  document = result.document
61
  status = (
 
172
  gr.Markdown(
173
  f"""
174
  # {settings.PROJECT_NAME}
175
+ Turn papers, PDFs, and articles into a searchable vector memory.
176
 
177
  Extract text, chunk it cleanly, embed locally, and use NVIDIA chat for grounded answers.
178
  """,
 
185
  <div class="kh-chip">Parser <code>{settings.NEMOTRON_PARSE_MODEL}</code></div>
186
  <div class="kh-chip">Chat <code>{settings.NVIDIA_CHAT_MODEL}</code></div>
187
  <div class="kh-chip">Collection <code>{settings.QDRANT_COLLECTION_NAME}</code></div>
188
+ <div class="kh-chip">Sources PDF · arXiv · Medium</div>
189
  </div>
190
  """,
191
  )
 
198
  "### Source Intake\n<div class='kh-subhead'>Upload a PDF or paste one link. The pipeline handles extraction, chunking, local embeddings, and Qdrant upload.</div>"
199
  )
200
  source_url = gr.Textbox(
201
+ label="Medium or arXiv input",
202
+ placeholder="Paste a Medium article URL, arXiv URL, or arXiv ID",
203
  lines=2,
204
  )
205
  pdf_file = gr.File(
 
207
  file_types=[".pdf"],
208
  type="filepath",
209
  )
 
 
 
 
 
210
  collection_name_ingest = gr.Textbox(
211
  label="Collection Name",
212
  value=settings.QDRANT_COLLECTION_NAME,
 
256
  inputs=[
257
  source_url,
258
  pdf_file,
 
259
  collection_name_ingest,
260
  ],
261
  outputs=[
app/utils/source_detection.py CHANGED
@@ -6,7 +6,7 @@ from app.core.models import SourceType
6
 
7
 
8
  ARXIV_RE = re.compile(r"(?:arxiv\.org/(?:abs|pdf)/)?(?P<id>\d{4}\.\d{4,5})(?:v\d+)?", re.I)
9
- YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"}
10
 
11
 
12
  def detect_source(url: str | None, pdf_path: str | None) -> SourceType:
@@ -17,17 +17,17 @@ def detect_source(url: str | None, pdf_path: str | None) -> SourceType:
17
  raise ValueError("Uploaded file must be a PDF.")
18
 
19
  if not url or not url.strip():
20
- raise ValueError("Provide a YouTube link, arXiv link/ID, or upload a PDF.")
21
 
22
  clean_url = url.strip()
23
  parsed = urlparse(clean_url)
24
  host = parsed.netloc.lower()
25
 
26
- if host in YOUTUBE_HOSTS:
27
- return SourceType.YOUTUBE
28
  if "arxiv.org" in host or ARXIV_RE.search(clean_url):
29
  return SourceType.ARXIV
30
- raise ValueError("Could not detect source type. Use a YouTube URL, arXiv URL/ID, or PDF.")
 
 
31
 
32
 
33
  def extract_arxiv_id(value: str) -> str:
 
6
 
7
 
8
  ARXIV_RE = re.compile(r"(?:arxiv\.org/(?:abs|pdf)/)?(?P<id>\d{4}\.\d{4,5})(?:v\d+)?", re.I)
9
+ MEDIUM_HOST_PARTS = ("medium.com", "freedium")
10
 
11
 
12
  def detect_source(url: str | None, pdf_path: str | None) -> SourceType:
 
17
  raise ValueError("Uploaded file must be a PDF.")
18
 
19
  if not url or not url.strip():
20
+ raise ValueError("Provide a Medium article link, arXiv link/ID, or upload a PDF.")
21
 
22
  clean_url = url.strip()
23
  parsed = urlparse(clean_url)
24
  host = parsed.netloc.lower()
25
 
 
 
26
  if "arxiv.org" in host or ARXIV_RE.search(clean_url):
27
  return SourceType.ARXIV
28
+ if parsed.scheme in {"http", "https"}:
29
+ return SourceType.MEDIUM
30
+ raise ValueError("Could not detect source type. Use a Medium URL, arXiv URL/ID, or PDF.")
31
 
32
 
33
  def extract_arxiv_id(value: str) -> str:
pyproject.toml CHANGED
@@ -1,11 +1,12 @@
1
  [project]
2
  name = "knowledgehub-ingestor"
3
  version = "1.0.0"
4
- description = "A Gradio document ingestion UI for PDFs, arXiv papers, and YouTube transcripts."
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
8
  "arxiv>=2.1.3",
 
9
  "datasets>=5.0.0",
10
  "gradio>=5.0.0",
11
  "openai>=1.99.0",
@@ -17,7 +18,6 @@ dependencies = [
17
  "sentence-transformers>=3.0.1",
18
  "spaces",
19
  "torchvision>=0.27.0",
20
- "youtube-transcript-api>=0.6.2",
21
  ]
22
 
23
  [project.optional-dependencies]
 
1
  [project]
2
  name = "knowledgehub-ingestor"
3
  version = "1.0.0"
4
+ description = "A Gradio document ingestion UI for PDFs, arXiv papers, and Medium articles."
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
8
  "arxiv>=2.1.3",
9
+ "beautifulsoup4>=4.12.3",
10
  "datasets>=5.0.0",
11
  "gradio>=5.0.0",
12
  "openai>=1.99.0",
 
18
  "sentence-transformers>=3.0.1",
19
  "spaces",
20
  "torchvision>=0.27.0",
 
21
  ]
22
 
23
  [project.optional-dependencies]
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  arxiv>=2.1.3
 
2
  gradio>=5.0.0
3
  openai>=1.99.0
4
  pydantic-settings>=2.4.0
@@ -8,4 +9,3 @@ qdrant-client>=1.12.1
8
  requests>=2.32.3
9
  sentence-transformers>=3.0.1
10
  spaces
11
- youtube-transcript-api>=0.6.2
 
1
  arxiv>=2.1.3
2
+ beautifulsoup4>=4.12.3
3
  gradio>=5.0.0
4
  openai>=1.99.0
5
  pydantic-settings>=2.4.0
 
9
  requests>=2.32.3
10
  sentence-transformers>=3.0.1
11
  spaces
 
uv.lock DELETED
The diff for this file is too large to render. See raw diff