Carson commited on
Commit
11b2e61
Β·
1 Parent(s): e89d891

improved UI/UX

Browse files
Files changed (2) hide show
  1. app.py +269 -245
  2. utils.py +8 -1
app.py CHANGED
@@ -1,6 +1,7 @@
1
  """
2
- RAG Chat Interface for Hugging Face Space β€” Gradio UI layer.
3
- All pure logic and backend HTTP lives in utils.py for testability.
 
4
  """
5
  import gradio as gr
6
  import os
@@ -10,14 +11,12 @@ import requests
10
  from typing import Generator, Tuple
11
 
12
  from utils import (
13
- BACKEND_URLS,
14
  MAX_FILE_SIZE,
15
  load_sample_data,
16
  get_backend_url,
17
  format_file_size,
18
  extract_text_from_file,
19
  resolve_file_path,
20
- check_health,
21
  fetch_indexed_docs,
22
  upload_text_to_backend,
23
  upload_pasted_text,
@@ -27,7 +26,7 @@ from utils import (
27
  )
28
 
29
 
30
- # ---------- Sample Data ----------
31
  _SAMPLE_DATA = load_sample_data()
32
  SAMPLE_LABELS: dict = _SAMPLE_DATA["labels"]
33
  SAMPLE_DOCUMENTS: dict = {int(k): v for k, v in _SAMPLE_DATA["documents"].items()}
@@ -36,31 +35,32 @@ SAMPLE_QUESTIONS: list = _SAMPLE_DATA["sample_questions"]
36
 
37
  # ---------- Sample-specific helpers ----------
38
 
39
- def load_selected_samples(selected_labels: list) -> Tuple[bool, str, int, str]:
40
- if not selected_labels:
41
- return False, None, None, "⚠️ Please select at least one report."
42
- years = sorted([SAMPLE_LABELS[lbl] for lbl in selected_labels])
43
- parts = []
44
- for year in years:
45
- separator = "=" * 60
46
- parts.append(f"{separator}\nANNUAL REPORT {year}\n{separator}\n{SAMPLE_DOCUMENTS[year]}")
47
- combined = "\n\n".join(parts)
48
- if len(years) == 1:
49
- filename = f"annual_report_{years[0]}.txt"
50
- else:
51
- filename = f"annual_reports_{'_'.join(str(y) for y in years)}.txt"
52
- return upload_text_to_backend(combined, filename)
53
-
54
-
55
- def build_preview_text(selected_labels: list) -> str:
56
- if not selected_labels:
57
- return ""
58
- years = sorted([SAMPLE_LABELS[lbl] for lbl in selected_labels])
59
- parts = []
60
- for year in years:
61
- separator = "=" * 60
62
- parts.append(f"{separator}\nANNUAL REPORT {year}\n{separator}\n{SAMPLE_DOCUMENTS[year]}")
63
- return "\n\n".join(parts)
 
64
 
65
 
66
  def warmup_with_status(start_time: float = None) -> Generator[Tuple[str, bool, str, float], None, None]:
@@ -81,10 +81,10 @@ def warmup_with_status(start_time: float = None) -> Generator[Tuple[str, bool, s
81
  threading.Thread(target=do_request, daemon=True).start()
82
 
83
  STEPS = [
84
- (0, "⏳ Initializing GPU model (Qwen 2.5 3B)..."),
85
- (3, "⏳ Initializing GPU model (Qwen 2.5 3B)...\nβœ… Embedding model ready (BGE-small)"),
86
- (6, "⏳ Initializing GPU model (Qwen 2.5 3B)...\nβœ… Embedding model ready (BGE-small)\n⏳ Loading index from volume..."),
87
- (9, "βœ… GPU model loaded (Qwen 2.5 3B)\nβœ… Embedding model ready (BGE-small)\n⏳ Loading index from volume..."),
88
  ]
89
 
90
  while not result["done"]:
@@ -93,50 +93,105 @@ def warmup_with_status(start_time: float = None) -> Generator[Tuple[str, bool, s
93
  for threshold, msg in STEPS:
94
  if elapsed >= threshold:
95
  step_msg = msg
96
- yield f"{step_msg}\n\n*({elapsed:.1f}s elapsed)*", False, f"⏳ Warming up... ({elapsed:.1f}s)", start_time
97
  time.sleep(1)
98
 
99
  elapsed = time.time() - start_time
100
  if result["success"]:
101
- final = (
102
- "βœ… GPU model loaded (Qwen 2.5 3B)\n"
103
- "βœ… Embedding model ready (BGE-small)\n"
104
- "βœ… Index volume ready\n\n"
105
- f"**βœ… Backend ready in {elapsed:.1f}s**"
106
- )
107
- yield final, True, f"βœ… Ready ({elapsed:.1f}s)", start_time
108
  elif result["error"]:
109
- yield f"❌ Cannot connect: {result['error']} ({elapsed:.1f}s)", False, f"❌ Failed ({elapsed:.1f}s)", start_time
110
  else:
111
- yield f"❌ Backend returned status {result['status_code']} ({elapsed:.1f}s)", False, f"❌ Failed ({elapsed:.1f}s)", start_time
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
 
114
  # ---------- Main App ----------
115
 
116
  def create_app() -> gr.Blocks:
117
- with gr.Blocks() as demo:
 
 
 
 
 
 
118
  page = gr.State("warmup")
119
  document_indexed = gr.State(False)
120
  indexed_docs = gr.State([])
121
- active_doc = gr.State(None)
122
 
123
- # ── Warmup page ──────────────────────────────────────────────────────
124
- with gr.Column(elem_id="warmup-page") as warmup_page:
125
- gr.Markdown(
126
  """
127
- <div style="text-align: center; margin-top: 15vh;">
128
- <h1 style="font-size: 3em; margin-bottom: 0.5em;">πŸ“š RAG Chat Interface</h1>
129
- <p style="font-size: 1.2em; color: #666;">Powered by Qwen 2.5 & Modal</p>
130
- <br>
131
- <p style="color: #555;">Click the button below to warm up the backend and get started.</p>
 
 
132
  </div>
133
  """
134
  )
135
  with gr.Row():
136
- with gr.Column():
137
- warmup_btn = gr.Button("πŸ”₯ Warm Up Backend", variant="primary", size="lg")
 
 
 
138
 
139
- warmup_status = gr.Markdown("")
140
  ready = gr.State(False)
141
  start_time_state = gr.State(0.0)
142
 
@@ -149,280 +204,249 @@ def create_app() -> gr.Blocks:
149
  outputs=[warmup_status, ready, warmup_btn, start_time_state],
150
  )
151
 
152
- # ── Chat page ────────────────────────────────────────────────────────
153
- with gr.Column(elem_id="chat-page", visible=False) as chat_page:
154
- gr.Markdown(
 
155
  """
156
- <div style="text-align: center; margin-bottom: 1em;">
157
- <h1>πŸ“š RAG Chat Interface</h1>
 
158
  </div>
159
  """
160
  )
161
 
162
- def respond(message, history, is_indexed, current_doc):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  history = history or []
164
  if not message or not message.strip():
165
  yield history, message
166
  return
167
  user_msg = {"role": "user", "content": message}
168
  if not is_indexed:
169
- yield history + [user_msg, {"role": "assistant", "content": "⚠️ Please index a document first (see section 1)."}], ""
170
  return
 
 
171
  yield history + [user_msg, {"role": "assistant", "content": "πŸ€” Thinking..."}], ""
 
172
  final_answer = "πŸ€” Thinking..."
173
- for chunk in query_backend(message, current_doc):
174
  final_answer = chunk
175
  yield history + [user_msg, {"role": "assistant", "content": final_answer}], ""
176
 
177
- def _after_index(success, doc_id, char_count, source_text, error_msg=""):
178
- """
179
- Shared post-index handler.
180
- Returns: (indexed, docs, active_doc, index_status, preview_box, dropdown_update)
181
- """
182
- if success:
183
- docs = fetch_indexed_docs()
184
- display = update_index_display(docs)
185
- preview = source_text or ""
186
- return (
187
- True,
188
- docs,
189
- doc_id,
190
- display,
191
- preview,
192
- gr.update(choices=docs, value=doc_id),
193
- )
194
- else:
195
- display = error_msg or "πŸ“„ *No document indexed yet*"
196
- return False, gr.update(), gr.update(), display, "", gr.update()
197
-
198
- # ── Section 1: Select files to index ──
199
- with gr.Accordion("πŸ“₯ 1. Select files to index", open=True):
200
- with gr.Tabs():
201
- with gr.Tab("πŸ“‹ Sample reports"):
202
- sample_selector = gr.CheckboxGroup(
203
- choices=list(SAMPLE_LABELS.keys()),
204
- value=[],
205
- label="Pick one or more sample annual reports",
206
- interactive=True,
207
- )
208
- load_samples_btn = gr.Button("πŸš€ Index Selected Reports", variant="primary")
209
- samples_status = gr.Markdown("")
210
-
211
- with gr.Tab("✏️ Paste text"):
212
- paste_input = gr.Textbox(
213
- label="Paste text here",
214
- lines=10,
215
- max_lines=20,
216
- placeholder="Paste any text content you want to ask questions about…",
217
- )
218
- paste_filename = gr.Textbox(
219
- label="Filename (optional)",
220
- value="pasted.txt",
221
- lines=1,
222
- )
223
- load_paste_btn = gr.Button("πŸš€ Index Pasted Text", variant="primary")
224
- paste_status = gr.Markdown("")
225
-
226
- with gr.Tab("πŸ“ Upload file"):
227
- file_input = gr.File(
228
- label="Upload .txt, .pdf, or .docx",
229
- file_types=[".txt", ".pdf", ".docx"],
230
- file_count="single",
231
- )
232
- gr.Markdown(
233
- "<small style='color:#888'>PDF and DOCX are parsed locally; "
234
- "only the extracted text is sent to the backend. "
235
- "Indexing starts automatically once a file is selected.</small>"
236
- )
237
- upload_status = gr.Markdown("")
238
-
239
- # ── Active Document Selector ──
240
- with gr.Row():
241
- active_doc_dropdown = gr.Dropdown(
242
- label="πŸ“‚ Active document for queries",
243
- choices=[],
244
- value=None,
245
- interactive=True,
246
- scale=5,
247
- )
248
- refresh_docs_btn = gr.Button("πŸ”„ Refresh", scale=1, min_width=80)
249
-
250
- # ── Section 2: Preview indexed text ──
251
- with gr.Accordion("πŸ“– 2. Preview indexed text", open=True):
252
- index_status = gr.Markdown("πŸ“„ *No documents indexed yet*")
253
- preview_box = gr.Textbox(
254
- value="",
255
- lines=18,
256
- max_lines=18,
257
- interactive=False,
258
- label="Content",
259
- placeholder="Index a document in section 1 to see its content here.",
260
  )
261
 
262
- # ── Section 3: Chat ──
263
- with gr.Accordion("πŸ’¬ 3. Ask questions", open=True):
264
- gr.Markdown("<small style='color:#888'>Quick questions:</small>")
265
- with gr.Row():
266
- q_btns = [gr.Button(q, size="sm") for q in SAMPLE_QUESTIONS]
267
 
268
- chatbot = gr.Chatbot(height=420, show_label=False, value=[])
269
 
270
- with gr.Row():
271
- question_input = gr.Textbox(
272
- placeholder="Type your question and press Enter…",
273
- lines=1,
274
- scale=9,
275
- show_label=False,
276
- container=False,
277
- )
278
- send_btn = gr.Button("Send β–Ά", scale=1, variant="primary")
279
-
280
- INDEX_OUTPUTS = [
281
- document_indexed,
282
- indexed_docs,
283
- active_doc,
284
- index_status,
285
- preview_box,
286
- active_doc_dropdown,
287
- ]
288
-
289
- SAMPLES_LABEL = "πŸš€ Index Selected Reports"
290
- PASTE_LABEL = "πŸš€ Index Pasted Text"
291
-
292
- def _busy(label):
293
- return gr.update(value=label, interactive=False)
294
-
295
- def _ready_btn(label):
296
- return gr.update(value=label, interactive=True)
297
-
298
- def _samples_action(selected_labels):
299
- yield _busy("πŸ“– Preparing reports..."), "πŸ“– Preparing reports…", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
300
- if not selected_labels:
301
- result = _after_index(False, None, None, "", "⚠️ Please select at least one report.")
302
- yield (_ready_btn(SAMPLES_LABEL), "⚠️ Please select at least one report.") + result
303
- return
304
- yield _busy("πŸ“€ Uploading & indexing..."), "πŸ“€ Uploading & indexing…", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
305
- success, doc_id, char_count, msg = load_selected_samples(selected_labels)
306
- source_text = build_preview_text(selected_labels) if success else ""
307
- result = _after_index(success, doc_id, char_count, source_text, msg)
308
- final_status = "βœ… Done." if success else msg
309
- yield (_ready_btn(SAMPLES_LABEL), final_status) + result
310
-
311
- load_samples_btn.click(
312
  fn=_samples_action,
313
- inputs=[sample_selector],
314
- outputs=[load_samples_btn, samples_status] + INDEX_OUTPUTS,
 
 
315
  )
316
 
317
  def _paste_action(text, filename):
318
- yield _busy("πŸ“– Preparing text..."), "πŸ“– Preparing text…", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
319
  if not text or not text.strip():
320
- result = _after_index(False, None, None, "", "⚠️ Please paste some text first.")
321
- yield (_ready_btn(PASTE_LABEL), "⚠️ Please paste some text first.") + result
322
  return
323
- yield _busy("πŸ“€ Uploading & indexing..."), "πŸ“€ Uploading & indexing…", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
324
  success, doc_id, char_count, msg = upload_pasted_text(text, filename)
325
- result = _after_index(success, doc_id, char_count, text if success else "", msg)
326
- final_status = "βœ… Done." if success else msg
327
- yield (_ready_btn(PASTE_LABEL), final_status) + result
328
 
329
  load_paste_btn.click(
330
  fn=_paste_action,
331
  inputs=[paste_input, paste_filename],
332
- outputs=[load_paste_btn, paste_status] + INDEX_OUTPUTS,
 
 
 
333
  )
334
 
335
  def _upload_action(file_obj):
336
- yield "πŸ“– Parsing file…", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
337
  path = resolve_file_path(file_obj)
338
  if not path:
339
- result = _after_index(False, None, None, "", "❌ No file selected.")
340
- yield ("❌ No file selected.",) + result
341
  return
342
  if not os.path.exists(path):
343
- result = _after_index(False, None, None, "", "❌ File not found.")
344
- yield ("❌ File not found.",) + result
345
  return
346
  file_size = os.path.getsize(path)
347
  if file_size > MAX_FILE_SIZE:
348
- msg = f"❌ File too large. Max {format_file_size(MAX_FILE_SIZE)}."
349
- result = _after_index(False, None, None, "", msg)
350
- yield (msg,) + result
351
  return
352
  text_content, err = extract_text_from_file(path)
353
  if err or not text_content or not text_content.strip():
354
- err_msg = err or "❌ File is empty or no extractable text found."
355
- result = _after_index(False, None, None, "", err_msg)
356
- yield (err_msg,) + result
357
  return
358
- yield "πŸ“€ Uploading & indexing…", gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
359
  base = os.path.basename(path)
360
  stem, _ext = os.path.splitext(base)
361
  filename = f"{stem}.txt"
362
  success, doc_id, char_count, msg = upload_text_to_backend(text_content, filename)
363
- result = _after_index(success, doc_id, char_count, text_content if success else "", msg)
364
- final_status = f"βœ… Indexed **{doc_id}** ({char_count:,} chars)." if success else msg
365
- yield (final_status,) + result
 
366
 
367
  file_input.upload(
368
  fn=_upload_action,
369
  inputs=[file_input],
370
- outputs=[upload_status] + INDEX_OUTPUTS,
371
  ).then(
372
  fn=lambda: gr.update(value=None),
373
  outputs=[file_input],
 
 
 
374
  )
375
 
376
- active_doc_dropdown.change(
377
- fn=lambda d: d,
378
- inputs=[active_doc_dropdown],
379
- outputs=[active_doc],
380
- )
381
-
382
- refresh_docs_btn.click(
383
- fn=lambda: gr.update(choices=fetch_indexed_docs()),
384
- outputs=[active_doc_dropdown],
385
- )
386
-
387
  for btn, q in zip(q_btns, SAMPLE_QUESTIONS):
388
  btn.click(
389
  fn=lambda x=q: x,
390
  outputs=[question_input],
391
  ).then(
392
  fn=respond,
393
- inputs=[question_input, chatbot, document_indexed, active_doc],
394
  outputs=[chatbot, question_input],
395
  )
396
 
397
  send_btn.click(
398
  fn=respond,
399
- inputs=[question_input, chatbot, document_indexed, active_doc],
400
  outputs=[chatbot, question_input],
401
  )
402
  question_input.submit(
403
  fn=respond,
404
- inputs=[question_input, chatbot, document_indexed, active_doc],
405
  outputs=[chatbot, question_input],
406
  )
407
 
 
408
  ready.change(
409
  fn=lambda r: (gr.update(visible=not r), gr.update(visible=r), "chat" if r else "warmup"),
410
  inputs=[ready],
411
  outputs=[warmup_page, chat_page, page],
412
  )
413
 
414
- demo.load(
415
- fn=load_index_state,
416
- outputs=[document_indexed, indexed_docs, active_doc, index_status],
417
- ).then(
418
- fn=lambda docs, active: gr.update(choices=docs, value=active),
419
- inputs=[indexed_docs, active_doc],
420
- outputs=[active_doc_dropdown],
421
- )
 
 
 
422
 
423
  return demo
424
 
425
 
426
  if __name__ == "__main__":
427
  app = create_app()
428
- app.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft())
 
1
  """
2
+ RAG Knowledge Base β€” Gradio UI layer.
3
+ Two-column layout: documents panel (left) + chat (right).
4
+ All pure logic lives in utils.py.
5
  """
6
  import gradio as gr
7
  import os
 
11
  from typing import Generator, Tuple
12
 
13
  from utils import (
 
14
  MAX_FILE_SIZE,
15
  load_sample_data,
16
  get_backend_url,
17
  format_file_size,
18
  extract_text_from_file,
19
  resolve_file_path,
 
20
  fetch_indexed_docs,
21
  upload_text_to_backend,
22
  upload_pasted_text,
 
26
  )
27
 
28
 
29
+ # ---------- Sample data ----------
30
  _SAMPLE_DATA = load_sample_data()
31
  SAMPLE_LABELS: dict = _SAMPLE_DATA["labels"]
32
  SAMPLE_DOCUMENTS: dict = {int(k): v for k, v in _SAMPLE_DATA["documents"].items()}
 
35
 
36
  # ---------- Sample-specific helpers ----------
37
 
38
+ def upload_sample(label: str) -> Tuple[bool, str, int, str]:
39
+ """Upload ONE sample report as its own document."""
40
+ if label not in SAMPLE_LABELS:
41
+ return False, None, None, "⚠️ Unknown sample."
42
+ year = SAMPLE_LABELS[label]
43
+ text = SAMPLE_DOCUMENTS[year]
44
+ filename = f"annual_report_{year}.txt"
45
+ return upload_text_to_backend(text, filename)
46
+
47
+
48
+ def upload_all_samples() -> Tuple[bool, list, str]:
49
+ """Upload every sample report as a separate document. Returns (any_success, doc_list, status_msg)."""
50
+ successes = []
51
+ failures = []
52
+ for label in SAMPLE_LABELS.keys():
53
+ ok, doc_id, _cc, _msg = upload_sample(label)
54
+ if ok:
55
+ successes.append(label)
56
+ else:
57
+ failures.append(label)
58
+ docs = fetch_indexed_docs()
59
+ if successes and not failures:
60
+ return True, docs, f"βœ… Indexed all {len(successes)} sample reports."
61
+ if successes and failures:
62
+ return True, docs, f"⚠️ Indexed {len(successes)}, failed {len(failures)}."
63
+ return False, docs, "❌ Failed to index sample reports."
64
 
65
 
66
  def warmup_with_status(start_time: float = None) -> Generator[Tuple[str, bool, str, float], None, None]:
 
81
  threading.Thread(target=do_request, daemon=True).start()
82
 
83
  STEPS = [
84
+ (0, "⏳ Booting GPU container..."),
85
+ (3, "⏳ Loading embedding model (BGE-small)..."),
86
+ (6, "⏳ Loading LLM weights (Qwen 2.5 3B)..."),
87
+ (9, "⏳ Restoring index from volume..."),
88
  ]
89
 
90
  while not result["done"]:
 
93
  for threshold, msg in STEPS:
94
  if elapsed >= threshold:
95
  step_msg = msg
96
+ yield f"{step_msg}\n\n*({elapsed:.1f}s)*", False, f"⏳ Warming up... ({elapsed:.1f}s)", start_time
97
  time.sleep(1)
98
 
99
  elapsed = time.time() - start_time
100
  if result["success"]:
101
+ yield f"βœ… Backend ready in **{elapsed:.1f}s**", True, f"βœ… Ready", start_time
 
 
 
 
 
 
102
  elif result["error"]:
103
+ yield f"❌ Cannot connect: {result['error']}", False, f"❌ Failed", start_time
104
  else:
105
+ yield f"❌ Backend returned {result['status_code']}", False, f"❌ Failed", start_time
106
+
107
+
108
+ # ---------- Display helpers ----------
109
+
110
+ def render_doc_chips(docs: list, active: str = "(all)") -> str:
111
+ """Render the indexed docs as a markdown chip list."""
112
+ if not docs:
113
+ return (
114
+ "<div style='padding:1em;text-align:center;color:#888;border:1px dashed #ccc;border-radius:6px'>"
115
+ "πŸ“­ <em>No documents yet</em><br>"
116
+ "<small>Add documents below to get started.</small>"
117
+ "</div>"
118
+ )
119
+ badge = lambda name, hot: (
120
+ f"<span style='display:inline-block;padding:4px 10px;margin:3px;border-radius:12px;"
121
+ f"background:{'#2563eb' if hot else '#eef2ff'};"
122
+ f"color:{'white' if hot else '#3730a3'};"
123
+ f"font-size:0.85em;border:1px solid {'#2563eb' if hot else '#c7d2fe'}'>"
124
+ f"πŸ“„ {name}</span>"
125
+ )
126
+ chips = "".join(badge(d, d == active) for d in docs)
127
+ count_line = f"<div style='margin-bottom:6px;color:#666;font-size:0.85em'><strong>{len(docs)}</strong> document{'s' if len(docs) != 1 else ''} indexed:</div>"
128
+ return f"<div>{count_line}{chips}</div>"
129
+
130
+
131
+ def scope_options(docs: list) -> list:
132
+ """Dropdown choices: search-all + per-doc."""
133
+ return ["πŸ” Search across all documents"] + [f"πŸ“„ {d}" for d in docs]
134
+
135
+
136
+ def scope_value_to_doc_id(value: str) -> str:
137
+ """Translate dropdown selection back to backend doc_id (or '' for search-all)."""
138
+ if not value or value.startswith("πŸ”"):
139
+ return ""
140
+ return value.removeprefix("πŸ“„ ").strip()
141
+
142
+
143
+ # ---------- Custom CSS ----------
144
+
145
+ CUSTOM_CSS = """
146
+ .gradio-container { max-width: 1280px !important; margin: 0 auto; }
147
+ #header-banner { background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%); color:white; padding: 18px 24px; border-radius: 8px; margin-bottom: 14px; }
148
+ #header-banner h1 { margin: 0; font-size: 1.6em; }
149
+ #header-banner .sub { opacity: 0.85; font-size: 0.95em; margin-top: 4px; }
150
+ .doc-panel { background: #fafafa; border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
151
+ .chat-panel { background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
152
+ .section-title { font-weight: 600; color: #374151; margin-bottom: 8px; }
153
+ .quick-q-row button { background: #f3f4f6 !important; border: 1px solid #e5e7eb !important; color: #374151 !important; font-size: 0.85em !important; }
154
+ .quick-q-row button:hover { background: #e5e7eb !important; }
155
+ .status-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.8em; font-weight: 500; }
156
+ footer { display: none !important; }
157
+ """
158
 
159
 
160
  # ---------- Main App ----------
161
 
162
  def create_app() -> gr.Blocks:
163
+ theme = gr.themes.Soft(
164
+ primary_hue="indigo",
165
+ secondary_hue="violet",
166
+ neutral_hue="slate",
167
+ )
168
+
169
+ with gr.Blocks(theme=theme, css=CUSTOM_CSS, title="RAG Knowledge Base") as demo:
170
  page = gr.State("warmup")
171
  document_indexed = gr.State(False)
172
  indexed_docs = gr.State([])
 
173
 
174
+ # ─── Warmup ────────────────────────────────────────────────────────
175
+ with gr.Column(visible=True) as warmup_page:
176
+ gr.HTML(
177
  """
178
+ <div style='text-align:center;margin-top:12vh;padding:2em'>
179
+ <div style='font-size:3em;margin-bottom:0.3em'>πŸ“š</div>
180
+ <h1 style='font-size:2.2em;margin:0;color:#4f46e5'>RAG Knowledge Base</h1>
181
+ <p style='color:#6b7280;font-size:1.1em;margin:0.5em 0 2em'>
182
+ Ask natural-language questions across your documents.<br>
183
+ Powered by Qwen 2.5 + LlamaIndex on Modal.
184
+ </p>
185
  </div>
186
  """
187
  )
188
  with gr.Row():
189
+ with gr.Column(scale=1): pass
190
+ with gr.Column(scale=2):
191
+ warmup_btn = gr.Button("πŸš€ Start", variant="primary", size="lg")
192
+ warmup_status = gr.Markdown("")
193
+ with gr.Column(scale=1): pass
194
 
 
195
  ready = gr.State(False)
196
  start_time_state = gr.State(0.0)
197
 
 
204
  outputs=[warmup_status, ready, warmup_btn, start_time_state],
205
  )
206
 
207
+ # ─── Main app ──────────────────────────────────────────────────────
208
+ with gr.Column(visible=False) as chat_page:
209
+
210
+ gr.HTML(
211
  """
212
+ <div id='header-banner'>
213
+ <h1>πŸ“š RAG Knowledge Base</h1>
214
+ <div class='sub'>Ask questions across multiple documents β€” answers cite their sources.</div>
215
  </div>
216
  """
217
  )
218
 
219
+ with gr.Row():
220
+ # ─── LEFT: Documents Panel ─────────────────────────────────
221
+ with gr.Column(scale=2, elem_classes=["doc-panel"]):
222
+ gr.Markdown("#### πŸ“ Documents")
223
+ doc_list_md = gr.Markdown(render_doc_chips([]))
224
+ refresh_btn = gr.Button("πŸ”„ Refresh list", size="sm")
225
+
226
+ with gr.Accordion("βž• Add documents", open=False):
227
+ with gr.Tabs():
228
+ # β€” Tab A: samples β€”
229
+ with gr.Tab("Samples"):
230
+ gr.Markdown(
231
+ "<small style='color:#666'>Demo annual reports β€” index all to try cross-document queries.</small>"
232
+ )
233
+ load_all_samples_btn = gr.Button(
234
+ "πŸ“‹ Load all 4 sample reports",
235
+ variant="primary",
236
+ size="sm",
237
+ )
238
+ samples_status = gr.Markdown("")
239
+
240
+ # β€” Tab B: paste β€”
241
+ with gr.Tab("Paste"):
242
+ paste_input = gr.Textbox(
243
+ label="Text content",
244
+ lines=8,
245
+ placeholder="Paste content here…",
246
+ )
247
+ paste_filename = gr.Textbox(
248
+ label="Document name",
249
+ value="pasted.txt",
250
+ lines=1,
251
+ )
252
+ load_paste_btn = gr.Button("Index text", variant="primary", size="sm")
253
+ paste_status = gr.Markdown("")
254
+
255
+ # β€” Tab C: file β€”
256
+ with gr.Tab("File"):
257
+ file_input = gr.File(
258
+ label="Drop a .txt, .pdf, or .docx file",
259
+ file_types=[".txt", ".pdf", ".docx"],
260
+ file_count="single",
261
+ )
262
+ gr.Markdown(
263
+ "<small style='color:#888'>Files are parsed locally; only extracted text is sent to the backend.</small>"
264
+ )
265
+ upload_status = gr.Markdown("")
266
+
267
+ # ─── RIGHT: Chat Panel ─────────────────────────────────────
268
+ with gr.Column(scale=5, elem_classes=["chat-panel"]):
269
+ gr.Markdown("#### πŸ’¬ Ask a question")
270
+
271
+ with gr.Row():
272
+ scope_dropdown = gr.Dropdown(
273
+ label="Search scope",
274
+ choices=scope_options([]),
275
+ value="πŸ” Search across all documents",
276
+ interactive=True,
277
+ scale=3,
278
+ )
279
+
280
+ chatbot = gr.Chatbot(
281
+ height=440,
282
+ show_label=False,
283
+ value=[],
284
+ type="messages",
285
+ avatar_images=(None, "https://api.dicebear.com/7.x/bottts/svg?seed=rag"),
286
+ placeholder="<div style='text-align:center;color:#9ca3af;padding:2em'><div style='font-size:2.5em'>πŸ’‘</div><strong>Index a document, then ask anything.</strong><br><small>Try: \"Compare revenue across all years\" or \"Who replaced Marcus Webb as CFO?\"</small></div>",
287
+ )
288
+
289
+ with gr.Row():
290
+ question_input = gr.Textbox(
291
+ placeholder="Type your question and press Enter…",
292
+ lines=1,
293
+ scale=9,
294
+ show_label=False,
295
+ container=False,
296
+ )
297
+ send_btn = gr.Button("Send β–Ά", scale=1, variant="primary", size="sm")
298
+
299
+ gr.Markdown("<small style='color:#888'>Quick questions:</small>")
300
+ with gr.Row(elem_classes=["quick-q-row"]):
301
+ q_btns = [gr.Button(q, size="sm") for q in SAMPLE_QUESTIONS]
302
+
303
+ # ─── Chat handler ──────────────────────────────────────────────
304
+ def respond(message, history, is_indexed, scope_choice):
305
  history = history or []
306
  if not message or not message.strip():
307
  yield history, message
308
  return
309
  user_msg = {"role": "user", "content": message}
310
  if not is_indexed:
311
+ yield history + [user_msg, {"role": "assistant", "content": "⚠️ No documents indexed yet. Add one in the panel on the left."}], ""
312
  return
313
+
314
+ doc_id = scope_value_to_doc_id(scope_choice)
315
  yield history + [user_msg, {"role": "assistant", "content": "πŸ€” Thinking..."}], ""
316
+
317
  final_answer = "πŸ€” Thinking..."
318
+ for chunk in query_backend(message, doc_id):
319
  final_answer = chunk
320
  yield history + [user_msg, {"role": "assistant", "content": final_answer}], ""
321
 
322
+ # ─── Post-index handler (shared across tabs) ───────────────────
323
+ def _refresh_panel():
324
+ docs = fetch_indexed_docs()
325
+ return (
326
+ len(docs) > 0,
327
+ docs,
328
+ render_doc_chips(docs, scope_value_to_doc_id("πŸ” Search across all documents") or "(all)"),
329
+ gr.update(choices=scope_options(docs), value="πŸ” Search across all documents"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  )
331
 
332
+ REFRESH_OUTPUTS = [document_indexed, indexed_docs, doc_list_md, scope_dropdown]
 
 
 
 
333
 
334
+ refresh_btn.click(fn=_refresh_panel, outputs=REFRESH_OUTPUTS)
335
 
336
+ # ─── Tab actions ───────────────────────────────────────────────
337
+ def _samples_action():
338
+ yield "πŸ“€ Indexing 4 sample reports..."
339
+ _ok, _docs, msg = upload_all_samples()
340
+ yield msg
341
+
342
+ load_all_samples_btn.click(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  fn=_samples_action,
344
+ outputs=[samples_status],
345
+ ).then(
346
+ fn=_refresh_panel,
347
+ outputs=REFRESH_OUTPUTS,
348
  )
349
 
350
  def _paste_action(text, filename):
 
351
  if not text or not text.strip():
352
+ yield "⚠️ Please paste some text first."
 
353
  return
354
+ yield "πŸ“€ Indexing text..."
355
  success, doc_id, char_count, msg = upload_pasted_text(text, filename)
356
+ yield msg
 
 
357
 
358
  load_paste_btn.click(
359
  fn=_paste_action,
360
  inputs=[paste_input, paste_filename],
361
+ outputs=[paste_status],
362
+ ).then(
363
+ fn=_refresh_panel,
364
+ outputs=REFRESH_OUTPUTS,
365
  )
366
 
367
  def _upload_action(file_obj):
368
+ yield "πŸ“– Parsing file…"
369
  path = resolve_file_path(file_obj)
370
  if not path:
371
+ yield "❌ No file selected."
 
372
  return
373
  if not os.path.exists(path):
374
+ yield "❌ File not found."
 
375
  return
376
  file_size = os.path.getsize(path)
377
  if file_size > MAX_FILE_SIZE:
378
+ yield f"❌ File too large. Max {format_file_size(MAX_FILE_SIZE)}."
 
 
379
  return
380
  text_content, err = extract_text_from_file(path)
381
  if err or not text_content or not text_content.strip():
382
+ yield err or "❌ File is empty or unreadable."
 
 
383
  return
384
+ yield "πŸ“€ Uploading & indexing…"
385
  base = os.path.basename(path)
386
  stem, _ext = os.path.splitext(base)
387
  filename = f"{stem}.txt"
388
  success, doc_id, char_count, msg = upload_text_to_backend(text_content, filename)
389
+ yield (
390
+ f"βœ… Indexed **{doc_id}** ({char_count:,} chars)."
391
+ if success else msg
392
+ )
393
 
394
  file_input.upload(
395
  fn=_upload_action,
396
  inputs=[file_input],
397
+ outputs=[upload_status],
398
  ).then(
399
  fn=lambda: gr.update(value=None),
400
  outputs=[file_input],
401
+ ).then(
402
+ fn=_refresh_panel,
403
+ outputs=REFRESH_OUTPUTS,
404
  )
405
 
406
+ # ─── Chat wiring ───────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
407
  for btn, q in zip(q_btns, SAMPLE_QUESTIONS):
408
  btn.click(
409
  fn=lambda x=q: x,
410
  outputs=[question_input],
411
  ).then(
412
  fn=respond,
413
+ inputs=[question_input, chatbot, document_indexed, scope_dropdown],
414
  outputs=[chatbot, question_input],
415
  )
416
 
417
  send_btn.click(
418
  fn=respond,
419
+ inputs=[question_input, chatbot, document_indexed, scope_dropdown],
420
  outputs=[chatbot, question_input],
421
  )
422
  question_input.submit(
423
  fn=respond,
424
+ inputs=[question_input, chatbot, document_indexed, scope_dropdown],
425
  outputs=[chatbot, question_input],
426
  )
427
 
428
+ # Page switch
429
  ready.change(
430
  fn=lambda r: (gr.update(visible=not r), gr.update(visible=r), "chat" if r else "warmup"),
431
  inputs=[ready],
432
  outputs=[warmup_page, chat_page, page],
433
  )
434
 
435
+ # Load state on first page render
436
+ def _initial_load():
437
+ is_indexed, docs, _active, _display = load_index_state()
438
+ return (
439
+ is_indexed,
440
+ docs,
441
+ render_doc_chips(docs, "(all)"),
442
+ gr.update(choices=scope_options(docs), value="πŸ” Search across all documents"),
443
+ )
444
+
445
+ demo.load(fn=_initial_load, outputs=REFRESH_OUTPUTS)
446
 
447
  return demo
448
 
449
 
450
  if __name__ == "__main__":
451
  app = create_app()
452
+ app.launch(server_name="0.0.0.0", server_port=7860)
utils.py CHANGED
@@ -219,9 +219,16 @@ def query_backend(message: str, doc_id: Optional[str] = None) -> Generator[str,
219
  response.raise_for_status()
220
  data = response.json()
221
  answer = clean_answer(data.get("answer", "⚠️ Received an empty response."))
 
 
 
 
 
 
 
222
  elapsed = data.get("elapsed")
223
  if elapsed is not None:
224
- answer += f"\n\n<sub style='color:#888'>⏱️ {elapsed:.1f} seconds</sub>"
225
  yield answer
226
  except requests.exceptions.Timeout:
227
  yield "❌ Query timed out. Please try again."
 
219
  response.raise_for_status()
220
  data = response.json()
221
  answer = clean_answer(data.get("answer", "⚠️ Received an empty response."))
222
+
223
+ # Append sources (from unified-index backend)
224
+ sources = data.get("sources") or []
225
+ if sources:
226
+ src_chips = " ".join(f"`{s}`" for s in sources)
227
+ answer += f"\n\n<sub style='color:#666'>πŸ“Ž Sources: {src_chips}</sub>"
228
+
229
  elapsed = data.get("elapsed")
230
  if elapsed is not None:
231
+ answer += f"\n\n<sub style='color:#888'>⏱️ {elapsed:.1f}s</sub>"
232
  yield answer
233
  except requests.exceptions.Timeout:
234
  yield "❌ Query timed out. Please try again."