rahulrb99 commited on
Commit
e655301
·
1 Parent(s): 5652ad9

RAG chat, MiniLM embeddings, M2 fixes

Browse files
README_PYTHON.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python Version
2
+
3
+ **Gradio does not work reliably with Python 3.13.** Use Python 3.10, 3.11, or 3.12.
4
+
5
+ ## Quick run
6
+
7
+ ```powershell
8
+ .\run.bat
9
+ ```
10
+
11
+ Or manually:
12
+
13
+ ```powershell
14
+ py -3.10 -m pip install -r requirements.txt
15
+ py -3.10 app.py
16
+ ```
17
+
18
+ ## Install Python 3.10
19
+
20
+ If you don't have Python 3.10:
21
+
22
+ 1. Download from https://www.python.org/downloads/release/python-31011/
23
+ 2. Run installer, check "Add Python to PATH"
24
+ 3. Restart terminal, then run `.\run.bat`
app.py CHANGED
@@ -1,5 +1,16 @@
1
  from pathlib import Path
2
  import shutil
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  from dotenv import load_dotenv
5
 
@@ -7,14 +18,20 @@ from dotenv import load_dotenv
7
  load_dotenv(Path(__file__).resolve().parent.parent / ".env")
8
  load_dotenv(Path(__file__).resolve().parent / ".env")
9
 
 
10
  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, 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
@@ -35,43 +52,76 @@ def _patched_json_schema_to_python_type(schema, defs=None):
35
  gradio_client_utils.get_type = _patched_gradio_get_type
36
  gradio_client_utils._json_schema_to_python_type = _patched_json_schema_to_python_type
37
 
38
- # Theme: adapts to light/dark mode
39
  theme = gr.themes.Soft(
40
  primary_hue="blue",
41
  secondary_hue="slate",
42
- font=gr.themes.GoogleFont("Inter"),
43
  )
44
 
45
  CUSTOM_CSS = """
46
- .container { max-width: 720px; margin: 0 auto; padding: 0 24px; }
47
- .login-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 24px 0; }
48
- .login-center .login-btn-wrap { display: flex; justify-content: center; width: 100%; }
49
- .login-center .login-btn-wrap button { display: inline-flex; align-items: center; gap: 8px; }
50
- .hero { font-size: 1.5rem; font-weight: 600; color: #1e293b; margin-bottom: 8px; }
51
- .sub { font-size: 0.875rem; color: #64748b; margin-bottom: 24px; }
52
- .nb-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #e2e8f0; }
53
- .nb-row:last-child { border-bottom: none; }
54
- .gr-button { min-height: 36px !important; padding: 0 16px !important; font-weight: 500 !important; border-radius: 8px !important; }
55
- .gr-input { min-height: 40px !important; border-radius: 8px !important; }
56
- .status { font-size: 0.875rem; color: #64748b; margin-top: 16px; padding: 12px 16px; background: #f8fafc; border-radius: 8px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @media (prefers-color-scheme: dark) {
58
- .hero { color: #f1f5f9 !important; }
59
- .sub { color: #94a3b8 !important; }
60
- .nb-row { border-color: #334155 !important; }
61
- .status { color: #94a3b8 !important; background: #1e293b !important; }
 
 
 
62
  }
63
- .dark .hero { color: #f1f5f9 !important; }
64
- .dark .sub { color: #94a3b8 !important; }
65
- .dark .nb-row { border-color: #334155 !important; }
66
- .dark .status { color: #94a3b8 !important; background: #1e293b !important; }
 
 
 
67
  """
68
 
69
- MAX_NOTEBOOKS = 20
70
-
71
-
72
  def _user_id(profile: gr.OAuthProfile | None) -> str | None:
73
  """Extract user_id from HF OAuth profile. None if not logged in."""
74
- return profile.name if profile else None
 
 
 
 
 
 
 
 
75
 
76
 
77
  def _get_notebooks(user_id: str | None):
@@ -85,70 +135,59 @@ def _safe_create(new_name, state, selected_id, profile: gr.OAuthProfile | None):
85
  try:
86
  user_id = _user_id(profile)
87
  if not user_id:
88
- return gr.skip(), gr.skip(), gr.skip(), "Please sign in with Hugging Face", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
89
  name = (new_name or "").strip() or "Untitled Notebook"
90
  nb = create_notebook(user_id, name)
91
  if nb:
92
  notebooks = _get_notebooks(user_id)
93
- state = [(n["notebook_id"], n["name"]) for n in notebooks]
94
- updates = _build_row_updates(notebooks)
95
- new_selected = nb["notebook_id"]
96
  status = f"Created: {nb['name']}"
97
- return "", state, new_selected, status, *updates
98
- return gr.skip(), gr.skip(), gr.skip(), "Failed to create", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
99
  except Exception as e:
100
- return gr.skip(), gr.skip(), gr.skip(), f"Error: {e}", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
101
 
102
 
103
  def _safe_rename(idx, new_name, state, selected_id, profile: gr.OAuthProfile | None):
104
  """Rename notebook at index."""
105
  try:
106
  if idx is None or idx < 0 or idx >= len(state):
107
- return gr.skip(), gr.skip(), gr.skip(), *([gr.skip()] * (MAX_NOTEBOOKS * 2))
108
  nb_id, _ = state[idx]
109
  name = (new_name or "").strip()
110
  if not name:
111
- return gr.skip(), gr.skip(), gr.skip(), "Enter a name.", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
112
  user_id = _user_id(profile)
113
  if not user_id:
114
- return gr.skip(), gr.skip(), gr.skip(), "Please sign in", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
115
  ok = rename_notebook(user_id, nb_id, name)
116
  if ok:
117
  notebooks = _get_notebooks(user_id)
118
- state = [(n["notebook_id"], n["name"]) for n in notebooks]
119
- updates = _build_row_updates(notebooks)
120
- return state, selected_id, f"Renamed to: {name}", *updates
121
- return gr.skip(), gr.skip(), gr.skip(), "Failed to rename", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
122
  except Exception as e:
123
- return gr.skip(), gr.skip(), gr.skip(), f"Error: {e}", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
124
 
125
 
126
  def _safe_delete(idx, state, selected_id, profile: gr.OAuthProfile | None):
127
  """Delete notebook at index."""
128
  try:
129
  if idx is None or idx < 0 or idx >= len(state):
130
- return gr.skip(), gr.skip(), gr.skip(), *([gr.skip()] * (MAX_NOTEBOOKS * 2))
131
  nb_id, _ = state[idx]
132
  user_id = _user_id(profile)
133
  if not user_id:
134
- return gr.skip(), gr.skip(), gr.skip(), "Please sign in", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
135
  ok = delete_notebook(user_id, nb_id)
136
  if ok:
137
  notebooks = _get_notebooks(user_id)
138
- state = [(n["notebook_id"], n["name"]) for n in notebooks]
139
- updates = _build_row_updates(notebooks)
140
  new_selected = notebooks[0]["notebook_id"] if notebooks else None
141
- return state, new_selected, "Notebook deleted", *updates
142
- return gr.skip(), gr.skip(), gr.skip(), "Failed to delete", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
143
  except Exception as e:
144
- return gr.skip(), gr.skip(), gr.skip(), f"Error: {e}", *([gr.skip()] * (MAX_NOTEBOOKS * 2))
145
-
146
-
147
- def _select_notebook(idx, state):
148
- """Set selected notebook when user interacts with a row."""
149
- if idx is None or idx < 0 or idx >= len(state):
150
- return gr.skip()
151
- return state[idx][0]
152
 
153
 
154
  def _initial_load(profile: gr.OAuthProfile | None):
@@ -157,9 +196,10 @@ def _initial_load(profile: gr.OAuthProfile | None):
157
  notebooks = _get_notebooks(user_id)
158
  state = [(n["notebook_id"], n["name"]) for n in notebooks]
159
  selected = notebooks[0]["notebook_id"] if notebooks else None
160
- updates = _build_row_updates(notebooks)
161
  status = f"Signed in as {user_id}" if user_id else "Sign in with Hugging Face to manage notebooks."
162
- return state, selected, status, *updates
 
 
163
 
164
 
165
  def _safe_upload_pdfs(files, selected_id, profile: gr.OAuthProfile | None):
@@ -310,16 +350,6 @@ def _safe_remove_url(url, selected_id, profile: gr.OAuthProfile | None):
310
 
311
 
312
 
313
- def _build_row_updates(notebooks):
314
- """Return gr.update values for each row: visibility, then text value."""
315
- out = []
316
- for i in range(MAX_NOTEBOOKS):
317
- visible = i < len(notebooks)
318
- name = notebooks[i]["name"] if visible else ""
319
- out.append(gr.update(visible=visible))
320
- out.append(gr.update(value=name, visible=visible))
321
- return out
322
-
323
  # ── Upload Handler Functions ──────────────────────────────────
324
  def _do_upload(text_content, title, notebook_id, profile: gr.OAuthProfile | None):
325
  """Handle direct text input and ingestion."""
@@ -385,90 +415,218 @@ def _load_sources(notebook_id, profile: gr.OAuthProfile | None):
385
  sources = list_sources(notebook_id)
386
  return _format_sources(sources)
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  with gr.Blocks(
389
  title="NotebookLM Clone - Notebooks",
390
  theme=theme,
391
  css=CUSTOM_CSS,
392
  ) as demo:
393
- gr.HTML('<div class="container"><p class="hero">Notebook Manager</p><p class="sub">Create notebook below, then manage with Rename and Delete</p></div>')
394
-
395
- with gr.Row(elem_classes=["login-center"]):
396
- gr.Markdown("**Sign in with Hugging Face to access your notebooks**")
397
- with gr.Row(elem_classes=["login-btn-wrap"]):
398
- login_btn = gr.LoginButton(value="🤗 Login with Hugging Face", size="lg")
399
-
400
- nb_state = gr.State([])
401
- selected_notebook_id = gr.State(None)
402
-
403
- # Create section: text box + Create button
404
- with gr.Row():
405
- create_txt = gr.Textbox(
406
- label="Create notebook",
407
- placeholder="Enter new notebook name",
408
- value="",
409
- scale=3,
410
- )
411
- create_btn = gr.Button("Create", variant="primary", scale=1)
412
-
413
- with gr.Row():
414
- pdf_upload_btn = gr.UploadButton(
415
- "Upload PDFs",
416
- file_types=[".pdf"],
417
- file_count="multiple",
418
- type="filepath",
419
- variant="secondary",
420
- )
421
-
422
- with gr.Row():
423
- uploaded_pdf_dd = gr.Dropdown(
424
- label="Uploaded PDFs",
425
- choices=[],
426
- value=None,
427
- scale=3,
428
- allow_custom_value=False,
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
-
445
- # Rows: each notebook has [name] [Rename] [Delete]
446
- row_components = []
447
- row_outputs = []
448
- for i in range(MAX_NOTEBOOKS):
449
- with gr.Row(visible=False) as row:
450
- name_txt = gr.Textbox(
451
- value="",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  show_label=False,
453
- scale=3,
454
- min_width=200,
455
  )
456
- rename_btn = gr.Button("Rename", scale=1, min_width=80)
457
- delete_btn = gr.Button("Delete", variant="stop", scale=1, min_width=80)
458
- select_btn = gr.Button("Select", scale=1, min_width=70)
459
- row_components.append({"row": row, "name": name_txt, "rename": rename_btn, "delete": delete_btn, "select": select_btn})
460
- row_outputs.extend([row, name_txt])
461
 
462
- status = gr.Markdown("Sign in with Hugging Face to manage notebooks.", elem_classes=["status"])
463
-
464
- demo.load(_initial_load, inputs=None, outputs=[nb_state, selected_notebook_id, status] + row_outputs, api_name=False)
 
 
 
465
  demo.load(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd], api_name=False)
466
 
467
- # Create button
 
 
 
 
 
 
 
 
 
 
468
  create_btn.click(
469
  _safe_create,
470
  inputs=[create_txt, nb_state, selected_notebook_id],
471
- outputs=[create_txt, nb_state, selected_notebook_id, status] + row_outputs,
472
  api_name=False,
473
  ).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
474
 
@@ -500,57 +658,6 @@ with gr.Blocks(
500
  api_name=False,
501
  ).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
502
 
503
- # Per-row: Rename, Delete, Select (profile injected by Gradio for OAuth)
504
- for i in range(MAX_NOTEBOOKS):
505
- rename_btn = row_components[i]["rename"]
506
- delete_btn = row_components[i]["delete"]
507
- select_btn = row_components[i]["select"]
508
- name_txt = row_components[i]["name"]
509
-
510
- rename_btn.click(
511
- _safe_rename,
512
- inputs=[gr.State(i), name_txt, nb_state, selected_notebook_id],
513
- outputs=[nb_state, selected_notebook_id, status] + row_outputs,
514
- api_name=False,
515
- )
516
- delete_btn.click(
517
- _safe_delete,
518
- inputs=[gr.State(i), nb_state, selected_notebook_id],
519
- outputs=[nb_state, selected_notebook_id, status] + row_outputs,
520
- api_name=False,
521
- ).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
522
- def _on_select():
523
- return "Selected notebook updated. Use this for chat/ingestion."
524
- select_btn.click(
525
- _select_notebook,
526
- inputs=[gr.State(i), nb_state],
527
- outputs=[selected_notebook_id],
528
- api_name=False,
529
- ).then(_on_select, None, [status]).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
530
-
531
- # ── Text Input Section ────────────────────────────────────
532
- gr.Markdown("---")
533
- gr.Markdown("## Add Text")
534
- gr.Markdown("Select a notebook above, then paste or type your text.")
535
-
536
- with gr.Row():
537
- txt_title = gr.Textbox(
538
- label="Title",
539
- placeholder="Give this text a name (e.g. 'Lecture Notes Week 1')",
540
- scale=1,
541
- )
542
-
543
- txt_input = gr.Textbox(
544
- label="Text Content",
545
- placeholder="Paste or type your text here...",
546
- lines=10,
547
- )
548
-
549
- submit_btn = gr.Button("Save & Process", variant="primary")
550
-
551
- upload_status = gr.Markdown("", elem_classes=["status"])
552
- sources_display = gr.Markdown("")
553
-
554
  submit_btn.click(
555
  _do_upload,
556
  inputs=[txt_input, txt_title, selected_notebook_id],
@@ -563,4 +670,17 @@ with gr.Blocks(
563
  outputs=[sources_display],
564
  )
565
 
566
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pathlib import Path
2
  import shutil
3
+ import sys
4
+ import warnings
5
+
6
+ # Flush print immediately
7
+ def _log(msg):
8
+ print(msg, flush=True)
9
+
10
+ _log("1. Loading env...")
11
+ # Suppress noisy dependency warnings
12
+ warnings.filterwarnings("ignore", message=".*urllib3.*")
13
+ warnings.filterwarnings("ignore", message=".*chardet.*")
14
 
15
  from dotenv import load_dotenv
16
 
 
18
  load_dotenv(Path(__file__).resolve().parent.parent / ".env")
19
  load_dotenv(Path(__file__).resolve().parent / ".env")
20
 
21
+ _log("2. Loading Gradio...")
22
  from datetime import datetime
23
  import gradio as gr
24
+ _log("2a. Loading gradio_client...")
25
  import gradio_client.utils as gradio_client_utils
26
 
27
+ _log("3. Loading backend...")
28
  from backend.ingestion_service import ingest_pdf_chunks, ingest_url_chunks, remove_chunks_for_source
29
  from backend.notebook_service import create_notebook, list_notebooks, rename_notebook, delete_notebook
30
+ from backend.chat_service import load_chat
31
+ from backend.rag_service import rag_chat
32
 
33
  import hashlib
34
+ _log("4. Imports done.")
35
 
36
  _original_gradio_get_type = gradio_client_utils.get_type
37
  _original_json_schema_to_python_type = gradio_client_utils._json_schema_to_python_type
 
52
  gradio_client_utils.get_type = _patched_gradio_get_type
53
  gradio_client_utils._json_schema_to_python_type = _patched_json_schema_to_python_type
54
 
55
+ # Theme: adapts to light/dark mode (use default font to avoid network fetch on startup)
56
  theme = gr.themes.Soft(
57
  primary_hue="blue",
58
  secondary_hue="slate",
 
59
  )
60
 
61
  CUSTOM_CSS = """
62
+ .gradio-container { max-width: 1000px !important; margin: 0 auto !important; }
63
+ .container { max-width: 1000px; margin: 0 auto; padding: 0 24px; }
64
+
65
+ .header-bar { padding: 12px 0; border-bottom: 1px solid #e2e8f0; margin-bottom: 24px; display: flex !important; justify-content: space-between !important; align-items: center !important; white-space: nowrap; }
66
+ .login-center { display: flex; justify-content: center; width: 100%; }
67
+ #auth-text { white-space: nowrap; margin: 8px 0 16px 0; font-size: 0.95rem; opacity: 0.9; }
68
+ .gr-button { padding: 14px 28px !important; font-size: 0.9rem !important; border-radius: 12px !important; white-space: nowrap !important; width: auto !important; }
69
+ .gr-button[aria-label*="Logout"] { min-width: auto !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; }
70
+ .header-bar .gr-button { padding-left: 40px !important; padding-right: 40px !important; min-width: 220px !important; font-size: 0.8rem !important; }
71
+ .dark .header-bar { border-bottom: 1px solid #334155; }
72
+
73
+ .hero-section { margin-bottom: 16px; }
74
+ .login-container { padding: 12px 0; }
75
+ .create-strip { padding: 18px; border-radius: 16px; }
76
+ .create-row { display: flex !important; align-items: center !important; gap: 16px !important; }
77
+ .create-label { white-space: nowrap; font-size: 0.95rem; margin: 0; min-width: 180px; }
78
+ .create-row .gr-textbox { flex: 1 !important; }
79
+ .create-row .gr-textbox textarea,
80
+ .create-row .gr-textbox input { border-radius: 10px !important; }
81
+ .create-row .gr-button { border-radius: 10px !important; padding: 10px 20px !important; }
82
+ .hero-title { font-size: 2rem; font-weight: 700; color: #1e293b; margin: 0 0 8px 0; }
83
+ .hero-sub { font-size: 1rem; color: #64748b; margin: 0; line-height: 1.5; }
84
+
85
+ .section-card { padding: 24px; border-radius: 16px; background: #f8fafc; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
86
+ .notebook-card { padding: 14px 20px; border-radius: 12px; background: #fff; margin-bottom: 8px; border: 1px solid #e2e8f0; display: flex; align-items: center; gap: 12px; transition: background 0.15s ease; }
87
+ .notebook-card:hover { background: #f8fafc; }
88
+
89
+ .section-title { font-size: 1.125rem; font-weight: 600; color: #1e293b; margin: 0 0 16px 0; }
90
+ .section-row { display: flex !important; align-items: center !important; gap: 16px !important; margin-bottom: 12px; }
91
+ .section-row .gr-textbox { flex: 1 !important; }
92
+ .section-row .gr-button { border-radius: 10px !important; padding: 10px 20px !important; }
93
+
94
+ .status { font-size: 0.875rem; color: #64748b; margin-top: 16px; padding: 12px 16px; background: #f1f5f9; border-radius: 12px; }
95
+
96
  @media (prefers-color-scheme: dark) {
97
+ .hero-title { color: #f1f5f9 !important; }
98
+ .hero-sub { color: #94a3b8 !important; }
99
+ .section-card { background: #1e293b !important; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
100
+ .section-title { color: #f1f5f9 !important; }
101
+ .notebook-card { background: #334155 !important; border-color: #475569; }
102
+ .notebook-card:hover { background: #475569 !important; }
103
+ .status { color: #94a3b8 !important; background: #334155 !important; }
104
  }
105
+ .dark .hero-title { color: #f1f5f9 !important; }
106
+ .dark .hero-sub { color: #94a3b8 !important; }
107
+ .dark .section-card { background: #1e293b !important; }
108
+ .dark .section-title { color: #f1f5f9 !important; }
109
+ .dark .notebook-card { background: #334155 !important; border-color: #475569; }
110
+ .dark .notebook-card:hover { background: #475569 !important; }
111
+ .dark .status { color: #94a3b8 !important; background: #334155 !important; }
112
  """
113
 
 
 
 
114
  def _user_id(profile: gr.OAuthProfile | None) -> str | None:
115
  """Extract user_id from HF OAuth profile. None if not logged in."""
116
+ if not profile:
117
+ return None
118
+ return (
119
+ getattr(profile, "id", None)
120
+ or getattr(profile, "sub", None)
121
+ or getattr(profile, "preferred_username", None)
122
+ or getattr(profile, "username", None)
123
+ or getattr(profile, "name", None)
124
+ )
125
 
126
 
127
  def _get_notebooks(user_id: str | None):
 
135
  try:
136
  user_id = _user_id(profile)
137
  if not user_id:
138
+ return gr.skip(), gr.skip(), gr.skip(), "Please sign in with Hugging Face"
139
  name = (new_name or "").strip() or "Untitled Notebook"
140
  nb = create_notebook(user_id, name)
141
  if nb:
142
  notebooks = _get_notebooks(user_id)
143
+ new_state = [(n["notebook_id"], n["name"]) for n in notebooks]
 
 
144
  status = f"Created: {nb['name']}"
145
+ return "", new_state, nb["notebook_id"], status
146
+ return gr.skip(), gr.skip(), gr.skip(), "Failed to create"
147
  except Exception as e:
148
+ return gr.skip(), gr.skip(), gr.skip(), f"Error: {e}"
149
 
150
 
151
  def _safe_rename(idx, new_name, state, selected_id, profile: gr.OAuthProfile | None):
152
  """Rename notebook at index."""
153
  try:
154
  if idx is None or idx < 0 or idx >= len(state):
155
+ return gr.skip(), gr.skip(), "Invalid selection"
156
  nb_id, _ = state[idx]
157
  name = (new_name or "").strip()
158
  if not name:
159
+ return gr.skip(), gr.skip(), "Enter a name."
160
  user_id = _user_id(profile)
161
  if not user_id:
162
+ return gr.skip(), gr.skip(), "Please sign in"
163
  ok = rename_notebook(user_id, nb_id, name)
164
  if ok:
165
  notebooks = _get_notebooks(user_id)
166
+ new_state = [(n["notebook_id"], n["name"]) for n in notebooks]
167
+ return new_state, selected_id, f"Renamed to: {name}"
168
+ return gr.skip(), gr.skip(), "Failed to rename"
 
169
  except Exception as e:
170
+ return gr.skip(), gr.skip(), f"Error: {e}"
171
 
172
 
173
  def _safe_delete(idx, state, selected_id, profile: gr.OAuthProfile | None):
174
  """Delete notebook at index."""
175
  try:
176
  if idx is None or idx < 0 or idx >= len(state):
177
+ return gr.skip(), gr.skip(), "Invalid selection"
178
  nb_id, _ = state[idx]
179
  user_id = _user_id(profile)
180
  if not user_id:
181
+ return gr.skip(), gr.skip(), "Please sign in"
182
  ok = delete_notebook(user_id, nb_id)
183
  if ok:
184
  notebooks = _get_notebooks(user_id)
185
+ new_state = [(n["notebook_id"], n["name"]) for n in notebooks]
 
186
  new_selected = notebooks[0]["notebook_id"] if notebooks else None
187
+ return new_state, new_selected, "Notebook deleted"
188
+ return gr.skip(), gr.skip(), "Failed to delete"
189
  except Exception as e:
190
+ return gr.skip(), gr.skip(), f"Error: {e}"
 
 
 
 
 
 
 
191
 
192
 
193
  def _initial_load(profile: gr.OAuthProfile | None):
 
196
  notebooks = _get_notebooks(user_id)
197
  state = [(n["notebook_id"], n["name"]) for n in notebooks]
198
  selected = notebooks[0]["notebook_id"] if notebooks else None
 
199
  status = f"Signed in as {user_id}" if user_id else "Sign in with Hugging Face to manage notebooks."
200
+ auth_update = f"You are logged in as {getattr(profile, 'name', None) or user_id} ({_user_id(profile)})" if user_id else ""
201
+ auth_row_visible = bool(user_id)
202
+ return state, selected, status, auth_update, gr.update(visible=auth_row_visible), gr.update(visible=bool(user_id)), gr.update(visible=not bool(user_id))
203
 
204
 
205
  def _safe_upload_pdfs(files, selected_id, profile: gr.OAuthProfile | None):
 
350
 
351
 
352
 
 
 
 
 
 
 
 
 
 
 
353
  # ── Upload Handler Functions ──────────────────────────────────
354
  def _do_upload(text_content, title, notebook_id, profile: gr.OAuthProfile | None):
355
  """Handle direct text input and ingestion."""
 
415
  sources = list_sources(notebook_id)
416
  return _format_sources(sources)
417
 
418
+
419
+ def _chat_history_to_pairs(messages: list[dict]) -> list[tuple[str, str]]:
420
+ """Convert load_chat output to Gradio Chatbot format [(user, assistant), ...]."""
421
+ pairs = []
422
+ i = 0
423
+ while i < len(messages):
424
+ m = messages[i]
425
+ if m["role"] == "user":
426
+ user_content = m["content"] or ""
427
+ asst_content = ""
428
+ if i + 1 < len(messages) and messages[i + 1]["role"] == "assistant":
429
+ asst_content = messages[i + 1]["content"] or ""
430
+ i += 1
431
+ pairs.append((user_content, asst_content))
432
+ i += 1
433
+ return pairs
434
+
435
+
436
+ def _load_chat_history(notebook_id) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
437
+ """Load chat for notebook. Returns (history_pairs, history_pairs) for State and Chatbot."""
438
+ if not notebook_id:
439
+ return [], []
440
+ messages = load_chat(notebook_id)
441
+ pairs = _chat_history_to_pairs(messages)
442
+ return pairs, pairs
443
+
444
+
445
+ def _on_chat_submit(query, notebook_id, chat_history, profile: gr.OAuthProfile | None):
446
+ """Handle chat submit: call RAG, return updated history."""
447
+ if not notebook_id:
448
+ return "", chat_history, "Select a notebook first."
449
+ if not query or not query.strip():
450
+ return "", chat_history, "Enter a message."
451
+ user_id = _user_id(profile)
452
+ if not user_id:
453
+ return "", chat_history, "Please sign in first."
454
+ try:
455
+ answer, updated = rag_chat(notebook_id, query.strip(), chat_history)
456
+ return "", updated, ""
457
+ except Exception as e:
458
+ return "", chat_history, f"Error: {e}"
459
+
460
  with gr.Blocks(
461
  title="NotebookLM Clone - Notebooks",
462
  theme=theme,
463
  css=CUSTOM_CSS,
464
  ) as demo:
465
+ with gr.Row(elem_classes=["header-bar"]):
466
+ gr.Markdown("### 📓 NotebookLM Clone")
467
+ login_btn = gr.LoginButton(value="🤗 Login with Hugging Face", size="lg")
468
+
469
+ with gr.Row(visible=False) as auth_info_row:
470
+ auth_text = gr.Markdown("", elem_id="auth-text")
471
+
472
+ gr.HTML("""
473
+ <div class="container hero-section">
474
+ <h1 class="hero-title">📓 NotebookLM Clone</h1>
475
+ <p class="hero-sub">Chat with your documents. Generate reports, quizzes, and podcasts with citations.</p>
476
+ </div>
477
+ """)
478
+
479
+ with gr.Column(visible=False, elem_classes=["login-container"]) as login_container:
480
+ gr.Markdown("**Sign in with Hugging Face to access your notebooks.**", elem_classes=["login-center"])
481
+
482
+ with gr.Column(visible=False) as app_content:
483
+ nb_state = gr.State([])
484
+ selected_notebook_id = gr.State(None)
485
+
486
+ with gr.Group(elem_classes=["create-strip"]):
487
+ with gr.Row(elem_classes=["create-row"]):
488
+ gr.Markdown("Create new notebook", elem_classes=["create-label"])
489
+ create_txt = gr.Textbox(
490
+ placeholder="Enter new notebook name",
491
+ show_label=False,
492
+ container=False,
493
+ value="",
494
+ )
495
+ create_btn = gr.Button("Create", variant="primary", size="sm")
496
+
497
+ with gr.Group(elem_classes=["section-card"]):
498
+ gr.Markdown("**Sources**", elem_classes=["section-title"])
499
+ gr.Markdown("*Upload PDFs, ingest URLs, or add text to your selected notebook*")
500
+ with gr.Row(elem_classes=["section-row"]):
501
+ pdf_upload_btn = gr.UploadButton(
502
+ "Upload PDFs",
503
+ file_types=[".pdf"],
504
+ file_count="multiple",
505
+ type="filepath",
506
+ variant="secondary",
507
+ )
508
+ with gr.Row(elem_classes=["section-row"]):
509
+ uploaded_pdf_dd = gr.Dropdown(
510
+ label="Uploaded PDFs",
511
+ choices=[],
512
+ value=None,
513
+ scale=3,
514
+ allow_custom_value=False,
515
+ )
516
+ remove_pdf_btn = gr.Button("Remove selected PDF", variant="stop", scale=1)
517
+ with gr.Row(elem_classes=["section-row"]):
518
+ url_txt = gr.Textbox(
519
+ label="Ingest web URL",
520
+ placeholder="https://example.com",
521
+ value="",
522
+ scale=3,
523
+ )
524
+ ingest_url_btn = gr.Button("Ingest URL", variant="primary", scale=1)
525
+ remove_url_btn = gr.Button("Delete URL", variant="stop", scale=1)
526
+
527
+ gr.HTML("<br>")
528
+ gr.Markdown("**Your Notebooks**", elem_classes=["section-title"])
529
+ gr.Markdown("*Selected notebook is used for chat and ingestion*", elem_id="sub-hint")
530
+ gr.HTML("<br>")
531
+
532
+ status = gr.Markdown("Sign in with Hugging Face to manage notebooks.", elem_classes=["status"])
533
+
534
+ @gr.render(inputs=[nb_state])
535
+ def render_notebooks(state):
536
+ if not state:
537
+ gr.Markdown("No notebooks yet. Create one to get started.")
538
+ else:
539
+ for i, (nb_id, name) in enumerate(state):
540
+ idx = i
541
+ with gr.Row(elem_classes=["notebook-card"]):
542
+ name_txt = gr.Textbox(value=name, show_label=False, scale=4, min_width=240, key=f"nb-name-{nb_id}")
543
+ select_btn = gr.Button("Select", variant="primary", scale=1, min_width=80, size="sm")
544
+ rename_btn = gr.Button("Rename", variant="secondary", scale=1, min_width=80, size="sm")
545
+ delete_btn = gr.Button("Delete", variant="secondary", scale=1, min_width=80, size="sm")
546
+
547
+ def on_select(nb_id=nb_id):
548
+ return nb_id
549
+
550
+ def on_select_status():
551
+ return "Selected notebook updated. Use this for chat/ingestion."
552
+
553
+ select_btn.click(
554
+ on_select,
555
+ inputs=None,
556
+ outputs=[selected_notebook_id],
557
+ ).then(on_select_status, None, [status]).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
558
+
559
+ rename_btn.click(
560
+ _safe_rename,
561
+ inputs=[gr.State(idx), name_txt, nb_state, selected_notebook_id],
562
+ outputs=[nb_state, selected_notebook_id, status],
563
+ api_name=False,
564
+ )
565
+
566
+ delete_btn.click(
567
+ _safe_delete,
568
+ inputs=[gr.State(idx), nb_state, selected_notebook_id],
569
+ outputs=[nb_state, selected_notebook_id, status],
570
+ api_name=False,
571
+ ).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
572
+
573
+ gr.HTML("<br>")
574
+
575
+ with gr.Group(elem_classes=["section-card"]):
576
+ gr.Markdown("**Add Text**", elem_classes=["section-title"])
577
+ gr.Markdown("*Select a notebook above, then paste or type your text*")
578
+ with gr.Row():
579
+ txt_title = gr.Textbox(
580
+ label="Title",
581
+ placeholder="Give this text a name (e.g. 'Lecture Notes Week 1')",
582
+ scale=1,
583
+ )
584
+ txt_input = gr.Textbox(
585
+ label="Text Content",
586
+ placeholder="Paste or type your text here...",
587
+ lines=10,
588
+ )
589
+ submit_btn = gr.Button("Save & Process", variant="primary")
590
+ upload_status = gr.Markdown("", elem_classes=["status"])
591
+ sources_display = gr.Markdown("")
592
+
593
+ with gr.Group(elem_classes=["section-card"]):
594
+ gr.Markdown("**Chat**", elem_classes=["section-title"])
595
+ gr.Markdown("*Ask questions about your notebook sources. Answers are grounded in retrieved chunks with citations.*")
596
+ chat_history_state = gr.State([])
597
+ chatbot = gr.Chatbot(label="Chat history", height=400)
598
+ chat_input = gr.Textbox(
599
+ label="Message",
600
+ placeholder="Ask a question about your sources...",
601
  show_label=False,
602
+ lines=2,
 
603
  )
604
+ chat_submit_btn = gr.Button("Send", variant="primary")
605
+ chat_status = gr.Markdown("", elem_classes=["status"])
 
 
 
606
 
607
+ demo.load(
608
+ _initial_load,
609
+ inputs=None,
610
+ outputs=[nb_state, selected_notebook_id, status, auth_text, auth_info_row, app_content, login_container],
611
+ api_name=False,
612
+ )
613
  demo.load(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd], api_name=False)
614
 
615
+ def _on_notebook_select_for_chat(notebook_id):
616
+ hist, _ = _load_chat_history(notebook_id)
617
+ return hist, hist
618
+
619
+ selected_notebook_id.change(
620
+ _on_notebook_select_for_chat,
621
+ inputs=[selected_notebook_id],
622
+ outputs=[chat_history_state, chatbot],
623
+ api_name=False,
624
+ )
625
+
626
  create_btn.click(
627
  _safe_create,
628
  inputs=[create_txt, nb_state, selected_notebook_id],
629
+ outputs=[create_txt, nb_state, selected_notebook_id, status],
630
  api_name=False,
631
  ).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
632
 
 
658
  api_name=False,
659
  ).then(_list_uploaded_pdfs, inputs=[selected_notebook_id], outputs=[uploaded_pdf_dd])
660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  submit_btn.click(
662
  _do_upload,
663
  inputs=[txt_input, txt_title, selected_notebook_id],
 
670
  outputs=[sources_display],
671
  )
672
 
673
+ chat_submit_btn.click(
674
+ _on_chat_submit,
675
+ inputs=[chat_input, selected_notebook_id, chat_history_state],
676
+ outputs=[chat_input, chat_history_state, chat_status],
677
+ api_name=False,
678
+ ).then(
679
+ lambda h: (h, h),
680
+ inputs=[chat_history_state],
681
+ outputs=[chat_history_state, chatbot],
682
+ )
683
+
684
+ if __name__ == "__main__":
685
+ _log("5. Launching Gradio...")
686
+ demo.launch()
backend/embedding_service.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared embedding service - 384-dim vectors for RAG (ingestion + retrieval). Uses MiniLM for low memory."""
2
+
3
+ from sentence_transformers import SentenceTransformer
4
+
5
+ _MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
6
+ _model = None
7
+
8
+
9
+ def _get_model() -> SentenceTransformer:
10
+ """Lazy-load the embedding model."""
11
+ global _model
12
+ if _model is None:
13
+ _model = SentenceTransformer(_MODEL_NAME)
14
+ return _model
15
+
16
+
17
+ def encode(texts: list[str], task: str = "search_document") -> list[list[float]]:
18
+ """
19
+ Embed texts. Returns list of 384-dim vectors.
20
+
21
+ Args:
22
+ texts: List of strings to embed.
23
+ task: Unused (MiniLM doesn't need prefix); kept for API compatibility.
24
+ """
25
+ if not texts:
26
+ return []
27
+
28
+ model = _get_model()
29
+ embeddings = model.encode(texts, show_progress_bar=False)
30
+ return [e.tolist() for e in embeddings]
backend/ingestion_service.py CHANGED
@@ -1,10 +1,11 @@
1
- """PDF ingestion for RAG: extract text, chunk, and persist to chunks table."""
2
 
3
  from pathlib import Path
4
 
5
  from pypdf import PdfReader
6
 
7
  from backend.db import supabase
 
8
 
9
  import requests
10
  from bs4 import BeautifulSoup
@@ -39,7 +40,7 @@ def _chunk_text(text: str, chunk_size: int = DEFAULT_CHUNK_SIZE, overlap: int =
39
 
40
 
41
  def ingest_pdf_chunks(notebook_id: str, source_id: str, pdf_path: Path) -> int:
42
- """Extract and store chunks for a single PDF. Returns number of chunks inserted."""
43
  text = _extract_pdf_text(pdf_path)
44
  chunks = _chunk_text(text)
45
 
@@ -48,11 +49,14 @@ def ingest_pdf_chunks(notebook_id: str, source_id: str, pdf_path: Path) -> int:
48
  if not chunks:
49
  return 0
50
 
 
 
51
  rows = [
52
  {
53
  "notebook_id": notebook_id,
54
  "source_id": source_id,
55
  "content": chunk,
 
56
  "metadata": {
57
  "file_name": source_id,
58
  "file_path": str(pdf_path),
@@ -88,6 +92,7 @@ def _extract_url_text(url: str) -> str:
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
 
@@ -96,11 +101,14 @@ def ingest_url_chunks(notebook_id: str, source_id: str, url: str) -> int:
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,
 
1
+ """PDF ingestion for RAG: extract text, chunk, embed, and persist to chunks table."""
2
 
3
  from pathlib import Path
4
 
5
  from pypdf import PdfReader
6
 
7
  from backend.db import supabase
8
+ from backend.embedding_service import encode as embed_texts
9
 
10
  import requests
11
  from bs4 import BeautifulSoup
 
40
 
41
 
42
  def ingest_pdf_chunks(notebook_id: str, source_id: str, pdf_path: Path) -> int:
43
+ """Extract, embed, and store chunks for a single PDF. Returns number of chunks inserted."""
44
  text = _extract_pdf_text(pdf_path)
45
  chunks = _chunk_text(text)
46
 
 
49
  if not chunks:
50
  return 0
51
 
52
+ embeddings = embed_texts(chunks, task="search_document")
53
+
54
  rows = [
55
  {
56
  "notebook_id": notebook_id,
57
  "source_id": source_id,
58
  "content": chunk,
59
+ "embedding": embeddings[index],
60
  "metadata": {
61
  "file_name": source_id,
62
  "file_path": str(pdf_path),
 
92
  return " ".join(text.split()).strip()
93
 
94
  def ingest_url_chunks(notebook_id: str, source_id: str, url: str) -> int:
95
+ """Extract, embed, and store chunks for a URL. Returns number of chunks inserted."""
96
  text = _extract_url_text(url)
97
  chunks = _chunk_text(text)
98
 
 
101
  if not chunks:
102
  return 0
103
 
104
+ embeddings = embed_texts(chunks, task="search_document")
105
+
106
  rows = [
107
  {
108
  "notebook_id": notebook_id,
109
  "source_id": source_id,
110
  "content": chunk,
111
+ "embedding": embeddings[index],
112
  "metadata": {
113
  "url": url,
114
  "chunk_index": index,
backend/ingestion_txt.py CHANGED
@@ -10,12 +10,7 @@ from uuid import uuid4
10
 
11
  from backend.db import supabase
12
  from backend.storage import save_file, get_sources_path
13
-
14
- import os
15
- from sentence_transformers import SentenceTransformer
16
-
17
- # Load model once at module level (not on every call)
18
- _model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
19
  # ── Constants ────────────────────────────────────────────────
20
 
21
  MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
@@ -116,16 +111,14 @@ def chunk_text(text: str, source_id: str, notebook_id: str, filename: str = "")
116
  # ── Embed + Store ─────────────────────────────────────────────
117
  def embed_and_store_chunks(chunks: list[dict]) -> None:
118
  """
119
- Embed chunks using sentence-transformers and store in pgvector.
120
  """
121
  if not chunks:
122
  return
123
 
124
- # Embed all chunks in one batch
125
  texts = [c["content"] for c in chunks]
126
- embeddings = _model.encode(texts, show_progress_bar=False)
127
 
128
- # Build rows for Supabase insert
129
  rows = []
130
  for chunk, embedding in zip(chunks, embeddings):
131
  rows.append({
@@ -133,7 +126,7 @@ def embed_and_store_chunks(chunks: list[dict]) -> None:
133
  "source_id": str(chunk["source_id"]),
134
  "notebook_id": str(chunk["notebook_id"]),
135
  "content": chunk["content"],
136
- "embedding": embedding.tolist(),
137
  "metadata": chunk["metadata"]
138
  })
139
 
 
10
 
11
  from backend.db import supabase
12
  from backend.storage import save_file, get_sources_path
13
+ from backend.embedding_service import encode as embed_texts
 
 
 
 
 
14
  # ── Constants ────────────────────────────────────────────────
15
 
16
  MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
 
111
  # ── Embed + Store ─────────────────────────────────────────────
112
  def embed_and_store_chunks(chunks: list[dict]) -> None:
113
  """
114
+ Embed chunks using shared 1536-dim model and store in pgvector.
115
  """
116
  if not chunks:
117
  return
118
 
 
119
  texts = [c["content"] for c in chunks]
120
+ embeddings = embed_texts(texts, task="search_document")
121
 
 
122
  rows = []
123
  for chunk, embedding in zip(chunks, embeddings):
124
  rows.append({
 
126
  "source_id": str(chunk["source_id"]),
127
  "notebook_id": str(chunk["notebook_id"]),
128
  "content": chunk["content"],
129
+ "embedding": embedding,
130
  "metadata": chunk["metadata"]
131
  })
132
 
backend/rag_service.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG chat service - retrieve chunks, call LLM, persist messages."""
2
+
3
+ import os
4
+ import re
5
+
6
+ from huggingface_hub import InferenceClient
7
+
8
+ from backend.chat_service import save_message, load_chat
9
+ from backend.retrieval_service import retrieve_chunks
10
+
11
+ MAX_HISTORY_MESSAGES = 20
12
+ DEFAULT_MODEL = "mistralai/Mistral-7B-Instruct-v0.2"
13
+ TOP_K = 5
14
+
15
+ _client: InferenceClient | None = None
16
+
17
+
18
+ def _get_client() -> InferenceClient:
19
+ global _client
20
+ if _client is None:
21
+ token = os.getenv("HF_TOKEN")
22
+ _client = InferenceClient(token=token)
23
+ return _client
24
+
25
+
26
+ def _validate_citations(text: str, num_chunks: int) -> str:
27
+ """Strip or fix citation numbers [N] where N > num_chunks."""
28
+ if num_chunks <= 0:
29
+ return text
30
+
31
+ def replace_citation(match):
32
+ n = int(match.group(1))
33
+ if 1 <= n <= num_chunks:
34
+ return match.group(0)
35
+ return ""
36
+
37
+ return re.sub(r"\[(\d+)\]", replace_citation, text)
38
+
39
+
40
+ def rag_chat(notebook_id: str, query: str, chat_history: list) -> tuple[str, list]:
41
+ """
42
+ RAG chat: retrieve chunks, build prompt, call LLM, persist, return answer and updated history.
43
+
44
+ chat_history: list of [user_msg, assistant_msg] pairs (Gradio Chatbot format).
45
+ Returns: (assistant_reply, updated_history).
46
+ """
47
+ save_message(notebook_id, "user", query)
48
+
49
+ chunks = retrieve_chunks(notebook_id, query, top_k=TOP_K)
50
+
51
+ context_parts = []
52
+ for i, c in enumerate(chunks, 1):
53
+ context_parts.append(f"[{i}] {c['content']}")
54
+ context = "\n\n".join(context_parts) if context_parts else "(No relevant sources found.)"
55
+
56
+ system_content = (
57
+ "You are a helpful assistant. Answer ONLY from the provided context. "
58
+ "Cite sources using [1], [2], etc. corresponding to the numbered passages. "
59
+ "If the answer is not in the context, say so clearly.\n\n"
60
+ f"Context:\n{context}"
61
+ )
62
+
63
+ # Truncate history to last MAX_HISTORY_MESSAGES (pairs -> 2*N messages)
64
+ max_pairs = MAX_HISTORY_MESSAGES // 2
65
+ truncated = chat_history[-max_pairs:] if len(chat_history) > max_pairs else chat_history
66
+
67
+ messages = [{"role": "system", "content": system_content}]
68
+ for user_msg, asst_msg in truncated:
69
+ if user_msg:
70
+ messages.append({"role": "user", "content": user_msg})
71
+ if asst_msg:
72
+ messages.append({"role": "assistant", "content": asst_msg})
73
+ messages.append({"role": "user", "content": query})
74
+
75
+ try:
76
+ client = _get_client()
77
+ response = client.chat_completion(
78
+ messages=messages,
79
+ model=DEFAULT_MODEL,
80
+ max_tokens=512,
81
+ )
82
+ raw_answer = response.choices[0].message.content
83
+ answer = _validate_citations(raw_answer, len(chunks))
84
+ except Exception as e:
85
+ answer = f"Error calling model: {e}"
86
+
87
+ save_message(notebook_id, "assistant", answer)
88
+
89
+ updated_history = chat_history + [[query, answer]]
90
+ return answer, updated_history
backend/retrieval_service.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retrieval service - vector similarity search for RAG."""
2
+
3
+ from backend.db import supabase
4
+ from backend.embedding_service import encode
5
+
6
+
7
+ def retrieve_chunks(notebook_id: str, query: str, top_k: int = 5) -> list[dict]:
8
+ """
9
+ Retrieve top-k chunks for a query, filtered by notebook_id.
10
+
11
+ Returns list of dicts with keys: id, content, metadata, similarity.
12
+ """
13
+ if not query or not query.strip():
14
+ return []
15
+
16
+ query_embedding = encode([query.strip()], task="search_query")[0]
17
+
18
+ try:
19
+ result = supabase.rpc(
20
+ "match_chunks",
21
+ {
22
+ "query_embedding": query_embedding,
23
+ "match_count": top_k,
24
+ "p_notebook_id": notebook_id,
25
+ },
26
+ ).execute()
27
+
28
+ rows = result.data or []
29
+ return [
30
+ {
31
+ "id": str(r["id"]),
32
+ "content": r["content"],
33
+ "metadata": r.get("metadata") or {},
34
+ "similarity": float(r.get("similarity", 0)),
35
+ }
36
+ for r in rows
37
+ ]
38
+ except Exception:
39
+ return []
db/migrate_to_384.sql ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- Migration: Switch from 1536-dim to 384-dim embeddings (MiniLM)
2
+ -- Run this in Supabase SQL Editor if you already have the chunks table with vector(1536)
3
+
4
+ -- 1. Drop the ivfflat index (required before altering column)
5
+ drop index if exists idx_chunks_embedding;
6
+
7
+ -- 2. Clear existing chunks (old 1536-dim embeddings are incompatible)
8
+ truncate table chunks;
9
+
10
+ -- 3. Replace embedding column with 384-dim version
11
+ alter table chunks drop column embedding;
12
+ alter table chunks add column embedding vector(384);
13
+
14
+ -- 4. Recreate the ivfflat index (run AFTER ingesting new PDF/TXT - requires rows)
15
+ -- create index if not exists idx_chunks_embedding on chunks using ivfflat (embedding vector_cosine_ops) with (lists = 100);
16
+
17
+ -- 5. Update match_chunks RPC
18
+ create or replace function match_chunks(
19
+ query_embedding vector(384),
20
+ match_count int,
21
+ p_notebook_id uuid
22
+ )
23
+ returns table (id uuid, content text, metadata jsonb, similarity float)
24
+ language plpgsql as $$
25
+ begin
26
+ return query
27
+ select c.id, c.content, c.metadata,
28
+ 1 - (c.embedding <=> query_embedding) as similarity
29
+ from chunks c
30
+ where c.notebook_id = p_notebook_id
31
+ and c.embedding is not null
32
+ order by c.embedding <=> query_embedding
33
+ limit match_count;
34
+ end;
35
+ $$;
db/schema.sql CHANGED
@@ -33,19 +33,40 @@ create index if not exists idx_artifacts_notebook_id on artifacts(notebook_id);
33
  -- pgvector extension for embeddings
34
  create extension if not exists vector;
35
 
36
- -- chunks with embeddings (for RAG)
37
  create table if not exists chunks (
38
  id uuid primary key default gen_random_uuid(),
39
  notebook_id uuid not null references notebooks(id) on delete cascade,
40
  source_id text,
41
  content text not null,
42
- embedding vector(1536),
43
  metadata jsonb,
44
  created_at timestamptz default now()
45
  );
46
  create index if not exists idx_chunks_notebook_id on chunks(notebook_id);
47
- -- Vector index (run after you have data; ivfflat requires rows):
48
- -- create index idx_chunks_embedding on chunks using ivfflat (embedding vector_cosine_ops) with (lists = 100);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  -- sources table (ingestion pipeline)
51
  create table if not exists sources (
 
33
  -- pgvector extension for embeddings
34
  create extension if not exists vector;
35
 
36
+ -- chunks with embeddings (for RAG) - 384 dims for MiniLM
37
  create table if not exists chunks (
38
  id uuid primary key default gen_random_uuid(),
39
  notebook_id uuid not null references notebooks(id) on delete cascade,
40
  source_id text,
41
  content text not null,
42
+ embedding vector(384),
43
  metadata jsonb,
44
  created_at timestamptz default now()
45
  );
46
  create index if not exists idx_chunks_notebook_id on chunks(notebook_id);
47
+
48
+ -- Vector index for fast similarity search (run after chunks have data; ivfflat requires rows)
49
+ create index if not exists idx_chunks_embedding on chunks using ivfflat (embedding vector_cosine_ops) with (lists = 100);
50
+
51
+ -- RPC for RAG retrieval: top-k chunks by cosine similarity, filtered by notebook_id
52
+ create or replace function match_chunks(
53
+ query_embedding vector(384),
54
+ match_count int,
55
+ p_notebook_id uuid
56
+ )
57
+ returns table (id uuid, content text, metadata jsonb, similarity float)
58
+ language plpgsql as $$
59
+ begin
60
+ return query
61
+ select c.id, c.content, c.metadata,
62
+ 1 - (c.embedding <=> query_embedding) as similarity
63
+ from chunks c
64
+ where c.notebook_id = p_notebook_id
65
+ and c.embedding is not null
66
+ order by c.embedding <=> query_embedding
67
+ limit match_count;
68
+ end;
69
+ $$;
70
 
71
  -- sources table (ingestion pipeline)
72
  create table if not exists sources (
requirements.txt CHANGED
@@ -5,5 +5,6 @@ python-dotenv>=1.0.0
5
  realtime==2.3.0
6
  chardet>=5.0.0
7
  sentence-transformers>=2.0.0
 
8
  pypdf>=4.2.0
9
  beautifulsoup4>=4.12.3
 
5
  realtime==2.3.0
6
  chardet>=5.0.0
7
  sentence-transformers>=2.0.0
8
+ einops>=0.7.0
9
  pypdf>=4.2.0
10
  beautifulsoup4>=4.12.3
run.bat ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ REM Gradio has issues with Python 3.13 - use 3.10, 3.11, or 3.12
3
+ echo Checking for Python 3.10/3.11/3.12...
4
+ py -3.10 --version 2>nul && goto run310
5
+ py -3.11 --version 2>nul && goto run311
6
+ py -3.12 --version 2>nul && goto run312
7
+ echo.
8
+ echo Python 3.10, 3.11, or 3.12 not found.
9
+ echo Gradio does NOT work with Python 3.13.
10
+ echo Install Python 3.10 from https://www.python.org/downloads/
11
+ pause
12
+ exit /b 1
13
+
14
+ :run310
15
+ echo Using Python 3.10
16
+ py -3.10 -m pip install -r requirements.txt -q
17
+ py -3.10 app.py
18
+ goto end
19
+
20
+ :run311
21
+ echo Using Python 3.11
22
+ py -3.11 -m pip install -r requirements.txt -q
23
+ py -3.11 app.py
24
+ goto end
25
+
26
+ :run312
27
+ echo Using Python 3.12
28
+ py -3.12 -m pip install -r requirements.txt -q
29
+ py -3.12 app.py
30
+ goto end
31
+
32
+ :end
33
+ pause