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

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +312 -299
main.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import os
2
  import json
3
  import re
@@ -19,21 +20,25 @@ from services.kb_creation import (
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
24
 
25
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
26
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
27
 
 
28
  def safe_str(e: Any) -> str:
29
  try:
30
  return builtins.str(e)
31
  except Exception:
32
  return "<error stringify failed>"
33
 
 
34
  load_dotenv()
35
  os.environ["POSTHOG_DISABLED"] = "true"
36
 
 
37
  @asynccontextmanager
38
  async def lifespan(app: FastAPI):
39
  try:
@@ -47,6 +52,7 @@ async def lifespan(app: FastAPI):
47
  print(f"[KB] ingestion failed: {safe_str(e)}")
48
  yield
49
 
 
50
  app = FastAPI(lifespan=lifespan)
51
  app.include_router(login_router)
52
 
@@ -59,24 +65,28 @@ app.add_middleware(
59
  allow_headers=["*"],
60
  )
61
 
62
- # ------------------ Models ------------------
63
  class ChatInput(BaseModel):
64
  user_message: str
65
  prev_status: Optional[str] = None
66
  last_issue: Optional[str] = None
67
 
 
68
  class IncidentInput(BaseModel):
69
  short_description: str
70
  description: str
71
  mark_resolved: Optional[bool] = False
72
 
 
73
  class TicketDescInput(BaseModel):
74
  issue: str
75
 
 
76
  class TicketStatusInput(BaseModel):
77
  sys_id: Optional[str] = None
78
  number: Optional[str] = None
79
 
 
80
  STATE_MAP = {
81
  "1": "New",
82
  "2": "In Progress",
@@ -92,8 +102,10 @@ GEMINI_URL = (
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:
@@ -102,44 +114,34 @@ def _normalize_lines(text: str) -> List[str]:
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):
@@ -155,55 +157,149 @@ def _ensure_numbering(text: str) -> str:
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()
@@ -216,6 +312,7 @@ def _friendly_permission_reply(raw: str) -> str:
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"
@@ -223,12 +320,14 @@ def _detect_language_hint(msg: str) -> Optional[str]:
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()
@@ -240,6 +339,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
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",
@@ -249,6 +349,7 @@ def _is_incident_intent(msg_norm: str) -> bool:
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):
@@ -262,6 +363,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
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",
@@ -270,6 +372,7 @@ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
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",
@@ -277,6 +380,7 @@ def _has_negation_resolved(msg_norm: str) -> bool:
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
@@ -298,157 +402,14 @@ Message: {user_message}"""
298
  except Exception:
299
  return False
300
 
301
- # ------------------ Filters ------------------
302
- STRICT_OVERLAP = 3
303
- MAX_SENTENCES_STRICT = 4
304
- MAX_SENTENCES_CONCISE = 3
305
- PERM_QUERY_TERMS = [
306
- "permission", "permissions", "access", "access right", "authorization", "authorisation",
307
- "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied",
308
- ]
309
- PERM_SYNONYMS = (
310
- "permission", "permissions", "access", "authorization", "authorisation",
311
- "role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
312
- )
313
-
314
- def _normalize_for_match(text: str) -> str:
315
- t = (text or "").lower()
316
- t = re.sub(r"[^\w\s]", " ", t)
317
- t = re.sub(r"\s+", " ", t).strip()
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]]:
325
- ctx = (context or "").strip()
326
- if not ctx or not query:
327
- return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
328
- q_norm = _normalize_for_match(query)
329
- q_terms = [t for t in q_norm.split() if len(t) > 2]
330
- if not q_terms:
331
- return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
332
- sentences = _split_sentences(ctx)
333
- matched_exact, matched_any = [], []
334
- for s in sentences:
335
- s_norm = _normalize_for_match(s)
336
- is_bullet = bool(re.match(r"^[\-\*]\s*", s))
337
- overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
338
- if overlap >= STRICT_OVERLAP:
339
- matched_exact.append(s)
340
- elif overlap > 0:
341
- matched_any.append(s)
342
- if matched_exact:
343
- kept = matched_exact[:MAX_SENTENCES_STRICT]
344
- return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
345
- if matched_any:
346
- kept = matched_any[:MAX_SENTENCES_CONCISE]
347
- return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)}
348
- kept = sentences[:MAX_SENTENCES_CONCISE]
349
- return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
350
-
351
- NAV_LINE_REGEX = re.compile(r"(navigate\s+to|login|log in|menu|screen)", re.IGNORECASE)
352
-
353
- def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
354
- lines = _normalize_lines(text)
355
- kept: List[str] = []
356
- for ln in lines:
357
- if NAV_LINE_REGEX.search(ln) or ln.lower().startswith("log in") or ln.lower().startswith("login"):
358
- kept.append(ln)
359
- if len(kept) >= max_lines:
360
- break
361
- return "\n".join(kept).strip() if kept else (text or "").strip()
362
-
363
- ERROR_STARTS = (
364
- "error", "resolution", "fix", "verify", "check",
365
- "permission", "access", "authorization", "authorisation",
366
- "role", "role mapping", "security profile", "escalation", "not allowed", "not authorized", "denied"
367
- )
368
-
369
- def _extract_errors_only(text: str, max_lines: int = 12) -> str:
370
- lines = _normalize_lines(text)
371
- kept: List[str] = []
372
- for ln in lines:
373
- low = ln.lower()
374
- if low.startswith(ERROR_STARTS) or any(key in low for key in ERROR_STARTS):
375
- kept.append(ln)
376
- if len(kept) >= max_lines:
377
- break
378
- return "\n".join(kept).strip() if kept else (text or "").strip()
379
-
380
- def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
381
- lines = _normalize_lines(text)
382
- kept: List[str] = []
383
- for ln in lines:
384
- low = ln.lower()
385
- if any(k in low for k in PERM_SYNONYMS):
386
- kept.append(ln)
387
- if len(kept) >= max_lines:
388
- break
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)):
414
- if not lines[j].strip():
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:
@@ -463,6 +424,7 @@ async def chat_with_ai(input_data: ChatInput):
463
  "options": [],
464
  "debug": {"intent": "continue_conversation"},
465
  }
 
466
  if msg_norm in ("no", "no thanks", "nope"):
467
  return {
468
  "bot_response": "No problem. Do you need assistance with any other issue?",
@@ -477,6 +439,7 @@ async def chat_with_ai(input_data: ChatInput):
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)
@@ -636,12 +599,21 @@ async def chat_with_ai(input_data: ChatInput):
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")
@@ -662,28 +634,26 @@ async def chat_with_ai(input_data: ChatInput):
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(),
@@ -698,7 +668,7 @@ async def chat_with_ai(input_data: ChatInput):
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,9 +684,9 @@ async def chat_with_ai(input_data: ChatInput):
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:
721
  try:
722
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
@@ -735,26 +705,22 @@ Return ONLY the rewritten guidance."""
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,
@@ -776,95 +742,57 @@ Return ONLY the rewritten guidance."""
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()
788
- instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
789
- if not instance_url:
790
- print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
791
- return False
792
- headers = {
793
- "Authorization": f"Bearer {token}",
794
- "Accept": "application/json",
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,
819
- "close_notes": close_notes_val,
820
- "caller_id": caller_sysid,
821
- "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
822
- "work_notes": "Auto-resolve set by NOVA.",
823
- "resolved_by": resolved_by_sysid,
824
- "assignment_group": assign_group,
825
- })
826
- respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
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,
834
- "close_notes": close_notes_val,
835
- "caller_id": caller_sysid,
836
- "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
837
- "work_notes": "Auto-resolve set by NOVA.",
838
- "resolved_by": resolved_by_sysid,
839
- "assignment_group": assign_group,
840
- })
841
- respB = requests.patch(url, headers=headers, json=payload_B, verify=VERIFY_SSL, timeout=25)
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({
849
- "state": "6",
850
- code_field: close_code_val,
851
- notes_field: close_notes_val,
852
- "caller_id": caller_sysid,
853
- "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
854
- "work_notes": "Auto-resolve set by NOVA.",
855
- "resolved_by": resolved_by_sysid,
856
- "assignment_group": assign_group,
857
- })
858
- respC = requests.patch(url, headers=headers, json=payload_C, verify=VERIFY_SSL, timeout=25)
859
- if respC.status_code in (200, 204):
860
- return True
861
- print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
862
- return False
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:
@@ -889,7 +817,8 @@ async def raise_incident(input_data: IncidentInput):
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:
@@ -913,6 +842,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
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()
@@ -927,7 +857,8 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
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:
@@ -955,7 +886,6 @@ async def incident_status(input_data: TicketStatusInput):
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"
@@ -969,3 +899,86 @@ async def incident_status(input_data: TicketStatusInput):
969
  }
970
  except Exception as e:
971
  raise HTTPException(status_code=500, detail=safe_str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
  import os
3
  import json
4
  import re
 
20
  get_best_steps_section_text,
21
  get_best_errors_section_text,
22
  )
23
+
24
  from services.login import router as login_router
25
  from services.generate_ticket import get_valid_token, create_incident
26
 
27
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
28
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
29
 
30
+
31
  def safe_str(e: Any) -> str:
32
  try:
33
  return builtins.str(e)
34
  except Exception:
35
  return "<error stringify failed>"
36
 
37
+
38
  load_dotenv()
39
  os.environ["POSTHOG_DISABLED"] = "true"
40
 
41
+
42
  @asynccontextmanager
43
  async def lifespan(app: FastAPI):
44
  try:
 
52
  print(f"[KB] ingestion failed: {safe_str(e)}")
53
  yield
54
 
55
+
56
  app = FastAPI(lifespan=lifespan)
57
  app.include_router(login_router)
58
 
 
65
  allow_headers=["*"],
66
  )
67
 
68
+ # ---------------------- Models ----------------------
69
  class ChatInput(BaseModel):
70
  user_message: str
71
  prev_status: Optional[str] = None
72
  last_issue: Optional[str] = None
73
 
74
+
75
  class IncidentInput(BaseModel):
76
  short_description: str
77
  description: str
78
  mark_resolved: Optional[bool] = False
79
 
80
+
81
  class TicketDescInput(BaseModel):
82
  issue: str
83
 
84
+
85
  class TicketStatusInput(BaseModel):
86
  sys_id: Optional[str] = None
87
  number: Optional[str] = None
88
 
89
+
90
  STATE_MAP = {
91
  "1": "New",
92
  "2": "In Progress",
 
102
  f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
103
  )
104
 
105
+ # ---------------------- Helpers ----------------------
106
  NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
107
+
108
+
109
  def _normalize_lines(text: str) -> List[str]:
110
  raw = (text or "")
111
  try:
 
114
  return [raw.strip()] if raw.strip() else []
115
 
116
 
117
+ def _normalize_for_match(text: str) -> str:
118
+ t = (text or "").lower()
119
+ t = re.sub(r"[^\w\s]", " ", t)
120
+ t = re.sub(r"\s+", " ", t).strip()
121
+ return t
122
+
 
 
123
 
124
+ def _ensure_numbering(text: str) -> str:
125
  # 0) Strip hidden chars that can break regexes
126
  text = re.sub(r"[\u2060\u200B]", "", text or "")
127
 
 
128
  def strip_prefix_any(s: str) -> str:
 
 
 
 
 
 
129
  return re.sub(
130
+ r"^\s*(?:[\u2060\u200B]*"
131
+ r"(?:\d+\s*[.)])"
132
+ r"|(?:step\s*\d+:?)"
133
+ r"|(?:[\-\*\u2022])"
134
+ r"|(?:[\u2460-\u2473])"
135
+ r")\s*",
136
  "",
137
  (s or "").strip(),
138
+ flags=re.IGNORECASE,
139
  )
140
 
 
141
  lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
142
  if not lines:
143
  return text or ""
144
 
 
145
  merged: List[str] = []
146
  i = 0
147
  while i < len(lines):
 
157
  if not para:
158
  return ""
159
 
160
+ para_clean = re.sub(r"(?:^|\s)\d+[.)]\s+", "\n\n\n", para)
161
+ para_clean = re.sub(r"(?:^|\s)[\u2460-\u2473]\s+", "\n\n\n", para_clean)
162
+ para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean)
 
 
 
163
 
164
+ segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
 
165
  if len(segments) < 2:
166
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
167
+ segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|\s*;\s+", para) if seg.strip()]
168
 
 
169
  segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
170
 
 
171
  circled = {
172
+ 1:"\u2460",2:"\u2461",3:"\u2462",4:"\u2463",5:"\u2464",6:"\u2465",7:"\u2466",8:"\u2467",9:"\u2468",10:"\u2469",
173
+ 11:"\u246a",12:"\u246b",13:"\u246c",14:"\u246d",15:"\u246e",16:"\u246f",17:"\u2470",18:"\u2471",19:"\u2472",20:"\u2473",
174
  }
175
  out: List[str] = []
176
  for idx, seg in enumerate(segments, start=1):
177
  marker = circled.get(idx, f"{idx})")
178
  out.append(f"{marker} {seg}")
 
179
  return "\n".join(out)
180
 
181
 
182
+ STRICT_OVERLAP = 3
183
+ MAX_SENTENCES_STRICT = 4
184
+ MAX_SENTENCES_CONCISE = 3
185
+
186
+ PERM_QUERY_TERMS = [
187
+ "permission", "permissions", "access", "access right", "authorization", "authorisation",
188
+ "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied",
189
+ ]
190
+ PERM_SYNONYMS = (
191
+ "permission", "permissions", "access", "authorization", "authorisation",
192
+ "role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
193
+ )
194
+
195
+ NAV_LINE_REGEX = re.compile(r"(navigate\s+to|login|log in|menu|screen)", re.IGNORECASE)
196
+ ERROR_STARTS = (
197
+ "error", "resolution", "fix", "verify", "check",
198
+ "permission", "access", "authorization", "authorisation",
199
+ "role", "role mapping", "security profile", "escalation", "not allowed", "not authorized", "denied"
200
+ )
201
+
202
+ # ---------------------- NEW: Generic dedupe helpers ----------------------
203
+ def _canonical_line_key(s: str) -> str:
204
+ """Normalize a line into a key used for deduplication."""
205
+ t = _normalize_for_match(s) # lower + punctuation stripped
206
+ t = re.sub(r'^\s*[\-\*\u2022]\s*', '', t) # bullets
207
+ t = re.sub(r'^\s*\d+\s*[.)]\s*', '', t) # "1)" or "2."
208
+ t = re.sub(r'^\s*[\u2460-\u2473]\s*', '', t) # circled numerals
209
+ return re.sub(r'\s+', ' ', t).strip()
210
+
211
+
212
+ def _dedupe_preserve_order(lines: List[str]) -> List[str]:
213
+ """Stable de-duplication by normalized content; preserves first occurrence order."""
214
+ seen = set()
215
+ out = []
216
+ for ln in lines:
217
+ key = _canonical_line_key(ln or "")
218
+ if key in seen:
219
+ continue
220
+ seen.add(key)
221
+ out.append(ln)
222
+ return out
223
 
224
+
225
+ def _dedupe_text_block(text: str) -> str:
226
+ """Split -> dedupe -> rejoin without changing wording."""
227
+ lines = _normalize_lines(text)
228
+ uniq = _dedupe_preserve_order(lines)
229
+ return "\n".join(uniq).strip()
230
+
231
+
232
+ # ---------------------- Filters ----------------------
233
+ def _split_sentences(ctx: str) -> List[str]:
234
+ raw_sents = re.split(r"(?<=[.!?])\s+|\n+|\-\s*\n|\*\s*", ctx or "")
235
+ return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
236
+
237
+
238
+ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
239
+ ctx = (context or "").strip()
240
+ if not ctx or not query:
241
+ return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
242
+ q_norm = _normalize_for_match(query)
243
+ q_terms = [t for t in q_norm.split() if len(t) > 2]
244
  if not q_terms:
245
+ return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
246
 
247
+ sentences = _split_sentences(ctx)
248
+ matched_exact, matched_any = [], []
249
+ for s in sentences:
250
+ s_norm = _normalize_for_match(s)
251
+ is_bullet = bool(re.match(r"^[\-\*]\s*", s))
252
+ overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
253
+ if overlap >= STRICT_OVERLAP:
254
+ matched_exact.append(s)
255
+ elif overlap > 0:
256
+ matched_any.append(s)
257
+
258
+ if matched_exact:
259
+ kept = matched_exact[:MAX_SENTENCES_STRICT]
260
+ return "\n".join(_dedupe_preserve_order(kept)).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
261
+ if matched_any:
262
+ kept = matched_any[:MAX_SENTENCES_CONCISE]
263
+ return "\n".join(_dedupe_preserve_order(kept)).strip(), {'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)}
264
+
265
+ kept = sentences[:MAX_SENTENCES_CONCISE]
266
+ return "\n".join(_dedupe_preserve_order(kept)).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
267
+
268
+
269
+ def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
270
+ lines = _normalize_lines(text)
271
  kept: List[str] = []
272
+ for ln in lines:
273
+ if NAV_LINE_REGEX.search(ln) or ln.lower().startswith("log in") or ln.lower().startswith("login"):
 
274
  kept.append(ln)
275
  if len(kept) >= max_lines:
276
  break
277
+ return "\n".join(_dedupe_preserve_order(kept)).strip() if kept else _dedupe_text_block(text or "")
278
+
279
+
280
+ def _extract_errors_only(text: str, max_lines: int = 12) -> str:
281
+ lines = _normalize_lines(text)
282
+ kept: List[str] = []
283
+ for ln in lines:
284
+ low = ln.lower()
285
+ if low.startswith(ERROR_STARTS) or any(key in low for key in ERROR_STARTS):
286
+ kept.append(ln)
287
+ if len(kept) >= max_lines:
288
+ break
289
+ return "\n".join(_dedupe_preserve_order(kept)).strip() if kept else _dedupe_text_block(text or "")
290
+
291
+
292
+ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
293
+ lines = _normalize_lines(text)
294
+ kept: List[str] = []
295
+ for ln in lines:
296
+ low = ln.lower()
297
+ if any(k in low for k in PERM_SYNONYMS):
298
+ kept.append(ln)
299
+ if len(kept) >= max_lines:
300
+ break
301
+ return "\n".join(_dedupe_preserve_order(kept)).strip() if kept else _dedupe_text_block(text or "")
302
 
 
303
 
304
  def _friendly_permission_reply(raw: str) -> str:
305
  line = (raw or "").strip()
 
312
  return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
313
  return line
314
 
315
+
316
  def _detect_language_hint(msg: str) -> Optional[str]:
317
  if re.search(r"[\u0B80-\u0BFF]", msg or ""):
318
  return "Tamil"
 
320
  return "Hindi"
321
  return None
322
 
323
+
324
  def _build_clarifying_message() -> str:
325
  return (
326
  "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
327
  "or should I raise a ServiceNow ticket for you?"
328
  )
329
 
330
+
331
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
332
  issue = (issue_text or "").strip()
333
  resolved = (resolved_text or "").strip()
 
339
  ).strip()
340
  return short_desc, long_desc
341
 
342
+
343
  def _is_incident_intent(msg_norm: str) -> bool:
344
  intent_phrases = [
345
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
 
349
  ]
350
  return any(p in msg_norm for p in intent_phrases)
351
 
352
+
353
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
354
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
355
  if not any(k in msg_norm for k in status_keywords):
 
363
  return {"number": val.upper() if val.lower().startswith("inc") else val}
364
  return {"number": None, "ask_number": True}
365
 
366
+
367
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
368
  phrases = [
369
  "it is resolved", "resolved", "issue resolved", "problem resolved",
 
372
  ]
373
  return any(p in msg_norm for p in phrases)
374
 
375
+
376
  def _has_negation_resolved(msg_norm: str) -> bool:
377
  neg_phrases = [
378
  "not resolved", "issue not resolved", "still not working", "not working",
 
380
  ]
381
  return any(p in msg_norm for p in neg_phrases)
382
 
383
+
384
  def _classify_resolution_llm(user_message: str) -> bool:
385
  if not GEMINI_API_KEY:
386
  return False
 
402
  except Exception:
403
  return False
404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
 
406
+ # ---------------------- Health ----------------------
 
 
 
 
 
407
  @app.get("/")
408
  async def health_check():
409
  return {"status": "ok"}
410
 
411
+
412
+ # ---------------------- Chat ----------------------
413
  @app.post("/chat")
414
  async def chat_with_ai(input_data: ChatInput):
415
  try:
 
424
  "options": [],
425
  "debug": {"intent": "continue_conversation"},
426
  }
427
+
428
  if msg_norm in ("no", "no thanks", "nope"):
429
  return {
430
  "bot_response": "No problem. Do you need assistance with any other issue?",
 
439
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
440
  if _has_negation_resolved(msg_norm):
441
  is_llm_resolved = False
442
+
443
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
444
  try:
445
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
 
599
  if comb is not None: m["combined"] = comb
600
  items.append({"text": text, "meta": m})
601
 
602
+ # --- MERGE top chunks and DEDUPE before filtering ---
603
  selected = items[:max(1, 2)]
604
+ if selected:
605
+ merged_lines = []
606
+ for s in selected:
607
+ merged_lines.extend(_normalize_lines(s["text"]))
608
+ merged_lines = _dedupe_preserve_order(merged_lines)
609
+ context_raw = "\n".join(merged_lines).strip()
610
+ else:
611
+ context_raw = ""
612
+
613
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
614
+ context = _dedupe_text_block(filtered_text) # <-- NEW: dedupe after filter
 
615
 
616
+ context_found = bool(context.strip())
617
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
618
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
619
  detected_intent = kb_results.get("user_intent", "neutral")
 
634
  sec = (top_meta or {}).get("section")
635
  if sec: full_steps = get_section_text(best_doc, sec)
636
  if full_steps:
637
+ context = _ensure_numbering(full_steps) # deterministic steps
638
+ context = _dedupe_text_block(context) # in case section repeats bullets
639
+
640
  elif detected_intent == "errors":
641
  full_errors = get_best_errors_section_text(best_doc)
642
  if full_errors:
 
643
  ctx_err = _extract_errors_only(full_errors, max_lines=12)
 
 
644
  if is_perm_query:
645
  context = _filter_permission_lines(ctx_err, max_lines=6)
646
  else:
647
+ context = _filter_error_lines_by_query(ctx_err=input_data.user_message, text=ctx_err, max_lines=6) if False else _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=6) # keep signature
 
 
 
648
  escalation_line = _extract_escalation_line(full_errors)
649
+ context = _dedupe_text_block(context)
650
 
651
+ # If only one permission line remains, keep friendly line logic
652
  lines = _normalize_lines(context)
653
  if len(lines) == 1:
654
  context = _friendly_permission_reply(lines[0])
655
 
656
+ # No context for errors -> clarifying + ticket option
657
  if detected_intent == "errors" and not (context or "").strip():
658
  return {
659
  "bot_response": _build_clarifying_message(),
 
668
  "debug": {"intent": "errors_no_context"},
669
  }
670
 
671
+ # LLM paraphrase for errors only (unchanged behavior)
672
  language_hint = _detect_language_hint(input_data.user_message)
673
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
674
  use_gemini = (detected_intent == "errors")
 
684
  Return ONLY the rewritten guidance."""
685
  headers = {"Content-Type": "application/json"}
686
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
 
687
  bot_text = ""
688
  http_code = 0
689
+
690
  if use_gemini:
691
  try:
692
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
 
705
  # Deterministic local formatting
706
  if detected_intent == "steps":
707
  bot_text = _ensure_numbering(context)
708
+ bot_text = _dedupe_text_block(bot_text)
709
  elif detected_intent == "errors":
710
  if not bot_text.strip() or http_code == 429:
 
711
  bot_text = context.strip()
 
 
712
  if escalation_line:
713
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
714
+ bot_text = _dedupe_text_block(bot_text)
 
715
 
 
716
  short_query = len((input_data.user_message or "").split()) <= 4
717
  gate_combined_ok = 0.60 if short_query else 0.55
718
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
719
  lower = (bot_text or "").lower()
720
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
721
  status = "PARTIAL"
 
722
  options = [{"type":"yesno","title":"Share details or raise a ticket?"}] if status == "PARTIAL" else []
723
+
724
  return {
725
  "bot_response": bot_text,
726
  "status": status,
 
742
  "best_doc": best_doc,
743
  },
744
  }
 
745
  except HTTPException:
746
  raise
747
  except Exception as e:
748
  raise HTTPException(status_code=500, detail=safe_str(e))
749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
 
751
+ def _extract_escalation_line(text: str) -> Optional[str]:
752
+ if not text:
753
+ return None
754
+ lines = _normalize_lines(text)
755
+ if not lines:
756
+ return None
757
 
758
+ start_idx = None
759
+ for i, ln in enumerate(lines):
760
+ low = ln.lower()
761
+ if "escalation" in low or "escalation path" in low or "escalate" in low:
762
+ start_idx = i
763
+ break
764
 
765
+ block = []
766
+ if start_idx is not None:
767
+ for j in range(start_idx, min(len(lines), start_idx + 6)):
768
+ if not lines[j].strip():
769
+ break
770
+ block.append(lines[j].strip())
771
+ else:
772
+ block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
773
 
774
+ if not block:
775
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
776
 
777
+ text_block = " ".join(block)
778
+ m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
779
+ path = m.group(1).strip() if m else None
780
+ if not path:
781
+ arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
782
+ if arrow_lines:
783
+ path = arrow_lines[0]
784
+ if not path:
785
+ m2 = re.search(r"(operator.*?administrator|operator.*)", text_block, flags=re.IGNORECASE)
786
+ path = m2.group(1).strip() if m2 else None
787
+ if not path:
788
+ return None
789
+
790
+ path = path.replace("->", "→").strip()
791
+ path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
792
+ return f"If you want to escalate the issue, follow: {path}"
793
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794
 
795
+ # ---------------------- Incident ----------------------
796
  @app.post("/incident")
797
  async def raise_incident(input_data: IncidentInput):
798
  try:
 
817
  except Exception as e:
818
  raise HTTPException(status_code=500, detail=safe_str(e))
819
 
820
+
821
+ # ---------------------- Ticket description generation ----------------------
822
  @app.post("/generate_ticket_desc")
823
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
824
  try:
 
842
  text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
843
  except Exception:
844
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
845
+
846
  if text.startswith("```"):
847
  lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
848
  text = "\n".join(lines).strip()
 
857
  except Exception as e:
858
  raise HTTPException(status_code=500, detail=safe_str(e))
859
 
860
+
861
+ # ---------------------- Incident status ----------------------
862
  @app.post("/incident_status")
863
  async def incident_status(input_data: TicketStatusInput):
864
  try:
 
886
  state_label = STATE_MAP.get(state_code, state_code)
887
  short = result.get("short_description", "")
888
  number = result.get("number", input_data.number or "unknown")
 
889
  return {
890
  "bot_response": (
891
  f"**Ticket:** {number} \n"
 
899
  }
900
  except Exception as e:
901
  raise HTTPException(status_code=500, detail=safe_str(e))
902
+
903
+
904
+ def _set_incident_resolved(sys_id: str) -> bool:
905
+ try:
906
+ token = get_valid_token()
907
+ instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
908
+ if not instance_url:
909
+ print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
910
+ return False
911
+ headers = {
912
+ "Authorization": f"Bearer {token}",
913
+ "Accept": "application/json",
914
+ "Content-Type": "application/json",
915
+ }
916
+ url = f"{instance_url}/api/now/table/incident/{sys_id}"
917
+
918
+ close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
919
+ close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
920
+ caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
921
+ resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
922
+ assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
923
+ require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
924
+
925
+ if require_progress:
926
+ try:
927
+ resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
928
+ print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
929
+ except Exception as e:
930
+ print(f"[SN PATCH progress] exception={safe_str(e)}")
931
+
932
+ def clean(d: dict) -> dict:
933
+ return {k: v for k, v in d.items() if v is not None}
934
+
935
+ payload_A = clean({
936
+ "state": "6",
937
+ "close_code": close_code_val,
938
+ "close_notes": close_notes_val,
939
+ "caller_id": caller_sysid,
940
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
941
+ "work_notes": "Auto-resolve set by NOVA.",
942
+ "resolved_by": resolved_by_sysid,
943
+ "assignment_group": assign_group,
944
+ })
945
+ respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
946
+ if respA.status_code in (200, 204):
947
+ return True
948
+ print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
949
+
950
+ payload_B = clean({
951
+ "state": "Resolved",
952
+ "close_code": close_code_val,
953
+ "close_notes": close_notes_val,
954
+ "caller_id": caller_sysid,
955
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
956
+ "work_notes": "Auto-resolve set by NOVA.",
957
+ "resolved_by": resolved_by_sysid,
958
+ "assignment_group": assign_group,
959
+ })
960
+ respB = requests.patch(url, headers=headers, json=payload_B, verify=VERIFY_SSL, timeout=25)
961
+ if respB.status_code in (200, 204):
962
+ return True
963
+ print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
964
+
965
+ code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
966
+ notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
967
+ payload_C = clean({
968
+ "state": "6",
969
+ code_field: close_code_val,
970
+ notes_field: close_notes_val,
971
+ "caller_id": caller_sysid,
972
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
973
+ "work_notes": "Auto-resolve set by NOVA.",
974
+ "resolved_by": resolved_by_sysid,
975
+ "assignment_group": assign_group,
976
+ })
977
+ respC = requests.patch(url, headers=headers, json=payload_C, verify=VERIFY_SSL, timeout=25)
978
+ if respC.status_code in (200, 204):
979
+ return True
980
+ print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
981
+ return False
982
+ except Exception as e:
983
+ print(f"[SN PATCH resolve] exception={safe_str(e)}")
984
+ return False