Thanmay Mohandas Das commited on
Commit
be07981
·
unverified ·
2 Parent(s): b5ccb882bc783a

Merge pull request #5 from tAnboyy/feature/url_ingestion

Browse files
Files changed (2) hide show
  1. app.py +82 -1
  2. backend/ingestion_service.py +49 -0
app.py CHANGED
@@ -11,9 +11,11 @@ from datetime import datetime
11
  import gradio as gr
12
  import gradio_client.utils as gradio_client_utils
13
 
14
- from backend.ingestion_service import ingest_pdf_chunks, remove_chunks_for_source
15
  from backend.notebook_service import create_notebook, list_notebooks, rename_notebook, delete_notebook
16
 
 
 
17
  _original_gradio_get_type = gradio_client_utils.get_type
18
  _original_json_schema_to_python_type = gradio_client_utils._json_schema_to_python_type
19
 
@@ -251,6 +253,61 @@ def _safe_remove_pdf(file_name, selected_id, profile: gr.OAuthProfile | None):
251
  return f"Removed PDF: {safe_name}"
252
  except Exception as error:
253
  return f"Error removing PDF: {error}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
 
256
  def _build_row_updates(notebooks):
@@ -372,6 +429,16 @@ with gr.Blocks(
372
  )
373
  remove_pdf_btn = gr.Button("Remove selected PDF", variant="stop", scale=1)
374
 
 
 
 
 
 
 
 
 
 
 
375
  gr.Markdown("---")
376
  gr.Markdown("**Your notebooks** (selected notebook used for chat/ingestion)")
377
 
@@ -412,6 +479,20 @@ with gr.Blocks(
412
  api_name=False,
413
  ).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  remove_pdf_btn.click(
416
  _safe_remove_pdf,
417
  inputs=[uploaded_pdf_dd, selected_notebook_id],
 
11
  import gradio as gr
12
  import gradio_client.utils as gradio_client_utils
13
 
14
+ from backend.ingestion_service import ingest_pdf_chunks, ingest_url_chunks, remove_chunks_for_source
15
  from backend.notebook_service import create_notebook, list_notebooks, rename_notebook, delete_notebook
16
 
17
+ import hashlib
18
+
19
  _original_gradio_get_type = gradio_client_utils.get_type
20
  _original_json_schema_to_python_type = gradio_client_utils._json_schema_to_python_type
21
 
 
253
  return f"Removed PDF: {safe_name}"
254
  except Exception as error:
255
  return f"Error removing PDF: {error}"
256
+
257
+ def _url_source_id(url: str) -> str:
258
+ """Stable source_id so re-ingesting the same URL overwrites old chunks."""
259
+ h = hashlib.sha256(url.encode("utf-8", errors="ignore")).hexdigest()[:16]
260
+ return f"url_{h}"
261
+
262
+
263
+ def _safe_ingest_url(url, selected_id, profile: gr.OAuthProfile | None):
264
+ """Ingest one URL into chunks table for the selected notebook."""
265
+ try:
266
+ user_id = _user_id(profile)
267
+ if not user_id:
268
+ return "", "Please sign in with Hugging Face before ingesting a URL."
269
+ if not selected_id:
270
+ return "", "Select a notebook first, then ingest a URL."
271
+
272
+ cleaned = (url or "").strip()
273
+ if not cleaned:
274
+ return "", "Enter a URL."
275
+ if not (cleaned.startswith("http://") or cleaned.startswith("https://")):
276
+ return "", "URL must start with http:// or https://"
277
+
278
+ source_id = _url_source_id(cleaned)
279
+ chunk_count = ingest_url_chunks(str(selected_id), source_id, cleaned)
280
+
281
+ if chunk_count == 0:
282
+ return "", (
283
+ "Ingested URL but extracted 0 chunks. Page may be JS-rendered/blocked/non-text. "
284
+ "Try a simpler static page (example.com / Wikipedia)."
285
+ )
286
+
287
+ return "", f"Ingested URL. Indexed {chunk_count} chunk(s). Source: {cleaned}"
288
+ except Exception as error:
289
+ return "", f"Error ingesting URL: {error}"
290
+
291
+ def _safe_remove_url(url, selected_id, profile: gr.OAuthProfile | None):
292
+ try:
293
+ user_id = _user_id(profile)
294
+ if not user_id:
295
+ return "", "Please sign in with Hugging Face before ingesting a URL."
296
+ if not selected_id:
297
+ return "", "Select a notebook first, then remove a URL."
298
+
299
+ cleaned = (url or "").strip()
300
+ if not cleaned:
301
+ return "", "Enter a URL."
302
+ if not (cleaned.startswith("http://") or cleaned.startswith("https://")):
303
+ return "", "URL must start with http:// or https://"
304
+
305
+ source_id = _url_source_id(cleaned)
306
+ remove_chunks_for_source(str(selected_id), source_id)
307
+ return "", f"Removed URL: {cleaned}"
308
+ except Exception as error:
309
+ return "", f"Error removing URL: {error}"
310
+
311
 
312
 
313
  def _build_row_updates(notebooks):
 
429
  )
430
  remove_pdf_btn = gr.Button("Remove selected PDF", variant="stop", scale=1)
431
 
432
+ with gr.Row():
433
+ url_txt = gr.Textbox(
434
+ label="Ingest web URL",
435
+ placeholder="https://example.com",
436
+ value="",
437
+ scale=3,
438
+ )
439
+ ingest_url_btn = gr.Button("Ingest URL", variant="primary", scale=1)
440
+ remove_url_btn = gr.Button("Delete URL", variant="stop", scale=1)
441
+
442
  gr.Markdown("---")
443
  gr.Markdown("**Your notebooks** (selected notebook used for chat/ingestion)")
444
 
 
479
  api_name=False,
480
  ).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
481
 
482
+ ingest_url_btn.click(
483
+ _safe_ingest_url,
484
+ inputs=[url_txt, selected_notebook_id],
485
+ outputs=[url_txt, status],
486
+ api_name=False,
487
+ )
488
+
489
+ remove_url_btn.click(
490
+ _safe_remove_url,
491
+ inputs=[url_txt, selected_notebook_id],
492
+ outputs=[url_txt, status],
493
+ api_name=False
494
+ )
495
+
496
  remove_pdf_btn.click(
497
  _safe_remove_pdf,
498
  inputs=[uploaded_pdf_dd, selected_notebook_id],
backend/ingestion_service.py CHANGED
@@ -6,6 +6,9 @@ from pypdf import PdfReader
6
 
7
  from backend.db import supabase
8
 
 
 
 
9
  DEFAULT_CHUNK_SIZE = 1200
10
  DEFAULT_CHUNK_OVERLAP = 200
11
 
@@ -66,6 +69,52 @@ def ingest_pdf_chunks(notebook_id: str, source_id: str, pdf_path: Path) -> int:
66
 
67
  return len(rows)
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  def remove_chunks_for_source(notebook_id: str, source_id: str) -> None:
71
  """Delete all chunks tied to one source file for a notebook."""
 
6
 
7
  from backend.db import supabase
8
 
9
+ import requests
10
+ from bs4 import BeautifulSoup
11
+
12
  DEFAULT_CHUNK_SIZE = 1200
13
  DEFAULT_CHUNK_OVERLAP = 200
14
 
 
69
 
70
  return len(rows)
71
 
72
+ def _extract_url_text(url: str) -> str:
73
+ headers = {
74
+ "User-Agent": "NotebookLM-Clone/1.0 (course project; contact: mgohn@charlotte.edu)",
75
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
76
+ "Accept-Language": "en-US,en;q=0.9",
77
+ }
78
+
79
+ response = requests.get(url, timeout=15, headers=headers)
80
+ response.raise_for_status()
81
+
82
+ soup = BeautifulSoup(response.text, "html.parser")
83
+
84
+ for tag in soup(["script", "style", "nav", "footer", "header"]):
85
+ tag.decompose()
86
+
87
+ text = soup.get_text(separator=" ")
88
+ return " ".join(text.split()).strip()
89
+
90
+ def ingest_url_chunks(notebook_id: str, source_id: str, url: str) -> int:
91
+ text = _extract_url_text(url)
92
+ chunks = _chunk_text(text)
93
+
94
+ supabase.table("chunks").delete().eq("notebook_id", notebook_id).eq("source_id", source_id).execute()
95
+
96
+ if not chunks:
97
+ return 0
98
+
99
+ rows = [
100
+ {
101
+ "notebook_id": notebook_id,
102
+ "source_id": source_id,
103
+ "content": chunk,
104
+ "metadata": {
105
+ "url": url,
106
+ "chunk_index": index,
107
+ "total_chunks": len(chunks),
108
+ },
109
+ }
110
+ for index, chunk in enumerate(chunks)
111
+ ]
112
+
113
+ batch_size = 100
114
+ for offset in range(0, len(rows), batch_size):
115
+ supabase.table("chunks").insert(rows[offset:offset + batch_size]).execute()
116
+
117
+ return len(rows)
118
 
119
  def remove_chunks_for_source(notebook_id: str, source_id: str) -> None:
120
  """Delete all chunks tied to one source file for a notebook."""