srilakshu012456 commited on
Commit
eea08ad
·
verified ·
1 Parent(s): 3fefb15

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +385 -423
main.py CHANGED
@@ -1,5 +1,5 @@
1
 
2
- # main_hugging_phase_recent.py
3
 
4
  import os
5
  import json
@@ -8,12 +8,14 @@ import requests
8
  import builtins
9
  from typing import Optional, Any, Dict, List, Tuple
10
  from contextlib import asynccontextmanager
 
 
11
  from fastapi import FastAPI, HTTPException
12
  from fastapi.middleware.cors import CORSMiddleware
13
  from pydantic import BaseModel
14
  from dotenv import load_dotenv
15
- from datetime import datetime
16
 
 
17
  from services.kb_creation import (
18
  collection,
19
  ingest_documents,
@@ -21,13 +23,26 @@ from services.kb_creation import (
21
  get_section_text,
22
  get_best_steps_section_text,
23
  get_best_errors_section_text,
24
- get_escalation_text, # for escalation heading
25
  )
 
 
26
  from services.login import router as login_router
27
  from services.generate_ticket import get_valid_token, create_incident
28
 
 
 
 
 
 
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:
@@ -37,10 +52,9 @@ def safe_str(e: Any) -> str:
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:
@@ -58,7 +72,11 @@ async def lifespan(app: FastAPI):
58
  app = FastAPI(lifespan=lifespan)
59
  app.include_router(login_router)
60
 
61
- origins = ["https://chatbotnova-chatbot-frontend.hf.space"]
 
 
 
 
62
  app.add_middleware(
63
  CORSMiddleware,
64
  allow_origins=origins,
@@ -67,7 +85,10 @@ app.add_middleware(
67
  allow_headers=["*"],
68
  )
69
 
70
- # ------------------------------ Models ------------------------------
 
 
 
71
  class ChatInput(BaseModel):
72
  user_message: str
73
  prev_status: Optional[str] = None
@@ -98,16 +119,12 @@ STATE_MAP = {
98
  "8": "Canceled",
99
  }
100
 
101
- GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
102
- GEMINI_URL = (
103
- f"https://generativelanguage.googleapis.com/v1beta/models/"
104
- f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
105
- )
106
 
107
- # ------------------------------ Helpers ------------------------------
 
 
108
  NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
109
 
110
- # Domain status terms (generic WMS domain words)
111
  DOMAIN_STATUS_TERMS = (
112
  "shipment", "order", "load", "trailer", "wave",
113
  "inventory", "putaway", "receiving", "appointment",
@@ -115,7 +132,6 @@ DOMAIN_STATUS_TERMS = (
115
  "asn", "grn", "pick", "picking"
116
  )
117
 
118
- # --- Generic error families (SOP-wide, reusable in gating and line selection) ---
119
  ERROR_FAMILY_SYNS = {
120
  "NOT_FOUND": (
121
  "not found", "missing", "does not exist", "doesn't exist",
@@ -127,7 +143,8 @@ ERROR_FAMILY_SYNS = {
127
  "difference", "discrepancy", "not equal"
128
  ),
129
  "LOCKED": (
130
- "locked", "status locked", "blocked", "read only", "read-only", "frozen", "freeze"
 
131
  ),
132
  "PERMISSION": (
133
  "permission", "permissions", "access denied", "not authorized",
@@ -146,7 +163,6 @@ ERROR_FAMILY_SYNS = {
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()
@@ -181,30 +197,28 @@ def _ensure_numbering(text: str) -> str:
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(),
@@ -212,20 +226,22 @@ def _ensure_numbering(text: str) -> str:
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)
@@ -237,19 +253,21 @@ def _split_sop_into_steps(numbered_text: str) -> list:
237
  """
238
  Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
239
  into atomic steps. Returns a list of raw step strings (order preserved).
240
- Safe for circled digits, '1.' styles, and bullets.
241
  """
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())
254
  tb = set(_norm_text(b).split())
255
  if not ta or not tb:
@@ -313,175 +331,6 @@ def _format_steps_as_numbered(steps: list) -> str:
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.
319
- Generic (SOP-agnostic) scoring:
320
- 1) error-family match (NOT_FOUND/MISMATCH/LOCKED/PERMISSION/TIMEOUT/SYNC),
321
- 2) anchored starts (line begins with error heading),
322
- 3) multi-word overlap (bigrams/trigrams),
323
- 4) token overlap,
324
- 5) formatting bonus for bullets/headings.
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)
331
- s = re.sub(r"\s+", " ", s).strip()
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)
339
- fams = set()
340
- for fam, syns in ERROR_FAMILY_SYNS.items():
341
- if any(k in low for k in syns):
342
- fams.add(fam)
343
- return fams
344
-
345
- q = _norm(query)
346
- q_tokens = [t for t in q.split() if len(t) > 1]
347
- q_bi = _ngrams(q_tokens, 2)
348
- q_tri = _ngrams(q_tokens, 3)
349
- q_fams = _families_for(query)
350
-
351
- lines = _normalize_lines(text)
352
- if not lines:
353
- return (text or "").strip()
354
-
355
- scored: List[Tuple[float, str]] = []
356
- for ln in lines:
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 ""
364
- if (first3 and ln_norm.startswith(first3)) or (first2 and ln_norm.startswith(first2)):
365
- anchored = 1.0
366
-
367
- bigram_hits = sum(1 for bg in q_bi if bg and bg in ln_norm)
368
- trigram_hits = sum(1 for tg in q_tri if tg and tg in ln_norm)
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 +
375
- 0.80 * trigram_hits +
376
- 0.55 * bigram_hits +
377
- 0.40 * exact_phrase +
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)):
385
- score += 0.15
386
-
387
- scored.append((score, ln))
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():
402
- return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
403
- if ("permission" in line.lower()) or ("access" in line.lower()) or ("authorization" 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"
411
- if re.search(r"[\u0900-\u097F]", msg or ""): # Hindi
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()
425
- resolved = (resolved_text or "").strip()
426
- short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
427
- long_desc = (
428
- f"User reported: \"{issue}\". "
429
- f"User confirmation: \"{resolved}\". "
430
- f"Tracking record created automatically by NOVA."
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",
438
- "create incident", "create an incident", "raise incident", "raise an incident", "open incident", "open an incident",
439
- "log ticket", "log an incident", "generate ticket", "create snow ticket", "raise snow ticket",
440
- "raise service now ticket", "create service now ticket", "raise sr", "open sr",
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)
448
- has_ticket_marker = (
449
- any(w in msg_norm for w in ("ticket", "incident", "servicenow", "snow")) or
450
- bool(re.search(r"\binc\d{5,}\b", msg_norm, flags=re.IGNORECASE))
451
- )
452
- # Disambiguation: if it's a domain status query and not clearly ticket/incident, do NOT route to ticket-status.
453
- if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
454
- return {}
455
- patterns = [
456
- r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
457
- r"(inc\d+)"
458
- ]
459
- for pat in patterns:
460
- m = re.search(pat, msg_norm, flags=re.IGNORECASE)
461
- if m:
462
- val = m.group(1).strip()
463
- if val:
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",
471
- "it's working", "working now", "works now", "fixed", "sorted",
472
- "ok now", "fine now", "all good", "all set", "thanks works", "thank you it works", "back to normal",
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",
480
- "didn't work", "doesn't work", "no change", "not fixed", "still failing", "failed again", "broken", "fail",
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
@@ -494,16 +343,18 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
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()
501
  if not ctx or not query:
502
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
 
503
  q_norm = _norm(query)
504
  q_terms = [t for t in q_norm.split() if len(t) > 2]
505
  if not q_terms:
506
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
 
507
  sentences = _split_sentences(ctx)
508
  matched_exact, matched_any = [], []
509
  for s in sentences:
@@ -514,28 +365,25 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
514
  matched_exact.append(s)
515
  elif overlap > 0:
516
  matched_any.append(s)
 
517
  if matched_exact:
518
  kept = matched_exact[:MAX_SENTENCES_STRICT]
519
  return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
520
  if matched_any:
521
  kept = matched_any[:MAX_SENTENCES_CONCISE]
522
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)}
 
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
 
@@ -560,13 +408,15 @@ def _extract_escalation_line(text: str) -> Optional[str]:
560
  lines = _normalize_lines(text)
561
  if not lines:
562
  return None
 
563
  start_idx = None
564
  for i, ln in enumerate(lines):
565
  low = ln.lower()
566
  if "escalation" in low or "escalation path" in low or "escalate" in low:
567
  start_idx = i
568
  break
569
- block = []
 
570
  if start_idx is not None:
571
  for j in range(start_idx, min(len(lines), start_idx + 6)):
572
  if not lines[j].strip():
@@ -574,11 +424,14 @@ def _extract_escalation_line(text: str) -> Optional[str]:
574
  block.append(lines[j].strip())
575
  else:
576
  block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
 
577
  if not block:
578
  return None
 
579
  text_block = " ".join(block)
580
  m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
581
  path = m.group(1).strip() if m else None
 
582
  if not path:
583
  arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
584
  if arrow_lines:
@@ -588,120 +441,92 @@ def _extract_escalation_line(text: str) -> Optional[str]:
588
  path = m2.group(1).strip() if m2 else None
589
  if not path:
590
  return None
 
591
  path = path.replace("->", "→").strip()
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
599
- prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
600
- Return only 'true' or 'false'.
601
- Message: {user_message}"""
602
- headers = {"Content-Type": "application/json"}
603
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
604
- try:
605
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
606
- data = resp.json()
607
- text = (
608
- data.get("candidates", [{}])[0]
609
- .get("content", {})
610
- .get("parts", [{}])[0]
611
- .get("text", "")
612
- )
613
- return "true" in (text or "").strip().lower()
614
- except Exception:
615
- return False
616
 
617
 
618
- def _set_incident_resolved(sys_id: str) -> bool:
619
- try:
620
- token = get_valid_token()
621
- instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
622
- if not instance_url:
623
- print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
624
- return False
625
- headers = {
626
- "Authorization": f"Bearer {token}",
627
- "Accept": "application/json",
628
- "Content-Type": "application/json",
629
- }
630
- url = f"{instance_url}/api/now/table/incident/{sys_id}"
631
- close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
632
- close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
633
- caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
634
- resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
635
- assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
636
- require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
637
- if require_progress:
638
- try:
639
- resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
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,
650
- "close_notes": close_notes_val,
651
- "caller_id": caller_sysid,
652
- "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
653
- "work_notes": "Auto-resolve set by NOVA.",
654
- "resolved_by": resolved_by_sysid,
655
- "assignment_group": assign_group,
656
- })
657
- respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
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,
665
- "close_notes": close_notes_val,
666
- "caller_id": caller_sysid,
667
- "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
668
- "work_notes": "Auto-resolve set by NOVA.",
669
- "resolved_by": resolved_by_sysid,
670
- "assignment_group": assign_group,
671
- })
672
- respB = requests.patch(url, headers=headers, json=payload_B, verify=VERIFY_SSL, timeout=25)
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({
680
- "state": "6",
681
- code_field: close_code_val,
682
- notes_field: close_notes_val,
683
- "caller_id": caller_sysid,
684
- "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
685
- "work_notes": "Auto-resolve set by NOVA.",
686
- "resolved_by": resolved_by_sysid,
687
- "assignment_group": assign_group,
688
- })
689
- respC = requests.patch(url, headers=headers, json=payload_C, verify=VERIFY_SSL, timeout=25)
690
- if respC.status_code in (200, 204):
691
- return True
692
- print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
693
- return False
694
- except Exception as e:
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
- """
702
- Return the prerequisites section text, trying common heading variants.
703
- Generic for future SOPs—no document-specific keywords.
704
- """
705
  variants = [
706
  "Pre-Requisites",
707
  "Prerequisites",
@@ -716,13 +541,17 @@ def _find_prereq_section_text(best_doc: str) -> str:
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):
728
  assist_followup: Optional[str] = None
@@ -752,6 +581,7 @@ async def chat_with_ai(input_data: ChatInput):
752
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
753
  if _has_negation_resolved(msg_norm):
754
  is_llm_resolved = False
 
755
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
756
  try:
757
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
@@ -846,6 +676,7 @@ async def chat_with_ai(input_data: ChatInput):
846
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
847
  if not instance_url:
848
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
 
849
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
850
  number = status_intent.get("number")
851
  url = f"{instance_url}/api/now/table/incident?number={number}"
@@ -853,10 +684,12 @@ async def chat_with_ai(input_data: ChatInput):
853
  data = response.json()
854
  lst = data.get("result", [])
855
  result = (lst or [{}])[0] if response.status_code == 200 else {}
 
856
  state_code = builtins.str(result.get("state", "unknown"))
857
  state_label = STATE_MAP.get(state_code, state_code)
858
  short = result.get("short_description", "")
859
  num = result.get("number", number or "unknown")
 
860
  return {
861
  "bot_response": (
862
  f"**Ticket:** {num}\n"
@@ -876,12 +709,15 @@ async def chat_with_ai(input_data: ChatInput):
876
  except Exception as e:
877
  raise HTTPException(status_code=500, detail=safe_str(e))
878
 
 
879
  # Hybrid KB search
 
880
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
881
  documents = kb_results.get("documents", [])
882
  metadatas = kb_results.get("metadatas", [])
883
  distances = kb_results.get("distances", [])
884
  combined = kb_results.get("combined_scores", [])
 
885
  items: List[Dict[str, Any]] = []
886
  for i, doc in enumerate(documents):
887
  text = doc.strip() if isinstance(doc, str) else ""
@@ -899,11 +735,18 @@ async def chat_with_ai(input_data: ChatInput):
899
 
900
  selected = items[:max(1, 2)]
901
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
 
902
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
903
  context = filtered_text
904
  context_found = bool(context.strip())
905
- best_distance = min([d for d in distances if d is not None], default=None) if distances else None
906
- best_combined = max([c for c in combined if c is not None], default=None) if combined else None
 
 
 
 
 
 
907
  detected_intent = kb_results.get("user_intent", "neutral")
908
  best_doc = kb_results.get("best_doc")
909
  top_meta = (metadatas or [{}])[0] if metadatas else {}
@@ -920,10 +763,11 @@ async def chat_with_ai(input_data: ChatInput):
920
  if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
921
  detected_intent = "prereqs"
922
 
923
- # Force errors intent for permissions
924
  PERM_QUERY_TERMS = [
925
  "permission", "permissions", "access", "access right", "authorization", "authorisation",
926
- "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied",
 
927
  ]
928
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
929
  if is_perm_query:
@@ -938,24 +782,14 @@ async def chat_with_ai(input_data: ChatInput):
938
  if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
939
  detected_intent = "prereqs"
940
 
941
- # --- Steps nudge: "how to / perform" + receiving/inbound => steps intent
942
- STEPS_TERMS = ("how to", "procedure", "perform", "steps", "do", "navigate")
943
- RECEIVING_TERMS = ("inbound", "receiving", "goods receipt", "grn")
944
-
945
- mod_tags = ((top_meta or {}).get("module_tags") or "").lower()
946
-
947
- looks_like_steps_query = any(t in msg_low for t in STEPS_TERMS)
948
- looks_like_receiving = (
949
- any(t in msg_low for t in RECEIVING_TERMS)
950
- or "receiving" in mod_tags
951
- or "inbound" in sec_title
952
- or "receiving" in sec_title
953
- )
954
-
955
- if detected_intent in ("neutral", "prereqs") and looks_like_steps_query and looks_like_receiving:
956
- detected_intent = "steps"
957
 
958
- # --- Meaning-aware SOP gating ---
959
  def _contains_any(s: str, keywords: tuple) -> bool:
960
  low = (s or "").lower()
961
  return any(k in low for k in keywords)
@@ -967,9 +801,9 @@ async def chat_with_ai(input_data: ChatInput):
967
  "asn", "grn", "pick", "picking"
968
  )
969
  ACTION_OR_ERROR_TERMS = (
970
- "how to", "procedure", "perform", # added
971
  "close", "closing", "open", "navigate", "scan", "confirm", "generate", "update",
972
- "receive", "receiving", # added
973
  "error", "issue", "fail", "failed", "not working", "locked", "mismatch",
974
  "access", "permission", "status"
975
  )
@@ -982,17 +816,15 @@ async def chat_with_ai(input_data: ChatInput):
982
  short_query = len((input_data.user_message or "").split()) <= 4
983
  gate_combined_ok = 0.60 if short_query else 0.55
984
  combined_ok = (best_combined is not None and best_combined >= gate_combined_ok)
985
-
986
  weak_domain_only = (mentions_domain and not has_any_action_or_error)
987
  low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
988
 
989
- # Bypass gate when strong steps signals are present for Receiving module
990
- strong_steps_bypass = looks_like_steps_query and looks_like_receiving
991
  strong_error_signal = len(_detect_error_families(msg_low)) > 0
992
- if (weak_domain_only or (low_context_hit and not combined_ok)) \
993
- and not strong_steps_bypass \
994
- and not (strong_error_signal or generic_error_signal):
995
 
 
 
 
996
  return {
997
  "bot_response": _build_clarifying_message(),
998
  "status": "NO_KB_MATCH",
@@ -1012,79 +844,89 @@ async def chat_with_ai(input_data: ChatInput):
1012
  "has_any_action_or_error": has_any_action_or_error,
1013
  "strong_steps_bypass": strong_steps_bypass,
1014
  "strong_error_signal": strong_error_signal,
1015
- "generic_error_signal": generic_error_signal
1016
  },
1017
  }
1018
 
1019
  # Build SOP context if allowed
1020
- if is_perm_query:
1021
- detected_intent = "errors"
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":
1081
- full_prereqs = _find_prereq_section_text(best_doc)
1082
- if full_prereqs:
1083
- context = full_prereqs.strip()
1084
 
 
 
 
 
 
1085
  language_hint = _detect_language_hint(input_data.user_message)
1086
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
1087
  use_gemini = (detected_intent == "errors")
 
1088
  enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
1089
  - Do not add any information that is not present in the context.
1090
  - If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
@@ -1095,10 +937,12 @@ async def chat_with_ai(input_data: ChatInput):
1095
  {input_data.user_message}
1096
  ### Output
1097
  Return ONLY the rewritten guidance."""
 
1098
  headers = {"Content-Type": "application/json"}
1099
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
1100
  bot_text = ""
1101
  http_code = 0
 
1102
  if use_gemini and GEMINI_API_KEY:
1103
  try:
1104
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
@@ -1116,23 +960,19 @@ Return ONLY the rewritten guidance."""
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
 
1135
- # If the user explicitly asks to escalate, append escalation even in 'steps' intent
1136
  needs_escalation = (" escalate" in msg_norm) or ("escalation" in msg_norm)
1137
  if needs_escalation and best_doc:
1138
  esc_text = get_escalation_text(best_doc)
@@ -1142,7 +982,7 @@ Return ONLY the rewritten guidance."""
1142
  if line:
1143
  bot_text = (bot_text or "").rstrip() + "\n\n" + line
1144
 
1145
- # Guarantee non-empty bot response
1146
  if not (bot_text or "").strip():
1147
  if context.strip():
1148
  bot_text = context.strip()
@@ -1155,9 +995,11 @@ Return ONLY the rewritten guidance."""
1155
  short_query = len((input_data.user_message or "").split()) <= 4
1156
  gate_combined_ok = 0.60 if short_query else 0.55
1157
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
 
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 {
@@ -1175,23 +1017,23 @@ Return ONLY the rewritten guidance."""
1175
  "best_distance": best_distance,
1176
  "best_combined": best_combined,
1177
  "http_status": http_code,
1178
- "filter_mode": filt_info.get("mode"),
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:
1189
  raise
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):
1197
  try:
@@ -1211,13 +1053,16 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
1211
  data = resp.json()
1212
  except Exception:
1213
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
 
1214
  try:
1215
  text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
1216
  except Exception:
1217
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
 
1218
  if text.startswith("```"):
1219
  lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
1220
  text = "\n".join(lines).strip()
 
1221
  try:
1222
  ticket_json = json.loads(text)
1223
  return {
@@ -1226,11 +1071,14 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
1226
  }
1227
  except Exception:
1228
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
 
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):
1236
  try:
@@ -1238,7 +1086,9 @@ async def incident_status(input_data: TicketStatusInput):
1238
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
1239
  if not instance_url:
1240
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
 
1241
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
 
1242
  if input_data.sys_id:
1243
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
1244
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
@@ -1252,10 +1102,12 @@ async def incident_status(input_data: TicketStatusInput):
1252
  result = (lst or [{}])[0] if response.status_code == 200 else {}
1253
  else:
1254
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
 
1255
  state_code = builtins.str(result.get("state", "unknown"))
1256
  state_label = STATE_MAP.get(state_code, state_code)
1257
  short = result.get("short_description", "")
1258
  number = result.get("number", input_data.number or "unknown")
 
1259
  return {
1260
  "bot_response": (
1261
  f"**Ticket:** {number} \n"
@@ -1267,11 +1119,121 @@ async def incident_status(input_data: TicketStatusInput):
1267
  "persist": True,
1268
  "debug": "Incident status fetched",
1269
  }
 
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):
1277
  try:
 
1
 
2
+ # main.py (FastAPI runtime) — CLEAN VERSION
3
 
4
  import os
5
  import json
 
8
  import builtins
9
  from typing import Optional, Any, Dict, List, Tuple
10
  from contextlib import asynccontextmanager
11
+ from datetime import datetime
12
+
13
  from fastapi import FastAPI, HTTPException
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from pydantic import BaseModel
16
  from dotenv import load_dotenv
 
17
 
18
+ # Import KB services
19
  from services.kb_creation import (
20
  collection,
21
  ingest_documents,
 
23
  get_section_text,
24
  get_best_steps_section_text,
25
  get_best_errors_section_text,
26
+ get_escalation_text,
27
  )
28
+
29
+ # ServiceNow helpers
30
  from services.login import router as login_router
31
  from services.generate_ticket import get_valid_token, create_incident
32
 
33
+
34
+ # ------------------------------------------------------------------------------
35
+ # Environment
36
+ # ------------------------------------------------------------------------------
37
+ load_dotenv()
38
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
39
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
40
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
41
+ GEMINI_URL = (
42
+ "https://generativelanguage.googleapis.com/v1beta/models/"
43
+ f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
44
+ )
45
+ os.environ["POSTHOG_DISABLED"] = "true"
46
 
47
 
48
  def safe_str(e: Any) -> str:
 
52
  return "<error stringify failed>"
53
 
54
 
55
+ # ------------------------------------------------------------------------------
56
+ # App / Lifespan
57
+ # ------------------------------------------------------------------------------
 
58
  @asynccontextmanager
59
  async def lifespan(app: FastAPI):
60
  try:
 
72
  app = FastAPI(lifespan=lifespan)
73
  app.include_router(login_router)
74
 
75
+ # Allow your HF Space frontend; add localhost for local dev if needed
76
+ origins = [
77
+ "https://chatbotnova-chatbot-frontend.hf.space",
78
+ # "http://localhost:5173", # uncomment for local dev (Vite)
79
+ ]
80
  app.add_middleware(
81
  CORSMiddleware,
82
  allow_origins=origins,
 
85
  allow_headers=["*"],
86
  )
87
 
88
+
89
+ # ------------------------------------------------------------------------------
90
+ # Models
91
+ # ------------------------------------------------------------------------------
92
  class ChatInput(BaseModel):
93
  user_message: str
94
  prev_status: Optional[str] = None
 
119
  "8": "Canceled",
120
  }
121
 
 
 
 
 
 
122
 
123
+ # ------------------------------------------------------------------------------
124
+ # Generic helpers
125
+ # ------------------------------------------------------------------------------
126
  NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
127
 
 
128
  DOMAIN_STATUS_TERMS = (
129
  "shipment", "order", "load", "trailer", "wave",
130
  "inventory", "putaway", "receiving", "appointment",
 
132
  "asn", "grn", "pick", "picking"
133
  )
134
 
 
135
  ERROR_FAMILY_SYNS = {
136
  "NOT_FOUND": (
137
  "not found", "missing", "does not exist", "doesn't exist",
 
143
  "difference", "discrepancy", "not equal"
144
  ),
145
  "LOCKED": (
146
+ "locked", "status locked", "blocked", "read only", "read-only",
147
+ "frozen", "freeze"
148
  ),
149
  "PERMISSION": (
150
  "permission", "permissions", "access denied", "not authorized",
 
163
 
164
 
165
  def _detect_error_families(msg: str) -> list:
 
166
  low = (msg or "").lower()
167
  low_norm = re.sub(r"[^\w\s]", " ", low)
168
  low_norm = re.sub(r"\s+", " ", low_norm).strip()
 
197
  if not lines:
198
  return text or ""
199
 
 
200
  para = " ".join(lines).strip()
201
  if not para:
202
  return ""
203
 
204
+ # Hard breaks at step boundaries
205
+ para_clean = re.sub(r"(?:\b\d+\s*[.\)])\s+", "\n\n\n", para) # 1. / 1)
206
+ para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
207
+ para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean) # Step 1:
208
+
209
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
210
 
 
211
  if len(segments) < 2:
212
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
213
+ segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+", para) if seg.strip()]
214
 
 
215
  def strip_prefix_any(s: str) -> str:
216
  return re.sub(
217
  r"^\s*(?:"
218
+ r"(?:\d+\s*[.\)])" # leading numbers 1., 2)
219
+ r"|(?:step\s*\d+:?)" # Step 1:
220
+ r"|(?:[-*\u2022])" # bullets
221
+ r"|(?:[\u2460-\u2473])" # circled digits
222
  r")\s*",
223
  "",
224
  (s or "").strip(),
 
226
  )
227
 
228
  clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
229
+
230
  circled = {
231
  1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
232
  6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
233
  11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
234
  16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
235
  }
236
+
237
  out = []
238
  for idx, seg in enumerate(clean_segments, start=1):
239
  marker = circled.get(idx, f"{idx})")
240
  out.append(f"{marker} {seg}")
241
+
242
  return "\n".join(out)
243
 
244
 
 
245
  def _norm_text(s: str) -> str:
246
  s = (s or "").lower()
247
  s = re.sub(r"[^\w\s]", " ", s)
 
253
  """
254
  Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
255
  into atomic steps. Returns a list of raw step strings (order preserved).
 
256
  """
257
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
258
  steps = []
259
  for ln in lines:
260
+ cleaned = re.sub(
261
+ r"^\s*(?:[\u2460-\u2473]|\d+[.\)]|[-*•])\s*",
262
+ "",
263
+ ln
264
+ )
265
  if cleaned:
266
  steps.append(cleaned)
267
  return steps
268
 
269
 
270
  def _soft_match_score(a: str, b: str) -> float:
 
271
  ta = set(_norm_text(a).split())
272
  tb = set(_norm_text(b).split())
273
  if not ta or not tb:
 
331
  return "\n".join(out)
332
 
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
335
  STRICT_OVERLAP = 3
336
  MAX_SENTENCES_STRICT = 4
 
343
  return t
344
 
345
  def _split_sentences(ctx: str) -> List[str]:
346
+ raw_sents = re.split(r"(?<=[.!?])\s+", ctx or "")
347
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
348
 
349
  ctx = (context or "").strip()
350
  if not ctx or not query:
351
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
352
+
353
  q_norm = _norm(query)
354
  q_terms = [t for t in q_norm.split() if len(t) > 2]
355
  if not q_terms:
356
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
357
+
358
  sentences = _split_sentences(ctx)
359
  matched_exact, matched_any = [], []
360
  for s in sentences:
 
365
  matched_exact.append(s)
366
  elif overlap > 0:
367
  matched_any.append(s)
368
+
369
  if matched_exact:
370
  kept = matched_exact[:MAX_SENTENCES_STRICT]
371
  return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
372
  if matched_any:
373
  kept = matched_any[:MAX_SENTENCES_CONCISE]
374
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)}
375
+
376
  kept = sentences[:MAX_SENTENCES_CONCISE]
377
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
378
 
379
 
380
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
 
 
 
 
 
381
  kept: List[str] = []
382
  for ln in _normalize_lines(text):
383
+ if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln):
384
  kept.append(ln)
385
+ if len(kept) >= max_lines:
386
+ break
387
  return "\n".join(kept).strip() if kept else (text or "").strip()
388
 
389
 
 
408
  lines = _normalize_lines(text)
409
  if not lines:
410
  return None
411
+
412
  start_idx = None
413
  for i, ln in enumerate(lines):
414
  low = ln.lower()
415
  if "escalation" in low or "escalation path" in low or "escalate" in low:
416
  start_idx = i
417
  break
418
+
419
+ block: List[str] = []
420
  if start_idx is not None:
421
  for j in range(start_idx, min(len(lines), start_idx + 6)):
422
  if not lines[j].strip():
 
424
  block.append(lines[j].strip())
425
  else:
426
  block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
427
+
428
  if not block:
429
  return None
430
+
431
  text_block = " ".join(block)
432
  m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
433
  path = m.group(1).strip() if m else None
434
+
435
  if not path:
436
  arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
437
  if arrow_lines:
 
441
  path = m2.group(1).strip() if m2 else None
442
  if not path:
443
  return None
444
+
445
  path = path.replace("->", "→").strip()
446
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
447
  return f"If you want to escalate the issue, follow: {path}"
448
 
449
 
450
+ def _detect_language_hint(msg: str) -> Optional[str]:
451
+ if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
452
+ return "Tamil"
453
+ if re.search(r"[\u0900-\u097F]", msg or ""): # Hindi
454
+ return "Hindi"
455
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
 
457
 
458
+ def _build_clarifying_message() -> str:
459
+ return (
460
+ "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
461
+ "or should I raise a ServiceNow ticket for you?"
462
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
 
 
 
464
 
465
+ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
466
+ issue = (issue_text or "").strip()
467
+ resolved = (resolved_text or "").strip()
468
+ short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
469
+ long_desc = (
470
+ f'User reported: "{issue}". '
471
+ f'User confirmation: "{resolved}". '
472
+ f"Tracking record created automatically by NOVA."
473
+ ).strip()
474
+ return short_desc, long_desc
 
 
 
 
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
+ def _is_incident_intent(msg_norm: str) -> bool:
478
+ intent_phrases = [
479
+ "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
480
+ "create incident", "create an incident", "raise incident", "raise an incident", "open incident", "open an incident",
481
+ "log ticket", "log an incident", "generate ticket", "create snow ticket", "raise snow ticket",
482
+ "raise service now ticket", "create service now ticket", "raise sr", "open sr",
483
+ ]
484
+ return any(p in msg_norm for p in intent_phrases)
485
+
486
+
487
+ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
488
+ status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
489
+ base_has_status = any(k in msg_norm for k in status_keywords)
490
+ has_ticket_marker = (
491
+ any(w in msg_norm for w in ("ticket", "incident", "servicenow", "snow"))
492
+ or bool(re.search(r"\binc\d{5,}\b", msg_norm, flags=re.IGNORECASE))
493
+ )
494
+ # Disambiguation: if it's a domain status query and not clearly ticket/incident, do NOT route to ticket-status.
495
+ if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
496
+ return {}
497
+
498
+ patterns = [
499
+ r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
500
+ r"(inc\d+)"
501
+ ]
502
+ for pat in patterns:
503
+ m = re.search(pat, msg_norm, flags=re.IGNORECASE)
504
+ if m:
505
+ val = m.group(1).strip()
506
+ if val:
507
+ return {"number": val.upper() if val.lower().startswith("inc") else val}
508
+ return {"number": None, "ask_number": True}
509
+
510
+
511
+ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
512
+ phrases = [
513
+ "it is resolved", "resolved", "issue resolved", "problem resolved",
514
+ "it's working", "working now", "works now", "fixed", "sorted",
515
+ "ok now", "fine now", "all good", "all set", "thanks works", "thank you it works", "back to normal",
516
+ ]
517
+ return any(p in msg_norm for p in phrases)
518
+
519
+
520
+ def _has_negation_resolved(msg_norm: str) -> bool:
521
+ neg_phrases = [
522
+ "not resolved", "issue not resolved", "still not working", "not working",
523
+ "didn't work", "doesn't work", "no change", "not fixed", "still failing", "failed again", "broken", "fail",
524
+ ]
525
+ return any(p in msg_norm for p in neg_phrases)
526
 
527
 
 
528
  def _find_prereq_section_text(best_doc: str) -> str:
529
+ """Return the prerequisites section text, trying common heading variants."""
 
 
 
530
  variants = [
531
  "Pre-Requisites",
532
  "Prerequisites",
 
541
  return ""
542
 
543
 
544
+ # ------------------------------------------------------------------------------
545
+ # Health
546
+ # ------------------------------------------------------------------------------
547
  @app.get("/")
548
  async def health_check():
549
  return {"status": "ok"}
550
 
551
 
552
+ # ------------------------------------------------------------------------------
553
+ # Chat
554
+ # ------------------------------------------------------------------------------
555
  @app.post("/chat")
556
  async def chat_with_ai(input_data: ChatInput):
557
  assist_followup: Optional[str] = None
 
581
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
582
  if _has_negation_resolved(msg_norm):
583
  is_llm_resolved = False
584
+
585
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
586
  try:
587
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
 
676
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
677
  if not instance_url:
678
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
679
+
680
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
681
  number = status_intent.get("number")
682
  url = f"{instance_url}/api/now/table/incident?number={number}"
 
684
  data = response.json()
685
  lst = data.get("result", [])
686
  result = (lst or [{}])[0] if response.status_code == 200 else {}
687
+
688
  state_code = builtins.str(result.get("state", "unknown"))
689
  state_label = STATE_MAP.get(state_code, state_code)
690
  short = result.get("short_description", "")
691
  num = result.get("number", number or "unknown")
692
+
693
  return {
694
  "bot_response": (
695
  f"**Ticket:** {num}\n"
 
709
  except Exception as e:
710
  raise HTTPException(status_code=500, detail=safe_str(e))
711
 
712
+ # -----------------------------
713
  # Hybrid KB search
714
+ # -----------------------------
715
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
716
  documents = kb_results.get("documents", [])
717
  metadatas = kb_results.get("metadatas", [])
718
  distances = kb_results.get("distances", [])
719
  combined = kb_results.get("combined_scores", [])
720
+
721
  items: List[Dict[str, Any]] = []
722
  for i, doc in enumerate(documents):
723
  text = doc.strip() if isinstance(doc, str) else ""
 
735
 
736
  selected = items[:max(1, 2)]
737
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
738
+
739
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
740
  context = filtered_text
741
  context_found = bool(context.strip())
742
+
743
+ best_distance = (
744
+ min([d for d in distances if d is not None], default=None) if distances else None
745
+ )
746
+ best_combined = (
747
+ max([c for c in combined if c is not None], default=None) if combined else None
748
+ )
749
+
750
  detected_intent = kb_results.get("user_intent", "neutral")
751
  best_doc = kb_results.get("best_doc")
752
  top_meta = (metadatas or [{}])[0] if metadatas else {}
 
763
  if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
764
  detected_intent = "prereqs"
765
 
766
+ # Permissions force
767
  PERM_QUERY_TERMS = [
768
  "permission", "permissions", "access", "access right", "authorization", "authorisation",
769
+ "role", "role access", "security", "security profile", "privilege",
770
+ "not allowed", "not authorized", "denied",
771
  ]
772
  is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
773
  if is_perm_query:
 
782
  if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
783
  detected_intent = "prereqs"
784
 
785
+ # ---- FORCE STEPS for "what's next" / "next step" queries ----
786
+ try:
787
+ if _detect_next_intent(input_data.user_message):
788
+ detected_intent = "steps"
789
+ except Exception:
790
+ pass
 
 
 
 
 
 
 
 
 
 
791
 
792
+ # Gating
793
  def _contains_any(s: str, keywords: tuple) -> bool:
794
  low = (s or "").lower()
795
  return any(k in low for k in keywords)
 
801
  "asn", "grn", "pick", "picking"
802
  )
803
  ACTION_OR_ERROR_TERMS = (
804
+ "how to", "procedure", "perform",
805
  "close", "closing", "open", "navigate", "scan", "confirm", "generate", "update",
806
+ "receive", "receiving",
807
  "error", "issue", "fail", "failed", "not working", "locked", "mismatch",
808
  "access", "permission", "status"
809
  )
 
816
  short_query = len((input_data.user_message or "").split()) <= 4
817
  gate_combined_ok = 0.60 if short_query else 0.55
818
  combined_ok = (best_combined is not None and best_combined >= gate_combined_ok)
 
819
  weak_domain_only = (mentions_domain and not has_any_action_or_error)
820
  low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
821
 
822
+ strong_steps_bypass = True # next-step override already set steps; allow
 
823
  strong_error_signal = len(_detect_error_families(msg_low)) > 0
 
 
 
824
 
825
+ if (weak_domain_only or (low_context_hit and not combined_ok)) \
826
+ and not strong_steps_bypass \
827
+ and not (strong_error_signal or generic_error_signal):
828
  return {
829
  "bot_response": _build_clarifying_message(),
830
  "status": "NO_KB_MATCH",
 
844
  "has_any_action_or_error": has_any_action_or_error,
845
  "strong_steps_bypass": strong_steps_bypass,
846
  "strong_error_signal": strong_error_signal,
847
+ "generic_error_signal": generic_error_signal,
848
  },
849
  }
850
 
851
  # Build SOP context if allowed
852
+ escalation_line: Optional[str] = None
853
+ full_errors: Optional[str] = None
 
 
 
854
  next_step_applied = False
855
  next_step_info: Dict[str, Any] = {}
856
+ context_preformatted = False
857
+
858
+ if best_doc and detected_intent == "steps":
859
+ full_steps = get_best_steps_section_text(best_doc)
860
+ if not full_steps:
861
+ sec = (top_meta or {}).get("section")
862
+ if sec:
863
+ full_steps = get_section_text(best_doc, sec)
864
+
865
+ if full_steps:
866
+ numbered_full = _ensure_numbering(full_steps)
867
+ next_only = _resolve_next_steps(
868
+ input_data.user_message,
869
+ numbered_full,
870
+ max_next=6,
871
+ min_score=0.35
872
+ )
873
+ if next_only is not None:
874
+ if len(next_only) == 0:
875
+ context = "You are at the final step of this SOP. No further steps."
876
+ next_step_applied = True
877
+ next_step_info = {"count": 0}
878
+ context_preformatted = True
 
 
 
879
  else:
880
+ context = _format_steps_as_numbered(next_only)
881
+ next_step_applied = True
882
+ next_step_info = {"count": len(next_only)}
883
+ context_preformatted = True
884
+ else:
885
+ context = full_steps
886
+ context_preformatted = False
887
+
888
+ # clear filter info for debug clarity
889
+ filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
890
+ context_found = True
891
+
892
+ elif best_doc and detected_intent == "errors":
893
+ full_errors = get_best_errors_section_text(best_doc)
894
+ if full_errors:
895
+ ctx_err = _extract_errors_only(full_errors, max_lines=30)
896
+ if is_perm_query:
897
+ context = _filter_permission_lines(ctx_err, max_lines=6)
898
+ else:
899
+ is_specific_error = len(_detect_error_families(msg_low)) > 0
900
+ if is_specific_error:
901
+ context = _filter_context_for_query(ctx_err, input_data.user_message)[0]
902
  else:
903
+ all_lines: List[str] = _normalize_lines(ctx_err)
904
+ error_bullets = [
905
+ ln for ln in all_lines
906
+ if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln)
907
+ ]
908
+ context = "\n".join(error_bullets[:8]).strip()
909
+ assist_followup = (
910
+ "Please tell me which error above matches your screen (paste the exact text), "
911
+ "or share a screenshot. I can guide you further or raise a ServiceNow ticket."
912
+ )
913
+ escalation_line = _extract_escalation_line(full_errors)
914
+
915
+ elif best_doc and detected_intent == "prereqs":
916
+ full_prereqs = _find_prereq_section_text(best_doc)
917
+ if full_prereqs:
918
+ context = full_prereqs.strip()
919
+ context_found = True
 
920
 
921
+ else:
922
+ # Neutral or other intents: use filtered context
923
+ context = filtered_text
924
+
925
+ # Language hint & paraphrase (for errors only)
926
  language_hint = _detect_language_hint(input_data.user_message)
927
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
928
  use_gemini = (detected_intent == "errors")
929
+
930
  enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
931
  - Do not add any information that is not present in the context.
932
  - If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
 
937
  {input_data.user_message}
938
  ### Output
939
  Return ONLY the rewritten guidance."""
940
+
941
  headers = {"Content-Type": "application/json"}
942
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
943
  bot_text = ""
944
  http_code = 0
945
+
946
  if use_gemini and GEMINI_API_KEY:
947
  try:
948
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
 
960
 
961
  # Deterministic local formatting
962
  if detected_intent == "steps":
963
+ if context_preformatted:
 
 
964
  bot_text = context
965
  else:
966
  bot_text = _ensure_numbering(context)
 
967
  elif detected_intent == "errors":
968
+ if not (bot_text or "").strip() or http_code == 429:
969
  bot_text = context.strip()
970
  if escalation_line:
971
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
 
972
  else:
973
  bot_text = context
974
 
975
+ # Append escalation if explicitly requested even in steps mode
976
  needs_escalation = (" escalate" in msg_norm) or ("escalation" in msg_norm)
977
  if needs_escalation and best_doc:
978
  esc_text = get_escalation_text(best_doc)
 
982
  if line:
983
  bot_text = (bot_text or "").rstrip() + "\n\n" + line
984
 
985
+ # Non-empty guarantee
986
  if not (bot_text or "").strip():
987
  if context.strip():
988
  bot_text = context.strip()
 
995
  short_query = len((input_data.user_message or "").split()) <= 4
996
  gate_combined_ok = 0.60 if short_query else 0.55
997
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
998
+
999
  lower = (bot_text or "").lower()
1000
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
1001
  status = "PARTIAL"
1002
+
1003
  options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
1004
 
1005
  return {
 
1017
  "best_distance": best_distance,
1018
  "best_combined": best_combined,
1019
  "http_status": http_code,
1020
+ "filter_mode": filt_info.get("mode") if isinstance(filt_info, dict) else None,
1021
+ "matched_count": filt_info.get("matched_count") if isinstance(filt_info, dict) else None,
1022
  "user_intent": detected_intent,
1023
  "best_doc": best_doc,
1024
+ "next_step": {"applied": next_step_applied, "info": next_step_info},
 
 
 
1025
  },
1026
  }
1027
+
1028
  except HTTPException:
1029
  raise
1030
  except Exception as e:
1031
  raise HTTPException(status_code=500, detail=safe_str(e))
1032
 
1033
 
1034
+ # ------------------------------------------------------------------------------
1035
+ # Ticket description generation
1036
+ # ------------------------------------------------------------------------------
1037
  @app.post("/generate_ticket_desc")
1038
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
1039
  try:
 
1053
  data = resp.json()
1054
  except Exception:
1055
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
1056
+
1057
  try:
1058
  text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
1059
  except Exception:
1060
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
1061
+
1062
  if text.startswith("```"):
1063
  lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
1064
  text = "\n".join(lines).strip()
1065
+
1066
  try:
1067
  ticket_json = json.loads(text)
1068
  return {
 
1071
  }
1072
  except Exception:
1073
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
1074
+
1075
  except Exception as e:
1076
  raise HTTPException(status_code=500, detail=safe_str(e))
1077
 
1078
 
1079
+ # ------------------------------------------------------------------------------
1080
+ # Incident status
1081
+ # ------------------------------------------------------------------------------
1082
  @app.post("/incident_status")
1083
  async def incident_status(input_data: TicketStatusInput):
1084
  try:
 
1086
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
1087
  if not instance_url:
1088
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
1089
+
1090
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
1091
+
1092
  if input_data.sys_id:
1093
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
1094
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
 
1102
  result = (lst or [{}])[0] if response.status_code == 200 else {}
1103
  else:
1104
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
1105
+
1106
  state_code = builtins.str(result.get("state", "unknown"))
1107
  state_label = STATE_MAP.get(state_code, state_code)
1108
  short = result.get("short_description", "")
1109
  number = result.get("number", input_data.number or "unknown")
1110
+
1111
  return {
1112
  "bot_response": (
1113
  f"**Ticket:** {number} \n"
 
1119
  "persist": True,
1120
  "debug": "Incident status fetched",
1121
  }
1122
+
1123
  except Exception as e:
1124
  raise HTTPException(status_code=500, detail=safe_str(e))
1125
 
1126
 
1127
+ # ------------------------------------------------------------------------------
1128
+ # Incident creation
1129
+ # ------------------------------------------------------------------------------
1130
+ def _classify_resolution_llm(user_message: str) -> bool:
1131
+ if not GEMINI_API_KEY:
1132
+ return False
1133
+ prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
1134
+ Return only 'true' or 'false'.
1135
+ Message: {user_message}"""
1136
+ headers = {"Content-Type": "application/json"}
1137
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
1138
+ try:
1139
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
1140
+ data = resp.json()
1141
+ text = (
1142
+ data.get("candidates", [{}])[0]
1143
+ .get("content", {})
1144
+ .get("parts", [{}])[0]
1145
+ .get("text", "")
1146
+ )
1147
+ return "true" in (text or "").strip().lower()
1148
+ except Exception:
1149
+ return False
1150
+
1151
+
1152
+ def _set_incident_resolved(sys_id: str) -> bool:
1153
+ try:
1154
+ token = get_valid_token()
1155
+ instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
1156
+ if not instance_url:
1157
+ print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
1158
+ return False
1159
+
1160
+ headers = {
1161
+ "Authorization": f"Bearer {token}",
1162
+ "Accept": "application/json",
1163
+ "Content-Type": "application/json",
1164
+ }
1165
+ url = f"{instance_url}/api/now/table/incident/{sys_id}"
1166
+
1167
+ close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
1168
+ close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
1169
+ caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
1170
+ resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
1171
+ assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
1172
+ require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
1173
+
1174
+ if require_progress:
1175
+ try:
1176
+ resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
1177
+ print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
1178
+ except Exception as e:
1179
+ print(f"[SN PATCH progress] exception={safe_str(e)}")
1180
+
1181
+ def clean(d: dict) -> dict:
1182
+ return {k: v for k, v in d.items() if v is not None}
1183
+
1184
+ payload_A = clean({
1185
+ "state": "6",
1186
+ "close_code": close_code_val,
1187
+ "close_notes": close_notes_val,
1188
+ "caller_id": caller_sysid,
1189
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
1190
+ "work_notes": "Auto-resolve set by NOVA.",
1191
+ "resolved_by": resolved_by_sysid,
1192
+ "assignment_group": assign_group,
1193
+ })
1194
+ respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
1195
+ if respA.status_code in (200, 204):
1196
+ return True
1197
+ print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
1198
+
1199
+ payload_B = clean({
1200
+ "state": "Resolved",
1201
+ "close_code": close_code_val,
1202
+ "close_notes": close_notes_val,
1203
+ "caller_id": caller_sysid,
1204
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
1205
+ "work_notes": "Auto-resolve set by NOVA.",
1206
+ "resolved_by": resolved_by_sysid,
1207
+ "assignment_group": assign_group,
1208
+ })
1209
+ respB = requests.patch(url, headers=headers, json=payload_B, verify=VERIFY_SSL, timeout=25)
1210
+ if respB.status_code in (200, 204):
1211
+ return True
1212
+ print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
1213
+
1214
+ code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
1215
+ notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
1216
+ payload_C = clean({
1217
+ "state": "6",
1218
+ code_field: close_notes_val, # adjust if custom fields
1219
+ notes_field: close_notes_val,
1220
+ "caller_id": caller_sysid,
1221
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
1222
+ "work_notes": "Auto-resolve set by NOVA.",
1223
+ "resolved_by": resolved_by_sysid,
1224
+ "assignment_group": assign_group,
1225
+ })
1226
+ respC = requests.patch(url, headers=headers, json=payload_C, verify=VERIFY_SSL, timeout=25)
1227
+ if respC.status_code in (200, 204):
1228
+ return True
1229
+ print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
1230
+
1231
+ return False
1232
+ except Exception as e:
1233
+ print(f"[SN PATCH resolve] exception={safe_str(e)}")
1234
+ return False
1235
+
1236
+
1237
  @app.post("/incident")
1238
  async def raise_incident(input_data: IncidentInput):
1239
  try: