srilakshu012456 commited on
Commit
4787b4e
·
verified ·
1 Parent(s): dc3399d

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +167 -277
main.py CHANGED
@@ -19,7 +19,7 @@ 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, # NEW
23
  )
24
  from services.login import router as login_router
25
  from services.generate_ticket import get_valid_token, create_incident
@@ -94,7 +94,7 @@ GEMINI_URL = (
94
  )
95
 
96
  # -------------------- Helpers --------------------
97
- NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
98
 
99
  def _normalize_lines(text: str) -> List[str]:
100
  raw = (text or "")
@@ -106,47 +106,40 @@ def _normalize_lines(text: str) -> List[str]:
106
  def _ensure_numbering(text: str) -> str:
107
  """
108
  Robust step numbering:
109
- - Split by natural delimiters (newline, end-of-sentence, semicolons, arrows, 'then', simple bullets).
110
- - Strip any existing list markers (numbers, 'Step N:', bullets).
111
- - Return ①..⑳ then 'N)' beyond 20.
112
  """
113
  t = (text or "").replace("\u2060", "").replace("\u200B", "")
114
-
115
- # 1) Split to segments by common step delimiters
116
  parts = re.split(
117
  r"(?:\r?\n|\r|\u2028|\u2029)" # newlines
118
- r"|(?<=[.!?])\s+" # sentence end + space
119
  r"|;\s+" # semicolons
120
  r"|(?:\s*→\s*|\s*->\s*)" # arrows
121
  r"|(?:\s*\bthen\b\s*)" # 'then'
122
- r"|(?:^\s*[-*•]\s*)", # simple bullets
123
  t,
124
- flags=re.IGNORECASE
125
  )
126
-
127
- # 2) Clean segments & drop empties
128
- cleaned = []
129
  for seg in parts:
130
  s = (seg or "").strip()
131
  if not s:
132
  continue
133
- # Remove ANY leading list marker (1) / 2. / Step 3: / -, *, • / circled numerals
134
  s = re.sub(
135
  r"^\s*(?:"
136
- r"(?:\d+\s*[.)])|" # 1) or 2.
137
- r"(?i:step\s*\d+:?)|" # Step 3:
138
- r"[-*•]|" # bullets
139
- r"[\u2460-\u2473]" # circled numerals
140
  r")\s*",
141
  "",
142
- s
143
  )
144
  if s:
145
  cleaned.append(s)
146
-
147
  if not cleaned:
148
  return (text or "").strip()
149
-
150
  circled = {
151
  1:"\u2460",2:"\u2461",3:"\u2462",4:"\u2463",5:"\u2464",
152
  6:"\u2465",7:"\u2466",8:"\u2467",9:"\u2468",10:"\u2469",
@@ -159,115 +152,6 @@ def _ensure_numbering(text: str) -> str:
159
  out.append(f"{marker} {s}")
160
  return "\n".join(out)
161
 
162
- def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 4) -> str:
163
- q = _normalize_for_match(query) # lower + punctuation stripped
164
- q_terms = [t for t in q.split() if len(t) > 2]
165
- if not q_terms:
166
- return text or ""
167
- kept: List[str] = []
168
- for ln in _normalize_lines(text):
169
- ln_norm = _normalize_for_match(ln)
170
- if any(t in ln_norm for t in q_terms):
171
- kept.append(ln)
172
- if len(kept) >= max_lines:
173
- break
174
- return "\n".join(kept).strip() if kept else (text or "").strip()
175
-
176
- def _friendly_permission_reply(raw: str) -> str:
177
- line = (raw or "").strip()
178
- line = re.sub(r"^\s*[\-\*\u2022]\s*", "", line)
179
- if not line:
180
- return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
181
- if "verify role access" in line.lower():
182
- return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
183
- if ("permission" in line.lower()) or ("access" in line.lower()) or ("authorization" in line.lower()):
184
- return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
185
- return line
186
-
187
- def _detect_language_hint(msg: str) -> Optional[str]:
188
- if re.search(r"[\u0B80-\u0BFF]", msg or ""):
189
- return "Tamil"
190
- if re.search(r"[\u0900-\u097F]", msg or ""):
191
- return "Hindi"
192
- return None
193
-
194
- def _build_clarifying_message() -> str:
195
- # PATCH: more professional message
196
- return (
197
- "To assist you better, please share a few more details (module/screen and the exact error), "
198
- "or I can create a ServiceNow ticket for you."
199
- )
200
-
201
- def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
202
- issue = (issue_text or "").strip()
203
- resolved = (resolved_text or "").strip()
204
- short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
205
- long_desc = (
206
- f"User reported: \"{issue}\". "
207
- f"User confirmation: \"{resolved}\". "
208
- f"Tracking record created automatically by NOVA."
209
- ).strip()
210
- return short_desc, long_desc
211
-
212
- def _is_incident_intent(msg_norm: str) -> bool:
213
- intent_phrases = [
214
- "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
215
- "create incident", "create an incident", "raise incident", "raise an incident", "open incident", "open an incident",
216
- "log ticket", "log an incident", "generate ticket", "create snow ticket", "raise snow ticket",
217
- "raise service now ticket", "create service now ticket", "raise sr", "open sr",
218
- ]
219
- return any(p in msg_norm for p in intent_phrases)
220
-
221
- def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
222
- status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
223
- if not any(k in msg_norm for k in status_keywords):
224
- return {}
225
- patterns = [r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)", r"(inc\d+)"]
226
- for pat in patterns:
227
- m = re.search(pat, msg_norm, flags=re.IGNORECASE)
228
- if m:
229
- val = m.group(1).strip()
230
- if val:
231
- return {"number": val.upper() if val.lower().startswith("inc") else val}
232
- return {"number": None, "ask_number": True}
233
-
234
- def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
235
- phrases = [
236
- "it is resolved", "resolved", "issue resolved", "problem resolved",
237
- "it's working", "working now", "works now", "fixed", "sorted",
238
- "ok now", "fine now", "all good", "all set", "thanks works", "thank you it works", "back to normal",
239
- ]
240
- return any(p in msg_norm for p in phrases)
241
-
242
- def _has_negation_resolved(msg_norm: str) -> bool:
243
- neg_phrases = [
244
- "not resolved", "issue not resolved", "still not working", "not working",
245
- "didn't work", "doesn't work", "no change", "not fixed", "still failing", "failed again", "broken", "fail",
246
- ]
247
- return any(p in msg_norm for p in neg_phrases)
248
-
249
- def _classify_resolution_llm(user_message: str) -> bool:
250
- if not GEMINI_API_KEY:
251
- return False
252
- prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
253
- Return only 'true' or 'false'.
254
- Message: {user_message}"""
255
- headers = {"Content-Type": "application/json"}
256
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
257
- try:
258
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
259
- data = resp.json()
260
- text = (
261
- data.get("candidates", [{}])[0]
262
- .get("content", {})
263
- .get("parts", [{}])[0]
264
- .get("text", "")
265
- )
266
- return "true" in (text or "").strip().lower()
267
- except Exception:
268
- return False
269
-
270
- # -------------------- Filters --------------------
271
  STRICT_OVERLAP = 3
272
  MAX_SENTENCES_STRICT = 4
273
  MAX_SENTENCES_CONCISE = 3
@@ -287,7 +171,6 @@ def _normalize_for_match(text: str) -> str:
287
  return t
288
 
289
  def _split_sentences(ctx: str) -> List[str]:
290
- # Split on end-of-sentence, newlines, or simple bullets
291
  raw_sents = re.split(r"(?<=[.!?])\s+|\n+|\-\s+|\*\s+", ctx or "")
292
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
293
 
@@ -396,6 +279,14 @@ def _extract_escalation_line(text: str) -> Optional[str]:
396
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
397
  return f"If you want to escalate the issue, follow: {path}"
398
 
 
 
 
 
 
 
 
 
399
  # -------------------- Health --------------------
400
  @app.get("/")
401
  async def health_check():
@@ -427,11 +318,58 @@ async def chat_with_ai(input_data: ChatInput):
427
  }
428
 
429
  # Resolution ack (auto incident + mark Resolved)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
431
  if _has_negation_resolved(msg_norm):
432
  is_llm_resolved = False
433
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
434
  try:
 
 
 
 
 
 
 
 
 
 
 
435
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
436
  result = create_incident(short_desc, long_desc)
437
  if isinstance(result, dict) and not result.get("error"):
@@ -481,7 +419,15 @@ async def chat_with_ai(input_data: ChatInput):
481
  "debug": {"intent": "resolved_ack", "exception": True},
482
  }
483
 
484
- # Incident intent
 
 
 
 
 
 
 
 
485
  if _is_incident_intent(msg_norm):
486
  return {
487
  "bot_response": (
@@ -501,7 +447,11 @@ async def chat_with_ai(input_data: ChatInput):
501
  }
502
 
503
  # Status intent
504
- status_intent = _parse_ticket_status_intent(msg_norm)
 
 
 
 
505
  if status_intent:
506
  if status_intent.get("ask_number"):
507
  return {
@@ -554,21 +504,6 @@ async def chat_with_ai(input_data: ChatInput):
554
  except Exception as e:
555
  raise HTTPException(status_code=500, detail=safe_str(e))
556
 
557
- # Generic opener
558
- if len(msg_norm.split()) <= 2 or any(p in msg_norm for p in ("issue", "problem", "help", "support")):
559
- return {
560
- "bot_response": _build_clarifying_message(),
561
- "status": "NO_KB_MATCH",
562
- "context_found": False,
563
- "ask_resolved": False,
564
- "suggest_incident": True,
565
- "followup": "You can provide more context, or say 'create ticket' if you'd like me to open one.",
566
- "options": [{"type":"yesno","title":"Provide details or create a ticket?"}],
567
- "top_hits": [],
568
- "sources": [],
569
- "debug": {"intent": "generic_issue"},
570
- }
571
-
572
  # Hybrid KB search
573
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
574
  documents = kb_results.get("documents", [])
@@ -589,33 +524,37 @@ async def chat_with_ai(input_data: ChatInput):
589
  items.append({"text": text, "meta": m})
590
  selected = items[:max(1, 2)]
591
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
592
- filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
593
- context = filtered_text
594
- context_found = bool(context.strip())
595
- best_distance = min([d for d in distances if d is not None], default=None) if distances else None
596
- best_combined = max([c for c in combined if c is not None], default=None) if combined else None
597
  detected_intent = kb_results.get("user_intent", "neutral")
598
  best_doc = kb_results.get("best_doc")
599
  top_meta = (metadatas or [{}])[0] if metadatas else {}
600
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  # Force errors intent for permissions
602
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
603
  if is_perm_query:
604
  detected_intent = "errors"
605
 
606
  # Prefer full SOP section when we have the best_doc
607
- escalation_line = None # SOP escalation candidate (if found)
608
  if best_doc:
609
  if detected_intent == "steps":
610
- # 1) Prefer action-matched steps first (update/reschedule/edit vs create)
611
  preferred_actions = kb_results.get("actions", []) or []
612
  full_steps = get_steps_text_by_action(best_doc, preferred_actions)
613
-
614
- # 2) If nothing found, fallback BUT FILTER creation-only when user asked update
615
  if not full_steps:
616
  fallback = get_best_steps_section_text(best_doc)
617
  if preferred_actions and ("update" in [a.lower() for a in preferred_actions]):
618
- # Drop lines that look like pure creation verbs when user asked update
619
  fallback_lines = [ln.strip() for ln in (fallback or "").splitlines() if ln.strip()]
620
  keep = []
621
  for ln in fallback_lines:
@@ -625,37 +564,30 @@ async def chat_with_ai(input_data: ChatInput):
625
  and not any(x in low for x in ("update", "modify", "change", "edit", "amend", "reschedule", "re-schedule"))
626
  )
627
  if is_creation_only:
628
- continue # skip creation-only line
629
  keep.append(ln)
630
  fallback = "\n".join(keep).strip()
631
  full_steps = fallback if (fallback and fallback.strip()) else full_steps
632
-
633
  if not full_steps:
634
  sec = (top_meta or {}).get("section")
635
  if sec:
636
  full_steps = get_section_text(best_doc, sec)
637
-
638
  if full_steps:
639
  context = _ensure_numbering(full_steps)
640
 
641
  elif detected_intent == "errors":
642
  full_errors = get_best_errors_section_text(best_doc)
643
  if full_errors:
644
- # Extract the error lines from the SOP
645
  ctx_err = _extract_errors_only(full_errors, max_lines=12)
646
- # If the query mentions permissions, reduce to permission-only lines
647
  if is_perm_query:
648
  context = _filter_permission_lines(ctx_err, max_lines=6)
649
  else:
650
- # Otherwise, keep only the lines relevant to the user's error words
651
  context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=6)
652
- # Try to pick escalation path from the same SOP
653
  escalation_line = _extract_escalation_line(full_errors)
654
  lines = _normalize_lines(context)
655
  if len(lines) == 1:
656
  context = _friendly_permission_reply(lines[0])
657
 
658
- # No context for errors → clarifying + ticket option
659
  if detected_intent == "errors" and not (context or "").strip():
660
  return {
661
  "bot_response": _build_clarifying_message(),
@@ -670,7 +602,6 @@ async def chat_with_ai(input_data: ChatInput):
670
  "debug": {"intent": "errors_no_context"},
671
  }
672
 
673
- # Build LLM prompt (unchanged flow)
674
  language_hint = _detect_language_hint(input_data.user_message)
675
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
676
  use_gemini = (detected_intent == "errors")
@@ -685,7 +616,7 @@ async def chat_with_ai(input_data: ChatInput):
685
  ### Output
686
  Return ONLY the rewritten guidance."""
687
  headers = {"Content-Type": "application/json"}
688
- payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
689
  bot_text = ""
690
  http_code = 0
691
  if use_gemini:
@@ -703,19 +634,16 @@ Return ONLY the rewritten guidance."""
703
  except Exception:
704
  bot_text, http_code = "", 0
705
 
706
- # Deterministic local formatting
707
  if detected_intent == "steps":
708
  bot_text = _ensure_numbering(context)
709
  elif detected_intent == "errors":
710
  if not bot_text.strip() or http_code == 429:
711
  bot_text = context.strip()
712
- # Append SOP-specific escalation if available
713
  if escalation_line:
714
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
715
  else:
716
  bot_text = context
717
 
718
- # Status compute
719
  short_query = len((input_data.user_message or "").split()) <= 4
720
  gate_combined_ok = 0.60 if short_query else 0.55
721
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
@@ -734,12 +662,12 @@ Return ONLY the rewritten guidance."""
734
  "top_hits": [],
735
  "sources": [],
736
  "debug": {
737
- "used_chunks": len((context or "").split("\n\n---\n\n")) if context else 0,
738
  "best_distance": best_distance,
739
  "best_combined": best_combined,
740
  "http_status": http_code,
741
- "filter_mode": filt_info.get("mode"),
742
- "matched_count": filt_info.get("matched_count"),
743
  "user_intent": detected_intent,
744
  "best_doc": best_doc,
745
  },
@@ -749,6 +677,71 @@ Return ONLY the rewritten guidance."""
749
  except Exception as e:
750
  raise HTTPException(status_code=500, detail=safe_str(e))
751
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
  def _set_incident_resolved(sys_id: str) -> bool:
753
  try:
754
  token = get_valid_token()
@@ -824,106 +817,3 @@ def _set_incident_resolved(sys_id: str) -> bool:
824
  except Exception as e:
825
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
826
  return False
827
-
828
- # -------------------- Incident --------------------
829
- @app.post("/incident")
830
- async def raise_incident(input_data: IncidentInput):
831
- try:
832
- result = create_incident(input_data.short_description, input_data.description)
833
- if isinstance(result, dict) and not result.get("error"):
834
- inc_number = result.get("number", "<unknown>")
835
- sys_id = result.get("sys_id")
836
- resolved_note = ""
837
- if bool(input_data.mark_resolved) and sys_id not in ("<unknown>", None):
838
- ok = _set_incident_resolved(sys_id)
839
- resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
840
- ticket_text = f"Incident created: {inc_number}{resolved_note}" if inc_number else "Incident created."
841
- return {
842
- "bot_response": f"✅ {ticket_text}",
843
- "debug": "Incident created via ServiceNow",
844
- "persist": True,
845
- "show_assist_card": True,
846
- "followup": "Is there anything else I can assist you with?",
847
- }
848
- else:
849
- raise HTTPException(status_code=500, detail=(result or {}).get("error", "Unknown error"))
850
- except Exception as e:
851
- raise HTTPException(status_code=500, detail=safe_str(e))
852
-
853
- # -------------------- Ticket description generation --------------------
854
- @app.post("/generate_ticket_desc")
855
- async def generate_ticket_desc_ep(input_data: TicketDescInput):
856
- try:
857
- prompt = (
858
- f"You are helping generate ServiceNow ticket descriptions based on the issue: {input_data.issue}.\n"
859
- "Please return the output strictly in JSON format with the following keys:\n"
860
- "{\n"
861
- ' "ShortDescription": "A concise summary of the issue (max 100 characters)",\n'
862
- ' "DetailedDescription": "A detailed explanation of the issue"\n'
863
- "}\n"
864
- "Do not include any extra text, comments, or explanations outside the JSON."
865
- )
866
- headers = {"Content-Type": "application/json"}
867
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
868
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
869
- try:
870
- data = resp.json()
871
- except Exception:
872
- return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
873
- try:
874
- text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
875
- except Exception:
876
- return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
877
- if text.startswith("```"):
878
- lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
879
- text = "\n".join(lines).strip()
880
- try:
881
- ticket_json = json.loads(text)
882
- return {
883
- "ShortDescription": ticket_json.get("ShortDescription", "").strip(),
884
- "DetailedDescription": ticket_json.get("DetailedDescription", "").strip(),
885
- }
886
- except Exception:
887
- return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
888
- except Exception as e:
889
- raise HTTPException(status_code=500, detail=safe_str(e))
890
-
891
- # -------------------- Incident status --------------------
892
- @app.post("/incident_status")
893
- async def incident_status(input_data: TicketStatusInput):
894
- try:
895
- token = get_valid_token()
896
- instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
897
- if not instance_url:
898
- raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
899
- headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
900
- if input_data.sys_id:
901
- url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
902
- response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
903
- data = response.json()
904
- result = data.get("result", {}) if response.status_code == 200 else {}
905
- elif input_data.number:
906
- url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
907
- response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
908
- data = response.json()
909
- lst = data.get("result", [])
910
- result = (lst or [{}])[0] if response.status_code == 200 else {}
911
- else:
912
- raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
913
- state_code = builtins.str(result.get("state", "unknown"))
914
- state_label = STATE_MAP.get(state_code, state_code)
915
- short = result.get("short_description", "")
916
- number = result.get("number", input_data.number or "unknown")
917
- return {
918
- "bot_response": (
919
- f"**Ticket:** {number} \n"
920
- f"**Status:** {state_label} \n"
921
- f"**Issue description:** {short}"
922
- ).replace("\n", " \n"),
923
- "followup": "Is there anything else I can assist you with?",
924
- "show_assist_card": True,
925
- "persist": True,
926
- "debug": "Incident status fetched",
927
- }
928
- except Exception as e:
929
- raise HTTPException(status_code=500, detail=safe_str(e))
 
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
 
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 "")
 
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",
 
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
  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
 
 
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():
 
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
  "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
  }
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
  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", [])
 
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:
 
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(),
 
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")
 
616
  ### Output
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
  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"
 
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
  },
 
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()
 
817
  except Exception as e:
818
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
819
  return False