Replace YouTube ingestion with Medium extraction
Browse files- README.md +10 -13
- app/core/models.py +1 -1
- app/extractors/medium.py +126 -0
- app/extractors/youtube.py +0 -58
- app/services/ingestion.py +4 -14
- app/ui/gradio_app.py +4 -12
- app/utils/source_detection.py +5 -5
- pyproject.toml +2 -2
- requirements.txt +1 -1
- uv.lock +0 -0
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 |
-
-
|
| 18 |
- arXiv links or IDs
|
| 19 |
- PDF documents
|
| 20 |
|
| 21 |
-
It extracts text, chunks
|
| 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 |
-
##
|
| 96 |
|
| 97 |
-
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 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 |
-
|
| 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
|
| 145 |
services/ chunking, embeddings, Qdrant, retrieval, ingestion orchestration
|
| 146 |
ui/ Gradio Blocks UI
|
| 147 |
utils/ source detection helpers
|
| 148 |
```
|
| 149 |
|
| 150 |
-
|
|
|
|
| 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 |
-
|
| 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.
|
| 29 |
-
|
| 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
|
| 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
|
| 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 ·
|
| 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="
|
| 204 |
-
placeholder="Paste a
|
| 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 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|