srilakshu012456 commited on
Commit
b37cdba
·
verified ·
1 Parent(s): b607009

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +78 -155
main.py CHANGED
@@ -216,6 +216,61 @@ def _get_steps_by_title_keywords_global(keywords: List[str], prefer_doc: Optiona
216
  return best_doc_g, best_sec_g, best_text_g
217
 
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  # --- Action-targeted steps selector (uses existing KB metadata) ---
220
  def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
221
  """
@@ -227,7 +282,6 @@ def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
227
  if not best_doc or not actions:
228
  return None
229
  act_set = set(a.strip().lower() for a in actions if a)
230
- # Collect candidate sections in this doc that are 'steps' and have an action_tag we need
231
  candidates = []
232
  for d in bm25_docs:
233
  m = d.get("meta", {})
@@ -235,7 +289,6 @@ def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
235
  tag = (m.get("action_tag") or "").strip().lower()
236
  if tag and tag in act_set:
237
  candidates.append(m.get("section"))
238
- # Prefer the first matched section with non-empty text
239
  for title in candidates:
240
  txt = get_section_text(best_doc, title)
241
  if txt and txt.strip():
@@ -402,11 +455,6 @@ def _norm_text(s: str) -> str:
402
 
403
 
404
  def _split_sop_into_steps(numbered_text: str) -> list:
405
- """
406
- Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
407
- into atomic steps. Returns a list of raw step strings (order preserved).
408
- Safe for circled digits, '1.' styles, and bullets.
409
- """
410
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
411
  steps = []
412
  for ln in lines:
@@ -417,7 +465,6 @@ def _split_sop_into_steps(numbered_text: str) -> list:
417
 
418
 
419
  def _soft_match_score(a: str, b: str) -> float:
420
- # Simple Jaccard-like score on tokens for fuzzy matching
421
  ta = set(_norm_text(a).split())
422
  tb = set(_norm_text(b).split())
423
  if not ta or not tb:
@@ -437,38 +484,27 @@ def _detect_next_intent(user_query: str) -> bool:
437
 
438
 
439
  def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
440
- """
441
- If 'what's next' intent is detected and we can reliably match the user's
442
- referenced line to a SOP step, return ONLY the subsequent steps.
443
- Else return None to preserve current behavior.
444
- """
445
  if not _detect_next_intent(user_query):
446
  return None
447
-
448
  steps = _split_sop_into_steps(numbered_text)
449
  if not steps:
450
  return None
451
-
452
  q = user_query or ""
453
  best_idx, best_score = -1, -1.0
454
  for idx, step in enumerate(steps):
455
  score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
456
  if score > best_score:
457
  best_score, best_idx = score, idx
458
-
459
  if best_idx < 0 or best_score < min_score:
460
- return None # fallback to full SOP
461
-
462
  start = best_idx + 1
463
  if start >= len(steps):
464
- return [] # user is at final step
465
-
466
  end = min(start + max_next, len(steps))
467
  return steps[start:end]
468
 
469
 
470
  def _format_steps_as_numbered(steps: list) -> str:
471
- """Render a small list of steps with circled numbers for visual continuity."""
472
  circled = {
473
  1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
474
  6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
@@ -482,17 +518,6 @@ def _format_steps_as_numbered(steps: list) -> str:
482
 
483
 
484
  def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
485
- """
486
- Pick the most relevant 'Common Errors & Resolution' bullet(s) for the user's message.
487
- Generic (SOP-agnostic) scoring:
488
- 1) error-family match (NOT_FOUND/MISMATCH/LOCKED/PERMISSION/TIMEOUT/SYNC),
489
- 2) anchored starts (line begins with error heading),
490
- 3) multi-word overlap (bigrams/trigrams),
491
- 4) token overlap,
492
- 5) formatting bonus for bullets/headings.
493
-
494
- Returns exactly `max_lines` best-scoring lines (defaults to 1).
495
- """
496
  def _norm(s: str) -> str:
497
  s = (s or "").lower()
498
  s = re.sub(r"[^\w\s]", " ", s)
@@ -546,7 +571,7 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
546
  0.30 * token_overlap
547
  )
548
 
549
- if re.match(r"^\s*[-*\u2022]\s*", ln): # bullet
550
  score += 0.10
551
  heading = ln_norm.split(":")[0].strip()
552
  if heading and (heading in q or (first2 and first2 in heading)):
@@ -617,7 +642,6 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
617
  any(w in msg_norm for w in ("ticket", "incident", "servicenow", "snow")) or
618
  bool(re.search(r"\binc\d{5,}\b", msg_norm, flags=re.IGNORECASE))
619
  )
620
- # Disambiguation: if it's a domain status query and not clearly ticket/incident, do NOT route to ticket-status.
621
  if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
622
  return {}
623
  patterns = [
@@ -693,11 +717,6 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
693
 
694
 
695
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
696
- """
697
- Collect error bullets/heading-style lines from the SOP errors section.
698
- Generic: keeps bullet points (•, -, *), and lines that look like "Heading: details".
699
- This ensures items like 'ASN not found', 'Trailer status locked', etc., are preserved.
700
- """
701
  kept: List[str] = []
702
  for ln in _normalize_lines(text):
703
  if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
@@ -768,7 +787,7 @@ def _classify_resolution_llm(user_message: str) -> bool:
768
  Return only 'true' or 'false'.
769
  Message: {user_message}"""
770
  headers = {"Content-Type": "application/json"}
771
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
772
  try:
773
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
774
  data = resp.json()
@@ -846,7 +865,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
846
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
847
  payload_C = clean({
848
  "state": "6",
849
- code_field: close_notes_val, # (if you have custom fields, adjust here)
850
  notes_field: close_notes_val,
851
  "caller_id": caller_sysid,
852
  "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
@@ -866,10 +885,6 @@ def _set_incident_resolved(sys_id: str) -> bool:
866
 
867
  # ------------------------------ Prereq helper ------------------------------
868
  def _find_prereq_section_text(best_doc: str) -> str:
869
- """
870
- Return the prerequisites section text, trying common heading variants.
871
- Generic for future SOPs—no document-specific keywords.
872
- """
873
  variants = [
874
  "Pre-Requisites",
875
  "Prerequisites",
@@ -1044,7 +1059,7 @@ async def chat_with_ai(input_data: ChatInput):
1044
  except Exception as e:
1045
  raise HTTPException(status_code=500, detail=safe_str(e))
1046
 
1047
- # Hybrid KB search
1048
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
1049
  documents = kb_results.get("documents", [])
1050
  metadatas = kb_results.get("metadatas", [])
@@ -1068,11 +1083,10 @@ async def chat_with_ai(input_data: ChatInput):
1068
  selected = items[:max(1, 2)]
1069
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
1070
 
1071
- # Compute filter info for gating only; do NOT use the filtered text for steps
1072
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
1073
  filtered_context = filtered_text
1074
 
1075
- context = context_raw # keep raw; we'll decide below
1076
  context_found = bool(context.strip())
1077
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
1078
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
@@ -1110,7 +1124,7 @@ async def chat_with_ai(input_data: ChatInput):
1110
  if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
1111
  detected_intent = "prereqs"
1112
 
1113
- # --- Module-aware steps nudge (appointments, picking, shipping, etc.) ---
1114
  STEPS_TERMS = ("how to", "procedure", "perform", "steps", "do", "navigate")
1115
  ACTIONS_PRESENT = any(s in msg_low for syns in ACTION_SYNONYMS_EXT.values() for s in syns)
1116
  mod_tags = ((top_meta or {}).get("module_tags") or "").lower()
@@ -1194,97 +1208,6 @@ async def chat_with_ai(input_data: ChatInput):
1194
  return act
1195
  return None
1196
 
1197
- def _strip_boilerplate(raw_context: str) -> str:
1198
- """Remove document title/date/author/change-history noise from steps."""
1199
- MONTH_TERMS = ("january", "february", "march", "april", "may", "june",
1200
- "july", "august", "september", "october", "november", "december")
1201
- lines = _normalize_lines(raw_context)
1202
- cleaned: List[str] = []
1203
- for ln in lines:
1204
- low = ln.lower()
1205
- is_change_hist = ("change history" in low) or ("initial draft" in low) or ("review" in low) or ("version" in low)
1206
- has_month_year = any(m in low for m in MONTH_TERMS) and bool(re.search(r"\b20\d{2}\b", low))
1207
- is_title_line = ("sop document" in low) or ("contents" in low)
1208
- if is_change_hist or has_month_year or is_title_line:
1209
- continue
1210
- cleaned.append(ln)
1211
- return "\n".join(cleaned).strip()
1212
-
1213
- def _extract_action_block(raw_context: str, target_act: Optional[str]) -> str:
1214
- """
1215
- Extract the contiguous block of lines for the target action (create/update/delete).
1216
- Start when a line mentions the target action OR looks procedural for 'create',
1217
- and stop ONLY when a line is a clear boundary:
1218
- - inline heading for another topic (e.g., 'Appointment schedule updation', 'Appointment deletion'), OR
1219
- - a line that strongly signals a different action (update/delete) via extended synonyms.
1220
- """
1221
- if not raw_context.strip():
1222
- return raw_context
1223
-
1224
- lines = _normalize_lines(raw_context)
1225
- if not lines or not target_act:
1226
- return raw_context
1227
-
1228
- INLINE_BOUNDARIES = (
1229
- "appointment schedule updation",
1230
- "schedule updation",
1231
- "appointment deletion",
1232
- "deletion",
1233
- )
1234
-
1235
- other_terms: List[str] = []
1236
- for act, syns in ACTION_SYNONYMS_EXT.items():
1237
- if act != target_act:
1238
- other_terms.extend(syns)
1239
- other_terms_low = set(t.lower() for t in other_terms)
1240
-
1241
- def is_boundary_line(low: str) -> bool:
1242
- if any(h in low for h in INLINE_BOUNDARIES):
1243
- return True
1244
- if any(t in low for t in other_terms_low):
1245
- return True
1246
- return False
1247
-
1248
- PROCEDURAL_VERBS = ("select", "choose", "click", "open", "add", "assign", "save",
1249
- "navigate", "tag", "displayed", "triggered")
1250
- def is_procedural(low: str) -> bool:
1251
- return any(v in low for v in PROCEDURAL_VERBS)
1252
-
1253
- target_terms_low = set(t.lower() for t in ACTION_SYNONYMS_EXT.get(target_act, []))
1254
-
1255
- started = False
1256
- block: List[str] = []
1257
-
1258
- for ln in lines:
1259
- low = ln.lower()
1260
-
1261
- contains_target = any(t in low for t in target_terms_low)
1262
- if not started:
1263
- if contains_target or (target_act == "create" and is_procedural(low)):
1264
- started = True
1265
- block.append(ln)
1266
- continue
1267
-
1268
- if is_boundary_line(low):
1269
- break
1270
-
1271
- block.append(ln)
1272
-
1273
- return "\n".join(block).strip() if block else raw_context
1274
-
1275
- def _filter_steps_by_action(raw_context: str, asked_act: Optional[str]) -> str:
1276
- cleaned = _strip_boilerplate(raw_context)
1277
- block = _extract_action_block(cleaned, asked_act)
1278
- if asked_act:
1279
- other_terms: List[str] = []
1280
- for act, syns in ACTION_SYNONYMS_EXT.items():
1281
- if act != asked_act:
1282
- other_terms.extend(syns)
1283
- lines = _normalize_lines(block)
1284
- lines = [ln for ln in lines if not any(t in ln.lower() for t in other_terms)]
1285
- block = "\n".join(lines).strip() if lines else block
1286
- return block
1287
-
1288
  escalation_line = None
1289
  full_errors = None
1290
  next_step_applied = False
@@ -1315,7 +1238,7 @@ async def chat_with_ai(input_data: ChatInput):
1315
  best_doc = alt_doc2
1316
  full_steps = alt_steps2
1317
 
1318
- # 1c) Title-keywords global fallback (robust when action_tag is missing)
1319
  if not full_steps:
1320
  if asked_action == "update":
1321
  kd = ["updation", "update", "reschedule"]
@@ -1330,7 +1253,16 @@ async def chat_with_ai(input_data: ChatInput):
1330
  best_doc = alt_doc3
1331
  full_steps = alt_steps3
1332
 
1333
- # Existing local fallbacks
 
 
 
 
 
 
 
 
 
1334
  if not full_steps:
1335
  default_sec = _pick_default_action_section(best_doc)
1336
  if default_sec:
@@ -1350,11 +1282,8 @@ async def chat_with_ai(input_data: ChatInput):
1350
  if not any(s in low_steps for s in SAVE_SYNS):
1351
  full_steps = (full_steps or "").rstrip() + "\n" + save_lines
1352
 
1353
- # Remove aggressive trimming; keep full section
1354
- asked_action = _detect_action_from_query(input_data.user_message)
1355
- full_steps = _filter_steps_by_action(full_steps, asked_action)
1356
-
1357
- # Number steps and apply next-step logic
1358
  numbered_full = _ensure_numbering(full_steps)
1359
  next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
1360
 
@@ -1373,7 +1302,6 @@ async def chat_with_ai(input_data: ChatInput):
1373
  context = full_steps
1374
  context_preformatted = False
1375
 
1376
- # Clear filter info for debug clarity
1377
  filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
1378
  context_found = True
1379
 
@@ -1399,8 +1327,6 @@ async def chat_with_ai(input_data: ChatInput):
1399
  else:
1400
  full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
1401
  if full_steps:
1402
- asked_action = _detect_action_from_query(input_data.user_message)
1403
- full_steps = _filter_steps_by_action(full_steps, asked_action)
1404
  context = full_steps
1405
  detected_intent = "steps"
1406
  context_preformatted = False
@@ -1412,14 +1338,11 @@ async def chat_with_ai(input_data: ChatInput):
1412
  else:
1413
  full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
1414
  if full_steps:
1415
- asked_action = _detect_action_from_query(input_data.user_message)
1416
- full_steps = _filter_steps_by_action(full_steps, asked_action)
1417
  context = full_steps
1418
  detected_intent = "steps"
1419
  context_preformatted = False
1420
 
1421
  else:
1422
- # Neutral or other intents: use filtered context
1423
  context = filtered_context
1424
 
1425
  language_hint = _detect_language_hint(input_data.user_message)
@@ -1436,7 +1359,7 @@ async def chat_with_ai(input_data: ChatInput):
1436
  ### Output
1437
  Return ONLY the rewritten guidance."""
1438
  headers = {"Content-Type": "application/json"}
1439
- payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
1440
  bot_text = ""
1441
  http_code = 0
1442
  if use_gemini and GEMINI_API_KEY:
@@ -1548,7 +1471,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
1548
  "Do not include any extra text, comments, or explanations outside the JSON."
1549
  )
1550
  headers = {"Content-Type": "application/json"}
1551
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
1552
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
1553
  try:
1554
  data = resp.json()
 
216
  return best_doc_g, best_sec_g, best_text_g
217
 
218
 
219
+ # --- Title or text global selector (ultimate fallback) ---
220
+ def _get_steps_by_title_or_text_global(keywords: List[str], prefer_doc: Optional[str] = None) -> Tuple[Optional[str], Optional[str], Optional[str]]:
221
+ """
222
+ Search ALL SOP docs for a 'steps' section where either the section title OR any step text
223
+ contains any of `keywords`. Returns (doc_name, section_title, steps_text).
224
+ """
225
+ if not keywords:
226
+ return None, None, None
227
+ keys_low = [k.strip().lower() for k in keywords if k and k.strip()]
228
+
229
+ # (score, doc, sec, text)
230
+ candidates: List[Tuple[float, str, str, str]] = []
231
+ seen = set()
232
+
233
+ for d in bm25_docs:
234
+ m = d.get("meta", {}) or {}
235
+ if m.get("intent_tag") != "steps":
236
+ continue
237
+ doc = m.get("filename")
238
+ sec = (m.get("section") or "").strip()
239
+ txt = (d.get("text") or "").strip()
240
+ if not doc or not sec or not txt:
241
+ continue
242
+ key_hit = False
243
+ sec_low = sec.lower()
244
+ txt_low = txt.lower()
245
+ if any(k in sec_low for k in keys_low) or any(k in txt_low for k in keys_low):
246
+ key_hit = True
247
+ if not key_hit:
248
+ continue
249
+
250
+ # aggregate once per (doc, sec)
251
+ if (doc, sec) in seen:
252
+ continue
253
+ full = get_section_text(doc, sec)
254
+ if not full or not full.strip():
255
+ continue
256
+
257
+ score = 1.0
258
+ if prefer_doc and doc == prefer_doc:
259
+ score += 0.4
260
+ mtags = (m.get("module_tags") or "").lower()
261
+ if "appointments" in mtags:
262
+ score += 0.3
263
+ candidates.append((score, doc, sec, full.strip()))
264
+ seen.add((doc, sec))
265
+
266
+ if not candidates:
267
+ return None, None, None
268
+
269
+ candidates.sort(key=lambda x: x[0], reverse=True)
270
+ _, bdoc, bsec, btext = candidates[0]
271
+ return bdoc, bsec, btext
272
+
273
+
274
  # --- Action-targeted steps selector (uses existing KB metadata) ---
275
  def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
276
  """
 
282
  if not best_doc or not actions:
283
  return None
284
  act_set = set(a.strip().lower() for a in actions if a)
 
285
  candidates = []
286
  for d in bm25_docs:
287
  m = d.get("meta", {})
 
289
  tag = (m.get("action_tag") or "").strip().lower()
290
  if tag and tag in act_set:
291
  candidates.append(m.get("section"))
 
292
  for title in candidates:
293
  txt = get_section_text(best_doc, title)
294
  if txt and txt.strip():
 
455
 
456
 
457
  def _split_sop_into_steps(numbered_text: str) -> list:
 
 
 
 
 
458
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
459
  steps = []
460
  for ln in lines:
 
465
 
466
 
467
  def _soft_match_score(a: str, b: str) -> float:
 
468
  ta = set(_norm_text(a).split())
469
  tb = set(_norm_text(b).split())
470
  if not ta or not tb:
 
484
 
485
 
486
  def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
 
 
 
 
 
487
  if not _detect_next_intent(user_query):
488
  return None
 
489
  steps = _split_sop_into_steps(numbered_text)
490
  if not steps:
491
  return None
 
492
  q = user_query or ""
493
  best_idx, best_score = -1, -1.0
494
  for idx, step in enumerate(steps):
495
  score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
496
  if score > best_score:
497
  best_score, best_idx = score, idx
 
498
  if best_idx < 0 or best_score < min_score:
499
+ return None
 
500
  start = best_idx + 1
501
  if start >= len(steps):
502
+ return []
 
503
  end = min(start + max_next, len(steps))
504
  return steps[start:end]
505
 
506
 
507
  def _format_steps_as_numbered(steps: list) -> str:
 
508
  circled = {
509
  1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
510
  6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
 
518
 
519
 
520
  def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
 
 
 
 
 
 
 
 
 
 
 
521
  def _norm(s: str) -> str:
522
  s = (s or "").lower()
523
  s = re.sub(r"[^\w\s]", " ", s)
 
571
  0.30 * token_overlap
572
  )
573
 
574
+ if re.match(r"^\s*[-*\u2022]\s*", ln):
575
  score += 0.10
576
  heading = ln_norm.split(":")[0].strip()
577
  if heading and (heading in q or (first2 and first2 in heading)):
 
642
  any(w in msg_norm for w in ("ticket", "incident", "servicenow", "snow")) or
643
  bool(re.search(r"\binc\d{5,}\b", msg_norm, flags=re.IGNORECASE))
644
  )
 
645
  if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
646
  return {}
647
  patterns = [
 
717
 
718
 
719
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
 
 
 
 
 
720
  kept: List[str] = []
721
  for ln in _normalize_lines(text):
722
  if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
 
787
  Return only 'true' or 'false'.
788
  Message: {user_message}"""
789
  headers = {"Content-Type": "application/json"}
790
+ payload = {"contents": [{"parts": [{"text": prompt}]}]]}
791
  try:
792
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
793
  data = resp.json()
 
865
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
866
  payload_C = clean({
867
  "state": "6",
868
+ code_field: close_notes_val,
869
  notes_field: close_notes_val,
870
  "caller_id": caller_sysid,
871
  "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
 
885
 
886
  # ------------------------------ Prereq helper ------------------------------
887
  def _find_prereq_section_text(best_doc: str) -> str:
 
 
 
 
888
  variants = [
889
  "Pre-Requisites",
890
  "Prerequisites",
 
1059
  except Exception as e:
1060
  raise HTTPException(status_code=500, detail=safe_str(e))
1061
 
1062
+ # Hybrid KB search (keep raw; filter only used for gating)
1063
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
1064
  documents = kb_results.get("documents", [])
1065
  metadatas = kb_results.get("metadatas", [])
 
1083
  selected = items[:max(1, 2)]
1084
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
1085
 
 
1086
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
1087
  filtered_context = filtered_text
1088
 
1089
+ context = context_raw
1090
  context_found = bool(context.strip())
1091
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
1092
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
 
1124
  if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
1125
  detected_intent = "prereqs"
1126
 
1127
+ # --- Module-aware steps nudge ---
1128
  STEPS_TERMS = ("how to", "procedure", "perform", "steps", "do", "navigate")
1129
  ACTIONS_PRESENT = any(s in msg_low for syns in ACTION_SYNONYMS_EXT.values() for s in syns)
1130
  mod_tags = ((top_meta or {}).get("module_tags") or "").lower()
 
1208
  return act
1209
  return None
1210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1211
  escalation_line = None
1212
  full_errors = None
1213
  next_step_applied = False
 
1238
  best_doc = alt_doc2
1239
  full_steps = alt_steps2
1240
 
1241
+ # 1c) Title keywords global fallback
1242
  if not full_steps:
1243
  if asked_action == "update":
1244
  kd = ["updation", "update", "reschedule"]
 
1253
  best_doc = alt_doc3
1254
  full_steps = alt_steps3
1255
 
1256
+ # 1d) Title OR text global fallback (ultimate)
1257
+ if not full_steps:
1258
+ alt_doc4, alt_sec4, alt_steps4 = _get_steps_by_title_or_text_global(
1259
+ ["updation", "update", "deletion", "delete", "cancel", "creation"], prefer_doc=best_doc
1260
+ )
1261
+ if alt_steps4:
1262
+ best_doc = alt_doc4
1263
+ full_steps = alt_steps4
1264
+
1265
+ # Existing local fallbacks (doc-scoped)
1266
  if not full_steps:
1267
  default_sec = _pick_default_action_section(best_doc)
1268
  if default_sec:
 
1282
  if not any(s in low_steps for s in SAVE_SYNS):
1283
  full_steps = (full_steps or "").rstrip() + "\n" + save_lines
1284
 
1285
+ # IMPORTANT: Do NOT trim steps by action — return full section
1286
+ # (We keep "what's next" behavior only.)
 
 
 
1287
  numbered_full = _ensure_numbering(full_steps)
1288
  next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
1289
 
 
1302
  context = full_steps
1303
  context_preformatted = False
1304
 
 
1305
  filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
1306
  context_found = True
1307
 
 
1327
  else:
1328
  full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
1329
  if full_steps:
 
 
1330
  context = full_steps
1331
  detected_intent = "steps"
1332
  context_preformatted = False
 
1338
  else:
1339
  full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
1340
  if full_steps:
 
 
1341
  context = full_steps
1342
  detected_intent = "steps"
1343
  context_preformatted = False
1344
 
1345
  else:
 
1346
  context = filtered_context
1347
 
1348
  language_hint = _detect_language_hint(input_data.user_message)
 
1359
  ### Output
1360
  Return ONLY the rewritten guidance."""
1361
  headers = {"Content-Type": "application/json"}
1362
+ payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]]}
1363
  bot_text = ""
1364
  http_code = 0
1365
  if use_gemini and GEMINI_API_KEY:
 
1471
  "Do not include any extra text, comments, or explanations outside the JSON."
1472
  )
1473
  headers = {"Content-Type": "application/json"}
1474
+ payload = {"contents": [{"parts": [{"text": prompt}]}]]}
1475
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
1476
  try:
1477
  data = resp.json()