srilakshu012456 commited on
Commit
e229d0b
·
verified ·
1 Parent(s): c3ee443

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +376 -224
main.py CHANGED
@@ -1,4 +1,3 @@
1
-
2
  import os
3
  import json
4
  import re
@@ -19,7 +18,6 @@ from services.kb_creation import (
19
  get_section_text,
20
  get_best_steps_section_text,
21
  get_best_errors_section_text,
22
- get_steps_text_by_action,
23
  )
24
  from services.login import router as login_router
25
  from services.generate_ticket import get_valid_token, create_incident
@@ -51,6 +49,7 @@ async def lifespan(app: FastAPI):
51
 
52
  app = FastAPI(lifespan=lifespan)
53
  app.include_router(login_router)
 
54
  origins = ["https://chatbotnova-chatbot-frontend.hf.space"]
55
  app.add_middleware(
56
  CORSMiddleware,
@@ -60,7 +59,7 @@ app.add_middleware(
60
  allow_headers=["*"],
61
  )
62
 
63
- # -------------------- Models --------------------
64
  class ChatInput(BaseModel):
65
  user_message: str
66
  prev_status: Optional[str] = None
@@ -93,9 +92,8 @@ 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()
98
-
99
  def _normalize_lines(text: str) -> List[str]:
100
  raw = (text or "")
101
  try:
@@ -103,55 +101,204 @@ def _normalize_lines(text: str) -> List[str]:
103
  except Exception:
104
  return [raw.strip()] if raw.strip() else []
105
 
 
106
  def _ensure_numbering(text: str) -> str:
107
  """
108
- Robust step numbering:
109
- - Split by natural delimiters (newline, end punctuation, semicolons, arrows, 'then', bullets).
110
- - Strip any existing list markers.
111
- - Emit circled numerals ①..⑳ then fallback to N).
 
112
  """
113
- t = (text or "").replace("\u2060", "").replace("\u200B", "")
114
- parts = re.split(
115
- r"(?:\r?\n|\r|\u2028|\u2029)" # newlines
116
- r"|(?<=[.!?])\s+" # sentence end
117
- r"|;\s+" # semicolons
118
- r"|(?:\s*→\s*|\s*->\s*)" # arrows
119
- r"|(?:\s*\bthen\b\s*)" # 'then'
120
- r"|(?:^\s*[-*•]\s*)", # bullets
121
- t,
122
- flags=re.IGNORECASE,
123
- )
124
- cleaned: List[str] = []
125
- for seg in parts:
126
- s = (seg or "").strip()
127
- if not s:
128
- continue
129
- s = re.sub(
130
- r"^\s*(?:"
131
- r"(?:\d+\s*[.)])|"
132
- r"(?i:step\s*\d+:?)|"
133
- r"[-*•]|"
134
- r"[\u2460-\u2473]" # ①..⑳
135
- r")\s*",
136
  "",
137
- s,
 
138
  )
139
- if s:
140
- cleaned.append(s)
141
- if not cleaned:
142
- return (text or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  circled = {
144
- 1:"\u2460",2:"\u2461",3:"\u2462",4:"\u2463",5:"\u2464",
145
- 6:"\u2465",7:"\u2466",8:"\u2467",9:"\u2468",10:"\u2469",
146
- 11:"\u246a",12:"\u246b",13:"\u246c",14:"\u246d",15:"\u246e",
147
- 16:"\u246f",17:"\u2470",18:"\u2471",19:"\u2472",20:"\u2473",
148
  }
149
- out = []
150
- for i, s in enumerate(cleaned, 1):
151
- marker = circled.get(i, f"{i})")
152
- out.append(f"{marker} {s}")
 
153
  return "\n".join(out)
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  STRICT_OVERLAP = 3
156
  MAX_SENTENCES_STRICT = 4
157
  MAX_SENTENCES_CONCISE = 3
@@ -171,7 +318,7 @@ def _normalize_for_match(text: str) -> str:
171
  return t
172
 
173
  def _split_sentences(ctx: str) -> List[str]:
174
- raw_sents = re.split(r"(?<=[.!?])\s+|\n+|\-\s+|\*\s+", ctx or "")
175
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
176
 
177
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
@@ -242,17 +389,25 @@ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
242
  return "\n".join(kept).strip() if kept else (text or "").strip()
243
 
244
  def _extract_escalation_line(text: str) -> Optional[str]:
 
 
 
 
245
  if not text:
246
  return None
 
247
  lines = _normalize_lines(text)
248
  if not lines:
249
  return None
 
 
250
  start_idx = None
251
  for i, ln in enumerate(lines):
252
  low = ln.lower()
253
  if "escalation" in low or "escalation path" in low or "escalate" in low:
254
  start_idx = i
255
  break
 
256
  block = []
257
  if start_idx is not None:
258
  for j in range(start_idx, min(len(lines), start_idx + 6)):
@@ -260,39 +415,40 @@ def _extract_escalation_line(text: str) -> Optional[str]:
260
  break
261
  block.append(lines[j].strip())
262
  else:
 
263
  block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
 
264
  if not block:
265
  return None
 
266
  text_block = " ".join(block)
 
267
  m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
268
  path = m.group(1).strip() if m else None
 
269
  if not path:
270
  arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
271
  if arrow_lines:
272
  path = arrow_lines[0]
 
273
  if not path:
274
  m2 = re.search(r"(operator.*?administrator|operator.*)", text_block, flags=re.IGNORECASE)
275
  path = m2.group(1).strip() if m2 else None
 
276
  if not path:
277
  return None
 
278
  path = path.replace("->", "→").strip()
279
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
280
- return f"If you want to escalate the issue, follow: {path}"
281
 
282
- # Optional: language hint (since you use it later)
283
- def _detect_language_hint(msg: str) -> Optional[str]:
284
- if re.search(r"[\u0B80-\u0BFF]", msg or ""):
285
- return "Tamil"
286
- if re.search(r"[\u0900-\u097F]", msg or ""):
287
- return "Hindi"
288
- return None
289
 
290
- # -------------------- Health --------------------
291
  @app.get("/")
292
  async def health_check():
293
  return {"status": "ok"}
294
 
295
- # -------------------- Chat --------------------
296
  @app.post("/chat")
297
  async def chat_with_ai(input_data: ChatInput):
298
  try:
@@ -318,58 +474,11 @@ async def chat_with_ai(input_data: ChatInput):
318
  }
319
 
320
  # Resolution ack (auto incident + mark Resolved)
321
- def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
322
- phrases = [
323
- "it is resolved", "resolved", "issue resolved", "problem resolved",
324
- "it's working", "working now", "works now", "fixed", "sorted",
325
- "ok now", "fine now", "all good", "all set", "thanks works", "thank you it works", "back to normal",
326
- ]
327
- return any(p in msg_norm for p in phrases)
328
-
329
- def _has_negation_resolved(msg_norm: str) -> bool:
330
- neg_phrases = [
331
- "not resolved", "issue not resolved", "still not working", "not working",
332
- "didn't work", "doesn't work", "no change", "not fixed", "still failing", "failed again", "broken", "fail",
333
- ]
334
- return any(p in msg_norm for p in neg_phrases)
335
-
336
- def _classify_resolution_llm(user_message: str) -> bool:
337
- if not GEMINI_API_KEY:
338
- return False
339
- prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
340
- Return only 'true' or 'false'.
341
- Message: {user_message}"""
342
- headers = {"Content-Type": "application/json"}
343
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
344
- try:
345
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
346
- data = resp.json()
347
- text = (
348
- data.get("candidates", [{}])[0]
349
- .get("content", {})
350
- .get("parts", [{}])[0]
351
- .get("text", "")
352
- )
353
- return "true" in (text or "").strip().lower()
354
- except Exception:
355
- return False
356
-
357
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
358
  if _has_negation_resolved(msg_norm):
359
  is_llm_resolved = False
360
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
361
  try:
362
- def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
363
- issue = (issue_text or "").strip()
364
- resolved = (resolved_text or "").strip()
365
- short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
366
- long_desc = (
367
- f'User reported: "{issue}". '
368
- f'User confirmation: "{resolved}". '
369
- "Tracking record created automatically by NOVA."
370
- ).strip()
371
- return short_desc, long_desc
372
-
373
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
374
  result = create_incident(short_desc, long_desc)
375
  if isinstance(result, dict) and not result.get("error"):
@@ -419,15 +528,7 @@ Message: {user_message}"""
419
  "debug": {"intent": "resolved_ack", "exception": True},
420
  }
421
 
422
- def _is_incident_intent(msg_norm: str) -> bool:
423
- intent_phrases = [
424
- "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
425
- "create incident", "create an incident", "raise incident", "raise an incident", "open incident", "open an incident",
426
- "log ticket", "log an incident", "generate ticket", "create snow ticket", "raise snow ticket",
427
- "raise service now ticket", "create service now ticket", "raise sr", "open sr",
428
- ]
429
- return any(p in msg_norm for p in intent_phrases)
430
-
431
  if _is_incident_intent(msg_norm):
432
  return {
433
  "bot_response": (
@@ -447,11 +548,7 @@ Message: {user_message}"""
447
  }
448
 
449
  # Status intent
450
- status_intent = (lambda m: (
451
- {"number": re.search(r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)", m, re.IGNORECASE).group(1).upper()} if re.search(r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)", m, re.IGNORECASE) else (
452
- {"number": re.search(r"(inc\d+)", m, re.IGNORECASE).group(1).upper()} if re.search(r"(inc\d+)", m, re.IGNORECASE) else {"number": None, "ask_number": True}
453
- )
454
- ))(msg_norm)
455
  if status_intent:
456
  if status_intent.get("ask_number"):
457
  return {
@@ -504,12 +601,28 @@ Message: {user_message}"""
504
  except Exception as e:
505
  raise HTTPException(status_code=500, detail=safe_str(e))
506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  # Hybrid KB search
508
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
509
  documents = kb_results.get("documents", [])
510
  metadatas = kb_results.get("metadatas", [])
511
  distances = kb_results.get("distances", [])
512
  combined = kb_results.get("combined_scores", [])
 
513
  items: List[Dict[str, Any]] = []
514
  for i, doc in enumerate(documents):
515
  text = doc.strip() if isinstance(doc, str) else ""
@@ -522,72 +635,55 @@ Message: {user_message}"""
522
  if score is not None: m["distance"] = score
523
  if comb is not None: m["combined"] = comb
524
  items.append({"text": text, "meta": m})
 
525
  selected = items[:max(1, 2)]
526
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
 
 
 
527
 
 
 
528
  detected_intent = kb_results.get("user_intent", "neutral")
529
  best_doc = kb_results.get("best_doc")
530
  top_meta = (metadatas or [{}])[0] if metadatas else {}
531
 
532
- # Use raw context for steps; filtered context for others
533
- if detected_intent == "steps":
534
- context = context_raw
535
- filt_info = {"mode": "raw", "matched_count": None}
536
- else:
537
- filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
538
- context = filtered_text
539
-
540
- context_found = bool((context or "").strip())
541
- best_distance = min([d for d in distances if d is not None], default=None) if distances else None
542
- best_combined = max([c for c in combined if c is not None], default=None) if combined else None
543
-
544
  # Force errors intent for permissions
545
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
546
  if is_perm_query:
547
  detected_intent = "errors"
548
 
549
  # Prefer full SOP section when we have the best_doc
550
- escalation_line = None
551
  if best_doc:
552
  if detected_intent == "steps":
553
- preferred_actions = kb_results.get("actions", []) or []
554
- full_steps = get_steps_text_by_action(best_doc, preferred_actions)
555
  if not full_steps:
556
- fallback = get_best_steps_section_text(best_doc)
557
- if preferred_actions and ("update" in [a.lower() for a in preferred_actions]):
558
- fallback_lines = [ln.strip() for ln in (fallback or "").splitlines() if ln.strip()]
559
- keep = []
560
- for ln in fallback_lines:
561
- low = ln.lower()
562
- is_creation_only = (
563
- ("create" in low or "creation" in low or " add " in low or " new " in low or " book " in low or " schedule " in low)
564
- and not any(x in low for x in ("update", "modify", "change", "edit", "amend", "reschedule", "re-schedule"))
565
- )
566
- if is_creation_only:
567
- continue
568
- keep.append(ln)
569
- fallback = "\n".join(keep).strip()
570
- full_steps = fallback if (fallback and fallback.strip()) else full_steps
571
- if not full_steps:
572
- sec = (top_meta or {}).get("section")
573
- if sec:
574
- full_steps = get_section_text(best_doc, sec)
575
  if full_steps:
576
  context = _ensure_numbering(full_steps)
577
-
578
  elif detected_intent == "errors":
579
  full_errors = get_best_errors_section_text(best_doc)
580
  if full_errors:
 
581
  ctx_err = _extract_errors_only(full_errors, max_lines=12)
 
 
582
  if is_perm_query:
583
  context = _filter_permission_lines(ctx_err, max_lines=6)
584
  else:
 
585
  context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=6)
 
 
586
  escalation_line = _extract_escalation_line(full_errors)
 
587
  lines = _normalize_lines(context)
588
  if len(lines) == 1:
589
  context = _friendly_permission_reply(lines[0])
590
 
 
591
  if detected_intent == "errors" and not (context or "").strip():
592
  return {
593
  "bot_response": _build_clarifying_message(),
@@ -595,13 +691,14 @@ Message: {user_message}"""
595
  "context_found": False,
596
  "ask_resolved": False,
597
  "suggest_incident": True,
598
- "followup": "Share more details (module/screen/error), or say 'create ticket'.",
599
- "options": [{"type":"yesno","title":"Provide details or create a ticket?"}],
600
  "top_hits": [],
601
  "sources": [],
602
  "debug": {"intent": "errors_no_context"},
603
  }
604
 
 
605
  language_hint = _detect_language_hint(input_data.user_message)
606
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
607
  use_gemini = (detected_intent == "errors")
@@ -617,6 +714,7 @@ Message: {user_message}"""
617
  Return ONLY the rewritten guidance."""
618
  headers = {"Content-Type": "application/json"}
619
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
 
620
  bot_text = ""
621
  http_code = 0
622
  if use_gemini:
@@ -634,23 +732,29 @@ Return ONLY the rewritten guidance."""
634
  except Exception:
635
  bot_text, http_code = "", 0
636
 
 
637
  if detected_intent == "steps":
638
  bot_text = _ensure_numbering(context)
639
  elif detected_intent == "errors":
640
  if not bot_text.strip() or http_code == 429:
 
641
  bot_text = context.strip()
 
 
642
  if escalation_line:
643
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
644
  else:
645
  bot_text = context
646
 
 
647
  short_query = len((input_data.user_message or "").split()) <= 4
648
  gate_combined_ok = 0.60 if short_query else 0.55
649
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
650
  lower = (bot_text or "").lower()
651
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
652
  status = "PARTIAL"
653
- options = [{"type":"yesno","title":"Provide details or create a ticket?"}] if status == "PARTIAL" else []
 
654
  return {
655
  "bot_response": bot_text,
656
  "status": status,
@@ -662,86 +766,22 @@ Return ONLY the rewritten guidance."""
662
  "top_hits": [],
663
  "sources": [],
664
  "debug": {
665
- "used_chunks": len((context_raw or "").split("\n\n---\n\n")) if context_raw else 0,
666
  "best_distance": best_distance,
667
  "best_combined": best_combined,
668
  "http_status": http_code,
669
- "filter_mode": filt_info.get("mode") if isinstance(filt_info, dict) else None,
670
- "matched_count": filt_info.get("matched_count") if isinstance(filt_info, dict) else None,
671
  "user_intent": detected_intent,
672
  "best_doc": best_doc,
673
  },
674
  }
 
675
  except HTTPException:
676
  raise
677
  except Exception as e:
678
  raise HTTPException(status_code=500, detail=safe_str(e))
679
 
680
- # -------------------- Incident --------------------
681
- @app.post("/incident")
682
- async def raise_incident(input_data: IncidentInput):
683
- try:
684
- result = create_incident(input_data.short_description, input_data.description)
685
- if isinstance(result, dict) and not result.get("error"):
686
- inc_number = result.get("number", "<unknown>")
687
- sys_id = result.get("sys_id")
688
- resolved_note = ""
689
- if bool(input_data.mark_resolved) and sys_id not in ("<unknown>", None):
690
- ok = _set_incident_resolved(sys_id)
691
- resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
692
- ticket_text = f"Incident created: {inc_number}{resolved_note}" if inc_number else "Incident created."
693
- return {
694
- "bot_response": f"✅ {ticket_text}",
695
- "debug": "Incident created via ServiceNow",
696
- "persist": True,
697
- "show_assist_card": True,
698
- "followup": "Is there anything else I can assist you with?",
699
- }
700
- else:
701
- raise HTTPException(status_code=500, detail=(result or {}).get("error", "Unknown error"))
702
- except Exception as e:
703
- raise HTTPException(status_code=500, detail=safe_str(e))
704
-
705
- # -------------------- Incident status --------------------
706
- @app.post("/incident_status")
707
- async def incident_status(input_data: TicketStatusInput):
708
- try:
709
- token = get_valid_token()
710
- instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
711
- if not instance_url:
712
- raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
713
- headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
714
- if input_data.sys_id:
715
- url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
716
- response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
717
- data = response.json()
718
- result = data.get("result", {}) if response.status_code == 200 else {}
719
- elif input_data.number:
720
- url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
721
- response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
722
- data = response.json()
723
- lst = data.get("result", [])
724
- result = (lst or [{}])[0] if response.status_code == 200 else {}
725
- else:
726
- raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
727
- state_code = builtins.str(result.get("state", "unknown"))
728
- state_label = STATE_MAP.get(state_code, state_code)
729
- short = result.get("short_description", "")
730
- number = result.get("number", input_data.number or "unknown")
731
- return {
732
- "bot_response": (
733
- f"**Ticket:** {number} \n"
734
- f"**Status:** {state_label} \n"
735
- f"**Issue description:** {short}"
736
- ).replace("\n", " \n"),
737
- "followup": "Is there anything else I can assist you with?",
738
- "show_assist_card": True,
739
- "persist": True,
740
- "debug": "Incident status fetched",
741
- }
742
- except Exception as e:
743
- raise HTTPException(status_code=500, detail=safe_str(e))
744
-
745
  def _set_incident_resolved(sys_id: str) -> bool:
746
  try:
747
  token = get_valid_token()
@@ -755,20 +795,24 @@ def _set_incident_resolved(sys_id: str) -> bool:
755
  "Content-Type": "application/json",
756
  }
757
  url = f"{instance_url}/api/now/table/incident/{sys_id}"
 
758
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
759
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
760
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
761
  resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
762
  assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
763
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
 
764
  if require_progress:
765
  try:
766
  resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
767
  print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
768
  except Exception as e:
769
  print(f"[SN PATCH progress] exception={safe_str(e)}")
 
770
  def clean(d: dict) -> dict:
771
  return {k: v for k, v in d.items() if v is not None}
 
772
  payload_A = clean({
773
  "state": "6",
774
  "close_code": close_code_val,
@@ -783,6 +827,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
783
  if respA.status_code in (200, 204):
784
  return True
785
  print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
 
786
  payload_B = clean({
787
  "state": "Resolved",
788
  "close_code": close_code_val,
@@ -797,6 +842,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
797
  if respB.status_code in (200, 204):
798
  return True
799
  print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
 
800
  code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
801
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
802
  payload_C = clean({
@@ -817,3 +863,109 @@ def _set_incident_resolved(sys_id: str) -> bool:
817
  except Exception as e:
818
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
819
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import json
3
  import re
 
18
  get_section_text,
19
  get_best_steps_section_text,
20
  get_best_errors_section_text,
 
21
  )
22
  from services.login import router as login_router
23
  from services.generate_ticket import get_valid_token, create_incident
 
49
 
50
  app = FastAPI(lifespan=lifespan)
51
  app.include_router(login_router)
52
+
53
  origins = ["https://chatbotnova-chatbot-frontend.hf.space"]
54
  app.add_middleware(
55
  CORSMiddleware,
 
59
  allow_headers=["*"],
60
  )
61
 
62
+ # ------------------ Models ------------------
63
  class ChatInput(BaseModel):
64
  user_message: str
65
  prev_status: Optional[str] = None
 
92
  f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
93
  )
94
 
95
+ # ------------------ Helpers ------------------
96
+ NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
 
97
  def _normalize_lines(text: str) -> List[str]:
98
  raw = (text or "")
99
  try:
 
101
  except Exception:
102
  return [raw.strip()] if raw.strip() else []
103
 
104
+
105
  def _ensure_numbering(text: str) -> str:
106
  """
107
+ Render numbered steps robustly:
108
+ - Handles single-paragraph SOP content that already contains inline markers.
109
+ - Strips ANY leading marker per segment (classic, bullets, and circled numerals).
110
+ - Uses circled numerals (①..⑳) so the UI won't treat them as ordered lists.
111
+ - Falls back to 'N)' beyond 20 steps.
112
  """
113
+
114
+ # 0) Strip hidden chars that can break regexes
115
+ text = re.sub(r"[\u2060\u200B]", "", text or "")
116
+
117
+ # --- Helpers ---
118
+ def strip_prefix_any(s: str) -> str:
119
+ """
120
+ Remove *any* leading list marker:
121
+ - classic: '1)', '2.', 'Step 3:', bullets '- * •'
122
+ - circled numerals: ①..⑳ (U+2460..U+2473)
123
+ - optional hidden chars before them
124
+ """
125
+ return re.sub(
126
+ r"^\s*(?:[\u2060\u200B]*" # optional zero-width prefix
127
+ r"(?:\d+\s*[.)]" # '1)' '2.' '3 )'
128
+ r"|(?:step\s*\d+:?)" # 'Step 3:' (case-insensitive handled below)
129
+ r"|[\-\*\u2022]" # bullets '-', '*', '•'
130
+ r"|[\u2460-\u2473]" # circled numerals ①..⑳
131
+ r"))\s*",
 
 
 
 
132
  "",
133
+ (s or "").strip(),
134
+ flags=re.IGNORECASE
135
  )
136
+
137
+ # 1) Normalize lines
138
+ lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
139
+ if not lines:
140
+ return text or ""
141
+
142
+ # 2) Merge dangling numeric-only lines with following text
143
+ merged: List[str] = []
144
+ i = 0
145
+ while i < len(lines):
146
+ ln = lines[i]
147
+ if re.fullmatch(r"\d+[.)]?|[\u2460-\u2473]", ln) and (i + 1) < len(lines):
148
+ merged.append(lines[i + 1].strip())
149
+ i += 2
150
+ else:
151
+ merged.append(ln)
152
+ i += 1
153
+
154
+ para = " ".join(merged).strip()
155
+ if not para:
156
+ return ""
157
+
158
+ # 3) Neutralize inline markers to a hard delimiter '|||'
159
+ # - classic numbers: ' 2) ', ' 3. '
160
+ # - circled numerals: ' ② ' etc.
161
+ para_clean = re.sub(r"(?:(?<=^)|(?<=\s))\d+[.)]\s+", "|||", para)
162
+ para_clean = re.sub(r"(?:(?<=^)|(?<=\s))[\u2460-\u2473]\s+", "|||", para_clean)
163
+ para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "|||", para_clean)
164
+
165
+ # 4) Split → segments
166
+ segments = [seg.strip() for seg in para_clean.split("|||") if seg.strip()]
167
+ if len(segments) < 2:
168
+ tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
169
+ segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|;\s+", para) if seg.strip()]
170
+
171
+ # 5) Strip ANY leading marker from each segment (including circled ones)
172
+ segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
173
+
174
+ # 6) Numbering: circled numerals for 1..20; then 'N)' style
175
  circled = {
176
+ 1:"",2:"",3:"",4:"",5:"",6:"⑥",7:"⑦",8:"⑧",9:"⑨",10:"⑩",
177
+ 11:"",12:"",13:"",14:"",15:"",16:"⑯",17:"⑰",18:"⑱",19:"⑲",20:"⑳",
 
 
178
  }
179
+ out: List[str] = []
180
+ for idx, seg in enumerate(segments, start=1):
181
+ marker = circled.get(idx, f"{idx})")
182
+ out.append(f"{marker} {seg}")
183
+
184
  return "\n".join(out)
185
 
186
+
187
+
188
+ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 4) -> str:
189
+ """
190
+ Keep only error lines that contain key tokens from the user's query.
191
+ E.g., for 'putaway error', keep lines containing 'putaway' / 'error'.
192
+ """
193
+ q = _normalize_for_match(query) # lower + punctuation stripped
194
+ q_terms = [t for t in q.split() if len(t) > 2]
195
+ if not q_terms:
196
+ return text or ""
197
+
198
+ kept: List[str] = []
199
+ for ln in _normalize_lines(text):
200
+ ln_norm = _normalize_for_match(ln)
201
+ if any(t in ln_norm for t in q_terms):
202
+ kept.append(ln)
203
+ if len(kept) >= max_lines:
204
+ break
205
+
206
+ return "\n".join(kept).strip() if kept else (text or "").strip()
207
+
208
+ def _friendly_permission_reply(raw: str) -> str:
209
+ line = (raw or "").strip()
210
+ line = re.sub(r"^\s*[\-\*\u2022]\s*", "", line)
211
+ if not line:
212
+ return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
213
+ if "verify role access" in line.lower():
214
+ return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
215
+ if ("permission" in line.lower()) or ("access" in line.lower()) or ("authorization" in line.lower()):
216
+ return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
217
+ return line
218
+
219
+ def _detect_language_hint(msg: str) -> Optional[str]:
220
+ if re.search(r"[\u0B80-\u0BFF]", msg or ""):
221
+ return "Tamil"
222
+ if re.search(r"[\u0900-\u097F]", msg or ""):
223
+ return "Hindi"
224
+ return None
225
+
226
+ def _build_clarifying_message() -> str:
227
+ return (
228
+ "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
229
+ "or should I raise a ServiceNow ticket for you?"
230
+ )
231
+
232
+ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
233
+ issue = (issue_text or "").strip()
234
+ resolved = (resolved_text or "").strip()
235
+ short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
236
+ long_desc = (
237
+ f"User reported: \"{issue}\". "
238
+ f"User confirmation: \"{resolved}\". "
239
+ f"Tracking record created automatically by NOVA."
240
+ ).strip()
241
+ return short_desc, long_desc
242
+
243
+ def _is_incident_intent(msg_norm: str) -> bool:
244
+ intent_phrases = [
245
+ "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
246
+ "create incident", "create an incident", "raise incident", "raise an incident", "open incident", "open an incident",
247
+ "log ticket", "log an incident", "generate ticket", "create snow ticket", "raise snow ticket",
248
+ "raise service now ticket", "create service now ticket", "raise sr", "open sr",
249
+ ]
250
+ return any(p in msg_norm for p in intent_phrases)
251
+
252
+ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
253
+ status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
254
+ if not any(k in msg_norm for k in status_keywords):
255
+ return {}
256
+ patterns = [r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)", r"(inc\d+)"]
257
+ for pat in patterns:
258
+ m = re.search(pat, msg_norm, flags=re.IGNORECASE)
259
+ if m:
260
+ val = m.group(1).strip()
261
+ if val:
262
+ return {"number": val.upper() if val.lower().startswith("inc") else val}
263
+ return {"number": None, "ask_number": True}
264
+
265
+ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
266
+ phrases = [
267
+ "it is resolved", "resolved", "issue resolved", "problem resolved",
268
+ "it's working", "working now", "works now", "fixed", "sorted",
269
+ "ok now", "fine now", "all good", "all set", "thanks works", "thank you it works", "back to normal",
270
+ ]
271
+ return any(p in msg_norm for p in phrases)
272
+
273
+ def _has_negation_resolved(msg_norm: str) -> bool:
274
+ neg_phrases = [
275
+ "not resolved", "issue not resolved", "still not working", "not working",
276
+ "didn't work", "doesn't work", "no change", "not fixed", "still failing", "failed again", "broken", "fail",
277
+ ]
278
+ return any(p in msg_norm for p in neg_phrases)
279
+
280
+ def _classify_resolution_llm(user_message: str) -> bool:
281
+ if not GEMINI_API_KEY:
282
+ return False
283
+ prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
284
+ Return only 'true' or 'false'.
285
+ Message: {user_message}"""
286
+ headers = {"Content-Type": "application/json"}
287
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
288
+ try:
289
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
290
+ data = resp.json()
291
+ text = (
292
+ data.get("candidates", [{}])[0]
293
+ .get("content", {})
294
+ .get("parts", [{}])[0]
295
+ .get("text", "")
296
+ )
297
+ return "true" in (text or "").strip().lower()
298
+ except Exception:
299
+ return False
300
+
301
+ # ------------------ Filters ------------------
302
  STRICT_OVERLAP = 3
303
  MAX_SENTENCES_STRICT = 4
304
  MAX_SENTENCES_CONCISE = 3
 
318
  return t
319
 
320
  def _split_sentences(ctx: str) -> List[str]:
321
+ raw_sents = re.split(r"(?<=[.!?])\s+|\n+|\-\s*|\*\s*", ctx or "")
322
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
323
 
324
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
 
389
  return "\n".join(kept).strip() if kept else (text or "").strip()
390
 
391
  def _extract_escalation_line(text: str) -> Optional[str]:
392
+ """
393
+ Extract escalation path from SOP text and return a friendly sentence.
394
+ Prefers the line after 'Escalation Path:' or any arrow chain (-> or →).
395
+ """
396
  if not text:
397
  return None
398
+
399
  lines = _normalize_lines(text)
400
  if not lines:
401
  return None
402
+
403
+ # Locate an escalation heading
404
  start_idx = None
405
  for i, ln in enumerate(lines):
406
  low = ln.lower()
407
  if "escalation" in low or "escalation path" in low or "escalate" in low:
408
  start_idx = i
409
  break
410
+
411
  block = []
412
  if start_idx is not None:
413
  for j in range(start_idx, min(len(lines), start_idx + 6)):
 
415
  break
416
  block.append(lines[j].strip())
417
  else:
418
+ # Look for any arrows if heading not found
419
  block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
420
+
421
  if not block:
422
  return None
423
+
424
  text_block = " ".join(block)
425
+
426
  m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
427
  path = m.group(1).strip() if m else None
428
+
429
  if not path:
430
  arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
431
  if arrow_lines:
432
  path = arrow_lines[0]
433
+
434
  if not path:
435
  m2 = re.search(r"(operator.*?administrator|operator.*)", text_block, flags=re.IGNORECASE)
436
  path = m2.group(1).strip() if m2 else None
437
+
438
  if not path:
439
  return None
440
+
441
  path = path.replace("->", "→").strip()
442
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
 
443
 
444
+ return f"If you want to escalate the issue, follow: {path}"
 
 
 
 
 
 
445
 
446
+ # ------------------ Health ------------------
447
  @app.get("/")
448
  async def health_check():
449
  return {"status": "ok"}
450
 
451
+ # ------------------ Chat ------------------
452
  @app.post("/chat")
453
  async def chat_with_ai(input_data: ChatInput):
454
  try:
 
474
  }
475
 
476
  # Resolution ack (auto incident + mark Resolved)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
478
  if _has_negation_resolved(msg_norm):
479
  is_llm_resolved = False
480
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
481
  try:
 
 
 
 
 
 
 
 
 
 
 
482
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
483
  result = create_incident(short_desc, long_desc)
484
  if isinstance(result, dict) and not result.get("error"):
 
528
  "debug": {"intent": "resolved_ack", "exception": True},
529
  }
530
 
531
+ # Incident intent
 
 
 
 
 
 
 
 
532
  if _is_incident_intent(msg_norm):
533
  return {
534
  "bot_response": (
 
548
  }
549
 
550
  # Status intent
551
+ status_intent = _parse_ticket_status_intent(msg_norm)
 
 
 
 
552
  if status_intent:
553
  if status_intent.get("ask_number"):
554
  return {
 
601
  except Exception as e:
602
  raise HTTPException(status_code=500, detail=safe_str(e))
603
 
604
+ # Generic opener
605
+ if len(msg_norm.split()) <= 2 or any(p in msg_norm for p in ("issue", "problem", "help", "support")):
606
+ return {
607
+ "bot_response": _build_clarifying_message(),
608
+ "status": "NO_KB_MATCH",
609
+ "context_found": False,
610
+ "ask_resolved": False,
611
+ "suggest_incident": True,
612
+ "followup": "Share more details or say 'create ticket'.",
613
+ "options": [{"type":"yesno","title":"Share details or raise a ticket?"}],
614
+ "top_hits": [],
615
+ "sources": [],
616
+ "debug": {"intent": "generic_issue"},
617
+ }
618
+
619
  # Hybrid KB search
620
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
621
  documents = kb_results.get("documents", [])
622
  metadatas = kb_results.get("metadatas", [])
623
  distances = kb_results.get("distances", [])
624
  combined = kb_results.get("combined_scores", [])
625
+
626
  items: List[Dict[str, Any]] = []
627
  for i, doc in enumerate(documents):
628
  text = doc.strip() if isinstance(doc, str) else ""
 
635
  if score is not None: m["distance"] = score
636
  if comb is not None: m["combined"] = comb
637
  items.append({"text": text, "meta": m})
638
+
639
  selected = items[:max(1, 2)]
640
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
641
+ filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
642
+ context = filtered_text
643
+ context_found = bool(context.strip())
644
 
645
+ best_distance = min([d for d in distances if d is not None], default=None) if distances else None
646
+ best_combined = max([c for c in combined if c is not None], default=None) if combined else None
647
  detected_intent = kb_results.get("user_intent", "neutral")
648
  best_doc = kb_results.get("best_doc")
649
  top_meta = (metadatas or [{}])[0] if metadatas else {}
650
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  # Force errors intent for permissions
652
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
653
  if is_perm_query:
654
  detected_intent = "errors"
655
 
656
  # Prefer full SOP section when we have the best_doc
657
+ escalation_line = None # SOP escalation candidate (if found)
658
  if best_doc:
659
  if detected_intent == "steps":
660
+ full_steps = get_best_steps_section_text(best_doc)
 
661
  if not full_steps:
662
+ sec = (top_meta or {}).get("section")
663
+ if sec: full_steps = get_section_text(best_doc, sec)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  if full_steps:
665
  context = _ensure_numbering(full_steps)
 
666
  elif detected_intent == "errors":
667
  full_errors = get_best_errors_section_text(best_doc)
668
  if full_errors:
669
+ # Extract the error lines from the SOP
670
  ctx_err = _extract_errors_only(full_errors, max_lines=12)
671
+
672
+ # If the query mentions permissions, reduce to permission-only lines
673
  if is_perm_query:
674
  context = _filter_permission_lines(ctx_err, max_lines=6)
675
  else:
676
+ # Otherwise, keep only the lines relevant to the user's error words
677
  context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=6)
678
+
679
+ # Try to pick escalation path from the same SOP
680
  escalation_line = _extract_escalation_line(full_errors)
681
+
682
  lines = _normalize_lines(context)
683
  if len(lines) == 1:
684
  context = _friendly_permission_reply(lines[0])
685
 
686
+ # No context for errors → clarifying + ticket option
687
  if detected_intent == "errors" and not (context or "").strip():
688
  return {
689
  "bot_response": _build_clarifying_message(),
 
691
  "context_found": False,
692
  "ask_resolved": False,
693
  "suggest_incident": True,
694
+ "followup": "Share more details or say 'create ticket'.",
695
+ "options": [{"type":"yesno","title":"Share details or raise a ticket?"}],
696
  "top_hits": [],
697
  "sources": [],
698
  "debug": {"intent": "errors_no_context"},
699
  }
700
 
701
+ # Build LLM prompt
702
  language_hint = _detect_language_hint(input_data.user_message)
703
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
704
  use_gemini = (detected_intent == "errors")
 
714
  Return ONLY the rewritten guidance."""
715
  headers = {"Content-Type": "application/json"}
716
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
717
+
718
  bot_text = ""
719
  http_code = 0
720
  if use_gemini:
 
732
  except Exception:
733
  bot_text, http_code = "", 0
734
 
735
+ # Deterministic local formatting
736
  if detected_intent == "steps":
737
  bot_text = _ensure_numbering(context)
738
  elif detected_intent == "errors":
739
  if not bot_text.strip() or http_code == 429:
740
+ # Fallback to SOP-derived filtered context (no generic)
741
  bot_text = context.strip()
742
+
743
+ # Append SOP-specific escalation if available; otherwise NO escalation
744
  if escalation_line:
745
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
746
  else:
747
  bot_text = context
748
 
749
+ # Status compute
750
  short_query = len((input_data.user_message or "").split()) <= 4
751
  gate_combined_ok = 0.60 if short_query else 0.55
752
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
753
  lower = (bot_text or "").lower()
754
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
755
  status = "PARTIAL"
756
+
757
+ options = [{"type":"yesno","title":"Share details or raise a ticket?"}] if status == "PARTIAL" else []
758
  return {
759
  "bot_response": bot_text,
760
  "status": status,
 
766
  "top_hits": [],
767
  "sources": [],
768
  "debug": {
769
+ "used_chunks": len((context or "").split("\n\n---\n\n")) if context else 0,
770
  "best_distance": best_distance,
771
  "best_combined": best_combined,
772
  "http_status": http_code,
773
+ "filter_mode": filt_info.get("mode"),
774
+ "matched_count": filt_info.get("matched_count"),
775
  "user_intent": detected_intent,
776
  "best_doc": best_doc,
777
  },
778
  }
779
+
780
  except HTTPException:
781
  raise
782
  except Exception as e:
783
  raise HTTPException(status_code=500, detail=safe_str(e))
784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
  def _set_incident_resolved(sys_id: str) -> bool:
786
  try:
787
  token = get_valid_token()
 
795
  "Content-Type": "application/json",
796
  }
797
  url = f"{instance_url}/api/now/table/incident/{sys_id}"
798
+
799
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
800
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
801
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
802
  resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
803
  assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
804
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
805
+
806
  if require_progress:
807
  try:
808
  resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
809
  print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
810
  except Exception as e:
811
  print(f"[SN PATCH progress] exception={safe_str(e)}")
812
+
813
  def clean(d: dict) -> dict:
814
  return {k: v for k, v in d.items() if v is not None}
815
+
816
  payload_A = clean({
817
  "state": "6",
818
  "close_code": close_code_val,
 
827
  if respA.status_code in (200, 204):
828
  return True
829
  print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
830
+
831
  payload_B = clean({
832
  "state": "Resolved",
833
  "close_code": close_code_val,
 
842
  if respB.status_code in (200, 204):
843
  return True
844
  print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
845
+
846
  code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
847
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
848
  payload_C = clean({
 
863
  except Exception as e:
864
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
865
  return False
866
+
867
+ # ------------------ Incident ------------------
868
+ @app.post("/incident")
869
+ async def raise_incident(input_data: IncidentInput):
870
+ try:
871
+ result = create_incident(input_data.short_description, input_data.description)
872
+ if isinstance(result, dict) and not result.get("error"):
873
+ inc_number = result.get("number", "<unknown>")
874
+ sys_id = result.get("sys_id")
875
+ resolved_note = ""
876
+ if bool(input_data.mark_resolved) and sys_id not in ("<unknown>", None):
877
+ ok = _set_incident_resolved(sys_id)
878
+ resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
879
+ ticket_text = f"Incident created: {inc_number}{resolved_note}" if inc_number else "Incident created."
880
+ return {
881
+ "bot_response": f"✅ {ticket_text}",
882
+ "debug": "Incident created via ServiceNow",
883
+ "persist": True,
884
+ "show_assist_card": True,
885
+ "followup": "Is there anything else I can assist you with?",
886
+ }
887
+ else:
888
+ raise HTTPException(status_code=500, detail=(result or {}).get("error", "Unknown error"))
889
+ except Exception as e:
890
+ raise HTTPException(status_code=500, detail=safe_str(e))
891
+
892
+ # ------------------ Ticket description generation ------------------
893
+ @app.post("/generate_ticket_desc")
894
+ async def generate_ticket_desc_ep(input_data: TicketDescInput):
895
+ try:
896
+ prompt = (
897
+ f"You are helping generate ServiceNow ticket descriptions based on the issue: {input_data.issue}.\n"
898
+ "Please return the output strictly in JSON format with the following keys:\n"
899
+ "{\n"
900
+ ' "ShortDescription": "A concise summary of the issue (max 100 characters)",\n'
901
+ ' "DetailedDescription": "A detailed explanation of the issue"\n'
902
+ "}\n"
903
+ "Do not include any extra text, comments, or explanations outside the JSON."
904
+ )
905
+ headers = {"Content-Type": "application/json"}
906
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
907
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
908
+ try:
909
+ data = resp.json()
910
+ except Exception:
911
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
912
+ try:
913
+ text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
914
+ except Exception:
915
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
916
+ if text.startswith("```"):
917
+ lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
918
+ text = "\n".join(lines).strip()
919
+ try:
920
+ ticket_json = json.loads(text)
921
+ return {
922
+ "ShortDescription": ticket_json.get("ShortDescription", "").strip(),
923
+ "DetailedDescription": ticket_json.get("DetailedDescription", "").strip(),
924
+ }
925
+ except Exception:
926
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
927
+ except Exception as e:
928
+ raise HTTPException(status_code=500, detail=safe_str(e))
929
+
930
+ # ------------------ Incident status ------------------
931
+ @app.post("/incident_status")
932
+ async def incident_status(input_data: TicketStatusInput):
933
+ try:
934
+ token = get_valid_token()
935
+ instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
936
+ if not instance_url:
937
+ raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
938
+ headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
939
+
940
+ if input_data.sys_id:
941
+ url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
942
+ response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
943
+ data = response.json()
944
+ result = data.get("result", {}) if response.status_code == 200 else {}
945
+ elif input_data.number:
946
+ url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
947
+ response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
948
+ data = response.json()
949
+ lst = data.get("result", [])
950
+ result = (lst or [{}])[0] if response.status_code == 200 else {}
951
+ else:
952
+ raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
953
+
954
+ state_code = builtins.str(result.get("state", "unknown"))
955
+ state_label = STATE_MAP.get(state_code, state_code)
956
+ short = result.get("short_description", "")
957
+ number = result.get("number", input_data.number or "unknown")
958
+
959
+ return {
960
+ "bot_response": (
961
+ f"**Ticket:** {number} \n"
962
+ f"**Status:** {state_label} \n"
963
+ f"**Issue description:** {short}"
964
+ ).replace("\n", " \n"),
965
+ "followup": "Is there anything else I can assist you with?",
966
+ "show_assist_card": True,
967
+ "persist": True,
968
+ "debug": "Incident status fetched",
969
+ }
970
+ except Exception as e:
971
+ raise HTTPException(status_code=500, detail=safe_str(e))