srilakshu012456 commited on
Commit
af2ca53
·
verified ·
1 Parent(s): f57f7d9

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +111 -91
main.py CHANGED
@@ -26,15 +26,18 @@ from services.generate_ticket import get_valid_token, create_incident
26
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
27
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
28
 
 
29
  def safe_str(e: Any) -> str:
30
  try:
31
  return builtins.str(e)
32
  except Exception:
33
  return "<error stringify failed>"
34
 
 
35
  load_dotenv()
36
  os.environ["POSTHOG_DISABLED"] = "true"
37
 
 
38
  @asynccontextmanager
39
  async def lifespan(app: FastAPI):
40
  try:
@@ -48,6 +51,7 @@ async def lifespan(app: FastAPI):
48
  print(f"[KB] ingestion failed: {safe_str(e)}")
49
  yield
50
 
 
51
  app = FastAPI(lifespan=lifespan)
52
  app.include_router(login_router)
53
 
@@ -60,24 +64,28 @@ app.add_middleware(
60
  allow_headers=["*"],
61
  )
62
 
63
- # ------------------ Models ------------------
64
  class ChatInput(BaseModel):
65
  user_message: str
66
  prev_status: Optional[str] = None
67
  last_issue: Optional[str] = None
68
 
 
69
  class IncidentInput(BaseModel):
70
  short_description: str
71
  description: str
72
  mark_resolved: Optional[bool] = False
73
 
 
74
  class TicketDescInput(BaseModel):
75
  issue: str
76
 
 
77
  class TicketStatusInput(BaseModel):
78
  sys_id: Optional[str] = None
79
  number: Optional[str] = None
80
 
 
81
  STATE_MAP = {
82
  "1": "New",
83
  "2": "In Progress",
@@ -93,8 +101,10 @@ GEMINI_URL = (
93
  f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
94
  )
95
 
96
- # ------------------ Helpers ------------------
97
  NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
 
 
98
  def _normalize_lines(text: str) -> List[str]:
99
  raw = (text or "")
100
  try:
@@ -111,36 +121,25 @@ def _ensure_numbering(text: str) -> str:
111
  - Uses circled numerals (①..⑳) so the UI won't treat them as ordered lists.
112
  - Falls back to 'N)' beyond 20 steps.
113
  """
114
-
115
- # 0) Strip hidden chars that can break regexes
116
  text = re.sub(r"[\u2060\u200B]", "", text or "")
117
 
118
- # --- Helpers ---
119
  def strip_prefix_any(s: str) -> str:
120
- """
121
- Remove *any* leading list marker:
122
- - classic: '1)', '2.', 'Step 3:', bullets '- * •'
123
- - circled numerals: ①..⑳ (U+2460..U+2473)
124
- - optional hidden chars before them
125
- """
126
  return re.sub(
127
- r"^\s*(?:[\u2060\u200B]*" # optional zero-width prefix
128
- r"(?:\d+\s*[.)]" # '1)' '2.' '3 )'
129
- r"|(?:step\s*\d+:?)" # 'Step 3:' (case-insensitive handled below)
130
- r"|[\-\*\u2022]" # bullets '-', '*', '•'
131
- r"|[\u2460-\u2473]" # circled numerals ①..⑳
132
- r"))\s*",
133
  "",
134
  (s or "").strip(),
135
- flags=re.IGNORECASE
136
  )
137
 
138
- # 1) Normalize lines
139
  lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
140
  if not lines:
141
  return text or ""
142
 
143
- # 2) Merge dangling numeric-only lines with following text
144
  merged: List[str] = []
145
  i = 0
146
  while i < len(lines):
@@ -156,46 +155,33 @@ def _ensure_numbering(text: str) -> str:
156
  if not para:
157
  return ""
158
 
159
- # 3) Neutralize inline markers to a hard delimiter '|||'
160
- # - classic numbers: ' 2) ', ' 3. '
161
- # - circled numerals: ' ② ' etc.
162
- para_clean = re.sub(r"(?:(?<=^)|(?<=\s))\d+[.)]\s+", "|||", para)
163
- para_clean = re.sub(r"(?:(?<=^)|(?<=\s))[\u2460-\u2473]\s+", "|||", para_clean)
164
- para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "|||", para_clean)
165
 
166
- # 4) Split segments
167
- segments = [seg.strip() for seg in para_clean.split("|||") if seg.strip()]
168
  if len(segments) < 2:
169
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
170
- segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|;\s+", para) if seg.strip()]
171
 
172
- # 5) Strip ANY leading marker from each segment (including circled ones)
173
  segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
174
 
175
- # 6) Numbering: circled numerals for 1..20; then 'N)' style
176
  circled = {
177
- 1:"",2:"",3:"",4:"",5:"",6:"",7:"",8:"",9:"",10:"",
178
- 11:"",12:"",13:"",14:"",15:"",16:"",17:"��",18:"",19:"",20:"",
179
  }
180
  out: List[str] = []
181
  for idx, seg in enumerate(segments, start=1):
182
  marker = circled.get(idx, f"{idx})")
183
  out.append(f"{marker} {seg}")
184
-
185
  return "\n".join(out)
186
 
187
 
188
-
189
  def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 4) -> str:
190
- """
191
- Keep only error lines that contain key tokens from the user's query.
192
- E.g., for 'putaway error', keep lines containing 'putaway' / 'error'.
193
- """
194
- q = _normalize_for_match(query) # lower + punctuation stripped
195
  q_terms = [t for t in q.split() if len(t) > 2]
196
  if not q_terms:
197
  return text or ""
198
-
199
  kept: List[str] = []
200
  for ln in _normalize_lines(text):
201
  ln_norm = _normalize_for_match(ln)
@@ -203,12 +189,12 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 4) -> s
203
  kept.append(ln)
204
  if len(kept) >= max_lines:
205
  break
206
-
207
  return "\n".join(kept).strip() if kept else (text or "").strip()
208
 
 
209
  def _friendly_permission_reply(raw: str) -> str:
210
  line = (raw or "").strip()
211
- line = re.sub(r"^\s*[\-\*\u2022]\s*", "", line)
212
  if not line:
213
  return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
214
  if "verify role access" in line.lower():
@@ -217,6 +203,7 @@ def _friendly_permission_reply(raw: str) -> str:
217
  return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
218
  return line
219
 
 
220
  def _detect_language_hint(msg: str) -> Optional[str]:
221
  if re.search(r"[\u0B80-\u0BFF]", msg or ""):
222
  return "Tamil"
@@ -224,12 +211,14 @@ def _detect_language_hint(msg: str) -> Optional[str]:
224
  return "Hindi"
225
  return None
226
 
 
227
  def _build_clarifying_message() -> str:
228
  return (
229
  "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
230
  "or should I raise a ServiceNow ticket for you?"
231
  )
232
 
 
233
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
234
  issue = (issue_text or "").strip()
235
  resolved = (resolved_text or "").strip()
@@ -241,6 +230,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
241
  ).strip()
242
  return short_desc, long_desc
243
 
 
244
  def _is_incident_intent(msg_norm: str) -> bool:
245
  intent_phrases = [
246
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
@@ -250,6 +240,7 @@ def _is_incident_intent(msg_norm: str) -> bool:
250
  ]
251
  return any(p in msg_norm for p in intent_phrases)
252
 
 
253
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
254
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
255
  if not any(k in msg_norm for k in status_keywords):
@@ -263,6 +254,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
263
  return {"number": val.upper() if val.lower().startswith("inc") else val}
264
  return {"number": None, "ask_number": True}
265
 
 
266
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
267
  phrases = [
268
  "it is resolved", "resolved", "issue resolved", "problem resolved",
@@ -271,6 +263,7 @@ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
271
  ]
272
  return any(p in msg_norm for p in phrases)
273
 
 
274
  def _has_negation_resolved(msg_norm: str) -> bool:
275
  neg_phrases = [
276
  "not resolved", "issue not resolved", "still not working", "not working",
@@ -278,6 +271,7 @@ def _has_negation_resolved(msg_norm: str) -> bool:
278
  ]
279
  return any(p in msg_norm for p in neg_phrases)
280
 
 
281
  def _classify_resolution_llm(user_message: str) -> bool:
282
  if not GEMINI_API_KEY:
283
  return False
@@ -299,10 +293,12 @@ Message: {user_message}"""
299
  except Exception:
300
  return False
301
 
302
- # ------------------ Filters ------------------
 
303
  STRICT_OVERLAP = 3
304
  MAX_SENTENCES_STRICT = 4
305
  MAX_SENTENCES_CONCISE = 3
 
306
  PERM_QUERY_TERMS = [
307
  "permission", "permissions", "access", "access right", "authorization", "authorisation",
308
  "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied",
@@ -312,16 +308,19 @@ PERM_SYNONYMS = (
312
  "role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
313
  )
314
 
 
315
  def _normalize_for_match(text: str) -> str:
316
  t = (text or "").lower()
317
  t = re.sub(r"[^\w\s]", " ", t)
318
  t = re.sub(r"\s+", " ", t).strip()
319
  return t
320
 
 
321
  def _split_sentences(ctx: str) -> List[str]:
322
- raw_sents = re.split(r"(?<=[.!?])\s+|\n+|\-\s*|\*\s*", ctx or "")
323
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
324
 
 
325
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
326
  ctx = (context or "").strip()
327
  if not ctx or not query:
@@ -349,8 +348,10 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
349
  kept = sentences[:MAX_SENTENCES_CONCISE]
350
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
351
 
 
352
  NAV_LINE_REGEX = re.compile(r"(navigate\s+to|login|log in|menu|screen)", re.IGNORECASE)
353
 
 
354
  def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
355
  lines = _normalize_lines(text)
356
  kept: List[str] = []
@@ -361,12 +362,14 @@ def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
361
  break
362
  return "\n".join(kept).strip() if kept else (text or "").strip()
363
 
 
364
  ERROR_STARTS = (
365
  "error", "resolution", "fix", "verify", "check",
366
  "permission", "access", "authorization", "authorisation",
367
  "role", "role mapping", "security profile", "escalation", "not allowed", "not authorized", "denied"
368
  )
369
 
 
370
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
371
  lines = _normalize_lines(text)
372
  kept: List[str] = []
@@ -378,6 +381,7 @@ def _extract_errors_only(text: str, max_lines: int = 12) -> str:
378
  break
379
  return "\n".join(kept).strip() if kept else (text or "").strip()
380
 
 
381
  def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
382
  lines = _normalize_lines(text)
383
  kept: List[str] = []
@@ -389,6 +393,7 @@ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
389
  break
390
  return "\n".join(kept).strip() if kept else (text or "").strip()
391
 
 
392
  def _extract_escalation_line(text: str) -> Optional[str]:
393
  """
394
  Extract escalation path from SOP text and return a friendly sentence.
@@ -396,19 +401,15 @@ def _extract_escalation_line(text: str) -> Optional[str]:
396
  """
397
  if not text:
398
  return None
399
-
400
  lines = _normalize_lines(text)
401
  if not lines:
402
  return None
403
-
404
- # Locate an escalation heading
405
  start_idx = None
406
  for i, ln in enumerate(lines):
407
  low = ln.lower()
408
  if "escalation" in low or "escalation path" in low or "escalate" in low:
409
  start_idx = i
410
  break
411
-
412
  block = []
413
  if start_idx is not None:
414
  for j in range(start_idx, min(len(lines), start_idx + 6)):
@@ -416,40 +417,68 @@ def _extract_escalation_line(text: str) -> Optional[str]:
416
  break
417
  block.append(lines[j].strip())
418
  else:
419
- # Look for any arrows if heading not found
420
  block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
421
-
422
  if not block:
423
  return None
424
-
425
  text_block = " ".join(block)
426
-
427
  m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
428
  path = m.group(1).strip() if m else None
429
-
430
  if not path:
431
  arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
432
  if arrow_lines:
433
  path = arrow_lines[0]
434
-
435
  if not path:
436
  m2 = re.search(r"(operator.*?administrator|operator.*)", text_block, flags=re.IGNORECASE)
437
  path = m2.group(1).strip() if m2 else None
438
-
439
  if not path:
440
  return None
441
-
442
  path = path.replace("->", "→").strip()
443
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
444
-
445
  return f"If you want to escalate the issue, follow: {path}"
446
 
447
- # ------------------ Health ------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  @app.get("/")
449
  async def health_check():
450
  return {"status": "ok"}
451
 
452
- # ------------------ Chat ------------------
 
453
  @app.post("/chat")
454
  async def chat_with_ai(input_data: ChatInput):
455
  try:
@@ -478,6 +507,7 @@ async def chat_with_ai(input_data: ChatInput):
478
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
479
  if _has_negation_resolved(msg_norm):
480
  is_llm_resolved = False
 
481
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
482
  try:
483
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
@@ -642,49 +672,45 @@ async def chat_with_ai(input_data: ChatInput):
642
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
643
  context = filtered_text
644
  context_found = bool(context.strip())
645
-
646
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
647
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
648
  detected_intent = kb_results.get("user_intent", "neutral")
649
  best_doc = kb_results.get("best_doc")
650
  top_meta = (metadatas or [{}])[0] if metadatas else {}
651
 
652
- # Force errors intent for permissions
653
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
654
  if is_perm_query:
655
  detected_intent = "errors"
656
 
657
- # Prefer full SOP section when we have the best_doc
658
  escalation_line = None # SOP escalation candidate (if found)
 
659
  if best_doc:
660
  if detected_intent == "steps":
661
  full_steps = get_best_steps_section_text(best_doc)
662
  if not full_steps:
663
  sec = (top_meta or {}).get("section")
664
- if sec: full_steps = get_section_text(best_doc, sec)
 
665
  if full_steps:
666
- context = _ensure_numbering(full_steps)
 
 
 
 
 
667
  elif detected_intent == "errors":
668
  full_errors = get_best_errors_section_text(best_doc)
669
  if full_errors:
670
- # Extract the error lines from the SOP
671
  ctx_err = _extract_errors_only(full_errors, max_lines=12)
672
-
673
- # If the query mentions permissions, reduce to permission-only lines
674
  if is_perm_query:
675
  context = _filter_permission_lines(ctx_err, max_lines=6)
676
  else:
677
- # Otherwise, keep only the lines relevant to the user's error words
678
  context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=6)
679
-
680
- # Try to pick escalation path from the same SOP
681
  escalation_line = _extract_escalation_line(full_errors)
682
-
683
  lines = _normalize_lines(context)
684
  if len(lines) == 1:
685
  context = _friendly_permission_reply(lines[0])
686
 
687
- # No context for errors → clarifying + ticket option
688
  if detected_intent == "errors" and not (context or "").strip():
689
  return {
690
  "bot_response": _build_clarifying_message(),
@@ -699,7 +725,6 @@ async def chat_with_ai(input_data: ChatInput):
699
  "debug": {"intent": "errors_no_context"},
700
  }
701
 
702
- # Build LLM prompt
703
  language_hint = _detect_language_hint(input_data.user_message)
704
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
705
  use_gemini = (detected_intent == "errors")
@@ -715,7 +740,6 @@ async def chat_with_ai(input_data: ChatInput):
715
  Return ONLY the rewritten guidance."""
716
  headers = {"Content-Type": "application/json"}
717
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
718
-
719
  bot_text = ""
720
  http_code = 0
721
  if use_gemini:
@@ -733,29 +757,24 @@ Return ONLY the rewritten guidance."""
733
  except Exception:
734
  bot_text, http_code = "", 0
735
 
736
- # Deterministic local formatting
737
  if detected_intent == "steps":
738
  bot_text = _ensure_numbering(context)
739
  elif detected_intent == "errors":
740
  if not bot_text.strip() or http_code == 429:
741
- # Fallback to SOP-derived filtered context (no generic)
742
  bot_text = context.strip()
743
-
744
- # Append SOP-specific escalation if available; otherwise NO escalation
745
  if escalation_line:
746
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
747
- else:
748
- bot_text = context
749
 
750
- # Status compute
751
  short_query = len((input_data.user_message or "").split()) <= 4
752
  gate_combined_ok = 0.60 if short_query else 0.55
753
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
754
  lower = (bot_text or "").lower()
755
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
756
  status = "PARTIAL"
757
-
758
  options = [{"type":"yesno","title":"Share details or raise a ticket?"}] if status == "PARTIAL" else []
 
759
  return {
760
  "bot_response": bot_text,
761
  "status": status,
@@ -783,6 +802,7 @@ Return ONLY the rewritten guidance."""
783
  except Exception as e:
784
  raise HTTPException(status_code=500, detail=safe_str(e))
785
 
 
786
  def _set_incident_resolved(sys_id: str) -> bool:
787
  try:
788
  token = get_valid_token()
@@ -796,14 +816,12 @@ def _set_incident_resolved(sys_id: str) -> bool:
796
  "Content-Type": "application/json",
797
  }
798
  url = f"{instance_url}/api/now/table/incident/{sys_id}"
799
-
800
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
801
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
802
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
803
  resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
804
  assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
805
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
806
-
807
  if require_progress:
808
  try:
809
  resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
@@ -865,7 +883,8 @@ def _set_incident_resolved(sys_id: str) -> bool:
865
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
866
  return False
867
 
868
- # ------------------ Incident ------------------
 
869
  @app.post("/incident")
870
  async def raise_incident(input_data: IncidentInput):
871
  try:
@@ -890,7 +909,8 @@ async def raise_incident(input_data: IncidentInput):
890
  except Exception as e:
891
  raise HTTPException(status_code=500, detail=safe_str(e))
892
 
893
- # ------------------ Ticket description generation ------------------
 
894
  @app.post("/generate_ticket_desc")
895
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
896
  try:
@@ -898,8 +918,8 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
898
  f"You are helping generate ServiceNow ticket descriptions based on the issue: {input_data.issue}.\n"
899
  "Please return the output strictly in JSON format with the following keys:\n"
900
  "{\n"
901
- ' "ShortDescription": "A concise summary of the issue (max 100 characters)",\n'
902
- ' "DetailedDescription": "A detailed explanation of the issue"\n'
903
  "}\n"
904
  "Do not include any extra text, comments, or explanations outside the JSON."
905
  )
@@ -928,7 +948,8 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
928
  except Exception as e:
929
  raise HTTPException(status_code=500, detail=safe_str(e))
930
 
931
- # ------------------ Incident status ------------------
 
932
  @app.post("/incident_status")
933
  async def incident_status(input_data: TicketStatusInput):
934
  try:
@@ -937,7 +958,6 @@ async def incident_status(input_data: TicketStatusInput):
937
  if not instance_url:
938
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
939
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
940
-
941
  if input_data.sys_id:
942
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
943
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
 
26
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
27
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
28
 
29
+
30
  def safe_str(e: Any) -> str:
31
  try:
32
  return builtins.str(e)
33
  except Exception:
34
  return "<error stringify failed>"
35
 
36
+
37
  load_dotenv()
38
  os.environ["POSTHOG_DISABLED"] = "true"
39
 
40
+
41
  @asynccontextmanager
42
  async def lifespan(app: FastAPI):
43
  try:
 
51
  print(f"[KB] ingestion failed: {safe_str(e)}")
52
  yield
53
 
54
+
55
  app = FastAPI(lifespan=lifespan)
56
  app.include_router(login_router)
57
 
 
64
  allow_headers=["*"],
65
  )
66
 
67
+ # ----------------------- Models -----------------------
68
  class ChatInput(BaseModel):
69
  user_message: str
70
  prev_status: Optional[str] = None
71
  last_issue: Optional[str] = None
72
 
73
+
74
  class IncidentInput(BaseModel):
75
  short_description: str
76
  description: str
77
  mark_resolved: Optional[bool] = False
78
 
79
+
80
  class TicketDescInput(BaseModel):
81
  issue: str
82
 
83
+
84
  class TicketStatusInput(BaseModel):
85
  sys_id: Optional[str] = None
86
  number: Optional[str] = None
87
 
88
+
89
  STATE_MAP = {
90
  "1": "New",
91
  "2": "In Progress",
 
101
  f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
102
  )
103
 
104
+ # ----------------------- Helpers -----------------------
105
  NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
106
+
107
+
108
  def _normalize_lines(text: str) -> List[str]:
109
  raw = (text or "")
110
  try:
 
121
  - Uses circled numerals (①..⑳) so the UI won't treat them as ordered lists.
122
  - Falls back to 'N)' beyond 20 steps.
123
  """
 
 
124
  text = re.sub(r"[\u2060\u200B]", "", text or "")
125
 
 
126
  def strip_prefix_any(s: str) -> str:
 
 
 
 
 
 
127
  return re.sub(
128
+ r"^\s*(?:[\u2060\u200B]*"
129
+ r"(?:\d+\s*[.)])"
130
+ r"|(?:step\s*\d+:?)"
131
+ r"|[-*\u2022]"
132
+ r"|[\u2460-\u2473]"
133
+ r")\s*",
134
  "",
135
  (s or "").strip(),
136
+ flags=re.IGNORECASE,
137
  )
138
 
 
139
  lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
140
  if not lines:
141
  return text or ""
142
 
 
143
  merged: List[str] = []
144
  i = 0
145
  while i < len(lines):
 
155
  if not para:
156
  return ""
157
 
158
+ para_clean = re.sub(r"(?:((?<=^)|(?<=\s))\d+[.)]\s+)", "\n\n\n", para)
159
+ para_clean = re.sub(r"(?:((?<=^)|(?<=\s))[\u2460-\u2473]\s+)", "\n\n\n", para_clean)
160
+ para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean)
 
 
 
161
 
162
+ segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
 
163
  if len(segments) < 2:
164
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
165
+ segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|\s+;\s+", para) if seg.strip()]
166
 
 
167
  segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
168
 
 
169
  circled = {
170
+ 1:"\u2460",2:"\u2461",3:"\u2462",4:"\u2463",5:"\u2464",6:"\u2465",7:"\u2466",8:"\u2467",9:"\u2468",10:"\u2469",
171
+ 11:"\u246a",12:"\u246b",13:"\u246c",14:"\u246d",15:"\u246e",16:"\u246f",17:"\u2470",18:"\u2471",19:"\u2472",20:"\u2473",
172
  }
173
  out: List[str] = []
174
  for idx, seg in enumerate(segments, start=1):
175
  marker = circled.get(idx, f"{idx})")
176
  out.append(f"{marker} {seg}")
 
177
  return "\n".join(out)
178
 
179
 
 
180
  def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 4) -> str:
181
+ q = _normalize_for_match(query)
 
 
 
 
182
  q_terms = [t for t in q.split() if len(t) > 2]
183
  if not q_terms:
184
  return text or ""
 
185
  kept: List[str] = []
186
  for ln in _normalize_lines(text):
187
  ln_norm = _normalize_for_match(ln)
 
189
  kept.append(ln)
190
  if len(kept) >= max_lines:
191
  break
 
192
  return "\n".join(kept).strip() if kept else (text or "").strip()
193
 
194
+
195
  def _friendly_permission_reply(raw: str) -> str:
196
  line = (raw or "").strip()
197
+ line = re.sub(r"^\s*[-*\u2022]\s*", "", line)
198
  if not line:
199
  return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
200
  if "verify role access" in line.lower():
 
203
  return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
204
  return line
205
 
206
+
207
  def _detect_language_hint(msg: str) -> Optional[str]:
208
  if re.search(r"[\u0B80-\u0BFF]", msg or ""):
209
  return "Tamil"
 
211
  return "Hindi"
212
  return None
213
 
214
+
215
  def _build_clarifying_message() -> str:
216
  return (
217
  "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
218
  "or should I raise a ServiceNow ticket for you?"
219
  )
220
 
221
+
222
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
223
  issue = (issue_text or "").strip()
224
  resolved = (resolved_text or "").strip()
 
230
  ).strip()
231
  return short_desc, long_desc
232
 
233
+
234
  def _is_incident_intent(msg_norm: str) -> bool:
235
  intent_phrases = [
236
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
 
240
  ]
241
  return any(p in msg_norm for p in intent_phrases)
242
 
243
+
244
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
245
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
246
  if not any(k in msg_norm for k in status_keywords):
 
254
  return {"number": val.upper() if val.lower().startswith("inc") else val}
255
  return {"number": None, "ask_number": True}
256
 
257
+
258
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
259
  phrases = [
260
  "it is resolved", "resolved", "issue resolved", "problem resolved",
 
263
  ]
264
  return any(p in msg_norm for p in phrases)
265
 
266
+
267
  def _has_negation_resolved(msg_norm: str) -> bool:
268
  neg_phrases = [
269
  "not resolved", "issue not resolved", "still not working", "not working",
 
271
  ]
272
  return any(p in msg_norm for p in neg_phrases)
273
 
274
+
275
  def _classify_resolution_llm(user_message: str) -> bool:
276
  if not GEMINI_API_KEY:
277
  return False
 
293
  except Exception:
294
  return False
295
 
296
+
297
+ # ----------------------- Filters -----------------------
298
  STRICT_OVERLAP = 3
299
  MAX_SENTENCES_STRICT = 4
300
  MAX_SENTENCES_CONCISE = 3
301
+
302
  PERM_QUERY_TERMS = [
303
  "permission", "permissions", "access", "access right", "authorization", "authorisation",
304
  "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied",
 
308
  "role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
309
  )
310
 
311
+
312
  def _normalize_for_match(text: str) -> str:
313
  t = (text or "").lower()
314
  t = re.sub(r"[^\w\s]", " ", t)
315
  t = re.sub(r"\s+", " ", t).strip()
316
  return t
317
 
318
+
319
  def _split_sentences(ctx: str) -> List[str]:
320
+ raw_sents = re.split(r"(?<=[.!?])\s+|\n+|\-\s*\*?\s*", ctx or "")
321
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
322
 
323
+
324
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
325
  ctx = (context or "").strip()
326
  if not ctx or not query:
 
348
  kept = sentences[:MAX_SENTENCES_CONCISE]
349
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
350
 
351
+
352
  NAV_LINE_REGEX = re.compile(r"(navigate\s+to|login|log in|menu|screen)", re.IGNORECASE)
353
 
354
+
355
  def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
356
  lines = _normalize_lines(text)
357
  kept: List[str] = []
 
362
  break
363
  return "\n".join(kept).strip() if kept else (text or "").strip()
364
 
365
+
366
  ERROR_STARTS = (
367
  "error", "resolution", "fix", "verify", "check",
368
  "permission", "access", "authorization", "authorisation",
369
  "role", "role mapping", "security profile", "escalation", "not allowed", "not authorized", "denied"
370
  )
371
 
372
+
373
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
374
  lines = _normalize_lines(text)
375
  kept: List[str] = []
 
381
  break
382
  return "\n".join(kept).strip() if kept else (text or "").strip()
383
 
384
+
385
  def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
386
  lines = _normalize_lines(text)
387
  kept: List[str] = []
 
393
  break
394
  return "\n".join(kept).strip() if kept else (text or "").strip()
395
 
396
+
397
  def _extract_escalation_line(text: str) -> Optional[str]:
398
  """
399
  Extract escalation path from SOP text and return a friendly sentence.
 
401
  """
402
  if not text:
403
  return None
 
404
  lines = _normalize_lines(text)
405
  if not lines:
406
  return None
 
 
407
  start_idx = None
408
  for i, ln in enumerate(lines):
409
  low = ln.lower()
410
  if "escalation" in low or "escalation path" in low or "escalate" in low:
411
  start_idx = i
412
  break
 
413
  block = []
414
  if start_idx is not None:
415
  for j in range(start_idx, min(len(lines), start_idx + 6)):
 
417
  break
418
  block.append(lines[j].strip())
419
  else:
 
420
  block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
 
421
  if not block:
422
  return None
 
423
  text_block = " ".join(block)
 
424
  m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
425
  path = m.group(1).strip() if m else None
 
426
  if not path:
427
  arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
428
  if arrow_lines:
429
  path = arrow_lines[0]
 
430
  if not path:
431
  m2 = re.search(r"(operator.*?administrator|operator.*)", text_block, flags=re.IGNORECASE)
432
  path = m2.group(1).strip() if m2 else None
 
433
  if not path:
434
  return None
 
435
  path = path.replace("->", "→").strip()
436
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
 
437
  return f"If you want to escalate the issue, follow: {path}"
438
 
439
+
440
+ # ----------------------- NEW: Operation-aware steps filter -----------------------
441
+ UPDATE_TERMS = ("update", "edit", "modify", "change", "reschedule", "adjust", "move", "save")
442
+ CREATE_TERMS = ("create", "add", "new", "book", "schedule", "set up", "make")
443
+
444
+
445
+ def _contains_any(s: str, terms: tuple) -> bool:
446
+ low = (s or "").lower()
447
+ return any(t in low for t in terms)
448
+
449
+
450
+ def _filter_steps_for_operation(text: str, actions: list, max_lines: int = 12) -> str:
451
+ """
452
+ Keep only the lines relevant to the requested operation ('update' or 'create').
453
+ Falls back to the original text if nothing matches (non-breaking).
454
+ """
455
+ lines = _normalize_lines(text)
456
+ if not lines:
457
+ return text or ""
458
+ want_update = "update" in (actions or [])
459
+ want_create = "create" in (actions or [])
460
+ kept = []
461
+ for ln in lines:
462
+ if want_update and not want_create:
463
+ if _contains_any(ln, UPDATE_TERMS) and not _contains_any(ln, CREATE_TERMS):
464
+ kept.append(ln)
465
+ elif want_create and not want_update:
466
+ if _contains_any(ln, CREATE_TERMS) and not _contains_any(ln, UPDATE_TERMS):
467
+ kept.append(ln)
468
+ else:
469
+ kept.append(ln)
470
+ if len(kept) >= max_lines:
471
+ break
472
+ return "\n".join(kept).strip() or text
473
+
474
+
475
+ # ----------------------- Health -----------------------
476
  @app.get("/")
477
  async def health_check():
478
  return {"status": "ok"}
479
 
480
+
481
+ # ----------------------- Chat -----------------------
482
  @app.post("/chat")
483
  async def chat_with_ai(input_data: ChatInput):
484
  try:
 
507
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
508
  if _has_negation_resolved(msg_norm):
509
  is_llm_resolved = False
510
+
511
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
512
  try:
513
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
 
672
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
673
  context = filtered_text
674
  context_found = bool(context.strip())
 
675
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
676
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
677
  detected_intent = kb_results.get("user_intent", "neutral")
678
  best_doc = kb_results.get("best_doc")
679
  top_meta = (metadatas or [{}])[0] if metadatas else {}
680
 
 
681
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
682
  if is_perm_query:
683
  detected_intent = "errors"
684
 
 
685
  escalation_line = None # SOP escalation candidate (if found)
686
+
687
  if best_doc:
688
  if detected_intent == "steps":
689
  full_steps = get_best_steps_section_text(best_doc)
690
  if not full_steps:
691
  sec = (top_meta or {}).get("section")
692
+ if sec:
693
+ full_steps = get_section_text(best_doc, sec)
694
  if full_steps:
695
+ # NEW: restrict to the requested operation before numbering
696
+ op_filtered = _filter_steps_for_operation(
697
+ full_steps,
698
+ kb_results.get("actions", [])
699
+ )
700
+ context = _ensure_numbering(op_filtered or full_steps)
701
  elif detected_intent == "errors":
702
  full_errors = get_best_errors_section_text(best_doc)
703
  if full_errors:
 
704
  ctx_err = _extract_errors_only(full_errors, max_lines=12)
 
 
705
  if is_perm_query:
706
  context = _filter_permission_lines(ctx_err, max_lines=6)
707
  else:
 
708
  context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=6)
 
 
709
  escalation_line = _extract_escalation_line(full_errors)
 
710
  lines = _normalize_lines(context)
711
  if len(lines) == 1:
712
  context = _friendly_permission_reply(lines[0])
713
 
 
714
  if detected_intent == "errors" and not (context or "").strip():
715
  return {
716
  "bot_response": _build_clarifying_message(),
 
725
  "debug": {"intent": "errors_no_context"},
726
  }
727
 
 
728
  language_hint = _detect_language_hint(input_data.user_message)
729
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
730
  use_gemini = (detected_intent == "errors")
 
740
  Return ONLY the rewritten guidance."""
741
  headers = {"Content-Type": "application/json"}
742
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
 
743
  bot_text = ""
744
  http_code = 0
745
  if use_gemini:
 
757
  except Exception:
758
  bot_text, http_code = "", 0
759
 
 
760
  if detected_intent == "steps":
761
  bot_text = _ensure_numbering(context)
762
  elif detected_intent == "errors":
763
  if not bot_text.strip() or http_code == 429:
 
764
  bot_text = context.strip()
 
 
765
  if escalation_line:
766
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
767
+ else:
768
+ bot_text = context
769
 
 
770
  short_query = len((input_data.user_message or "").split()) <= 4
771
  gate_combined_ok = 0.60 if short_query else 0.55
772
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
773
  lower = (bot_text or "").lower()
774
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
775
  status = "PARTIAL"
 
776
  options = [{"type":"yesno","title":"Share details or raise a ticket?"}] if status == "PARTIAL" else []
777
+
778
  return {
779
  "bot_response": bot_text,
780
  "status": status,
 
802
  except Exception as e:
803
  raise HTTPException(status_code=500, detail=safe_str(e))
804
 
805
+
806
  def _set_incident_resolved(sys_id: str) -> bool:
807
  try:
808
  token = get_valid_token()
 
816
  "Content-Type": "application/json",
817
  }
818
  url = f"{instance_url}/api/now/table/incident/{sys_id}"
 
819
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
820
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
821
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
822
  resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
823
  assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
824
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
 
825
  if require_progress:
826
  try:
827
  resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
 
883
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
884
  return False
885
 
886
+
887
+ # ----------------------- Incident -----------------------
888
  @app.post("/incident")
889
  async def raise_incident(input_data: IncidentInput):
890
  try:
 
909
  except Exception as e:
910
  raise HTTPException(status_code=500, detail=safe_str(e))
911
 
912
+
913
+ # ----------------------- Ticket description generation -----------------------
914
  @app.post("/generate_ticket_desc")
915
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
916
  try:
 
918
  f"You are helping generate ServiceNow ticket descriptions based on the issue: {input_data.issue}.\n"
919
  "Please return the output strictly in JSON format with the following keys:\n"
920
  "{\n"
921
+ ' "ShortDescription": "A concise summary of the issue (max 100 characters)",\n'
922
+ ' "DetailedDescription": "A detailed explanation of the issue"\n'
923
  "}\n"
924
  "Do not include any extra text, comments, or explanations outside the JSON."
925
  )
 
948
  except Exception as e:
949
  raise HTTPException(status_code=500, detail=safe_str(e))
950
 
951
+
952
+ # ----------------------- Incident status -----------------------
953
  @app.post("/incident_status")
954
  async def incident_status(input_data: TicketStatusInput):
955
  try:
 
958
  if not instance_url:
959
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
960
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
 
961
  if input_data.sys_id:
962
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
963
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)