sofzcc commited on
Commit
49823a5
Β·
verified Β·
1 Parent(s): c72c932

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +83 -66
app.py CHANGED
@@ -13,15 +13,16 @@ from sentence_transformers import SentenceTransformer
13
 
14
  # -----------------------------
15
  # LOGGING
16
- # -----------------------------
17
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
18
  logger = logging.getLogger(__name__)
19
 
20
  # -----------------------------
21
  # CONFIG β€” loaded from config.yaml, with safe defaults
22
- # -----------------------------
23
  _CONFIG_PATH = "./config.yaml"
24
 
 
25
  def load_config(path: str = _CONFIG_PATH) -> dict:
26
  """Load configuration from YAML file with fallback defaults."""
27
  defaults = {
@@ -40,7 +41,6 @@ def load_config(path: str = _CONFIG_PATH) -> dict:
40
  try:
41
  with open(path, "r", encoding="utf-8") as f:
42
  user_cfg = yaml.safe_load(f) or {}
43
- # Deep merge user config over defaults
44
  for key, val in user_cfg.items():
45
  if isinstance(val, dict) and key in defaults:
46
  defaults[key].update(val)
@@ -53,6 +53,7 @@ def load_config(path: str = _CONFIG_PATH) -> dict:
53
  logger.warning(f"{path} not found. Using default configuration.")
54
  return defaults
55
 
 
56
  CONFIG = load_config()
57
 
58
  KB_DIR = CONFIG["kb"]["directory"]
@@ -64,15 +65,15 @@ MIN_SIMILARITY = CONFIG["retrieval"]["min_similarity"]
64
  CONTEXT_CHARS = CONFIG["retrieval"]["context_chars"]
65
  LLM_MODEL_NAME = CONFIG["llm"]["model"]
66
  MAX_NEW_TOKENS = CONFIG["llm"]["max_new_tokens"]
67
- MAX_QUERY_CHARS = 1000 # Guard against excessively long user input
68
 
69
  # Paths for caching embeddings
70
  _CACHE_DIR = "./.cache"
71
  _CACHE_FILE = os.path.join(_CACHE_DIR, "kb_embeddings.npz")
72
 
73
- # -----------------------------
74
  # UTILITIES
75
- # -----------------------------
76
 
77
  def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
78
  """
@@ -82,12 +83,14 @@ def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVE
82
  if not text:
83
  return []
84
 
85
- # Split into sentences using punctuation as delimiters
 
 
86
  sentence_endings = re.compile(r'(?<=[.!?])\s+')
87
  sentences = sentence_endings.split(text.strip())
88
 
89
  chunks = []
90
- current_chunk = []
91
  current_len = 0
92
 
93
  for sentence in sentences:
@@ -102,7 +105,6 @@ def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVE
102
  chunks.append(" ".join(current_chunk))
103
  current_chunk = []
104
  current_len = 0
105
- # Hard split the long sentence with overlap
106
  start = 0
107
  while start < slen:
108
  end = min(start + chunk_size, slen)
@@ -114,7 +116,7 @@ def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVE
114
  if current_chunk:
115
  chunks.append(" ".join(current_chunk))
116
  # Carry over last sentence(s) for overlap
117
- overlap_words = []
118
  overlap_len = 0
119
  for s in reversed(current_chunk):
120
  if overlap_len + len(s) + 1 > overlap:
@@ -138,19 +140,25 @@ def clean_context(text: str) -> str:
138
  Clean text for LLM context: remove only Markdown heading markers (leading #),
139
  collapse whitespace, but preserve legitimate # characters mid-text.
140
  """
141
- # Only strip leading '#' at the start of lines (Markdown headings)
142
  text = re.sub(r'(?m)^#+\s*', '', text)
143
- # Collapse multiple whitespace/newlines
144
  text = re.sub(r'\s+', ' ', text)
145
  return text.strip()
146
 
147
 
148
  def kb_fingerprint(kb_dir: str) -> str:
149
- """Return an MD5 fingerprint of all KB filenames + modification times for cache invalidation."""
 
 
 
 
150
  paths = sorted(
151
  glob.glob(os.path.join(kb_dir, "*.txt")) +
152
  glob.glob(os.path.join(kb_dir, "*.md"))
153
  )
 
 
 
 
154
  sig = "|".join(f"{p}:{os.path.getmtime(p)}" for p in paths)
155
  return hashlib.md5(sig.encode()).hexdigest()
156
 
@@ -202,15 +210,16 @@ def load_kb_texts(kb_dir: str = KB_DIR) -> List[Tuple[str, str]]:
202
  return texts
203
 
204
 
205
- # -----------------------------
206
  # KB INDEX
207
- # -----------------------------
208
 
209
  class KBIndex:
210
  def __init__(self, kb_dir: str = KB_DIR, model_name: str = EMBEDDING_MODEL_NAME):
211
  self.kb_dir = kb_dir
212
  self.model_name = model_name
213
- self._lock = threading.Lock() # Protect concurrent generate calls
 
214
 
215
  logger.info("Loading embedding model...")
216
  self.model = SentenceTransformer(model_name)
@@ -236,7 +245,10 @@ class KBIndex:
236
  if not force and os.path.exists(_CACHE_FILE):
237
  try:
238
  cached = np.load(_CACHE_FILE, allow_pickle=True)
239
- if str(cached.get("fingerprint")) == fingerprint:
 
 
 
240
  self.chunks = list(cached["chunks"])
241
  self.chunk_sources = list(cached["chunk_sources"])
242
  self.embeddings = cached["embeddings"]
@@ -246,8 +258,8 @@ class KBIndex:
246
  except Exception as e:
247
  logger.warning(f"Cache load failed: {e}. Rebuilding index.")
248
 
249
- all_chunks = []
250
- all_sources = []
251
 
252
  for source_name, content in texts:
253
  for chunk in chunk_text(content):
@@ -306,11 +318,10 @@ class KBIndex:
306
  [query], show_progress_bar=False, convert_to_numpy=True
307
  )[0]
308
 
309
- # Correct cosine similarity: single epsilon, applied once
310
  norm_docs = np.linalg.norm(self.embeddings, axis=1)
311
  norm_query = np.linalg.norm(query_vec)
312
  denom = norm_docs * norm_query
313
- denom = np.where(denom == 0, 1e-10, denom) # avoid divide-by-zero cleanly
314
  scores = np.dot(self.embeddings, query_vec) / denom
315
 
316
  top_idx = np.argsort(scores)[::-1][:top_k]
@@ -320,9 +331,16 @@ class KBIndex:
320
  ]
321
 
322
 
323
- # -----------------------------
324
  # LLM SETUP
325
- # -----------------------------
 
 
 
 
 
 
 
326
 
327
  logger.info("Initializing KB index...")
328
  kb_index = KBIndex(kb_dir=KB_DIR, model_name=EMBEDDING_MODEL_NAME)
@@ -331,34 +349,41 @@ logger.info("Loading LLM for answer generation...")
331
  llm_available = False
332
  llm_tokenizer = None
333
  llm_model = None
334
- _llm_lock = threading.Lock() # Serialize LLM generate calls
335
 
336
- try:
337
- from transformers import AutoTokenizer, AutoModelForCausalLM
338
- import torch
339
 
340
- logger.info(f"Loading {LLM_MODEL_NAME}...")
341
- llm_tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_NAME)
342
- llm_model = AutoModelForCausalLM.from_pretrained(
343
- LLM_MODEL_NAME,
344
- torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
345
- device_map="auto" if torch.cuda.is_available() else None,
346
- )
347
 
348
- if not torch.cuda.is_available():
349
- llm_model = llm_model.to("cpu")
 
 
 
 
 
 
350
 
351
- llm_model.eval()
352
- llm_available = True
353
- logger.info(f"βœ… LLM loaded on {'GPU' if torch.cuda.is_available() else 'CPU'}")
354
 
355
- except Exception as e:
356
- logger.warning(f"⚠️ Could not load LLM: {e}. Will use fallback retrieval mode.")
 
 
357
 
358
 
359
- # -----------------------------
360
  # CHAT LOGIC
361
- # -----------------------------
362
 
363
  def normalise_history(history: list) -> List[Tuple[str, str]]:
364
  """
@@ -369,7 +394,6 @@ def normalise_history(history: list) -> List[Tuple[str, str]]:
369
  if not history:
370
  return []
371
  if isinstance(history[0], dict):
372
- # Gradio 6: walk role-by-role and pair up user + assistant turns
373
  pairs = []
374
  i = 0
375
  while i < len(history):
@@ -391,7 +415,7 @@ def normalise_history(history: list) -> List[Tuple[str, str]]:
391
 
392
  def build_llm_prompt(query: str, context: str, history: list) -> str:
393
  """
394
- Build a TinyLlama chat prompt that includes recent conversation history
395
  so the model can resolve follow-up questions.
396
  """
397
  system = (
@@ -401,7 +425,6 @@ def build_llm_prompt(query: str, context: str, history: list) -> str:
401
  "If the context doesn't contain enough information, say so honestly."
402
  )
403
 
404
- # Include up to last 3 history turns to stay within context budget
405
  history_text = ""
406
  for user_msg, asst_msg in normalise_history(history)[-3:]:
407
  if user_msg:
@@ -409,7 +432,7 @@ def build_llm_prompt(query: str, context: str, history: list) -> str:
409
  if asst_msg:
410
  history_text += f"<|assistant|>\n{asst_msg}\n</s>\n"
411
 
412
- prompt = (
413
  f"<|system|>\n{system}\n</s>\n"
414
  f"{history_text}"
415
  f"<|user|>\n"
@@ -418,7 +441,6 @@ def build_llm_prompt(query: str, context: str, history: list) -> str:
418
  f"</s>\n"
419
  f"<|assistant|>\n"
420
  )
421
- return prompt
422
 
423
 
424
  def generate_answer_with_llm(
@@ -431,11 +453,11 @@ def generate_answer_with_llm(
431
  if not llm_available:
432
  return None
433
 
434
- import torch
435
-
436
  prompt = build_llm_prompt(query, context, history)
437
 
438
  try:
 
 
439
  with _llm_lock:
440
  inputs = llm_tokenizer(
441
  prompt,
@@ -454,10 +476,11 @@ def generate_answer_with_llm(
454
  temperature=0.7,
455
  top_p=0.9,
456
  do_sample=True,
457
- pad_token_id=llm_tokenizer.eos_token_id,
458
  )
459
 
460
- full_response = llm_tokenizer.decode(outputs[0], skip_special_tokens=True)
 
461
 
462
  # Extract only the final assistant turn
463
  if "<|assistant|>" in full_response:
@@ -466,7 +489,6 @@ def generate_answer_with_llm(
466
  answer = full_response.strip()
467
 
468
  answer = answer.replace("</s>", "").strip()
469
-
470
  sources_text = ", ".join(sources)
471
  return f"{answer}\n\n---\nπŸ“š **Sources:** {sources_text}"
472
 
@@ -481,21 +503,19 @@ def format_fallback_answer(results: List[Tuple[str, str, float]]) -> str:
481
  return CONFIG["messages"]["no_answer"]
482
 
483
  parts = []
484
- seen_sources = []
485
 
486
  for chunk, source, score in results:
487
  cleaned = clean_context(chunk)
488
  label = "πŸ₯‡" if not parts else "πŸ’‘"
489
  confidence = (
490
- "High" if score >= 0.6 else
491
  "Medium" if score >= 0.4 else
492
  "Low"
493
  )
494
  parts.append(
495
  f"{label} **From {source}** *(relevance: {confidence} β€” {score:.2f})*\n\n{cleaned}"
496
  )
497
- if source not in seen_sources:
498
- seen_sources.append(source)
499
 
500
  return "\n\n---\n\n".join(parts)
501
 
@@ -531,10 +551,9 @@ def build_answer(query: str, history: list) -> str:
531
  "Please try being more specific or using different keywords."
532
  )
533
 
534
- # Build context from ALL filtered results (not just top 2), capped by CONTEXT_CHARS
535
  context_parts = []
536
- sources = []
537
- for chunk, source, _ in filtered: # uses all TOP_K results
538
  cleaned = clean_context(chunk)
539
  context_parts.append(cleaned)
540
  if source not in sources:
@@ -555,7 +574,6 @@ def chat_respond(message: str, history: list) -> str:
555
  if not message or not message.strip():
556
  return "Please ask me a question about the knowledge base."
557
 
558
- # Guard against excessively long queries
559
  query = message.strip()
560
  if len(query) > MAX_QUERY_CHARS:
561
  query = query[:MAX_QUERY_CHARS]
@@ -568,9 +586,9 @@ def chat_respond(message: str, history: list) -> str:
568
  return "Sorry, something went wrong while processing your question. Please try again."
569
 
570
 
571
- # -----------------------------
572
  # GRADIO UI
573
- # -----------------------------
574
 
575
  description = f"""
576
  πŸš€ **{CONFIG['client']['name']}**
@@ -599,12 +617,11 @@ chat_interface = gr.ChatInterface(
599
  description=description,
600
  examples=examples,
601
  cache_examples=False,
602
- type="messages", # Use modern messages format for proper history passing
603
  )
604
 
605
- # -----------------------------
606
  # LAUNCH
607
- # -----------------------------
608
 
609
  if __name__ == "__main__":
610
  is_huggingface = os.getenv("SPACE_ID") is not None
 
13
 
14
  # -----------------------------
15
  # LOGGING
16
+ # -----------------------------
17
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
18
  logger = logging.getLogger(__name__)
19
 
20
  # -----------------------------
21
  # CONFIG β€” loaded from config.yaml, with safe defaults
22
+ # -----------------------------
23
  _CONFIG_PATH = "./config.yaml"
24
 
25
+
26
  def load_config(path: str = _CONFIG_PATH) -> dict:
27
  """Load configuration from YAML file with fallback defaults."""
28
  defaults = {
 
41
  try:
42
  with open(path, "r", encoding="utf-8") as f:
43
  user_cfg = yaml.safe_load(f) or {}
 
44
  for key, val in user_cfg.items():
45
  if isinstance(val, dict) and key in defaults:
46
  defaults[key].update(val)
 
53
  logger.warning(f"{path} not found. Using default configuration.")
54
  return defaults
55
 
56
+
57
  CONFIG = load_config()
58
 
59
  KB_DIR = CONFIG["kb"]["directory"]
 
65
  CONTEXT_CHARS = CONFIG["retrieval"]["context_chars"]
66
  LLM_MODEL_NAME = CONFIG["llm"]["model"]
67
  MAX_NEW_TOKENS = CONFIG["llm"]["max_new_tokens"]
68
+ MAX_QUERY_CHARS = 1000
69
 
70
  # Paths for caching embeddings
71
  _CACHE_DIR = "./.cache"
72
  _CACHE_FILE = os.path.join(_CACHE_DIR, "kb_embeddings.npz")
73
 
74
+ # -----------------------------
75
  # UTILITIES
76
+ # -----------------------------
77
 
78
  def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
79
  """
 
83
  if not text:
84
  return []
85
 
86
+ # FIX: clamp overlap so chunk_size - overlap is always >= 1, preventing infinite loop
87
+ overlap = min(overlap, chunk_size - 1)
88
+
89
  sentence_endings = re.compile(r'(?<=[.!?])\s+')
90
  sentences = sentence_endings.split(text.strip())
91
 
92
  chunks = []
93
+ current_chunk: List[str] = []
94
  current_len = 0
95
 
96
  for sentence in sentences:
 
105
  chunks.append(" ".join(current_chunk))
106
  current_chunk = []
107
  current_len = 0
 
108
  start = 0
109
  while start < slen:
110
  end = min(start + chunk_size, slen)
 
116
  if current_chunk:
117
  chunks.append(" ".join(current_chunk))
118
  # Carry over last sentence(s) for overlap
119
+ overlap_words: List[str] = []
120
  overlap_len = 0
121
  for s in reversed(current_chunk):
122
  if overlap_len + len(s) + 1 > overlap:
 
140
  Clean text for LLM context: remove only Markdown heading markers (leading #),
141
  collapse whitespace, but preserve legitimate # characters mid-text.
142
  """
 
143
  text = re.sub(r'(?m)^#+\s*', '', text)
 
144
  text = re.sub(r'\s+', ' ', text)
145
  return text.strip()
146
 
147
 
148
  def kb_fingerprint(kb_dir: str) -> str:
149
+ """
150
+ Return an MD5 fingerprint of all KB filenames + modification times.
151
+ Returns a sentinel string when the directory is empty or missing,
152
+ so cache comparisons never falsely match a populated index.
153
+ """
154
  paths = sorted(
155
  glob.glob(os.path.join(kb_dir, "*.txt")) +
156
  glob.glob(os.path.join(kb_dir, "*.md"))
157
  )
158
+ # FIX: return distinct sentinel when no files found, not md5('') which
159
+ # would match any other empty-directory state from a different run.
160
+ if not paths:
161
+ return "NO_KB_FILES"
162
  sig = "|".join(f"{p}:{os.path.getmtime(p)}" for p in paths)
163
  return hashlib.md5(sig.encode()).hexdigest()
164
 
 
210
  return texts
211
 
212
 
213
+ # -----------------------------
214
  # KB INDEX
215
+ # -----------------------------
216
 
217
  class KBIndex:
218
  def __init__(self, kb_dir: str = KB_DIR, model_name: str = EMBEDDING_MODEL_NAME):
219
  self.kb_dir = kb_dir
220
  self.model_name = model_name
221
+ # FIX: _lock removed from KBIndex β€” it was defined but never acquired.
222
+ # LLM concurrency is handled by the module-level _llm_lock instead.
223
 
224
  logger.info("Loading embedding model...")
225
  self.model = SentenceTransformer(model_name)
 
245
  if not force and os.path.exists(_CACHE_FILE):
246
  try:
247
  cached = np.load(_CACHE_FILE, allow_pickle=True)
248
+ # FIX: NpzFile stores arrays; extract scalar string correctly
249
+ # using item() instead of str() which would produce "array('...')"
250
+ cached_fp = str(cached["fingerprint"].item())
251
+ if cached_fp == fingerprint:
252
  self.chunks = list(cached["chunks"])
253
  self.chunk_sources = list(cached["chunk_sources"])
254
  self.embeddings = cached["embeddings"]
 
258
  except Exception as e:
259
  logger.warning(f"Cache load failed: {e}. Rebuilding index.")
260
 
261
+ all_chunks: List[str] = []
262
+ all_sources: List[str] = []
263
 
264
  for source_name, content in texts:
265
  for chunk in chunk_text(content):
 
318
  [query], show_progress_bar=False, convert_to_numpy=True
319
  )[0]
320
 
 
321
  norm_docs = np.linalg.norm(self.embeddings, axis=1)
322
  norm_query = np.linalg.norm(query_vec)
323
  denom = norm_docs * norm_query
324
+ denom = np.where(denom == 0, 1e-10, denom)
325
  scores = np.dot(self.embeddings, query_vec) / denom
326
 
327
  top_idx = np.argsort(scores)[::-1][:top_k]
 
331
  ]
332
 
333
 
334
+ # -----------------------------
335
  # LLM SETUP
336
+ # -----------------------------
337
+
338
+ # Single import of torch at module level (not duplicated inside functions)
339
+ try:
340
+ import torch
341
+ _torch_available = True
342
+ except ImportError:
343
+ _torch_available = False
344
 
345
  logger.info("Initializing KB index...")
346
  kb_index = KBIndex(kb_dir=KB_DIR, model_name=EMBEDDING_MODEL_NAME)
 
349
  llm_available = False
350
  llm_tokenizer = None
351
  llm_model = None
352
+ _llm_lock = threading.Lock()
353
 
354
+ if _torch_available:
355
+ try:
356
+ from transformers import AutoTokenizer, AutoModelForCausalLM
357
 
358
+ logger.info(f"Loading {LLM_MODEL_NAME}...")
359
+ llm_tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_NAME)
360
+
361
+ # FIX: ensure pad_token is set so eos_token_id is never None at generation time
362
+ if llm_tokenizer.pad_token is None:
363
+ llm_tokenizer.pad_token = llm_tokenizer.eos_token
 
364
 
365
+ llm_model = AutoModelForCausalLM.from_pretrained(
366
+ LLM_MODEL_NAME,
367
+ torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
368
+ device_map="auto" if torch.cuda.is_available() else None,
369
+ )
370
+
371
+ if not torch.cuda.is_available():
372
+ llm_model = llm_model.to("cpu")
373
 
374
+ llm_model.eval()
375
+ llm_available = True
376
+ logger.info(f"βœ… LLM loaded on {'GPU' if torch.cuda.is_available() else 'CPU'}")
377
 
378
+ except Exception as e:
379
+ logger.warning(f"⚠️ Could not load LLM: {e}. Will use fallback retrieval mode.")
380
+ else:
381
+ logger.warning("⚠️ torch not available. Will use fallback retrieval mode.")
382
 
383
 
384
+ # -----------------------------
385
  # CHAT LOGIC
386
+ # -----------------------------
387
 
388
  def normalise_history(history: list) -> List[Tuple[str, str]]:
389
  """
 
394
  if not history:
395
  return []
396
  if isinstance(history[0], dict):
 
397
  pairs = []
398
  i = 0
399
  while i < len(history):
 
415
 
416
  def build_llm_prompt(query: str, context: str, history: list) -> str:
417
  """
418
+ Build a TinyLlama chat prompt including recent conversation history
419
  so the model can resolve follow-up questions.
420
  """
421
  system = (
 
425
  "If the context doesn't contain enough information, say so honestly."
426
  )
427
 
 
428
  history_text = ""
429
  for user_msg, asst_msg in normalise_history(history)[-3:]:
430
  if user_msg:
 
432
  if asst_msg:
433
  history_text += f"<|assistant|>\n{asst_msg}\n</s>\n"
434
 
435
+ return (
436
  f"<|system|>\n{system}\n</s>\n"
437
  f"{history_text}"
438
  f"<|user|>\n"
 
441
  f"</s>\n"
442
  f"<|assistant|>\n"
443
  )
 
444
 
445
 
446
  def generate_answer_with_llm(
 
453
  if not llm_available:
454
  return None
455
 
 
 
456
  prompt = build_llm_prompt(query, context, history)
457
 
458
  try:
459
+ # FIX: keep decode() inside the lock so llm_tokenizer can't be
460
+ # reassigned/cleared by a reload() between generate() and decode()
461
  with _llm_lock:
462
  inputs = llm_tokenizer(
463
  prompt,
 
476
  temperature=0.7,
477
  top_p=0.9,
478
  do_sample=True,
479
+ pad_token_id=llm_tokenizer.pad_token_id, # FIX: use pad_token_id (always set above)
480
  )
481
 
482
+ # FIX: decode inside the lock β€” keeps tokenizer reference stable
483
+ full_response = llm_tokenizer.decode(outputs[0], skip_special_tokens=True)
484
 
485
  # Extract only the final assistant turn
486
  if "<|assistant|>" in full_response:
 
489
  answer = full_response.strip()
490
 
491
  answer = answer.replace("</s>", "").strip()
 
492
  sources_text = ", ".join(sources)
493
  return f"{answer}\n\n---\nπŸ“š **Sources:** {sources_text}"
494
 
 
503
  return CONFIG["messages"]["no_answer"]
504
 
505
  parts = []
 
506
 
507
  for chunk, source, score in results:
508
  cleaned = clean_context(chunk)
509
  label = "πŸ₯‡" if not parts else "πŸ’‘"
510
  confidence = (
511
+ "High" if score >= 0.6 else
512
  "Medium" if score >= 0.4 else
513
  "Low"
514
  )
515
  parts.append(
516
  f"{label} **From {source}** *(relevance: {confidence} β€” {score:.2f})*\n\n{cleaned}"
517
  )
518
+ # FIX: removed seen_sources β€” it was built but never used for anything
 
519
 
520
  return "\n\n---\n\n".join(parts)
521
 
 
551
  "Please try being more specific or using different keywords."
552
  )
553
 
 
554
  context_parts = []
555
+ sources: List[str] = []
556
+ for chunk, source, _ in filtered:
557
  cleaned = clean_context(chunk)
558
  context_parts.append(cleaned)
559
  if source not in sources:
 
574
  if not message or not message.strip():
575
  return "Please ask me a question about the knowledge base."
576
 
 
577
  query = message.strip()
578
  if len(query) > MAX_QUERY_CHARS:
579
  query = query[:MAX_QUERY_CHARS]
 
586
  return "Sorry, something went wrong while processing your question. Please try again."
587
 
588
 
589
+ # -----------------------------
590
  # GRADIO UI
591
+ # -----------------------------
592
 
593
  description = f"""
594
  πŸš€ **{CONFIG['client']['name']}**
 
617
  description=description,
618
  examples=examples,
619
  cache_examples=False,
 
620
  )
621
 
622
+ # -----------------------------
623
  # LAUNCH
624
+ # -----------------------------
625
 
626
  if __name__ == "__main__":
627
  is_huggingface = os.getenv("SPACE_ID") is not None