sofzcc commited on
Commit
2863683
Β·
verified Β·
1 Parent(s): b31abe0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +464 -770
app.py CHANGED
@@ -1,14 +1,15 @@
1
  import os
2
  import re
3
  import glob
 
4
  import threading
5
- import hashlib
6
  import logging
7
  from typing import List, Tuple, Optional
8
 
9
- import gradio as gr
10
  import numpy as np
11
  import yaml
 
12
  from sentence_transformers import SentenceTransformer
13
 
14
  # -----------------------------
@@ -18,24 +19,26 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
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 = {
29
- "kb": {"directory": "./kb"},
30
- "models": {"embedding": "sentence-transformers/all-MiniLM-L6-v2"},
 
 
 
31
  "chunking": {"chunk_size": 500, "overlap": 100},
32
- "retrieval": {"top_k": 3, "min_similarity": 0.3, "context_chars": 1500},
33
- "llm": {"model": "TinyLlama/TinyLlama-1.1B-Chat-v1.0", "max_new_tokens": 256},
34
  "client": {"name": "Self-Service KB Assistant"},
35
  "messages": {
36
- "welcome": "πŸ‘‹ Ask me anything you'd normally search for in a knowledge base.",
37
- "no_answer": "❌ I couldn't find anything relevant in the knowledge base for this query.",
38
  },
 
39
  }
40
  if os.path.exists(path):
41
  try:
@@ -50,351 +53,432 @@ def load_config(path: str = _CONFIG_PATH) -> dict:
50
  except Exception as e:
51
  logger.warning(f"Could not load {path}: {e}. Using defaults.")
52
  else:
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"]
 
60
  EMBEDDING_MODEL_NAME = CONFIG["models"]["embedding"]
 
61
  CHUNK_SIZE = CONFIG["chunking"]["chunk_size"]
62
  CHUNK_OVERLAP = CONFIG["chunking"]["overlap"]
63
  TOP_K = CONFIG["retrieval"]["top_k"]
64
  MIN_SIMILARITY = CONFIG["retrieval"]["min_similarity"]
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
- """
80
- Split text into overlapping chunks that break on sentence boundaries
81
- where possible, preventing mid-word or mid-sentence splits.
82
- """
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:
97
- sentence = sentence.strip()
98
- if not sentence:
99
  continue
100
- slen = len(sentence)
101
 
102
- # If a single sentence exceeds chunk_size, force-split it
103
  if slen > chunk_size:
104
- if current_chunk:
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)
111
- chunks.append(sentence[start:end].strip())
112
  start += chunk_size - overlap
113
  continue
114
 
115
  if current_len + slen + 1 > chunk_size:
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:
123
  break
124
- overlap_words.insert(0, s)
125
- overlap_len += len(s) + 1
126
- current_chunk = overlap_words
127
- current_len = overlap_len
128
 
129
- current_chunk.append(sentence)
130
  current_len += slen + 1
131
 
132
- if current_chunk:
133
- chunks.append(" ".join(current_chunk))
134
 
135
- return [c for c in chunks if c.strip()]
136
 
 
 
 
137
 
138
  def clean_context(text: str) -> str:
139
  """
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
-
165
-
166
- def load_kb_texts(kb_dir: str = KB_DIR) -> List[Tuple[str, str]]:
167
- """
168
- Load all .txt and .md files from the KB directory.
169
- Returns a list of (source_name, content).
170
- Falls back to built-in demo content if directory is empty or missing.
171
  """
172
- texts = []
173
-
174
- if os.path.isdir(kb_dir):
175
- paths = (
176
- glob.glob(os.path.join(kb_dir, "*.txt")) +
177
- glob.glob(os.path.join(kb_dir, "*.md"))
178
- )
179
- for path in sorted(paths):
180
- try:
181
- with open(path, "r", encoding="utf-8") as f:
182
- content = f.read()
183
- if content.strip():
184
- texts.append((os.path.basename(path), content))
185
- except Exception as e:
186
- logger.warning(f"Could not read {path}: {e}")
187
-
188
- if not texts:
189
- logger.warning("No KB files found. Using built-in demo content.")
190
- demo_text = """
191
- Welcome to the Self-Service KB Assistant.
192
-
193
- This assistant is meant to help you find information inside a knowledge base.
194
- In a real setup, it would be connected to your own articles, procedures,
195
- troubleshooting guides and FAQs.
196
-
197
- Good knowledge base content is:
198
- - Clear and structured with headings, steps and expected outcomes.
199
- - Written in a customer-friendly tone.
200
- - Easy to scan, with short paragraphs and bullet points.
201
- - Maintained regularly to reflect product and process changes.
202
-
203
- Example use cases for a KB assistant:
204
- - Agents quickly searching for internal procedures.
205
- - Customers asking "how do I..." style questions.
206
- - Managers analyzing gaps in documentation based on repeated queries.
207
- """
208
- texts.append(("demo_content.txt", demo_text))
209
 
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)
 
226
  logger.info("Embedding model loaded.")
227
 
228
- self.chunks: List[str] = []
229
- self.chunk_sources: List[str] = []
230
- self.embeddings: Optional[np.ndarray] = None
231
- self._fingerprint: Optional[str] = None
232
-
233
- self.build_index()
234
 
235
- def build_index(self, force: bool = False):
236
- """
237
- Load KB texts, split into chunks, and build an embedding index.
238
- Uses disk cache keyed on KB fingerprint to skip re-encoding on cold start.
239
- Pass force=True to rebuild regardless of cache.
240
- """
241
- texts = load_kb_texts(self.kb_dir)
242
- fingerprint = kb_fingerprint(self.kb_dir)
243
 
244
- # Try loading from cache if fingerprint matches
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"]
255
- self._fingerprint = fingerprint
256
- logger.info(f"βœ… Loaded {len(self.chunks)} chunks from embedding cache.")
257
- return
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):
266
  all_chunks.append(chunk)
267
- all_sources.append(source_name)
268
 
269
  if not all_chunks:
270
- logger.warning("⚠️ No chunks found for KB index.")
 
271
  self.chunks = []
272
- self.chunk_sources = []
273
- self.embeddings = None
274
  return
275
 
276
- logger.info(f"Creating embeddings for {len(all_chunks)} chunks...")
277
- embeddings = self.model.encode(
278
- all_chunks,
279
- show_progress_bar=False,
280
- convert_to_numpy=True,
281
- batch_size=64,
282
- )
283
- self.chunks = all_chunks
284
- self.chunk_sources = all_sources
285
- self.embeddings = embeddings
286
- self._fingerprint = fingerprint
 
 
 
287
 
288
- # Save cache
289
  try:
290
- os.makedirs(_CACHE_DIR, exist_ok=True)
291
- np.savez_compressed(
292
- _CACHE_FILE,
293
- chunks=np.array(all_chunks, dtype=object),
294
- chunk_sources=np.array(all_sources, dtype=object),
295
- embeddings=embeddings,
296
- fingerprint=np.array(fingerprint),
297
- )
298
- logger.info("Embedding cache saved.")
299
  except Exception as e:
300
- logger.warning(f"Could not save embedding cache: {e}")
301
 
302
  logger.info("KB index ready.")
303
 
304
- def reload(self):
305
- """Rebuild the index from disk, bypassing cache."""
306
- logger.info("Reloading KB index...")
307
- self.build_index(force=True)
308
- logger.info("KB index reloaded.")
309
-
310
- def search(self, query: str, top_k: int = TOP_K) -> List[Tuple[str, str, float]]:
311
- """Return top-k (chunk, source_name, score) for a given query."""
312
- if not query.strip():
313
- return []
314
- if self.embeddings is None or not self.chunks:
 
 
 
 
 
 
315
  return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
- query_vec = self.model.encode(
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]
328
- return [
329
- (self.chunks[i], self.chunk_sources[i], float(scores[i]))
330
- for i in top_idx
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
 
348
- logger.info("Loading LLM for answer generation...")
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
- """
390
- Normalise Gradio history into a consistent list of (user, assistant) string pairs.
391
- - Gradio 6 messages format: list of {"role": ..., "content": ...} dicts
392
- - Gradio 4 format: list of [user_str, assistant_str] pairs
393
- """
394
  if not history:
395
  return []
396
  if isinstance(history[0], dict):
397
- pairs = []
398
  i = 0
399
  while i < len(history):
400
  if history[i].get("role") == "user":
@@ -409,547 +493,173 @@ def normalise_history(history: list) -> List[Tuple[str, str]]:
409
  else:
410
  i += 1
411
  return pairs
412
- # Gradio 4: list of [user, assistant] pairs
413
  return [(str(u or ""), str(a or "")) for u, a in history]
414
 
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 = (
422
- "You are a warm, helpful assistant. "
423
- "Use the provided context to answer the user's question. "
424
- "Always open with a natural, friendly sentence that acknowledges what they asked β€” "
425
- "never start your reply with a bare list or phrases like 'To fix' or 'To do' without a human lead-in first. "
426
- "When numbered steps or a list are the clearest way to explain something, use them β€” "
427
- "but always introduce the list with a brief friendly sentence first (e.g. 'Here's what you can do:' or 'Sure! Here are the steps:'). "
428
- "Keep your overall tone warm and conversational. "
429
- "If the context doesn't have enough information, say so honestly and suggest they rephrase or try different keywords. "
430
- "Do NOT mention sources, filenames, or where the information came from."
431
- )
432
-
433
- history_text = ""
434
- for user_msg, asst_msg in normalise_history(history)[-3:]:
435
- if user_msg:
436
- history_text += f"<|user|>\n{user_msg}\n</s>\n"
437
- if asst_msg:
438
- history_text += f"<|assistant|>\n{asst_msg}\n</s>\n"
439
-
440
- return (
441
- f"<|system|>\n{system}\n</s>\n"
442
- f"{history_text}"
443
- f"<|user|>\n"
444
- f"Context:\n{context}\n\n"
445
- f"Question: {query}\n"
446
- f"</s>\n"
447
- f"<|assistant|>\n"
448
- )
449
-
450
-
451
- def generate_answer_with_llm(
452
- query: str,
453
- context: str,
454
- sources: List[str],
455
- history: list,
456
- ) -> Optional[str]:
457
- """Generate a natural answer using the local LLM. Returns None on failure."""
458
- if not llm_available:
459
- return None
460
-
461
- prompt = build_llm_prompt(query, context, history)
462
-
463
- try:
464
- # FIX: keep decode() inside the lock so llm_tokenizer can't be
465
- # reassigned/cleared by a reload() between generate() and decode()
466
- with _llm_lock:
467
- inputs = llm_tokenizer(
468
- prompt,
469
- return_tensors="pt",
470
- truncation=True,
471
- max_length=1024,
472
- )
473
-
474
- if torch.cuda.is_available():
475
- inputs = {k: v.to("cuda") for k, v in inputs.items()}
476
-
477
- with torch.no_grad():
478
- outputs = llm_model.generate(
479
- **inputs,
480
- max_new_tokens=MAX_NEW_TOKENS,
481
- temperature=0.7,
482
- top_p=0.9,
483
- do_sample=True,
484
- pad_token_id=llm_tokenizer.pad_token_id, # FIX: use pad_token_id (always set above)
485
- )
486
-
487
- # FIX: decode inside the lock β€” keeps tokenizer reference stable
488
- full_response = llm_tokenizer.decode(outputs[0], skip_special_tokens=True)
489
-
490
- # Extract only the final assistant turn
491
- if "<|assistant|>" in full_response:
492
- answer = full_response.split("<|assistant|>")[-1].strip()
493
- else:
494
- answer = full_response.strip()
495
-
496
- answer = answer.replace("</s>", "").strip()
497
- return answer
498
-
499
- except Exception as e:
500
- logger.error(f"LLM generation error: {e}")
501
- return None
502
-
503
-
504
- def format_fallback_answer(results: List[Tuple[str, str, float]]) -> str:
505
- """Format retrieved chunks into a readable answer when LLM is unavailable."""
506
- if not results:
507
- return CONFIG["messages"]["no_answer"]
508
-
509
- parts = []
510
-
511
- for chunk, source, score in results:
512
- cleaned = clean_context(chunk)
513
- label = "πŸ₯‡" if not parts else "πŸ’‘"
514
- confidence = (
515
- "High" if score >= 0.6 else
516
- "Medium" if score >= 0.4 else
517
- "Low"
518
- )
519
- parts.append(
520
- f"{label} {cleaned}"
521
- )
522
- # FIX: removed seen_sources β€” it was built but never used for anything
523
-
524
- return "\n\n---\n\n".join(parts)
525
-
526
-
527
  def build_answer(query: str, history: list) -> str:
528
- """
529
- Main RAG pipeline:
530
- 1. Retrieve relevant chunks
531
- 2. Filter by similarity threshold
532
- 3. Build context from ALL top results (up to TOP_K)
533
- 4. Generate answer via LLM (or fallback)
534
- """
535
- results = kb_index.search(query, top_k=TOP_K)
536
 
537
  if not results:
538
  return (
539
- "I couldn't find any relevant information in the knowledge base.\n\n"
540
- "**Suggestions:**\n"
541
- "- Try rephrasing with different words\n"
542
- "- Use more specific keywords\n"
543
- "- Break complex questions into smaller ones"
544
  )
545
 
546
- filtered = [
547
- (chunk, src, score)
548
- for chunk, src, score in results
549
- if score >= MIN_SIMILARITY
550
- ]
551
 
552
- if not filtered:
553
  return (
554
- "I found some content, but it doesn't seem relevant enough to your question.\n\n"
555
- "Please try being more specific or using different keywords."
556
  )
557
 
558
- context_parts = []
559
- sources: List[str] = []
560
- for chunk, source, _ in filtered:
561
- cleaned = clean_context(chunk)
562
- context_parts.append(cleaned)
563
- if source not in sources:
564
- sources.append(source)
565
-
566
- context = " ".join(context_parts)[:CONTEXT_CHARS]
567
 
568
- if llm_available:
569
- llm_answer = generate_answer_with_llm(query, context, sources, history)
570
- if llm_answer:
571
- return llm_answer
572
 
573
- return format_fallback_answer(filtered)
 
 
 
 
574
 
575
 
576
  def chat_respond(message: str, history: list) -> str:
577
  """Gradio ChatInterface callback."""
578
  if not message or not message.strip():
579
  return "Please ask me a question about the knowledge base."
580
-
581
- query = message.strip()
582
- if len(query) > MAX_QUERY_CHARS:
583
- query = query[:MAX_QUERY_CHARS]
584
- logger.warning(f"Query truncated to {MAX_QUERY_CHARS} characters.")
585
-
586
  try:
587
  return build_answer(query, history or [])
588
  except Exception as e:
589
- logger.error(f"Error generating answer: {e}")
590
- return "Sorry, something went wrong while processing your question. Please try again."
591
 
592
 
593
  # -----------------------------
594
  # KB FILE MANAGEMENT
595
  # -----------------------------
596
 
597
- ALLOWED_EXTENSIONS = {".txt", ".md"}
598
- MAX_FILE_SIZE_MB = 10
599
-
600
-
601
  def list_kb_files() -> str:
602
- """Return a formatted markdown table of current KB files."""
603
- paths = sorted(
604
- glob.glob(os.path.join(KB_DIR, "*.txt")) +
605
- glob.glob(os.path.join(KB_DIR, "*.md"))
606
- )
607
  if not paths:
608
- return "_No files in knowledge base yet. Upload some documents to get started._"
609
-
610
  rows = ["| File | Size |", "|------|------|"]
611
  for p in paths:
612
- size_kb = os.path.getsize(p) / 1024
613
- rows.append(f"| {os.path.basename(p)} | {size_kb:.1f} KB |")
614
  return "\n".join(rows)
615
 
616
 
617
  def upload_kb_files(files) -> Tuple[str, str]:
618
- """
619
- Accept uploaded files, validate them, save to KB_DIR, and rebuild the index.
620
- Returns (status_message, updated_file_list).
621
- """
622
  if not files:
623
  return "⚠️ No files selected.", list_kb_files()
624
-
625
  os.makedirs(KB_DIR, exist_ok=True)
626
-
627
- saved = []
628
- skipped = []
629
 
630
  for file in files:
631
- # Gradio passes either a filepath string or a NamedString object
632
  filepath = file if isinstance(file, str) else file.name
633
- original_name = os.path.basename(filepath)
634
- ext = os.path.splitext(original_name)[1].lower()
635
 
636
  if ext not in ALLOWED_EXTENSIONS:
637
- skipped.append(f"{original_name} (unsupported type β€” use .txt or .md)")
638
  continue
639
-
640
- size_mb = os.path.getsize(filepath) / (1024 * 1024)
641
- if size_mb > MAX_FILE_SIZE_MB:
642
- skipped.append(f"{original_name} (too large β€” max {MAX_FILE_SIZE_MB} MB)")
643
  continue
644
 
645
- dest = os.path.join(KB_DIR, original_name)
646
- try:
647
- with open(filepath, "r", encoding="utf-8", errors="replace") as f:
648
- content = f.read()
649
- if not content.strip():
650
- skipped.append(f"{original_name} (empty file)")
651
- continue
652
- with open(dest, "w", encoding="utf-8") as f:
653
- f.write(content)
654
- saved.append(original_name)
655
- logger.info(f"KB file saved: {dest}")
656
- except Exception as e:
657
- skipped.append(f"{original_name} (error: {e})")
658
- logger.error(f"Failed to save {original_name}: {e}")
659
-
660
- if saved:
661
- logger.info("Rebuilding KB index after upload...")
662
  try:
663
- kb_index.reload()
664
- reload_msg = "βœ… Index rebuilt successfully."
665
  except Exception as e:
666
- reload_msg = f"⚠️ Index rebuild failed: {e}"
667
- logger.error(reload_msg)
668
- else:
669
- reload_msg = ""
670
 
671
- status_parts = []
672
  if saved:
673
- status_parts.append(f"βœ… **Uploaded:** {', '.join(saved)}")
674
- if skipped:
675
- status_parts.append(f"⚠️ **Skipped:** {', '.join(skipped)}")
676
- if reload_msg:
677
- status_parts.append(reload_msg)
678
 
679
- return "\n\n".join(status_parts), list_kb_files()
 
 
 
 
680
 
681
 
682
  def delete_kb_file(filename: str) -> Tuple[str, str]:
683
- """Delete a file from the KB directory and rebuild the index."""
684
- filename = filename.strip()
685
  if not filename:
686
  return "⚠️ No filename provided.", list_kb_files()
687
-
688
- # Safety: strip any path components β€” only allow plain filenames
689
- filename = os.path.basename(filename)
690
  ext = os.path.splitext(filename)[1].lower()
691
  if ext not in ALLOWED_EXTENSIONS:
692
- return f"⚠️ Invalid file type: {filename}", list_kb_files()
693
-
694
  target = os.path.join(KB_DIR, filename)
695
  if not os.path.exists(target):
696
  return f"⚠️ File not found: {filename}", list_kb_files()
697
-
698
  try:
699
  os.remove(target)
700
- logger.info(f"Deleted KB file: {target}")
701
- kb_index.reload()
702
- return f"πŸ—‘οΈ **Deleted** `{filename}` and rebuilt index.", list_kb_files()
703
  except Exception as e:
704
- logger.error(f"Failed to delete {filename}: {e}")
705
  return f"❌ Could not delete `{filename}`: {e}", list_kb_files()
706
 
707
 
708
  # -----------------------------
709
  # GRADIO UI
 
 
710
  # -----------------------------
711
 
712
- CUSTOM_CSS = """
713
- /* ── Base & typography ─────────────────────────────────── */
714
  :root {
715
- --radius: 12px;
716
- --shadow: 0 2px 12px rgba(0,0,0,.08);
717
- --accent: #4f6ef7;
718
- --accent-hover: #3a56d4;
719
- --surface: #ffffff;
720
- --bg: #f4f6fb;
721
- --muted: #6b7280;
722
- --border: #e5e7eb;
723
- --danger: #ef4444;
724
- --danger-hover: #dc2626;
725
- --success: #22c55e;
726
  }
727
-
728
- body, .gradio-container {
729
- background: var(--bg) !important;
730
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
731
- }
732
-
733
- /* ── Header ────────────────────────────────────────────── */
734
- .app-header {
735
- background: linear-gradient(135deg, var(--accent) 0%, #7c3aed 100%);
736
- color: white;
737
- padding: 20px 24px 16px;
738
- border-radius: var(--radius) var(--radius) 0 0;
739
- margin-bottom: 0;
740
- }
741
- .app-header h1 {
742
- margin: 0 0 4px;
743
- font-size: clamp(1.2rem, 4vw, 1.6rem);
744
- font-weight: 700;
745
- letter-spacing: -0.02em;
746
- }
747
- .app-header p {
748
- margin: 0;
749
- opacity: 0.85;
750
- font-size: clamp(0.8rem, 2.5vw, 0.92rem);
751
- }
752
-
753
- /* ── Tabs ──────────────────────────────────────────────── */
754
- .tabs > .tab-nav {
755
- background: var(--surface) !important;
756
- border-bottom: 2px solid var(--border) !important;
757
- padding: 0 16px !important;
758
- gap: 4px !important;
759
- }
760
- .tabs > .tab-nav button {
761
- border-radius: var(--radius) var(--radius) 0 0 !important;
762
- padding: 10px 18px !important;
763
- font-weight: 500 !important;
764
- font-size: 0.88rem !important;
765
- color: var(--muted) !important;
766
- border: none !important;
767
- background: transparent !important;
768
- transition: color .2s, background .2s !important;
769
- }
770
- .tabs > .tab-nav button.selected {
771
- color: var(--accent) !important;
772
- border-bottom: 2px solid var(--accent) !important;
773
- background: transparent !important;
774
- }
775
-
776
- /* ── Chat bubbles ──────────────────────────────────────── */
777
- .message.user > div {
778
- background: var(--accent) !important;
779
- color: white !important;
780
- border-radius: var(--radius) var(--radius) 4px var(--radius) !important;
781
- padding: 10px 14px !important;
782
- max-width: 82% !important;
783
- font-size: clamp(0.85rem, 2.5vw, 0.95rem) !important;
784
- line-height: 1.55 !important;
785
- box-shadow: var(--shadow) !important;
786
- }
787
- .message.bot > div {
788
- background: var(--surface) !important;
789
- border: 1px solid var(--border) !important;
790
- border-radius: var(--radius) var(--radius) var(--radius) 4px !important;
791
- padding: 10px 14px !important;
792
- max-width: 88% !important;
793
- font-size: clamp(0.85rem, 2.5vw, 0.95rem) !important;
794
- line-height: 1.6 !important;
795
- box-shadow: var(--shadow) !important;
796
- }
797
-
798
- /* ── Chat input bar ────────────────────────────────────── */
799
- .chat-input-container, footer .chat-input-container {
800
- background: var(--surface) !important;
801
- border-top: 1px solid var(--border) !important;
802
- padding: 10px 12px !important;
803
- }
804
- .chat-input-container textarea {
805
- border-radius: 24px !important;
806
- border: 1.5px solid var(--border) !important;
807
- padding: 10px 16px !important;
808
- font-size: 0.93rem !important;
809
- resize: none !important;
810
- transition: border-color .2s !important;
811
- }
812
- .chat-input-container textarea:focus {
813
- border-color: var(--accent) !important;
814
- outline: none !important;
815
- box-shadow: 0 0 0 3px rgba(79,110,247,.15) !important;
816
- }
817
-
818
- /* ── Send button ───────────────────────────────────────── */
819
- .chat-input-container button[aria-label="Send"] {
820
- background: var(--accent) !important;
821
- border-radius: 50% !important;
822
- width: 40px !important;
823
- height: 40px !important;
824
- min-width: 40px !important;
825
- padding: 0 !important;
826
- color: white !important;
827
- border: none !important;
828
- transition: background .2s, transform .1s !important;
829
- }
830
- .chat-input-container button[aria-label="Send"]:hover {
831
- background: var(--accent-hover) !important;
832
- transform: scale(1.06) !important;
833
- }
834
-
835
- /* ── Example pills ─────────────────────────────────────── */
836
- .examples-table button, .example {
837
- border-radius: 999px !important;
838
- border: 1.5px solid var(--accent) !important;
839
- color: var(--accent) !important;
840
- background: transparent !important;
841
- padding: 6px 14px !important;
842
- font-size: 0.82rem !important;
843
- font-weight: 500 !important;
844
- transition: background .2s, color .2s !important;
845
- white-space: nowrap !important;
846
- }
847
- .examples-table button:hover, .example:hover {
848
- background: var(--accent) !important;
849
- color: white !important;
850
- }
851
-
852
- /* ── KB Manager cards ──────────────────────────────────── */
853
- .kb-card {
854
- background: var(--surface);
855
- border-radius: var(--radius);
856
- border: 1px solid var(--border);
857
- padding: 20px;
858
- box-shadow: var(--shadow);
859
- }
860
-
861
- /* ── File list table ───────────────────────────────────── */
862
- .kb-file-list table {
863
- width: 100%;
864
- border-collapse: collapse;
865
- font-size: 0.88rem;
866
- }
867
- .kb-file-list th {
868
- background: var(--bg);
869
- padding: 8px 12px;
870
- text-align: left;
871
- font-weight: 600;
872
- color: var(--muted);
873
- border-bottom: 1px solid var(--border);
874
- }
875
- .kb-file-list td {
876
- padding: 8px 12px;
877
- border-bottom: 1px solid var(--border);
878
- }
879
-
880
- /* ── Buttons ───────────────────────────────────────────── */
881
- button.primary-btn {
882
- background: var(--accent) !important;
883
- color: white !important;
884
- border-radius: 8px !important;
885
- padding: 10px 20px !important;
886
- font-weight: 600 !important;
887
- font-size: 0.9rem !important;
888
- border: none !important;
889
- width: 100% !important;
890
- transition: background .2s, transform .1s !important;
891
- }
892
- button.primary-btn:hover {
893
- background: var(--accent-hover) !important;
894
- transform: translateY(-1px) !important;
895
- }
896
- button.danger-btn {
897
- background: var(--danger) !important;
898
- color: white !important;
899
- border-radius: 8px !important;
900
- padding: 10px 20px !important;
901
- font-weight: 600 !important;
902
- font-size: 0.9rem !important;
903
- border: none !important;
904
- width: 100% !important;
905
- transition: background .2s !important;
906
- }
907
- button.danger-btn:hover { background: var(--danger-hover) !important; }
908
-
909
- /* ── Inputs & textboxes ────────────────────────────────── */
910
- input[type="text"], textarea.gr-text-input {
911
- border-radius: 8px !important;
912
- border: 1.5px solid var(--border) !important;
913
- padding: 9px 13px !important;
914
- font-size: 0.9rem !important;
915
- width: 100% !important;
916
- transition: border-color .2s !important;
917
- }
918
- input[type="text"]:focus, textarea.gr-text-input:focus {
919
- border-color: var(--accent) !important;
920
- box-shadow: 0 0 0 3px rgba(79,110,247,.15) !important;
921
- outline: none !important;
922
- }
923
-
924
- /* ── File drop zone ────────────────────────────────────── */
925
- .file-drop {
926
- border: 2px dashed var(--border) !important;
927
- border-radius: var(--radius) !important;
928
- background: var(--bg) !important;
929
- transition: border-color .2s, background .2s !important;
930
- }
931
- .file-drop:hover {
932
- border-color: var(--accent) !important;
933
- background: rgba(79,110,247,.04) !important;
934
- }
935
-
936
- /* ── Status messages ───────────────────────────────────── */
937
- .status-box {
938
- border-radius: 8px;
939
- padding: 10px 14px;
940
- font-size: 0.88rem;
941
- min-height: 36px;
942
- }
943
-
944
- /* ── Mobile responsiveness ─────────────────────────────── */
945
- @media (max-width: 640px) {
946
- .gradio-container { padding: 0 !important; }
947
- .app-header { border-radius: 0 !important; padding: 14px 16px 12px !important; }
948
- .tabs > .tab-nav button { padding: 8px 12px !important; font-size: 0.82rem !important; }
949
- .message.user > div, .message.bot > div { max-width: 95% !important; font-size: 0.88rem !important; }
950
- .kb-row { flex-direction: column !important; }
951
- .kb-row > * { width: 100% !important; }
952
  }
 
953
  """
954
 
955
  examples = [
@@ -958,26 +668,25 @@ examples = [
958
  if "query" in q
959
  ] or [
960
  "What makes a good knowledge base article?",
961
- "How could a KB assistant help agents?",
962
  "Why is self-service important for customer support?",
963
  ]
964
 
965
- with gr.Blocks(
966
- title=CONFIG["client"]["name"],
967
- css=CUSTOM_CSS,
968
- ) as demo:
969
 
970
- # ── Header ───────────────────────────────────────────────────
971
  gr.HTML(f"""
972
  <div class="app-header">
973
  <h1>πŸ€– {CONFIG["client"]["name"]}</h1>
974
- <p>Ask me anything β€” I'll search the knowledge base and answer to the best of my abilities.</p>
975
  </div>
976
  """)
977
 
978
  with gr.Tabs(elem_classes="tabs"):
979
 
980
- # ── Tab 1: Chat ──────────────────────────────────────────
981
  with gr.TabItem("πŸ’¬ Chat"):
982
  gr.ChatInterface(
983
  fn=chat_respond,
@@ -985,49 +694,38 @@ with gr.Blocks(
985
  cache_examples=False,
986
  )
987
 
988
- # ── Tab 2: KB Manager ────────────────────────────────────
989
  with gr.TabItem("πŸ“ KB Manager"):
990
- gr.HTML("<div style='padding:16px 0 4px'><strong>Manage Knowledge Base Documents</strong><br>"
991
- "<span style='color:#6b7280;font-size:0.88rem'>Upload .txt or .md files Β· Index rebuilds automatically after every change</span></div>")
 
 
 
 
 
992
 
993
  with gr.Row(elem_classes="kb-row"):
994
 
995
- # Left column β€” upload
996
- with gr.Column(scale=2, elem_classes="kb-card"):
997
  upload_input = gr.File(
998
- label="Drop files here or click to browse (.txt / .md, max 10 MB each)",
999
  file_count="multiple",
1000
- file_types=[".txt", ".md"],
1001
- elem_classes="file-drop",
1002
  )
1003
- upload_btn = gr.Button("⬆️ Upload & Index", variant="primary", elem_classes="primary-btn")
1004
- upload_status = gr.Markdown(elem_classes="status-box")
1005
 
1006
- # Right column β€” file list + delete
1007
- with gr.Column(scale=1, elem_classes="kb-card"):
1008
  gr.Markdown("**Current KB Files**")
1009
- kb_file_list = gr.Markdown(value=list_kb_files, elem_classes="kb-file-list")
1010
-
1011
  gr.HTML("<hr style='border:none;border-top:1px solid #e5e7eb;margin:12px 0'>")
1012
  gr.Markdown("**Delete a File**")
1013
- delete_input = gr.Textbox(
1014
- placeholder="Exact filename, e.g. my_doc.md",
1015
- show_label=False,
1016
- elem_classes="gr-text-input",
1017
- )
1018
- delete_btn = gr.Button("πŸ—‘οΈ Delete", variant="stop", elem_classes="danger-btn")
1019
- delete_status = gr.Markdown(elem_classes="status-box")
1020
 
1021
- upload_btn.click(
1022
- fn=upload_kb_files,
1023
- inputs=[upload_input],
1024
- outputs=[upload_status, kb_file_list],
1025
- )
1026
- delete_btn.click(
1027
- fn=delete_kb_file,
1028
- inputs=[delete_input],
1029
- outputs=[delete_status, kb_file_list],
1030
- )
1031
 
1032
 
1033
  # -----------------------------
@@ -1035,13 +733,9 @@ with gr.Blocks(
1035
  # -----------------------------
1036
 
1037
  if __name__ == "__main__":
1038
- is_huggingface = os.getenv("SPACE_ID") is not None
1039
- is_container = os.path.exists("/.dockerenv") or os.getenv("KUBERNETES_SERVICE_HOST") is not None
1040
-
1041
- if is_huggingface or is_container:
1042
- env_label = "πŸ€— HuggingFace Spaces" if is_huggingface else "🐳 container"
1043
- logger.info(f"Launching on {env_label}...")
1044
  demo.launch(server_name="0.0.0.0", server_port=7860)
1045
  else:
1046
- logger.info("πŸ’» Launching locally...")
1047
  demo.launch(share=False)
 
1
  import os
2
  import re
3
  import glob
4
+ import shutil
5
  import threading
 
6
  import logging
7
  from typing import List, Tuple, Optional
8
 
9
+ import faiss
10
  import numpy as np
11
  import yaml
12
+ import gradio as gr
13
  from sentence_transformers import SentenceTransformer
14
 
15
  # -----------------------------
 
19
  logger = logging.getLogger(__name__)
20
 
21
  # -----------------------------
22
+ # CONFIG
23
  # -----------------------------
24
  _CONFIG_PATH = "./config.yaml"
25
 
26
 
27
  def load_config(path: str = _CONFIG_PATH) -> dict:
 
28
  defaults = {
29
+ "kb": {"directory": "./kb", "index_directory": "./.cache/faiss"},
30
+ "models": {
31
+ "embedding": "sentence-transformers/all-MiniLM-L6-v2",
32
+ "qa": "google/flan-t5-small",
33
+ },
34
  "chunking": {"chunk_size": 500, "overlap": 100},
35
+ "retrieval": {"top_k": 5, "min_similarity": 0.25, "max_sentences": 6},
 
36
  "client": {"name": "Self-Service KB Assistant"},
37
  "messages": {
38
+ "welcome": "Ask me anything about the documents in the knowledge base!",
39
+ "no_answer": "I couldn't find anything relevant to your question in the knowledge base.",
40
  },
41
+ "quick_actions": [],
42
  }
43
  if os.path.exists(path):
44
  try:
 
53
  except Exception as e:
54
  logger.warning(f"Could not load {path}: {e}. Using defaults.")
55
  else:
56
+ logger.warning(f"{path} not found β€” using defaults.")
57
  return defaults
58
 
59
 
60
  CONFIG = load_config()
61
 
62
  KB_DIR = CONFIG["kb"]["directory"]
63
+ INDEX_DIR = CONFIG["kb"]["index_directory"]
64
  EMBEDDING_MODEL_NAME = CONFIG["models"]["embedding"]
65
+ QA_MODEL_NAME = CONFIG["models"]["qa"]
66
  CHUNK_SIZE = CONFIG["chunking"]["chunk_size"]
67
  CHUNK_OVERLAP = CONFIG["chunking"]["overlap"]
68
  TOP_K = CONFIG["retrieval"]["top_k"]
69
  MIN_SIMILARITY = CONFIG["retrieval"]["min_similarity"]
70
+ MAX_SENTENCES = CONFIG["retrieval"]["max_sentences"]
71
+ MAX_QUERY_CHARS = 800
72
+
73
+ ALLOWED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx", ".doc"}
74
+ MAX_FILE_SIZE_MB = 10
75
 
76
+ # FAISS index paths
77
+ _IDX_PATH = os.path.join(INDEX_DIR, "kb.index")
78
+ _META_PATH = os.path.join(INDEX_DIR, "kb_meta.npy")
79
 
80
  # -----------------------------
81
+ # FILE LOADING (txt / md / pdf / docx)
82
+ # -----------------------------
83
+
84
+ def load_file_text(path: str) -> str:
85
+ """Extract plain text from .txt, .md, .pdf, or .docx files."""
86
+ ext = os.path.splitext(path)[1].lower()
87
+ try:
88
+ if ext == ".pdf":
89
+ from PyPDF2 import PdfReader
90
+ reader = PdfReader(path)
91
+ parts = []
92
+ for page in reader.pages:
93
+ t = page.extract_text()
94
+ if t:
95
+ parts.append(t)
96
+ return "\n".join(parts)
97
+
98
+ elif ext in (".docx", ".doc"):
99
+ import docx as python_docx
100
+ doc = python_docx.Document(path)
101
+ return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
102
+
103
+ else: # .txt / .md
104
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
105
+ return f.read()
106
+
107
+ except Exception as e:
108
+ logger.error(f"Could not read {path}: {e}")
109
+ raise
110
+
111
+
112
+ def load_kb_documents(kb_dir: str) -> List[Tuple[str, str]]:
113
+ """Load all supported documents from kb_dir. Returns [(filename, text), ...]."""
114
+ docs: List[Tuple[str, str]] = []
115
+ if not os.path.isdir(kb_dir):
116
+ logger.warning(f"KB directory not found: {kb_dir}")
117
+ os.makedirs(kb_dir, exist_ok=True)
118
+ return docs
119
+
120
+ patterns = ["*.txt", "*.md", "*.pdf", "*.docx", "*.doc"]
121
+ paths: List[str] = []
122
+ for pat in patterns:
123
+ paths.extend(glob.glob(os.path.join(kb_dir, pat)))
124
+
125
+ if not paths:
126
+ logger.warning(f"No documents found in {kb_dir}")
127
+ return docs
128
+
129
+ for path in sorted(paths):
130
+ try:
131
+ text = load_file_text(path)
132
+ if text and text.strip():
133
+ docs.append((os.path.basename(path), text))
134
+ logger.info(f"Loaded: {os.path.basename(path)}")
135
+ except Exception as e:
136
+ logger.warning(f"Skipped {path}: {e}")
137
+
138
+ return docs
139
+
140
+ # -----------------------------
141
+ # CHUNKING
142
  # -----------------------------
143
 
144
  def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
145
+ """Sentence-boundary chunking with overlap. Clamps overlap to prevent infinite loops."""
 
 
 
146
  if not text:
147
  return []
 
 
148
  overlap = min(overlap, chunk_size - 1)
149
+ sentence_re = re.compile(r'(?<=[.!?])\s+')
150
+ sentences = sentence_re.split(text.strip())
151
 
152
+ chunks: List[str] = []
153
+ current: List[str] = []
 
 
 
154
  current_len = 0
155
 
156
+ for sent in sentences:
157
+ sent = sent.strip()
158
+ if not sent:
159
  continue
160
+ slen = len(sent)
161
 
 
162
  if slen > chunk_size:
163
+ if current:
164
+ chunks.append(" ".join(current))
165
+ current, current_len = [], 0
 
166
  start = 0
167
  while start < slen:
168
+ chunks.append(sent[start:min(start + chunk_size, slen)].strip())
 
169
  start += chunk_size - overlap
170
  continue
171
 
172
  if current_len + slen + 1 > chunk_size:
173
+ if current:
174
+ chunks.append(" ".join(current))
175
+ carry: List[str] = []
176
+ carry_len = 0
177
+ for s in reversed(current):
178
+ if carry_len + len(s) + 1 > overlap:
 
179
  break
180
+ carry.insert(0, s)
181
+ carry_len += len(s) + 1
182
+ current, current_len = carry, carry_len
 
183
 
184
+ current.append(sent)
185
  current_len += slen + 1
186
 
187
+ if current:
188
+ chunks.append(" ".join(current))
189
 
190
+ return [c for c in chunks if len(c.strip()) > 20]
191
 
192
+ # -----------------------------
193
+ # CONTEXT CLEANING
194
+ # -----------------------------
195
 
196
  def clean_context(text: str) -> str:
197
  """
198
+ Clean retrieved text before scoring:
199
+ - Strip Markdown headings, ordered/unordered list markers
200
+ - Remove duplicate lines and very short noise lines
201
+ - Skip title-like lines (mostly capitalised words)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  """
203
+ lines = text.splitlines()
204
+ cleaned: List[str] = []
205
+ seen: set = set()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
+ for line in lines:
208
+ l = line.strip()
209
+ if not l or len(l) < 5:
210
+ continue
211
+ l = re.sub(r'^#+\s*', '', l) # Markdown headings
212
+ l = re.sub(r'^\d+[\.\)]\s*', '', l) # ordered list markers
213
+ l = re.sub(r'^[-*]\s*', '', l) # bullet markers
214
+ if len(l) < 5:
215
+ continue
216
+ words = l.split()
217
+ cap = sum(1 for w in words if w[:1].isupper())
218
+ if words and len(words) <= 10 and cap >= len(words) - 1:
219
+ continue # looks like a heading/title
220
+ if l in seen:
221
+ continue
222
+ seen.add(l)
223
+ cleaned.append(l)
224
 
225
+ return "\n".join(cleaned)
226
 
227
  # -----------------------------
228
+ # KB INDEX (FAISS + sentence re-ranking)
229
  # -----------------------------
230
 
231
  class KBIndex:
232
+ def __init__(self):
233
+ self.embedder: Optional[SentenceTransformer] = None
234
+ self.chunks: List[str] = []
235
+ self.sources: List[str] = []
236
+ self.index: Optional[faiss.Index] = None
237
+ self._lock = threading.Lock()
238
+
239
+ logger.info("Loading embedding model…")
240
+ self.embedder = SentenceTransformer(EMBEDDING_MODEL_NAME)
241
  logger.info("Embedding model loaded.")
242
 
243
+ os.makedirs(INDEX_DIR, exist_ok=True)
244
+ self._load_or_build()
 
 
 
 
245
 
246
+ # ── Index persistence ──────────────────────────────────────────
 
 
 
 
 
 
 
247
 
248
+ def _load_or_build(self) -> None:
249
+ """Load FAISS index from disk if available, otherwise build from KB docs."""
250
+ if os.path.exists(_IDX_PATH) and os.path.exists(_META_PATH):
251
  try:
252
+ self.index = faiss.read_index(_IDX_PATH)
253
+ meta = np.load(_META_PATH, allow_pickle=True).item()
254
+ self.chunks = list(meta["chunks"])
255
+ self.sources = list(meta["sources"])
256
+ logger.info(f"βœ… FAISS index loaded β€” {len(self.chunks)} chunks.")
257
+ return
 
 
 
 
 
258
  except Exception as e:
259
+ logger.warning(f"Could not load existing index: {e}. Rebuilding…")
260
+
261
+ self._build()
262
+
263
+ def _build(self) -> None:
264
+ """Build a new FAISS index from all KB documents and save to disk."""
265
+ docs = load_kb_documents(KB_DIR)
266
+
267
+ # Fall back to demo content if KB is empty
268
+ if not docs:
269
+ logger.warning("No KB documents found β€” using demo content.")
270
+ demo = (
271
+ "demo_content.txt",
272
+ "Welcome to the Self-Service KB Assistant. "
273
+ "Upload your own .txt, .md, .pdf, or .docx files via the KB Manager tab. "
274
+ "The assistant will index them automatically and answer questions from their content."
275
+ )
276
+ docs = [demo]
277
 
278
  all_chunks: List[str] = []
279
  all_sources: List[str] = []
280
+ for source, text in docs:
281
+ for chunk in chunk_text(text):
 
282
  all_chunks.append(chunk)
283
+ all_sources.append(source)
284
 
285
  if not all_chunks:
286
+ logger.warning("No valid chunks produced.")
287
+ self.index = None
288
  self.chunks = []
289
+ self.sources = []
 
290
  return
291
 
292
+ logger.info(f"Encoding {len(all_chunks)} chunks…")
293
+ embeddings = self.embedder.encode(
294
+ all_chunks, show_progress_bar=False,
295
+ convert_to_numpy=True, batch_size=64,
296
+ ).astype("float32")
297
+ faiss.normalize_L2(embeddings)
298
+
299
+ dim = embeddings.shape[1]
300
+ index = faiss.IndexFlatIP(dim)
301
+ index.add(embeddings)
302
+
303
+ self.index = index
304
+ self.chunks = all_chunks
305
+ self.sources = all_sources
306
 
 
307
  try:
308
+ faiss.write_index(index, _IDX_PATH)
309
+ np.save(_META_PATH, {"chunks": np.array(all_chunks, dtype=object),
310
+ "sources": np.array(all_sources, dtype=object)})
311
+ logger.info("FAISS index saved to disk.")
 
 
 
 
 
312
  except Exception as e:
313
+ logger.warning(f"Could not save index: {e}")
314
 
315
  logger.info("KB index ready.")
316
 
317
+ def rebuild(self) -> str:
318
+ """Force a full rebuild of the index from current KB documents."""
319
+ with self._lock:
320
+ # Remove stale index files so _build always starts fresh
321
+ for p in (_IDX_PATH, _META_PATH):
322
+ if os.path.exists(p):
323
+ os.remove(p)
324
+ self._build()
325
+ if self.index is None or not self.chunks:
326
+ return "⚠️ Index rebuilt but no documents found. Upload files first."
327
+ return f"βœ… Index rebuilt β€” {len(self.chunks)} chunks from {len(set(self.sources))} files."
328
+
329
+ # ── Retrieval + sentence re-ranking ───────────────────────────
330
+
331
+ def retrieve(self, query: str) -> List[Tuple[str, str, float]]:
332
+ """Retrieve top-K chunks via FAISS cosine similarity."""
333
+ if not query.strip() or self.index is None or not self.chunks:
334
  return []
335
+ q_emb = self.embedder.encode([query], convert_to_numpy=True).astype("float32")
336
+ faiss.normalize_L2(q_emb)
337
+ k = min(TOP_K, len(self.chunks))
338
+ scores, idxs = self.index.search(q_emb, k)
339
+ results = []
340
+ for score, idx in zip(scores[0], idxs[0]):
341
+ if idx < 0 or idx >= len(self.chunks):
342
+ continue
343
+ if float(score) < MIN_SIMILARITY:
344
+ continue
345
+ results.append((self.chunks[idx], self.sources[idx], float(score)))
346
+ return results
347
+
348
+ def best_sentences(self, query: str, results: List[Tuple[str, str, float]]) -> str:
349
+ """
350
+ Re-rank at sentence level:
351
+ 1. Split each retrieved chunk into sentences
352
+ 2. Score each sentence with semantic similarity + lexical overlap
353
+ 3. Pick the top MAX_SENTENCES and join them as context for the LLM
354
+ """
355
+ candidate_sents: List[str] = []
356
+ sent_splitter = re.compile(r'(?<=[.!?])\s+|\n+')
357
 
358
+ for chunk, _, _ in results:
359
+ cleaned = clean_context(chunk)
360
+ for s in sent_splitter.split(cleaned):
361
+ s = s.strip()
362
+ if len(s) >= 25:
363
+ candidate_sents.append(s)
364
 
365
+ if not candidate_sents:
366
+ return ""
 
 
 
367
 
368
+ # Semantic scores
369
+ try:
370
+ q_emb = self.embedder.encode([query], convert_to_numpy=True).astype("float32")
371
+ s_embs = self.embedder.encode(candidate_sents, convert_to_numpy=True).astype("float32")
372
+ faiss.normalize_L2(q_emb)
373
+ faiss.normalize_L2(s_embs)
374
+ sims = np.dot(s_embs, q_emb.T).reshape(-1)
375
+ except Exception:
376
+ sims = np.zeros(len(candidate_sents))
377
+
378
+ # Lexical overlap
379
+ q_words = {w.lower() for w in re.findall(r"\w+", query) if len(w) > 3}
380
+ lex = np.array([
381
+ len(q_words & {w.lower() for w in re.findall(r"\w+", s) if len(w) > 3})
382
+ for s in candidate_sents
383
+ ], dtype=float)
384
+
385
+ combined = 1.5 * sims + 0.5 * lex
386
+ top_idx = np.argsort(-combined)
387
+
388
+ chosen: List[str] = []
389
+ seen: set = set()
390
+ for i in top_idx:
391
+ s = candidate_sents[i].strip()
392
+ if s and s not in seen:
393
+ chosen.append(s)
394
+ seen.add(s)
395
+ if len(chosen) >= MAX_SENTENCES:
396
+ break
397
+
398
+ return "\n".join(chosen)
399
 
400
 
401
  # -----------------------------
402
+ # QA MODEL (FLAN-T5 seq2seq β€” lightweight, deterministic)
403
  # -----------------------------
404
 
405
+ qa_tokenizer = None
406
+ qa_model = None
407
+ qa_available = False
408
+ _qa_lock = threading.Lock()
 
 
409
 
410
+ try:
411
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
412
+ logger.info(f"Loading QA model: {QA_MODEL_NAME}…")
413
+ qa_tokenizer = AutoTokenizer.from_pretrained(QA_MODEL_NAME)
414
+ qa_model = AutoModelForSeq2SeqLM.from_pretrained(QA_MODEL_NAME)
415
+ qa_model.eval()
416
+ qa_available = True
417
+ logger.info(f"βœ… QA model loaded ({QA_MODEL_NAME})")
418
+ except Exception as e:
419
+ logger.warning(f"⚠️ Could not load QA model: {e}. Will use extractive fallback.")
420
+
421
+
422
+ def generate_answer(question: str, context: str) -> Optional[str]:
423
+ """
424
+ Generate a grounded answer with FLAN-T5.
425
+ Uses temperature=0 (deterministic) β€” correct for factual KB answers.
426
+ Returns None on failure so the caller can fall back gracefully.
427
+ """
428
+ if not qa_available or qa_tokenizer is None or qa_model is None:
429
+ return None
430
 
431
+ prompt = (
432
+ "You are a warm, helpful assistant. "
433
+ "Answer the question using ONLY the information in the context below. "
434
+ "Always open with a friendly sentence before any list or steps. "
435
+ "When steps are needed, introduce them with a sentence first (e.g. 'Here is what you can do:'). "
436
+ "Keep your tone conversational. "
437
+ "If the context doesn't contain enough information, say so honestly.\n\n"
438
+ f"Context:\n{context}\n\n"
439
+ f"Question: {question}\n\n"
440
+ "Answer:"
441
+ )
442
 
 
443
  try:
444
+ with _qa_lock:
445
+ inputs = qa_tokenizer(
446
+ prompt,
447
+ return_tensors="pt",
448
+ truncation=True,
449
+ max_length=768,
450
+ )
451
+ outputs = qa_model.generate(
452
+ **inputs,
453
+ max_new_tokens=220,
454
+ temperature=1.0, # FLAN-T5 requires temp=1 with do_sample=False
455
+ do_sample=False, # deterministic β€” no random variation in factual answers
456
+ )
457
+ answer = qa_tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
458
+ return answer if answer else None
459
+ except Exception as e:
460
+ logger.error(f"QA generation error: {e}")
461
+ return None
462
 
 
 
463
 
464
+ # -----------------------------
465
+ # INITIALISE INDEX
466
+ # -----------------------------
467
 
468
+ logger.info("Initialising KB index…")
469
+ kb_index = KBIndex()
 
 
470
 
471
 
472
  # -----------------------------
473
+ # CHAT PIPELINE
474
  # -----------------------------
475
 
476
  def normalise_history(history: list) -> List[Tuple[str, str]]:
477
+ """Normalise Gradio history β€” handles both Gradio 4 (tuples) and Gradio 6 (dicts)."""
 
 
 
 
478
  if not history:
479
  return []
480
  if isinstance(history[0], dict):
481
+ pairs: List[Tuple[str, str]] = []
482
  i = 0
483
  while i < len(history):
484
  if history[i].get("role") == "user":
 
493
  else:
494
  i += 1
495
  return pairs
 
496
  return [(str(u or ""), str(a or "")) for u, a in history]
497
 
498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  def build_answer(query: str, history: list) -> str:
500
+ """Full RAG pipeline: FAISS retrieve β†’ sentence re-rank β†’ FLAN-T5 generate."""
501
+ results = kb_index.retrieve(query)
 
 
 
 
 
 
502
 
503
  if not results:
504
  return (
505
+ "I couldn't find anything relevant in the knowledge base for your question.\n\n"
506
+ "**Try:** rephrasing Β· using different keywords Β· breaking it into smaller questions"
 
 
 
507
  )
508
 
509
+ context = kb_index.best_sentences(query, results)
 
 
 
 
510
 
511
+ if not context:
512
  return (
513
+ "I found some content, but couldn't extract sentences relevant enough to answer.\n\n"
514
+ "Please try rephrasing your question."
515
  )
516
 
517
+ answer = generate_answer(query, context)
 
 
 
 
 
 
 
 
518
 
519
+ if answer:
520
+ return answer
 
 
521
 
522
+ # Extractive fallback β€” surface the top re-ranked sentences directly
523
+ return (
524
+ "Here's the most relevant information I found:\n\n"
525
+ + "\n".join(f"- {s}" for s in context.split("\n") if s.strip())
526
+ )
527
 
528
 
529
  def chat_respond(message: str, history: list) -> str:
530
  """Gradio ChatInterface callback."""
531
  if not message or not message.strip():
532
  return "Please ask me a question about the knowledge base."
533
+ query = message.strip()[:MAX_QUERY_CHARS]
 
 
 
 
 
534
  try:
535
  return build_answer(query, history or [])
536
  except Exception as e:
537
+ logger.error(f"chat_respond error: {e}")
538
+ return "Sorry, something went wrong. Please try again."
539
 
540
 
541
  # -----------------------------
542
  # KB FILE MANAGEMENT
543
  # -----------------------------
544
 
 
 
 
 
545
  def list_kb_files() -> str:
546
+ patterns = ["*.txt", "*.md", "*.pdf", "*.docx", "*.doc"]
547
+ paths: List[str] = []
548
+ for pat in patterns:
549
+ paths.extend(glob.glob(os.path.join(KB_DIR, pat)))
550
+ paths = sorted(paths)
551
  if not paths:
552
+ return "_No files yet. Upload documents to get started._"
 
553
  rows = ["| File | Size |", "|------|------|"]
554
  for p in paths:
555
+ rows.append(f"| {os.path.basename(p)} | {os.path.getsize(p)/1024:.1f} KB |")
 
556
  return "\n".join(rows)
557
 
558
 
559
  def upload_kb_files(files) -> Tuple[str, str]:
 
 
 
 
560
  if not files:
561
  return "⚠️ No files selected.", list_kb_files()
 
562
  os.makedirs(KB_DIR, exist_ok=True)
563
+ saved, skipped = [], []
 
 
564
 
565
  for file in files:
 
566
  filepath = file if isinstance(file, str) else file.name
567
+ name = os.path.basename(filepath)
568
+ ext = os.path.splitext(name)[1].lower()
569
 
570
  if ext not in ALLOWED_EXTENSIONS:
571
+ skipped.append(f"{name} (unsupported β€” use .txt .md .pdf .docx)")
572
  continue
573
+ if os.path.getsize(filepath) / (1024 * 1024) > MAX_FILE_SIZE_MB:
574
+ skipped.append(f"{name} (too large β€” max {MAX_FILE_SIZE_MB} MB)")
 
 
575
  continue
576
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  try:
578
+ shutil.copy(filepath, os.path.join(KB_DIR, name))
579
+ saved.append(name)
580
  except Exception as e:
581
+ skipped.append(f"{name} (error: {e})")
 
 
 
582
 
583
+ rebuild_msg = ""
584
  if saved:
585
+ rebuild_msg = kb_index.rebuild()
 
 
 
 
586
 
587
+ parts = []
588
+ if saved: parts.append(f"βœ… **Uploaded:** {', '.join(saved)}")
589
+ if skipped: parts.append(f"⚠️ **Skipped:** {', '.join(skipped)}")
590
+ if rebuild_msg: parts.append(rebuild_msg)
591
+ return "\n\n".join(parts), list_kb_files()
592
 
593
 
594
  def delete_kb_file(filename: str) -> Tuple[str, str]:
595
+ filename = os.path.basename(filename.strip())
 
596
  if not filename:
597
  return "⚠️ No filename provided.", list_kb_files()
 
 
 
598
  ext = os.path.splitext(filename)[1].lower()
599
  if ext not in ALLOWED_EXTENSIONS:
600
+ return f"⚠️ Invalid file: {filename}", list_kb_files()
 
601
  target = os.path.join(KB_DIR, filename)
602
  if not os.path.exists(target):
603
  return f"⚠️ File not found: {filename}", list_kb_files()
 
604
  try:
605
  os.remove(target)
606
+ msg = kb_index.rebuild()
607
+ return f"πŸ—‘οΈ Deleted `{filename}`. {msg}", list_kb_files()
 
608
  except Exception as e:
 
609
  return f"❌ Could not delete `{filename}`: {e}", list_kb_files()
610
 
611
 
612
  # -----------------------------
613
  # GRADIO UI
614
+ # β€” css= not supported in all Gradio versions;
615
+ # inject styles via gr.HTML(<style>) inside the Blocks instead.
616
  # -----------------------------
617
 
618
+ STYLE_HTML = """
619
+ <style>
620
  :root {
621
+ --accent:#4f6ef7; --accent-h:#3a56d4;
622
+ --danger:#ef4444; --danger-h:#dc2626;
623
+ --surface:#fff; --bg:#f4f6fb;
624
+ --border:#e5e7eb; --muted:#6b7280;
625
+ --radius:12px; --shadow:0 2px 12px rgba(0,0,0,.08);
 
 
 
 
 
 
626
  }
627
+ body,.gradio-container{background:var(--bg)!important;
628
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif!important}
629
+ .app-header{background:linear-gradient(135deg,var(--accent) 0%,#7c3aed 100%);
630
+ color:#fff;padding:20px 24px 16px;border-radius:var(--radius) var(--radius) 0 0;margin-bottom:0}
631
+ .app-header h1{margin:0 0 4px;font-size:clamp(1.2rem,4vw,1.6rem);font-weight:700;letter-spacing:-.02em}
632
+ .app-header p{margin:0;opacity:.85;font-size:clamp(.8rem,2.5vw,.92rem)}
633
+ .tabs>.tab-nav{background:var(--surface)!important;border-bottom:2px solid var(--border)!important;padding:0 16px!important;gap:4px!important}
634
+ .tabs>.tab-nav button{border-radius:var(--radius) var(--radius) 0 0!important;padding:10px 18px!important;
635
+ font-weight:500!important;font-size:.88rem!important;color:var(--muted)!important;
636
+ border:none!important;background:transparent!important;transition:color .2s,background .2s!important}
637
+ .tabs>.tab-nav button.selected{color:var(--accent)!important;border-bottom:2px solid var(--accent)!important}
638
+ .message.user>div{background:var(--accent)!important;color:#fff!important;
639
+ border-radius:var(--radius) var(--radius) 4px var(--radius)!important;
640
+ padding:10px 14px!important;max-width:82%!important;
641
+ font-size:clamp(.85rem,2.5vw,.95rem)!important;line-height:1.55!important;box-shadow:var(--shadow)!important}
642
+ .message.bot>div{background:var(--surface)!important;border:1px solid var(--border)!important;
643
+ border-radius:var(--radius) var(--radius) var(--radius) 4px!important;
644
+ padding:10px 14px!important;max-width:88%!important;
645
+ font-size:clamp(.85rem,2.5vw,.95rem)!important;line-height:1.6!important;box-shadow:var(--shadow)!important}
646
+ .chat-input-container textarea{border-radius:24px!important;border:1.5px solid var(--border)!important;
647
+ padding:10px 16px!important;font-size:.93rem!important;resize:none!important;transition:border-color .2s!important}
648
+ .chat-input-container textarea:focus{border-color:var(--accent)!important;outline:none!important;
649
+ box-shadow:0 0 0 3px rgba(79,110,247,.15)!important}
650
+ .examples-table button,.example{border-radius:999px!important;border:1.5px solid var(--accent)!important;
651
+ color:var(--accent)!important;background:transparent!important;padding:6px 14px!important;
652
+ font-size:.82rem!important;font-weight:500!important;transition:background .2s,color .2s!important;white-space:nowrap!important}
653
+ .examples-table button:hover,.example:hover{background:var(--accent)!important;color:#fff!important}
654
+ @media(max-width:640px){
655
+ .gradio-container{padding:0!important}
656
+ .app-header{border-radius:0!important;padding:14px 16px 12px!important}
657
+ .tabs>.tab-nav button{padding:8px 12px!important;font-size:.82rem!important}
658
+ .message.user>div,.message.bot>div{max-width:95%!important;font-size:.88rem!important}
659
+ .kb-row{flex-direction:column!important}
660
+ .kb-row>*{width:100%!important}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  }
662
+ </style>
663
  """
664
 
665
  examples = [
 
668
  if "query" in q
669
  ] or [
670
  "What makes a good knowledge base article?",
671
+ "How could a KB assistant help support agents?",
672
  "Why is self-service important for customer support?",
673
  ]
674
 
675
+ with gr.Blocks(title=CONFIG["client"]["name"]) as demo:
676
+
677
+ # Inject styles β€” compatible with all Gradio versions
678
+ gr.HTML(STYLE_HTML)
679
 
 
680
  gr.HTML(f"""
681
  <div class="app-header">
682
  <h1>πŸ€– {CONFIG["client"]["name"]}</h1>
683
+ <p>Ask me anything β€” I'll search the knowledge base and answer in plain language.</p>
684
  </div>
685
  """)
686
 
687
  with gr.Tabs(elem_classes="tabs"):
688
 
689
+ # ── Tab 1: Chat ──────────────────────────────────────────────
690
  with gr.TabItem("πŸ’¬ Chat"):
691
  gr.ChatInterface(
692
  fn=chat_respond,
 
694
  cache_examples=False,
695
  )
696
 
697
+ # ── Tab 2: KB Manager ────────────────────────────────────────
698
  with gr.TabItem("πŸ“ KB Manager"):
699
+ gr.HTML(
700
+ "<div style='padding:16px 0 4px'>"
701
+ "<strong>Manage Knowledge Base Documents</strong><br>"
702
+ "<span style='color:#6b7280;font-size:.88rem'>"
703
+ "Supported: .txt Β· .md Β· .pdf Β· .docx β€” Index rebuilds automatically after every change"
704
+ "</span></div>"
705
+ )
706
 
707
  with gr.Row(elem_classes="kb-row"):
708
 
709
+ with gr.Column(scale=2):
 
710
  upload_input = gr.File(
711
+ label="Drop files here or click to browse (max 10 MB each)",
712
  file_count="multiple",
713
+ file_types=[".txt", ".md", ".pdf", ".docx", ".doc"],
 
714
  )
715
+ upload_btn = gr.Button("⬆️ Upload & Index", variant="primary")
716
+ upload_status = gr.Markdown()
717
 
718
+ with gr.Column(scale=1):
 
719
  gr.Markdown("**Current KB Files**")
720
+ kb_file_list = gr.Markdown(value=list_kb_files)
 
721
  gr.HTML("<hr style='border:none;border-top:1px solid #e5e7eb;margin:12px 0'>")
722
  gr.Markdown("**Delete a File**")
723
+ delete_input = gr.Textbox(placeholder="Exact filename, e.g. guide.pdf", show_label=False)
724
+ delete_btn = gr.Button("πŸ—‘οΈ Delete", variant="stop")
725
+ delete_status = gr.Markdown()
 
 
 
 
726
 
727
+ upload_btn.click(upload_kb_files, inputs=[upload_input], outputs=[upload_status, kb_file_list])
728
+ delete_btn.click(delete_kb_file, inputs=[delete_input], outputs=[delete_status, kb_file_list])
 
 
 
 
 
 
 
 
729
 
730
 
731
  # -----------------------------
 
733
  # -----------------------------
734
 
735
  if __name__ == "__main__":
736
+ is_hf = os.getenv("SPACE_ID") is not None
737
+ is_container = os.path.exists("/.dockerenv") or bool(os.getenv("KUBERNETES_SERVICE_HOST"))
738
+ if is_hf or is_container:
 
 
 
739
  demo.launch(server_name="0.0.0.0", server_port=7860)
740
  else:
 
741
  demo.launch(share=False)