sofzcc commited on
Commit
f1b9a06
Β·
verified Β·
1 Parent(s): c89ae51

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +408 -235
app.py CHANGED
@@ -1,66 +1,184 @@
1
  import os
 
2
  import glob
3
- from typing import List, Tuple
4
- import time
 
 
5
 
6
  import gradio as gr
7
  import numpy as np
8
- from sentence_transformers import SentenceTransformer
 
9
 
10
  # -----------------------------
11
- # CONFIG
12
- # -----------------------------
13
- KB_DIR = "./kb" # folder with .txt or .md files
14
- EMBEDDING_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
15
- TOP_K = 3
16
- CHUNK_SIZE = 500 # characters
17
- CHUNK_OVERLAP = 100 # characters
18
- MIN_SIMILARITY_THRESHOLD = 0.3 # Minimum similarity score to include results
19
 
20
  # -----------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  # UTILITIES
22
- # -----------------------------
23
 
24
  def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
25
- """Split long text into overlapping chunks so retrieval is more precise."""
 
 
 
26
  if not text:
27
  return []
28
 
 
 
 
 
29
  chunks = []
30
- start = 0
31
- length = len(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
- while start < length:
34
- end = min(start + chunk_size, length)
35
- chunk = text[start:end].strip()
36
- if chunk:
37
- chunks.append(chunk)
38
- start += chunk_size - overlap
39
 
40
- return chunks
 
 
 
 
 
 
 
41
 
42
 
43
  def load_kb_texts(kb_dir: str = KB_DIR) -> List[Tuple[str, str]]:
44
  """
45
  Load all .txt and .md files from the KB directory.
46
  Returns a list of (source_name, content).
 
47
  """
48
  texts = []
49
 
50
  if os.path.isdir(kb_dir):
51
- paths = glob.glob(os.path.join(kb_dir, "*.txt")) + glob.glob(os.path.join(kb_dir, "*.md"))
52
- for path in paths:
 
 
 
53
  try:
54
  with open(path, "r", encoding="utf-8") as f:
55
  content = f.read()
56
  if content.strip():
57
  texts.append((os.path.basename(path), content))
58
  except Exception as e:
59
- print(f"Could not read {path}: {e}")
60
 
61
- # If no files found, fall back to built-in demo content
62
  if not texts:
63
- print("No KB files found. Using built-in demo content.")
64
  demo_text = """
65
  Welcome to the Self-Service KB Assistant.
66
 
@@ -76,7 +194,7 @@ def load_kb_texts(kb_dir: str = KB_DIR) -> List[Tuple[str, str]]:
76
 
77
  Example use cases for a KB assistant:
78
  - Agents quickly searching for internal procedures.
79
- - Customers asking "how do I…" style questions.
80
  - Managers analyzing gaps in documentation based on repeated queries.
81
  """
82
  texts.append(("demo_content.txt", demo_text))
@@ -84,23 +202,50 @@ def load_kb_texts(kb_dir: str = KB_DIR) -> List[Tuple[str, str]]:
84
  return texts
85
 
86
 
87
- # -----------------------------
88
  # KB INDEX
89
- # -----------------------------
90
 
91
  class KBIndex:
92
- def __init__(self, model_name: str = EMBEDDING_MODEL_NAME):
93
- print("Loading embedding model...")
 
 
 
 
94
  self.model = SentenceTransformer(model_name)
95
- print("Embedding model loaded.")
 
96
  self.chunks: List[str] = []
97
  self.chunk_sources: List[str] = []
98
- self.embeddings = None
 
 
99
  self.build_index()
100
 
101
- def build_index(self):
102
- """Load KB texts, split into chunks, and build an embedding index."""
103
- texts = load_kb_texts(KB_DIR)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  all_chunks = []
105
  all_sources = []
106
 
@@ -110,278 +255,300 @@ class KBIndex:
110
  all_sources.append(source_name)
111
 
112
  if not all_chunks:
113
- print("⚠️ No chunks found for KB index.")
114
  self.chunks = []
115
  self.chunk_sources = []
116
  self.embeddings = None
117
  return
118
 
119
- print(f"Creating embeddings for {len(all_chunks)} chunks...")
120
- embeddings = self.model.encode(all_chunks, show_progress_bar=False, convert_to_numpy=True)
 
 
 
 
 
121
  self.chunks = all_chunks
122
  self.chunk_sources = all_sources
123
  self.embeddings = embeddings
124
- print("KB index ready.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  def search(self, query: str, top_k: int = TOP_K) -> List[Tuple[str, str, float]]:
127
  """Return top-k (chunk, source_name, score) for a given query."""
128
  if not query.strip():
129
  return []
130
-
131
- if self.embeddings is None or not len(self.chunks):
132
  return []
133
 
134
- query_vec = self.model.encode([query], show_progress_bar=False, convert_to_numpy=True)[0]
 
 
135
 
136
- # Cosine similarity
137
- dot_scores = np.dot(self.embeddings, query_vec)
138
- norm_docs = np.linalg.norm(self.embeddings, axis=1)
139
- norm_query = np.linalg.norm(query_vec) + 1e-10
140
- scores = dot_scores / (norm_docs * norm_query + 1e-10)
 
141
 
142
  top_idx = np.argsort(scores)[::-1][:top_k]
143
- results = []
144
- for idx in top_idx:
145
- results.append((self.chunks[idx], self.chunk_sources[idx], float(scores[idx])))
 
 
146
 
147
- return results
 
 
148
 
 
 
149
 
150
- # Initialize KB index
151
- print("Initializing KB index...")
152
- kb_index = KBIndex()
 
 
153
 
154
- # Initialize LLM for answer generation
155
- print("Loading LLM for answer generation...")
156
  try:
157
  from transformers import AutoTokenizer, AutoModelForCausalLM
158
  import torch
159
-
160
- # Use a small but capable model for faster responses
161
- LLM_MODEL_NAME = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" # Fast and good quality
162
-
163
- print(f"Loading {LLM_MODEL_NAME}...")
164
  llm_tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL_NAME)
165
  llm_model = AutoModelForCausalLM.from_pretrained(
166
  LLM_MODEL_NAME,
167
  torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
168
  device_map="auto" if torch.cuda.is_available() else None,
169
  )
170
-
171
  if not torch.cuda.is_available():
172
  llm_model = llm_model.to("cpu")
173
-
174
  llm_model.eval()
175
- print(f"βœ… LLM loaded successfully on {'GPU' if torch.cuda.is_available() else 'CPU'}")
176
  llm_available = True
177
-
 
178
  except Exception as e:
179
- print(f"⚠️ Could not load LLM: {e}")
180
- print("⚠️ Will use fallback mode (direct retrieval)")
181
- llm_available = False
182
- llm_tokenizer = None
183
- llm_model = None
184
 
185
- print("βœ… KB Assistant ready!")
186
 
187
- # -----------------------------
188
- # CHAT LOGIC (With LLM Answer Generation)
189
- # -----------------------------
190
 
191
- def clean_context(text: str) -> str:
192
- """Clean up text for context, removing markdown and excess whitespace."""
193
- # Remove markdown headers
194
- text = text.replace('#', '')
195
- # Remove multiple spaces
196
- text = ' '.join(text.split())
197
- return text.strip()
198
-
199
-
200
- def generate_answer_with_llm(query: str, context: str, sources: List[str]) -> str:
201
  """
202
- Generate a natural, conversational answer using LLM based on retrieved context.
 
203
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  if not llm_available:
205
  return None
206
-
207
- # Create a focused prompt
208
- prompt = f"""<|system|>
209
- You are a helpful knowledge base assistant. Answer the user's question based ONLY on the provided context. Be conversational, clear, and concise. If the context doesn't contain enough information, say so.
210
- </s>
211
- <|user|>
212
- Context from knowledge base:
213
- {context}
214
-
215
- Question: {query}
216
- </s>
217
- <|assistant|>
218
- """
219
-
220
  try:
221
- # Tokenize
222
- inputs = llm_tokenizer(
223
- prompt,
224
- return_tensors="pt",
225
- truncation=True,
226
- max_length=1024
227
- )
228
-
229
- if torch.cuda.is_available():
230
- inputs = {k: v.to("cuda") for k, v in inputs.items()}
231
-
232
- # Generate
233
- with torch.no_grad():
234
- outputs = llm_model.generate(
235
- **inputs,
236
- max_new_tokens=256,
237
- temperature=0.7,
238
- top_p=0.9,
239
- do_sample=True,
240
- pad_token_id=llm_tokenizer.eos_token_id,
241
  )
242
-
243
- # Decode
 
 
 
 
 
 
 
 
 
 
 
 
244
  full_response = llm_tokenizer.decode(outputs[0], skip_special_tokens=True)
245
-
246
- # Extract only the assistant's response
247
  if "<|assistant|>" in full_response:
248
  answer = full_response.split("<|assistant|>")[-1].strip()
249
  else:
250
  answer = full_response.strip()
251
-
252
- # Clean up the answer
253
  answer = answer.replace("</s>", "").strip()
254
-
255
- # Add source attribution
256
  sources_text = ", ".join(sources)
257
- final_answer = f"{answer}\n\n---\nπŸ“š **Sources:** {sources_text}"
258
-
259
- return final_answer
260
-
261
  except Exception as e:
262
- print(f"Error in LLM generation: {e}")
263
  return None
264
 
265
 
266
  def format_fallback_answer(results: List[Tuple[str, str, float]]) -> str:
267
- """
268
- Fallback formatting when LLM is not available or fails.
269
- """
270
  if not results:
271
- return (
272
- "I couldn't find any relevant information in the knowledge base.\n\n"
273
- "**Try:**\n"
274
- "- Rephrasing your question\n"
275
- "- Using different keywords\n"
276
- "- Breaking down complex questions"
 
 
 
 
 
 
277
  )
278
-
279
- # Get best result
280
- best_chunk, best_source, best_score = results[0]
281
-
282
- # Clean markdown
283
- cleaned = clean_context(best_chunk)
284
-
285
- # Format nicely
286
- answer = f"**From {best_source}:**\n\n{cleaned}"
287
-
288
- # Add other sources if available
289
- if len(results) > 1:
290
- other_sources = list(set([src for _, src, _ in results[1:]]))
291
- if other_sources:
292
- answer += f"\n\nπŸ’‘ **Also see:** {', '.join(other_sources)}"
293
-
294
- return answer
295
-
296
-
297
- def build_answer(query: str) -> str:
298
  """
299
- Main answer generation function using LLM for natural responses.
300
-
301
- Process:
302
- 1. Retrieve relevant chunks from KB
303
- 2. Build context from top results
304
- 3. Use LLM to generate natural answer
305
- 4. Cite sources
306
  """
307
- # Step 1: Search the knowledge base
308
  results = kb_index.search(query, top_k=TOP_K)
309
-
310
  if not results:
311
  return (
312
- "I couldn't find any relevant information in the knowledge base to answer your question.\n\n"
313
  "**Suggestions:**\n"
314
  "- Try rephrasing with different words\n"
315
- "- Check if the topic is covered in the KB\n"
316
- "- Be more specific about what you're looking for"
317
  )
318
-
319
- # Step 2: Filter by similarity threshold
320
- filtered_results = [
321
- (chunk, src, score)
322
- for chunk, src, score in results
323
- if score >= MIN_SIMILARITY_THRESHOLD
324
  ]
325
-
326
- if not filtered_results:
327
  return (
328
  "I found some content, but it doesn't seem relevant enough to your question.\n\n"
329
  "Please try being more specific or using different keywords."
330
  )
331
-
332
- # Step 3: Build context from top results
333
  context_parts = []
334
  sources = []
335
-
336
- for chunk, source, score in filtered_results[:2]: # Top 2 most relevant
337
  cleaned = clean_context(chunk)
338
  context_parts.append(cleaned)
339
  if source not in sources:
340
  sources.append(source)
341
-
342
- # Combine context (limit to 1000 chars for speed)
343
- context = " ".join(context_parts)[:1000]
344
-
345
- # Step 4: Generate answer with LLM
346
  if llm_available:
347
- llm_answer = generate_answer_with_llm(query, context, sources)
348
  if llm_answer:
349
  return llm_answer
350
-
351
- # Step 5: Fallback if LLM fails or unavailable
352
- return format_fallback_answer(filtered_results)
353
 
 
354
 
355
- def chat_respond(message: str, history):
356
- """
357
- Gradio ChatInterface callback.
358
-
359
- Args:
360
- message: Latest user message (str)
361
- history: List of previous messages (handled by Gradio)
362
-
363
- Returns:
364
- Assistant's reply as a string
365
- """
366
  if not message or not message.strip():
367
  return "Please ask me a question about the knowledge base."
368
-
 
 
 
 
 
 
369
  try:
370
- answer = build_answer(message.strip())
371
- return answer
372
  except Exception as e:
373
- print(f"Error generating answer: {e}")
374
- return f"Sorry, I encountered an error processing your question: {str(e)}"
375
 
376
 
377
- # -----------------------------
378
  # GRADIO UI
379
- # -----------------------------
380
 
381
- description = """
382
- πŸš€ **Fast Knowledge Base Search Assistant**
383
 
384
- Ask questions and get instant answers from the knowledge base. This assistant uses semantic search to find the most relevant information quickly.
385
 
386
  **Tips for better results:**
387
  - Be specific in your questions
@@ -389,31 +556,37 @@ Ask questions and get instant answers from the knowledge base. This assistant us
389
  - Ask one question at a time
390
  """
391
 
392
- # Create ChatInterface (without 'type' parameter for compatibility)
 
 
 
 
 
 
 
 
 
393
  chat_interface = gr.ChatInterface(
394
  fn=chat_respond,
395
- title="πŸ€– Self-Service KB Assistant",
396
  description=description,
397
- examples=[
398
- "What makes a good knowledge base article?",
399
- "How could a KB assistant help agents?",
400
- "Why is self-service important for customer support?",
401
- ],
402
  cache_examples=False,
 
403
  )
404
 
405
- # Launch
 
 
 
406
  if __name__ == "__main__":
407
- # Detect environment and launch appropriately
408
- is_huggingface = os.getenv('SPACE_ID') is not None
409
- is_container = os.path.exists('/.dockerenv') or os.getenv('KUBERNETES_SERVICE_HOST') is not None
410
-
411
- if is_huggingface:
412
- print("πŸ€— Launching on HuggingFace Spaces...")
413
  chat_interface.launch(server_name="0.0.0.0", server_port=7860)
414
- elif is_container:
415
- print("🐳 Launching in container environment...")
416
- chat_interface.launch(server_name="0.0.0.0", server_port=7860, share=False)
417
  else:
418
- print("πŸ’» Launching locally...")
419
  chat_interface.launch(share=False)
 
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
  # -----------------------------
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 = {
28
+ "kb": {"directory": "./kb"},
29
+ "models": {"embedding": "sentence-transformers/all-MiniLM-L6-v2"},
30
+ "chunking": {"chunk_size": 500, "overlap": 100},
31
+ "retrieval": {"top_k": 3, "min_similarity": 0.3, "context_chars": 1500},
32
+ "llm": {"model": "TinyLlama/TinyLlama-1.1B-Chat-v1.0", "max_new_tokens": 256},
33
+ "client": {"name": "Self-Service KB Assistant"},
34
+ "messages": {
35
+ "welcome": "πŸ‘‹ Ask me anything you'd normally search for in a knowledge base.",
36
+ "no_answer": "❌ I couldn't find anything relevant in the knowledge base for this query.",
37
+ },
38
+ }
39
+ if os.path.exists(path):
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)
47
+ else:
48
+ defaults[key] = val
49
+ logger.info(f"Config loaded from {path}")
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
+ CONFIG = load_config()
57
+
58
+ KB_DIR = CONFIG["kb"]["directory"]
59
+ EMBEDDING_MODEL_NAME = CONFIG["models"]["embedding"]
60
+ CHUNK_SIZE = CONFIG["chunking"]["chunk_size"]
61
+ CHUNK_OVERLAP = CONFIG["chunking"]["overlap"]
62
+ TOP_K = CONFIG["retrieval"]["top_k"]
63
+ 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
+ """
79
+ Split text into overlapping chunks that break on sentence boundaries
80
+ where possible, preventing mid-word or mid-sentence splits.
81
+ """
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:
94
+ sentence = sentence.strip()
95
+ if not sentence:
96
+ continue
97
+ slen = len(sentence)
98
+
99
+ # If a single sentence exceeds chunk_size, force-split it
100
+ if slen > chunk_size:
101
+ if current_chunk:
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)
109
+ chunks.append(sentence[start:end].strip())
110
+ start += chunk_size - overlap
111
+ continue
112
+
113
+ if current_len + slen + 1 > chunk_size:
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:
121
+ break
122
+ overlap_words.insert(0, s)
123
+ overlap_len += len(s) + 1
124
+ current_chunk = overlap_words
125
+ current_len = overlap_len
126
+
127
+ current_chunk.append(sentence)
128
+ current_len += slen + 1
129
+
130
+ if current_chunk:
131
+ chunks.append(" ".join(current_chunk))
132
+
133
+ return [c for c in chunks if c.strip()]
134
+
135
+
136
+ def clean_context(text: str) -> str:
137
+ """
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
 
157
 
158
  def load_kb_texts(kb_dir: str = KB_DIR) -> List[Tuple[str, str]]:
159
  """
160
  Load all .txt and .md files from the KB directory.
161
  Returns a list of (source_name, content).
162
+ Falls back to built-in demo content if directory is empty or missing.
163
  """
164
  texts = []
165
 
166
  if os.path.isdir(kb_dir):
167
+ paths = (
168
+ glob.glob(os.path.join(kb_dir, "*.txt")) +
169
+ glob.glob(os.path.join(kb_dir, "*.md"))
170
+ )
171
+ for path in sorted(paths):
172
  try:
173
  with open(path, "r", encoding="utf-8") as f:
174
  content = f.read()
175
  if content.strip():
176
  texts.append((os.path.basename(path), content))
177
  except Exception as e:
178
+ logger.warning(f"Could not read {path}: {e}")
179
 
 
180
  if not texts:
181
+ logger.warning("No KB files found. Using built-in demo content.")
182
  demo_text = """
183
  Welcome to the Self-Service KB Assistant.
184
 
 
194
 
195
  Example use cases for a KB assistant:
196
  - Agents quickly searching for internal procedures.
197
+ - Customers asking "how do I..." style questions.
198
  - Managers analyzing gaps in documentation based on repeated queries.
199
  """
200
  texts.append(("demo_content.txt", demo_text))
 
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)
217
+ logger.info("Embedding model loaded.")
218
+
219
  self.chunks: List[str] = []
220
  self.chunk_sources: List[str] = []
221
+ self.embeddings: Optional[np.ndarray] = None
222
+ self._fingerprint: Optional[str] = None
223
+
224
  self.build_index()
225
 
226
+ def build_index(self, force: bool = False):
227
+ """
228
+ Load KB texts, split into chunks, and build an embedding index.
229
+ Uses disk cache keyed on KB fingerprint to skip re-encoding on cold start.
230
+ Pass force=True to rebuild regardless of cache.
231
+ """
232
+ texts = load_kb_texts(self.kb_dir)
233
+ fingerprint = kb_fingerprint(self.kb_dir)
234
+
235
+ # Try loading from cache if fingerprint matches
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"]
243
+ self._fingerprint = fingerprint
244
+ logger.info(f"βœ… Loaded {len(self.chunks)} chunks from embedding cache.")
245
+ return
246
+ except Exception as e:
247
+ logger.warning(f"Cache load failed: {e}. Rebuilding index.")
248
+
249
  all_chunks = []
250
  all_sources = []
251
 
 
255
  all_sources.append(source_name)
256
 
257
  if not all_chunks:
258
+ logger.warning("⚠️ No chunks found for KB index.")
259
  self.chunks = []
260
  self.chunk_sources = []
261
  self.embeddings = None
262
  return
263
 
264
+ logger.info(f"Creating embeddings for {len(all_chunks)} chunks...")
265
+ embeddings = self.model.encode(
266
+ all_chunks,
267
+ show_progress_bar=False,
268
+ convert_to_numpy=True,
269
+ batch_size=64,
270
+ )
271
  self.chunks = all_chunks
272
  self.chunk_sources = all_sources
273
  self.embeddings = embeddings
274
+ self._fingerprint = fingerprint
275
+
276
+ # Save cache
277
+ try:
278
+ os.makedirs(_CACHE_DIR, exist_ok=True)
279
+ np.savez_compressed(
280
+ _CACHE_FILE,
281
+ chunks=np.array(all_chunks, dtype=object),
282
+ chunk_sources=np.array(all_sources, dtype=object),
283
+ embeddings=embeddings,
284
+ fingerprint=np.array(fingerprint),
285
+ )
286
+ logger.info("Embedding cache saved.")
287
+ except Exception as e:
288
+ logger.warning(f"Could not save embedding cache: {e}")
289
+
290
+ logger.info("KB index ready.")
291
+
292
+ def reload(self):
293
+ """Rebuild the index from disk, bypassing cache."""
294
+ logger.info("Reloading KB index...")
295
+ self.build_index(force=True)
296
+ logger.info("KB index reloaded.")
297
 
298
  def search(self, query: str, top_k: int = TOP_K) -> List[Tuple[str, str, float]]:
299
  """Return top-k (chunk, source_name, score) for a given query."""
300
  if not query.strip():
301
  return []
302
+ if self.embeddings is None or not self.chunks:
 
303
  return []
304
 
305
+ query_vec = self.model.encode(
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]
317
+ return [
318
+ (self.chunks[i], self.chunk_sources[i], float(scores[i]))
319
+ for i in top_idx
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)
329
 
330
+ 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 build_llm_prompt(query: str, context: str, history: list) -> str:
 
 
 
 
 
 
 
 
 
364
  """
365
+ Build a TinyLlama chat prompt that includes recent conversation history
366
+ so the model can resolve follow-up questions.
367
  """
368
+ system = (
369
+ "You are a helpful knowledge base assistant. "
370
+ "Answer the user's question based ONLY on the provided context. "
371
+ "Be conversational, clear, and concise. "
372
+ "If the context doesn't contain enough information, say so honestly."
373
+ )
374
+
375
+ # Include up to last 3 history turns to stay within context budget
376
+ history_text = ""
377
+ for turn in history[-3:]:
378
+ user_msg = turn.get("role") == "user" and turn.get("content", "")
379
+ asst_msg = turn.get("role") == "assistant" and turn.get("content", "")
380
+ if user_msg:
381
+ history_text += f"<|user|>\n{turn['content']}\n</s>\n"
382
+ elif asst_msg:
383
+ history_text += f"<|assistant|>\n{turn['content']}\n</s>\n"
384
+
385
+ prompt = (
386
+ f"<|system|>\n{system}\n</s>\n"
387
+ f"{history_text}"
388
+ f"<|user|>\n"
389
+ f"Context from knowledge base:\n{context}\n\n"
390
+ f"Question: {query}\n"
391
+ f"</s>\n"
392
+ f"<|assistant|>\n"
393
+ )
394
+ return prompt
395
+
396
+
397
+ def generate_answer_with_llm(
398
+ query: str,
399
+ context: str,
400
+ sources: List[str],
401
+ history: list,
402
+ ) -> Optional[str]:
403
+ """Generate a natural answer using the local LLM. Returns None on failure."""
404
  if not llm_available:
405
  return None
406
+
407
+ import torch
408
+
409
+ prompt = build_llm_prompt(query, context, history)
410
+
 
 
 
 
 
 
 
 
 
411
  try:
412
+ with _llm_lock:
413
+ inputs = llm_tokenizer(
414
+ prompt,
415
+ return_tensors="pt",
416
+ truncation=True,
417
+ max_length=1024,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  )
419
+
420
+ if torch.cuda.is_available():
421
+ inputs = {k: v.to("cuda") for k, v in inputs.items()}
422
+
423
+ with torch.no_grad():
424
+ outputs = llm_model.generate(
425
+ **inputs,
426
+ max_new_tokens=MAX_NEW_TOKENS,
427
+ temperature=0.7,
428
+ top_p=0.9,
429
+ do_sample=True,
430
+ pad_token_id=llm_tokenizer.eos_token_id,
431
+ )
432
+
433
  full_response = llm_tokenizer.decode(outputs[0], skip_special_tokens=True)
434
+
435
+ # Extract only the final assistant turn
436
  if "<|assistant|>" in full_response:
437
  answer = full_response.split("<|assistant|>")[-1].strip()
438
  else:
439
  answer = full_response.strip()
440
+
 
441
  answer = answer.replace("</s>", "").strip()
442
+
 
443
  sources_text = ", ".join(sources)
444
+ return f"{answer}\n\n---\nπŸ“š **Sources:** {sources_text}"
445
+
 
 
446
  except Exception as e:
447
+ logger.error(f"LLM generation error: {e}")
448
  return None
449
 
450
 
451
  def format_fallback_answer(results: List[Tuple[str, str, float]]) -> str:
452
+ """Format retrieved chunks into a readable answer when LLM is unavailable."""
 
 
453
  if not results:
454
+ return CONFIG["messages"]["no_answer"]
455
+
456
+ parts = []
457
+ seen_sources = []
458
+
459
+ for chunk, source, score in results:
460
+ cleaned = clean_context(chunk)
461
+ label = "πŸ₯‡" if not parts else "πŸ’‘"
462
+ confidence = (
463
+ "High" if score >= 0.6 else
464
+ "Medium" if score >= 0.4 else
465
+ "Low"
466
  )
467
+ parts.append(
468
+ f"{label} **From {source}** *(relevance: {confidence} β€” {score:.2f})*\n\n{cleaned}"
469
+ )
470
+ if source not in seen_sources:
471
+ seen_sources.append(source)
472
+
473
+ return "\n\n---\n\n".join(parts)
474
+
475
+
476
+ def build_answer(query: str, history: list) -> str:
 
 
 
 
 
 
 
 
 
 
477
  """
478
+ Main RAG pipeline:
479
+ 1. Retrieve relevant chunks
480
+ 2. Filter by similarity threshold
481
+ 3. Build context from ALL top results (up to TOP_K)
482
+ 4. Generate answer via LLM (or fallback)
 
 
483
  """
 
484
  results = kb_index.search(query, top_k=TOP_K)
485
+
486
  if not results:
487
  return (
488
+ "I couldn't find any relevant information in the knowledge base.\n\n"
489
  "**Suggestions:**\n"
490
  "- Try rephrasing with different words\n"
491
+ "- Use more specific keywords\n"
492
+ "- Break complex questions into smaller ones"
493
  )
494
+
495
+ filtered = [
496
+ (chunk, src, score)
497
+ for chunk, src, score in results
498
+ if score >= MIN_SIMILARITY
 
499
  ]
500
+
501
+ if not filtered:
502
  return (
503
  "I found some content, but it doesn't seem relevant enough to your question.\n\n"
504
  "Please try being more specific or using different keywords."
505
  )
506
+
507
+ # Build context from ALL filtered results (not just top 2), capped by CONTEXT_CHARS
508
  context_parts = []
509
  sources = []
510
+ for chunk, source, _ in filtered: # uses all TOP_K results
 
511
  cleaned = clean_context(chunk)
512
  context_parts.append(cleaned)
513
  if source not in sources:
514
  sources.append(source)
515
+
516
+ context = " ".join(context_parts)[:CONTEXT_CHARS]
517
+
 
 
518
  if llm_available:
519
+ llm_answer = generate_answer_with_llm(query, context, sources, history)
520
  if llm_answer:
521
  return llm_answer
 
 
 
522
 
523
+ return format_fallback_answer(filtered)
524
 
525
+
526
+ def chat_respond(message: str, history: list) -> str:
527
+ """Gradio ChatInterface callback."""
 
 
 
 
 
 
 
 
528
  if not message or not message.strip():
529
  return "Please ask me a question about the knowledge base."
530
+
531
+ # Guard against excessively long queries
532
+ query = message.strip()
533
+ if len(query) > MAX_QUERY_CHARS:
534
+ query = query[:MAX_QUERY_CHARS]
535
+ logger.warning(f"Query truncated to {MAX_QUERY_CHARS} characters.")
536
+
537
  try:
538
+ return build_answer(query, history or [])
 
539
  except Exception as e:
540
+ logger.error(f"Error generating answer: {e}")
541
+ return "Sorry, something went wrong while processing your question. Please try again."
542
 
543
 
544
+ # -----------------------------
545
  # GRADIO UI
546
+ # -----------------------------
547
 
548
+ description = f"""
549
+ πŸš€ **{CONFIG['client']['name']}**
550
 
551
+ Ask questions and get instant answers from the knowledge base using semantic search.
552
 
553
  **Tips for better results:**
554
  - Be specific in your questions
 
556
  - Ask one question at a time
557
  """
558
 
559
+ examples = [
560
+ q["query"]
561
+ for q in CONFIG.get("quick_actions", [])
562
+ if "query" in q
563
+ ] or [
564
+ "What makes a good knowledge base article?",
565
+ "How could a KB assistant help agents?",
566
+ "Why is self-service important for customer support?",
567
+ ]
568
+
569
  chat_interface = gr.ChatInterface(
570
  fn=chat_respond,
571
+ title=f"πŸ€– {CONFIG['client']['name']}",
572
  description=description,
573
+ examples=examples,
 
 
 
 
574
  cache_examples=False,
575
+ type="messages", # Use modern messages format for proper history passing
576
  )
577
 
578
+ # -----------------------------
579
+ # LAUNCH
580
+ # -----------------------------
581
+
582
  if __name__ == "__main__":
583
+ is_huggingface = os.getenv("SPACE_ID") is not None
584
+ is_container = os.path.exists("/.dockerenv") or os.getenv("KUBERNETES_SERVICE_HOST") is not None
585
+
586
+ if is_huggingface or is_container:
587
+ env_label = "πŸ€— HuggingFace Spaces" if is_huggingface else "🐳 container"
588
+ logger.info(f"Launching on {env_label}...")
589
  chat_interface.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
590
  else:
591
+ logger.info("πŸ’» Launching locally...")
592
  chat_interface.launch(share=False)