srilakshu012456 commited on
Commit
b612daa
·
verified ·
1 Parent(s): 2470a1f

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +154 -93
main.py CHANGED
@@ -1,4 +1,6 @@
1
 
 
 
2
  import os
3
  import json
4
  import re
@@ -27,15 +29,18 @@ from services.generate_ticket import get_valid_token, create_incident
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
  def safe_str(e: Any) -> str:
31
  try:
32
  return builtins.str(e)
33
  except Exception:
34
  return "<error stringify failed>"
35
 
 
36
  load_dotenv()
37
  os.environ["POSTHOG_DISABLED"] = "true"
38
 
 
39
  @asynccontextmanager
40
  async def lifespan(app: FastAPI):
41
  try:
@@ -49,6 +54,7 @@ async def lifespan(app: FastAPI):
49
  print(f"[KB] ingestion failed: {safe_str(e)}")
50
  yield
51
 
 
52
  app = FastAPI(lifespan=lifespan)
53
  app.include_router(login_router)
54
 
@@ -67,18 +73,22 @@ class ChatInput(BaseModel):
67
  prev_status: Optional[str] = None
68
  last_issue: Optional[str] = None
69
 
 
70
  class IncidentInput(BaseModel):
71
  short_description: str
72
  description: str
73
  mark_resolved: Optional[bool] = False
74
 
 
75
  class TicketDescInput(BaseModel):
76
  issue: str
77
 
 
78
  class TicketStatusInput(BaseModel):
79
  sys_id: Optional[str] = None
80
  number: Optional[str] = None
81
 
 
82
  STATE_MAP = {
83
  "1": "New",
84
  "2": "In Progress",
@@ -134,10 +144,10 @@ ERROR_FAMILY_SYNS = {
134
  ),
135
  }
136
 
 
137
  def _detect_error_families(msg: str) -> list:
138
  """Return matching error family names found in the message (generic across SOPs)."""
139
  low = (msg or "").lower()
140
- import re
141
  low_norm = re.sub(r"[^\w\s]", " ", low)
142
  low_norm = re.sub(r"\s+", " ", low_norm).strip()
143
  fams = []
@@ -146,11 +156,13 @@ def _detect_error_families(msg: str) -> list:
146
  fams.append(fam)
147
  return fams
148
 
 
149
  def _is_domain_status_context(msg_norm: str) -> bool:
150
  if "status locked" in msg_norm or "locked status" in msg_norm:
151
  return True
152
  return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
153
 
 
154
  def _normalize_lines(text: str) -> List[str]:
155
  raw = (text or "")
156
  try:
@@ -158,59 +170,69 @@ def _normalize_lines(text: str) -> List[str]:
158
  except Exception:
159
  return [raw.strip()] if raw.strip() else []
160
 
 
161
  def _ensure_numbering(text: str) -> str:
162
- import re
 
 
 
163
  text = re.sub(r"[\u2060\u200B]", "", text or "")
164
- def strip_prefix_any(s: str) -> str:
165
- return re.sub(
166
- r"^\s*(?:[\u2060\u200B]*"
167
- r"(?:\d+\s*[.)])"
168
- r"|(?:step\s*\d+:?)"
169
- r"|(?:[\-\*\u2022])"
170
- r"|(?:[\u2460-\u2473]))\s*",
171
- "", (s or "").strip(), flags=re.IGNORECASE
172
- )
173
  lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
174
  if not lines:
175
  return text or ""
176
- merged: List[str] = []
177
- i = 0
178
- while i < len(lines):
179
- ln = lines[i]
180
- if re.fullmatch(r"\d+[.)]?|[\u2460-\u2473]", ln) and (i + 1) < len(lines):
181
- merged.append(lines[i + 1].strip())
182
- i += 2
183
- else:
184
- merged.append(ln)
185
- i += 1
186
- para = " ".join(merged).strip()
187
  if not para:
188
  return ""
189
- para_clean = re.sub(r"(?:\b\d+[.)]\s+)", "\n\n\n", para)
190
- para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean)
191
- para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean)
 
 
192
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
 
 
193
  if len(segments) < 2:
194
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
195
  segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|\s+;\s+", para) if seg.strip()]
196
- segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
197
- circled = {1:"\u2460",2:"\u2461",3:"\u2462",4:"\u2463",5:"\u2464",6:"\u2465",7:"\u2466",8:"\u2467",9:"\u2468",10:"\u2469",
198
- 11:"\u246a",12:"\u246b",13:"\u246c",14:"\u246d",15:"\u246e",16:"\u246f",17:"\u2470",18:"\u2471",19:"\u2472",20:"\u2473"}
199
- out: List[str] = []
200
- for idx, seg in enumerate(segments, start=1):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  marker = circled.get(idx, f"{idx})")
202
  out.append(f"{marker} {seg}")
203
  return "\n".join(out)
204
 
205
- # --- Next-step helpers (generic; SOP-agnostic) ---
206
 
 
207
  def _norm_text(s: str) -> str:
208
- import re
209
  s = (s or "").lower()
210
  s = re.sub(r"[^\w\s]", " ", s)
211
  s = re.sub(r"\s+", " ", s).strip()
212
  return s
213
 
 
214
  def _split_sop_into_steps(numbered_text: str) -> list:
215
  """
216
  Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
@@ -220,13 +242,12 @@ def _split_sop_into_steps(numbered_text: str) -> list:
220
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
221
  steps = []
222
  for ln in lines:
223
- # Strip circled/number/bullet marker
224
- cleaned = ln
225
- cleaned = re.sub(r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*", "", cleaned)
226
  if cleaned:
227
  steps.append(cleaned)
228
  return steps
229
 
 
230
  def _soft_match_score(a: str, b: str) -> float:
231
  # Simple Jaccard-like score on tokens for fuzzy matching
232
  ta = set(_norm_text(a).split())
@@ -237,15 +258,16 @@ def _soft_match_score(a: str, b: str) -> float:
237
  union = len(ta | tb)
238
  return inter / union if union else 0.0
239
 
 
240
  def _detect_next_intent(user_query: str) -> bool:
241
  q = _norm_text(user_query)
242
- # Conservative rules to avoid false triggers
243
  keys = [
244
  "after", "after this", "what next", "whats next", "next step",
245
  "then what", "following step", "continue", "subsequent", "proceed"
246
  ]
247
  return any(k in q for k in keys)
248
 
 
249
  def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
250
  """
251
  If 'what's next' intent is detected and we can reliably match the user's
@@ -262,7 +284,6 @@ def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6,
262
  q = user_query or ""
263
  best_idx, best_score = -1, -1.0
264
  for idx, step in enumerate(steps):
265
- # Exact substring match gets max score; else use soft match
266
  score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
267
  if score > best_score:
268
  best_score, best_idx = score, idx
@@ -277,17 +298,21 @@ def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6,
277
  end = min(start + max_next, len(steps))
278
  return steps[start:end]
279
 
 
280
  def _format_steps_as_numbered(steps: list) -> str:
281
- """
282
- Render a small list of steps with circled numbers for visual continuity.
283
- """
284
- circled = {1:"\u2460",2:"\u2461",3:"\u2462",4:"\u2463",5:"\u2464",6:"\u2465",7:"\u2466",8:"\u2467",9:"\u2468",10:"\u2469",
285
- 11:"\u246a",12:"\u246b",13:"\u246c",14:"\u246d",15:"\u246e",16:"\u246f",17:"\u2470",18:"\u2471",19:"\u2472",20:"\u2473"}
 
 
286
  out = []
287
  for i, s in enumerate(steps, start=1):
288
  out.append(f"{circled.get(i, str(i))} {s}")
289
  return "\n".join(out)
290
 
 
291
  def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
292
  """
293
  Pick the most relevant 'Common Errors & Resolution' bullet(s) for the user's message.
@@ -300,10 +325,6 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
300
 
301
  Returns exactly `max_lines` best-scoring lines (defaults to 1).
302
  """
303
-
304
- import re
305
- from typing import List, Tuple
306
-
307
  def _norm(s: str) -> str:
308
  s = (s or "").lower()
309
  s = re.sub(r"[^\w\s]", " ", s)
@@ -311,7 +332,7 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
311
  return s
312
 
313
  def _ngrams(tokens: List[str], n: int) -> List[str]:
314
- return [" ".join(tokens[i:i+n]) for i in range(len(tokens) - n + 1)]
315
 
316
  def _families_for(s: str) -> set:
317
  low = _norm(s)
@@ -336,7 +357,7 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
336
  ln_norm = _norm(ln)
337
  ln_fams = _families_for(ln)
338
 
339
- fam_overlap = len(q_fams & ln_fams) # strong signal
340
  anchored = 0.0
341
  first2 = " ".join(q_tokens[:2]) if len(q_tokens) >= 2 else ""
342
  first3 = " ".join(q_tokens[:3]) if len(q_tokens) >= 3 else ""
@@ -348,7 +369,6 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
348
  token_overlap = sum(1 for t in q_tokens if t and t in ln_norm)
349
  exact_phrase = 1.0 if (q and q in ln_norm) else 0.0
350
 
351
- # Composite score (tuned generically)
352
  score = (
353
  1.70 * fam_overlap +
354
  1.00 * anchored +
@@ -358,7 +378,7 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
358
  0.30 * token_overlap
359
  )
360
 
361
- if re.match(r"^\s*[\-\*\u2022]\s*", ln): # bullet
362
  score += 0.10
363
  heading = ln_norm.split(":")[0].strip()
364
  if heading and (heading in q or (first2 and first2 in heading)):
@@ -368,15 +388,14 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
368
 
369
  scored.sort(key=lambda x: x[0], reverse=True)
370
  top = [ln for s, ln in scored[:max_lines] if s > 0.0]
371
-
372
  if not top:
373
  top = lines[:max_lines]
374
-
375
  return "\n".join(top).strip()
376
 
 
377
  def _friendly_permission_reply(raw: str) -> str:
378
  line = (raw or "").strip()
379
- line = re.sub(r"^\s*[\-\*\u2022]\s*", "", line)
380
  if not line:
381
  return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
382
  if "verify role access" in line.lower():
@@ -385,6 +404,7 @@ def _friendly_permission_reply(raw: str) -> str:
385
  return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
386
  return line
387
 
 
388
  def _detect_language_hint(msg: str) -> Optional[str]:
389
  if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
390
  return "Tamil"
@@ -392,9 +412,13 @@ def _detect_language_hint(msg: str) -> Optional[str]:
392
  return "Hindi"
393
  return None
394
 
 
395
  def _build_clarifying_message() -> str:
396
- return ("It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
397
- "or should I raise a ServiceNow ticket for you?")
 
 
 
398
 
399
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
400
  issue = (issue_text or "").strip()
@@ -407,6 +431,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
407
  ).strip()
408
  return short_desc, long_desc
409
 
 
410
  def _is_incident_intent(msg_norm: str) -> bool:
411
  intent_phrases = [
412
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
@@ -416,6 +441,7 @@ def _is_incident_intent(msg_norm: str) -> bool:
416
  ]
417
  return any(p in msg_norm for p in intent_phrases)
418
 
 
419
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
420
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
421
  base_has_status = any(k in msg_norm for k in status_keywords)
@@ -438,6 +464,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
438
  return {"number": val.upper() if val.lower().startswith("inc") else val}
439
  return {"number": None, "ask_number": True}
440
 
 
441
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
442
  phrases = [
443
  "it is resolved", "resolved", "issue resolved", "problem resolved",
@@ -446,6 +473,7 @@ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
446
  ]
447
  return any(p in msg_norm for p in phrases)
448
 
 
449
  def _has_negation_resolved(msg_norm: str) -> bool:
450
  neg_phrases = [
451
  "not resolved", "issue not resolved", "still not working", "not working",
@@ -453,8 +481,8 @@ def _has_negation_resolved(msg_norm: str) -> bool:
453
  ]
454
  return any(p in msg_norm for p in neg_phrases)
455
 
 
456
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
457
- import re
458
  STRICT_OVERLAP = 3
459
  MAX_SENTENCES_STRICT = 4
460
  MAX_SENTENCES_CONCISE = 3
@@ -466,7 +494,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
466
  return t
467
 
468
  def _split_sentences(ctx: str) -> List[str]:
469
- raw_sents = re.split(r"(?<=[.!?])\s+|\n+|\-\s*|\*\s*", ctx or "")
470
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
471
 
472
  ctx = (context or "").strip()
@@ -495,23 +523,22 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
495
  kept = sentences[:MAX_SENTENCES_CONCISE]
496
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
497
 
 
498
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
499
  """
500
  Collect error bullets/heading-style lines from the SOP errors section.
501
  Generic: keeps bullet points (•, -, *), and lines that look like "Heading: details".
502
  This ensures items like 'ASN not found', 'Trailer status locked', etc., are preserved.
503
  """
504
- import re
505
  kept: List[str] = []
506
  for ln in _normalize_lines(text):
507
- # bullet (•, -, *) OR a heading-like line that contains a colon (e.g., "ASN not found: ...")
508
- if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln):
509
  kept.append(ln)
510
  if len(kept) >= max_lines:
511
  break
512
- # Fallback: if nothing matched, return original text
513
  return "\n".join(kept).strip() if kept else (text or "").strip()
514
 
 
515
  def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
516
  PERM_SYNONYMS = (
517
  "permission", "permissions", "access", "authorization", "authorisation",
@@ -526,6 +553,7 @@ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
526
  break
527
  return "\n".join(kept).strip() if kept else (text or "").strip()
528
 
 
529
  def _extract_escalation_line(text: str) -> Optional[str]:
530
  if not text:
531
  return None
@@ -564,6 +592,7 @@ def _extract_escalation_line(text: str) -> Optional[str]:
564
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
565
  return f"If you want to escalate the issue, follow: {path}"
566
 
 
567
  def _classify_resolution_llm(user_message: str) -> bool:
568
  if not GEMINI_API_KEY:
569
  return False
@@ -585,6 +614,7 @@ Message: {user_message}"""
585
  except Exception:
586
  return False
587
 
 
588
  def _set_incident_resolved(sys_id: str) -> bool:
589
  try:
590
  token = get_valid_token()
@@ -610,8 +640,10 @@ def _set_incident_resolved(sys_id: str) -> bool:
610
  print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
611
  except Exception as e:
612
  print(f"[SN PATCH progress] exception={safe_str(e)}")
 
613
  def clean(d: dict) -> dict:
614
  return {k: v for k, v in d.items() if v is not None}
 
615
  payload_A = clean({
616
  "state": "6",
617
  "close_code": close_code_val,
@@ -626,6 +658,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
626
  if respA.status_code in (200, 204):
627
  return True
628
  print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
 
629
  payload_B = clean({
630
  "state": "Resolved",
631
  "close_code": close_code_val,
@@ -640,6 +673,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
640
  if respB.status_code in (200, 204):
641
  return True
642
  print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
 
643
  code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
644
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
645
  payload_C = clean({
@@ -661,6 +695,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
661
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
662
  return False
663
 
 
664
  # ------------------------------ Prereq helper ------------------------------
665
  def _find_prereq_section_text(best_doc: str) -> str:
666
  """
@@ -680,11 +715,13 @@ def _find_prereq_section_text(best_doc: str) -> str:
680
  return txt.strip()
681
  return ""
682
 
 
683
  # ------------------------------ Health ------------------------------
684
  @app.get("/")
685
  async def health_check():
686
  return {"status": "ok"}
687
 
 
688
  # ------------------------------ Chat ------------------------------
689
  @app.post("/chat")
690
  async def chat_with_ai(input_data: ChatInput):
@@ -747,7 +784,7 @@ async def chat_with_ai(input_data: ChatInput):
747
  "ask_resolved": False,
748
  "suggest_incident": True,
749
  "followup": "Shall I create a ticket now?",
750
- "options": [{"type":"yesno","title":"Create ticket now?"}],
751
  "top_hits": [],
752
  "sources": [],
753
  "debug": {"intent": "resolved_ack", "auto_created": False, "error": err},
@@ -760,7 +797,7 @@ async def chat_with_ai(input_data: ChatInput):
760
  "ask_resolved": False,
761
  "suggest_incident": True,
762
  "followup": "Shall I create a ticket now?",
763
- "options": [{"type":"yesno","title":"Create ticket now?"}],
764
  "top_hits": [],
765
  "sources": [],
766
  "debug": {"intent": "resolved_ack", "exception": True},
@@ -854,8 +891,10 @@ async def chat_with_ai(input_data: ChatInput):
854
  score = distances[i] if i < len(distances) else None
855
  comb = combined[i] if i < len(combined) else None
856
  m = dict(meta)
857
- if score is not None: m["distance"] = score
858
- if comb is not None: m["combined"] = comb
 
 
859
  items.append({"text": text, "meta": m})
860
 
861
  selected = items[:max(1, 2)]
@@ -961,7 +1000,7 @@ async def chat_with_ai(input_data: ChatInput):
961
  "ask_resolved": False,
962
  "suggest_incident": True,
963
  "followup": "Share more details (module/screen/error), or say 'create ticket'.",
964
- "options": [{"type":"yesno","title":"Share details or raise a ticket?"}],
965
  "top_hits": [],
966
  "sources": [],
967
  "debug": {
@@ -974,7 +1013,6 @@ async def chat_with_ai(input_data: ChatInput):
974
  "strong_steps_bypass": strong_steps_bypass,
975
  "strong_error_signal": strong_error_signal,
976
  "generic_error_signal": generic_error_signal
977
-
978
  },
979
  }
980
 
@@ -984,51 +1022,59 @@ async def chat_with_ai(input_data: ChatInput):
984
 
985
  escalation_line = None # SOP escalation candidate
986
  full_errors = None # keep for possible escalation extraction
 
 
987
 
988
  if best_doc:
989
  if detected_intent == "steps":
990
  full_steps = get_best_steps_section_text(best_doc)
991
  if not full_steps:
992
  sec = (top_meta or {}).get("section")
993
- if sec: full_steps = get_section_text(best_doc, sec)
 
 
994
  if full_steps:
995
-
996
- numbered = _ensure_numbering(full_steps) # keep your existing formatting
997
- # NEW: return only subsequent steps when user asks "what's next"
998
- next_only = _resolve_next_steps(input_data.user_message, numbered, max_next=6, min_score=0.35)
999
- if next_only is not None:
1000
- if len(next_only) == 0:
1001
- context = "You are at the final step of this SOP. No further steps."
1002
- else:
1003
- context = _format_steps_as_numbered(next_only)
1004
- else:
1005
- context=numbered
1006
-
 
 
 
 
 
 
 
 
 
 
1007
  elif detected_intent == "errors":
1008
  full_errors = get_best_errors_section_text(best_doc)
1009
- #assist_followup = None # collect a helpful follow-up for generic cases
1010
-
1011
  if full_errors:
1012
  ctx_err = _extract_errors_only(full_errors, max_lines=30)
1013
  if is_perm_query:
1014
  context = _filter_permission_lines(ctx_err, max_lines=6)
1015
  else:
1016
  # Decide specific vs generic:
1017
- # - specific: user named a family (NOT_FOUND / LOCKED / MISMATCH / ...)
1018
- # - generic: user only said 'issue'/'error' without naming family
1019
  is_specific_error = len(_detect_error_families(msg_low)) > 0
1020
  if is_specific_error:
1021
- context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=1)
1022
  else:
1023
-
1024
  all_lines: List[str] = _normalize_lines(ctx_err)
1025
- error_bullets = [ln for ln in all_lines if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln)]
1026
- # limit to reasonable count (e.g., 8)
1027
  context = "\n".join(error_bullets[:8]).strip()
1028
  assist_followup = (
1029
- "Please tell me which error above matches your screen (paste the exact text), "
1030
- "or share a screenshot. I can guide you further or raise a ServiceNow ticket." )
1031
-
1032
  escalation_line = _extract_escalation_line(full_errors)
1033
 
1034
  elif detected_intent == "prereqs":
@@ -1070,12 +1116,19 @@ Return ONLY the rewritten guidance."""
1070
 
1071
  # Deterministic local formatting
1072
  if detected_intent == "steps":
1073
- bot_text = _ensure_numbering(context)
 
 
 
 
 
 
1074
  elif detected_intent == "errors":
1075
  if not bot_text.strip() or http_code == 429:
1076
  bot_text = context.strip()
1077
  if escalation_line:
1078
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
 
1079
  else:
1080
  bot_text = context
1081
 
@@ -1105,7 +1158,8 @@ Return ONLY the rewritten guidance."""
1105
  lower = (bot_text or "").lower()
1106
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
1107
  status = "PARTIAL"
1108
- options = [{"type":"yesno","title":"Share details or raise a ticket?"}] if status == "PARTIAL" else []
 
1109
  return {
1110
  "bot_response": bot_text,
1111
  "status": status,
@@ -1125,6 +1179,10 @@ Return ONLY the rewritten guidance."""
1125
  "matched_count": filt_info.get("matched_count"),
1126
  "user_intent": detected_intent,
1127
  "best_doc": best_doc,
 
 
 
 
1128
  },
1129
  }
1130
  except HTTPException:
@@ -1132,6 +1190,7 @@ Return ONLY the rewritten guidance."""
1132
  except Exception as e:
1133
  raise HTTPException(status_code=500, detail=safe_str(e))
1134
 
 
1135
  # ------------------------------ Ticket description generation ------------------------------
1136
  @app.post("/generate_ticket_desc")
1137
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
@@ -1170,6 +1229,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
1170
  except Exception as e:
1171
  raise HTTPException(status_code=500, detail=safe_str(e))
1172
 
 
1173
  # ------------------------------ Incident status ------------------------------
1174
  @app.post("/incident_status")
1175
  async def incident_status(input_data: TicketStatusInput):
@@ -1210,6 +1270,7 @@ async def incident_status(input_data: TicketStatusInput):
1210
  except Exception as e:
1211
  raise HTTPException(status_code=500, detail=safe_str(e))
1212
 
 
1213
  # ------------------------------ Incident ------------------------------
1214
  @app.post("/incident")
1215
  async def raise_incident(input_data: IncidentInput):
 
1
 
2
+ # main_hugging_phase_recent.py
3
+
4
  import os
5
  import json
6
  import re
 
29
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
30
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
31
 
32
+
33
  def safe_str(e: Any) -> str:
34
  try:
35
  return builtins.str(e)
36
  except Exception:
37
  return "<error stringify failed>"
38
 
39
+
40
  load_dotenv()
41
  os.environ["POSTHOG_DISABLED"] = "true"
42
 
43
+
44
  @asynccontextmanager
45
  async def lifespan(app: FastAPI):
46
  try:
 
54
  print(f"[KB] ingestion failed: {safe_str(e)}")
55
  yield
56
 
57
+
58
  app = FastAPI(lifespan=lifespan)
59
  app.include_router(login_router)
60
 
 
73
  prev_status: Optional[str] = None
74
  last_issue: Optional[str] = None
75
 
76
+
77
  class IncidentInput(BaseModel):
78
  short_description: str
79
  description: str
80
  mark_resolved: Optional[bool] = False
81
 
82
+
83
  class TicketDescInput(BaseModel):
84
  issue: str
85
 
86
+
87
  class TicketStatusInput(BaseModel):
88
  sys_id: Optional[str] = None
89
  number: Optional[str] = None
90
 
91
+
92
  STATE_MAP = {
93
  "1": "New",
94
  "2": "In Progress",
 
144
  ),
145
  }
146
 
147
+
148
  def _detect_error_families(msg: str) -> list:
149
  """Return matching error family names found in the message (generic across SOPs)."""
150
  low = (msg or "").lower()
 
151
  low_norm = re.sub(r"[^\w\s]", " ", low)
152
  low_norm = re.sub(r"\s+", " ", low_norm).strip()
153
  fams = []
 
156
  fams.append(fam)
157
  return fams
158
 
159
+
160
  def _is_domain_status_context(msg_norm: str) -> bool:
161
  if "status locked" in msg_norm or "locked status" in msg_norm:
162
  return True
163
  return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
164
 
165
+
166
  def _normalize_lines(text: str) -> List[str]:
167
  raw = (text or "")
168
  try:
 
170
  except Exception:
171
  return [raw.strip()] if raw.strip() else []
172
 
173
+
174
  def _ensure_numbering(text: str) -> str:
175
+ """
176
+ Normalize raw SOP steps into a clean numbered list using circled digits.
177
+ Robust against '1.', '1)', 'Step 1:', bullets ('-', '*', '•'), and circled digits.
178
+ """
179
  text = re.sub(r"[\u2060\u200B]", "", text or "")
 
 
 
 
 
 
 
 
 
180
  lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
181
  if not lines:
182
  return text or ""
183
+
184
+ # Collapse lines into a block and then split on common step markers
185
+ para = " ".join(lines).strip()
 
 
 
 
 
 
 
 
186
  if not para:
187
  return ""
188
+
189
+ # Create hard breaks at typical step boundaries
190
+ para_clean = re.sub(r"(?:\b\d+[.)]\s+)", "\n\n\n", para) # 1. / 1)
191
+ para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
192
+ para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean) # Step 1:
193
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
194
+
195
+ # Fallback splitting if we didn't detect separators
196
  if len(segments) < 2:
197
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
198
  segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|\s+;\s+", para) if seg.strip()]
199
+
200
+ # Strip any step prefixes
201
+ def strip_prefix_any(s: str) -> str:
202
+ return re.sub(
203
+ r"^\s*(?:"
204
+ r"(?:\d+\s*[.)])" # leading numbers 1., 2)
205
+ r"|(?:step\s*\d+:?)" # Step 1:
206
+ r"|(?:[-*\u2022])" # bullets
207
+ r"|(?:[\u2460-\u2473])" # circled digits
208
+ r")\s*",
209
+ "",
210
+ (s or "").strip(),
211
+ flags=re.IGNORECASE
212
+ )
213
+
214
+ clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
215
+ circled = {
216
+ 1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
217
+ 6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
218
+ 11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
219
+ 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
220
+ }
221
+ out = []
222
+ for idx, seg in enumerate(clean_segments, start=1):
223
  marker = circled.get(idx, f"{idx})")
224
  out.append(f"{marker} {seg}")
225
  return "\n".join(out)
226
 
 
227
 
228
+ # --- Next-step helpers (generic; SOP-agnostic) ---
229
  def _norm_text(s: str) -> str:
 
230
  s = (s or "").lower()
231
  s = re.sub(r"[^\w\s]", " ", s)
232
  s = re.sub(r"\s+", " ", s).strip()
233
  return s
234
 
235
+
236
  def _split_sop_into_steps(numbered_text: str) -> list:
237
  """
238
  Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
 
242
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
243
  steps = []
244
  for ln in lines:
245
+ cleaned = re.sub(r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*", "", ln)
 
 
246
  if cleaned:
247
  steps.append(cleaned)
248
  return steps
249
 
250
+
251
  def _soft_match_score(a: str, b: str) -> float:
252
  # Simple Jaccard-like score on tokens for fuzzy matching
253
  ta = set(_norm_text(a).split())
 
258
  union = len(ta | tb)
259
  return inter / union if union else 0.0
260
 
261
+
262
  def _detect_next_intent(user_query: str) -> bool:
263
  q = _norm_text(user_query)
 
264
  keys = [
265
  "after", "after this", "what next", "whats next", "next step",
266
  "then what", "following step", "continue", "subsequent", "proceed"
267
  ]
268
  return any(k in q for k in keys)
269
 
270
+
271
  def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
272
  """
273
  If 'what's next' intent is detected and we can reliably match the user's
 
284
  q = user_query or ""
285
  best_idx, best_score = -1, -1.0
286
  for idx, step in enumerate(steps):
 
287
  score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
288
  if score > best_score:
289
  best_score, best_idx = score, idx
 
298
  end = min(start + max_next, len(steps))
299
  return steps[start:end]
300
 
301
+
302
  def _format_steps_as_numbered(steps: list) -> str:
303
+ """Render a small list of steps with circled numbers for visual continuity."""
304
+ circled = {
305
+ 1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
306
+ 6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
307
+ 11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
308
+ 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
309
+ }
310
  out = []
311
  for i, s in enumerate(steps, start=1):
312
  out.append(f"{circled.get(i, str(i))} {s}")
313
  return "\n".join(out)
314
 
315
+
316
  def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
317
  """
318
  Pick the most relevant 'Common Errors & Resolution' bullet(s) for the user's message.
 
325
 
326
  Returns exactly `max_lines` best-scoring lines (defaults to 1).
327
  """
 
 
 
 
328
  def _norm(s: str) -> str:
329
  s = (s or "").lower()
330
  s = re.sub(r"[^\w\s]", " ", s)
 
332
  return s
333
 
334
  def _ngrams(tokens: List[str], n: int) -> List[str]:
335
+ return [" ".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]
336
 
337
  def _families_for(s: str) -> set:
338
  low = _norm(s)
 
357
  ln_norm = _norm(ln)
358
  ln_fams = _families_for(ln)
359
 
360
+ fam_overlap = len(q_fams & ln_fams)
361
  anchored = 0.0
362
  first2 = " ".join(q_tokens[:2]) if len(q_tokens) >= 2 else ""
363
  first3 = " ".join(q_tokens[:3]) if len(q_tokens) >= 3 else ""
 
369
  token_overlap = sum(1 for t in q_tokens if t and t in ln_norm)
370
  exact_phrase = 1.0 if (q and q in ln_norm) else 0.0
371
 
 
372
  score = (
373
  1.70 * fam_overlap +
374
  1.00 * anchored +
 
378
  0.30 * token_overlap
379
  )
380
 
381
+ if re.match(r"^\s*[-*\u2022]\s*", ln): # bullet
382
  score += 0.10
383
  heading = ln_norm.split(":")[0].strip()
384
  if heading and (heading in q or (first2 and first2 in heading)):
 
388
 
389
  scored.sort(key=lambda x: x[0], reverse=True)
390
  top = [ln for s, ln in scored[:max_lines] if s > 0.0]
 
391
  if not top:
392
  top = lines[:max_lines]
 
393
  return "\n".join(top).strip()
394
 
395
+
396
  def _friendly_permission_reply(raw: str) -> str:
397
  line = (raw or "").strip()
398
+ line = re.sub(r"^\s*[-*\u2022]\s*", "", line)
399
  if not line:
400
  return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
401
  if "verify role access" in line.lower():
 
404
  return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
405
  return line
406
 
407
+
408
  def _detect_language_hint(msg: str) -> Optional[str]:
409
  if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
410
  return "Tamil"
 
412
  return "Hindi"
413
  return None
414
 
415
+
416
  def _build_clarifying_message() -> str:
417
+ return (
418
+ "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
419
+ "or should I raise a ServiceNow ticket for you?"
420
+ )
421
+
422
 
423
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
424
  issue = (issue_text or "").strip()
 
431
  ).strip()
432
  return short_desc, long_desc
433
 
434
+
435
  def _is_incident_intent(msg_norm: str) -> bool:
436
  intent_phrases = [
437
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
 
441
  ]
442
  return any(p in msg_norm for p in intent_phrases)
443
 
444
+
445
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
446
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
447
  base_has_status = any(k in msg_norm for k in status_keywords)
 
464
  return {"number": val.upper() if val.lower().startswith("inc") else val}
465
  return {"number": None, "ask_number": True}
466
 
467
+
468
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
469
  phrases = [
470
  "it is resolved", "resolved", "issue resolved", "problem resolved",
 
473
  ]
474
  return any(p in msg_norm for p in phrases)
475
 
476
+
477
  def _has_negation_resolved(msg_norm: str) -> bool:
478
  neg_phrases = [
479
  "not resolved", "issue not resolved", "still not working", "not working",
 
481
  ]
482
  return any(p in msg_norm for p in neg_phrases)
483
 
484
+
485
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
 
486
  STRICT_OVERLAP = 3
487
  MAX_SENTENCES_STRICT = 4
488
  MAX_SENTENCES_CONCISE = 3
 
494
  return t
495
 
496
  def _split_sentences(ctx: str) -> List[str]:
497
+ raw_sents = re.split(r"(?<=[.!?])\s+|\n+|-\s*|\*\s*", ctx or "")
498
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
499
 
500
  ctx = (context or "").strip()
 
523
  kept = sentences[:MAX_SENTENCES_CONCISE]
524
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
525
 
526
+
527
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
528
  """
529
  Collect error bullets/heading-style lines from the SOP errors section.
530
  Generic: keeps bullet points (•, -, *), and lines that look like "Heading: details".
531
  This ensures items like 'ASN not found', 'Trailer status locked', etc., are preserved.
532
  """
 
533
  kept: List[str] = []
534
  for ln in _normalize_lines(text):
535
+ if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
 
536
  kept.append(ln)
537
  if len(kept) >= max_lines:
538
  break
 
539
  return "\n".join(kept).strip() if kept else (text or "").strip()
540
 
541
+
542
  def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
543
  PERM_SYNONYMS = (
544
  "permission", "permissions", "access", "authorization", "authorisation",
 
553
  break
554
  return "\n".join(kept).strip() if kept else (text or "").strip()
555
 
556
+
557
  def _extract_escalation_line(text: str) -> Optional[str]:
558
  if not text:
559
  return None
 
592
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
593
  return f"If you want to escalate the issue, follow: {path}"
594
 
595
+
596
  def _classify_resolution_llm(user_message: str) -> bool:
597
  if not GEMINI_API_KEY:
598
  return False
 
614
  except Exception:
615
  return False
616
 
617
+
618
  def _set_incident_resolved(sys_id: str) -> bool:
619
  try:
620
  token = get_valid_token()
 
640
  print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
641
  except Exception as e:
642
  print(f"[SN PATCH progress] exception={safe_str(e)}")
643
+
644
  def clean(d: dict) -> dict:
645
  return {k: v for k, v in d.items() if v is not None}
646
+
647
  payload_A = clean({
648
  "state": "6",
649
  "close_code": close_code_val,
 
658
  if respA.status_code in (200, 204):
659
  return True
660
  print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
661
+
662
  payload_B = clean({
663
  "state": "Resolved",
664
  "close_code": close_code_val,
 
673
  if respB.status_code in (200, 204):
674
  return True
675
  print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
676
+
677
  code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
678
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
679
  payload_C = clean({
 
695
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
696
  return False
697
 
698
+
699
  # ------------------------------ Prereq helper ------------------------------
700
  def _find_prereq_section_text(best_doc: str) -> str:
701
  """
 
715
  return txt.strip()
716
  return ""
717
 
718
+
719
  # ------------------------------ Health ------------------------------
720
  @app.get("/")
721
  async def health_check():
722
  return {"status": "ok"}
723
 
724
+
725
  # ------------------------------ Chat ------------------------------
726
  @app.post("/chat")
727
  async def chat_with_ai(input_data: ChatInput):
 
784
  "ask_resolved": False,
785
  "suggest_incident": True,
786
  "followup": "Shall I create a ticket now?",
787
+ "options": [{"type": "yesno", "title": "Create ticket now?"}],
788
  "top_hits": [],
789
  "sources": [],
790
  "debug": {"intent": "resolved_ack", "auto_created": False, "error": err},
 
797
  "ask_resolved": False,
798
  "suggest_incident": True,
799
  "followup": "Shall I create a ticket now?",
800
+ "options": [{"type": "yesno", "title": "Create ticket now?"}],
801
  "top_hits": [],
802
  "sources": [],
803
  "debug": {"intent": "resolved_ack", "exception": True},
 
891
  score = distances[i] if i < len(distances) else None
892
  comb = combined[i] if i < len(combined) else None
893
  m = dict(meta)
894
+ if score is not None:
895
+ m["distance"] = score
896
+ if comb is not None:
897
+ m["combined"] = comb
898
  items.append({"text": text, "meta": m})
899
 
900
  selected = items[:max(1, 2)]
 
1000
  "ask_resolved": False,
1001
  "suggest_incident": True,
1002
  "followup": "Share more details (module/screen/error), or say 'create ticket'.",
1003
+ "options": [{"type": "yesno", "title": "Share details or raise a ticket?"}],
1004
  "top_hits": [],
1005
  "sources": [],
1006
  "debug": {
 
1013
  "strong_steps_bypass": strong_steps_bypass,
1014
  "strong_error_signal": strong_error_signal,
1015
  "generic_error_signal": generic_error_signal
 
1016
  },
1017
  }
1018
 
 
1022
 
1023
  escalation_line = None # SOP escalation candidate
1024
  full_errors = None # keep for possible escalation extraction
1025
+ next_step_applied = False
1026
+ next_step_info: Dict[str, Any] = {}
1027
 
1028
  if best_doc:
1029
  if detected_intent == "steps":
1030
  full_steps = get_best_steps_section_text(best_doc)
1031
  if not full_steps:
1032
  sec = (top_meta or {}).get("section")
1033
+ if sec:
1034
+ full_steps = get_section_text(best_doc, sec)
1035
+
1036
  if full_steps:
1037
+ # Use numbered form only for matching; keep raw for full output
1038
+ numbered_full = _ensure_numbering(full_steps)
1039
+ next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
1040
+
1041
+ if next_only is not None:
1042
+ # "what's next" mode
1043
+ if len(next_only) == 0:
1044
+ context = "You are at the final step of this SOP. No further steps."
1045
+ next_step_applied = True
1046
+ next_step_info = {"count": 0}
1047
+ context_preformatted = True
1048
+ else:
1049
+ context = _format_steps_as_numbered(next_only)
1050
+ next_step_applied = True
1051
+ next_step_info = {"count": len(next_only)}
1052
+ context_preformatted = True
1053
+ else:
1054
+ # Normal mode: return the full SOP section (raw),
1055
+ # and we'll number it below once.
1056
+ context = full_steps
1057
+ context_preformatted = False
1058
+
1059
  elif detected_intent == "errors":
1060
  full_errors = get_best_errors_section_text(best_doc)
 
 
1061
  if full_errors:
1062
  ctx_err = _extract_errors_only(full_errors, max_lines=30)
1063
  if is_perm_query:
1064
  context = _filter_permission_lines(ctx_err, max_lines=6)
1065
  else:
1066
  # Decide specific vs generic:
 
 
1067
  is_specific_error = len(_detect_error_families(msg_low)) > 0
1068
  if is_specific_error:
1069
+ context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=1)
1070
  else:
 
1071
  all_lines: List[str] = _normalize_lines(ctx_err)
1072
+ error_bullets = [ln for ln in all_lines if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln)]
 
1073
  context = "\n".join(error_bullets[:8]).strip()
1074
  assist_followup = (
1075
+ "Please tell me which error above matches your screen (paste the exact text), "
1076
+ "or share a screenshot. I can guide you further or raise a ServiceNow ticket."
1077
+ )
1078
  escalation_line = _extract_escalation_line(full_errors)
1079
 
1080
  elif detected_intent == "prereqs":
 
1116
 
1117
  # Deterministic local formatting
1118
  if detected_intent == "steps":
1119
+ # If we trimmed to next steps, 'context' is already formatted (or a sentence).
1120
+ # Only number when returning full SOP raw text.
1121
+ if ('context_preformatted' in locals()) and context_preformatted:
1122
+ bot_text = context
1123
+ else:
1124
+ bot_text = _ensure_numbering(context)
1125
+
1126
  elif detected_intent == "errors":
1127
  if not bot_text.strip() or http_code == 429:
1128
  bot_text = context.strip()
1129
  if escalation_line:
1130
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
1131
+
1132
  else:
1133
  bot_text = context
1134
 
 
1158
  lower = (bot_text or "").lower()
1159
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
1160
  status = "PARTIAL"
1161
+ options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
1162
+
1163
  return {
1164
  "bot_response": bot_text,
1165
  "status": status,
 
1179
  "matched_count": filt_info.get("matched_count"),
1180
  "user_intent": detected_intent,
1181
  "best_doc": best_doc,
1182
+ "next_step": {
1183
+ "applied": next_step_applied,
1184
+ "info": next_step_info,
1185
+ },
1186
  },
1187
  }
1188
  except HTTPException:
 
1190
  except Exception as e:
1191
  raise HTTPException(status_code=500, detail=safe_str(e))
1192
 
1193
+
1194
  # ------------------------------ Ticket description generation ------------------------------
1195
  @app.post("/generate_ticket_desc")
1196
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
 
1229
  except Exception as e:
1230
  raise HTTPException(status_code=500, detail=safe_str(e))
1231
 
1232
+
1233
  # ------------------------------ Incident status ------------------------------
1234
  @app.post("/incident_status")
1235
  async def incident_status(input_data: TicketStatusInput):
 
1270
  except Exception as e:
1271
  raise HTTPException(status_code=500, detail=safe_str(e))
1272
 
1273
+
1274
  # ------------------------------ Incident ------------------------------
1275
  @app.post("/incident")
1276
  async def raise_incident(input_data: IncidentInput):