Bhaskar Ram commited on
Commit
3151380
·
1 Parent(s): 634117a

feat: model selector, progress bar, sources panel, chat export, CSV parsing, dockerignore

Browse files

UI/UX Features:
- app.py: model selector dropdown (Llama 3.1 8B, Mistral 7B, Mixtral 8x7B, Qwen2.5 72B)
— switches active LLM per-request without server restart
- app.py: gr.Progress in process_files() with step labels (Parsing / Embedding / Done)
— no more silent 30s freeze on large uploads
- app.py: Retrieved Sources accordion below chat — shows each chunk's source file,
cosine score, score bar (█░ visual), and 220-char preview
- app.py: Chat export button — downloads conversation as timestamped Markdown file
- app.py: Max response tokens slider (128–4096, default 1024) in Settings panel
- app.py: CSS moved into Blocks(css=) to avoid duplicate arg on launch()

RAG Core:
- document_loader.py: CSV files now parsed with csv.DictReader into
'Column: value. Column: value.' natural-language sentences per row

Infra:
- .dockerignore: excludes .git, .env, __pycache__, tests, venv, .vscode,
sdk/ and FAISS snapshot files from Docker image

Files changed (3) hide show
  1. .dockerignore +52 -0
  2. app.py +162 -54
  3. rag/document_loader.py +31 -0
.dockerignore ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ignore files that should never go into the Docker image
2
+
3
+ # Git internals
4
+ .git
5
+ .gitignore
6
+
7
+ # Python cache & build artifacts
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.pyo
11
+ *.pyd
12
+ .Python
13
+ *.egg-info/
14
+ dist/
15
+ build/
16
+ .eggs/
17
+
18
+ # Virtual environments
19
+ .venv/
20
+ venv/
21
+ env/
22
+
23
+ # Environment secrets — NEVER bake into image
24
+ .env
25
+ .env.*
26
+ !.env.example
27
+
28
+ # Dev dependencies and tooling
29
+ requirements-dev.txt
30
+ .pytest_cache/
31
+ .ruff_cache/
32
+ .mypy_cache/
33
+
34
+ # Test files
35
+ tests/
36
+
37
+ # IDE / editor configs
38
+ .vscode/
39
+ .idea/
40
+ *.swp
41
+ *.swo
42
+
43
+ # OS noise
44
+ .DS_Store
45
+ Thumbs.db
46
+
47
+ # SDK (not needed in runtime image)
48
+ sdk/
49
+
50
+ # Saved FAISS index snapshots (user-local, not for containers)
51
+ *.faiss
52
+ *.pkl
app.py CHANGED
@@ -3,51 +3,65 @@ app.py — Enterprise Document Q&A (RAG)
3
  Powered by Llama 3 + FAISS + Sentence Transformers
4
  A Demo Product by Kerdos Infrasoft Private Limited
5
  Website: https://kerdos.in
 
 
 
 
 
 
 
 
6
  """
7
 
8
  import os
 
 
 
9
  from dotenv import load_dotenv
10
  import gradio as gr
11
  from rag.document_loader import load_documents
12
  from rag.embedder import build_index, add_to_index
13
  from rag.retriever import retrieve
14
  from rag.chain import answer_stream
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- load_dotenv() # Load HF_TOKEN etc. from .env when running locally
17
 
18
- # ─────────────────────────────────────────────
19
  # State helpers
20
- # ─────────────────────────────────────────────
21
 
22
  def get_hf_token(user_token: str) -> str:
23
- """Prefer user-supplied token; fall back to Space secret."""
24
  t = user_token.strip() if user_token else ""
25
  return t or os.environ.get("HF_TOKEN", "")
26
 
27
 
28
- # ─────────────────────────────────────────────
29
  # Gradio handlers
30
- # ─────────────────────────────────────────────
31
 
32
- def process_files(files, current_index, indexed_sources):
33
- """Parse uploaded files and build / extend the FAISS index.
34
-
35
- Args:
36
- files: Uploaded file objects from gr.File.
37
- current_index: Existing VectorIndex state (None on first upload).
38
- indexed_sources: Set of already-indexed filenames (duplicate guard).
39
- """
40
  if not files:
41
  return current_index, indexed_sources, "⚠️ No files uploaded."
42
 
43
  file_paths = [f.name for f in files] if hasattr(files[0], "name") else files
44
 
45
- # ── Duplicate guard ────────────────────────────────────────────────────
46
- # Filter out files whose name is already in the knowledge base so that
47
- # re-uploading the same document doesn't silently double the chunk count.
48
  new_paths, skipped = [], []
49
  for p in file_paths:
50
- from pathlib import Path
51
  name = Path(p).name
52
  if name in indexed_sources:
53
  skipped.append(name)
@@ -58,13 +72,18 @@ def process_files(files, current_index, indexed_sources):
58
  return current_index, indexed_sources, (
59
  f"⚠️ Already indexed: {', '.join(skipped)}. No new documents added."
60
  )
61
- # ──────────────────────────────────────────────────────────────────────
62
 
 
 
63
  docs = load_documents(new_paths)
64
 
65
  if not docs:
66
- return current_index, indexed_sources, "❌ Could not extract text from the uploaded files. Please upload PDF, DOCX, or TXT files."
 
 
67
 
 
 
68
  try:
69
  if current_index is None:
70
  idx = build_index(docs)
@@ -73,6 +92,8 @@ def process_files(files, current_index, indexed_sources):
73
  except Exception as e:
74
  return current_index, indexed_sources, f"❌ Failed to build index: {e}"
75
 
 
 
76
  new_sources = {d["source"] for d in docs}
77
  updated_sources = indexed_sources | new_sources
78
  total_chunks = idx.index.ntotal
@@ -85,19 +106,19 @@ def process_files(files, current_index, indexed_sources):
85
  return idx, updated_sources, msg
86
 
87
 
88
- def chat(user_message, history, vector_index, hf_token_input, top_k):
89
- """Streaming chat handler — yields progressively-updated history for real-time response."""
90
  if not user_message.strip():
91
- yield history, ""
92
  return
93
 
94
  hf_token = get_hf_token(hf_token_input)
95
  if not hf_token:
96
  history = history + [
97
  {"role": "user", "content": user_message},
98
- {"role": "assistant", "content": "⚠️ Please provide a Hugging Face API token to use the chat."},
99
  ]
100
- yield history, ""
101
  return
102
 
103
  if vector_index is None:
@@ -105,32 +126,75 @@ def chat(user_message, history, vector_index, hf_token_input, top_k):
105
  {"role": "user", "content": user_message},
106
  {"role": "assistant", "content": "⚠️ Please upload at least one document first."},
107
  ]
108
- yield history, ""
109
  return
110
 
 
 
 
 
 
111
  try:
112
  chunks = retrieve(user_message, vector_index, top_k=int(top_k))
113
- # Append placeholder so user sees their message immediately
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  history = history + [
115
  {"role": "user", "content": user_message},
116
  {"role": "assistant", "content": ""},
117
  ]
118
  for partial in answer_stream(user_message, chunks, hf_token, chat_history=history[:-2]):
119
  history[-1]["content"] = partial
120
- yield history, ""
 
 
 
121
  except Exception as e:
122
  history[-1]["content"] = f"❌ Error: {e}"
123
- yield history, ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
 
126
  def reset_all():
127
- """Clear index, chat, and the indexed-sources tracker."""
128
- return None, set(), [], "🗑️ Knowledge base and chat cleared.", ""
129
 
130
 
131
- # ─────────────────────────────────────────────
132
- # UI
133
- # ─────────────────────────────────────────────
134
 
135
  CSS = """
136
  /* ── Kerdos Brand Theme ── */
@@ -196,16 +260,19 @@ body { font-family: 'Segoe UI', Arial, sans-serif; }
196
  font-size: 0.82em;
197
  color: #888;
198
  }
199
- #title { text-align: center; }
200
  #subtitle { text-align: center; color: #6B8CFF; margin-bottom: 8px; }
201
  .upload-box { border: 2px dashed #0055FF !important; border-radius: 12px !important; }
202
  #status-box { font-size: 0.9em; }
203
  footer { display: none !important; }
204
  """
205
 
206
- with gr.Blocks(title="Kerdos AI — Custom LLM Chat | Document Q&A Demo") as demo:
 
 
207
 
208
- # ── Kerdos Header ─────────────────────────
 
 
209
  gr.HTML("""
210
  <div id="kerdos-header">
211
  <div id="kerdos-logo-line">
@@ -226,12 +293,10 @@ with gr.Blocks(title="Kerdos AI — Custom LLM Chat | Document Q&A Demo") as dem
226
  &nbsp;|&nbsp;
227
  📞 <a href="https://kerdos.in/contact" target="_blank" style="color:#00C2FF; text-decoration:none;">Contact Us</a>
228
  </div>
229
-
230
  <div id="kerdos-demo-banner">
231
  ⚠️ <strong style="color:#FFA000;">This is a Demo Version.</strong>
232
  <span style="color:#FFD080;"> Features, model selection, and customisation are limited. The full product will support private, on-premise LLM deployments tailored to your organisation.</span>
233
  </div>
234
-
235
  <div id="kerdos-fund-banner">
236
  🚀 <strong style="color:#00C2FF;">We are actively seeking investment &amp; partnerships</strong>
237
  <span style="color:#A0C8FF;"> to build the <em>fully customisable</em> enterprise edition — including <strong>private LLM hosting</strong>, custom model fine-tuning, data privacy guarantees, and white-label deployments.</span>
@@ -249,12 +314,12 @@ with gr.Blocks(title="Kerdos AI — Custom LLM Chat | Document Q&A Demo") as dem
249
  elem_id="subtitle",
250
  )
251
 
252
- # ── Shared state ─────────────────────────
253
  vector_index = gr.State(None)
254
- indexed_sources = gr.State(set()) # tracks filenames already in the index
255
 
256
  with gr.Row():
257
- # ── Left panel: Upload + config ──────
258
  with gr.Column(scale=1, min_width=300):
259
  gr.Markdown("### 📂 Upload Documents")
260
  file_upload = gr.File(
@@ -278,16 +343,34 @@ with gr.Blocks(title="Kerdos AI — Custom LLM Chat | Document Q&A Demo") as dem
278
  type="password",
279
  value="",
280
  )
 
 
 
 
 
 
 
 
 
281
  top_k_slider = gr.Slider(
282
  minimum=1, maximum=10, value=5, step=1,
283
  label="Chunks to retrieve (top-K)",
284
  )
 
 
 
 
 
 
 
 
285
  reset_btn = gr.Button("🗑️ Clear All", variant="stop")
286
 
287
- # ── Right panel: Chat ─────────────────
288
  with gr.Column(scale=2):
289
  gr.Markdown("### 💬 Ask Questions")
290
- chatbot = gr.Chatbot(height=460, show_label=False)
 
291
  with gr.Row():
292
  user_input = gr.Textbox(
293
  placeholder="Ask a question about your documents...",
@@ -297,18 +380,31 @@ with gr.Blocks(title="Kerdos AI — Custom LLM Chat | Document Q&A Demo") as dem
297
  )
298
  send_btn = gr.Button("Send ▶", variant="primary", scale=1)
299
 
300
- # ── Examples ─────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
301
  gr.Examples(
302
  examples=[
303
  ["What is the refund policy?"],
304
  ["Summarize the key points of this document."],
305
  ["What are the terms of service?"],
306
  ["Who is the contact person for support?"],
 
307
  ],
308
  inputs=user_input,
309
  )
310
 
311
- # ── Event wiring ──────────────────────────
312
  index_btn.click(
313
  fn=process_files,
314
  inputs=[file_upload, vector_index, indexed_sources],
@@ -317,23 +413,35 @@ with gr.Blocks(title="Kerdos AI — Custom LLM Chat | Document Q&A Demo") as dem
317
 
318
  send_btn.click(
319
  fn=chat,
320
- inputs=[user_input, chatbot, vector_index, hf_token_input, top_k_slider],
321
- outputs=[chatbot, user_input],
 
322
  )
323
 
324
  user_input.submit(
325
  fn=chat,
326
- inputs=[user_input, chatbot, vector_index, hf_token_input, top_k_slider],
327
- outputs=[chatbot, user_input],
 
328
  )
329
 
330
  reset_btn.click(
331
  fn=reset_all,
332
  inputs=[],
333
- outputs=[vector_index, indexed_sources, chatbot, status_box, user_input],
 
 
 
 
 
 
 
 
 
 
334
  )
335
 
336
- # ── Kerdos Footer ───────��─────────────────
337
  gr.HTML("""
338
  <div id="kerdos-footer">
339
  &copy; 2024–2026 <strong>Kerdos Infrasoft Private Limited</strong> &nbsp;|&nbsp;
@@ -348,5 +456,5 @@ with gr.Blocks(title="Kerdos AI — Custom LLM Chat | Document Q&A Demo") as dem
348
  """)
349
 
350
  if __name__ == "__main__":
351
- demo.queue() # Required for streaming generators
352
- demo.launch(css=CSS, theme=gr.themes.Soft())
 
3
  Powered by Llama 3 + FAISS + Sentence Transformers
4
  A Demo Product by Kerdos Infrasoft Private Limited
5
  Website: https://kerdos.in
6
+
7
+ New features in this version:
8
+ • Model selector dropdown (switch LLM without restart)
9
+ • Indexing progress indicator (gr.Progress)
10
+ • MAX_NEW_TOKENS slider exposed in UI
11
+ • Retrieved sources panel with cosine scores (accordion)
12
+ • Chat export — download conversation as Markdown
13
+ • .dockerignore added for security
14
  """
15
 
16
  import os
17
+ import datetime
18
+ import tempfile
19
+ from pathlib import Path
20
  from dotenv import load_dotenv
21
  import gradio as gr
22
  from rag.document_loader import load_documents
23
  from rag.embedder import build_index, add_to_index
24
  from rag.retriever import retrieve
25
  from rag.chain import answer_stream
26
+ import rag.chain as _chain_module
27
+
28
+ load_dotenv()
29
+
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+ # Available models (HF Inference API — free tier)
32
+ # ─────────────────────────────────────────────────────────────────────────────
33
+ AVAILABLE_MODELS = {
34
+ "Llama 3.1 8B Instruct ⚡ (default)": "meta-llama/Llama-3.1-8B-Instruct",
35
+ "Mistral 7B Instruct v0.3": "mistralai/Mistral-7B-Instruct-v0.3",
36
+ "Mixtral 8×7B Instruct v0.1": "mistralai/Mixtral-8x7B-Instruct-v0.1",
37
+ "Qwen2.5 72B Instruct": "Qwen/Qwen2.5-72B-Instruct",
38
+ }
39
+ DEFAULT_MODEL_LABEL = list(AVAILABLE_MODELS.keys())[0]
40
 
 
41
 
42
+ # ─────────────────────────────────────────────────────────────────────────────
43
  # State helpers
44
+ # ─────────────────────────────────────────────────────────────────────────────
45
 
46
  def get_hf_token(user_token: str) -> str:
 
47
  t = user_token.strip() if user_token else ""
48
  return t or os.environ.get("HF_TOKEN", "")
49
 
50
 
51
+ # ─────────────────────────────────────────────────────────────────────────────
52
  # Gradio handlers
53
+ # ─────────────────────────────────────────────────────────────────────────────
54
 
55
+ def process_files(files, current_index, indexed_sources, progress=gr.Progress()):
56
+ """Parse uploaded files and build / extend the FAISS index with live progress."""
 
 
 
 
 
 
57
  if not files:
58
  return current_index, indexed_sources, "⚠️ No files uploaded."
59
 
60
  file_paths = [f.name for f in files] if hasattr(files[0], "name") else files
61
 
62
+ # ── Duplicate guard ──────────────────────────────────────────────────────
 
 
63
  new_paths, skipped = [], []
64
  for p in file_paths:
 
65
  name = Path(p).name
66
  if name in indexed_sources:
67
  skipped.append(name)
 
72
  return current_index, indexed_sources, (
73
  f"⚠️ Already indexed: {', '.join(skipped)}. No new documents added."
74
  )
 
75
 
76
+ # ── Load ─────────────────────────────────────────────────────────────────
77
+ progress(0.10, desc="📄 Parsing documents…")
78
  docs = load_documents(new_paths)
79
 
80
  if not docs:
81
+ return current_index, indexed_sources, (
82
+ "❌ Could not extract text. Please upload PDF, DOCX, TXT, MD, or CSV."
83
+ )
84
 
85
+ # ── Embed & index ─────────────────────────────────────────────────────────
86
+ progress(0.40, desc="🧠 Embedding chunks…")
87
  try:
88
  if current_index is None:
89
  idx = build_index(docs)
 
92
  except Exception as e:
93
  return current_index, indexed_sources, f"❌ Failed to build index: {e}"
94
 
95
+ progress(1.0, desc="✅ Done!")
96
+
97
  new_sources = {d["source"] for d in docs}
98
  updated_sources = indexed_sources | new_sources
99
  total_chunks = idx.index.ntotal
 
106
  return idx, updated_sources, msg
107
 
108
 
109
+ def chat(user_message, history, vector_index, hf_token_input, top_k, model_label, max_tokens):
110
+ """Streaming chat handler — yields progressively-updated history + sources panel."""
111
  if not user_message.strip():
112
+ yield history, "", ""
113
  return
114
 
115
  hf_token = get_hf_token(hf_token_input)
116
  if not hf_token:
117
  history = history + [
118
  {"role": "user", "content": user_message},
119
+ {"role": "assistant", "content": "⚠️ Please provide a Hugging Face API token."},
120
  ]
121
+ yield history, "", ""
122
  return
123
 
124
  if vector_index is None:
 
126
  {"role": "user", "content": user_message},
127
  {"role": "assistant", "content": "⚠️ Please upload at least one document first."},
128
  ]
129
+ yield history, "", ""
130
  return
131
 
132
+ # Apply model + token settings from UI for this request
133
+ selected_model = AVAILABLE_MODELS.get(model_label, _chain_module.LLM_MODEL)
134
+ _chain_module.LLM_MODEL = selected_model
135
+ _chain_module.MAX_NEW_TOKENS = int(max_tokens)
136
+
137
  try:
138
  chunks = retrieve(user_message, vector_index, top_k=int(top_k))
139
+
140
+ # Build sources panel text
141
+ if chunks:
142
+ sources_lines = ["**🔍 Retrieved Chunks:**\n"]
143
+ for i, c in enumerate(chunks, 1):
144
+ score_bar = "█" * int(c["score"] * 10) + "░" * (10 - int(c["score"] * 10))
145
+ sources_lines.append(
146
+ f"**[{i}] {c['source']}** — score: `{c['score']:.3f}` `{score_bar}`\n"
147
+ f"> {c['text'][:220].strip()}{'…' if len(c['text']) > 220 else ''}\n"
148
+ )
149
+ sources_md = "\n".join(sources_lines)
150
+ else:
151
+ sources_md = "_(No relevant chunks above score threshold)_"
152
+
153
+ # Append placeholder for streaming
154
  history = history + [
155
  {"role": "user", "content": user_message},
156
  {"role": "assistant", "content": ""},
157
  ]
158
  for partial in answer_stream(user_message, chunks, hf_token, chat_history=history[:-2]):
159
  history[-1]["content"] = partial
160
+ yield history, "", sources_md
161
+
162
+ yield history, "", sources_md
163
+
164
  except Exception as e:
165
  history[-1]["content"] = f"❌ Error: {e}"
166
+ yield history, "", ""
167
+
168
+
169
+ def export_chat(history) -> str | None:
170
+ """Export the current chat history to a Markdown file for download."""
171
+ if not history:
172
+ return None
173
+ lines = [
174
+ f"# Kerdos AI — Chat Export",
175
+ f"_Exported: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}_\n",
176
+ "---\n",
177
+ ]
178
+ for msg in history:
179
+ role = "👤 **User**" if msg["role"] == "user" else "🤖 **Assistant**"
180
+ lines.append(f"{role}\n\n{msg['content']}\n\n---\n")
181
+
182
+ tmp = tempfile.NamedTemporaryFile(
183
+ mode="w", suffix=".md", prefix="kerdos_chat_", delete=False, encoding="utf-8"
184
+ )
185
+ tmp.write("\n".join(lines))
186
+ tmp.close()
187
+ return tmp.name
188
 
189
 
190
  def reset_all():
191
+ """Clear index, chat, sources panel, and the indexed-sources tracker."""
192
+ return None, set(), [], "🗑️ Knowledge base and chat cleared.", "", ""
193
 
194
 
195
+ # ─────────────────────────────────────────────────────────────────────────────
196
+ # CSS
197
+ # ─────────────────────────────────────────────────────────────────────────────
198
 
199
  CSS = """
200
  /* ── Kerdos Brand Theme ── */
 
260
  font-size: 0.82em;
261
  color: #888;
262
  }
 
263
  #subtitle { text-align: center; color: #6B8CFF; margin-bottom: 8px; }
264
  .upload-box { border: 2px dashed #0055FF !important; border-radius: 12px !important; }
265
  #status-box { font-size: 0.9em; }
266
  footer { display: none !important; }
267
  """
268
 
269
+ # ─────────────────────────────────────────────────────────────────────────────
270
+ # UI
271
+ # ─────────────────────────────────────────────────────────────────────────────
272
 
273
+ with gr.Blocks(title="Kerdos AI — Custom LLM Chat | Document Q&A Demo", css=CSS) as demo:
274
+
275
+ # ── Kerdos Header ────────────────────────────────────────────────────────
276
  gr.HTML("""
277
  <div id="kerdos-header">
278
  <div id="kerdos-logo-line">
 
293
  &nbsp;|&nbsp;
294
  📞 <a href="https://kerdos.in/contact" target="_blank" style="color:#00C2FF; text-decoration:none;">Contact Us</a>
295
  </div>
 
296
  <div id="kerdos-demo-banner">
297
  ⚠️ <strong style="color:#FFA000;">This is a Demo Version.</strong>
298
  <span style="color:#FFD080;"> Features, model selection, and customisation are limited. The full product will support private, on-premise LLM deployments tailored to your organisation.</span>
299
  </div>
 
300
  <div id="kerdos-fund-banner">
301
  🚀 <strong style="color:#00C2FF;">We are actively seeking investment &amp; partnerships</strong>
302
  <span style="color:#A0C8FF;"> to build the <em>fully customisable</em> enterprise edition — including <strong>private LLM hosting</strong>, custom model fine-tuning, data privacy guarantees, and white-label deployments.</span>
 
314
  elem_id="subtitle",
315
  )
316
 
317
+ # ── Shared state ─────────────────────────────────────────────────────────
318
  vector_index = gr.State(None)
319
+ indexed_sources = gr.State(set())
320
 
321
  with gr.Row():
322
+ # ── Left panel: Upload + Settings ────────────────────────────────────
323
  with gr.Column(scale=1, min_width=300):
324
  gr.Markdown("### 📂 Upload Documents")
325
  file_upload = gr.File(
 
343
  type="password",
344
  value="",
345
  )
346
+
347
+ # ── NEW: Model selector ──────���───────────────────────────────────
348
+ model_selector = gr.Dropdown(
349
+ choices=list(AVAILABLE_MODELS.keys()),
350
+ value=DEFAULT_MODEL_LABEL,
351
+ label="🤖 LLM Model",
352
+ info="Requires appropriate HF token permissions.",
353
+ )
354
+
355
  top_k_slider = gr.Slider(
356
  minimum=1, maximum=10, value=5, step=1,
357
  label="Chunks to retrieve (top-K)",
358
  )
359
+
360
+ # ── NEW: Max tokens slider ───────────────────────────────────────
361
+ max_tokens_slider = gr.Slider(
362
+ minimum=128, maximum=4096, value=1024, step=128,
363
+ label="Max response tokens",
364
+ info="Higher = longer answers, slower generation.",
365
+ )
366
+
367
  reset_btn = gr.Button("🗑️ Clear All", variant="stop")
368
 
369
+ # ── Right panel: Chat ─────────────────────────────────────────────────
370
  with gr.Column(scale=2):
371
  gr.Markdown("### 💬 Ask Questions")
372
+ chatbot = gr.Chatbot(height=420, show_label=False, type="messages")
373
+
374
  with gr.Row():
375
  user_input = gr.Textbox(
376
  placeholder="Ask a question about your documents...",
 
380
  )
381
  send_btn = gr.Button("Send ▶", variant="primary", scale=1)
382
 
383
+ with gr.Row():
384
+ # ── NEW: Export button ────────────────────────────────────────
385
+ export_btn = gr.Button("💾 Export Chat", variant="secondary", size="sm")
386
+ export_file = gr.File(label="Download", visible=False, scale=2)
387
+
388
+ # ── NEW: Retrieved sources accordion ──────────────────────────────
389
+ with gr.Accordion("🔍 Retrieved Sources", open=False):
390
+ sources_panel = gr.Markdown(
391
+ value="_Sources will appear here after each answer._",
392
+ label="Sources",
393
+ )
394
+
395
+ # ── Examples ─────────────────────────────────────────────────────────────
396
  gr.Examples(
397
  examples=[
398
  ["What is the refund policy?"],
399
  ["Summarize the key points of this document."],
400
  ["What are the terms of service?"],
401
  ["Who is the contact person for support?"],
402
+ ["List all products and their prices."],
403
  ],
404
  inputs=user_input,
405
  )
406
 
407
+ # ── Event wiring ──────────────────────────────────────────────────────────
408
  index_btn.click(
409
  fn=process_files,
410
  inputs=[file_upload, vector_index, indexed_sources],
 
413
 
414
  send_btn.click(
415
  fn=chat,
416
+ inputs=[user_input, chatbot, vector_index, hf_token_input,
417
+ top_k_slider, model_selector, max_tokens_slider],
418
+ outputs=[chatbot, user_input, sources_panel],
419
  )
420
 
421
  user_input.submit(
422
  fn=chat,
423
+ inputs=[user_input, chatbot, vector_index, hf_token_input,
424
+ top_k_slider, model_selector, max_tokens_slider],
425
+ outputs=[chatbot, user_input, sources_panel],
426
  )
427
 
428
  reset_btn.click(
429
  fn=reset_all,
430
  inputs=[],
431
+ outputs=[vector_index, indexed_sources, chatbot, status_box, user_input, sources_panel],
432
+ )
433
+
434
+ export_btn.click(
435
+ fn=export_chat,
436
+ inputs=[chatbot],
437
+ outputs=[export_file],
438
+ ).then(
439
+ fn=lambda f: gr.File(value=f, visible=f is not None),
440
+ inputs=[export_file],
441
+ outputs=[export_file],
442
  )
443
 
444
+ # ── Kerdos Footer ─────────────────────────────────────────────────────────
445
  gr.HTML("""
446
  <div id="kerdos-footer">
447
  &copy; 2024–2026 <strong>Kerdos Infrasoft Private Limited</strong> &nbsp;|&nbsp;
 
456
  """)
457
 
458
  if __name__ == "__main__":
459
+ demo.queue()
460
+ demo.launch(theme=gr.themes.Soft())
rag/document_loader.py CHANGED
@@ -72,5 +72,36 @@ def _load_docx(path: str) -> str:
72
 
73
 
74
  def _load_text(path: str) -> str:
 
 
 
 
75
  with open(path, "r", encoding="utf-8", errors="ignore") as f:
76
  return f.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  def _load_text(path: str) -> str:
75
+ """Load plain text files. CSVs are parsed into natural-language row sentences."""
76
+ ext = Path(path).suffix.lower()
77
+ if ext == ".csv":
78
+ return _load_csv(path)
79
  with open(path, "r", encoding="utf-8", errors="ignore") as f:
80
  return f.read()
81
+
82
+
83
+ def _load_csv(path: str) -> str:
84
+ """
85
+ Parse a CSV file into natural-language sentences.
86
+
87
+ Each row becomes: "ColumnA: value1. ColumnB: value2. ..."
88
+ This makes tabular data semantically meaningful to the LLM rather
89
+ than presenting it as raw comma-separated text.
90
+ """
91
+ import csv
92
+
93
+ rows: list[str] = []
94
+ with open(path, "r", encoding="utf-8", errors="ignore", newline="") as f:
95
+ reader = csv.DictReader(f)
96
+ if reader.fieldnames is None:
97
+ # Fallback to raw text for headerless CSVs
98
+ f.seek(0)
99
+ return f.read()
100
+
101
+ for row in reader:
102
+ parts = [f"{col}: {val.strip()}" for col, val in row.items() if val and val.strip()]
103
+ if parts:
104
+ rows.append(". ".join(parts) + ".")
105
+
106
+ return "\n".join(rows)
107
+