srilakshu012456 commited on
Commit
072cf7c
·
verified ·
1 Parent(s): f93e7e1

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +211 -231
main.py CHANGED
@@ -23,7 +23,6 @@ from public_api.public_api_location import get_location_status
23
  from public_api.utils import flatten_json, extract_fields
24
  from public_api.field_mapping import TOOL_REGISTRY, ALL_TOOLS
25
 
26
-
27
  # KB services
28
  from services.kb_creation import (
29
  collection,
@@ -39,23 +38,51 @@ from services.kb_creation import (
39
  from services.login import router as login_router
40
  from services.generate_ticket import get_valid_token, create_incident
41
 
42
- # ---------------------------------------------------------------------
43
- # Environment
44
- # ---------------------------------------------------------------------
 
45
  load_dotenv()
 
46
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
47
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
48
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
 
 
 
 
49
  GEMINI_URL = (
50
  "https://generativelanguage.googleapis.com/v1beta/models/"
51
- f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
52
  )
 
53
  os.environ["POSTHOG_DISABLED"] = "true"
54
 
55
  # --- API WMS session cache ---
56
  _WMS_SESSION_DATA = None
57
  _WMS_WAREHOUSE_ID = None
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def _ensure_wms_session() -> bool:
60
  """Login once to WMS and cache session_data + default warehouse."""
61
  global _WMS_SESSION_DATA, _WMS_WAREHOUSE_ID
@@ -71,9 +98,10 @@ def _ensure_wms_session() -> bool:
71
  print("[WMS] login failed:", e)
72
  return False
73
 
74
- # ---------------------------------------------------------------------
75
- # Minimal server-side cache (used to populate short description if frontend didn’t pass last_issue)
76
- # ---------------------------------------------------------------------
 
77
  LAST_ISSUE_HINT: str = ""
78
 
79
 
@@ -84,9 +112,9 @@ def safe_str(e: Any) -> str:
84
  return "<error stringify failed>"
85
 
86
 
87
- # ---------------------------------------------------------------------
88
  # App / Lifespan
89
- # ---------------------------------------------------------------------
90
  @asynccontextmanager
91
  async def lifespan(app: FastAPI):
92
  try:
@@ -106,7 +134,7 @@ app.include_router(login_router)
106
 
107
  # CORS
108
  origins = [
109
- "https://chatbotnova-chatbot-frontend.hf.space",
110
  # "http://localhost:5173", # local dev if needed
111
  ]
112
  app.add_middleware(
@@ -117,9 +145,9 @@ app.add_middleware(
117
  allow_headers=["*"],
118
  )
119
 
120
- # ---------------------------------------------------------------------
121
  # Models
122
- # ---------------------------------------------------------------------
123
  class ChatInput(BaseModel):
124
  user_message: str
125
  prev_status: Optional[str] = None
@@ -150,9 +178,9 @@ STATE_MAP = {
150
  "8": "Canceled",
151
  }
152
 
153
- # ---------------------------------------------------------------------
154
  # Generic helpers (shared)
155
- # ---------------------------------------------------------------------
156
  NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
157
  DOMAIN_STATUS_TERMS = (
158
  "shipment", "order", "load", "trailer", "wave",
@@ -160,6 +188,7 @@ DOMAIN_STATUS_TERMS = (
160
  "dock", "door", "manifest", "pallet", "container",
161
  "asn", "grn", "pick", "picking",
162
  )
 
163
  ERROR_FAMILY_SYNS = {
164
  "NOT_FOUND": (
165
  "not found", "missing", "does not exist", "doesn't exist",
@@ -190,38 +219,29 @@ ERROR_FAMILY_SYNS = {
190
  }
191
 
192
 
193
-
194
-
195
  def _try_wms_tool(user_text: str) -> dict | None:
196
  """
197
  Uses Gemini function calling + TOOL_REGISTRY to decide if a WMS tool should be called.
198
  Returns YOUR existing response dict shape. If no tool match or any error -> returns None.
199
  """
200
-
201
  # Guards
202
  if not GEMINI_API_KEY:
203
  return None
204
  if not _ensure_wms_session():
205
  return None
206
 
207
- headers = {"Content-Type": "application/json", "x-goog-api-key": GEMINI_API_KEY}
 
 
 
208
  payload = {
209
- "contents": [{"role": "user","parts": [{"text": user_text}]}],
210
- "tools": [{"functionDeclarations": ALL_TOOLS}],
211
- "toolConfig": { # REST field name
212
- "functionCallingConfig": { "mode": "ANY" }
213
- }
214
  }
215
 
216
  try:
217
- resp = requests.post(
218
- GEMINI_URL,
219
- headers=headers,
220
- json=payload,
221
- timeout=25,
222
- verify=GEMINI_SSL_VERIFY
223
- )
224
- # Raise if HTTP not 2xx so we handle cleanly
225
  resp.raise_for_status()
226
  data = resp.json()
227
 
@@ -229,12 +249,9 @@ def _try_wms_tool(user_text: str) -> dict | None:
229
  if not candidates:
230
  return None
231
 
232
- # Defensive extraction of the first part
233
  content = candidates[0].get("content", {})
234
  parts = content.get("parts", [])
235
  part = parts[0] if parts else {}
236
-
237
- # Gemini returns a function call in part["functionCall"] when a tool is selected
238
  fn_call = part.get("functionCall")
239
  if not fn_call or "name" not in fn_call:
240
  return None
@@ -244,21 +261,19 @@ def _try_wms_tool(user_text: str) -> dict | None:
244
 
245
  cfg = TOOL_REGISTRY.get(tool_name)
246
  if not cfg:
247
- # Tool not registered → bail
248
  return None
249
 
250
- # -------- Resolve warehouse --------
251
  wh = (
252
  args.pop("warehouse_id", None)
253
  or args.pop("warehouseId", None)
254
  or args.pop("wh_id", None)
255
- or _WMS_WAREHOUSE_ID
256
  )
257
 
258
- # -------- Invoke the tool callable correctly --------
259
  tool_fn = cfg.get("function")
260
  if not callable(tool_fn):
261
- # Misconfigured registry
262
  return {
263
  "bot_response": f"⚠️ Tool '{tool_name}' is not callable. Check TOOL_REGISTRY['{tool_name}']['function'].",
264
  "status": "PARTIAL",
@@ -272,15 +287,20 @@ def _try_wms_tool(user_text: str) -> dict | None:
272
  "source": "ERROR",
273
  }
274
 
275
- # Holds & Location use kwargs (e.g., lodnum/subnum/dtlnum OR stoloc);
276
- # Item uses single ID parameter (e.g., item_id) plus warehouse.
277
- if tool_name in ("get_inventory_holds", "get_location_status"):
278
- # Pass warehouse as kwarg if present; rest comes from args
279
- call_kwargs = {"warehouse_id": wh} if wh is not None else {}
280
- call_kwargs.update(args)
281
- raw = tool_fn(**call_kwargs)
 
 
 
 
 
282
  else:
283
- id_param = cfg.get("id_param")
284
  target = args.get(id_param)
285
  if id_param and target is None:
286
  return {
@@ -295,19 +315,10 @@ def _try_wms_tool(user_text: str) -> dict | None:
295
  "debug": {"intent": "wms_missing_id_param", "tool": tool_name},
296
  "source": "CLIENT",
297
  }
 
 
298
 
299
- call_kwargs = {"warehouse_id": wh} if wh is not None else {}
300
- # Include the ID param, plus any other Gemini-parsed args
301
- if id_param:
302
- call_kwargs[id_param] = target
303
- # Keep other non-ID args (filters, etc.)
304
- for k, v in args.items():
305
- if k != id_param:
306
- call_kwargs[k] = v
307
-
308
- raw = tool_fn(**call_kwargs)
309
-
310
- # -------- Error handling from tool layer --------
311
  if isinstance(raw, dict) and raw.get("error"):
312
  return {
313
  "bot_response": f"⚠️ {raw['error']}",
@@ -322,7 +333,7 @@ def _try_wms_tool(user_text: str) -> dict | None:
322
  "source": "ERROR",
323
  }
324
 
325
- # -------- Extract data list (default key = 'data') --------
326
  response_key = cfg.get("response_key", "data")
327
  data_list = (raw.get(response_key, []) if isinstance(raw, dict) else []) or []
328
  if not data_list:
@@ -339,24 +350,20 @@ def _try_wms_tool(user_text: str) -> dict | None:
339
  "source": "LIVE_API",
340
  }
341
 
342
- # -------- TABLE: Holds / Location --------
343
  if tool_name in ("get_inventory_holds", "get_location_status"):
344
- rows_for_text = []
345
- total_qty = 0
346
-
347
  for item in data_list:
348
  flat = flatten_json(item)
349
  row = extract_fields(flat, cfg["mapping"], cfg.get("formatters", {}))
350
  rows_for_text.append("• " + "; ".join(f"{k}: {v}" for k, v in row.items()))
351
 
352
  if tool_name == "get_inventory_holds":
353
- # Only holds have quantity in most schemas
354
  def _to_int(x):
355
  try:
356
  return int(x or 0)
357
  except Exception:
358
  return 0
359
-
360
  total_qty = sum(_to_int(it.get("untqty", 0)) for it in data_list)
361
  msg = f"Found {len(rows_for_text)} hold records (Total Qty: {total_qty}).\n" + "\n".join(rows_for_text[:10])
362
  else:
@@ -379,9 +386,8 @@ def _try_wms_tool(user_text: str) -> dict | None:
379
  "show_export": True if tool_name == "get_inventory_holds" else False,
380
  }
381
 
382
- # -------- ITEM: summary + single-row table --------
383
  flat = flatten_json(data_list[0])
384
-
385
  requested = args.get("fields", [])
386
  if requested:
387
  filtered_map = {k: v for k, v in cfg["mapping"].items() if k in requested or k == "Item"}
@@ -412,7 +418,6 @@ def _try_wms_tool(user_text: str) -> dict | None:
412
  }
413
 
414
  except requests.HTTPError as e:
415
- # Surface Gemini API issues more clearly
416
  return {
417
  "bot_response": f"Gemini API error: {e}",
418
  "status": "PARTIAL",
@@ -429,6 +434,7 @@ def _try_wms_tool(user_text: str) -> dict | None:
429
  print("[WMS] tool call error:", e)
430
  return None
431
 
 
432
  def _detect_error_families(msg: str) -> list:
433
  low = (msg or "").lower()
434
  low_norm = re.sub(r"[^\w\s]", " ", low)
@@ -493,7 +499,7 @@ def _dedupe_lines(text: str) -> str:
493
 
494
 
495
  def _split_sentences(block: str) -> list:
496
- parts = [t.strip() for t in re.split(r"(?<=[.!?])\s+", block or "") if t.strip()]
497
  return parts if parts else ([block.strip()] if (block or "").strip() else [])
498
 
499
 
@@ -505,33 +511,31 @@ def _ensure_numbering(text: str) -> str:
505
  para = " ".join(lines).strip()
506
  if not para:
507
  return ""
508
- para_clean = re.sub(r"(?:\b\d+\s*[.\)])\s+", "\n\n\n", para) # 1. / 1)
509
- para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
510
- para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean) # Step 1:
 
 
 
511
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
512
  if len(segments) < 2:
513
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
514
- segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+", para) if seg.strip()]
515
 
516
  def strip_prefix_any(s: str) -> str:
517
  return re.sub(
518
  r"^\s*(?:"
519
- r"(?:\d+\s*[.\)])|"
520
- r"(?i:step\s*\d+:?)|"
521
- r"(?:[-*\u2022])|"
522
- r"(?:[\u2460-\u2473])"
523
  r")\s*",
524
  "",
525
  (s or "").strip(),
526
  )
527
 
528
  clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
529
- circled = {
530
- 1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
531
- 6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
532
- 11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
533
- 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473",
534
- }
535
  out = []
536
  for idx, seg in enumerate(clean_segments, start=1):
537
  marker = circled.get(idx, f"{idx})")
@@ -539,49 +543,13 @@ def _ensure_numbering(text: str) -> str:
539
  return "\n".join(out)
540
 
541
 
542
- def _norm_text(s: str) -> str:
543
- s = (s or "").lower()
544
- s = re.sub(r"[^\w\s]", " ", s)
545
- s = re.sub(r"\s+", " ", s).strip()
546
- if not s:
547
  return s
548
- toks = s.split()
549
- stemmed = []
550
- for t in toks:
551
- if len(t) > 3 and t.endswith("s"):
552
- t = t[:-1]
553
- if len(t) > 4 and t.endswith("ed"):
554
- t = t[:-2]
555
- if len(t) > 5 and t.endswith("ing"):
556
- t = t[:-3]
557
- stemmed.append(t)
558
- return " ".join(stemmed).strip()
559
-
560
-
561
- def _split_sop_into_steps(numbered_text: str) -> list:
562
- lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
563
- steps = []
564
- for ln in lines:
565
- cleaned = re.sub(r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*", "", ln)
566
- if cleaned:
567
- steps.append(cleaned)
568
- return steps
569
-
570
-
571
- def _format_steps_as_numbered(steps: list) -> str:
572
- circled = {
573
- 1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
574
- 6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
575
- 11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
576
- 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473",
577
- }
578
- out = []
579
- for i, s in enumerate(steps, start=1):
580
- out.append(f"{circled.get(i, str(i))} {s}")
581
- return "\n".join(out)
582
-
583
 
584
- def _similarity(a: str, b: str) -> float:
585
  a_norm, b_norm = _norm_text(a), _norm_text(b)
586
  ta, tb = set(a_norm.split()), set(b_norm.split())
587
  inter = len(ta & tb)
@@ -595,13 +563,15 @@ def _similarity(a: str, b: str) -> float:
595
  big_inter = len(ab & bb)
596
  big_union = len(ab | bb) or 1
597
  big = big_inter / big_union
 
598
  char = SequenceMatcher(None, a_norm, b_norm).ratio()
599
  return min(1.0, 0.45 * jacc + 0.30 * big + 0.35 * char)
600
 
601
 
602
  def _extract_anchor_from_query(msg: str) -> dict:
603
  raw = (msg or "").strip()
604
- low = _norm_text(raw)
 
605
  FOLLOWUP_CUES = ("what next", "what is next", "what to do", "then", "after that", "next")
606
  has_followup = any(cue in low for cue in FOLLOWUP_CUES)
607
 
@@ -610,17 +580,16 @@ def _extract_anchor_from_query(msg: str) -> dict:
610
  return {"anchor": raw, "has_followup": has_followup}
611
 
612
  last = parts[-1]
613
- last_low = _norm_text(last)
614
  if any(cue in last_low for cue in FOLLOWUP_CUES) and len(parts) >= 2:
615
  anchor = parts[-2]
616
  else:
617
  anchor = parts[-1] if len(parts) > 1 else parts[0]
618
-
619
  return {"anchor": anchor.strip(), "has_followup": has_followup}
620
 
621
 
622
  def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8) -> list | None:
623
- steps = _split_sop_into_steps(numbered_text)
624
  if not steps:
625
  return None
626
 
@@ -628,9 +597,8 @@ def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8)
628
  anchor = info.get("anchor", "").strip()
629
  if not anchor:
630
  return None
631
- anchor_norm = _norm_text(anchor)
632
- has_followup = bool(info.get("has_followup"))
633
 
 
634
  candidates = []
635
  for idx, step_line in enumerate(steps):
636
  s_full = _similarity(anchor, step_line)
@@ -638,23 +606,24 @@ def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8)
638
  scores = [s_full]
639
  for s in _split_sentences(step_line):
640
  scores.append(_similarity(anchor, s))
641
- a_flat = re.sub(r"\W+", "", anchor_norm)
642
- s_flat = re.sub(r"\W+", "", _norm_text(s))
643
- if a_flat and (a_flat in s_flat or s_flat in a_flat):
644
- literal_hit = True
645
  score = max(scores)
646
  candidates.append((idx, score, literal_hit))
647
 
648
  candidates.sort(key=lambda t: (t[1], t[0]), reverse=True)
649
  best_idx, best_score, best_literal = candidates[0]
650
 
651
- tok_count = len([t for t in anchor_norm.split() if len(t) > 1])
652
  if best_literal:
653
  accept = True
654
  else:
655
  base_ok = best_score >= (0.55 if not has_followup else 0.50)
656
  len_ok = (best_score >= 0.40) and (tok_count >= 3)
657
  accept = base_ok or len_ok
 
658
  if not accept:
659
  return None
660
 
@@ -678,12 +647,13 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
678
  return t
679
 
680
  def _split_sents(ctx: str) -> List[str]:
681
- raw_sents = re.split(r"(?<=[.!?])\s+", ctx or "")
682
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
683
 
684
  ctx = (context or "").strip()
685
  if not ctx or not query:
686
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
 
687
  q_norm = _norm(query)
688
  q_terms = [t for t in q_norm.split() if len(t) > 2]
689
  if not q_terms:
@@ -693,7 +663,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
693
  matched_exact, matched_any = [], []
694
  for s in sentences:
695
  s_norm = _norm(s)
696
- is_bullet = bool(re.match(r"^[\-\*]\s*", s))
697
  overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
698
  if overlap >= STRICT_OVERLAP:
699
  matched_exact.append(s)
@@ -705,11 +675,13 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
705
  return _dedupe_lines("\n".join(kept).strip()), {
706
  'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences),
707
  }
 
708
  if matched_any:
709
  kept = matched_any[:MAX_SENTENCES_CONCISE]
710
  return _dedupe_lines("\n".join(kept).strip()), {
711
  'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences),
712
  }
 
713
  kept = sentences[:MAX_SENTENCES_CONCISE]
714
  return _dedupe_lines("\n".join(kept).strip()), {
715
  'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences),
@@ -719,7 +691,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
719
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
720
  kept: List[str] = []
721
  for ln in _normalize_lines(text):
722
- if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln):
723
  kept.append(ln)
724
  if len(kept) >= max_lines:
725
  break
@@ -747,12 +719,14 @@ def _extract_escalation_line(text: str) -> Optional[str]:
747
  lines = _normalize_lines(text)
748
  if not lines:
749
  return None
 
750
  start_idx = None
751
  for i, ln in enumerate(lines):
752
  low = ln.lower()
753
  if "escalation" in low or "escalation path" in low or "escalate" in low:
754
  start_idx = i
755
  break
 
756
  block: List[str] = []
757
  if start_idx is not None:
758
  for j in range(start_idx, min(len(lines), start_idx + 6)):
@@ -763,9 +737,11 @@ def _extract_escalation_line(text: str) -> Optional[str]:
763
  block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
764
  if not block:
765
  return None
 
766
  text_block = " ".join(block)
767
  m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
768
  path = m.group(1).strip() if m else None
 
769
  if not path:
770
  arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
771
  if arrow_lines:
@@ -775,6 +751,7 @@ def _extract_escalation_line(text: str) -> Optional[str]:
775
  path = m2.group(1).strip() if m2 else None
776
  if not path:
777
  return None
 
778
  path = path.replace("->", "→").strip()
779
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
780
  return f"If you want to escalate the issue, follow: {path}"
@@ -789,9 +766,10 @@ def _detect_language_hint(msg: str) -> Optional[str]:
789
 
790
 
791
  def _build_clarifying_message() -> str:
792
- return ("It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
793
- "or should I raise a ServiceNow ticket for you?")
794
-
 
795
 
796
 
797
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
@@ -804,16 +782,14 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
804
  issue = (issue_text or "").strip()
805
  resolved = (resolved_text or "").strip()
806
 
807
- # Detect if it's a process/SOP query (heuristic)
808
  is_process_query = bool(re.search(r"\b(how to|steps|procedure|process)\b", issue.lower()))
809
 
810
- # Avoid "Process Steps – Process Steps" duplication
811
  if is_process_query:
812
- # Remove any leading 'process steps' words from issue to avoid duplication
813
  cleaned_issue = re.sub(r"^\s*(process\s*steps\s*[-–:]?\s*)", "", issue, flags=re.IGNORECASE).strip()
814
  short_desc = f"Process Steps – {cleaned_issue or issue}"[:100]
815
  else:
816
- # Error or other query: use the issue as-is
817
  short_desc = issue[:100]
818
 
819
  long_desc = (
@@ -824,6 +800,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
824
 
825
  return short_desc, long_desc
826
 
 
827
  def _is_incident_intent(msg_norm: str) -> bool:
828
  intent_phrases = [
829
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
@@ -843,6 +820,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
843
  )
844
  if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
845
  return {}
 
846
  patterns = [
847
  r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
848
  r"(inc\d+)",
@@ -884,21 +862,22 @@ def _find_prereq_section_text(best_doc: str) -> str:
884
  return ""
885
 
886
 
887
- # ---------------------------------------------------------------------
888
  # Health
889
- # ---------------------------------------------------------------------
890
  @app.get("/")
891
  async def health_check():
892
  return {"status": "ok"}
893
 
894
 
895
- # ---------------------------------------------------------------------
896
  # Chat
897
- # ---------------------------------------------------------------------
898
  @app.post("/chat")
899
  async def chat_with_ai(input_data: ChatInput):
900
- global LAST_ISSUE_HINT # <-- ensure global declared before any assignment/use
901
  assist_followup: Optional[str] = None
 
902
  try:
903
  msg_norm = (input_data.user_message or "").lower().strip()
904
 
@@ -911,6 +890,7 @@ async def chat_with_ai(input_data: ChatInput):
911
  "options": [],
912
  "debug": {"intent": "continue_conversation"},
913
  }
 
914
  if msg_norm in ("no", "no thanks", "nope"):
915
  return {
916
  "bot_response": "No problem. Do you need assistance with any other issue?",
@@ -925,6 +905,7 @@ async def chat_with_ai(input_data: ChatInput):
925
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
926
  if _has_negation_resolved(msg_norm):
927
  is_llm_resolved = False
 
928
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
929
  try:
930
  # Prefer cached issue hint if frontend didn't pass last_issue
@@ -1015,6 +996,7 @@ async def chat_with_ai(input_data: ChatInput):
1015
  "sources": [],
1016
  "debug": {"intent": "status_request_missing_id"},
1017
  }
 
1018
  try:
1019
  token = get_valid_token()
1020
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
@@ -1049,14 +1031,13 @@ async def chat_with_ai(input_data: ChatInput):
1049
  }
1050
  except Exception as e:
1051
  raise HTTPException(status_code=500, detail=safe_str(e))
1052
-
1053
- # --- Try WMS API tools before KB search ---
1054
  res = _try_wms_tool(input_data.user_message)
1055
  if res is not None:
1056
- return res
1057
-
1058
 
1059
- # ------------------ Hybrid KB search ------------------
1060
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
1061
  documents = kb_results.get("documents", [])
1062
  metadatas = kb_results.get("metadatas", [])
@@ -1080,16 +1061,17 @@ async def chat_with_ai(input_data: ChatInput):
1080
 
1081
  selected = items[:max(1, 2)]
1082
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
 
1083
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
1084
  context = filtered_text
1085
  context_found = bool(context.strip())
1086
 
1087
  best_distance = (min([d for d in distances if d is not None], default=None) if distances else None)
1088
  best_combined = (max([c for c in combined if c is not None], default=None) if combined else None)
1089
-
1090
  detected_intent = kb_results.get("user_intent", "neutral")
1091
  best_doc = kb_results.get("best_doc")
1092
  top_meta = (metadatas or [{}])[0] if metadatas else {}
 
1093
  msg_low = (input_data.user_message or "").lower()
1094
  GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
1095
  generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
@@ -1113,12 +1095,12 @@ async def chat_with_ai(input_data: ChatInput):
1113
  detected_intent = "errors"
1114
 
1115
  # heading-aware prereq nudge
1116
- sec_title = ((top_meta or {}).get("section") or "").strip().lower()
 
1117
  PREREQ_HEADINGS = ("pre-requisites", "prerequisites", "pre requisites", "pre-requirements", "requirements")
1118
- if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
1119
  detected_intent = "prereqs"
1120
 
1121
- # gating
1122
  def _contains_any(s: str, keywords: tuple) -> bool:
1123
  low = (s or "").lower()
1124
  return any(k in low for k in keywords)
@@ -1134,13 +1116,16 @@ async def chat_with_ai(input_data: ChatInput):
1134
  "confirm", "generate", "update", "receive", "receiving", "error", "issue", "fail", "failed",
1135
  "not working", "locked", "mismatch", "access", "permission", "status",
1136
  )
 
1137
  matched_count = int(filt_info.get("matched_count") or 0)
1138
  filter_mode = (filt_info.get("mode") or "").lower()
1139
  has_any_action_or_error = _contains_any(msg_low, ACTION_OR_ERROR_TERMS)
1140
  mentions_domain = _contains_any(msg_low, DOMAIN_TERMS)
 
1141
  short_query = len((input_data.user_message or "").split()) <= 4
1142
  gate_combined_ok = 0.60 if short_query else 0.55
1143
  combined_ok = (best_combined is not None and best_combined >= gate_combined_ok)
 
1144
  weak_domain_only = (mentions_domain and not has_any_action_or_error)
1145
  low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
1146
  strong_steps_bypass = True
@@ -1172,7 +1157,7 @@ async def chat_with_ai(input_data: ChatInput):
1172
  },
1173
  }
1174
 
1175
- # ---------- Build SOP context ----------
1176
  escalation_line: Optional[str] = None
1177
  full_errors: Optional[str] = None
1178
  next_step_applied = False
@@ -1186,13 +1171,11 @@ async def chat_with_ai(input_data: ChatInput):
1186
  full_steps = get_section_text(best_doc, sec)
1187
  else:
1188
  full_steps = get_best_steps_section_text(best_doc)
1189
-
1190
  if full_steps:
1191
  numbered_full = _ensure_numbering(full_steps)
1192
 
1193
  raw_actions = set((kb_results.get("actions") or []))
1194
  msg_low2 = (input_data.user_message or "").lower()
1195
-
1196
  if not raw_actions and ("creation" in msg_low2 or "create" in msg_low2 or "set up" in msg_low2 or "setup" in msg_low2):
1197
  raw_actions = {"create"}
1198
  elif not raw_actions and ("update" in msg_low2 or "modify" in msg_low2 or "edit" in msg_low2 or "change" in msg_low2):
@@ -1227,7 +1210,6 @@ async def chat_with_ai(input_data: ChatInput):
1227
  numbered_full = before
1228
 
1229
  next_only = _anchor_next_steps(input_data.user_message, numbered_full, max_next=6)
1230
-
1231
  if next_only is not None:
1232
  if len(next_only) == 0:
1233
  context = "You are at the final step of this SOP. No further steps."
@@ -1235,8 +1217,7 @@ async def chat_with_ai(input_data: ChatInput):
1235
  next_step_info = {"count": 0}
1236
  context_preformatted = True
1237
  else:
1238
- context = _format_steps_as_numbered(next_only)
1239
- context = _dedupe_lines(context)
1240
  next_step_applied = True
1241
  next_step_info = {"count": len(next_only)}
1242
  context_preformatted = True
@@ -1256,10 +1237,9 @@ async def chat_with_ai(input_data: ChatInput):
1256
  flags=re.IGNORECASE,
1257
  ))
1258
  )
1259
-
1260
  if said_not_resolved:
1261
  return {
1262
- "bot_response": "Select an option below.", # concise helper; avoids duplicate long sentence/dot
1263
  "status": "OK",
1264
  "context_found": False,
1265
  "ask_resolved": False,
@@ -1270,26 +1250,18 @@ async def chat_with_ai(input_data: ChatInput):
1270
  "top_hits": [],
1271
  "sources": [],
1272
  "debug": {
1273
- "intent": "errors_not_resolved",
1274
- "best_doc": best_doc,
1275
  },
1276
  }
1277
 
1278
  full_errors = get_best_errors_section_text(best_doc)
1279
  if full_errors:
1280
  ctx_err = _extract_errors_only(full_errors, max_lines=30)
1281
-
1282
  if is_perm_query:
1283
  context = _filter_permission_lines(ctx_err, max_lines=6)
1284
  else:
1285
- DOMAIN_TERMS = (
1286
- "trailer", "shipment", "order", "load", "wave",
1287
- "inventory", "putaway", "receiving", "appointment",
1288
- "dock", "door", "manifest", "pallet", "container",
1289
- "asn", "grn", "pick", "picking",
1290
- )
1291
  mentions_domain_local = any(t in msg_low for t in DOMAIN_TERMS)
1292
-
1293
  is_specific_error = (len(_detect_error_families(msg_low)) > 0) or mentions_domain_local
1294
  if is_specific_error:
1295
  context = _filter_context_for_query(ctx_err, input_data.user_message)[0]
@@ -1297,14 +1269,14 @@ async def chat_with_ai(input_data: ChatInput):
1297
  all_lines = _normalize_lines(ctx_err)
1298
  error_bullets = [
1299
  ln for ln in all_lines
1300
- if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln)
1301
  ]
1302
  context = "\n".join(error_bullets[:6]).strip()
1303
  else:
1304
  all_lines = _normalize_lines(ctx_err)
1305
  error_bullets = [
1306
  ln for ln in all_lines
1307
- if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln)
1308
  ]
1309
  context = "\n".join(error_bullets[:6]).strip()
1310
 
@@ -1321,8 +1293,7 @@ async def chat_with_ai(input_data: ChatInput):
1321
  numbered_steps = _ensure_numbering(steps_src)
1322
  next_only = _anchor_next_steps(input_data.user_message, numbered_steps, max_next=6)
1323
  if next_only is not None and len(next_only) > 0:
1324
- context = _format_steps_as_numbered(next_only)
1325
- context = _dedupe_lines(context)
1326
  context_preformatted = True
1327
  next_step_applied = True
1328
  next_step_info = {"count": len(next_only), "source": "errors_domain_override"}
@@ -1336,10 +1307,11 @@ async def chat_with_ai(input_data: ChatInput):
1336
  context = full_prereqs.strip()
1337
  context_found = True
1338
 
1339
- # language hint & paraphrase (errors only)
1340
  language_hint = _detect_language_hint(input_data.user_message)
1341
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
1342
  use_gemini = (detected_intent == "errors") and not steps_override_applied
 
1343
  enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
1344
  - Do not add any information that is not present in the context.
1345
  - If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
@@ -1350,14 +1322,13 @@ async def chat_with_ai(input_data: ChatInput):
1350
  {input_data.user_message}
1351
  ### Output
1352
  Return ONLY the rewritten guidance."""
1353
- headers = {"Content-Type": "application/json"}
1354
- payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
1355
  bot_text = ""
1356
  http_code = 0
1357
 
1358
  if use_gemini and GEMINI_API_KEY:
1359
  try:
1360
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
 
1361
  http_code = getattr(resp, "status_code", 0)
1362
  try:
1363
  result = resp.json()
@@ -1372,10 +1343,7 @@ Return ONLY the rewritten guidance."""
1372
 
1373
  # deterministic local formatting
1374
  if detected_intent == "steps":
1375
- if context_preformatted:
1376
- bot_text = context
1377
- else:
1378
- bot_text = _ensure_numbering(context)
1379
  elif detected_intent == "errors":
1380
  if not (bot_text or "").strip() or http_code == 429:
1381
  bot_text = context.strip()
@@ -1407,41 +1375,35 @@ Return ONLY the rewritten guidance."""
1407
  short_query = len((input_data.user_message or "").split()) <= 4
1408
  gate_combined_ok = 0.60 if short_query else 0.55
1409
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
 
1410
  lower = (bot_text or "").lower()
1411
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
1412
  status = "PARTIAL"
1413
 
1414
  options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
1415
 
1416
- # ----- Cache last issue hint (used if user later says "issue resolved thanks")
1417
- # ----- Cache last issue hint (used if user later says "issue resolved thanks")
1418
  try:
1419
- # Always prefer the user's query as the base
1420
- base_query = (input_data.user_message or "").strip()
1421
- if detected_intent == "steps":
1422
- # For process/SOP: cache the user query — not the heading "Process Steps"
1423
- LAST_ISSUE_HINT = base_query[:100]
1424
- elif detected_intent == "errors":
1425
- # For errors: build "<user query> — <first error line shown>"
1426
- shown_lines = [ln.strip() for ln in (bot_text or "").splitlines() if ln.strip()]
1427
-
1428
- # Pick the first bullet/colon line that is NOT escalation
1429
- top_error_line = ""
1430
- for ln in shown_lines:
1431
- if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln):
1432
- low = ln.lower()
1433
- if "escalation" in low:
1434
- continue # skip escalation sentences
1435
- top_error_line = ln
1436
- break
1437
- # Strip any trailing escalation phrase from the chosen line (safety)
1438
- if top_error_line:
1439
- top_error_line = re.split(r"\bif you want to escalate\b", top_error_line, flags=re.IGNORECASE)[0].strip()
1440
- if top_error_line:
1441
- LAST_ISSUE_HINT = f"{base_query} — {top_error_line}"
1442
- else:
1443
- LAST_ISSUE_HINT = base_query
1444
- LAST_ISSUE_HINT = LAST_ISSUE_HINT[:100]
1445
  except Exception:
1446
  pass
1447
 
@@ -1474,9 +1436,9 @@ Return ONLY the rewritten guidance."""
1474
  raise HTTPException(status_code=500, detail=safe_str(e))
1475
 
1476
 
1477
- # ---------------------------------------------------------------------
1478
  # Ticket description generation
1479
- # ---------------------------------------------------------------------
1480
  @app.post("/generate_ticket_desc")
1481
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
1482
  try:
@@ -1489,20 +1451,23 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
1489
  "}\n"
1490
  "Do not include any extra text, comments, or explanations outside the JSON."
1491
  )
1492
- headers = {"Content-Type": "application/json"}
1493
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
1494
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
1495
  try:
1496
  data = resp.json()
1497
  except Exception:
1498
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
 
1499
  try:
1500
  text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
1501
  except Exception:
1502
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
 
1503
  if text.startswith("```"):
1504
  lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
1505
  text = "\n".join(lines).strip()
 
1506
  try:
1507
  ticket_json = json.loads(text)
1508
  return {
@@ -1511,13 +1476,14 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
1511
  }
1512
  except Exception:
1513
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
 
1514
  except Exception as e:
1515
  raise HTTPException(status_code=500, detail=safe_str(e))
1516
 
1517
 
1518
- # ---------------------------------------------------------------------
1519
  # Incident status
1520
- # ---------------------------------------------------------------------
1521
  @app.post("/incident_status")
1522
  async def incident_status(input_data: TicketStatusInput):
1523
  try:
@@ -1525,24 +1491,30 @@ async def incident_status(input_data: TicketStatusInput):
1525
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
1526
  if not instance_url:
1527
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
 
1528
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
 
1529
  if input_data.sys_id:
1530
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
1531
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
1532
  data = response.json()
1533
  result = data.get("result", {}) if response.status_code == 200 else {}
 
1534
  elif input_data.number:
1535
  url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
1536
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
1537
  data = response.json()
1538
  lst = data.get("result", [])
1539
  result = (lst or [{}])[0] if response.status_code == 200 else {}
 
1540
  else:
1541
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
 
1542
  state_code = builtins.str(result.get("state", "unknown"))
1543
  state_label = STATE_MAP.get(state_code, state_code)
1544
  short = result.get("short_description", "")
1545
  number = result.get("number", input_data.number or "unknown")
 
1546
  return {
1547
  "bot_response": (
1548
  f"**Ticket:** {number} \n"
@@ -1554,23 +1526,23 @@ async def incident_status(input_data: TicketStatusInput):
1554
  "persist": True,
1555
  "debug": "Incident status fetched",
1556
  }
 
1557
  except Exception as e:
1558
  raise HTTPException(status_code=500, detail=safe_str(e))
1559
 
1560
 
1561
- # ---------------------------------------------------------------------
1562
  # Incident creation
1563
- # ---------------------------------------------------------------------
1564
  def _classify_resolution_llm(user_message: str) -> bool:
1565
  if not GEMINI_API_KEY:
1566
  return False
1567
  prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
1568
  Return only 'true' or 'false'.
1569
  Message: {user_message}"""
1570
- headers = {"Content-Type": "application/json"}
1571
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
1572
  try:
1573
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
1574
  data = resp.json()
1575
  text = (
1576
  data.get("candidates", [{}])[0]
@@ -1590,18 +1562,21 @@ def _set_incident_resolved(sys_id: str) -> bool:
1590
  if not instance_url:
1591
  print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
1592
  return False
 
1593
  headers = {
1594
  "Authorization": f"Bearer {token}",
1595
  "Accept": "application/json",
1596
  "Content-Type": "application/json",
1597
  }
1598
  url = f"{instance_url}/api/now/table/incident/{sys_id}"
 
1599
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
1600
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
1601
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
1602
  resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
1603
  assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
1604
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
 
1605
  if require_progress:
1606
  try:
1607
  resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
@@ -1612,6 +1587,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
1612
  def clean(d: dict) -> dict:
1613
  return {k: v for k, v in d.items() if v is not None}
1614
 
 
1615
  payload_A = clean({
1616
  "state": "6",
1617
  "close_code": close_code_val,
@@ -1627,6 +1603,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
1627
  return True
1628
  print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
1629
 
 
1630
  payload_B = clean({
1631
  "state": "Resolved",
1632
  "close_code": close_code_val,
@@ -1642,6 +1619,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
1642
  return True
1643
  print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
1644
 
 
1645
  code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
1646
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
1647
  payload_C = clean({
@@ -1658,7 +1636,9 @@ def _set_incident_resolved(sys_id: str) -> bool:
1658
  if respC.status_code in (200, 204):
1659
  return True
1660
  print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
 
1661
  return False
 
1662
  except Exception as e:
1663
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
1664
  return False
 
23
  from public_api.utils import flatten_json, extract_fields
24
  from public_api.field_mapping import TOOL_REGISTRY, ALL_TOOLS
25
 
 
26
  # KB services
27
  from services.kb_creation import (
28
  collection,
 
38
  from services.login import router as login_router
39
  from services.generate_ticket import get_valid_token, create_incident
40
 
41
+
42
+ # =============================================================================
43
+ # Environment / Gemini config
44
+ # =============================================================================
45
  load_dotenv()
46
+
47
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
48
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
49
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
50
+
51
+ # ✔ Use header-based auth (safer than embedding the key in the URL)
52
+ # You can switch model id if needed (e.g., "gemini-2.5-flash-lite"), but
53
+ # standard "flash" is generally stronger with function-calling.
54
  GEMINI_URL = (
55
  "https://generativelanguage.googleapis.com/v1beta/models/"
56
+ "gemini-2.5-flash:generateContent"
57
  )
58
+
59
  os.environ["POSTHOG_DISABLED"] = "true"
60
 
61
  # --- API WMS session cache ---
62
  _WMS_SESSION_DATA = None
63
  _WMS_WAREHOUSE_ID = None
64
 
65
+
66
+ def _gemini_post(payload: dict, timeout: int = 25):
67
+ """
68
+ Central helper to call Gemini with proper headers and SSL behavior.
69
+ """
70
+ if not GEMINI_API_KEY:
71
+ # Return an object mimicking requests.Response for graceful handling
72
+ return type(
73
+ "Resp",
74
+ (),
75
+ {
76
+ "status_code": 401,
77
+ "json": lambda: {"error": {"message": "Missing GEMINI_API_KEY"}},
78
+ "text": "Missing GEMINI_API_KEY",
79
+ "raise_for_status": lambda: None,
80
+ },
81
+ )()
82
+ headers = {"Content-Type": "application/json", "x-goog-api-key": GEMINI_API_KEY}
83
+ return requests.post(GEMINI_URL, headers=headers, json=payload, timeout=timeout, verify=GEMINI_SSL_VERIFY)
84
+
85
+
86
  def _ensure_wms_session() -> bool:
87
  """Login once to WMS and cache session_data + default warehouse."""
88
  global _WMS_SESSION_DATA, _WMS_WAREHOUSE_ID
 
98
  print("[WMS] login failed:", e)
99
  return False
100
 
101
+
102
+ # =============================================================================
103
+ # Minimal server-side cache (used to populate short description later)
104
+ # =============================================================================
105
  LAST_ISSUE_HINT: str = ""
106
 
107
 
 
112
  return "<error stringify failed>"
113
 
114
 
115
+ # =============================================================================
116
  # App / Lifespan
117
+ # =============================================================================
118
  @asynccontextmanager
119
  async def lifespan(app: FastAPI):
120
  try:
 
134
 
135
  # CORS
136
  origins = [
137
+ "https://chatbotnova-chatbot-frontend.hf.space", # HF frontend Space URL
138
  # "http://localhost:5173", # local dev if needed
139
  ]
140
  app.add_middleware(
 
145
  allow_headers=["*"],
146
  )
147
 
148
+ # =============================================================================
149
  # Models
150
+ # =============================================================================
151
  class ChatInput(BaseModel):
152
  user_message: str
153
  prev_status: Optional[str] = None
 
178
  "8": "Canceled",
179
  }
180
 
181
+ # =============================================================================
182
  # Generic helpers (shared)
183
+ # =============================================================================
184
  NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
185
  DOMAIN_STATUS_TERMS = (
186
  "shipment", "order", "load", "trailer", "wave",
 
188
  "dock", "door", "manifest", "pallet", "container",
189
  "asn", "grn", "pick", "picking",
190
  )
191
+
192
  ERROR_FAMILY_SYNS = {
193
  "NOT_FOUND": (
194
  "not found", "missing", "does not exist", "doesn't exist",
 
219
  }
220
 
221
 
 
 
222
  def _try_wms_tool(user_text: str) -> dict | None:
223
  """
224
  Uses Gemini function calling + TOOL_REGISTRY to decide if a WMS tool should be called.
225
  Returns YOUR existing response dict shape. If no tool match or any error -> returns None.
226
  """
 
227
  # Guards
228
  if not GEMINI_API_KEY:
229
  return None
230
  if not _ensure_wms_session():
231
  return None
232
 
233
+ # Prepare default warehouse from cached session
234
+ session_data = _WMS_SESSION_DATA
235
+ default_wh = session_data.get("user_warehouse_id") if session_data else None
236
+
237
  payload = {
238
+ "contents": [{"role": "user", "parts": [{"text": user_text}]}],
239
+ "tools": [{"functionDeclarations": ALL_TOOLS}], # from public_api.field_mapping
240
+ "toolConfig": {"functionCallingConfig": {"mode": "ANY"}}, # nudge function calling
 
 
241
  }
242
 
243
  try:
244
+ resp = _gemini_post(payload, timeout=25)
 
 
 
 
 
 
 
245
  resp.raise_for_status()
246
  data = resp.json()
247
 
 
249
  if not candidates:
250
  return None
251
 
 
252
  content = candidates[0].get("content", {})
253
  parts = content.get("parts", [])
254
  part = parts[0] if parts else {}
 
 
255
  fn_call = part.get("functionCall")
256
  if not fn_call or "name" not in fn_call:
257
  return None
 
261
 
262
  cfg = TOOL_REGISTRY.get(tool_name)
263
  if not cfg:
 
264
  return None
265
 
266
+ # Resolve warehouse override, else use session default
267
  wh = (
268
  args.pop("warehouse_id", None)
269
  or args.pop("warehouseId", None)
270
  or args.pop("wh_id", None)
271
+ or default_wh
272
  )
273
 
274
+ # Get callable
275
  tool_fn = cfg.get("function")
276
  if not callable(tool_fn):
 
277
  return {
278
  "bot_response": f"⚠️ Tool '{tool_name}' is not callable. Check TOOL_REGISTRY['{tool_name}']['function'].",
279
  "status": "PARTIAL",
 
287
  "source": "ERROR",
288
  }
289
 
290
+ # ---- Correct invocation according to your real function signatures ----
291
+ # get_inventory_holds(session_data, warehouse_id, **kwargs)
292
+ # get_location_status(session_data, warehouse_id, stoloc)
293
+ # get_item(session_data, warehouse_id, item_number, **kwargs)
294
+ if tool_name == "get_inventory_holds":
295
+ call_kwargs = {**args} # lodnum / subnum / dtlnum
296
+ raw = tool_fn(session_data, wh, **call_kwargs)
297
+
298
+ elif tool_name == "get_location_status":
299
+ stoloc = args.get("stoloc")
300
+ raw = tool_fn(session_data, wh, stoloc)
301
+
302
  else:
303
+ id_param = cfg.get("id_param") # "item_number"
304
  target = args.get(id_param)
305
  if id_param and target is None:
306
  return {
 
315
  "debug": {"intent": "wms_missing_id_param", "tool": tool_name},
316
  "source": "CLIENT",
317
  }
318
+ extra_kwargs = {k: v for k, v in args.items() if k != id_param}
319
+ raw = tool_fn(session_data, wh, target, **extra_kwargs)
320
 
321
+ # ---- Tool-layer error handling ----
 
 
 
 
 
 
 
 
 
 
 
322
  if isinstance(raw, dict) and raw.get("error"):
323
  return {
324
  "bot_response": f"⚠️ {raw['error']}",
 
333
  "source": "ERROR",
334
  }
335
 
336
+ # ---- Extract rows from response ----
337
  response_key = cfg.get("response_key", "data")
338
  data_list = (raw.get(response_key, []) if isinstance(raw, dict) else []) or []
339
  if not data_list:
 
350
  "source": "LIVE_API",
351
  }
352
 
353
+ # ---- Table rendering (holds + location) ----
354
  if tool_name in ("get_inventory_holds", "get_location_status"):
355
+ rows_for_text, total_qty = [], 0
 
 
356
  for item in data_list:
357
  flat = flatten_json(item)
358
  row = extract_fields(flat, cfg["mapping"], cfg.get("formatters", {}))
359
  rows_for_text.append("• " + "; ".join(f"{k}: {v}" for k, v in row.items()))
360
 
361
  if tool_name == "get_inventory_holds":
 
362
  def _to_int(x):
363
  try:
364
  return int(x or 0)
365
  except Exception:
366
  return 0
 
367
  total_qty = sum(_to_int(it.get("untqty", 0)) for it in data_list)
368
  msg = f"Found {len(rows_for_text)} hold records (Total Qty: {total_qty}).\n" + "\n".join(rows_for_text[:10])
369
  else:
 
386
  "show_export": True if tool_name == "get_inventory_holds" else False,
387
  }
388
 
389
+ # ---- Item summary + one-row table ----
390
  flat = flatten_json(data_list[0])
 
391
  requested = args.get("fields", [])
392
  if requested:
393
  filtered_map = {k: v for k, v in cfg["mapping"].items() if k in requested or k == "Item"}
 
418
  }
419
 
420
  except requests.HTTPError as e:
 
421
  return {
422
  "bot_response": f"Gemini API error: {e}",
423
  "status": "PARTIAL",
 
434
  print("[WMS] tool call error:", e)
435
  return None
436
 
437
+
438
  def _detect_error_families(msg: str) -> list:
439
  low = (msg or "").lower()
440
  low_norm = re.sub(r"[^\w\s]", " ", low)
 
499
 
500
 
501
  def _split_sentences(block: str) -> list:
502
+ parts = [t.strip() for t in re.split(r"(?<=\w[.!?])\s+", block or "") if t.strip()]
503
  return parts if parts else ([block.strip()] if (block or "").strip() else [])
504
 
505
 
 
511
  para = " ".join(lines).strip()
512
  if not para:
513
  return ""
514
+
515
+ # Remove existing numbering/bullets to rebuild consistently
516
+ para_clean = re.sub(r"(?:\b\d+\s*[\.\)])\s+", "\n\n\n", para) # 1. / 1)
517
+ para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
518
+ para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:?\s*", "\n\n\n", para_clean) # Step 1:
519
+
520
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
521
  if len(segments) < 2:
522
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
523
+ segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=\w[.!?])\s+", para) if seg.strip()]
524
 
525
  def strip_prefix_any(s: str) -> str:
526
  return re.sub(
527
  r"^\s*(?:"
528
+ r"(?:\d+\s*[\.\)])|" # 1. / 1)
529
+ r"(?i:step\s*\d+:?)|" # Step 1:
530
+ r"(?:[-*\u2022])|" # bullets
531
+ r"(?:[\u2460-\u2473])" # circled digits
532
  r")\s*",
533
  "",
534
  (s or "").strip(),
535
  )
536
 
537
  clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
538
+ circled = {i: chr(9311 + i) for i in range(1, 21)} # \u2460.. \u2473
 
 
 
 
 
539
  out = []
540
  for idx, seg in enumerate(clean_segments, start=1):
541
  marker = circled.get(idx, f"{idx})")
 
543
  return "\n".join(out)
544
 
545
 
546
+ def _similarity(a: str, b: str) -> float:
547
+ def _norm_text(s: str) -> str:
548
+ s = (s or "").lower()
549
+ s = re.sub(r"[^\w\s]", " ", s)
550
+ s = re.sub(r"\s+", " ", s).strip()
551
  return s
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
 
553
  a_norm, b_norm = _norm_text(a), _norm_text(b)
554
  ta, tb = set(a_norm.split()), set(b_norm.split())
555
  inter = len(ta & tb)
 
563
  big_inter = len(ab & bb)
564
  big_union = len(ab | bb) or 1
565
  big = big_inter / big_union
566
+
567
  char = SequenceMatcher(None, a_norm, b_norm).ratio()
568
  return min(1.0, 0.45 * jacc + 0.30 * big + 0.35 * char)
569
 
570
 
571
  def _extract_anchor_from_query(msg: str) -> dict:
572
  raw = (msg or "").strip()
573
+ low = re.sub(r"[^\w\s]", " ", raw.lower()).strip()
574
+
575
  FOLLOWUP_CUES = ("what next", "what is next", "what to do", "then", "after that", "next")
576
  has_followup = any(cue in low for cue in FOLLOWUP_CUES)
577
 
 
580
  return {"anchor": raw, "has_followup": has_followup}
581
 
582
  last = parts[-1]
583
+ last_low = re.sub(r"[^\w\s]", " ", last.lower()).strip()
584
  if any(cue in last_low for cue in FOLLOWUP_CUES) and len(parts) >= 2:
585
  anchor = parts[-2]
586
  else:
587
  anchor = parts[-1] if len(parts) > 1 else parts[0]
 
588
  return {"anchor": anchor.strip(), "has_followup": has_followup}
589
 
590
 
591
  def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8) -> list | None:
592
+ steps = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
593
  if not steps:
594
  return None
595
 
 
597
  anchor = info.get("anchor", "").strip()
598
  if not anchor:
599
  return None
 
 
600
 
601
+ has_followup = bool(info.get("has_followup"))
602
  candidates = []
603
  for idx, step_line in enumerate(steps):
604
  s_full = _similarity(anchor, step_line)
 
606
  scores = [s_full]
607
  for s in _split_sentences(step_line):
608
  scores.append(_similarity(anchor, s))
609
+ a_flat = re.sub(r"\W+", "", re.sub(r"[^\w\s]", " ", anchor.lower()))
610
+ s_flat = re.sub(r"\W+", "", re.sub(r"[^\w\s]", " ", s.lower()))
611
+ if a_flat and (a_flat in s_flat or s_flat in a_flat):
612
+ literal_hit = True
613
  score = max(scores)
614
  candidates.append((idx, score, literal_hit))
615
 
616
  candidates.sort(key=lambda t: (t[1], t[0]), reverse=True)
617
  best_idx, best_score, best_literal = candidates[0]
618
 
619
+ tok_count = len([t for t in re.sub(r"[^\w\s]", " ", anchor.lower()).split() if len(t) > 1])
620
  if best_literal:
621
  accept = True
622
  else:
623
  base_ok = best_score >= (0.55 if not has_followup else 0.50)
624
  len_ok = (best_score >= 0.40) and (tok_count >= 3)
625
  accept = base_ok or len_ok
626
+
627
  if not accept:
628
  return None
629
 
 
647
  return t
648
 
649
  def _split_sents(ctx: str) -> List[str]:
650
+ raw_sents = re.split(r"(?<=\w[.!?])\s+", ctx or "")
651
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
652
 
653
  ctx = (context or "").strip()
654
  if not ctx or not query:
655
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
656
+
657
  q_norm = _norm(query)
658
  q_terms = [t for t in q_norm.split() if len(t) > 2]
659
  if not q_terms:
 
663
  matched_exact, matched_any = [], []
664
  for s in sentences:
665
  s_norm = _norm(s)
666
+ is_bullet = bool(re.match(r"^[-*\u2022]\s*", s))
667
  overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
668
  if overlap >= STRICT_OVERLAP:
669
  matched_exact.append(s)
 
675
  return _dedupe_lines("\n".join(kept).strip()), {
676
  'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences),
677
  }
678
+
679
  if matched_any:
680
  kept = matched_any[:MAX_SENTENCES_CONCISE]
681
  return _dedupe_lines("\n".join(kept).strip()), {
682
  'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences),
683
  }
684
+
685
  kept = sentences[:MAX_SENTENCES_CONCISE]
686
  return _dedupe_lines("\n".join(kept).strip()), {
687
  'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences),
 
691
  def _extract_errors_only(text: str, max_lines: int = 12) -> str:
692
  kept: List[str] = []
693
  for ln in _normalize_lines(text):
694
+ if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
695
  kept.append(ln)
696
  if len(kept) >= max_lines:
697
  break
 
719
  lines = _normalize_lines(text)
720
  if not lines:
721
  return None
722
+
723
  start_idx = None
724
  for i, ln in enumerate(lines):
725
  low = ln.lower()
726
  if "escalation" in low or "escalation path" in low or "escalate" in low:
727
  start_idx = i
728
  break
729
+
730
  block: List[str] = []
731
  if start_idx is not None:
732
  for j in range(start_idx, min(len(lines), start_idx + 6)):
 
737
  block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
738
  if not block:
739
  return None
740
+
741
  text_block = " ".join(block)
742
  m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
743
  path = m.group(1).strip() if m else None
744
+
745
  if not path:
746
  arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
747
  if arrow_lines:
 
751
  path = m2.group(1).strip() if m2 else None
752
  if not path:
753
  return None
754
+
755
  path = path.replace("->", "→").strip()
756
  path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
757
  return f"If you want to escalate the issue, follow: {path}"
 
766
 
767
 
768
  def _build_clarifying_message() -> str:
769
+ return (
770
+ "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
771
+ "or should I raise a ServiceNow ticket for you?"
772
+ )
773
 
774
 
775
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
 
782
  issue = (issue_text or "").strip()
783
  resolved = (resolved_text or "").strip()
784
 
785
+ # Detect process/SOP query
786
  is_process_query = bool(re.search(r"\b(how to|steps|procedure|process)\b", issue.lower()))
787
 
 
788
  if is_process_query:
789
+ # Avoid "Process Steps Process Steps" duplication
790
  cleaned_issue = re.sub(r"^\s*(process\s*steps\s*[-–:]?\s*)", "", issue, flags=re.IGNORECASE).strip()
791
  short_desc = f"Process Steps – {cleaned_issue or issue}"[:100]
792
  else:
 
793
  short_desc = issue[:100]
794
 
795
  long_desc = (
 
800
 
801
  return short_desc, long_desc
802
 
803
+
804
  def _is_incident_intent(msg_norm: str) -> bool:
805
  intent_phrases = [
806
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
 
820
  )
821
  if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
822
  return {}
823
+
824
  patterns = [
825
  r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
826
  r"(inc\d+)",
 
862
  return ""
863
 
864
 
865
+ # =============================================================================
866
  # Health
867
+ # =============================================================================
868
  @app.get("/")
869
  async def health_check():
870
  return {"status": "ok"}
871
 
872
 
873
+ # =============================================================================
874
  # Chat
875
+ # =============================================================================
876
  @app.post("/chat")
877
  async def chat_with_ai(input_data: ChatInput):
878
+ global LAST_ISSUE_HINT # ensure global declared before any assignment/use
879
  assist_followup: Optional[str] = None
880
+
881
  try:
882
  msg_norm = (input_data.user_message or "").lower().strip()
883
 
 
890
  "options": [],
891
  "debug": {"intent": "continue_conversation"},
892
  }
893
+
894
  if msg_norm in ("no", "no thanks", "nope"):
895
  return {
896
  "bot_response": "No problem. Do you need assistance with any other issue?",
 
905
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
906
  if _has_negation_resolved(msg_norm):
907
  is_llm_resolved = False
908
+
909
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
910
  try:
911
  # Prefer cached issue hint if frontend didn't pass last_issue
 
996
  "sources": [],
997
  "debug": {"intent": "status_request_missing_id"},
998
  }
999
+
1000
  try:
1001
  token = get_valid_token()
1002
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
 
1031
  }
1032
  except Exception as e:
1033
  raise HTTPException(status_code=500, detail=safe_str(e))
1034
+
1035
+ # --- Try WMS API tools BEFORE KB search ---
1036
  res = _try_wms_tool(input_data.user_message)
1037
  if res is not None:
1038
+ return res
 
1039
 
1040
+ # ---------------- Hybrid KB search ----------------
1041
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
1042
  documents = kb_results.get("documents", [])
1043
  metadatas = kb_results.get("metadatas", [])
 
1061
 
1062
  selected = items[:max(1, 2)]
1063
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
1064
+
1065
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
1066
  context = filtered_text
1067
  context_found = bool(context.strip())
1068
 
1069
  best_distance = (min([d for d in distances if d is not None], default=None) if distances else None)
1070
  best_combined = (max([c for c in combined if c is not None], default=None) if combined else None)
 
1071
  detected_intent = kb_results.get("user_intent", "neutral")
1072
  best_doc = kb_results.get("best_doc")
1073
  top_meta = (metadatas or [{}])[0] if metadatas else {}
1074
+
1075
  msg_low = (input_data.user_message or "").lower()
1076
  GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
1077
  generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
 
1095
  detected_intent = "errors"
1096
 
1097
  # heading-aware prereq nudge
1098
+ sec_title = (top_meta or {}).get("section", "") or ""
1099
+ sec_title_low = sec_title.strip().lower()
1100
  PREREQ_HEADINGS = ("pre-requisites", "prerequisites", "pre requisites", "pre-requirements", "requirements")
1101
+ if detected_intent == "neutral" and any(h in sec_title_low for h in PREREQ_HEADINGS):
1102
  detected_intent = "prereqs"
1103
 
 
1104
  def _contains_any(s: str, keywords: tuple) -> bool:
1105
  low = (s or "").lower()
1106
  return any(k in low for k in keywords)
 
1116
  "confirm", "generate", "update", "receive", "receiving", "error", "issue", "fail", "failed",
1117
  "not working", "locked", "mismatch", "access", "permission", "status",
1118
  )
1119
+
1120
  matched_count = int(filt_info.get("matched_count") or 0)
1121
  filter_mode = (filt_info.get("mode") or "").lower()
1122
  has_any_action_or_error = _contains_any(msg_low, ACTION_OR_ERROR_TERMS)
1123
  mentions_domain = _contains_any(msg_low, DOMAIN_TERMS)
1124
+
1125
  short_query = len((input_data.user_message or "").split()) <= 4
1126
  gate_combined_ok = 0.60 if short_query else 0.55
1127
  combined_ok = (best_combined is not None and best_combined >= gate_combined_ok)
1128
+
1129
  weak_domain_only = (mentions_domain and not has_any_action_or_error)
1130
  low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
1131
  strong_steps_bypass = True
 
1157
  },
1158
  }
1159
 
1160
+ # Build SOP/Errors/Prereqs context
1161
  escalation_line: Optional[str] = None
1162
  full_errors: Optional[str] = None
1163
  next_step_applied = False
 
1171
  full_steps = get_section_text(best_doc, sec)
1172
  else:
1173
  full_steps = get_best_steps_section_text(best_doc)
 
1174
  if full_steps:
1175
  numbered_full = _ensure_numbering(full_steps)
1176
 
1177
  raw_actions = set((kb_results.get("actions") or []))
1178
  msg_low2 = (input_data.user_message or "").lower()
 
1179
  if not raw_actions and ("creation" in msg_low2 or "create" in msg_low2 or "set up" in msg_low2 or "setup" in msg_low2):
1180
  raw_actions = {"create"}
1181
  elif not raw_actions and ("update" in msg_low2 or "modify" in msg_low2 or "edit" in msg_low2 or "change" in msg_low2):
 
1210
  numbered_full = before
1211
 
1212
  next_only = _anchor_next_steps(input_data.user_message, numbered_full, max_next=6)
 
1213
  if next_only is not None:
1214
  if len(next_only) == 0:
1215
  context = "You are at the final step of this SOP. No further steps."
 
1217
  next_step_info = {"count": 0}
1218
  context_preformatted = True
1219
  else:
1220
+ context = _dedupe_lines(_ensure_numbering("\n".join(next_only)))
 
1221
  next_step_applied = True
1222
  next_step_info = {"count": len(next_only)}
1223
  context_preformatted = True
 
1237
  flags=re.IGNORECASE,
1238
  ))
1239
  )
 
1240
  if said_not_resolved:
1241
  return {
1242
+ "bot_response": "Select an option below.",
1243
  "status": "OK",
1244
  "context_found": False,
1245
  "ask_resolved": False,
 
1250
  "top_hits": [],
1251
  "sources": [],
1252
  "debug": {
1253
+ "intent": "errors_not_resolved",
1254
+ "best_doc": best_doc,
1255
  },
1256
  }
1257
 
1258
  full_errors = get_best_errors_section_text(best_doc)
1259
  if full_errors:
1260
  ctx_err = _extract_errors_only(full_errors, max_lines=30)
 
1261
  if is_perm_query:
1262
  context = _filter_permission_lines(ctx_err, max_lines=6)
1263
  else:
 
 
 
 
 
 
1264
  mentions_domain_local = any(t in msg_low for t in DOMAIN_TERMS)
 
1265
  is_specific_error = (len(_detect_error_families(msg_low)) > 0) or mentions_domain_local
1266
  if is_specific_error:
1267
  context = _filter_context_for_query(ctx_err, input_data.user_message)[0]
 
1269
  all_lines = _normalize_lines(ctx_err)
1270
  error_bullets = [
1271
  ln for ln in all_lines
1272
+ if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln)
1273
  ]
1274
  context = "\n".join(error_bullets[:6]).strip()
1275
  else:
1276
  all_lines = _normalize_lines(ctx_err)
1277
  error_bullets = [
1278
  ln for ln in all_lines
1279
+ if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln)
1280
  ]
1281
  context = "\n".join(error_bullets[:6]).strip()
1282
 
 
1293
  numbered_steps = _ensure_numbering(steps_src)
1294
  next_only = _anchor_next_steps(input_data.user_message, numbered_steps, max_next=6)
1295
  if next_only is not None and len(next_only) > 0:
1296
+ context = _dedupe_lines(_ensure_numbering("\n".join(next_only)))
 
1297
  context_preformatted = True
1298
  next_step_applied = True
1299
  next_step_info = {"count": len(next_only), "source": "errors_domain_override"}
 
1307
  context = full_prereqs.strip()
1308
  context_found = True
1309
 
1310
+ # language hint & paraphrase (errors only when not overridden)
1311
  language_hint = _detect_language_hint(input_data.user_message)
1312
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
1313
  use_gemini = (detected_intent == "errors") and not steps_override_applied
1314
+
1315
  enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
1316
  - Do not add any information that is not present in the context.
1317
  - If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
 
1322
  {input_data.user_message}
1323
  ### Output
1324
  Return ONLY the rewritten guidance."""
 
 
1325
  bot_text = ""
1326
  http_code = 0
1327
 
1328
  if use_gemini and GEMINI_API_KEY:
1329
  try:
1330
+ payload = {"contents": [{"role": "user", "parts": [{"text": enhanced_prompt}]}]}
1331
+ resp = _gemini_post(payload, timeout=25)
1332
  http_code = getattr(resp, "status_code", 0)
1333
  try:
1334
  result = resp.json()
 
1343
 
1344
  # deterministic local formatting
1345
  if detected_intent == "steps":
1346
+ bot_text = context if context_preformatted else _ensure_numbering(context)
 
 
 
1347
  elif detected_intent == "errors":
1348
  if not (bot_text or "").strip() or http_code == 429:
1349
  bot_text = context.strip()
 
1375
  short_query = len((input_data.user_message or "").split()) <= 4
1376
  gate_combined_ok = 0.60 if short_query else 0.55
1377
  status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
1378
+
1379
  lower = (bot_text or "").lower()
1380
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
1381
  status = "PARTIAL"
1382
 
1383
  options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
1384
 
1385
+ # --- Cache last issue hint (used if user later says "issue resolved thanks")
 
1386
  try:
1387
+ base_query = (input_data.user_message or "").strip()
1388
+ if detected_intent == "steps":
1389
+ LAST_ISSUE_HINT = base_query[:100]
1390
+ elif detected_intent == "errors":
1391
+ shown_lines = [ln.strip() for ln in (bot_text or "").splitlines() if ln.strip()]
1392
+ top_error_line = ""
1393
+ for ln in shown_lines:
1394
+ if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
1395
+ low = ln.lower()
1396
+ if "escalation" in low:
1397
+ continue # skip escalation sentences
1398
+ top_error_line = ln
1399
+ break
1400
+ if top_error_line:
1401
+ top_error_line = re.split(r"\bif you want to escalate\b", top_error_line, flags=re.IGNORECASE)[0].strip()
1402
+ if top_error_line:
1403
+ LAST_ISSUE_HINT = f"{base_query} — {top_error_line}"
1404
+ else:
1405
+ LAST_ISSUE_HINT = base_query
1406
+ LAST_ISSUE_HINT = LAST_ISSUE_HINT[:100]
 
 
 
 
 
 
1407
  except Exception:
1408
  pass
1409
 
 
1436
  raise HTTPException(status_code=500, detail=safe_str(e))
1437
 
1438
 
1439
+ # =============================================================================
1440
  # Ticket description generation
1441
+ # =============================================================================
1442
  @app.post("/generate_ticket_desc")
1443
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
1444
  try:
 
1451
  "}\n"
1452
  "Do not include any extra text, comments, or explanations outside the JSON."
1453
  )
1454
+ payload = {"contents": [{"role": "user", "parts": [{"text": prompt}]}]}
1455
+ resp = _gemini_post(payload, timeout=25)
1456
+
1457
  try:
1458
  data = resp.json()
1459
  except Exception:
1460
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
1461
+
1462
  try:
1463
  text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
1464
  except Exception:
1465
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
1466
+
1467
  if text.startswith("```"):
1468
  lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
1469
  text = "\n".join(lines).strip()
1470
+
1471
  try:
1472
  ticket_json = json.loads(text)
1473
  return {
 
1476
  }
1477
  except Exception:
1478
  return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
1479
+
1480
  except Exception as e:
1481
  raise HTTPException(status_code=500, detail=safe_str(e))
1482
 
1483
 
1484
+ # =============================================================================
1485
  # Incident status
1486
+ # =============================================================================
1487
  @app.post("/incident_status")
1488
  async def incident_status(input_data: TicketStatusInput):
1489
  try:
 
1491
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
1492
  if not instance_url:
1493
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
1494
+
1495
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
1496
+
1497
  if input_data.sys_id:
1498
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
1499
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
1500
  data = response.json()
1501
  result = data.get("result", {}) if response.status_code == 200 else {}
1502
+
1503
  elif input_data.number:
1504
  url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
1505
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
1506
  data = response.json()
1507
  lst = data.get("result", [])
1508
  result = (lst or [{}])[0] if response.status_code == 200 else {}
1509
+
1510
  else:
1511
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
1512
+
1513
  state_code = builtins.str(result.get("state", "unknown"))
1514
  state_label = STATE_MAP.get(state_code, state_code)
1515
  short = result.get("short_description", "")
1516
  number = result.get("number", input_data.number or "unknown")
1517
+
1518
  return {
1519
  "bot_response": (
1520
  f"**Ticket:** {number} \n"
 
1526
  "persist": True,
1527
  "debug": "Incident status fetched",
1528
  }
1529
+
1530
  except Exception as e:
1531
  raise HTTPException(status_code=500, detail=safe_str(e))
1532
 
1533
 
1534
+ # =============================================================================
1535
  # Incident creation
1536
+ # =============================================================================
1537
  def _classify_resolution_llm(user_message: str) -> bool:
1538
  if not GEMINI_API_KEY:
1539
  return False
1540
  prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
1541
  Return only 'true' or 'false'.
1542
  Message: {user_message}"""
1543
+ payload = {"contents": [{"role": "user", "parts": [{"text": prompt}]}]}
 
1544
  try:
1545
+ resp = _gemini_post(payload, timeout=12)
1546
  data = resp.json()
1547
  text = (
1548
  data.get("candidates", [{}])[0]
 
1562
  if not instance_url:
1563
  print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
1564
  return False
1565
+
1566
  headers = {
1567
  "Authorization": f"Bearer {token}",
1568
  "Accept": "application/json",
1569
  "Content-Type": "application/json",
1570
  }
1571
  url = f"{instance_url}/api/now/table/incident/{sys_id}"
1572
+
1573
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
1574
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
1575
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
1576
  resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
1577
  assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
1578
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
1579
+
1580
  if require_progress:
1581
  try:
1582
  resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
 
1587
  def clean(d: dict) -> dict:
1588
  return {k: v for k, v in d.items() if v is not None}
1589
 
1590
+ # Attempt A: numeric state
1591
  payload_A = clean({
1592
  "state": "6",
1593
  "close_code": close_code_val,
 
1603
  return True
1604
  print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
1605
 
1606
+ # Attempt B: string state (some instances prefer labels)
1607
  payload_B = clean({
1608
  "state": "Resolved",
1609
  "close_code": close_code_val,
 
1619
  return True
1620
  print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
1621
 
1622
+ # Attempt C: configurable field names
1623
  code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
1624
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
1625
  payload_C = clean({
 
1636
  if respC.status_code in (200, 204):
1637
  return True
1638
  print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
1639
+
1640
  return False
1641
+
1642
  except Exception as e:
1643
  print(f"[SN PATCH resolve] exception={safe_str(e)}")
1644
  return False