Spaces:
Sleeping
Sleeping
Update services/kb_creation.py
Browse files- services/kb_creation.py +50 -32
services/kb_creation.py
CHANGED
|
@@ -71,26 +71,24 @@ def _split_by_sections(doc: Document) -> List[Tuple[str, List[str]]]:
|
|
| 71 |
def _paragraphs_to_lines(paragraphs: List[str]) -> List[str]:
|
| 72 |
"""
|
| 73 |
Split paragraphs into bullet-aware lines:
|
| 74 |
-
- Preserve bullets
|
| 75 |
-
- Split long paragraphs by
|
| 76 |
"""
|
| 77 |
lines: List[str] = []
|
| 78 |
for p in (paragraphs or []):
|
| 79 |
p = (p or "").strip()
|
| 80 |
if not p:
|
| 81 |
continue
|
| 82 |
-
# If looks like a bullet/numbered item, keep as is
|
| 83 |
if BULLET_RE.match(p):
|
| 84 |
lines.append(p)
|
| 85 |
continue
|
| 86 |
-
# Otherwise split by sentence boundaries
|
| 87 |
parts = [s.strip() for s in re.split(r"(?<=[.!?])\s+", p) if s.strip()]
|
| 88 |
lines.extend(parts)
|
| 89 |
return lines
|
| 90 |
|
| 91 |
-
def _chunk_text_with_context(doc_title: str, section_title: str, paragraphs: List[str], max_words: int =
|
| 92 |
"""
|
| 93 |
-
Smaller chunks for better recall
|
| 94 |
"""
|
| 95 |
lines = _paragraphs_to_lines(paragraphs)
|
| 96 |
chunks: List[str] = []
|
|
@@ -100,7 +98,6 @@ def _chunk_text_with_context(doc_title: str, section_title: str, paragraphs: Lis
|
|
| 100 |
for ln in lines:
|
| 101 |
w = ln.split()
|
| 102 |
if current_len + len(w) > max_words or (BULLET_RE.match(ln) and current):
|
| 103 |
-
# close current chunk
|
| 104 |
chunk = " ".join(current).strip()
|
| 105 |
if chunk:
|
| 106 |
chunks.append(chunk)
|
|
@@ -220,7 +217,7 @@ def ingest_documents(folder_path: str) -> None:
|
|
| 220 |
total_chunks = 0
|
| 221 |
|
| 222 |
for s_idx, (section_title, paras) in enumerate(sections):
|
| 223 |
-
chunks = _chunk_text_with_context(doc_title, section_title, paras, max_words=
|
| 224 |
total_chunks += len(chunks)
|
| 225 |
|
| 226 |
base_intent = _infer_intent_tag(section_title)
|
|
@@ -255,7 +252,6 @@ def ingest_documents(folder_path: str) -> None:
|
|
| 255 |
except Exception as e2:
|
| 256 |
print(f"[KB] ERROR: Upsert failed for {doc_id}: {e2}")
|
| 257 |
|
| 258 |
-
# Build BM25 index entries
|
| 259 |
tokens = _tokenize(chunk)
|
| 260 |
tf: Dict[str, int] = {}
|
| 261 |
for tkn in tokens:
|
|
@@ -395,7 +391,7 @@ def search_knowledge_base(query: str, top_k: int = 10) -> dict:
|
|
| 395 |
"ids": ids,
|
| 396 |
}
|
| 397 |
|
| 398 |
-
# ---------------------------- Hybrid search (improved) ----------------------------
|
| 399 |
ACTION_SYNONYMS = {
|
| 400 |
"create": ["create", "creation", "add", "new", "generate"],
|
| 401 |
"update": ["update", "modify", "change", "edit"],
|
|
@@ -481,8 +477,12 @@ def _intent_weight(meta: dict, user_intent: str) -> float:
|
|
| 481 |
st = ((meta or {}).get("section", "") or "").lower()
|
| 482 |
topics = (meta or {}).get("topic_tags", "") or ""
|
| 483 |
topic_list = [t.strip() for t in topics.split(",") if t.strip()]
|
| 484 |
-
|
| 485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
if user_intent == "steps" and any(k in st for k in ["inbound receiving", "receiving", "goods receipt", "grn"]):
|
| 487 |
return 0.75
|
| 488 |
return -0.2
|
|
@@ -512,30 +512,26 @@ def _phrase_boost_score(text: str, q_terms: List[str]) -> float:
|
|
| 512 |
score = 0.0
|
| 513 |
for bg in bigrams:
|
| 514 |
if bg and bg in low:
|
| 515 |
-
score += 0.
|
| 516 |
for tg in trigrams:
|
| 517 |
if tg and tg in low:
|
| 518 |
-
score += 0.
|
| 519 |
-
return min(score,
|
| 520 |
|
| 521 |
def _literal_query_match_boost(text: str, query_norm: str) -> float:
|
| 522 |
-
"""
|
| 523 |
-
Extra boost if the exact normalized query substring (or key tokens) appear in the chunk.
|
| 524 |
-
Helps errors like 'item mismatch' pick the right KB line.
|
| 525 |
-
"""
|
| 526 |
t = (text or "").lower()
|
| 527 |
q = (query_norm or "").lower()
|
| 528 |
boost = 0.0
|
| 529 |
if q and q in t:
|
| 530 |
-
boost += 0.
|
| 531 |
-
# Also check key 2-word error tokens present in query (e.g., 'item mismatch')
|
| 532 |
toks = [tok for tok in q.split() if len(tok) > 2]
|
| 533 |
bigrams = _make_ngrams(toks, 2)
|
| 534 |
for bg in bigrams:
|
| 535 |
if bg in t:
|
| 536 |
-
boost += 0.
|
| 537 |
break
|
| 538 |
-
return min(boost, 1.
|
| 539 |
|
| 540 |
def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6, beta: float = 0.4) -> dict:
|
| 541 |
norm_query = _normalize_query(query)
|
|
@@ -544,7 +540,7 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
|
|
| 544 |
actions = _extract_actions(query)
|
| 545 |
user_modules = _extract_modules_from_query(query)
|
| 546 |
|
| 547 |
-
sem_res = search_knowledge_base(norm_query, top_k=max(top_k,
|
| 548 |
sem_docs = sem_res.get("documents", [])
|
| 549 |
sem_metas = sem_res.get("metadatas", [])
|
| 550 |
sem_dists = sem_res.get("distances", [])
|
|
@@ -560,7 +556,7 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
|
|
| 560 |
|
| 561 |
sem_sims = [dist_to_sim(d) for d in sem_dists]
|
| 562 |
|
| 563 |
-
bm25_hits = bm25_search(norm_query, top_k=max(
|
| 564 |
bm25_max = max([s for _, s in bm25_hits], default=1.0)
|
| 565 |
bm25_norm_pairs = [(idx, (score / bm25_max) if bm25_max > 0 else 0.0) for idx, score in bm25_hits]
|
| 566 |
bm25_id_to_norm, bm25_id_to_text, bm25_id_to_meta = {}, {}, {}
|
|
@@ -573,12 +569,12 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
|
|
| 573 |
union_ids = set(sem_ids) | set(bm25_id_to_norm.keys())
|
| 574 |
|
| 575 |
gamma = 0.30 # meta overlap
|
| 576 |
-
delta = 0.
|
| 577 |
epsilon = 0.30 # action weight
|
| 578 |
zeta = 0.65 # module weight
|
| 579 |
-
eta = 0.
|
| 580 |
theta = 0.40 # heading alignment bonus
|
| 581 |
-
iota = 0.
|
| 582 |
|
| 583 |
combined_records_ext: List[Tuple[str, float, float, str, Dict[str, Any], float, float, float, float, float, float, float]] = []
|
| 584 |
for cid in union_ids:
|
|
@@ -631,6 +627,24 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
|
|
| 631 |
(cid, final_score, (sem_dist if sem_dist is not None else 999.0), text, meta, m_overlap, intent_boost, act_wt, mod_wt, phrase_wt, heading_bonus, literal_wt)
|
| 632 |
)
|
| 633 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
from collections import defaultdict
|
| 635 |
doc_groups: Dict[str, List[Tuple[str, float, float, str, Dict[str, Any], float, float, float, float, float, float, float]]] = defaultdict(list)
|
| 636 |
for rec in combined_records_ext:
|
|
@@ -648,8 +662,11 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
|
|
| 648 |
total_heading = sum(r[10] for r in recs)
|
| 649 |
total_literal = sum(r[11] for r in recs)
|
| 650 |
total_penalty = sum(min(0.0, r[6]) for r in recs) + sum(min(0.0, r[7]) for r in recs)
|
| 651 |
-
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
| 653 |
return (
|
| 654 |
total_score
|
| 655 |
+ 0.4 * total_overlap
|
|
@@ -658,9 +675,9 @@ def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6
|
|
| 658 |
+ 0.8 * total_module
|
| 659 |
+ 0.6 * total_phrase
|
| 660 |
+ 0.6 * total_heading
|
| 661 |
-
+ 0.
|
|
|
|
| 662 |
+ 0.3 * total_penalty
|
| 663 |
-
+ esc_weight + perm_weight
|
| 664 |
)
|
| 665 |
|
| 666 |
best_doc, best_doc_prior = None, -1.0
|
|
@@ -734,6 +751,7 @@ def get_best_errors_section_text(filename: str) -> str:
|
|
| 734 |
or "access" in sec
|
| 735 |
or "known issues" in sec
|
| 736 |
or "common issues" in sec
|
|
|
|
| 737 |
or ("permissions" in topic_list)
|
| 738 |
):
|
| 739 |
t = (d.get("text") or "").strip()
|
|
|
|
| 71 |
def _paragraphs_to_lines(paragraphs: List[str]) -> List[str]:
|
| 72 |
"""
|
| 73 |
Split paragraphs into bullet-aware lines:
|
| 74 |
+
- Preserve bullets/numbered list lines as separate atomic lines.
|
| 75 |
+
- Split long paragraphs by sentence boundaries.
|
| 76 |
"""
|
| 77 |
lines: List[str] = []
|
| 78 |
for p in (paragraphs or []):
|
| 79 |
p = (p or "").strip()
|
| 80 |
if not p:
|
| 81 |
continue
|
|
|
|
| 82 |
if BULLET_RE.match(p):
|
| 83 |
lines.append(p)
|
| 84 |
continue
|
|
|
|
| 85 |
parts = [s.strip() for s in re.split(r"(?<=[.!?])\s+", p) if s.strip()]
|
| 86 |
lines.extend(parts)
|
| 87 |
return lines
|
| 88 |
|
| 89 |
+
def _chunk_text_with_context(doc_title: str, section_title: str, paragraphs: List[str], max_words: int = 160) -> List[str]:
|
| 90 |
"""
|
| 91 |
+
Smaller chunks (≈160 words), bullet-aware for better recall of error bullets.
|
| 92 |
"""
|
| 93 |
lines = _paragraphs_to_lines(paragraphs)
|
| 94 |
chunks: List[str] = []
|
|
|
|
| 98 |
for ln in lines:
|
| 99 |
w = ln.split()
|
| 100 |
if current_len + len(w) > max_words or (BULLET_RE.match(ln) and current):
|
|
|
|
| 101 |
chunk = " ".join(current).strip()
|
| 102 |
if chunk:
|
| 103 |
chunks.append(chunk)
|
|
|
|
| 217 |
total_chunks = 0
|
| 218 |
|
| 219 |
for s_idx, (section_title, paras) in enumerate(sections):
|
| 220 |
+
chunks = _chunk_text_with_context(doc_title, section_title, paras, max_words=160)
|
| 221 |
total_chunks += len(chunks)
|
| 222 |
|
| 223 |
base_intent = _infer_intent_tag(section_title)
|
|
|
|
| 252 |
except Exception as e2:
|
| 253 |
print(f"[KB] ERROR: Upsert failed for {doc_id}: {e2}")
|
| 254 |
|
|
|
|
| 255 |
tokens = _tokenize(chunk)
|
| 256 |
tf: Dict[str, int] = {}
|
| 257 |
for tkn in tokens:
|
|
|
|
| 391 |
"ids": ids,
|
| 392 |
}
|
| 393 |
|
| 394 |
+
# ---------------------------- Hybrid search (improved + exact-match rerank) ----------------------------
|
| 395 |
ACTION_SYNONYMS = {
|
| 396 |
"create": ["create", "creation", "add", "new", "generate"],
|
| 397 |
"update": ["update", "modify", "change", "edit"],
|
|
|
|
| 477 |
st = ((meta or {}).get("section", "") or "").lower()
|
| 478 |
topics = (meta or {}).get("topic_tags", "") or ""
|
| 479 |
topic_list = [t.strip() for t in topics.split(",") if t.strip()]
|
| 480 |
+
# Prefer errors sections strongly
|
| 481 |
+
if user_intent == "errors" and (
|
| 482 |
+
any(k in st for k in ["common errors", "known issues", "common issues", "errors", "escalation", "permissions", "access"])
|
| 483 |
+
or ("permissions" in topic_list)
|
| 484 |
+
):
|
| 485 |
+
return 1.10 # stronger than before
|
| 486 |
if user_intent == "steps" and any(k in st for k in ["inbound receiving", "receiving", "goods receipt", "grn"]):
|
| 487 |
return 0.75
|
| 488 |
return -0.2
|
|
|
|
| 512 |
score = 0.0
|
| 513 |
for bg in bigrams:
|
| 514 |
if bg and bg in low:
|
| 515 |
+
score += 0.40
|
| 516 |
for tg in trigrams:
|
| 517 |
if tg and tg in low:
|
| 518 |
+
score += 0.70
|
| 519 |
+
return min(score, 2.0)
|
| 520 |
|
| 521 |
def _literal_query_match_boost(text: str, query_norm: str) -> float:
|
| 522 |
+
"""Extra boost if exact normalized query substring or bigrams appear."""
|
|
|
|
|
|
|
|
|
|
| 523 |
t = (text or "").lower()
|
| 524 |
q = (query_norm or "").lower()
|
| 525 |
boost = 0.0
|
| 526 |
if q and q in t:
|
| 527 |
+
boost += 0.8
|
|
|
|
| 528 |
toks = [tok for tok in q.split() if len(tok) > 2]
|
| 529 |
bigrams = _make_ngrams(toks, 2)
|
| 530 |
for bg in bigrams:
|
| 531 |
if bg in t:
|
| 532 |
+
boost += 0.8
|
| 533 |
break
|
| 534 |
+
return min(boost, 1.6)
|
| 535 |
|
| 536 |
def hybrid_search_knowledge_base(query: str, top_k: int = 10, alpha: float = 0.6, beta: float = 0.4) -> dict:
|
| 537 |
norm_query = _normalize_query(query)
|
|
|
|
| 540 |
actions = _extract_actions(query)
|
| 541 |
user_modules = _extract_modules_from_query(query)
|
| 542 |
|
| 543 |
+
sem_res = search_knowledge_base(norm_query, top_k=max(top_k, 40))
|
| 544 |
sem_docs = sem_res.get("documents", [])
|
| 545 |
sem_metas = sem_res.get("metadatas", [])
|
| 546 |
sem_dists = sem_res.get("distances", [])
|
|
|
|
| 556 |
|
| 557 |
sem_sims = [dist_to_sim(d) for d in sem_dists]
|
| 558 |
|
| 559 |
+
bm25_hits = bm25_search(norm_query, top_k=max(80, top_k * 6))
|
| 560 |
bm25_max = max([s for _, s in bm25_hits], default=1.0)
|
| 561 |
bm25_norm_pairs = [(idx, (score / bm25_max) if bm25_max > 0 else 0.0) for idx, score in bm25_hits]
|
| 562 |
bm25_id_to_norm, bm25_id_to_text, bm25_id_to_meta = {}, {}, {}
|
|
|
|
| 569 |
union_ids = set(sem_ids) | set(bm25_id_to_norm.keys())
|
| 570 |
|
| 571 |
gamma = 0.30 # meta overlap
|
| 572 |
+
delta = 0.55 # intent boost (stronger)
|
| 573 |
epsilon = 0.30 # action weight
|
| 574 |
zeta = 0.65 # module weight
|
| 575 |
+
eta = 0.50 # phrase-level boost (stronger)
|
| 576 |
theta = 0.40 # heading alignment bonus
|
| 577 |
+
iota = 0.60 # literal query match boost (stronger)
|
| 578 |
|
| 579 |
combined_records_ext: List[Tuple[str, float, float, str, Dict[str, Any], float, float, float, float, float, float, float]] = []
|
| 580 |
for cid in union_ids:
|
|
|
|
| 627 |
(cid, final_score, (sem_dist if sem_dist is not None else 999.0), text, meta, m_overlap, intent_boost, act_wt, mod_wt, phrase_wt, heading_bonus, literal_wt)
|
| 628 |
)
|
| 629 |
|
| 630 |
+
# ---- Exact-match rerank for errors ----
|
| 631 |
+
if user_intent == "errors":
|
| 632 |
+
exact_hits = []
|
| 633 |
+
for rec in combined_records_ext:
|
| 634 |
+
text_lower = (rec[3] or "").lower()
|
| 635 |
+
if any(phrase in text_lower for phrase in [
|
| 636 |
+
norm_query, # whole normalized query
|
| 637 |
+
# common 2-gram patterns extracted from the query
|
| 638 |
+
*(_make_ngrams([tok for tok in norm_query.split() if len(tok) > 2], 2))
|
| 639 |
+
]):
|
| 640 |
+
exact_hits.append(rec)
|
| 641 |
+
if exact_hits:
|
| 642 |
+
# Move exact hits to front and keep order by current final_score
|
| 643 |
+
rest = [r for r in combined_records_ext if r not in exact_hits]
|
| 644 |
+
exact_hits.sort(key=lambda x: x[1], reverse=True)
|
| 645 |
+
rest.sort(key=lambda x: x[1], reverse=True)
|
| 646 |
+
combined_records_ext = exact_hits + rest
|
| 647 |
+
|
| 648 |
from collections import defaultdict
|
| 649 |
doc_groups: Dict[str, List[Tuple[str, float, float, str, Dict[str, Any], float, float, float, float, float, float, float]]] = defaultdict(list)
|
| 650 |
for rec in combined_records_ext:
|
|
|
|
| 662 |
total_heading = sum(r[10] for r in recs)
|
| 663 |
total_literal = sum(r[11] for r in recs)
|
| 664 |
total_penalty = sum(min(0.0, r[6]) for r in recs) + sum(min(0.0, r[7]) for r in recs)
|
| 665 |
+
# Errors doc prior: if many chunks are from an errors/known issues section, add a bonus
|
| 666 |
+
errors_section_bonus = 0.0
|
| 667 |
+
if any("error" in ((r[4] or {}).get("section", "")).lower() or "known issues" in ((r[4] or {}).get("section", "")).lower()
|
| 668 |
+
or "common issues" in ((r[4] or {}).get("section", "")).lower() for r in recs):
|
| 669 |
+
errors_section_bonus = 0.5
|
| 670 |
return (
|
| 671 |
total_score
|
| 672 |
+ 0.4 * total_overlap
|
|
|
|
| 675 |
+ 0.8 * total_module
|
| 676 |
+ 0.6 * total_phrase
|
| 677 |
+ 0.6 * total_heading
|
| 678 |
+
+ 0.7 * total_literal
|
| 679 |
+
+ errors_section_bonus
|
| 680 |
+ 0.3 * total_penalty
|
|
|
|
| 681 |
)
|
| 682 |
|
| 683 |
best_doc, best_doc_prior = None, -1.0
|
|
|
|
| 751 |
or "access" in sec
|
| 752 |
or "known issues" in sec
|
| 753 |
or "common issues" in sec
|
| 754 |
+
or "errors" in sec
|
| 755 |
or ("permissions" in topic_list)
|
| 756 |
):
|
| 757 |
t = (d.get("text") or "").strip()
|