srilakshu012456 commited on
Commit
7d139a1
·
verified ·
1 Parent(s): 7762999

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +118 -58
main.py CHANGED
@@ -43,12 +43,14 @@ GEMINI_URL = (
43
  )
44
  os.environ["POSTHOG_DISABLED"] = "true"
45
 
 
46
  def safe_str(e: Any) -> str:
47
  try:
48
  return builtins.str(e)
49
  except Exception:
50
  return "<error stringify failed>"
51
 
 
52
  # ---------------------------------------------------------------------
53
  # App / Lifespan
54
  # ---------------------------------------------------------------------
@@ -65,6 +67,7 @@ async def lifespan(app: FastAPI):
65
  print(f"[KB] ingestion failed: {safe_str(e)}")
66
  yield
67
 
 
68
  app = FastAPI(lifespan=lifespan)
69
  app.include_router(login_router)
70
 
@@ -89,18 +92,22 @@ class ChatInput(BaseModel):
89
  prev_status: Optional[str] = None
90
  last_issue: Optional[str] = None
91
 
 
92
  class IncidentInput(BaseModel):
93
  short_description: str
94
  description: str
95
  mark_resolved: Optional[bool] = False
96
 
 
97
  class TicketDescInput(BaseModel):
98
  issue: str
99
 
 
100
  class TicketStatusInput(BaseModel):
101
  sys_id: Optional[str] = None
102
  number: Optional[str] = None
103
 
 
104
  STATE_MAP = {
105
  "1": "New",
106
  "2": "In Progress",
@@ -118,37 +125,38 @@ DOMAIN_STATUS_TERMS = (
118
  "shipment", "order", "load", "trailer", "wave",
119
  "inventory", "putaway", "receiving", "appointment",
120
  "dock", "door", "manifest", "pallet", "container",
121
- "asn", "grn", "pick", "picking"
122
  )
123
  ERROR_FAMILY_SYNS = {
124
  "NOT_FOUND": (
125
  "not found", "missing", "does not exist", "doesn't exist",
126
  "unavailable", "not available", "cannot find", "no such",
127
- "not present", "absent"
128
  ),
129
  "MISMATCH": (
130
  "mismatch", "doesn't match", "does not match", "variance",
131
- "difference", "discrepancy", "not equal"
132
  ),
133
  "LOCKED": (
134
  "locked", "status locked", "blocked", "read only", "read-only",
135
- "frozen", "freeze"
136
  ),
137
  "PERMISSION": (
138
  "permission", "permissions", "access denied", "not authorized",
139
  "not authorised", "insufficient privileges", "no access",
140
- "authorization", "authorisation"
141
  ),
142
  "TIMEOUT": (
143
  "timeout", "timed out", "network", "connection",
144
- "unable to connect", "disconnected", "no network"
145
  ),
146
  "SYNC": (
147
  "sync", "synchronization", "synchronisation", "replication",
148
- "refresh", "out of sync", "stale", "delay", "lag"
149
  ),
150
  }
151
 
 
152
  def _detect_error_families(msg: str) -> list:
153
  low = (msg or "").lower()
154
  low_norm = re.sub(r"[^\w\s]", " ", low)
@@ -159,11 +167,13 @@ def _detect_error_families(msg: str) -> list:
159
  fams.append(fam)
160
  return fams
161
 
 
162
  def _is_domain_status_context(msg_norm: str) -> bool:
163
  if "status locked" in msg_norm or "locked status" in msg_norm:
164
  return True
165
  return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
166
 
 
167
  def _normalize_lines(text: str) -> List[str]:
168
  raw = (text or "")
169
  try:
@@ -171,6 +181,7 @@ def _normalize_lines(text: str) -> List[str]:
171
  except Exception:
172
  return [raw.strip()] if raw.strip() else []
173
 
 
174
  # ---------------- Action filters for steps (create/update/delete) ----------------
175
  def _filter_numbered_steps_by_actions(numbered_text: str, wanted: set[str], exclude: set[str]) -> str:
176
  ACTION_SYNONYMS = {
@@ -179,6 +190,7 @@ def _filter_numbered_steps_by_actions(numbered_text: str, wanted: set[str], excl
179
  "delete": ("delete", "remove"),
180
  "navigate": ("navigate", "go to", "open"),
181
  }
 
182
  def _has_any(line: str, keys: set[str]) -> bool:
183
  low = (line or "").lower()
184
  for k in keys:
@@ -198,6 +210,7 @@ def _filter_numbered_steps_by_actions(numbered_text: str, wanted: set[str], excl
198
  out_lines.append(ln)
199
  return "\n".join(out_lines).strip() or (numbered_text or "").strip()
200
 
 
201
  # ---------------- Small utilities used by next-step & filtering ----------------
202
  def _dedupe_lines(text: str) -> str:
203
  seen, out = set(), []
@@ -208,10 +221,12 @@ def _dedupe_lines(text: str) -> str:
208
  seen.add(key)
209
  return "\n".join(out).strip()
210
 
 
211
  def _split_sentences(block: str) -> list:
212
  parts = [t.strip() for t in re.split(r"(?<=[.!?])\s+", block or "") if t.strip()]
213
  return parts if parts else ([block.strip()] if (block or "").strip() else [])
214
 
 
215
  # ------------- Numbering + text normalization used elsewhere ----------
216
  def _ensure_numbering(text: str) -> str:
217
  text = re.sub(r"[\u2060\u200B]", "", text or "")
@@ -221,9 +236,10 @@ def _ensure_numbering(text: str) -> str:
221
  para = " ".join(lines).strip()
222
  if not para:
223
  return ""
224
- para_clean = re.sub(r"(?:\b\d+\s*[.\)])\s+", "\n\n\n", para)
225
- para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean)
226
- para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean)
 
227
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
228
  if len(segments) < 2:
229
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
@@ -232,18 +248,21 @@ def _ensure_numbering(text: str) -> str:
232
  def strip_prefix_any(s: str) -> str:
233
  return re.sub(
234
  r"^\s*(?:"
235
- r"(?:\d+\s*[.\)])|" # 1. / 1)
236
- r"(?i:step\s*\d+:?)|" # Step 1:
237
- r"(?:[-*\u2022])|" # bullets
238
- r"(?:[\u2460-\u2473])" # circled digits
239
- r")\s*", "", (s or "").strip()
 
 
240
  )
 
241
  clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
242
  circled = {
243
  1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
244
  6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
245
  11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
246
- 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
247
  }
248
  out = []
249
  for idx, seg in enumerate(clean_segments, start=1):
@@ -251,6 +270,7 @@ def _ensure_numbering(text: str) -> str:
251
  out.append(f"{marker} {seg}")
252
  return "\n".join(out)
253
 
 
254
  def _norm_text(s: str) -> str:
255
  s = (s or "").lower()
256
  s = re.sub(r"[^\w\s]", " ", s)
@@ -269,32 +289,31 @@ def _norm_text(s: str) -> str:
269
  stemmed.append(t)
270
  return " ".join(stemmed).strip()
271
 
 
272
  def _split_sop_into_steps(numbered_text: str) -> list:
273
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
274
  steps = []
275
  for ln in lines:
276
- cleaned = re.sub(
277
- r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*",
278
- "",
279
- ln
280
- )
281
  if cleaned:
282
  steps.append(cleaned)
283
  return steps
284
 
 
285
  def _format_steps_as_numbered(steps: list) -> str:
286
  """Render a list of steps with circled numbers for visual continuity."""
287
  circled = {
288
  1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
289
  6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
290
  11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
291
- 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
292
  }
293
  out = []
294
  for i, s in enumerate(steps, start=1):
295
  out.append(f"{circled.get(i, str(i))} {s}")
296
  return "\n".join(out)
297
 
 
298
  # ---------------- Similarity for anchor-based next steps ----------------
299
  def _similarity(a: str, b: str) -> float:
300
  a_norm, b_norm = _norm_text(a), _norm_text(b)
@@ -302,35 +321,50 @@ def _similarity(a: str, b: str) -> float:
302
  inter = len(ta & tb)
303
  union = len(ta | tb) or 1
304
  jacc = inter / union
 
305
  def _bigrams(tokens: list) -> set:
306
- return set([" ".join(tokens[i:i+2]) for i in range(len(tokens)-1)]) if len(tokens) > 1 else set()
 
307
  ab, bb = _bigrams(a_norm.split()), _bigrams(b_norm.split())
308
  big_inter = len(ab & bb)
309
  big_union = len(ab | bb) or 1
310
  big = big_inter / big_union
311
  char = SequenceMatcher(None, a_norm, b_norm).ratio()
312
- return min(1.0, 0.45*jacc + 0.30*big + 0.35*char)
 
313
 
314
  def _extract_anchor_from_query(msg: str) -> dict:
 
 
 
315
  raw = (msg or "").strip()
316
  low = _norm_text(raw)
317
  FOLLOWUP_CUES = ("what next", "what is next", "what to do", "then", "after that", "next")
318
  has_followup = any(cue in low for cue in FOLLOWUP_CUES)
 
319
  parts = [p.strip() for p in re.split(r"[?.,;:\-\n]+", raw) if p.strip()]
320
  if not parts:
321
  return {"anchor": raw, "has_followup": has_followup}
 
322
  last = parts[-1]
323
  last_low = _norm_text(last)
324
  if any(cue in last_low for cue in FOLLOWUP_CUES) and len(parts) >= 2:
325
  anchor = parts[-2]
326
  else:
327
  anchor = parts[-1] if len(parts) > 1 else parts[0]
 
328
  return {"anchor": anchor.strip(), "has_followup": has_followup}
329
 
 
330
  def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8) -> list | None:
 
 
 
 
331
  steps = _split_sop_into_steps(numbered_text)
332
  if not steps:
333
  return None
 
334
  info = _extract_anchor_from_query(user_message)
335
  anchor = info.get("anchor", "").strip()
336
  if not anchor:
@@ -360,8 +394,8 @@ def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8)
360
  accept = True
361
  else:
362
  base_ok = best_score >= (0.55 if not has_followup else 0.50)
363
- len_ok = (best_score >= 0.40) and (tok_count >= 3)
364
- accept = base_ok or len_ok
365
  if not accept:
366
  return None
367
 
@@ -372,6 +406,7 @@ def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8)
372
  next_steps = steps[start:end]
373
  return [ln for ln in _dedupe_lines("\n".join(next_steps)).splitlines() if ln.strip()]
374
 
 
375
  # ---------------- Context filtering (neutral/errors rendering) ----------------
376
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
377
  STRICT_OVERLAP = 3
@@ -410,18 +445,19 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
410
  if matched_exact:
411
  kept = matched_exact[:MAX_SENTENCES_STRICT]
412
  return _dedupe_lines("\n".join(kept).strip()), {
413
- 'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)
414
  }
415
  if matched_any:
416
  kept = matched_any[:MAX_SENTENCES_CONCISE]
417
  return _dedupe_lines("\n".join(kept).strip()), {
418
- 'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)
419
  }
420
  kept = sentences[:MAX_SENTENCES_CONCISE]
421
  return _dedupe_lines("\n".join(kept).strip()), {
422
- 'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)
423
  }
424
 
 
425
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
426
  kept: List[str] = []
427
  for ln in _normalize_lines(text):
@@ -431,10 +467,11 @@ def _extract_errors_only(text: str, max_lines: int = 12) -> str:
431
  break
432
  return "\n".join(kept).strip() if kept else (text or "").strip()
433
 
 
434
  def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
435
  PERM_SYNONYMS = (
436
  "permission", "permissions", "access", "authorization", "authorisation",
437
- "role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
438
  )
439
  kept: List[str] = []
440
  for ln in _normalize_lines(text):
@@ -445,6 +482,7 @@ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
445
  break
446
  return "\n".join(kept).strip() if kept else (text or "").strip()
447
 
 
448
  def _extract_escalation_line(text: str) -> Optional[str]:
449
  if not text:
450
  return None
@@ -483,6 +521,7 @@ def _extract_escalation_line(text: str) -> Optional[str]:
483
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
484
  return f"If you want to escalate the issue, follow: {path}"
485
 
 
486
  def _detect_language_hint(msg: str) -> Optional[str]:
487
  if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
488
  return "Tamil"
@@ -490,10 +529,12 @@ def _detect_language_hint(msg: str) -> Optional[str]:
490
  return "Hindi"
491
  return None
492
 
 
493
  def _build_clarifying_message() -> str:
494
  return ("It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
495
  "or should I raise a ServiceNow ticket for you?")
496
 
 
497
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
498
  issue = (issue_text or "").strip()
499
  resolved = (resolved_text or "").strip()
@@ -505,6 +546,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
505
  ).strip()
506
  return short_desc, long_desc
507
 
 
508
  def _is_incident_intent(msg_norm: str) -> bool:
509
  intent_phrases = [
510
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
@@ -514,6 +556,7 @@ def _is_incident_intent(msg_norm: str) -> bool:
514
  ]
515
  return any(p in msg_norm for p in intent_phrases)
516
 
 
517
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
518
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
519
  base_has_status = any(k in msg_norm for k in status_keywords)
@@ -525,7 +568,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
525
  return {}
526
  patterns = [
527
  r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
528
- r"(inc\d+)"
529
  ]
530
  for pat in patterns:
531
  m = re.search(pat, msg_norm, flags=re.IGNORECASE)
@@ -535,6 +578,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
535
  return {"number": val.upper() if val.lower().startswith("inc") else val}
536
  return {"number": None, "ask_number": True}
537
 
 
538
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
539
  phrases = [
540
  "it is resolved", "resolved", "issue resolved", "problem resolved",
@@ -543,6 +587,7 @@ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
543
  ]
544
  return any(p in msg_norm for p in phrases)
545
 
 
546
  def _has_negation_resolved(msg_norm: str) -> bool:
547
  neg_phrases = [
548
  "not resolved", "issue not resolved", "still not working", "not working",
@@ -550,6 +595,7 @@ def _has_negation_resolved(msg_norm: str) -> bool:
550
  ]
551
  return any(p in msg_norm for p in neg_phrases)
552
 
 
553
  def _find_prereq_section_text(best_doc: str) -> str:
554
  variants = [
555
  "Pre-Requisites", "Prerequisites", "Pre Requisites", "Pre-Requirements", "Requirements",
@@ -560,6 +606,7 @@ def _find_prereq_section_text(best_doc: str) -> str:
560
  return txt.strip()
561
  return ""
562
 
 
563
  # ---------------------------------------------------------------------
564
  # Health
565
  # ---------------------------------------------------------------------
@@ -567,6 +614,7 @@ def _find_prereq_section_text(best_doc: str) -> str:
567
  async def health_check():
568
  return {"status": "ok"}
569
 
 
570
  # ---------------------------------------------------------------------
571
  # Chat
572
  # ---------------------------------------------------------------------
@@ -758,8 +806,10 @@ async def chat_with_ai(input_data: ChatInput):
758
  generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
759
 
760
  # intent nudge for prereqs
761
- PREREQ_TERMS = ("pre req", "pre-requisite", "pre-requisites", "prerequisite",
762
- "prerequisites", "pre requirement", "pre-requirements", "requirements")
 
 
763
  if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
764
  detected_intent = "prereqs"
765
 
@@ -788,14 +838,12 @@ async def chat_with_ai(input_data: ChatInput):
788
  "trailer", "shipment", "order", "load", "wave",
789
  "inventory", "putaway", "receiving", "appointment",
790
  "dock", "door", "manifest", "pallet", "container",
791
- "asn", "grn", "pick", "picking"
792
  )
793
  ACTION_OR_ERROR_TERMS = (
794
- "how to", "procedure", "perform",
795
- "close", "closing", "open", "navigate", "scan", "confirm", "generate", "update",
796
- "receive", "receiving",
797
- "error", "issue", "fail", "failed", "not working", "locked", "mismatch",
798
- "access", "permission", "status"
799
  )
800
  matched_count = int(filt_info.get("matched_count") or 0)
801
  filter_mode = (filt_info.get("mode") or "").lower()
@@ -810,8 +858,8 @@ async def chat_with_ai(input_data: ChatInput):
810
  strong_error_signal = len(_detect_error_families(msg_low)) > 0
811
 
812
  if (weak_domain_only or (low_context_hit and not combined_ok)) \
813
- and not strong_steps_bypass \
814
- and not (strong_error_signal or generic_error_signal):
815
  return {
816
  "bot_response": _build_clarifying_message(),
817
  "status": "NO_KB_MATCH",
@@ -853,36 +901,46 @@ async def chat_with_ai(input_data: ChatInput):
853
  if full_steps:
854
  numbered_full = _ensure_numbering(full_steps)
855
 
856
- # action filtering (create/update/delete) only when user clearly asks
857
  raw_actions = set((kb_results.get("actions") or []))
858
  msg_low2 = (input_data.user_message or "").lower()
 
 
859
  if not raw_actions and ("creation" in msg_low2 or "create" in msg_low2 or "set up" in msg_low2 or "setup" in msg_low2):
860
- raw_actions = {"create"}
861
  elif not raw_actions and ("update" in msg_low2 or "modify" in msg_low2 or "edit" in msg_low2 or "change" in msg_low2):
862
- raw_actions = {"update"}
863
  elif not raw_actions and ("delete" in msg_low2 or "remove" in msg_low2 or "cancel" in msg_low2 or "void" in msg_low2):
864
- raw_actions = {"delete"}
 
865
  sec_title_low = ((top_meta or {}).get("section") or "").strip().lower()
866
  section_is_create = any(k in sec_title_low for k in ("create", "creation"))
867
  section_is_update = any(k in sec_title_low for k in ("update", "updation"))
868
  section_is_delete = any(k in sec_title_low for k in ("delete", "removal", "cancellation"))
 
869
  skip_action_filter = (
870
  ("create" in raw_actions and section_is_create) or
871
  ("update" in raw_actions and section_is_update) or
872
  ("delete" in raw_actions and section_is_delete)
873
- )
 
874
  wanted, exclude = set(), set()
875
  if not skip_action_filter:
876
- if "create" in raw_actions and not ({"update", "delete"} & raw_actions):
877
- wanted, exclude = {"create"}, {"update", "delete"}
878
- elif "update" in raw_actions and not ({"create", "delete"} & raw_actions):
879
- wanted, exclude = {"update"}, {"create", "delete"}
880
- elif "delete" in raw_actions and not ({"create", "update"} & raw_actions):
881
- wanted, exclude = {"delete"}, {"create", "update"}
 
882
  if (wanted or exclude) and not skip_action_filter:
883
- numbered_full = _filter_numbered_steps_by_actions(numbered_full, wanted=wanted, exclude=exclude)
 
 
 
 
884
 
885
- # --- NEW: keyword-free anchor-based next-step resolver ---
886
  next_only = _anchor_next_steps(input_data.user_message, numbered_full, max_next=6)
887
 
888
  if next_only is not None:
@@ -933,9 +991,6 @@ async def chat_with_ai(input_data: ChatInput):
933
  if full_prereqs:
934
  context = full_prereqs.strip()
935
  context_found = True
936
- else:
937
- # neutral or other intents: keep filtered context (already set as 'context')
938
- pass
939
 
940
  # language hint & paraphrase (errors only)
941
  language_hint = _detect_language_hint(input_data.user_message)
@@ -997,7 +1052,7 @@ Return ONLY the rewritten guidance."""
997
 
998
  # non-empty guarantee
999
  if not (bot_text or "").strip():
1000
- if context.strip():
1001
  bot_text = context.strip()
1002
  else:
1003
  bot_text = (
@@ -1042,6 +1097,7 @@ Return ONLY the rewritten guidance."""
1042
  except Exception as e:
1043
  raise HTTPException(status_code=500, detail=safe_str(e))
1044
 
 
1045
  # ---------------------------------------------------------------------
1046
  # Ticket description generation
1047
  # ---------------------------------------------------------------------
@@ -1082,6 +1138,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
1082
  except Exception as e:
1083
  raise HTTPException(status_code=500, detail=safe_str(e))
1084
 
 
1085
  # ---------------------------------------------------------------------
1086
  # Incident status
1087
  # ---------------------------------------------------------------------
@@ -1124,6 +1181,7 @@ async def incident_status(input_data: TicketStatusInput):
1124
  except Exception as e:
1125
  raise HTTPException(status_code=500, detail=safe_str(e))
1126
 
 
1127
  # ---------------------------------------------------------------------
1128
  # Incident creation
1129
  # ---------------------------------------------------------------------
@@ -1148,6 +1206,7 @@ Message: {user_message}"""
1148
  except Exception:
1149
  return False
1150
 
 
1151
  def _set_incident_resolved(sys_id: str) -> bool:
1152
  try:
1153
  token = get_valid_token()
@@ -1228,6 +1287,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
1228
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
1229
  return False
1230
 
 
1231
  @app.post("/incident")
1232
  async def raise_incident(input_data: IncidentInput):
1233
  try:
 
43
  )
44
  os.environ["POSTHOG_DISABLED"] = "true"
45
 
46
+
47
  def safe_str(e: Any) -> str:
48
  try:
49
  return builtins.str(e)
50
  except Exception:
51
  return "<error stringify failed>"
52
 
53
+
54
  # ---------------------------------------------------------------------
55
  # App / Lifespan
56
  # ---------------------------------------------------------------------
 
67
  print(f"[KB] ingestion failed: {safe_str(e)}")
68
  yield
69
 
70
+
71
  app = FastAPI(lifespan=lifespan)
72
  app.include_router(login_router)
73
 
 
92
  prev_status: Optional[str] = None
93
  last_issue: Optional[str] = None
94
 
95
+
96
  class IncidentInput(BaseModel):
97
  short_description: str
98
  description: str
99
  mark_resolved: Optional[bool] = False
100
 
101
+
102
  class TicketDescInput(BaseModel):
103
  issue: str
104
 
105
+
106
  class TicketStatusInput(BaseModel):
107
  sys_id: Optional[str] = None
108
  number: Optional[str] = None
109
 
110
+
111
  STATE_MAP = {
112
  "1": "New",
113
  "2": "In Progress",
 
125
  "shipment", "order", "load", "trailer", "wave",
126
  "inventory", "putaway", "receiving", "appointment",
127
  "dock", "door", "manifest", "pallet", "container",
128
+ "asn", "grn", "pick", "picking",
129
  )
130
  ERROR_FAMILY_SYNS = {
131
  "NOT_FOUND": (
132
  "not found", "missing", "does not exist", "doesn't exist",
133
  "unavailable", "not available", "cannot find", "no such",
134
+ "not present", "absent",
135
  ),
136
  "MISMATCH": (
137
  "mismatch", "doesn't match", "does not match", "variance",
138
+ "difference", "discrepancy", "not equal",
139
  ),
140
  "LOCKED": (
141
  "locked", "status locked", "blocked", "read only", "read-only",
142
+ "frozen", "freeze",
143
  ),
144
  "PERMISSION": (
145
  "permission", "permissions", "access denied", "not authorized",
146
  "not authorised", "insufficient privileges", "no access",
147
+ "authorization", "authorisation",
148
  ),
149
  "TIMEOUT": (
150
  "timeout", "timed out", "network", "connection",
151
+ "unable to connect", "disconnected", "no network",
152
  ),
153
  "SYNC": (
154
  "sync", "synchronization", "synchronisation", "replication",
155
+ "refresh", "out of sync", "stale", "delay", "lag",
156
  ),
157
  }
158
 
159
+
160
  def _detect_error_families(msg: str) -> list:
161
  low = (msg or "").lower()
162
  low_norm = re.sub(r"[^\w\s]", " ", low)
 
167
  fams.append(fam)
168
  return fams
169
 
170
+
171
  def _is_domain_status_context(msg_norm: str) -> bool:
172
  if "status locked" in msg_norm or "locked status" in msg_norm:
173
  return True
174
  return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
175
 
176
+
177
  def _normalize_lines(text: str) -> List[str]:
178
  raw = (text or "")
179
  try:
 
181
  except Exception:
182
  return [raw.strip()] if raw.strip() else []
183
 
184
+
185
  # ---------------- Action filters for steps (create/update/delete) ----------------
186
  def _filter_numbered_steps_by_actions(numbered_text: str, wanted: set[str], exclude: set[str]) -> str:
187
  ACTION_SYNONYMS = {
 
190
  "delete": ("delete", "remove"),
191
  "navigate": ("navigate", "go to", "open"),
192
  }
193
+
194
  def _has_any(line: str, keys: set[str]) -> bool:
195
  low = (line or "").lower()
196
  for k in keys:
 
210
  out_lines.append(ln)
211
  return "\n".join(out_lines).strip() or (numbered_text or "").strip()
212
 
213
+
214
  # ---------------- Small utilities used by next-step & filtering ----------------
215
  def _dedupe_lines(text: str) -> str:
216
  seen, out = set(), []
 
221
  seen.add(key)
222
  return "\n".join(out).strip()
223
 
224
+
225
  def _split_sentences(block: str) -> list:
226
  parts = [t.strip() for t in re.split(r"(?<=[.!?])\s+", block or "") if t.strip()]
227
  return parts if parts else ([block.strip()] if (block or "").strip() else [])
228
 
229
+
230
  # ------------- Numbering + text normalization used elsewhere ----------
231
  def _ensure_numbering(text: str) -> str:
232
  text = re.sub(r"[\u2060\u200B]", "", text or "")
 
236
  para = " ".join(lines).strip()
237
  if not para:
238
  return ""
239
+ # Hard breaks at step boundaries
240
+ para_clean = re.sub(r"(?:\b\d+\s*[.\)])\s+", "\n\n\n", para) # 1. / 1)
241
+ para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
242
+ para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean) # Step 1:
243
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
244
  if len(segments) < 2:
245
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
 
248
  def strip_prefix_any(s: str) -> str:
249
  return re.sub(
250
  r"^\s*(?:"
251
+ r"(?:\d+\s*[.\)])|"
252
+ r"(?i:step\s*\d+:?)|"
253
+ r"(?:[-*\u2022])|"
254
+ r"(?:[\u2460-\u2473])"
255
+ r")\s*",
256
+ "",
257
+ (s or "").strip(),
258
  )
259
+
260
  clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
261
  circled = {
262
  1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
263
  6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
264
  11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
265
+ 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473",
266
  }
267
  out = []
268
  for idx, seg in enumerate(clean_segments, start=1):
 
270
  out.append(f"{marker} {seg}")
271
  return "\n".join(out)
272
 
273
+
274
  def _norm_text(s: str) -> str:
275
  s = (s or "").lower()
276
  s = re.sub(r"[^\w\s]", " ", s)
 
289
  stemmed.append(t)
290
  return " ".join(stemmed).strip()
291
 
292
+
293
  def _split_sop_into_steps(numbered_text: str) -> list:
294
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
295
  steps = []
296
  for ln in lines:
297
+ cleaned = re.sub(r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*", "", ln)
 
 
 
 
298
  if cleaned:
299
  steps.append(cleaned)
300
  return steps
301
 
302
+
303
  def _format_steps_as_numbered(steps: list) -> str:
304
  """Render a list of steps with circled numbers for visual continuity."""
305
  circled = {
306
  1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
307
  6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
308
  11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
309
+ 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473",
310
  }
311
  out = []
312
  for i, s in enumerate(steps, start=1):
313
  out.append(f"{circled.get(i, str(i))} {s}")
314
  return "\n".join(out)
315
 
316
+
317
  # ---------------- Similarity for anchor-based next steps ----------------
318
  def _similarity(a: str, b: str) -> float:
319
  a_norm, b_norm = _norm_text(a), _norm_text(b)
 
321
  inter = len(ta & tb)
322
  union = len(ta | tb) or 1
323
  jacc = inter / union
324
+
325
  def _bigrams(tokens: list) -> set:
326
+ return set([" ".join(tokens[i:i + 2]) for i in range(len(tokens) - 1)]) if len(tokens) > 1 else set()
327
+
328
  ab, bb = _bigrams(a_norm.split()), _bigrams(b_norm.split())
329
  big_inter = len(ab & bb)
330
  big_union = len(ab | bb) or 1
331
  big = big_inter / big_union
332
  char = SequenceMatcher(None, a_norm, b_norm).ratio()
333
+ return min(1.0, 0.45 * jacc + 0.30 * big + 0.35 * char)
334
+
335
 
336
  def _extract_anchor_from_query(msg: str) -> dict:
337
+ """
338
+ Pull the anchor clause out of the user's sentence and note if a follow-up cue exists.
339
+ """
340
  raw = (msg or "").strip()
341
  low = _norm_text(raw)
342
  FOLLOWUP_CUES = ("what next", "what is next", "what to do", "then", "after that", "next")
343
  has_followup = any(cue in low for cue in FOLLOWUP_CUES)
344
+
345
  parts = [p.strip() for p in re.split(r"[?.,;:\-\n]+", raw) if p.strip()]
346
  if not parts:
347
  return {"anchor": raw, "has_followup": has_followup}
348
+
349
  last = parts[-1]
350
  last_low = _norm_text(last)
351
  if any(cue in last_low for cue in FOLLOWUP_CUES) and len(parts) >= 2:
352
  anchor = parts[-2]
353
  else:
354
  anchor = parts[-1] if len(parts) > 1 else parts[0]
355
+
356
  return {"anchor": anchor.strip(), "has_followup": has_followup}
357
 
358
+
359
  def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8) -> list | None:
360
+ """
361
+ Locate the best-matching line (or sentence inside it) for the user's anchor,
362
+ then return ONLY subsequent steps. Returns None if no strong anchor is found.
363
+ """
364
  steps = _split_sop_into_steps(numbered_text)
365
  if not steps:
366
  return None
367
+
368
  info = _extract_anchor_from_query(user_message)
369
  anchor = info.get("anchor", "").strip()
370
  if not anchor:
 
394
  accept = True
395
  else:
396
  base_ok = best_score >= (0.55 if not has_followup else 0.50)
397
+ len_ok = (best_score >= 0.40) and (tok_count >= 3)
398
+ accept = base_ok or len_ok
399
  if not accept:
400
  return None
401
 
 
406
  next_steps = steps[start:end]
407
  return [ln for ln in _dedupe_lines("\n".join(next_steps)).splitlines() if ln.strip()]
408
 
409
+
410
  # ---------------- Context filtering (neutral/errors rendering) ----------------
411
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
412
  STRICT_OVERLAP = 3
 
445
  if matched_exact:
446
  kept = matched_exact[:MAX_SENTENCES_STRICT]
447
  return _dedupe_lines("\n".join(kept).strip()), {
448
+ 'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences),
449
  }
450
  if matched_any:
451
  kept = matched_any[:MAX_SENTENCES_CONCISE]
452
  return _dedupe_lines("\n".join(kept).strip()), {
453
+ 'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences),
454
  }
455
  kept = sentences[:MAX_SENTENCES_CONCISE]
456
  return _dedupe_lines("\n".join(kept).strip()), {
457
+ 'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences),
458
  }
459
 
460
+
461
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
462
  kept: List[str] = []
463
  for ln in _normalize_lines(text):
 
467
  break
468
  return "\n".join(kept).strip() if kept else (text or "").strip()
469
 
470
+
471
  def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
472
  PERM_SYNONYMS = (
473
  "permission", "permissions", "access", "authorization", "authorisation",
474
+ "role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient",
475
  )
476
  kept: List[str] = []
477
  for ln in _normalize_lines(text):
 
482
  break
483
  return "\n".join(kept).strip() if kept else (text or "").strip()
484
 
485
+
486
  def _extract_escalation_line(text: str) -> Optional[str]:
487
  if not text:
488
  return None
 
521
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
522
  return f"If you want to escalate the issue, follow: {path}"
523
 
524
+
525
  def _detect_language_hint(msg: str) -> Optional[str]:
526
  if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
527
  return "Tamil"
 
529
  return "Hindi"
530
  return None
531
 
532
+
533
  def _build_clarifying_message() -> str:
534
  return ("It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
535
  "or should I raise a ServiceNow ticket for you?")
536
 
537
+
538
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
539
  issue = (issue_text or "").strip()
540
  resolved = (resolved_text or "").strip()
 
546
  ).strip()
547
  return short_desc, long_desc
548
 
549
+
550
  def _is_incident_intent(msg_norm: str) -> bool:
551
  intent_phrases = [
552
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
 
556
  ]
557
  return any(p in msg_norm for p in intent_phrases)
558
 
559
+
560
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
561
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
562
  base_has_status = any(k in msg_norm for k in status_keywords)
 
568
  return {}
569
  patterns = [
570
  r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
571
+ r"(inc\d+)",
572
  ]
573
  for pat in patterns:
574
  m = re.search(pat, msg_norm, flags=re.IGNORECASE)
 
578
  return {"number": val.upper() if val.lower().startswith("inc") else val}
579
  return {"number": None, "ask_number": True}
580
 
581
+
582
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
583
  phrases = [
584
  "it is resolved", "resolved", "issue resolved", "problem resolved",
 
587
  ]
588
  return any(p in msg_norm for p in phrases)
589
 
590
+
591
  def _has_negation_resolved(msg_norm: str) -> bool:
592
  neg_phrases = [
593
  "not resolved", "issue not resolved", "still not working", "not working",
 
595
  ]
596
  return any(p in msg_norm for p in neg_phrases)
597
 
598
+
599
  def _find_prereq_section_text(best_doc: str) -> str:
600
  variants = [
601
  "Pre-Requisites", "Prerequisites", "Pre Requisites", "Pre-Requirements", "Requirements",
 
606
  return txt.strip()
607
  return ""
608
 
609
+
610
  # ---------------------------------------------------------------------
611
  # Health
612
  # ---------------------------------------------------------------------
 
614
  async def health_check():
615
  return {"status": "ok"}
616
 
617
+
618
  # ---------------------------------------------------------------------
619
  # Chat
620
  # ---------------------------------------------------------------------
 
806
  generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
807
 
808
  # intent nudge for prereqs
809
+ PREREQ_TERMS = (
810
+ "pre req", "pre-requisite", "pre-requisites", "prerequisite",
811
+ "prerequisites", "pre requirement", "pre-requirements", "requirements",
812
+ )
813
  if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
814
  detected_intent = "prereqs"
815
 
 
838
  "trailer", "shipment", "order", "load", "wave",
839
  "inventory", "putaway", "receiving", "appointment",
840
  "dock", "door", "manifest", "pallet", "container",
841
+ "asn", "grn", "pick", "picking",
842
  )
843
  ACTION_OR_ERROR_TERMS = (
844
+ "how to", "procedure", "perform", "close", "closing", "open", "navigate", "scan",
845
+ "confirm", "generate", "update", "receive", "receiving", "error", "issue", "fail", "failed",
846
+ "not working", "locked", "mismatch", "access", "permission", "status",
 
 
847
  )
848
  matched_count = int(filt_info.get("matched_count") or 0)
849
  filter_mode = (filt_info.get("mode") or "").lower()
 
858
  strong_error_signal = len(_detect_error_families(msg_low)) > 0
859
 
860
  if (weak_domain_only or (low_context_hit and not combined_ok)) \
861
+ and not strong_steps_bypass \
862
+ and not (strong_error_signal or generic_error_signal):
863
  return {
864
  "bot_response": _build_clarifying_message(),
865
  "status": "NO_KB_MATCH",
 
901
  if full_steps:
902
  numbered_full = _ensure_numbering(full_steps)
903
 
904
+ # --- Section-aware action filtering (avoid over-trimming "update" sections) ---
905
  raw_actions = set((kb_results.get("actions") or []))
906
  msg_low2 = (input_data.user_message or "").lower()
907
+
908
+ # infer action from user text if extractor missed it
909
  if not raw_actions and ("creation" in msg_low2 or "create" in msg_low2 or "set up" in msg_low2 or "setup" in msg_low2):
910
+ raw_actions = {"create"}
911
  elif not raw_actions and ("update" in msg_low2 or "modify" in msg_low2 or "edit" in msg_low2 or "change" in msg_low2):
912
+ raw_actions = {"update"}
913
  elif not raw_actions and ("delete" in msg_low2 or "remove" in msg_low2 or "cancel" in msg_low2 or "void" in msg_low2):
914
+ raw_actions = {"delete"}
915
+
916
  sec_title_low = ((top_meta or {}).get("section") or "").strip().lower()
917
  section_is_create = any(k in sec_title_low for k in ("create", "creation"))
918
  section_is_update = any(k in sec_title_low for k in ("update", "updation"))
919
  section_is_delete = any(k in sec_title_low for k in ("delete", "removal", "cancellation"))
920
+
921
  skip_action_filter = (
922
  ("create" in raw_actions and section_is_create) or
923
  ("update" in raw_actions and section_is_update) or
924
  ("delete" in raw_actions and section_is_delete)
925
+ )
926
+
927
  wanted, exclude = set(), set()
928
  if not skip_action_filter:
929
+ if "create" in raw_actions and not ({"update", "delete"} & raw_actions):
930
+ wanted, exclude = {"create"}, {"update", "delete"}
931
+ elif "update" in raw_actions and not ({"create", "delete"} & raw_actions):
932
+ wanted, exclude = {"update"}, {"create", "delete"}
933
+ elif "delete" in raw_actions and not ({"create", "update"} & raw_actions):
934
+ wanted, exclude = {"delete"}, {"create", "update"}
935
+
936
  if (wanted or exclude) and not skip_action_filter:
937
+ before = numbered_full
938
+ numbered_full = _filter_numbered_steps_by_actions(numbered_full, wanted=wanted, exclude=exclude)
939
+ # safety: if over-trimmed to <=1 line, revert
940
+ if len([ln for ln in numbered_full.splitlines() if ln.strip()]) <= 1:
941
+ numbered_full = before
942
 
943
+ # --- Keyword-free anchor-based next-step resolver ---
944
  next_only = _anchor_next_steps(input_data.user_message, numbered_full, max_next=6)
945
 
946
  if next_only is not None:
 
991
  if full_prereqs:
992
  context = full_prereqs.strip()
993
  context_found = True
 
 
 
994
 
995
  # language hint & paraphrase (errors only)
996
  language_hint = _detect_language_hint(input_data.user_message)
 
1052
 
1053
  # non-empty guarantee
1054
  if not (bot_text or "").strip():
1055
+ if (context or "").strip():
1056
  bot_text = context.strip()
1057
  else:
1058
  bot_text = (
 
1097
  except Exception as e:
1098
  raise HTTPException(status_code=500, detail=safe_str(e))
1099
 
1100
+
1101
  # ---------------------------------------------------------------------
1102
  # Ticket description generation
1103
  # ---------------------------------------------------------------------
 
1138
  except Exception as e:
1139
  raise HTTPException(status_code=500, detail=safe_str(e))
1140
 
1141
+
1142
  # ---------------------------------------------------------------------
1143
  # Incident status
1144
  # ---------------------------------------------------------------------
 
1181
  except Exception as e:
1182
  raise HTTPException(status_code=500, detail=safe_str(e))
1183
 
1184
+
1185
  # ---------------------------------------------------------------------
1186
  # Incident creation
1187
  # ---------------------------------------------------------------------
 
1206
  except Exception:
1207
  return False
1208
 
1209
+
1210
  def _set_incident_resolved(sys_id: str) -> bool:
1211
  try:
1212
  token = get_valid_token()
 
1287
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
1288
  return False
1289
 
1290
+
1291
  @app.post("/incident")
1292
  async def raise_incident(input_data: IncidentInput):
1293
  try: