srilakshu012456 commited on
Commit
4cbfdcf
·
verified ·
1 Parent(s): 9cd2767

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +153 -76
main.py CHANGED
@@ -12,6 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
12
  from pydantic import BaseModel
13
  from dotenv import load_dotenv
14
  from datetime import datetime
 
15
  from services.kb_creation import (
16
  collection,
17
  ingest_documents,
@@ -26,15 +27,18 @@ from services.generate_ticket import get_valid_token, create_incident
26
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
27
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
28
 
 
29
  def safe_str(e: Any) -> str:
30
  try:
31
  return builtins.str(e)
32
  except Exception:
33
  return "<error stringify failed>"
34
 
 
35
  load_dotenv()
36
  os.environ["POSTHOG_DISABLED"] = "true"
37
 
 
38
  @asynccontextmanager
39
  async def lifespan(app: FastAPI):
40
  try:
@@ -48,6 +52,7 @@ async def lifespan(app: FastAPI):
48
  print(f"[KB] ingestion failed: {safe_str(e)}")
49
  yield
50
 
 
51
  app = FastAPI(lifespan=lifespan)
52
  app.include_router(login_router)
53
 
@@ -60,23 +65,28 @@ app.add_middleware(
60
  allow_headers=["*"],
61
  )
62
 
 
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,27 +102,48 @@ GEMINI_URL = (
92
  f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
93
  )
94
 
95
- # ---------------- Numbering & friendly fallbacks ----------------
96
  def _normalize_lines(text: str) -> List[str]:
97
- return [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
 
 
 
 
 
 
 
 
 
98
 
99
  def _ensure_numbering(text: str) -> str:
100
  """
101
  Force plain-text numbering: 1) 2) 3) so it renders in any bubble.
 
102
  """
103
- lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
104
  if not lines:
105
  return text or ""
106
- normalized = []
 
107
  for ln in lines:
108
- ln2 = re.sub(r"^\s*(?:\d+[\.\)]\s+|[\-\*\u2022]\s+)", "", ln).strip()
 
 
 
 
 
109
  normalized.append(ln2)
 
110
  if len(normalized) == 1:
111
- parts = [p.strip() for p in re.split(r"\.\s+(?=[A-Z0-9])", normalized[0]) if p.strip()]
 
 
112
  if len(parts) > 1:
113
  normalized = parts
 
114
  return "\n".join([f"{i+1}) {ln}" for i, ln in enumerate(normalized)])
115
 
 
116
  def _friendly_permission_reply(raw: str) -> str:
117
  line = (raw or "").strip()
118
  line = re.sub(r"^\s*[\-\*\u2022]\s*", "", line)
@@ -124,7 +155,7 @@ def _friendly_permission_reply(raw: str) -> str:
124
  return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
125
  return line
126
 
127
- # ---------------- Language detection (Tamil/Hindi mirror) ----------------
128
  def _detect_language_hint(msg: str) -> Optional[str]:
129
  if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
130
  return "Tamil"
@@ -132,14 +163,14 @@ def _detect_language_hint(msg: str) -> Optional[str]:
132
  return "Hindi"
133
  return None
134
 
135
- # ---------------- Clarifying message ----------------
136
  def _build_clarifying_message() -> str:
137
  return (
138
  "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
139
  "or should I raise a ServiceNow ticket for you?"
140
  )
141
 
142
- # ---------------- Resolution/Incident helpers ----------------
143
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
144
  issue = (issue_text or "").strip()
145
  resolved = (resolved_text or "").strip()
@@ -151,6 +182,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
151
  ).strip()
152
  return short_desc, long_desc
153
 
 
154
  def _is_incident_intent(msg_norm: str) -> bool:
155
  intent_phrases = [
156
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
@@ -160,6 +192,7 @@ def _is_incident_intent(msg_norm: str) -> bool:
160
  ]
161
  return any(p in msg_norm for p in intent_phrases)
162
 
 
163
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
164
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
165
  if not any(k in msg_norm for k in status_keywords):
@@ -173,6 +206,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
173
  return {"number": val.upper() if val.lower().startswith("inc") else val}
174
  return {"number": None, "ask_number": True}
175
 
 
176
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
177
  phrases = [
178
  "it is resolved", "resolved", "issue resolved", "problem resolved",
@@ -181,6 +215,7 @@ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
181
  ]
182
  return any(p in msg_norm for p in phrases)
183
 
 
184
  def _has_negation_resolved(msg_norm: str) -> bool:
185
  neg_phrases = [
186
  "not resolved", "issue not resolved", "still not working", "not working",
@@ -188,13 +223,12 @@ def _has_negation_resolved(msg_norm: str) -> bool:
188
  ]
189
  return any(p in msg_norm for p in neg_phrases)
190
 
 
191
  def _classify_resolution_llm(user_message: str) -> bool:
192
  if not GEMINI_API_KEY:
193
  return False
194
  prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
195
-
196
  Return only 'true' or 'false'.
197
-
198
  Message: {user_message}"""
199
  headers = {"Content-Type": "application/json"}
200
  payload = {"contents": [{"parts": [{"text": prompt}]}]}
@@ -211,26 +245,37 @@ Message: {user_message}"""
211
  except Exception:
212
  return False
213
 
214
- # ---------------- Filters ----------------
215
  STRICT_OVERLAP = 3
216
  MAX_SENTENCES_STRICT = 4
217
  MAX_SENTENCES_CONCISE = 3
218
-
219
  PERM_QUERY_TERMS = [
220
  "permission", "permissions", "access", "access right", "authorization", "authorisation",
221
- "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied"
222
  ]
223
 
 
 
 
 
 
 
 
224
  def _normalize_for_match(text: str) -> str:
225
  t = (text or "").lower()
226
  t = re.sub(r"[^\w\s]", " ", t)
227
  t = re.sub(r"\s+", " ", t).strip()
228
  return t
229
 
 
230
  def _split_sentences(ctx: str) -> List[str]:
231
- raw_sents = re.split(r"(?<=[.!?])\s+|\n+|\-\s*|\*\s*", ctx or "")
 
 
 
232
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
233
 
 
234
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
235
  ctx = (context or "").strip()
236
  if not ctx or not query:
@@ -239,6 +284,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
239
  q_terms = [t for t in q_norm.split() if len(t) > 2]
240
  if not q_terms:
241
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
 
242
  sentences = _split_sentences(ctx)
243
  matched_exact, matched_any = [], []
244
  for s in sentences:
@@ -249,6 +295,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
249
  matched_exact.append(s)
250
  elif overlap > 0:
251
  matched_any.append(s)
 
252
  if matched_exact:
253
  kept = matched_exact[:MAX_SENTENCES_STRICT]
254
  return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
@@ -258,11 +305,12 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
258
  kept = sentences[:MAX_SENTENCES_CONCISE]
259
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
260
 
261
- # ---------------- Navigation extraction ----------------
262
  NAV_LINE_REGEX = re.compile(r"(navigate\s+to|login|log in|menu|screen)", re.IGNORECASE)
263
 
 
264
  def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
265
- lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
266
  kept: List[str] = []
267
  for ln in lines:
268
  if NAV_LINE_REGEX.search(ln) or ln.lower().startswith("log in") or ln.lower().startswith("login"):
@@ -271,15 +319,16 @@ def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
271
  break
272
  return "\n".join(kept).strip() if kept else (text or "").strip()
273
 
274
- # ---------------- Errors extraction ----------------
275
  ERROR_STARTS = (
276
  "error", "resolution", "fix", "verify", "check",
277
  "permission", "access", "authorization", "authorisation",
278
  "role", "role mapping", "security profile", "escalation", "not allowed", "not authorized", "denied"
279
  )
280
 
 
281
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
282
- lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
283
  kept: List[str] = []
284
  for ln in lines:
285
  low = ln.lower()
@@ -289,10 +338,27 @@ def _extract_errors_only(text: str, max_lines: int = 12) -> str:
289
  break
290
  return "\n".join(kept).strip() if kept else (text or "").strip()
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  @app.get("/")
293
  async def health_check():
294
  return {"status": "ok"}
295
 
 
296
  @app.post("/chat")
297
  async def chat_with_ai(input_data: ChatInput):
298
  try:
@@ -477,20 +543,22 @@ async def chat_with_ai(input_data: ChatInput):
477
  comb = combined[i] if i < len(combined) else None
478
  m = dict(meta)
479
  if score is not None: m["distance"] = score
480
- if comb is not None: m["combined"] = comb
481
  items.append({"text": text, "meta": m})
 
482
  selected = items[:max(1, 2)]
483
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
484
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
485
  context = filtered_text
486
  context_found = bool(context.strip())
 
487
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
488
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
489
  detected_intent = kb_results.get("user_intent", "neutral")
490
  best_doc = kb_results.get("best_doc")
491
  top_meta = (metadatas or [{}])[0] if metadatas else {}
492
 
493
- # Force errors intent for permissions
494
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
495
  if is_perm_query:
496
  detected_intent = "errors"
@@ -507,101 +575,101 @@ async def chat_with_ai(input_data: ChatInput):
507
  elif detected_intent == "errors":
508
  full_errors = get_best_errors_section_text(best_doc)
509
  if full_errors:
510
- context = _extract_errors_only(full_errors, max_lines=12)
511
- lines = _normalize_lines(context)
512
- if len(lines) == 1:
513
- context = _friendly_permission_reply(lines[0])
514
-
515
- # Intent shaping
516
- q = (input_data.user_message or "").lower()
517
- if detected_intent == "errors" or any(k in q for k in [
518
- "error", "issue", "fail", "not working", "resolution", "fix",
519
- "permission", "access", "authorization", "escalation", "role", "security profile",
520
- "not allowed", "not authorized", "denied"
521
- ]):
522
- context = _extract_errors_only(context, max_lines=12)
523
- lines = _normalize_lines(context)
524
- if len(lines) == 1:
525
- context = _friendly_permission_reply(lines[0])
526
- elif any(k in q for k in ["navigate", "navigation", "menu", "screen"]):
527
- context = _extract_navigation_only(context, max_lines=6)
528
- elif detected_intent == "steps":
529
- context = _ensure_numbering(context)
 
530
 
531
  # Build LLM prompt (mirror language if possible)
532
  language_hint = _detect_language_hint(input_data.user_message)
533
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
 
 
 
534
  enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
535
  - Do not add any information that is not present in the context.
536
- - If the content describes a procedure or steps, format as a strictly numbered list using plain text: 1), 2), 3), ...
537
  - If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
538
  - {lang_line}
539
-
540
  ### Context
541
  {context}
542
-
543
  ### Question
544
  {input_data.user_message}
545
-
546
  ### Output
547
  Return ONLY the rewritten guidance."""
 
548
  headers = {"Content-Type": "application/json"}
549
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
550
- try:
551
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
 
 
552
  try:
553
- result = resp.json()
 
 
 
 
 
 
 
 
 
554
  except Exception:
555
- result = {}
556
- except Exception:
557
- resp = type("RespStub", (), {"status_code": 0})()
558
- result = {}
559
- try:
560
- bot_text = result.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
561
- except Exception:
562
- bot_text = ""
563
- http_code = getattr(resp, "status_code", 0)
564
 
565
- # Fallback to local formatting
566
- if not bot_text.strip() or http_code == 429:
567
- if detected_intent == "steps":
568
- bot_text = _ensure_numbering(context)
569
- elif detected_intent == "errors":
570
  lines = _normalize_lines(context)
571
  bot_text = _friendly_permission_reply(lines[0] if lines else context)
572
- else:
573
- bot_text = context
574
-
575
- # Force numbering for steps
576
- if detected_intent == "steps":
577
- bot_text = _ensure_numbering(bot_text)
578
 
579
  # Status compute
580
  short_query = len((input_data.user_message or "").split()) <= 4
581
  gate_combined_ok = 0.60 if short_query else 0.55
582
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
583
-
584
- lower = bot_text.lower()
585
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
586
  status = "PARTIAL"
587
 
588
  options = [{"type":"yesno","title":"Share details or raise a ticket?"}] if status == "PARTIAL" else []
589
-
590
  return {
591
  "bot_response": bot_text,
592
  "status": status,
593
  "context_found": True,
594
- "ask_resolved": (status == "OK"),
595
  "suggest_incident": (status == "PARTIAL"),
596
  "followup": ("Is this helpful, or should I raise a ticket?" if status == "PARTIAL" else None),
597
  "options": options,
598
  "top_hits": [],
599
  "sources": [],
600
  "debug": {
601
- "used_chunks": len(context.split("\n\n---\n\n")) if context else 0,
602
  "best_distance": best_distance,
603
  "best_combined": best_combined,
604
- "http_status": getattr(resp, "status_code", 0),
605
  "filter_mode": filt_info.get("mode"),
606
  "matched_count": filt_info.get("matched_count"),
607
  "user_intent": detected_intent,
@@ -614,6 +682,7 @@ Return ONLY the rewritten guidance."""
614
  except Exception as e:
615
  raise HTTPException(status_code=500, detail=safe_str(e))
616
 
 
617
  def _set_incident_resolved(sys_id: str) -> bool:
618
  try:
619
  token = get_valid_token()
@@ -627,12 +696,14 @@ def _set_incident_resolved(sys_id: str) -> bool:
627
  "Content-Type": "application/json",
628
  }
629
  url = f"{instance_url}/api/now/table/incident/{sys_id}"
 
630
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
631
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
632
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
633
  resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
634
  assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
635
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
 
636
  if require_progress:
637
  try:
638
  resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
@@ -690,6 +761,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
690
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
691
  return False
692
 
 
693
  @app.post("/incident")
694
  async def raise_incident(input_data: IncidentInput):
695
  try:
@@ -714,6 +786,7 @@ async def raise_incident(input_data: IncidentInput):
714
  except Exception as e:
715
  raise HTTPException(status_code=500, detail=safe_str(e))
716
 
 
717
  @app.post("/generate_ticket_desc")
718
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
719
  try:
@@ -721,8 +794,8 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
721
  f"You are helping generate ServiceNow ticket descriptions based on the issue: {input_data.issue}.\n"
722
  "Please return the output strictly in JSON format with the following keys:\n"
723
  "{\n"
724
- ' "ShortDescription": "A concise summary of the issue (max 100 characters)",\n'
725
- ' "DetailedDescription": "A detailed explanation of the issue"\n'
726
  "}\n"
727
  "Do not include any extra text, comments, or explanations outside the JSON."
728
  )
@@ -751,6 +824,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
751
  except Exception as e:
752
  raise HTTPException(status_code=500, detail=safe_str(e))
753
 
 
754
  @app.post("/incident_status")
755
  async def incident_status(input_data: TicketStatusInput):
756
  try:
@@ -759,6 +833,7 @@ async def incident_status(input_data: TicketStatusInput):
759
  if not instance_url:
760
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
761
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
 
762
  if input_data.sys_id:
763
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
764
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
@@ -772,10 +847,12 @@ async def incident_status(input_data: TicketStatusInput):
772
  result = (lst or [{}])[0] if response.status_code == 200 else {}
773
  else:
774
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
 
775
  state_code = builtins.str(result.get("state", "unknown"))
776
  state_label = STATE_MAP.get(state_code, state_code)
777
  short = result.get("short_description", "")
778
  number = result.get("number", input_data.number or "unknown")
 
779
  return {
780
  "bot_response": (
781
  f"**Ticket:** {number} \n"
 
12
  from pydantic import BaseModel
13
  from dotenv import load_dotenv
14
  from datetime import datetime
15
+
16
  from services.kb_creation import (
17
  collection,
18
  ingest_documents,
 
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
+ # ------------------ Numbering & friendly fallbacks ------------------
106
  def _normalize_lines(text: str) -> List[str]:
107
+ """
108
+ Safe splitter used everywhere to avoid undefined local variable bugs.
109
+ Always returns a list (possibly empty), never raises.
110
+ """
111
+ raw = (text or "")
112
+ try:
113
+ return [ln.strip() for ln in raw.splitlines() if ln.strip()]
114
+ except Exception:
115
+ return [raw.strip()] if raw.strip() else []
116
+
117
 
118
  def _ensure_numbering(text: str) -> str:
119
  """
120
  Force plain-text numbering: 1) 2) 3) so it renders in any bubble.
121
+ Handles bullets (-, *, •), and "Step N" formats. Splits single-paragraph procedures.
122
  """
123
+ lines = _normalize_lines(text)
124
  if not lines:
125
  return text or ""
126
+
127
+ normalized: List[str] = []
128
  for ln in lines:
129
+ ln2 = re.sub(
130
+ r"^\s*(?:\d+[.\)\]]\s+|(?:step\s*\d+:?)\s+|[\-\*\u2022]\s+)",
131
+ "",
132
+ ln,
133
+ flags=re.IGNORECASE,
134
+ ).strip()
135
  normalized.append(ln2)
136
+
137
  if len(normalized) == 1:
138
+ para = normalized[0]
139
+ # split on ';' or '. <Upper/Digit>' boundaries
140
+ parts = [p.strip() for p in re.split(r"(?:;|\.\s+(?=[A-Z0-9]))", para) if p.strip()]
141
  if len(parts) > 1:
142
  normalized = parts
143
+
144
  return "\n".join([f"{i+1}) {ln}" for i, ln in enumerate(normalized)])
145
 
146
+
147
  def _friendly_permission_reply(raw: str) -> str:
148
  line = (raw or "").strip()
149
  line = re.sub(r"^\s*[\-\*\u2022]\s*", "", line)
 
155
  return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
156
  return line
157
 
158
+ # ------------------ Language detection (Tamil/Hindi hint) ------------------
159
  def _detect_language_hint(msg: str) -> Optional[str]:
160
  if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
161
  return "Tamil"
 
163
  return "Hindi"
164
  return None
165
 
166
+ # ------------------ Clarifying message ------------------
167
  def _build_clarifying_message() -> str:
168
  return (
169
  "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
170
  "or should I raise a ServiceNow ticket for you?"
171
  )
172
 
173
+ # ------------------ Resolution/Incident helpers ------------------
174
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
175
  issue = (issue_text or "").strip()
176
  resolved = (resolved_text or "").strip()
 
182
  ).strip()
183
  return short_desc, long_desc
184
 
185
+
186
  def _is_incident_intent(msg_norm: str) -> bool:
187
  intent_phrases = [
188
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
 
192
  ]
193
  return any(p in msg_norm for p in intent_phrases)
194
 
195
+
196
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
197
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
198
  if not any(k in msg_norm for k in status_keywords):
 
206
  return {"number": val.upper() if val.lower().startswith("inc") else val}
207
  return {"number": None, "ask_number": True}
208
 
209
+
210
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
211
  phrases = [
212
  "it is resolved", "resolved", "issue resolved", "problem resolved",
 
215
  ]
216
  return any(p in msg_norm for p in phrases)
217
 
218
+
219
  def _has_negation_resolved(msg_norm: str) -> bool:
220
  neg_phrases = [
221
  "not resolved", "issue not resolved", "still not working", "not working",
 
223
  ]
224
  return any(p in msg_norm for p in neg_phrases)
225
 
226
+
227
  def _classify_resolution_llm(user_message: str) -> bool:
228
  if not GEMINI_API_KEY:
229
  return False
230
  prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
 
231
  Return only 'true' or 'false'.
 
232
  Message: {user_message}"""
233
  headers = {"Content-Type": "application/json"}
234
  payload = {"contents": [{"parts": [{"text": prompt}]}]}
 
245
  except Exception:
246
  return False
247
 
248
+ # ------------------ Filters ------------------
249
  STRICT_OVERLAP = 3
250
  MAX_SENTENCES_STRICT = 4
251
  MAX_SENTENCES_CONCISE = 3
 
252
  PERM_QUERY_TERMS = [
253
  "permission", "permissions", "access", "access right", "authorization", "authorisation",
254
+ "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied",
255
  ]
256
 
257
+ # Permission-synonyms used to pick only permission-specific lines
258
+ PERM_SYNONYMS = (
259
+ "permission", "permissions", "access", "authorization", "authorisation",
260
+ "role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
261
+ )
262
+
263
+
264
  def _normalize_for_match(text: str) -> str:
265
  t = (text or "").lower()
266
  t = re.sub(r"[^\w\s]", " ", t)
267
  t = re.sub(r"\s+", " ", t).strip()
268
  return t
269
 
270
+
271
  def _split_sentences(ctx: str) -> List[str]:
272
+ raw_sents = re.split(
273
+ r"(?<=([.!?]))\s+|\n+|\-\s*|\*\s*",
274
+ ctx or ""
275
+ )
276
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
277
 
278
+
279
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
280
  ctx = (context or "").strip()
281
  if not ctx or not query:
 
284
  q_terms = [t for t in q_norm.split() if len(t) > 2]
285
  if not q_terms:
286
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
287
+
288
  sentences = _split_sentences(ctx)
289
  matched_exact, matched_any = [], []
290
  for s in sentences:
 
295
  matched_exact.append(s)
296
  elif overlap > 0:
297
  matched_any.append(s)
298
+
299
  if matched_exact:
300
  kept = matched_exact[:MAX_SENTENCES_STRICT]
301
  return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
 
305
  kept = sentences[:MAX_SENTENCES_CONCISE]
306
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
307
 
308
+ # ------------------ Navigation & Errors extraction ------------------
309
  NAV_LINE_REGEX = re.compile(r"(navigate\s+to|login|log in|menu|screen)", re.IGNORECASE)
310
 
311
+
312
  def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
313
+ lines = _normalize_lines(text)
314
  kept: List[str] = []
315
  for ln in lines:
316
  if NAV_LINE_REGEX.search(ln) or ln.lower().startswith("log in") or ln.lower().startswith("login"):
 
319
  break
320
  return "\n".join(kept).strip() if kept else (text or "").strip()
321
 
322
+
323
  ERROR_STARTS = (
324
  "error", "resolution", "fix", "verify", "check",
325
  "permission", "access", "authorization", "authorisation",
326
  "role", "role mapping", "security profile", "escalation", "not allowed", "not authorized", "denied"
327
  )
328
 
329
+
330
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
331
+ lines = _normalize_lines(text)
332
  kept: List[str] = []
333
  for ln in lines:
334
  low = ln.lower()
 
338
  break
339
  return "\n".join(kept).strip() if kept else (text or "").strip()
340
 
341
+
342
+ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
343
+ """
344
+ Keep only permission/access-related lines so the bot answers the exact thing user asked.
345
+ """
346
+ lines = _normalize_lines(text)
347
+ kept: List[str] = []
348
+ for ln in lines:
349
+ low = ln.lower()
350
+ if any(k in low for k in PERM_SYNONYMS):
351
+ kept.append(ln)
352
+ if len(kept) >= max_lines:
353
+ break
354
+ return "\n".join(kept).strip() if kept else (text or "").strip()
355
+
356
+ # ------------------ Health ------------------
357
  @app.get("/")
358
  async def health_check():
359
  return {"status": "ok"}
360
 
361
+ # ------------------ Chat ------------------
362
  @app.post("/chat")
363
  async def chat_with_ai(input_data: ChatInput):
364
  try:
 
543
  comb = combined[i] if i < len(combined) else None
544
  m = dict(meta)
545
  if score is not None: m["distance"] = score
546
+ if comb is not None: m["combined"] = comb
547
  items.append({"text": text, "meta": m})
548
+
549
  selected = items[:max(1, 2)]
550
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
551
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
552
  context = filtered_text
553
  context_found = bool(context.strip())
554
+
555
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
556
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
557
  detected_intent = kb_results.get("user_intent", "neutral")
558
  best_doc = kb_results.get("best_doc")
559
  top_meta = (metadatas or [{}])[0] if metadatas else {}
560
 
561
+ # Force errors intent for permissions (and demote steps entirely)
562
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
563
  if is_perm_query:
564
  detected_intent = "errors"
 
575
  elif detected_intent == "errors":
576
  full_errors = get_best_errors_section_text(best_doc)
577
  if full_errors:
578
+ # Extract only error lines, then narrow to permission-only if the query is permission-related
579
+ ctx_err = _extract_errors_only(full_errors, max_lines=12)
580
+ context = _filter_permission_lines(ctx_err, max_lines=6) if is_perm_query else ctx_err
581
+ lines = _normalize_lines(context)
582
+ if len(lines) == 1:
583
+ context = _friendly_permission_reply(lines[0])
584
+
585
+ # If we STILL have no context for an errors/permission query, return clarifying + ticket option
586
+ if detected_intent == "errors" and not (context or "").strip():
587
+ return {
588
+ "bot_response": _build_clarifying_message(),
589
+ "status": "NO_KB_MATCH",
590
+ "context_found": False,
591
+ "ask_resolved": False,
592
+ "suggest_incident": True,
593
+ "followup": "Share more details or say 'create ticket'.",
594
+ "options": [{"type":"yesno","title":"Share details or raise a ticket?"}],
595
+ "top_hits": [],
596
+ "sources": [],
597
+ "debug": {"intent": "errors_no_context"},
598
+ }
599
 
600
  # Build LLM prompt (mirror language if possible)
601
  language_hint = _detect_language_hint(input_data.user_message)
602
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
603
+
604
+ # Safer: skip Gemini for steps to avoid altering procedure; use Gemini only for errors/permissions.
605
+ use_gemini = (detected_intent == "errors")
606
  enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
607
  - Do not add any information that is not present in the context.
 
608
  - If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
609
  - {lang_line}
 
610
  ### Context
611
  {context}
 
612
  ### Question
613
  {input_data.user_message}
 
614
  ### Output
615
  Return ONLY the rewritten guidance."""
616
+
617
  headers = {"Content-Type": "application/json"}
618
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
619
+
620
+ bot_text = ""
621
+ http_code = 0
622
+ if use_gemini:
623
  try:
624
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
625
+ http_code = getattr(resp, "status_code", 0)
626
+ try:
627
+ result = resp.json()
628
+ except Exception:
629
+ result = {}
630
+ try:
631
+ bot_text = result.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
632
+ except Exception:
633
+ bot_text = ""
634
  except Exception:
635
+ bot_text, http_code = "", 0
 
 
 
 
 
 
 
 
636
 
637
+ # Deterministic local formatting
638
+ if detected_intent == "steps":
639
+ bot_text = _ensure_numbering(context)
640
+ elif detected_intent == "errors":
641
+ if not bot_text.strip() or http_code == 429:
642
  lines = _normalize_lines(context)
643
  bot_text = _friendly_permission_reply(lines[0] if lines else context)
644
+ # Append standard escalation line for errors/permission messages
645
+ bot_text = (bot_text or "").rstrip() + "\n\nIf you want to escalate the issue, reach out to Operator – Inventory Analyst."
646
+ else:
647
+ bot_text = context
 
 
648
 
649
  # Status compute
650
  short_query = len((input_data.user_message or "").split()) <= 4
651
  gate_combined_ok = 0.60 if short_query else 0.55
652
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
653
+ lower = (bot_text or "").lower()
 
654
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
655
  status = "PARTIAL"
656
 
657
  options = [{"type":"yesno","title":"Share details or raise a ticket?"}] if status == "PARTIAL" else []
 
658
  return {
659
  "bot_response": bot_text,
660
  "status": status,
661
  "context_found": True,
662
+ "ask_resolved": (status == "OK" and detected_intent != "errors"), # don't ask resolved for error explanations
663
  "suggest_incident": (status == "PARTIAL"),
664
  "followup": ("Is this helpful, or should I raise a ticket?" if status == "PARTIAL" else None),
665
  "options": options,
666
  "top_hits": [],
667
  "sources": [],
668
  "debug": {
669
+ "used_chunks": len((context or "").split("\n\n---\n\n")) if context else 0,
670
  "best_distance": best_distance,
671
  "best_combined": best_combined,
672
+ "http_status": http_code,
673
  "filter_mode": filt_info.get("mode"),
674
  "matched_count": filt_info.get("matched_count"),
675
  "user_intent": detected_intent,
 
682
  except Exception as e:
683
  raise HTTPException(status_code=500, detail=safe_str(e))
684
 
685
+
686
  def _set_incident_resolved(sys_id: str) -> bool:
687
  try:
688
  token = get_valid_token()
 
696
  "Content-Type": "application/json",
697
  }
698
  url = f"{instance_url}/api/now/table/incident/{sys_id}"
699
+
700
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
701
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
702
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
703
  resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
704
  assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
705
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
706
+
707
  if require_progress:
708
  try:
709
  resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
 
761
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
762
  return False
763
 
764
+ # ------------------ Incident creation ------------------
765
  @app.post("/incident")
766
  async def raise_incident(input_data: IncidentInput):
767
  try:
 
786
  except Exception as e:
787
  raise HTTPException(status_code=500, detail=safe_str(e))
788
 
789
+ # ------------------ Ticket description generation ------------------
790
  @app.post("/generate_ticket_desc")
791
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
792
  try:
 
794
  f"You are helping generate ServiceNow ticket descriptions based on the issue: {input_data.issue}.\n"
795
  "Please return the output strictly in JSON format with the following keys:\n"
796
  "{\n"
797
+ ' "ShortDescription": "A concise summary of the issue (max 100 characters)",\n'
798
+ ' "DetailedDescription": "A detailed explanation of the issue"\n'
799
  "}\n"
800
  "Do not include any extra text, comments, or explanations outside the JSON."
801
  )
 
824
  except Exception as e:
825
  raise HTTPException(status_code=500, detail=safe_str(e))
826
 
827
+ # ------------------ Incident status ------------------
828
  @app.post("/incident_status")
829
  async def incident_status(input_data: TicketStatusInput):
830
  try:
 
833
  if not instance_url:
834
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
835
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
836
+
837
  if input_data.sys_id:
838
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
839
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
 
847
  result = (lst or [{}])[0] if response.status_code == 200 else {}
848
  else:
849
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
850
+
851
  state_code = builtins.str(result.get("state", "unknown"))
852
  state_label = STATE_MAP.get(state_code, state_code)
853
  short = result.get("short_description", "")
854
  number = result.get("number", input_data.number or "unknown")
855
+
856
  return {
857
  "bot_response": (
858
  f"**Ticket:** {number} \n"