srilakshu012456 commited on
Commit
527fe92
·
verified ·
1 Parent(s): 002f705

Update services/kb_creation.py

Browse files
Files changed (1) hide show
  1. services/kb_creation.py +97 -219
services/kb_creation.py CHANGED
@@ -1,16 +1,13 @@
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
  """
4
  services/kb_creation.py
5
 
6
- Single-file replacement to make NOVA's KB search and intent logic generic and
7
- meaning-aware. Intent is classified semantically (not via brittle keywords).
8
- Ranking combines semantic similarity, BM25 lexical signals, and semantic meta
9
- overlap with SOP filename/title/section.
10
-
11
- If EXACT context is found → return full steps from that topic/section.
12
- If only PARTIAL context is found → return concise response clipped to partial
13
- matches.
14
  """
15
 
16
  import os
@@ -22,15 +19,15 @@ from docx import Document
22
  from sentence_transformers import SentenceTransformer
23
  import chromadb
24
 
25
- # --------------------------- ChromaDB setup ---------------------------
26
  CHROMA_PATH = os.path.join(os.getcwd(), "chroma_db")
27
  client = chromadb.PersistentClient(path=CHROMA_PATH)
28
  collection = client.get_or_create_collection(name="knowledge_base")
29
 
30
- # --------------------------- Embedding model --------------------------
31
  model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
32
 
33
- # --------------------------- BM25 (lightweight) -----------------------
34
  BM25_INDEX_FILE = os.path.join(CHROMA_PATH, "bm25_index.pkl")
35
  bm25_docs: List[Dict[str, Any]] = []
36
  bm25_inverted: Dict[str, List[int]] = {}
@@ -40,36 +37,26 @@ bm25_ready: bool = False
40
  BM25_K1 = 1.5
41
  BM25_B = 0.75
42
 
43
- # --------------------------- Utilities --------------------------------
44
  def _tokenize(text: str) -> List[str]:
45
- if not text:
46
- return []
47
  text = text.lower()
48
  return re.findall(r"[a-z0-9]+", text)
49
 
50
-
51
  def _normalize_query(q: str) -> str:
52
  q = (q or "").strip().lower()
53
  q = re.sub(r"[^\w\s]", " ", q)
54
- # remove filler issue words
55
- q = re.sub(
56
- r"\b(facing|get|getting|got|seeing|receiving|encountered|having|observing|issue|problem)\b",
57
- " ",
58
- q,
59
- )
60
  q = re.sub(r"\s+", " ", q).strip()
61
  return q
62
 
63
-
64
  def _tokenize_meta_value(val: Optional[str]) -> List[str]:
65
  return _tokenize(val or "")
66
 
67
- # ---------------------- DOCX parsing & chunking -----------------------
68
-
69
  def _split_by_sections(doc: Document) -> List[Tuple[str, List[str]]]:
70
  sections: List[Tuple[str, List[str]]] = []
71
- current_title = None
72
- current_paras: List[str] = []
73
  for para in doc.paragraphs:
74
  text = (para.text or "").strip()
75
  style_name = (para.style.name if para.style else "") or ""
@@ -77,8 +64,7 @@ def _split_by_sections(doc: Document) -> List[Tuple[str, List[str]]]:
77
  if is_heading and text:
78
  if current_title or current_paras:
79
  sections.append((current_title or "Untitled Section", current_paras))
80
- current_title = text
81
- current_paras = []
82
  else:
83
  if text:
84
  current_paras.append(text)
@@ -89,44 +75,31 @@ def _split_by_sections(doc: Document) -> List[Tuple[str, List[str]]]:
89
  sections = [("Document", all_text)]
90
  return sections
91
 
92
-
93
  def _chunk_text_with_context(doc_title: str, section_title: str, paragraphs: List[str], max_words: int = 900) -> List[str]:
94
  body = "\n".join(paragraphs).strip()
95
- if not body:
96
- return []
97
  words = body.split()
98
  chunks: List[str] = []
99
  for i in range(0, len(words), max_words):
100
  chunk_body = ' '.join(words[i:i + max_words]).strip()
101
  if chunk_body:
102
  chunks.append(chunk_body)
103
- if not chunks:
104
- chunks = [body]
105
  return chunks
106
 
107
- # ---------------------- Intent tagging (section-based) ----------------
108
-
109
  def _infer_intent_tag(section_title: str) -> str:
110
  st = (section_title or "").lower()
111
- if any(k in st for k in ["process steps", "procedure", "how to", "workflow", "instructions"]):
112
- return "steps"
113
- if any(k in st for k in ["common errors", "resolution", "troubleshooting"]):
114
- return "errors"
115
- if any(k in st for k in ["pre-requisites", "prerequisites"]):
116
- return "prereqs"
117
- if any(k in st for k in ["purpose", "overview", "introduction"]):
118
- return "purpose"
119
  return "neutral"
120
 
121
- # ---------------------- Ingestion ------------------------------------
122
-
123
  def ingest_documents(folder_path: str) -> None:
124
- print(f"📂 Checking folder: {folder_path}")
125
  files = [f for f in os.listdir(folder_path) if f.lower().endswith('.docx')]
126
- print(f"Found {len(files)} Word files: {files}")
127
- if not files:
128
- print("⚠️ No .docx files found. Please check the folder path.")
129
- return
130
 
131
  global bm25_docs, bm25_inverted, bm25_df, bm25_avgdl, bm25_ready
132
  bm25_docs, bm25_inverted, bm25_df = [], {}, {}
@@ -137,10 +110,8 @@ def ingest_documents(folder_path: str) -> None:
137
  doc_title = os.path.splitext(file)[0]
138
  doc = Document(file_path)
139
  sections = _split_by_sections(doc)
140
- total_chunks = 0
141
  for s_idx, (section_title, paras) in enumerate(sections):
142
  chunks = _chunk_text_with_context(doc_title, section_title, paras, max_words=900)
143
- total_chunks += len(chunks)
144
  intent_tag = _infer_intent_tag(section_title)
145
  for c_idx, chunk in enumerate(chunks):
146
  embedding = model.encode(chunk).tolist()
@@ -159,13 +130,12 @@ def ingest_documents(folder_path: str) -> None:
159
  try:
160
  collection.delete(ids=[doc_id])
161
  collection.add(ids=[doc_id], embeddings=[embedding], documents=[chunk], metadatas=[meta])
162
- except Exception as e2:
163
- print(f"❌ Upsert failed for {doc_id}: {e2}")
164
 
165
  tokens = _tokenize(chunk)
166
  tf: Dict[str, int] = {}
167
- for t in tokens:
168
- tf[t] = tf.get(t, 0) + 1
169
  idx = len(bm25_docs)
170
  bm25_docs.append({"id": doc_id, "text": chunk, "tokens": tokens, "tf": tf, "length": len(tokens), "meta": meta})
171
  seen = set()
@@ -174,31 +144,21 @@ def ingest_documents(folder_path: str) -> None:
174
  if term not in seen:
175
  bm25_df[term] = bm25_df.get(term, 0) + 1
176
  seen.add(term)
177
- print(f"📄 Ingested {file} → {total_chunks} chunks")
178
 
179
  N = len(bm25_docs)
180
  if N > 0:
181
  bm25_avgdl = sum(d["length"] for d in bm25_docs) / float(N)
182
  bm25_ready = True
183
- payload = {
184
- "bm25_docs": bm25_docs,
185
- "bm25_inverted": bm25_inverted,
186
- "bm25_df": bm25_df,
187
- "bm25_avgdl": bm25_avgdl,
188
- "BM25_K1": BM25_K1,
189
- "BM25_B": BM25_B,
190
- }
191
  os.makedirs(CHROMA_PATH, exist_ok=True)
192
  with open(BM25_INDEX_FILE, "wb") as f:
193
  pickle.dump(payload, f)
194
- print(f"✅ BM25 index saved: {BM25_INDEX_FILE}")
195
- print(f"✅ Documents ingested. Total entries in Chroma: {collection.count()}")
196
-
197
 
198
  def _load_bm25_index() -> None:
199
  global bm25_docs, bm25_inverted, bm25_df, bm25_avgdl, bm25_ready
200
- if not os.path.exists(BM25_INDEX_FILE):
201
- return
202
  try:
203
  with open(BM25_INDEX_FILE, "rb") as f:
204
  payload = pickle.load(f)
@@ -207,122 +167,88 @@ def _load_bm25_index() -> None:
207
  bm25_df = payload.get("bm25_df", {})
208
  bm25_avgdl = payload.get("bm25_avgdl", 0.0)
209
  bm25_ready = len(bm25_docs) > 0
210
- if bm25_ready:
211
- print(f"✅ BM25 index loaded: {BM25_INDEX_FILE} (docs={len(bm25_docs)})")
212
- except Exception as e:
213
- print(f"⚠️ Could not load BM25 index: {e}")
214
-
215
 
216
  _load_bm25_index()
217
 
218
- # ---------------------- BM25 search ----------------------------------
219
-
220
  def _bm25_score_for_doc(query_terms: List[str], doc_idx: int) -> float:
221
- if not bm25_ready or doc_idx < 0 or doc_idx >= len(bm25_docs):
222
- return 0.0
223
  doc = bm25_docs[doc_idx]
224
- score = 0.0
225
- dl = doc["length"] or 1
226
  for term in query_terms:
227
  df = bm25_df.get(term, 0)
228
- if df == 0:
229
- continue
230
  tf = doc["tf"].get(term, 0)
231
- if tf == 0:
232
- continue
233
  N = len(bm25_docs)
234
  idf_ratio = ((N - df + 0.5) / (df + 0.5))
235
- try:
236
- idf = math.log(idf_ratio + 1.0)
237
- except Exception:
238
- idf = 1.0
239
  denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (dl / (bm25_avgdl or 1.0)))
240
  score += idf * ((tf * (BM25_K1 + 1)) / (denom or 1.0))
241
  return score
242
 
243
-
244
  def bm25_search(query: str, top_k: int = 50) -> List[Tuple[int, float]]:
245
- if not bm25_ready:
246
- return []
247
  norm = _normalize_query(query)
248
  q_terms = _tokenize(norm)
249
- if not q_terms:
250
- return []
251
  candidates = set()
252
  for t in q_terms:
253
- for idx in bm25_inverted.get(t, []):
254
- candidates.add(idx)
255
- if not candidates:
256
- candidates = set(range(len(bm25_docs)))
257
  scored = []
258
  for idx in candidates:
259
  s = _bm25_score_for_doc(q_terms, idx)
260
- if s > 0:
261
- scored.append((idx, s))
262
  scored.sort(key=lambda x: x[1], reverse=True)
263
  return scored[:top_k]
264
 
265
- # ---------------------- Semantic-only --------------------------------
 
 
 
266
 
267
  def search_knowledge_base(query: str, top_k: int = 10) -> dict:
268
  query_embedding = model.encode(query).tolist()
269
- res = collection.query(
270
- query_embeddings=[query_embedding],
271
- n_results=top_k,
272
- include=['documents', 'metadatas', 'distances', 'ids']
273
- )
274
  documents = (res.get("documents", [[]]) or [[]])[0]
275
  metadatas = (res.get("metadatas", [[]]) or [[]])[0]
276
  distances = (res.get("distances", [[]]) or [[]])[0]
277
- ids = (res.get("ids", [[]]) or [[]])[0]
278
- return {
279
- "documents": documents,
280
- "metadatas": metadatas,
281
- "distances": distances,
282
- "ids": ids,
283
- }
284
-
285
- # ---------------------- Semantic intent + Hybrid ranking --------------
286
-
287
- # Semantic intent prototypes (generic, wording-agnostic)
288
  INTENT_PROTOTYPES = {
289
- "steps": [
290
- "how to perform", "procedure", "workflow", "instructions",
291
- "steps to accomplish", "operate", "process to follow"
292
- ],
293
- "errors": [
294
- "error condition", "issue troubleshooting", "resolution steps",
295
- "fix failure", "diagnose problem"
296
- ],
297
- "prereqs": [
298
- "pre-requisites", "requirements before starting", "setup needed"
299
- ],
300
- "purpose": [
301
- "overview", "purpose", "introduction", "what is this about"
302
- ],
303
  "neutral": ["general information", "context", "details"],
304
  }
305
-
306
  INTENT_PROTO_VECS = {name: model.encode(" ; ".join(phrases)).tolist() for name, phrases in INTENT_PROTOTYPES.items()}
307
 
308
-
309
  def _cosine(a: list, b: list) -> float:
310
- if not a or not b or len(a) != len(b):
311
- return 0.0
312
- dot = sum(x * y for x, y in zip(a, b))
313
- na = math.sqrt(sum(x * x for x in a)) or 1.0
314
- nb = math.sqrt(sum(y * y for y in b)) or 1.0
315
  return dot / (na * nb)
316
 
317
-
318
  def classify_intent_semantic(query: str, min_margin: float = 0.08) -> str:
319
- """Meaning-based intent classification using sentence embeddings."""
320
  qv = model.encode((query or "").strip()).tolist()
321
  scores = {name: _cosine(qv, vec) for name, vec in INTENT_PROTO_VECS.items()}
322
  best = max(scores.items(), key=lambda kv: kv[1])
323
  second = sorted(scores.values(), reverse=True)[1] if len(scores) > 1 else 0.0
324
- if best[1] - second >= min_margin:
325
- return best[0] if best[0] != "neutral" else "neutral"
326
  return "neutral"
327
 
328
  ACTION_SYNONYMS = {
@@ -332,80 +258,58 @@ ACTION_SYNONYMS = {
332
  "navigate": ["navigate", "go to", "open"],
333
  }
334
 
335
-
336
  def _extract_actions(query: str) -> List[str]:
337
  q = (query or "").lower()
338
  found = []
339
  for act, syns in ACTION_SYNONYMS.items():
340
- if any(s in q for s in syns):
341
- found.append(act)
342
  return found or []
343
 
344
-
345
  def _intent_weight(meta: dict, user_intent: str) -> float:
346
  tag = (meta or {}).get("intent_tag", "neutral")
347
- if user_intent == "neutral":
348
- return 0.0
349
- if tag == user_intent:
350
- return 1.0
351
- if tag in ["purpose", "prereqs"] and user_intent in ["steps", "errors"]:
352
- return -0.6
353
  return -0.2
354
 
355
-
356
  def _meta_overlap(meta: Dict[str, Any], q_terms: List[str]) -> float:
357
  fn_tokens = _tokenize_meta_value(meta.get("filename"))
358
  title_tokens = _tokenize_meta_value(meta.get("title"))
359
  section_tokens = _tokenize_meta_value(meta.get("section"))
360
  meta_tokens = set(fn_tokens + title_tokens + section_tokens)
361
- if not meta_tokens or not q_terms:
362
- return 0.0
363
  qset = set(q_terms)
364
  inter = len(meta_tokens & qset)
365
  return inter / max(1, len(qset))
366
 
367
-
368
  def _semantic_meta_overlap(meta: Dict[str, Any], query_vec: List[float]) -> float:
369
- """Compare query vector to semantic vector of filename/title/section."""
370
- if not meta:
371
- return 0.0
372
  s = " ".join([str(meta.get("filename", "")), str(meta.get("title", "")), str(meta.get("section", ""))]).strip()
373
- if not s:
374
- return 0.0
375
  mv = model.encode(s).tolist()
376
  return max(0.0, _cosine(query_vec, mv))
377
 
378
-
379
  def _action_weight(text: str, actions: List[str]) -> float:
380
- if not actions:
381
- return 0.0
382
  t = (text or "").lower()
383
  score = 0.0
384
  for act in actions:
385
  for syn in ACTION_SYNONYMS.get(act, [act]):
386
- if syn in t:
387
- score += 1.0
388
  conflicts = {"create": ["delete"], "delete": ["create"], "update": ["delete"], "navigate": []}
389
  for act in actions:
390
  for bad in conflicts.get(act, []):
391
  for syn in ACTION_SYNONYMS.get(bad, [bad]):
392
- if syn in t:
393
- score -= 0.8
394
  return score
395
 
396
-
397
  def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6, beta: float = 0.4) -> dict:
398
  norm_query = _normalize_query(query)
399
  q_terms = _tokenize(norm_query)
400
 
401
- # semantic intent
402
- user_intent = classify_intent_semantic(query)
403
  actions = _extract_actions(query)
404
-
405
- # query vector
406
  query_vec = model.encode(norm_query).tolist()
407
 
408
- # semantic results
409
  sem_res = search_knowledge_base(norm_query, top_k=max(top_k, 30))
410
  sem_docs = sem_res.get("documents", [])
411
  sem_metas = sem_res.get("metadatas", [])
@@ -413,16 +317,12 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
413
  sem_ids = sem_res.get("ids", [])
414
 
415
  def dist_to_sim(d: Optional[float]) -> float:
416
- if d is None:
417
- return 0.0
418
- try:
419
- return 1.0 / (1.0 + float(d))
420
- except Exception:
421
- return 0.0
422
 
423
  sem_sims = [dist_to_sim(d) for d in sem_dists]
424
 
425
- # bm25 results
426
  bm25_hits = bm25_search(norm_query, top_k=max(50, top_k * 5))
427
  bm25_max = max([s for _, s in bm25_hits], default=1.0)
428
  bm25_norm_pairs = [(idx, (score / bm25_max) if bm25_max > 0 else 0.0) for idx, score in bm25_hits]
@@ -436,13 +336,12 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
436
  union_ids = set(sem_ids) | set(bm25_id_to_norm.keys())
437
 
438
  # weights
439
- gamma = 0.25 # lexical meta overlap
440
- delta = 0.35 # intent boost
441
- epsilon = 0.25 # action weight
442
- zeta = 0.35 # semantic meta similarity
443
 
444
  combined_records_ext: List[Tuple[str, float, float, str, Dict[str, Any], float, float, float, float]] = []
445
-
446
  for cid in union_ids:
447
  if cid in sem_ids:
448
  pos = sem_ids.index(cid)
@@ -460,22 +359,13 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
460
  text = sem_text if sem_text else bm25_text
461
  meta = sem_meta if sem_meta else bm25_meta
462
 
463
- m_overlap = _meta_overlap(meta, q_terms) # lexical overlap
464
- m_sem = _semantic_meta_overlap(meta, query_vec) # semantic overlap
465
  intent_boost = _intent_weight(meta, user_intent)
466
  act_wt = _action_weight(text, actions)
467
 
468
- final_score = (
469
- alpha * sem_sim +
470
- beta * bm25_sim +
471
- gamma * m_overlap +
472
- zeta * m_sem +
473
- delta * intent_boost +
474
- epsilon * act_wt
475
- )
476
- combined_records_ext.append(
477
- (cid, final_score, (sem_dist if sem_dist is not None else 999.0), text, meta, m_overlap, intent_boost, act_wt, m_sem)
478
- )
479
 
480
  from collections import defaultdict
481
  doc_groups: Dict[str, List[Tuple[str, float, float, str, Dict[str, Any], float, float, float, float]]] = defaultdict(list)
@@ -502,8 +392,7 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
502
  best_recs = sorted(doc_groups.get(best_doc, []), key=lambda x: x[1], reverse=True)
503
  other_recs = []
504
  for fn, recs in doc_groups.items():
505
- if fn == best_doc:
506
- continue
507
  other_recs.extend(recs)
508
  other_recs.sort(key=lambda x: x[1], reverse=True)
509
  reordered = best_recs + other_recs
@@ -526,33 +415,26 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
526
  "actions": actions,
527
  }
528
 
529
- # ---------------------- Section fetch helpers -------------------------
530
-
531
  def get_section_text(filename: str, section: str) -> str:
532
- """Concatenate all chunk texts for a given filename+section."""
533
  texts: List[str] = []
534
  for d in bm25_docs:
535
  m = d.get("meta", {})
536
  if m.get("filename") == filename and m.get("section") == section:
537
  t = (d.get("text") or "").strip()
538
- if t:
539
- texts.append(t)
540
  return "\n\n".join(texts).strip()
541
 
542
-
543
  def get_best_steps_section_text(filename: str) -> str:
544
- """Return combined text of all 'steps' sections in the given SOP (filename)."""
545
  texts: List[str] = []
546
  for d in bm25_docs:
547
  m = d.get("meta", {})
548
  if m.get("filename") == filename and (m.get("intent_tag") == "steps"):
549
  t = (d.get("text") or "").strip()
550
- if t:
551
- texts.append(t)
552
  return "\n\n".join(texts).strip()
553
 
554
- # ---------------------- Admin helpers --------------------------------
555
-
556
  def get_kb_runtime_info() -> Dict[str, Any]:
557
  return {
558
  "chroma_path": CHROMA_PATH,
@@ -563,19 +445,15 @@ def get_kb_runtime_info() -> Dict[str, Any]:
563
  "bm25_ready": bm25_ready,
564
  }
565
 
566
-
567
  def reset_kb(folder_path: str) -> Dict[str, Any]:
568
  result = {"status": "OK", "message": "KB reset and re-ingested"}
569
  try:
570
- try:
571
- client.delete_collection(name="knowledge_base")
572
- except Exception:
573
- pass
574
  global collection
575
  collection = client.get_or_create_collection(name="knowledge_base")
576
  try:
577
- if os.path.isfile(BM25_INDEX_FILE):
578
- os.remove(BM25_INDEX_FILE)
579
  except Exception as e:
580
  result.setdefault("warnings", []).append(f"bm25 index delete: {e}")
581
  os.makedirs(CHROMA_PATH, exist_ok=True)
 
1
+
2
  #!/usr/bin/env python3
3
  # -*- coding: utf-8 -*-
4
  """
5
  services/kb_creation.py
6
 
7
+ Generic, meaning-aware intent & ranking:
8
+ - Semantic intent classification (no keyword rules).
9
+ - Hybrid score = semantic similarity + BM25 + lexical meta overlap + semantic meta overlap.
10
+ - Chroma 'include' excludes 'ids'; IDs synthesized from metadata.
 
 
 
 
11
  """
12
 
13
  import os
 
19
  from sentence_transformers import SentenceTransformer
20
  import chromadb
21
 
22
+ # -------- ChromaDB --------
23
  CHROMA_PATH = os.path.join(os.getcwd(), "chroma_db")
24
  client = chromadb.PersistentClient(path=CHROMA_PATH)
25
  collection = client.get_or_create_collection(name="knowledge_base")
26
 
27
+ # -------- Embeddings --------
28
  model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
29
 
30
+ # -------- BM25 (lightweight) --------
31
  BM25_INDEX_FILE = os.path.join(CHROMA_PATH, "bm25_index.pkl")
32
  bm25_docs: List[Dict[str, Any]] = []
33
  bm25_inverted: Dict[str, List[int]] = {}
 
37
  BM25_K1 = 1.5
38
  BM25_B = 0.75
39
 
40
+ # -------- Utilities --------
41
  def _tokenize(text: str) -> List[str]:
42
+ if not text: return []
 
43
  text = text.lower()
44
  return re.findall(r"[a-z0-9]+", text)
45
 
 
46
  def _normalize_query(q: str) -> str:
47
  q = (q or "").strip().lower()
48
  q = re.sub(r"[^\w\s]", " ", q)
49
+ q = re.sub(r"\b(facing|get|getting|got|seeing|receiving|encountered|having|observing|issue|problem)\b", " ", q)
 
 
 
 
 
50
  q = re.sub(r"\s+", " ", q).strip()
51
  return q
52
 
 
53
  def _tokenize_meta_value(val: Optional[str]) -> List[str]:
54
  return _tokenize(val or "")
55
 
56
+ # -------- DOCX parsing & chunking --------
 
57
  def _split_by_sections(doc: Document) -> List[Tuple[str, List[str]]]:
58
  sections: List[Tuple[str, List[str]]] = []
59
+ current_title, current_paras = None, []
 
60
  for para in doc.paragraphs:
61
  text = (para.text or "").strip()
62
  style_name = (para.style.name if para.style else "") or ""
 
64
  if is_heading and text:
65
  if current_title or current_paras:
66
  sections.append((current_title or "Untitled Section", current_paras))
67
+ current_title, current_paras = text, []
 
68
  else:
69
  if text:
70
  current_paras.append(text)
 
75
  sections = [("Document", all_text)]
76
  return sections
77
 
 
78
  def _chunk_text_with_context(doc_title: str, section_title: str, paragraphs: List[str], max_words: int = 900) -> List[str]:
79
  body = "\n".join(paragraphs).strip()
80
+ if not body: return []
 
81
  words = body.split()
82
  chunks: List[str] = []
83
  for i in range(0, len(words), max_words):
84
  chunk_body = ' '.join(words[i:i + max_words]).strip()
85
  if chunk_body:
86
  chunks.append(chunk_body)
87
+ if not chunks: chunks = [body]
 
88
  return chunks
89
 
90
+ # -------- Intent tag from section (for metadata only) --------
 
91
  def _infer_intent_tag(section_title: str) -> str:
92
  st = (section_title or "").lower()
93
+ if any(k in st for k in ["process steps", "procedure", "how to", "workflow", "instructions"]): return "steps"
94
+ if any(k in st for k in ["common errors", "resolution", "troubleshooting"]): return "errors"
95
+ if any(k in st for k in ["pre-requisites", "prerequisites"]): return "prereqs"
96
+ if any(k in st for k in ["purpose", "overview", "introduction"]): return "purpose"
 
 
 
 
97
  return "neutral"
98
 
99
+ # -------- Ingestion --------
 
100
  def ingest_documents(folder_path: str) -> None:
 
101
  files = [f for f in os.listdir(folder_path) if f.lower().endswith('.docx')]
102
+ if not files: return
 
 
 
103
 
104
  global bm25_docs, bm25_inverted, bm25_df, bm25_avgdl, bm25_ready
105
  bm25_docs, bm25_inverted, bm25_df = [], {}, {}
 
110
  doc_title = os.path.splitext(file)[0]
111
  doc = Document(file_path)
112
  sections = _split_by_sections(doc)
 
113
  for s_idx, (section_title, paras) in enumerate(sections):
114
  chunks = _chunk_text_with_context(doc_title, section_title, paras, max_words=900)
 
115
  intent_tag = _infer_intent_tag(section_title)
116
  for c_idx, chunk in enumerate(chunks):
117
  embedding = model.encode(chunk).tolist()
 
130
  try:
131
  collection.delete(ids=[doc_id])
132
  collection.add(ids=[doc_id], embeddings=[embedding], documents=[chunk], metadatas=[meta])
133
+ except Exception:
134
+ pass
135
 
136
  tokens = _tokenize(chunk)
137
  tf: Dict[str, int] = {}
138
+ for t in tokens: tf[t] = tf.get(t, 0) + 1
 
139
  idx = len(bm25_docs)
140
  bm25_docs.append({"id": doc_id, "text": chunk, "tokens": tokens, "tf": tf, "length": len(tokens), "meta": meta})
141
  seen = set()
 
144
  if term not in seen:
145
  bm25_df[term] = bm25_df.get(term, 0) + 1
146
  seen.add(term)
 
147
 
148
  N = len(bm25_docs)
149
  if N > 0:
150
  bm25_avgdl = sum(d["length"] for d in bm25_docs) / float(N)
151
  bm25_ready = True
152
+
153
+ payload = {"bm25_docs": bm25_docs, "bm25_inverted": bm25_inverted, "bm25_df": bm25_df,
154
+ "bm25_avgdl": bm25_avgdl, "BM25_K1": BM25_K1, "BM25_B": BM25_B}
 
 
 
 
 
155
  os.makedirs(CHROMA_PATH, exist_ok=True)
156
  with open(BM25_INDEX_FILE, "wb") as f:
157
  pickle.dump(payload, f)
 
 
 
158
 
159
  def _load_bm25_index() -> None:
160
  global bm25_docs, bm25_inverted, bm25_df, bm25_avgdl, bm25_ready
161
+ if not os.path.exists(BM25_INDEX_FILE): return
 
162
  try:
163
  with open(BM25_INDEX_FILE, "rb") as f:
164
  payload = pickle.load(f)
 
167
  bm25_df = payload.get("bm25_df", {})
168
  bm25_avgdl = payload.get("bm25_avgdl", 0.0)
169
  bm25_ready = len(bm25_docs) > 0
170
+ except Exception:
171
+ pass
 
 
 
172
 
173
  _load_bm25_index()
174
 
175
+ # -------- BM25 search --------
 
176
  def _bm25_score_for_doc(query_terms: List[str], doc_idx: int) -> float:
177
+ if not bm25_ready or doc_idx < 0 or doc_idx >= len(bm25_docs): return 0.0
 
178
  doc = bm25_docs[doc_idx]
179
+ score, dl = 0.0, (doc["length"] or 1)
 
180
  for term in query_terms:
181
  df = bm25_df.get(term, 0)
182
+ if df == 0: continue
 
183
  tf = doc["tf"].get(term, 0)
184
+ if tf == 0: continue
 
185
  N = len(bm25_docs)
186
  idf_ratio = ((N - df + 0.5) / (df + 0.5))
187
+ try: idf = math.log(idf_ratio + 1.0)
188
+ except Exception: idf = 1.0
 
 
189
  denom = tf + BM25_K1 * (1 - BM25_B + BM25_B * (dl / (bm25_avgdl or 1.0)))
190
  score += idf * ((tf * (BM25_K1 + 1)) / (denom or 1.0))
191
  return score
192
 
 
193
  def bm25_search(query: str, top_k: int = 50) -> List[Tuple[int, float]]:
194
+ if not bm25_ready: return []
 
195
  norm = _normalize_query(query)
196
  q_terms = _tokenize(norm)
197
+ if not q_terms: return []
 
198
  candidates = set()
199
  for t in q_terms:
200
+ for idx in bm25_inverted.get(t, []): candidates.add(idx)
201
+ if not candidates: candidates = set(range(len(bm25_docs)))
 
 
202
  scored = []
203
  for idx in candidates:
204
  s = _bm25_score_for_doc(q_terms, idx)
205
+ if s > 0: scored.append((idx, s))
 
206
  scored.sort(key=lambda x: x[1], reverse=True)
207
  return scored[:top_k]
208
 
209
+ # -------- SAFE Chroma query and semantic-only --------
210
+ def _safe_collection_query(query_embedding, top_k: int):
211
+ base_include = ['documents', 'metadatas', 'distances'] # supported
212
+ return collection.query(query_embeddings=[query_embedding], n_results=top_k, include=base_include)
213
 
214
  def search_knowledge_base(query: str, top_k: int = 10) -> dict:
215
  query_embedding = model.encode(query).tolist()
216
+ res = _safe_collection_query(query_embedding, top_k)
 
 
 
 
217
  documents = (res.get("documents", [[]]) or [[]])[0]
218
  metadatas = (res.get("metadatas", [[]]) or [[]])[0]
219
  distances = (res.get("distances", [[]]) or [[]])[0]
220
+ # synthesize ids
221
+ ids = []
222
+ for i, m in enumerate(metadatas):
223
+ fn = (m or {}).get("filename", "unknown")
224
+ sec = (m or {}).get("section", "section")
225
+ idx = (m or {}).get("chunk_index", i)
226
+ ids.append(f"{fn}:{sec}:{idx}")
227
+ return {"documents": documents, "metadatas": metadatas, "distances": distances, "ids": ids}
228
+
229
+ # -------- Semantic intent + Hybrid ranking --------
 
230
  INTENT_PROTOTYPES = {
231
+ "steps": ["how to perform", "procedure", "workflow", "instructions", "steps to accomplish", "operate", "process to follow"],
232
+ "errors": ["error condition", "issue troubleshooting", "resolution steps", "fix failure", "diagnose problem"],
233
+ "prereqs": ["pre-requisites", "requirements before starting", "setup needed"],
234
+ "purpose": ["overview", "purpose", "introduction", "what is this about"],
 
 
 
 
 
 
 
 
 
 
235
  "neutral": ["general information", "context", "details"],
236
  }
 
237
  INTENT_PROTO_VECS = {name: model.encode(" ; ".join(phrases)).tolist() for name, phrases in INTENT_PROTOTYPES.items()}
238
 
 
239
  def _cosine(a: list, b: list) -> float:
240
+ if not a or not b or len(a) != len(b): return 0.0
241
+ dot = sum(x*y for x, y in zip(a, b))
242
+ na = math.sqrt(sum(x*x for x in a)) or 1.0
243
+ nb = math.sqrt(sum(y*y for y in b)) or 1.0
 
244
  return dot / (na * nb)
245
 
 
246
  def classify_intent_semantic(query: str, min_margin: float = 0.08) -> str:
 
247
  qv = model.encode((query or "").strip()).tolist()
248
  scores = {name: _cosine(qv, vec) for name, vec in INTENT_PROTO_VECS.items()}
249
  best = max(scores.items(), key=lambda kv: kv[1])
250
  second = sorted(scores.values(), reverse=True)[1] if len(scores) > 1 else 0.0
251
+ if best[1] - second >= min_margin: return best[0] if best[0] != "neutral" else "neutral"
 
252
  return "neutral"
253
 
254
  ACTION_SYNONYMS = {
 
258
  "navigate": ["navigate", "go to", "open"],
259
  }
260
 
 
261
  def _extract_actions(query: str) -> List[str]:
262
  q = (query or "").lower()
263
  found = []
264
  for act, syns in ACTION_SYNONYMS.items():
265
+ if any(s in q for s in syns): found.append(act)
 
266
  return found or []
267
 
 
268
  def _intent_weight(meta: dict, user_intent: str) -> float:
269
  tag = (meta or {}).get("intent_tag", "neutral")
270
+ if user_intent == "neutral": return 0.0
271
+ if tag == user_intent: return 1.0
272
+ if tag in ["purpose", "prereqs"] and user_intent in ["steps", "errors"]: return -0.6
 
 
 
273
  return -0.2
274
 
 
275
  def _meta_overlap(meta: Dict[str, Any], q_terms: List[str]) -> float:
276
  fn_tokens = _tokenize_meta_value(meta.get("filename"))
277
  title_tokens = _tokenize_meta_value(meta.get("title"))
278
  section_tokens = _tokenize_meta_value(meta.get("section"))
279
  meta_tokens = set(fn_tokens + title_tokens + section_tokens)
280
+ if not meta_tokens or not q_terms: return 0.0
 
281
  qset = set(q_terms)
282
  inter = len(meta_tokens & qset)
283
  return inter / max(1, len(qset))
284
 
 
285
  def _semantic_meta_overlap(meta: Dict[str, Any], query_vec: List[float]) -> float:
 
 
 
286
  s = " ".join([str(meta.get("filename", "")), str(meta.get("title", "")), str(meta.get("section", ""))]).strip()
287
+ if not s: return 0.0
 
288
  mv = model.encode(s).tolist()
289
  return max(0.0, _cosine(query_vec, mv))
290
 
 
291
  def _action_weight(text: str, actions: List[str]) -> float:
292
+ if not actions: return 0.0
 
293
  t = (text or "").lower()
294
  score = 0.0
295
  for act in actions:
296
  for syn in ACTION_SYNONYMS.get(act, [act]):
297
+ if syn in t: score += 1.0
 
298
  conflicts = {"create": ["delete"], "delete": ["create"], "update": ["delete"], "navigate": []}
299
  for act in actions:
300
  for bad in conflicts.get(act, []):
301
  for syn in ACTION_SYNONYMS.get(bad, [bad]):
302
+ if syn in t: score -= 0.8
 
303
  return score
304
 
 
305
  def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6, beta: float = 0.4) -> dict:
306
  norm_query = _normalize_query(query)
307
  q_terms = _tokenize(norm_query)
308
 
309
+ user_intent = classify_intent_semantic(query) # semantic intent
 
310
  actions = _extract_actions(query)
 
 
311
  query_vec = model.encode(norm_query).tolist()
312
 
 
313
  sem_res = search_knowledge_base(norm_query, top_k=max(top_k, 30))
314
  sem_docs = sem_res.get("documents", [])
315
  sem_metas = sem_res.get("metadatas", [])
 
317
  sem_ids = sem_res.get("ids", [])
318
 
319
  def dist_to_sim(d: Optional[float]) -> float:
320
+ if d is None: return 0.0
321
+ try: return 1.0 / (1.0 + float(d))
322
+ except Exception: return 0.0
 
 
 
323
 
324
  sem_sims = [dist_to_sim(d) for d in sem_dists]
325
 
 
326
  bm25_hits = bm25_search(norm_query, top_k=max(50, top_k * 5))
327
  bm25_max = max([s for _, s in bm25_hits], default=1.0)
328
  bm25_norm_pairs = [(idx, (score / bm25_max) if bm25_max > 0 else 0.0) for idx, score in bm25_hits]
 
336
  union_ids = set(sem_ids) | set(bm25_id_to_norm.keys())
337
 
338
  # weights
339
+ gamma = 0.25 # lexical meta overlap
340
+ delta = 0.35 # intent tag vs user intent
341
+ epsilon = 0.25 # action weight
342
+ zeta = 0.35 # semantic meta similarity (NEW)
343
 
344
  combined_records_ext: List[Tuple[str, float, float, str, Dict[str, Any], float, float, float, float]] = []
 
345
  for cid in union_ids:
346
  if cid in sem_ids:
347
  pos = sem_ids.index(cid)
 
359
  text = sem_text if sem_text else bm25_text
360
  meta = sem_meta if sem_meta else bm25_meta
361
 
362
+ m_overlap = _meta_overlap(meta, q_terms)
363
+ m_sem = _semantic_meta_overlap(meta, query_vec) # NEW semantic meta
364
  intent_boost = _intent_weight(meta, user_intent)
365
  act_wt = _action_weight(text, actions)
366
 
367
+ final_score = alpha * sem_sim + beta * bm25_sim + gamma * m_overlap + zeta * m_sem + delta * intent_boost + epsilon * act_wt
368
+ combined_records_ext.append((cid, final_score, (sem_dist if sem_dist is not None else 999.0), text, meta, m_overlap, intent_boost, act_wt, m_sem))
 
 
 
 
 
 
 
 
 
369
 
370
  from collections import defaultdict
371
  doc_groups: Dict[str, List[Tuple[str, float, float, str, Dict[str, Any], float, float, float, float]]] = defaultdict(list)
 
392
  best_recs = sorted(doc_groups.get(best_doc, []), key=lambda x: x[1], reverse=True)
393
  other_recs = []
394
  for fn, recs in doc_groups.items():
395
+ if fn == best_doc: continue
 
396
  other_recs.extend(recs)
397
  other_recs.sort(key=lambda x: x[1], reverse=True)
398
  reordered = best_recs + other_recs
 
415
  "actions": actions,
416
  }
417
 
418
+ # -------- Section helpers --------
 
419
  def get_section_text(filename: str, section: str) -> str:
 
420
  texts: List[str] = []
421
  for d in bm25_docs:
422
  m = d.get("meta", {})
423
  if m.get("filename") == filename and m.get("section") == section:
424
  t = (d.get("text") or "").strip()
425
+ if t: texts.append(t)
 
426
  return "\n\n".join(texts).strip()
427
 
 
428
  def get_best_steps_section_text(filename: str) -> str:
 
429
  texts: List[str] = []
430
  for d in bm25_docs:
431
  m = d.get("meta", {})
432
  if m.get("filename") == filename and (m.get("intent_tag") == "steps"):
433
  t = (d.get("text") or "").strip()
434
+ if t: texts.append(t)
 
435
  return "\n\n".join(texts).strip()
436
 
437
+ # -------- Admin --------
 
438
  def get_kb_runtime_info() -> Dict[str, Any]:
439
  return {
440
  "chroma_path": CHROMA_PATH,
 
445
  "bm25_ready": bm25_ready,
446
  }
447
 
 
448
  def reset_kb(folder_path: str) -> Dict[str, Any]:
449
  result = {"status": "OK", "message": "KB reset and re-ingested"}
450
  try:
451
+ try: client.delete_collection(name="knowledge_base")
452
+ except Exception: pass
 
 
453
  global collection
454
  collection = client.get_or_create_collection(name="knowledge_base")
455
  try:
456
+ if os.path.isfile(BM25_INDEX_FILE): os.remove(BM25_INDEX_FILE)
 
457
  except Exception as e:
458
  result.setdefault("warnings", []).append(f"bm25 index delete: {e}")
459
  os.makedirs(CHROMA_PATH, exist_ok=True)