sofzcc commited on
Commit
3f86d04
·
verified ·
1 Parent(s): 7fda255

Update app.py

Browse files

Changes to expand abilities

Files changed (1) hide show
  1. app.py +252 -207
app.py CHANGED
@@ -1,9 +1,10 @@
1
  import os
2
  import re
3
  import json
 
 
4
  from pathlib import Path
5
  from typing import List, Dict, Tuple, Optional
6
- import tempfile
7
 
8
  import numpy as np
9
  import faiss
@@ -14,22 +15,86 @@ from sentence_transformers import SentenceTransformer
14
  import PyPDF2
15
  import docx
16
 
17
- # ----------- Paths -----------
18
- KB_DIR = Path("./kb")
19
- INDEX_DIR = Path("./.index")
20
- INDEX_DIR.mkdir(exist_ok=True, parents=True)
21
-
22
- # ----------- Models (free) -----------
23
- EMBEDDING_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
24
- READER_MODEL_NAME = "deepset/roberta-base-squad2"
25
-
26
- EMBEDDINGS_PATH = INDEX_DIR / "kb_embeddings.npy"
27
- METADATA_PATH = INDEX_DIR / "kb_metadata.json"
28
- FAISS_PATH = INDEX_DIR / "kb_faiss.index"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$", re.MULTILINE)
 
31
 
32
- # ----------- Load Documents -----------
33
  def extract_text_from_pdf(file_path: str) -> str:
34
  """Extract text from PDF file."""
35
  text = ""
@@ -60,10 +125,7 @@ def extract_text_from_txt(file_path: str) -> str:
60
  raise RuntimeError(f"Error reading TXT: {str(e)}")
61
 
62
  def extract_text_from_file(file_path: str) -> Tuple[str, str]:
63
- """
64
- Extract text from uploaded file based on extension.
65
- Returns: (text_content, file_type)
66
- """
67
  ext = Path(file_path).suffix.lower()
68
 
69
  if ext == '.pdf':
@@ -75,6 +137,9 @@ def extract_text_from_file(file_path: str) -> Tuple[str, str]:
75
  else:
76
  raise ValueError(f"Unsupported file type: {ext}. Supported: .pdf, .docx, .txt, .md")
77
 
 
 
 
78
  def read_markdown_files(kb_dir: Path) -> List[Dict]:
79
  """Read all markdown files from the knowledge base directory."""
80
  docs = []
@@ -92,11 +157,13 @@ def read_markdown_files(kb_dir: Path) -> List[Dict]:
92
  })
93
  return docs
94
 
95
- def chunk_markdown(doc: Dict, chunk_chars: int = 800, overlap: int = 200) -> List[Dict]:
96
- """
97
- Split markdown document into overlapping chunks for better retrieval.
98
- Reduced chunk size and increased overlap for more precise matching.
99
- """
 
 
100
  text = doc["text"]
101
  sections = re.split(r"(?=^##\s+|\n##\s+|\n###\s+|^###\s+)", text, flags=re.MULTILINE)
102
  if len(sections) == 1:
@@ -105,19 +172,18 @@ def chunk_markdown(doc: Dict, chunk_chars: int = 800, overlap: int = 200) -> Lis
105
  chunks = []
106
  for sec in sections:
107
  sec = sec.strip()
108
- if not sec or len(sec) < 50: # Skip very short sections
109
  continue
110
 
111
  heading_match = HEADING_RE.search(sec)
112
  section_heading = heading_match.group(2).strip() if heading_match else doc["title"]
113
 
114
- # Better chunking logic
115
  start = 0
116
  while start < len(sec):
117
  end = min(start + chunk_chars, len(sec))
118
  chunk_text = sec[start:end].strip()
119
 
120
- if len(chunk_text) > 50: # Only keep substantial chunks
121
  chunks.append({
122
  "doc_title": doc["title"],
123
  "filename": doc["filename"],
@@ -135,9 +201,9 @@ def chunk_markdown(doc: Dict, chunk_chars: int = 800, overlap: int = 200) -> Lis
135
  # ----------- KB Index -----------
136
  class KBIndex:
137
  def __init__(self):
138
- self.embedder = SentenceTransformer(EMBEDDING_MODEL_NAME)
139
- self.reader_tokenizer = AutoTokenizer.from_pretrained(READER_MODEL_NAME)
140
- self.reader_model = AutoModelForQuestionAnswering.from_pretrained(READER_MODEL_NAME)
141
  self.reader = pipeline(
142
  "question-answering",
143
  model=self.reader_model,
@@ -149,7 +215,12 @@ class KBIndex:
149
  self.index = None
150
  self.embeddings = None
151
  self.metadata = []
152
- self.uploaded_file_active = False # Track if using uploaded file
 
 
 
 
 
153
 
154
  def build(self, kb_dir: Path):
155
  """Build the FAISS index from markdown files."""
@@ -182,20 +253,21 @@ class KBIndex:
182
  self.metadata = all_chunks
183
  self.uploaded_file_active = False
184
 
185
- np.save(EMBEDDINGS_PATH, embeddings)
186
- with open(METADATA_PATH, "w", encoding="utf-8") as f:
 
 
 
187
  json.dump(self.metadata, f, ensure_ascii=False, indent=2)
188
- faiss.write_index(index, str(FAISS_PATH))
189
 
190
  def build_from_uploaded_file(self, file_path: str, filename: str):
191
  """Build temporary index from an uploaded file."""
192
- # Extract text from file
193
  text_content, file_type = extract_text_from_file(file_path)
194
 
195
  if not text_content or len(text_content.strip()) < 100:
196
  raise RuntimeError("File appears to be empty or too short.")
197
 
198
- # Create document structure
199
  doc = {
200
  "filepath": file_path,
201
  "filename": filename,
@@ -203,13 +275,11 @@ class KBIndex:
203
  "text": text_content
204
  }
205
 
206
- # Chunk the document
207
  all_chunks = chunk_markdown(doc)
208
 
209
  if not all_chunks:
210
  raise RuntimeError("Could not extract meaningful content from file.")
211
 
212
- # Build embeddings
213
  texts = [c["content"] for c in all_chunks]
214
  embeddings = self.embedder.encode(
215
  texts,
@@ -219,7 +289,6 @@ class KBIndex:
219
  )
220
  faiss.normalize_L2(embeddings)
221
 
222
- # Create new index
223
  dim = embeddings.shape[1]
224
  index = faiss.IndexFlatIP(dim)
225
  index.add(embeddings)
@@ -233,12 +302,12 @@ class KBIndex:
233
 
234
  def load(self) -> bool:
235
  """Load pre-built index from disk."""
236
- if not (EMBEDDINGS_PATH.exists() and METADATA_PATH.exists() and FAISS_PATH.exists()):
237
  return False
238
- self.embeddings = np.load(EMBEDDINGS_PATH)
239
- with open(METADATA_PATH, "r", encoding="utf-8") as f:
240
  self.metadata = json.load(f)
241
- self.index = faiss.read_index(str(FAISS_PATH))
242
  self.uploaded_file_active = False
243
  return True
244
 
@@ -250,10 +319,7 @@ class KBIndex:
250
  return list(zip(I[0].tolist(), D[0].tolist()))
251
 
252
  def answer(self, question: str, retrieved: List[Tuple[int, float]]) -> Tuple[Optional[str], float, List[Dict], float]:
253
- """
254
- Extract answer from retrieved chunks using QA model.
255
- Returns: (answer_text, qa_score, citations, best_similarity)
256
- """
257
  candidates = []
258
 
259
  for idx, sim in retrieved:
@@ -265,9 +331,7 @@ class KBIndex:
265
  score = float(out.get("score", 0.0))
266
  answer_text = out.get("answer", "").strip()
267
 
268
- # Enhanced answer extraction with context
269
  if answer_text and len(answer_text) > 3:
270
- # Try to expand the answer with surrounding context
271
  expanded_answer = self._expand_answer(answer_text, ctx)
272
 
273
  candidates.append({
@@ -284,11 +348,9 @@ class KBIndex:
284
  if not candidates:
285
  return None, 0.0, [], max([s for _, s in retrieved]) if retrieved else 0.0
286
 
287
- # Sort by combined score (QA score + similarity)
288
  candidates.sort(key=lambda x: x["score"] * 0.7 + x["sim"] * 0.3, reverse=True)
289
  best = candidates[0]
290
 
291
- # Generate citations from top retrieved chunks
292
  citations = []
293
  seen = set()
294
  for idx, _ in retrieved[:3]:
@@ -307,44 +369,34 @@ class KBIndex:
307
  return best["text"], best["score"], citations, best_sim
308
 
309
  def _expand_answer(self, answer: str, context: str, max_chars: int = 300) -> str:
310
- """
311
- Expand the extracted answer with surrounding context to make it more complete.
312
- """
313
- # Find the answer in the context
314
  answer_pos = context.lower().find(answer.lower())
315
 
316
  if answer_pos == -1:
317
  return answer
318
 
319
- # Get sentence boundaries around the answer
320
  start = answer_pos
321
  end = answer_pos + len(answer)
322
 
323
- # Expand backwards to sentence start
324
  while start > 0 and context[start - 1] not in '.!?\n':
325
  start -= 1
326
  if answer_pos - start > max_chars // 2:
327
  break
328
 
329
- # Expand forwards to sentence end
330
  while end < len(context) and context[end] not in '.!?\n':
331
  end += 1
332
  if end - answer_pos > max_chars // 2:
333
  break
334
 
335
- # Include the punctuation
336
  if end < len(context) and context[end] in '.!?':
337
  end += 1
338
 
339
  expanded = context[start:end].strip()
340
 
341
- # If still too short, try to get the full sentence(s)
342
  if len(expanded) < 50:
343
- # Look for complete sentences around the answer
344
  sentences = context.split('.')
345
  for i, sent in enumerate(sentences):
346
  if answer.lower() in sent.lower():
347
- # Get this sentence and maybe the next one
348
  result = sent.strip()
349
  if i + 1 < len(sentences) and len(result) < 100:
350
  result += ". " + sentences[i + 1].strip()
@@ -352,31 +404,18 @@ class KBIndex:
352
 
353
  return expanded
354
 
355
- # Initialize KB
356
- kb = KBIndex()
357
 
358
  def ensure_index():
359
  """Build index on first run or load from cache."""
360
  if not kb.load():
361
- if KB_DIR.exists():
362
- kb.build(KB_DIR)
363
  else:
364
- print(f"Warning: KB directory {KB_DIR} not found. Please create it and add markdown files.")
365
-
366
- ensure_index()
367
-
368
- # ----------- Guardrails -----------
369
- CONFIDENCE_THRESHOLD = 0.25
370
- SIMILARITY_THRESHOLD = 0.35
371
-
372
- QUICK_ACTIONS = [
373
- ("🔗 Connect WhatsApp", "How do I connect my WhatsApp number?"),
374
- ("🔑 Reset Password", "I can't sign in / forgot my password"),
375
- ("⚡ First Automation", "How do I create my first automation?"),
376
- ("💳 Billing & Invoices", "How do I download invoices for billing?"),
377
- ("📸 Fix Instagram", "Why can't I connect Instagram?")
378
- ]
379
 
 
380
  def format_citations(citations: List[Dict]) -> str:
381
  """Format citations as markdown list."""
382
  if not citations:
@@ -391,47 +430,34 @@ def respond(user_msg: str, history: List, uploaded_file_info: str = None) -> str
391
  user_msg = (user_msg or "").strip()
392
 
393
  if not user_msg:
394
- return "👋 How can I help? Ask me anything about the knowledge base, or use a quick action button below."
395
 
396
- # Check if we have an index
397
  if kb.index is None or len(kb.metadata) == 0:
398
- return "❌ I don't know the answer to that but if you have any document with details I can learn about it. Please upload a file using the upload section above."
399
 
400
- # Add context about uploaded file
401
  source_info = f" in the uploaded file" if kb.uploaded_file_active and uploaded_file_info else " in the knowledge base"
402
 
403
- # Retrieve relevant chunks
404
  retrieved = kb.retrieve(user_msg, top_k=6)
405
 
406
  if not retrieved or (retrieved and max([s for _, s in retrieved]) < 0.20):
407
- # Very low similarity - clearly don't know the answer
408
- return (
409
- f"❌ **I don't know the answer to that** but if you have any document with details I can learn about it.\n\n"
410
- f"📤 Upload a relevant document above, and I'll be able to help you find the information you need!"
411
- )
412
 
413
- # Extract answer using QA model
414
  answer, qa_score, citations, best_sim = kb.answer(user_msg, retrieved)
415
 
416
- # Stricter threshold for "I don't know" response
417
  if not answer or qa_score < 0.15 or best_sim < 0.25:
418
  return (
419
- f"❌ **I don't know the answer to that** but if you have any document with details I can learn about it.\n\n"
420
  f"The question seems outside the scope of what I currently know{source_info}. "
421
  f"Try uploading a relevant document, or rephrase your question if you think the information might be here."
422
  )
423
 
424
- # Clean up the answer text
425
  answer = answer.strip()
426
- # Ensure answer ends with proper punctuation
427
  if answer and answer[-1] not in '.!?':
428
  answer += "."
429
 
430
- # Check confidence
431
- low_confidence = (qa_score < CONFIDENCE_THRESHOLD) or (best_sim < SIMILARITY_THRESHOLD)
432
  citations_md = format_citations(citations)
433
 
434
- # Format response based on confidence
435
  if low_confidence:
436
  return (
437
  f"⚠️ **Answer (Low Confidence):**\n\n{answer}\n\n"
@@ -447,6 +473,7 @@ def respond(user_msg: str, history: List, uploaded_file_info: str = None) -> str
447
  f"💡 *Say \"show more details\" to see the full context.*"
448
  )
449
 
 
450
  def process_message(user_input: str, history: List, uploaded_file_info: str) -> Tuple[List, Dict]:
451
  """Process user message and return updated chat history."""
452
  user_input = (user_input or "").strip()
@@ -462,7 +489,7 @@ def process_message(user_input: str, history: List, uploaded_file_info: str) ->
462
 
463
  def process_quick(label: str, history: List, uploaded_file_info: str) -> Tuple[List, Dict]:
464
  """Process quick action button click."""
465
- for btn_label, query in QUICK_ACTIONS:
466
  if label == btn_label:
467
  return process_message(query, history, uploaded_file_info)
468
  return history, gr.update(value="")
@@ -503,129 +530,147 @@ def clear_uploaded_file():
503
  def rebuild_index_handler():
504
  """Rebuild the search index from KB directory."""
505
  try:
506
- kb.build(KB_DIR)
507
  return "✅ Index rebuilt successfully! Ready to answer questions."
508
  except Exception as e:
509
  return f"❌ Error rebuilding index: {str(e)}"
510
 
511
  # ----------- Gradio UI -----------
512
- with gr.Blocks(
513
- title="RAG Knowledge Assistant",
514
- theme=gr.themes.Soft(primary_hue="blue"),
515
- css="""
516
- .contain { max-width: 1200px; margin: auto; }
517
- .quick-btn { min-width: 180px !important; }
518
- .upload-section { border: 2px dashed #ccc; padding: 20px; border-radius: 8px; }
519
- """
520
- ) as demo:
521
 
522
- # State to track uploaded file
523
- uploaded_file_state = gr.State("")
524
-
525
- # Header
526
- gr.Markdown(
527
- """
528
- # 🤖 RAG Knowledge Assistant
529
- ### AI-powered Q&A with document retrieval and citation
530
- Upload a document or use the knowledge base to get answers backed by relevant sources.
531
  """
532
- )
533
-
534
- # File upload section
535
- with gr.Row():
536
- with gr.Column(scale=1):
537
- gr.Markdown("### 📤 Upload Document")
538
- file_upload = gr.File(
539
- label="Upload PDF, DOCX, TXT, or MD file",
540
- file_types=[".pdf", ".docx", ".txt", ".md"],
541
- type="filepath"
542
- )
543
- upload_status = gr.Markdown("ℹ️ Upload a file to ask questions about it.")
544
- with gr.Row():
545
- clear_btn = gr.Button("🔄 Clear & Use KB", variant="secondary", size="sm")
546
-
547
- # Main chat interface
548
- with gr.Row():
549
- with gr.Column(scale=1):
550
- chat = gr.Chatbot(
551
- height=500,
552
- show_copy_button=True,
553
- type="messages",
554
- avatar_images=(None, "https://em-content.zobj.net/source/twitter/376/robot_1f916.png")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
  )
556
-
557
  with gr.Row():
558
- txt = gr.Textbox(
559
- placeholder="💬 Ask a question about the document or knowledge base...",
560
- scale=9,
561
- show_label=False,
562
- container=False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  )
564
- send = gr.Button("Send", variant="primary", scale=1)
565
-
566
- # Quick action buttons (only for KB mode)
567
- with gr.Accordion("⚡ Quick Actions (Knowledge Base)", open=False):
568
- with gr.Row():
569
- quick_buttons = []
570
- for label, _ in QUICK_ACTIONS:
571
- btn = gr.Button(label, elem_classes="quick-btn", size="sm")
572
- quick_buttons.append((btn, label))
573
-
574
- # Admin section
575
- with gr.Accordion("🔧 Admin Panel", open=False):
576
  gr.Markdown(
577
  """
578
- **Rebuild Index:** Use this after adding or modifying files in the `/kb` directory.
579
- The system will re-scan all markdown files and update the search index.
 
 
 
 
580
  """
581
  )
582
- with gr.Row():
583
- rebuild_btn = gr.Button("🔄 Rebuild KB Index", variant="secondary")
584
- status_msg = gr.Markdown("")
585
 
586
- # Event handlers
587
- file_upload.change(
588
- handle_file_upload,
589
- inputs=[file_upload],
590
- outputs=[upload_status, uploaded_file_state]
591
- )
592
-
593
- clear_btn.click(
594
- clear_uploaded_file,
595
- outputs=[upload_status, uploaded_file_state, file_upload]
596
- )
597
-
598
- send.click(
599
- process_message,
600
- inputs=[txt, chat, uploaded_file_state],
601
- outputs=[chat, txt]
602
- )
603
- txt.submit(
604
- process_message,
605
- inputs=[txt, chat, uploaded_file_state],
606
- outputs=[chat, txt]
607
- )
608
 
609
- for btn, label in quick_buttons:
610
- btn.click(
611
- process_quick,
612
- inputs=[gr.State(label), chat, uploaded_file_state],
613
- outputs=[chat, txt]
614
- )
615
 
616
- rebuild_btn.click(rebuild_index_handler, outputs=status_msg)
 
 
617
 
618
- # Footer
619
- gr.Markdown(
620
- """
621
- ---
622
- 💡 **Tips:**
623
- - Upload a document to ask questions specifically about that file
624
- - Use "Clear & Use KB" to switch back to the knowledge base
625
- - Be specific in your questions for better results
626
- - Check the cited sources for full context
627
- """
628
- )
629
-
630
- if __name__ == "__main__":
631
  demo.launch()
 
1
  import os
2
  import re
3
  import json
4
+ import yaml
5
+ import argparse
6
  from pathlib import Path
7
  from typing import List, Dict, Tuple, Optional
 
8
 
9
  import numpy as np
10
  import faiss
 
15
  import PyPDF2
16
  import docx
17
 
18
+ # ----------- Configuration Loader -----------
19
+ class Config:
20
+ """Load and manage configuration from YAML file."""
21
+
22
+ def __init__(self, config_path: str = "config.yaml"):
23
+ with open(config_path, 'r', encoding='utf-8') as f:
24
+ self.data = yaml.safe_load(f)
25
+
26
+ @property
27
+ def client_name(self) -> str:
28
+ return self.data.get('client', {}).get('name', 'RAG Assistant')
29
+
30
+ @property
31
+ def client_description(self) -> str:
32
+ return self.data.get('client', {}).get('description', 'AI-powered Q&A with document retrieval and citation')
33
+
34
+ @property
35
+ def client_logo(self) -> Optional[str]:
36
+ return self.data.get('client', {}).get('logo')
37
+
38
+ @property
39
+ def theme_color(self) -> str:
40
+ return self.data.get('client', {}).get('theme_color', 'blue')
41
+
42
+ @property
43
+ def kb_directory(self) -> Path:
44
+ return Path(self.data.get('kb', {}).get('directory', './kb'))
45
+
46
+ @property
47
+ def index_directory(self) -> Path:
48
+ return Path(self.data.get('kb', {}).get('index_directory', './.index'))
49
+
50
+ @property
51
+ def embedding_model(self) -> str:
52
+ return self.data.get('models', {}).get('embedding', 'sentence-transformers/all-MiniLM-L6-v2')
53
+
54
+ @property
55
+ def qa_model(self) -> str:
56
+ return self.data.get('models', {}).get('qa', 'deepset/roberta-base-squad2')
57
+
58
+ @property
59
+ def confidence_threshold(self) -> float:
60
+ return self.data.get('thresholds', {}).get('confidence', 0.25)
61
+
62
+ @property
63
+ def similarity_threshold(self) -> float:
64
+ return self.data.get('thresholds', {}).get('similarity', 0.35)
65
+
66
+ @property
67
+ def chunk_size(self) -> int:
68
+ return self.data.get('chunking', {}).get('chunk_size', 800)
69
+
70
+ @property
71
+ def chunk_overlap(self) -> int:
72
+ return self.data.get('chunking', {}).get('overlap', 200)
73
+
74
+ @property
75
+ def quick_actions(self) -> List[Tuple[str, str]]:
76
+ actions = self.data.get('quick_actions', [])
77
+ return [(a['label'], a['query']) for a in actions]
78
+
79
+ @property
80
+ def welcome_message(self) -> str:
81
+ return self.data.get('messages', {}).get('welcome',
82
+ '👋 How can I help? Ask me anything or use a quick action button below.')
83
+
84
+ @property
85
+ def no_answer_message(self) -> str:
86
+ return self.data.get('messages', {}).get('no_answer',
87
+ "❌ **I don't know the answer to that** but if you have any document with details I can learn about it.")
88
+
89
+ @property
90
+ def upload_prompt(self) -> str:
91
+ return self.data.get('messages', {}).get('upload_prompt',
92
+ '📤 Upload a relevant document above, and I\'ll be able to help you find the information you need!')
93
 
94
+ # Global config instance
95
+ config = None
96
 
97
+ # ----------- Document Extraction -----------
98
  def extract_text_from_pdf(file_path: str) -> str:
99
  """Extract text from PDF file."""
100
  text = ""
 
125
  raise RuntimeError(f"Error reading TXT: {str(e)}")
126
 
127
  def extract_text_from_file(file_path: str) -> Tuple[str, str]:
128
+ """Extract text from uploaded file based on extension."""
 
 
 
129
  ext = Path(file_path).suffix.lower()
130
 
131
  if ext == '.pdf':
 
137
  else:
138
  raise ValueError(f"Unsupported file type: {ext}. Supported: .pdf, .docx, .txt, .md")
139
 
140
+ # ----------- Document Processing -----------
141
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$", re.MULTILINE)
142
+
143
  def read_markdown_files(kb_dir: Path) -> List[Dict]:
144
  """Read all markdown files from the knowledge base directory."""
145
  docs = []
 
157
  })
158
  return docs
159
 
160
+ def chunk_markdown(doc: Dict, chunk_chars: int = None, overlap: int = None) -> List[Dict]:
161
+ """Split markdown document into overlapping chunks."""
162
+ if chunk_chars is None:
163
+ chunk_chars = config.chunk_size
164
+ if overlap is None:
165
+ overlap = config.chunk_overlap
166
+
167
  text = doc["text"]
168
  sections = re.split(r"(?=^##\s+|\n##\s+|\n###\s+|^###\s+)", text, flags=re.MULTILINE)
169
  if len(sections) == 1:
 
172
  chunks = []
173
  for sec in sections:
174
  sec = sec.strip()
175
+ if not sec or len(sec) < 50:
176
  continue
177
 
178
  heading_match = HEADING_RE.search(sec)
179
  section_heading = heading_match.group(2).strip() if heading_match else doc["title"]
180
 
 
181
  start = 0
182
  while start < len(sec):
183
  end = min(start + chunk_chars, len(sec))
184
  chunk_text = sec[start:end].strip()
185
 
186
+ if len(chunk_text) > 50:
187
  chunks.append({
188
  "doc_title": doc["title"],
189
  "filename": doc["filename"],
 
201
  # ----------- KB Index -----------
202
  class KBIndex:
203
  def __init__(self):
204
+ self.embedder = SentenceTransformer(config.embedding_model)
205
+ self.reader_tokenizer = AutoTokenizer.from_pretrained(config.qa_model)
206
+ self.reader_model = AutoModelForQuestionAnswering.from_pretrained(config.qa_model)
207
  self.reader = pipeline(
208
  "question-answering",
209
  model=self.reader_model,
 
215
  self.index = None
216
  self.embeddings = None
217
  self.metadata = []
218
+ self.uploaded_file_active = False
219
+
220
+ # Paths based on config
221
+ self.embeddings_path = config.index_directory / "kb_embeddings.npy"
222
+ self.metadata_path = config.index_directory / "kb_metadata.json"
223
+ self.faiss_path = config.index_directory / "kb_faiss.index"
224
 
225
  def build(self, kb_dir: Path):
226
  """Build the FAISS index from markdown files."""
 
253
  self.metadata = all_chunks
254
  self.uploaded_file_active = False
255
 
256
+ # Ensure index directory exists
257
+ config.index_directory.mkdir(exist_ok=True, parents=True)
258
+
259
+ np.save(self.embeddings_path, embeddings)
260
+ with open(self.metadata_path, "w", encoding="utf-8") as f:
261
  json.dump(self.metadata, f, ensure_ascii=False, indent=2)
262
+ faiss.write_index(index, str(self.faiss_path))
263
 
264
  def build_from_uploaded_file(self, file_path: str, filename: str):
265
  """Build temporary index from an uploaded file."""
 
266
  text_content, file_type = extract_text_from_file(file_path)
267
 
268
  if not text_content or len(text_content.strip()) < 100:
269
  raise RuntimeError("File appears to be empty or too short.")
270
 
 
271
  doc = {
272
  "filepath": file_path,
273
  "filename": filename,
 
275
  "text": text_content
276
  }
277
 
 
278
  all_chunks = chunk_markdown(doc)
279
 
280
  if not all_chunks:
281
  raise RuntimeError("Could not extract meaningful content from file.")
282
 
 
283
  texts = [c["content"] for c in all_chunks]
284
  embeddings = self.embedder.encode(
285
  texts,
 
289
  )
290
  faiss.normalize_L2(embeddings)
291
 
 
292
  dim = embeddings.shape[1]
293
  index = faiss.IndexFlatIP(dim)
294
  index.add(embeddings)
 
302
 
303
  def load(self) -> bool:
304
  """Load pre-built index from disk."""
305
+ if not (self.embeddings_path.exists() and self.metadata_path.exists() and self.faiss_path.exists()):
306
  return False
307
+ self.embeddings = np.load(self.embeddings_path)
308
+ with open(self.metadata_path, "r", encoding="utf-8") as f:
309
  self.metadata = json.load(f)
310
+ self.index = faiss.read_index(str(self.faiss_path))
311
  self.uploaded_file_active = False
312
  return True
313
 
 
319
  return list(zip(I[0].tolist(), D[0].tolist()))
320
 
321
  def answer(self, question: str, retrieved: List[Tuple[int, float]]) -> Tuple[Optional[str], float, List[Dict], float]:
322
+ """Extract answer from retrieved chunks using QA model."""
 
 
 
323
  candidates = []
324
 
325
  for idx, sim in retrieved:
 
331
  score = float(out.get("score", 0.0))
332
  answer_text = out.get("answer", "").strip()
333
 
 
334
  if answer_text and len(answer_text) > 3:
 
335
  expanded_answer = self._expand_answer(answer_text, ctx)
336
 
337
  candidates.append({
 
348
  if not candidates:
349
  return None, 0.0, [], max([s for _, s in retrieved]) if retrieved else 0.0
350
 
 
351
  candidates.sort(key=lambda x: x["score"] * 0.7 + x["sim"] * 0.3, reverse=True)
352
  best = candidates[0]
353
 
 
354
  citations = []
355
  seen = set()
356
  for idx, _ in retrieved[:3]:
 
369
  return best["text"], best["score"], citations, best_sim
370
 
371
  def _expand_answer(self, answer: str, context: str, max_chars: int = 300) -> str:
372
+ """Expand the extracted answer with surrounding context."""
 
 
 
373
  answer_pos = context.lower().find(answer.lower())
374
 
375
  if answer_pos == -1:
376
  return answer
377
 
 
378
  start = answer_pos
379
  end = answer_pos + len(answer)
380
 
 
381
  while start > 0 and context[start - 1] not in '.!?\n':
382
  start -= 1
383
  if answer_pos - start > max_chars // 2:
384
  break
385
 
 
386
  while end < len(context) and context[end] not in '.!?\n':
387
  end += 1
388
  if end - answer_pos > max_chars // 2:
389
  break
390
 
 
391
  if end < len(context) and context[end] in '.!?':
392
  end += 1
393
 
394
  expanded = context[start:end].strip()
395
 
 
396
  if len(expanded) < 50:
 
397
  sentences = context.split('.')
398
  for i, sent in enumerate(sentences):
399
  if answer.lower() in sent.lower():
 
400
  result = sent.strip()
401
  if i + 1 < len(sentences) and len(result) < 100:
402
  result += ". " + sentences[i + 1].strip()
 
404
 
405
  return expanded
406
 
407
+ # Initialize KB (will be done after config is loaded)
408
+ kb = None
409
 
410
  def ensure_index():
411
  """Build index on first run or load from cache."""
412
  if not kb.load():
413
+ if config.kb_directory.exists():
414
+ kb.build(config.kb_directory)
415
  else:
416
+ print(f"Warning: KB directory {config.kb_directory} not found.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
 
418
+ # ----------- Response Generation -----------
419
  def format_citations(citations: List[Dict]) -> str:
420
  """Format citations as markdown list."""
421
  if not citations:
 
430
  user_msg = (user_msg or "").strip()
431
 
432
  if not user_msg:
433
+ return config.welcome_message
434
 
 
435
  if kb.index is None or len(kb.metadata) == 0:
436
+ return f"{config.no_answer_message}\n\n{config.upload_prompt}"
437
 
 
438
  source_info = f" in the uploaded file" if kb.uploaded_file_active and uploaded_file_info else " in the knowledge base"
439
 
 
440
  retrieved = kb.retrieve(user_msg, top_k=6)
441
 
442
  if not retrieved or (retrieved and max([s for _, s in retrieved]) < 0.20):
443
+ return f"{config.no_answer_message}\n\n{config.upload_prompt}"
 
 
 
 
444
 
 
445
  answer, qa_score, citations, best_sim = kb.answer(user_msg, retrieved)
446
 
 
447
  if not answer or qa_score < 0.15 or best_sim < 0.25:
448
  return (
449
+ f"{config.no_answer_message}\n\n"
450
  f"The question seems outside the scope of what I currently know{source_info}. "
451
  f"Try uploading a relevant document, or rephrase your question if you think the information might be here."
452
  )
453
 
 
454
  answer = answer.strip()
 
455
  if answer and answer[-1] not in '.!?':
456
  answer += "."
457
 
458
+ low_confidence = (qa_score < config.confidence_threshold) or (best_sim < config.similarity_threshold)
 
459
  citations_md = format_citations(citations)
460
 
 
461
  if low_confidence:
462
  return (
463
  f"⚠️ **Answer (Low Confidence):**\n\n{answer}\n\n"
 
473
  f"💡 *Say \"show more details\" to see the full context.*"
474
  )
475
 
476
+ # ----------- UI Handlers -----------
477
  def process_message(user_input: str, history: List, uploaded_file_info: str) -> Tuple[List, Dict]:
478
  """Process user message and return updated chat history."""
479
  user_input = (user_input or "").strip()
 
489
 
490
  def process_quick(label: str, history: List, uploaded_file_info: str) -> Tuple[List, Dict]:
491
  """Process quick action button click."""
492
+ for btn_label, query in config.quick_actions:
493
  if label == btn_label:
494
  return process_message(query, history, uploaded_file_info)
495
  return history, gr.update(value="")
 
530
  def rebuild_index_handler():
531
  """Rebuild the search index from KB directory."""
532
  try:
533
+ kb.build(config.kb_directory)
534
  return "✅ Index rebuilt successfully! Ready to answer questions."
535
  except Exception as e:
536
  return f"❌ Error rebuilding index: {str(e)}"
537
 
538
  # ----------- Gradio UI -----------
539
+ def create_interface():
540
+ """Create Gradio interface with configuration."""
 
 
 
 
 
 
 
541
 
542
+ with gr.Blocks(
543
+ title=config.client_name,
544
+ theme=gr.themes.Soft(primary_hue=config.theme_color),
545
+ css="""
546
+ .contain { max-width: 1200px; margin: auto; }
547
+ .quick-btn { min-width: 180px !important; }
 
 
 
548
  """
549
+ ) as demo:
550
+
551
+ uploaded_file_state = gr.State("")
552
+
553
+ # Header
554
+ header_text = f"# 🤖 {config.client_name}\n### {config.client_description}"
555
+ if config.client_logo:
556
+ header_text += f"\n![Logo]({config.client_logo})"
557
+
558
+ gr.Markdown(header_text)
559
+
560
+ # File upload section
561
+ with gr.Row():
562
+ with gr.Column(scale=1):
563
+ gr.Markdown("### 📤 Upload Document")
564
+ file_upload = gr.File(
565
+ label="Upload PDF, DOCX, TXT, or MD file",
566
+ file_types=[".pdf", ".docx", ".txt", ".md"],
567
+ type="filepath"
568
+ )
569
+ upload_status = gr.Markdown("ℹ️ Upload a file to ask questions about it.")
570
+ with gr.Row():
571
+ clear_btn = gr.Button("🔄 Clear & Use KB", variant="secondary", size="sm")
572
+
573
+ # Main chat interface
574
+ with gr.Row():
575
+ with gr.Column(scale=1):
576
+ chat = gr.Chatbot(
577
+ height=500,
578
+ show_copy_button=True,
579
+ type="messages",
580
+ avatar_images=(None, "https://em-content.zobj.net/source/twitter/376/robot_1f916.png")
581
+ )
582
+
583
+ with gr.Row():
584
+ txt = gr.Textbox(
585
+ placeholder="💬 Ask a question about the document or knowledge base...",
586
+ scale=9,
587
+ show_label=False,
588
+ container=False
589
+ )
590
+ send = gr.Button("Send", variant="primary", scale=1)
591
+
592
+ # Quick action buttons (if configured)
593
+ if config.quick_actions:
594
+ with gr.Accordion("⚡ Quick Actions", open=False):
595
+ with gr.Row():
596
+ quick_buttons = []
597
+ for label, _ in config.quick_actions:
598
+ btn = gr.Button(label, elem_classes="quick-btn", size="sm")
599
+ quick_buttons.append((btn, label))
600
+
601
+ # Admin section
602
+ with gr.Accordion("🔧 Admin Panel", open=False):
603
+ gr.Markdown(
604
+ f"""
605
+ **Rebuild Index:** Use this after adding or modifying files in the `{config.kb_directory}` directory.
606
+ The system will re-scan all markdown files and update the search index.
607
+ """
608
  )
 
609
  with gr.Row():
610
+ rebuild_btn = gr.Button("🔄 Rebuild KB Index", variant="secondary")
611
+ status_msg = gr.Markdown("")
612
+
613
+ # Event handlers
614
+ file_upload.change(
615
+ handle_file_upload,
616
+ inputs=[file_upload],
617
+ outputs=[upload_status, uploaded_file_state]
618
+ )
619
+
620
+ clear_btn.click(
621
+ clear_uploaded_file,
622
+ outputs=[upload_status, uploaded_file_state, file_upload]
623
+ )
624
+
625
+ send.click(
626
+ process_message,
627
+ inputs=[txt, chat, uploaded_file_state],
628
+ outputs=[chat, txt]
629
+ )
630
+ txt.submit(
631
+ process_message,
632
+ inputs=[txt, chat, uploaded_file_state],
633
+ outputs=[chat, txt]
634
+ )
635
+
636
+ if config.quick_actions:
637
+ for btn, label in quick_buttons:
638
+ btn.click(
639
+ process_quick,
640
+ inputs=[gr.State(label), chat, uploaded_file_state],
641
+ outputs=[chat, txt]
642
  )
643
+
644
+ rebuild_btn.click(rebuild_index_handler, outputs=status_msg)
645
+
646
+ # Footer
 
 
 
 
 
 
 
 
647
  gr.Markdown(
648
  """
649
+ ---
650
+ 💡 **Tips:**
651
+ - Upload a document to ask questions specifically about that file
652
+ - Use "Clear & Use KB" to switch back to the knowledge base
653
+ - Be specific in your questions for better results
654
+ - Check the cited sources for full context
655
  """
656
  )
 
 
 
657
 
658
+ return demo
659
+
660
+ # ----------- Main Entry Point -----------
661
+ if __name__ == "__main__":
662
+ parser = argparse.ArgumentParser(description='Configurable RAG Assistant')
663
+ parser.add_argument('--config', type=str, default='config.yaml',
664
+ help='Path to configuration YAML file (default: config.yaml)')
665
+ args = parser.parse_args()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
 
667
+ # Load configuration
668
+ config = Config(args.config)
 
 
 
 
669
 
670
+ # Initialize KB with config
671
+ kb = KBIndex()
672
+ ensure_index()
673
 
674
+ # Create and launch interface
675
+ demo = create_interface()
 
 
 
 
 
 
 
 
 
 
 
676
  demo.launch()