srilakshu012456 commited on
Commit
39b3cce
·
verified ·
1 Parent(s): 84e70a3

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +612 -392
main.py CHANGED
@@ -12,7 +12,6 @@ from pydantic import BaseModel
12
  from dotenv import load_dotenv
13
  from datetime import datetime
14
 
15
- # ------------------------------ Imports ------------------------------
16
  # Import shared vocab from KB services
17
  from services.kb_creation import ACTION_SYNONYMS, MODULE_VOCAB
18
  from services.kb_creation import (
@@ -23,24 +22,25 @@ from services.kb_creation import (
23
  get_best_steps_section_text,
24
  get_best_errors_section_text,
25
  get_escalation_text, # for escalation heading
26
- bm25_docs,
27
  )
28
  from services.login import router as login_router
29
  from services.generate_ticket import get_valid_token, create_incident
30
 
31
- # ------------------------------ Config ------------------------------
32
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
33
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
34
 
 
35
  def safe_str(e: Any) -> str:
36
  try:
37
  return builtins.str(e)
38
  except Exception:
39
  return "<error stringify failed>"
40
 
 
41
  load_dotenv()
42
  os.environ["POSTHOG_DISABLED"] = "true"
43
 
 
44
  @asynccontextmanager
45
  async def lifespan(app: FastAPI):
46
  try:
@@ -54,6 +54,7 @@ async def lifespan(app: FastAPI):
54
  print(f"[KB] ingestion failed: {safe_str(e)}")
55
  yield
56
 
 
57
  app = FastAPI(lifespan=lifespan)
58
  app.include_router(login_router)
59
 
@@ -72,18 +73,22 @@ class ChatInput(BaseModel):
72
  prev_status: Optional[str] = None
73
  last_issue: Optional[str] = None
74
 
 
75
  class IncidentInput(BaseModel):
76
  short_description: str
77
  description: str
78
  mark_resolved: Optional[bool] = False
79
 
 
80
  class TicketDescInput(BaseModel):
81
  issue: str
82
 
 
83
  class TicketStatusInput(BaseModel):
84
  sys_id: Optional[str] = None
85
  number: Optional[str] = None
86
 
 
87
  STATE_MAP = {
88
  "1": "New",
89
  "2": "In Progress",
@@ -102,6 +107,7 @@ GEMINI_URL = (
102
  # ------------------------------ Helpers ------------------------------
103
  NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
104
 
 
105
  DOMAIN_STATUS_TERMS = (
106
  "shipment", "order", "load", "trailer", "wave",
107
  "inventory", "putaway", "receiving", "appointment",
@@ -109,6 +115,7 @@ DOMAIN_STATUS_TERMS = (
109
  "asn", "grn", "pick", "picking"
110
  )
111
 
 
112
  ERROR_FAMILY_SYNS = {
113
  "NOT_FOUND": (
114
  "not found", "missing", "does not exist", "doesn't exist",
@@ -137,10 +144,13 @@ ERROR_FAMILY_SYNS = {
137
  ),
138
  }
139
 
140
- # Runtime synonym extension
 
141
  ACTION_SYNONYMS_EXT: Dict[str, List[str]] = {}
142
  for k, v in ACTION_SYNONYMS.items():
143
- ACTION_SYNONYMS_EXT[k] = list(v)
 
 
144
  ACTION_SYNONYMS_EXT.setdefault("create", []).extend(["appointment creation", "create appointment"])
145
  ACTION_SYNONYMS_EXT.setdefault("update", []).extend([
146
  "updation", "reschedule", "change time", "change date", "change slot",
@@ -148,7 +158,9 @@ ACTION_SYNONYMS_EXT.setdefault("update", []).extend([
148
  ])
149
  ACTION_SYNONYMS_EXT.setdefault("delete", []).extend(["deletion", "delete appointment", "cancel appointment"])
150
 
 
151
  def _detect_error_families(msg: str) -> list:
 
152
  low = (msg or "").lower()
153
  low_norm = re.sub(r"[^\w\s]", " ", low)
154
  low_norm = re.sub(r"\s+", " ", low_norm).strip()
@@ -158,11 +170,21 @@ def _detect_error_families(msg: str) -> list:
158
  fams.append(fam)
159
  return fams
160
 
161
- # ------------------------------ Title/section global helpers ------------------------------
 
 
 
162
  def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
 
 
 
 
 
 
163
  if not best_doc or not actions:
164
  return None
165
  act_set = set(a.strip().lower() for a in actions if a)
 
166
  candidates = []
167
  for d in bm25_docs:
168
  m = d.get("meta", {})
@@ -170,40 +192,24 @@ def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
170
  tag = (m.get("action_tag") or "").strip().lower()
171
  if tag and tag in act_set:
172
  candidates.append(m.get("section"))
 
173
  for title in candidates:
174
  txt = get_section_text(best_doc, title)
175
  if txt and txt.strip():
176
  return txt.strip()
177
  return None
178
 
179
- def _get_steps_by_action_tag(best_doc: str, action: Optional[str]) -> Optional[str]:
180
- """
181
- Generic: return the section text from `best_doc` whose meta.action_tag == action.
182
- No hard-coded SOP words; relies purely on KB metadata.
183
- """
184
- if not best_doc or not action:
185
- return None
186
- target = (action or "").strip().lower()
187
- for d in bm25_docs:
188
- m = d.get("meta", {}) or {}
189
- if m.get("intent_tag") != "steps":
190
- continue
191
- if (m.get("filename") or "") != best_doc:
192
- continue
193
- tag = (m.get("action_tag") or "").strip().lower()
194
- if tag and tag == target:
195
- sec = (m.get("section") or "").strip()
196
- if sec:
197
- txt = get_section_text(best_doc, sec)
198
- if txt and txt.strip():
199
- return txt.strip()
200
- return None
201
 
202
  def _get_steps_for_action_global(actions: list, prefer_doc: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
 
 
 
 
203
  if not actions:
204
  return None, None
205
  act_set = set(a.strip().lower() for a in actions if a)
206
- candidates: List[Tuple[float, str, str]] = []
 
207
  for d in bm25_docs:
208
  m = d.get("meta", {}) or {}
209
  if m.get("intent_tag") != "steps":
@@ -222,38 +228,25 @@ def _get_steps_for_action_global(actions: list, prefer_doc: Optional[str] = None
222
  if "appointments" in mtags:
223
  score += 0.3
224
  candidates.append((score, doc, txt.strip()))
 
225
  if not candidates:
226
  return None, None
 
227
  candidates.sort(key=lambda x: x[0], reverse=True)
228
  _, best_doc_global, best_text = candidates[0]
229
  return best_doc_global, best_text
230
 
 
231
  def _pick_default_action_section(best_doc: str) -> Optional[str]:
232
  """
233
- Backward-compatible wrapper: prefer-action version with no preference.
234
- """
235
- return _pick_default_action_section_with_preference(best_doc, None)
236
-
237
- def _pick_default_action_section_with_preference(best_doc: str, prefer_action: Optional[str]) -> Optional[str]:
238
- """
239
- Generic: if there are sections tagged with the requested action (via action_tag),
240
- prefer them; otherwise fall back to your original order.
241
  """
242
- # 1) Prefer exact action_tag match
243
- if prefer_action:
244
- for d in bm25_docs:
245
- m = d.get("meta", {}) or {}
246
- if m.get("filename") == best_doc and m.get("intent_tag") == "steps":
247
- tag = (m.get("action_tag") or "").strip().lower()
248
- sec = (m.get("section") or "").strip()
249
- if tag == prefer_action.lower() and sec:
250
- return sec
251
-
252
- # 2) Fall back to your original generic order (no SOP-specific words)
253
  order = ("creation", "updation", "update", "deletion", "delete", "cancel")
254
  sections = []
255
  for d in bm25_docs:
256
- m = d.get("meta", {}) or {}
257
  if m.get("filename") == best_doc and m.get("intent_tag") == "steps":
258
  title = (m.get("section") or "").strip().lower()
259
  if title:
@@ -264,146 +257,36 @@ def _pick_default_action_section_with_preference(best_doc: str, prefer_action: O
264
  return t
265
  return sections[0] if sections else None
266
 
267
- # ------------------------------ Optional title-based fallback ------------------------------
268
- ACTION_SECTION_KEYS = {
269
- "create": ("create", "creation", "appointment creation", "new appointment", "book", "schedule"),
270
- "update": ("update", "updation", "reschedule", "change", "modify", "edit"),
271
- "delete": ("delete", "deletion", "cancel", "remove", "void"),
272
- }
273
-
274
- def _pick_action_section(best_doc: str, action: Optional[str]) -> Optional[str]:
275
- if not best_doc or not action:
276
- return None
277
- keys = ACTION_SECTION_KEYS.get(action.lower(), ())
278
- if not keys:
279
- return None
280
- candidates = []
281
- for d in bm25_docs:
282
- m = d.get("meta", {}) or {}
283
- if m.get("filename") == best_doc and (m.get("intent_tag") == "steps"):
284
- sec = (m.get("section") or "").strip()
285
- if not sec:
286
- continue
287
- low = sec.lower()
288
- if any(k in low for k in keys):
289
- score = 1.0
290
- if action.lower() in low:
291
- score += 0.5
292
- mtags = (m.get("module_tags") or "").lower()
293
- if "appointments" in mtags:
294
- score += 0.2
295
- candidates.append((score, sec))
296
- if not candidates:
297
- return None
298
- candidates.sort(key=lambda x: x[0], reverse=True)
299
- return candidates[0][1]
300
 
301
- def _get_steps_text_for_action(best_doc: str, action: Optional[str]) -> Optional[str]:
302
- sec = _pick_action_section(best_doc, action)
303
- if not sec:
304
- return None
305
- txt = get_section_text(best_doc, sec)
306
- return (txt or "").strip() or None
307
-
308
- # ------------------------------ Save lines helpers ------------------------------
309
  SAVE_SYNS = ("save", "saving", "save changes", "click save", "press save", "select save")
310
 
311
- def _find_save_lines_in_section(section_text: str, max_lines: int = 2) -> str:
312
- lines: List[str] = []
313
- for ln in [x.strip() for x in (section_text or "").splitlines() if x.strip()]:
314
- low = ln.lower()
315
- if any(s in low for s in SAVE_SYNS):
316
- lines.append(ln)
317
- if len(lines) >= max_lines:
318
- break
319
- return "\n".join(lines)
320
-
321
- # ------------------------------ Generic boundary cutter (metadata + action-family) ------------------------------
322
- def _build_doc_section_index(best_doc: str) -> Dict[str, Optional[str]]:
323
  """
324
- Build a dictionary for the given doc:
325
- { lower(section_title): lower(action_tag or None) }
326
  """
327
- index: Dict[str, Optional[str]] = {}
328
  for d in bm25_docs:
329
- m = d.get("meta", {}) or {}
330
- if m.get("filename") == best_doc and m.get("intent_tag") == "steps":
331
- sec = (m.get("section") or "").strip()
332
- tag = (m.get("action_tag") or "").strip().lower() or None
333
- if sec:
334
- index[sec.lower()] = tag
335
- return index
336
-
337
- def _cut_at_next_boundary_generic(section_text: str, best_doc: str, current_action: Optional[str]) -> str:
338
- """
339
- Stop when we hit:
340
- 1) Any known section heading (from KB metadata) that belongs to a *different* action_tag, OR
341
- 2) Any heading-like OR numbered line that contains generic action keywords for a *different* action family.
342
-
343
- Generic & future-proof: no SOP-specific terms.
344
- """
345
- if not (section_text or "").strip():
346
- return section_text
347
-
348
- index = _build_doc_section_index(best_doc) # {lower(section_title): action_tag}
349
- known_headings = set(index.keys())
350
-
351
- ACTION_FAMILIES = {
352
- "create": ("create", "creation", "new"),
353
- "update": ("update", "updation", "reschedule", "edit", "modify", "change"),
354
- "delete": ("delete", "deletion", "cancel", "remove", "void"),
355
- }
356
-
357
- STEP_PREFIX_RX = re.compile(r"^\s*(?:[\u2460-\u2473]|\d+\s*[.)]|[-*•])")
358
-
359
- def detect_action_family_in_line(line_low: str) -> Optional[str]:
360
- for fam, toks in ACTION_FAMILIES.items():
361
- for t in toks:
362
- if re.search(rf"\b{re.escape(t)}\b", line_low, flags=re.IGNORECASE):
363
- return fam
364
- return None
365
-
366
- def is_heading_like(raw_line: str, line_low: str) -> bool:
367
- if (":" in raw_line) or ("–" in raw_line) or re.match(r"^\s*[-–]\s*", raw_line):
368
- return True
369
- if any(h in line_low for h in known_headings):
370
- return True
371
- if len(raw_line.strip()) <= 140:
372
- words = re.findall(r"[A-Za-z][A-Za-z]+", raw_line)
373
- cap_ratio = sum(1 for w in words if (w[0].isupper() or w.isupper())) / (len(words) or 1)
374
- if cap_ratio >= 0.40:
375
- return True
376
- return False
377
-
378
- lines = [ln for ln in (section_text or "").splitlines() if ln.strip()]
379
- out: List[str] = []
380
-
381
- for ln in lines:
382
- low = ln.lower().strip()
383
-
384
- # 1) Metadata heading boundary
385
- matched_heading = None
386
- for h in known_headings:
387
- if h in low:
388
- matched_heading = h
389
- break
390
-
391
- if matched_heading:
392
- next_action = index.get(matched_heading)
393
- if (current_action and next_action and next_action != current_action) or (current_action is None):
394
- break
395
 
396
- # 2) Generic action boundary (works even if visible text != metadata title)
397
- fam = detect_action_family_in_line(low)
398
- if current_action and fam and fam != current_action:
399
- if is_heading_like(ln, low) or STEP_PREFIX_RX.match(ln):
400
- break
401
 
402
- out.append(ln)
 
 
 
403
 
404
- return "\n".join(out).strip()
405
 
406
- # ------------------------------ Text normalization / numbering ------------------------------
407
  def _normalize_lines(text: str) -> List[str]:
408
  raw = (text or "")
409
  try:
@@ -411,29 +294,41 @@ def _normalize_lines(text: str) -> List[str]:
411
  except Exception:
412
  return [raw.strip()] if raw.strip() else []
413
 
 
414
  def _ensure_numbering(text: str) -> str:
 
 
 
 
415
  text = re.sub(r"[\u2060\u200B]", "", text or "")
416
  lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
417
  if not lines:
418
  return text or ""
 
 
419
  para = " ".join(lines).strip()
420
  if not para:
421
  return ""
422
- para_clean = re.sub(r"(?:\b\d+[.)]\s+)", "\n\n\n", para)
423
- para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean)
424
- para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean)
 
 
425
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
 
 
426
  if len(segments) < 2:
427
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
428
  segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|\s+;\s+", para) if seg.strip()]
429
 
 
430
  def strip_prefix_any(s: str) -> str:
431
  return re.sub(
432
  r"^\s*(?:"
433
- r"(?:\d+\s*[.)])"
434
- r"|(?:step\s*\d+:?)"
435
- r"|(?:[-*\u2022])"
436
- r"|(?:[\u2460-\u2473])"
437
  r")\s*",
438
  "",
439
  (s or "").strip(),
@@ -441,21 +336,33 @@ def _ensure_numbering(text: str) -> str:
441
  )
442
 
443
  clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
444
- circled = {i: chr(9311 + i) for i in range(1, 21)} # ①..⑳
 
 
 
 
 
445
  out = []
446
  for idx, seg in enumerate(clean_segments, start=1):
447
  marker = circled.get(idx, f"{idx})")
448
  out.append(f"{marker} {seg}")
449
  return "\n".join(out)
450
 
451
- # ------------------------------ Next-step helpers ------------------------------
 
452
  def _norm_text(s: str) -> str:
453
  s = (s or "").lower()
454
  s = re.sub(r"[^\w\s]", " ", s)
455
  s = re.sub(r"\s+", " ", s).strip()
456
  return s
457
 
 
458
  def _split_sop_into_steps(numbered_text: str) -> list:
 
 
 
 
 
459
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
460
  steps = []
461
  for ln in lines:
@@ -464,7 +371,9 @@ def _split_sop_into_steps(numbered_text: str) -> list:
464
  steps.append(cleaned)
465
  return steps
466
 
 
467
  def _soft_match_score(a: str, b: str) -> float:
 
468
  ta = set(_norm_text(a).split())
469
  tb = set(_norm_text(b).split())
470
  if not ta or not tb:
@@ -473,132 +382,153 @@ def _soft_match_score(a: str, b: str) -> float:
473
  union = len(ta | tb)
474
  return inter / union if union else 0.0
475
 
 
476
  def _detect_next_intent(user_query: str) -> bool:
477
  q = _norm_text(user_query)
478
  keys = [
479
  "after", "after this", "what next", "whats next", "next step",
480
- "then what", "following step", "continue", "subsequent", "proceed",
481
- "next", "go next", "what is next", "what should be next"
482
  ]
483
  return any(k in q for k in keys)
484
 
485
- def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.30):
 
486
  """
487
- Smarter anchor: verb overlap boost + keyword anchors.
 
 
488
  """
489
  if not _detect_next_intent(user_query):
490
  return None
 
491
  steps = _split_sop_into_steps(numbered_text)
492
  if not steps:
493
  return None
494
 
495
- q = _norm_text(user_query)
496
- q_tokens = set(q.split())
497
-
498
- anchors = [
499
- ({"scan", "location"}, "scan location"),
500
- ({"confirm", "item"}, "confirm item"),
501
- ({"pick", "quantity"}, "pick quantity"),
502
- ({"place", "picked"}, "place picked"),
503
- ({"confirm", "completion"}, "confirm completion"),
504
- ({"move", "pallet"}, "move pallet"),
505
- ({"system", "prompts"}, "system prompts"),
506
- ]
507
-
508
- anchor_idx = -1
509
- for need, phrase in anchors:
510
- if need.issubset(q_tokens):
511
- for i, s in enumerate(steps):
512
- if phrase in _norm_text(s):
513
- anchor_idx = i
514
- break
515
- if anchor_idx >= 0:
516
- break
517
-
518
- PROCEDURE_VERBS = {"scan", "confirm", "pick", "place", "move", "complete", "submit", "save"}
519
- def verb_overlap_boost(step_text: str) -> float:
520
- stoks = set(_norm_text(step_text).split())
521
- overlap = len(PROCEDURE_VERBS & stoks & q_tokens)
522
- return 0.12 * overlap
523
-
524
  best_idx, best_score = -1, -1.0
525
- if anchor_idx >= 0:
526
- best_idx, best_score = anchor_idx, 1.0
527
- else:
528
- for idx, step in enumerate(steps):
529
- base = _soft_match_score(user_query, step)
530
- boost = verb_overlap_boost(step)
531
- score = base + boost
532
- if score > best_score:
533
- best_score, best_idx = score, idx
534
 
535
  if best_idx < 0 or best_score < min_score:
536
- return None
 
537
  start = best_idx + 1
538
  if start >= len(steps):
539
- return []
 
540
  end = min(start + max_next, len(steps))
541
  return steps[start:end]
542
 
 
543
  def _format_steps_as_numbered(steps: list) -> str:
544
- circled = {i: chr(9311 + i) for i in range(1, 21)} # ..⑳
 
 
 
 
 
 
545
  out = []
546
  for i, s in enumerate(steps, start=1):
547
  out.append(f"{circled.get(i, str(i))} {s}")
548
  return "\n".join(out)
549
 
550
- # ------------------------------ Context filter (ensure defined before /chat) ------------------------------
551
- def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
552
  """
553
- Keep only the most relevant sentences from the KB context for the query.
554
- Returns (filtered_text, info_dict).
 
 
 
 
 
 
 
555
  """
556
- STRICT_OVERLAP = 3
557
- MAX_SENTENCES_STRICT = 4
558
- MAX_SENTENCES_CONCISE = 3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
 
560
- def _norm(text: str) -> str:
561
- t = (text or "").lower()
562
- t = re.sub(r"[^\w\s]", " ", t)
563
- t = re.sub(r"\s+", " ", t).strip()
564
- return t
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
 
566
- def _split_sentences(ctx: str) -> List[str]:
567
- raw_sents = re.split(r"(?<=[.!?])\s+|\n+|-\s*|\*\s*", ctx or "")
568
- return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
 
 
569
 
570
- ctx = (context or "").strip()
571
- if not ctx or not query:
572
- return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
573
 
574
- q_norm = _norm(query)
575
- q_terms = [t for t in q_norm.split() if len(t) > 2]
576
- if not q_terms:
577
- return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
 
578
 
579
- sentences = _split_sentences(ctx)
580
- matched_exact, matched_any = [], []
581
- for s in sentences:
582
- s_norm = _norm(s)
583
- is_bullet = bool(re.match(r"^[\-\*]\s*", s))
584
- overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
585
- if overlap >= STRICT_OVERLAP:
586
- matched_exact.append(s)
587
- elif overlap > 0:
588
- matched_any.append(s)
589
 
590
- if matched_exact:
591
- kept = matched_exact[:MAX_SENTENCES_STRICT]
592
- return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
 
 
 
 
 
 
 
593
 
594
- if matched_any:
595
- kept = matched_any[:MAX_SENTENCES_CONCISE]
596
- return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)}
597
-
598
- kept = sentences[:MAX_SENTENCES_CONCISE]
599
- return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
600
 
601
- # ------------------------------ Language hint ------------------------------
602
  def _detect_language_hint(msg: str) -> Optional[str]:
603
  if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
604
  return "Tamil"
@@ -606,25 +536,26 @@ def _detect_language_hint(msg: str) -> Optional[str]:
606
  return "Hindi"
607
  return None
608
 
609
- # ------------------------------ Clarifications / tracking ------------------------------
610
  def _build_clarifying_message() -> str:
611
  return (
612
  "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
613
  "or should I raise a ServiceNow ticket for you?"
614
  )
615
 
 
616
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
617
  issue = (issue_text or "").strip()
618
  resolved = (resolved_text or "").strip()
619
  short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
620
  long_desc = (
621
- f'User reported: "{issue}". '
622
- f'User confirmation: "{resolved}". '
623
  f"Tracking record created automatically by NOVA."
624
  ).strip()
625
  return short_desc, long_desc
626
 
627
- # ------------------------------ Incident helpers ------------------------------
628
  def _is_incident_intent(msg_norm: str) -> bool:
629
  intent_phrases = [
630
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
@@ -634,6 +565,7 @@ def _is_incident_intent(msg_norm: str) -> bool:
634
  ]
635
  return any(p in msg_norm for p in intent_phrases)
636
 
 
637
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
638
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
639
  base_has_status = any(k in msg_norm for k in status_keywords)
@@ -641,7 +573,8 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
641
  any(w in msg_norm for w in ("ticket", "incident", "servicenow", "snow")) or
642
  bool(re.search(r"\binc\d{5,}\b", msg_norm, flags=re.IGNORECASE))
643
  )
644
- if (not base_has_status) or (base_has_status and not has_ticket_marker and any(t in msg_norm for t in DOMAIN_STATUS_TERMS)):
 
645
  return {}
646
  patterns = [
647
  r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
@@ -655,6 +588,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
655
  return {"number": val.upper() if val.lower().startswith("inc") else val}
656
  return {"number": None, "ask_number": True}
657
 
 
658
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
659
  phrases = [
660
  "it is resolved", "resolved", "issue resolved", "problem resolved",
@@ -663,6 +597,7 @@ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
663
  ]
664
  return any(p in msg_norm for p in phrases)
665
 
 
666
  def _has_negation_resolved(msg_norm: str) -> bool:
667
  neg_phrases = [
668
  "not resolved", "issue not resolved", "still not working", "not working",
@@ -670,11 +605,247 @@ def _has_negation_resolved(msg_norm: str) -> bool:
670
  ]
671
  return any(p in msg_norm for p in neg_phrases)
672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  # ------------------------------ Health ------------------------------
674
  @app.get("/")
675
  async def health_check():
676
  return {"status": "ok"}
677
 
 
678
  # ------------------------------ Chat ------------------------------
679
  @app.post("/chat")
680
  async def chat_with_ai(input_data: ChatInput):
@@ -702,31 +873,10 @@ async def chat_with_ai(input_data: ChatInput):
702
  }
703
 
704
  # Resolution ack (auto incident + mark Resolved)
705
- is_llm_resolved = False
706
- if GEMINI_API_KEY:
707
- try:
708
- prompt = f"Classify if resolved: return 'true' or 'false'.\nMessage: {input_data.user_message}"
709
- headers = {"Content-Type": "application/json"}
710
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
711
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
712
- data = resp.json()
713
- text = (
714
- data.get("candidates", [{}])[0]
715
- .get("content", {})
716
- .get("parts", [{}])[0]
717
- .get("text", "")
718
- )
719
- is_llm_resolved = "true" in (text or "").strip().lower()
720
- except Exception:
721
- is_llm_resolved = False
722
-
723
  if _has_negation_resolved(msg_norm):
724
  is_llm_resolved = False
725
- if (not _has_negation_resolved(msg_norm)) and (any(p in msg_norm for p in [
726
- "it is resolved", "resolved", "issue resolved", "problem resolved",
727
- "it's working", "working now", "works now", "fixed", "sorted",
728
- "ok now", "fine now", "all good", "all set", "thanks works", "thank you it works", "back to normal",
729
- ]) or is_llm_resolved):
730
  try:
731
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
732
  result = create_incident(short_desc, long_desc)
@@ -735,28 +885,8 @@ async def chat_with_ai(input_data: ChatInput):
735
  sys_id = result.get("sys_id")
736
  resolved_note = ""
737
  if sys_id:
738
- # attempt to mark resolved
739
- try:
740
- token = get_valid_token()
741
- instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
742
- if instance_url and token:
743
- headers = {
744
- "Authorization": f"Bearer {token}",
745
- "Accept": "application/json",
746
- "Content-Type": "application/json",
747
- }
748
- url = f"{instance_url}/api/now/table/incident/{sys_id}"
749
- payload_A = {
750
- "state": "6",
751
- "close_code": os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided"),
752
- "close_notes": os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed"),
753
- }
754
- respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
755
- resolved_note = " (marked Resolved)" if respA.status_code in (200, 204) else " (could not mark Resolved; please update manually)"
756
- else:
757
- resolved_note = ""
758
- except Exception:
759
- resolved_note = ""
760
  return {
761
  "bot_response": f"✅ Incident created: {inc_number}{resolved_note}",
762
  "status": "OK",
@@ -816,7 +946,7 @@ async def chat_with_ai(input_data: ChatInput):
816
  "debug": {"intent": "create_ticket"},
817
  }
818
 
819
- # Status intent (ticket/incident)
820
  status_intent = _parse_ticket_status_intent(msg_norm)
821
  if status_intent:
822
  if status_intent.get("ask_number"):
@@ -876,7 +1006,6 @@ async def chat_with_ai(input_data: ChatInput):
876
  metadatas = kb_results.get("metadatas", [])
877
  distances = kb_results.get("distances", [])
878
  combined = kb_results.get("combined_scores", [])
879
-
880
  items: List[Dict[str, Any]] = []
881
  for i, doc in enumerate(documents):
882
  text = doc.strip() if isinstance(doc, str) else ""
@@ -895,10 +1024,11 @@ async def chat_with_ai(input_data: ChatInput):
895
  selected = items[:max(1, 2)]
896
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
897
 
 
898
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
899
  filtered_context = filtered_text
900
 
901
- context = context_raw
902
  context_found = bool(context.strip())
903
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
904
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
@@ -907,18 +1037,18 @@ async def chat_with_ai(input_data: ChatInput):
907
  top_meta = (metadatas or [{}])[0] if metadatas else {}
908
  msg_low = (input_data.user_message or "").lower()
909
 
910
- # Force "steps" intent when user asks for next step
911
- if _detect_next_intent(input_data.user_message):
912
- detected_intent = "steps"
913
-
914
  GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
915
  generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
916
 
917
- PREREQ_TERMS = ("pre req", "pre-requisite", "pre-requisites", "prerequisite",
918
- "prerequisites", "pre requirement", "pre-requirements", "requirements")
 
 
 
919
  if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
920
  detected_intent = "prereqs"
921
 
 
922
  PERM_QUERY_TERMS = [
923
  "permission", "permissions", "access", "access right", "authorization", "authorisation",
924
  "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied",
@@ -927,11 +1057,16 @@ async def chat_with_ai(input_data: ChatInput):
927
  if is_perm_query:
928
  detected_intent = "errors"
929
 
 
930
  sec_title = ((top_meta or {}).get("section") or "").strip().lower()
931
- PREREQ_HEADINGS = ("pre-requisites", "prerequisites", "pre requisites", "pre-requirements", "requirements")
 
 
 
932
  if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
933
  detected_intent = "prereqs"
934
 
 
935
  STEPS_TERMS = ("how to", "procedure", "perform", "steps", "do", "navigate")
936
  ACTIONS_PRESENT = any(s in msg_low for syns in ACTION_SYNONYMS_EXT.values() for s in syns)
937
  mod_tags = ((top_meta or {}).get("module_tags") or "").lower()
@@ -947,6 +1082,7 @@ async def chat_with_ai(input_data: ChatInput):
947
  if detected_intent in ("neutral", "prereqs") and looks_like_steps_query and looks_like_module:
948
  detected_intent = "steps"
949
 
 
950
  def _contains_any(s: str, keywords: tuple) -> bool:
951
  low = (s or "").lower()
952
  return any(k in low for k in keywords)
@@ -1014,6 +1150,97 @@ async def chat_with_ai(input_data: ChatInput):
1014
  return act
1015
  return None
1016
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1017
  escalation_line = None
1018
  full_errors = None
1019
  next_step_applied = False
@@ -1023,55 +1250,49 @@ async def chat_with_ai(input_data: ChatInput):
1023
  context_preformatted = False
1024
  full_steps = None
1025
 
1026
- # Detect asked action generically using ACTION_SYNONYMS_EXT (already defined)
1027
- asked_action = _detect_action_from_query(input_data.user_message) # 'create'/'update'/'delete'/None
1028
-
1029
- # 1) Try KB-tagged action section (existing)
1030
  action_steps = _get_steps_for_action(best_doc, kb_results.get("actions", []))
1031
  if action_steps:
1032
  full_steps = action_steps
1033
  else:
1034
- # 2) Global action lookup (existing)
1035
  if kb_results.get("actions"):
1036
- alt_doc, alt_steps = _get_steps_for_action_global(kb_results["actions"], prefer_doc=best_doc)
1037
- if alt_steps:
1038
- best_doc = alt_doc
1039
- full_steps = alt_steps
1040
-
1041
- # 3) Generic: exact section by meta.action_tag in SAME best_doc
1042
- if not full_steps and asked_action:
1043
- via_tag_steps = _get_steps_by_action_tag(best_doc, asked_action)
1044
- if via_tag_steps:
1045
- full_steps = via_tag_steps
1046
-
1047
- # 4) Prefer requested action when defaulting
1048
- if not full_steps:
1049
- default_sec = _pick_default_action_section_with_preference(best_doc, asked_action)
1050
- if default_sec:
1051
- full_steps = get_section_text(best_doc, default_sec)
1052
 
1053
- # 5) Existing fallbacks
1054
  if not full_steps:
1055
- full_steps = get_best_steps_section_text(best_doc)
 
 
1056
  if not full_steps:
1057
- sec = (top_meta or {}).get("section")
1058
- if sec:
1059
- full_steps = get_section_text(best_doc, sec)
1060
-
 
 
1061
  if full_steps:
1062
- # Generic boundary cutter (prevents create/update/delete bleed)
1063
- full_steps = _cut_at_next_boundary_generic(full_steps, best_doc, asked_action)
1064
-
1065
- # Scope 'Save' lines to current section
1066
- save_local = _find_save_lines_in_section(full_steps, max_lines=2)
1067
- if save_local:
1068
  low_steps = (full_steps or "").lower()
1069
  if not any(s in low_steps for s in SAVE_SYNS):
1070
- full_steps = (full_steps or "").rstrip() + "\n" + save_local
 
 
 
1071
 
1072
- # Number + Next steps
1073
  numbered_full = _ensure_numbering(full_steps)
1074
- next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.30)
 
1075
  if next_only is not None:
1076
  if len(next_only) == 0:
1077
  context = "You are at the final step of this SOP. No further steps."
@@ -1087,8 +1308,9 @@ async def chat_with_ai(input_data: ChatInput):
1087
  context = full_steps
1088
  context_preformatted = False
1089
 
1090
- context_found = True
1091
  filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
 
1092
 
1093
  elif best_doc and detected_intent == "errors":
1094
  full_errors = get_best_errors_section_text(best_doc)
@@ -1112,21 +1334,27 @@ async def chat_with_ai(input_data: ChatInput):
1112
  else:
1113
  full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
1114
  if full_steps:
 
 
1115
  context = full_steps
1116
  detected_intent = "steps"
1117
  context_preformatted = False
1118
 
1119
  elif best_doc and detected_intent == "prereqs":
1120
- full_prereqs = get_section_text(best_doc, "Pre-Requisites") or get_section_text(best_doc, "Prerequisites")
1121
  if full_prereqs:
1122
  context = full_prereqs.strip()
1123
  else:
1124
  full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
1125
  if full_steps:
 
 
1126
  context = full_steps
1127
  detected_intent = "steps"
1128
  context_preformatted = False
 
1129
  else:
 
1130
  context = filtered_context
1131
 
1132
  language_hint = _detect_language_hint(input_data.user_message)
@@ -1161,19 +1389,23 @@ Return ONLY the rewritten guidance."""
1161
  except Exception:
1162
  bot_text, http_code = "", 0
1163
 
 
1164
  if detected_intent == "steps":
1165
  if ('context_preformatted' in locals()) and context_preformatted:
1166
  bot_text = context
1167
  else:
1168
  bot_text = _ensure_numbering(context)
 
1169
  elif detected_intent == "errors":
1170
  if not bot_text.strip() or http_code == 429:
1171
  bot_text = context.strip()
1172
  if escalation_line:
1173
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
 
1174
  else:
1175
  bot_text = context
1176
 
 
1177
  needs_escalation = (" escalate" in msg_norm) or ("escalation" in msg_norm)
1178
  if needs_escalation and best_doc:
1179
  esc_text = get_escalation_text(best_doc)
@@ -1183,6 +1415,7 @@ Return ONLY the rewritten guidance."""
1183
  if line:
1184
  bot_text = (bot_text or "").rstrip() + "\n\n" + line
1185
 
 
1186
  if not (bot_text or "").strip():
1187
  if context.strip():
1188
  bot_text = context.strip()
@@ -1192,6 +1425,7 @@ Return ONLY the rewritten guidance."""
1192
  "Share a bit more detail (module/screen/error), or say ‘create ticket’."
1193
  )
1194
 
 
1195
  if detected_intent == "steps" and bot_text.strip():
1196
  status = "OK"
1197
  else:
@@ -1234,6 +1468,7 @@ Return ONLY the rewritten guidance."""
1234
  except Exception as e:
1235
  raise HTTPException(status_code=500, detail=safe_str(e))
1236
 
 
1237
  # ------------------------------ Ticket description generation ------------------------------
1238
  @app.post("/generate_ticket_desc")
1239
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
@@ -1272,6 +1507,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
1272
  except Exception as e:
1273
  raise HTTPException(status_code=500, detail=safe_str(e))
1274
 
 
1275
  # ------------------------------ Incident status ------------------------------
1276
  @app.post("/incident_status")
1277
  async def incident_status(input_data: TicketStatusInput):
@@ -1312,6 +1548,7 @@ async def incident_status(input_data: TicketStatusInput):
1312
  except Exception as e:
1313
  raise HTTPException(status_code=500, detail=safe_str(e))
1314
 
 
1315
  # ------------------------------ Incident ------------------------------
1316
  @app.post("/incident")
1317
  async def raise_incident(input_data: IncidentInput):
@@ -1322,25 +1559,8 @@ async def raise_incident(input_data: IncidentInput):
1322
  sys_id = result.get("sys_id")
1323
  resolved_note = ""
1324
  if bool(input_data.mark_resolved) and sys_id not in ("<unknown>", None):
1325
- try:
1326
- token = get_valid_token()
1327
- instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
1328
- if instance_url and token:
1329
- headers = {
1330
- "Authorization": f"Bearer {token}",
1331
- "Accept": "application/json",
1332
- "Content-Type": "application/json",
1333
- }
1334
- url = f"{instance_url}/api/now/table/incident/{sys_id}"
1335
- payload_A = {
1336
- "state": "6",
1337
- "close_code": os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided"),
1338
- "close_notes": os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed"),
1339
- }
1340
- respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
1341
- resolved_note = " (marked Resolved)" if respA.status_code in (200, 204) else " (could not mark Resolved; please update manually)"
1342
- except Exception:
1343
- resolved_note = ""
1344
  ticket_text = f"Incident created: {inc_number}{resolved_note}" if inc_number else "Incident created."
1345
  return {
1346
  "bot_response": f"✅ {ticket_text}",
 
12
  from dotenv import load_dotenv
13
  from datetime import datetime
14
 
 
15
  # Import shared vocab from KB services
16
  from services.kb_creation import ACTION_SYNONYMS, MODULE_VOCAB
17
  from services.kb_creation import (
 
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:
34
  try:
35
  return builtins.str(e)
36
  except Exception:
37
  return "<error stringify failed>"
38
 
39
+
40
  load_dotenv()
41
  os.environ["POSTHOG_DISABLED"] = "true"
42
 
43
+
44
  @asynccontextmanager
45
  async def lifespan(app: FastAPI):
46
  try:
 
54
  print(f"[KB] ingestion failed: {safe_str(e)}")
55
  yield
56
 
57
+
58
  app = FastAPI(lifespan=lifespan)
59
  app.include_router(login_router)
60
 
 
73
  prev_status: Optional[str] = None
74
  last_issue: Optional[str] = None
75
 
76
+
77
  class IncidentInput(BaseModel):
78
  short_description: str
79
  description: str
80
  mark_resolved: Optional[bool] = False
81
 
82
+
83
  class TicketDescInput(BaseModel):
84
  issue: str
85
 
86
+
87
  class TicketStatusInput(BaseModel):
88
  sys_id: Optional[str] = None
89
  number: Optional[str] = None
90
 
91
+
92
  STATE_MAP = {
93
  "1": "New",
94
  "2": "In Progress",
 
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
  "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",
 
144
  ),
145
  }
146
 
147
+ # ----- local extension so runtime filtering is precise even without re-ingest -----
148
+ # (Does NOT override your KB synonyms—just augments them at runtime.)
149
  ACTION_SYNONYMS_EXT: Dict[str, List[str]] = {}
150
  for k, v in ACTION_SYNONYMS.items():
151
+ ACTION_SYNONYMS_EXT[k] = list(v) # copy
152
+
153
+ # Extend with SOP phrasing (appointments often say 'updation', 'deletion', 'reschedule')
154
  ACTION_SYNONYMS_EXT.setdefault("create", []).extend(["appointment creation", "create appointment"])
155
  ACTION_SYNONYMS_EXT.setdefault("update", []).extend([
156
  "updation", "reschedule", "change time", "change date", "change slot",
 
158
  ])
159
  ACTION_SYNONYMS_EXT.setdefault("delete", []).extend(["deletion", "delete appointment", "cancel appointment"])
160
 
161
+
162
  def _detect_error_families(msg: str) -> list:
163
+ """Return matching error family names found in the message (generic across SOPs)."""
164
  low = (msg or "").lower()
165
  low_norm = re.sub(r"[^\w\s]", " ", low)
166
  low_norm = re.sub(r"\s+", " ", low_norm).strip()
 
170
  fams.append(fam)
171
  return fams
172
 
173
+
174
+ # --- Action-targeted steps selector (uses existing KB metadata) ---
175
+ from services.kb_creation import bm25_docs, get_section_text
176
+
177
  def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
178
+ """
179
+ Return the full text of the steps section whose action_tag matches the user's intent.
180
+ e.g., actions=['update'] -> section: "Appointment schedule updation"
181
+ actions=['delete'] -> section: "Appointment deletion"
182
+ actions=['create'] -> section: "Appointment Creation"
183
+ """
184
  if not best_doc or not actions:
185
  return None
186
  act_set = set(a.strip().lower() for a in actions if a)
187
+ # Collect candidate sections in this doc that are 'steps' and have an action_tag we need
188
  candidates = []
189
  for d in bm25_docs:
190
  m = d.get("meta", {})
 
192
  tag = (m.get("action_tag") or "").strip().lower()
193
  if tag and tag in act_set:
194
  candidates.append(m.get("section"))
195
+ # Prefer the first matched section with non-empty text
196
  for title in candidates:
197
  txt = get_section_text(best_doc, title)
198
  if txt and txt.strip():
199
  return txt.strip()
200
  return None
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
  def _get_steps_for_action_global(actions: list, prefer_doc: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
204
+ """
205
+ Search ALL SOP docs for a 'steps' section whose action_tag matches one of `actions`.
206
+ Returns (doc_name, steps_text). Prefers sections from `prefer_doc` and the 'appointments' module.
207
+ """
208
  if not actions:
209
  return None, None
210
  act_set = set(a.strip().lower() for a in actions if a)
211
+
212
+ candidates: List[Tuple[float, str, str]] = [] # (score, doc, text)
213
  for d in bm25_docs:
214
  m = d.get("meta", {}) or {}
215
  if m.get("intent_tag") != "steps":
 
228
  if "appointments" in mtags:
229
  score += 0.3
230
  candidates.append((score, doc, txt.strip()))
231
+
232
  if not candidates:
233
  return None, None
234
+
235
  candidates.sort(key=lambda x: x[0], reverse=True)
236
  _, best_doc_global, best_text = candidates[0]
237
  return best_doc_global, best_text
238
 
239
+ # --- Default section picker when query doesn't reveal action ---
240
  def _pick_default_action_section(best_doc: str) -> Optional[str]:
241
  """
242
+ If user actions are empty, prefer '...Creation' section,
243
+ else prefer '...Updation'/'...Update', else '...Deletion'/'...Cancel'.
244
+ Works generically for SOPs that use common headings.
 
 
 
 
 
245
  """
 
 
 
 
 
 
 
 
 
 
 
246
  order = ("creation", "updation", "update", "deletion", "delete", "cancel")
247
  sections = []
248
  for d in bm25_docs:
249
+ m = d.get("meta", {})
250
  if m.get("filename") == best_doc and m.get("intent_tag") == "steps":
251
  title = (m.get("section") or "").strip().lower()
252
  if title:
 
257
  return t
258
  return sections[0] if sections else None
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
+ # --- Harvest 'Save' lines from ALL steps chunks in the doc (generic across SOPs) ---
 
 
 
 
 
 
 
262
  SAVE_SYNS = ("save", "saving", "save changes", "click save", "press save", "select save")
263
 
264
+ def _find_save_lines_in_doc(best_doc: str, max_lines: int = 2) -> str:
 
 
 
 
 
 
 
 
 
 
 
265
  """
266
+ Pulls up to max_lines lines that mention 'save' from any steps chunk in best_doc.
267
+ Returns a \n-joined string or empty if none found.
268
  """
269
+ lines: List[str] = []
270
  for d in bm25_docs:
271
+ m = d.get("meta", {})
272
+ if m.get("filename") != best_doc or m.get("intent_tag") != "steps":
273
+ continue
274
+ t = (d.get("text") or "").strip()
275
+ for ln in [x.strip() for x in t.splitlines() if x.strip()]:
276
+ low = ln.lower()
277
+ if any(s in low for s in SAVE_SYNS):
278
+ lines.append(ln)
279
+ if len(lines) >= max_lines:
280
+ return "\n".join(lines)
281
+ return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
 
 
 
 
 
283
 
284
+ def _is_domain_status_context(msg_norm: str) -> bool:
285
+ if "status locked" in msg_norm or "locked status" in msg_norm:
286
+ return True
287
+ return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
288
 
 
289
 
 
290
  def _normalize_lines(text: str) -> List[str]:
291
  raw = (text or "")
292
  try:
 
294
  except Exception:
295
  return [raw.strip()] if raw.strip() else []
296
 
297
+
298
  def _ensure_numbering(text: str) -> str:
299
+ """
300
+ Normalize raw SOP steps into a clean numbered list using circled digits.
301
+ Robust against '1.', '1)', 'Step 1:', bullets ('-', '*', '•'), and circled digits.
302
+ """
303
  text = re.sub(r"[\u2060\u200B]", "", text or "")
304
  lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
305
  if not lines:
306
  return text or ""
307
+
308
+ # Collapse lines into a block and then split on common step markers
309
  para = " ".join(lines).strip()
310
  if not para:
311
  return ""
312
+
313
+ # Create hard breaks at typical step boundaries
314
+ para_clean = re.sub(r"(?:\b\d+[.)]\s+)", "\n\n\n", para) # 1. / 1)
315
+ para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
316
+ para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean) # Step 1:
317
  segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
318
+
319
+ # Fallback splitting if we didn't detect separators
320
  if len(segments) < 2:
321
  tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
322
  segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|\s+;\s+", para) if seg.strip()]
323
 
324
+ # Strip any step prefixes
325
  def strip_prefix_any(s: str) -> str:
326
  return re.sub(
327
  r"^\s*(?:"
328
+ r"(?:\d+\s*[.)])" # leading numbers 1., 2)
329
+ r"|(?:step\s*\d+:?)" # Step 1:
330
+ r"|(?:[-*\u2022])" # bullets
331
+ r"|(?:[\u2460-\u2473])" # circled digits
332
  r")\s*",
333
  "",
334
  (s or "").strip(),
 
336
  )
337
 
338
  clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
339
+ circled = {
340
+ 1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
341
+ 6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
342
+ 11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
343
+ 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
344
+ }
345
  out = []
346
  for idx, seg in enumerate(clean_segments, start=1):
347
  marker = circled.get(idx, f"{idx})")
348
  out.append(f"{marker} {seg}")
349
  return "\n".join(out)
350
 
351
+
352
+ # --- Next-step helpers (generic; SOP-agnostic) ---
353
  def _norm_text(s: str) -> str:
354
  s = (s or "").lower()
355
  s = re.sub(r"[^\w\s]", " ", s)
356
  s = re.sub(r"\s+", " ", s).strip()
357
  return s
358
 
359
+
360
  def _split_sop_into_steps(numbered_text: str) -> list:
361
+ """
362
+ Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
363
+ into atomic steps. Returns a list of raw step strings (order preserved).
364
+ Safe for circled digits, '1.' styles, and bullets.
365
+ """
366
  lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
367
  steps = []
368
  for ln in lines:
 
371
  steps.append(cleaned)
372
  return steps
373
 
374
+
375
  def _soft_match_score(a: str, b: str) -> float:
376
+ # Simple Jaccard-like score on tokens for fuzzy matching
377
  ta = set(_norm_text(a).split())
378
  tb = set(_norm_text(b).split())
379
  if not ta or not tb:
 
382
  union = len(ta | tb)
383
  return inter / union if union else 0.0
384
 
385
+
386
  def _detect_next_intent(user_query: str) -> bool:
387
  q = _norm_text(user_query)
388
  keys = [
389
  "after", "after this", "what next", "whats next", "next step",
390
+ "then what", "following step", "continue", "subsequent", "proceed"
 
391
  ]
392
  return any(k in q for k in keys)
393
 
394
+
395
+ def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
396
  """
397
+ If 'what's next' intent is detected and we can reliably match the user's
398
+ referenced line to a SOP step, return ONLY the subsequent steps.
399
+ Else return None to preserve current behavior.
400
  """
401
  if not _detect_next_intent(user_query):
402
  return None
403
+
404
  steps = _split_sop_into_steps(numbered_text)
405
  if not steps:
406
  return None
407
 
408
+ q = user_query or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  best_idx, best_score = -1, -1.0
410
+ for idx, step in enumerate(steps):
411
+ score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
412
+ if score > best_score:
413
+ best_score, best_idx = score, idx
 
 
 
 
 
414
 
415
  if best_idx < 0 or best_score < min_score:
416
+ return None # fallback to full SOP
417
+
418
  start = best_idx + 1
419
  if start >= len(steps):
420
+ return [] # user is at final step
421
+
422
  end = min(start + max_next, len(steps))
423
  return steps[start:end]
424
 
425
+
426
  def _format_steps_as_numbered(steps: list) -> str:
427
+ """Render a small list of steps with circled numbers for visual continuity."""
428
+ circled = {
429
+ 1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
430
+ 6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
431
+ 11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
432
+ 16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
433
+ }
434
  out = []
435
  for i, s in enumerate(steps, start=1):
436
  out.append(f"{circled.get(i, str(i))} {s}")
437
  return "\n".join(out)
438
 
439
+
440
+ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
441
  """
442
+ Pick the most relevant 'Common Errors & Resolution' bullet(s) for the user's message.
443
+ Generic (SOP-agnostic) scoring:
444
+ 1) error-family match (NOT_FOUND/MISMATCH/LOCKED/PERMISSION/TIMEOUT/SYNC),
445
+ 2) anchored starts (line begins with error heading),
446
+ 3) multi-word overlap (bigrams/trigrams),
447
+ 4) token overlap,
448
+ 5) formatting bonus for bullets/headings.
449
+
450
+ Returns exactly `max_lines` best-scoring lines (defaults to 1).
451
  """
452
+ def _norm(s: str) -> str:
453
+ s = (s or "").lower()
454
+ s = re.sub(r"[^\w\s]", " ", s)
455
+ s = re.sub(r"\s+", " ", s).strip()
456
+ return s
457
+
458
+ def _ngrams(tokens: List[str], n: int) -> List[str]:
459
+ return [" ".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]
460
+
461
+ def _families_for(s: str) -> set:
462
+ low = _norm(s)
463
+ fams = set()
464
+ for fam, syns in ERROR_FAMILY_SYNS.items():
465
+ if any(k in low for k in syns):
466
+ fams.add(fam)
467
+ return fams
468
+
469
+ q = _norm(query)
470
+ q_tokens = [t for t in q.split() if len(t) > 1]
471
+ q_bi = _ngrams(q_tokens, 2)
472
+ q_tri = _ngrams(q_tokens, 3)
473
+ q_fams = _families_for(query)
474
+
475
+ lines = _normalize_lines(text)
476
+ if not lines:
477
+ return (text or "").strip()
478
 
479
+ scored: List[Tuple[float, str]] = []
480
+ for ln in lines:
481
+ ln_norm = _norm(ln)
482
+ ln_fams = _families_for(ln)
483
+
484
+ fam_overlap = len(q_fams & ln_fams)
485
+ anchored = 0.0
486
+ first2 = " ".join(q_tokens[:2]) if len(q_tokens) >= 2 else ""
487
+ first3 = " ".join(q_tokens[:3]) if len(q_tokens) >= 3 else ""
488
+ if (first3 and ln_norm.startswith(first3)) or (first2 and ln_norm.startswith(first2)):
489
+ anchored = 1.0
490
+
491
+ bigram_hits = sum(1 for bg in q_bi if bg and bg in ln_norm)
492
+ trigram_hits = sum(1 for tg in q_tri if tg and tg in ln_norm)
493
+ token_overlap = sum(1 for t in q_tokens if t and t in ln_norm)
494
+ exact_phrase = 1.0 if (q and q in ln_norm) else 0.0
495
+
496
+ score = (
497
+ 1.70 * fam_overlap +
498
+ 1.00 * anchored +
499
+ 0.80 * trigram_hits +
500
+ 0.55 * bigram_hits +
501
+ 0.40 * exact_phrase +
502
+ 0.30 * token_overlap
503
+ )
504
 
505
+ if re.match(r"^\s*[-*\u2022]\s*", ln): # bullet
506
+ score += 0.10
507
+ heading = ln_norm.split(":")[0].strip()
508
+ if heading and (heading in q or (first2 and first2 in heading)):
509
+ score += 0.15
510
 
511
+ scored.append((score, ln))
 
 
512
 
513
+ scored.sort(key=lambda x: x[0], reverse=True)
514
+ top = [ln for s, ln in scored[:max_lines] if s > 0.0]
515
+ if not top:
516
+ top = lines[:max_lines]
517
+ return "\n".join(top).strip()
518
 
 
 
 
 
 
 
 
 
 
 
519
 
520
+ def _friendly_permission_reply(raw: str) -> str:
521
+ line = (raw or "").strip()
522
+ line = re.sub(r"^\s*[-*\u2022]\s*", "", line)
523
+ if not line:
524
+ return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
525
+ if "verify role access" in line.lower():
526
+ return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
527
+ if ("permission" in line.lower()) or ("access" in line.lower()) or ("authorization" in line.lower()):
528
+ return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
529
+ return line
530
 
 
 
 
 
 
 
531
 
 
532
  def _detect_language_hint(msg: str) -> Optional[str]:
533
  if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
534
  return "Tamil"
 
536
  return "Hindi"
537
  return None
538
 
539
+
540
  def _build_clarifying_message() -> str:
541
  return (
542
  "It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
543
  "or should I raise a ServiceNow ticket for you?"
544
  )
545
 
546
+
547
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
548
  issue = (issue_text or "").strip()
549
  resolved = (resolved_text or "").strip()
550
  short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
551
  long_desc = (
552
+ f"User reported: \"{issue}\". "
553
+ f"User confirmation: \"{resolved}\". "
554
  f"Tracking record created automatically by NOVA."
555
  ).strip()
556
  return short_desc, long_desc
557
 
558
+
559
  def _is_incident_intent(msg_norm: str) -> bool:
560
  intent_phrases = [
561
  "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
 
565
  ]
566
  return any(p in msg_norm for p in intent_phrases)
567
 
568
+
569
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
570
  status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
571
  base_has_status = any(k in msg_norm for k in status_keywords)
 
573
  any(w in msg_norm for w in ("ticket", "incident", "servicenow", "snow")) or
574
  bool(re.search(r"\binc\d{5,}\b", msg_norm, flags=re.IGNORECASE))
575
  )
576
+ # Disambiguation: if it's a domain status query and not clearly ticket/incident, do NOT route to ticket-status.
577
+ if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
578
  return {}
579
  patterns = [
580
  r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
 
588
  return {"number": val.upper() if val.lower().startswith("inc") else val}
589
  return {"number": None, "ask_number": True}
590
 
591
+
592
  def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
593
  phrases = [
594
  "it is resolved", "resolved", "issue resolved", "problem resolved",
 
597
  ]
598
  return any(p in msg_norm for p in phrases)
599
 
600
+
601
  def _has_negation_resolved(msg_norm: str) -> bool:
602
  neg_phrases = [
603
  "not resolved", "issue not resolved", "still not working", "not working",
 
605
  ]
606
  return any(p in msg_norm for p in neg_phrases)
607
 
608
+
609
+ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
610
+ STRICT_OVERLAP = 3
611
+ MAX_SENTENCES_STRICT = 4
612
+ MAX_SENTENCES_CONCISE = 3
613
+
614
+ def _norm(text: str) -> str:
615
+ t = (text or "").lower()
616
+ t = re.sub(r"[^\w\s]", " ", t)
617
+ t = re.sub(r"\s+", " ", t).strip()
618
+ return t
619
+
620
+ def _split_sentences(ctx: str) -> List[str]:
621
+ raw_sents = re.split(r"(?<=[.!?])\s+|\n+|-\s*|\*\s*", ctx or "")
622
+ return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
623
+
624
+ ctx = (context or "").strip()
625
+ if not ctx or not query:
626
+ return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
627
+ q_norm = _norm(query)
628
+ q_terms = [t for t in q_norm.split() if len(t) > 2]
629
+ if not q_terms:
630
+ return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
631
+ sentences = _split_sentences(ctx)
632
+ matched_exact, matched_any = [], []
633
+ for s in sentences:
634
+ s_norm = _norm(s)
635
+ is_bullet = bool(re.match(r"^[\-\*]\s*", s))
636
+ overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
637
+ if overlap >= STRICT_OVERLAP:
638
+ matched_exact.append(s)
639
+ elif overlap > 0:
640
+ matched_any.append(s)
641
+ if matched_exact:
642
+ kept = matched_exact[:MAX_SENTENCES_STRICT]
643
+ return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
644
+ if matched_any:
645
+ kept = matched_any[:MAX_SENTENCES_CONCISE]
646
+ return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)}
647
+ kept = sentences[:MAX_SENTENCES_CONCISE]
648
+ return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
649
+
650
+
651
+ def _extract_errors_only(text: str, max_lines: int = 12) -> str:
652
+ """
653
+ Collect error bullets/heading-style lines from the SOP errors section.
654
+ Generic: keeps bullet points (•, -, *), and lines that look like "Heading: details".
655
+ This ensures items like 'ASN not found', 'Trailer status locked', etc., are preserved.
656
+ """
657
+ kept: List[str] = []
658
+ for ln in _normalize_lines(text):
659
+ if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
660
+ kept.append(ln)
661
+ if len(kept) >= max_lines:
662
+ break
663
+ return "\n".join(kept).strip() if kept else (text or "").strip()
664
+
665
+
666
+ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
667
+ PERM_SYNONYMS = (
668
+ "permission", "permissions", "access", "authorization", "authorisation",
669
+ "role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
670
+ )
671
+ kept: List[str] = []
672
+ for ln in _normalize_lines(text):
673
+ low = ln.lower()
674
+ if any(k in low for k in PERM_SYNONYMS):
675
+ kept.append(ln)
676
+ if len(kept) >= max_lines:
677
+ break
678
+ return "\n".join(kept).strip() if kept else (text or "").strip()
679
+
680
+
681
+ def _extract_escalation_line(text: str) -> Optional[str]:
682
+ if not text:
683
+ return None
684
+ lines = _normalize_lines(text)
685
+ if not lines:
686
+ return None
687
+ start_idx = None
688
+ for i, ln in enumerate(lines):
689
+ low = ln.lower()
690
+ if "escalation" in low or "escalation path" in low or "escalate" in low:
691
+ start_idx = i
692
+ break
693
+ block = []
694
+ if start_idx is not None:
695
+ for j in range(start_idx, min(len(lines), start_idx + 6)):
696
+ if not lines[j].strip():
697
+ break
698
+ block.append(lines[j].strip())
699
+ else:
700
+ block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
701
+ if not block:
702
+ return None
703
+ text_block = " ".join(block)
704
+ m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
705
+ path = m.group(1).strip() if m else None
706
+ if not path:
707
+ arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
708
+ if arrow_lines:
709
+ path = arrow_lines[0]
710
+ if not path:
711
+ m2 = re.search(r"(operator.*?administrator|operator.*)", text_block, flags=re.IGNORECASE)
712
+ path = m2.group(1).strip() if m2 else None
713
+ if not path:
714
+ return None
715
+ path = path.replace("->", "→").strip()
716
+ path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
717
+ return f"If you want to escalate the issue, follow: {path}"
718
+
719
+
720
+ def _classify_resolution_llm(user_message: str) -> bool:
721
+ if not GEMINI_API_KEY:
722
+ return False
723
+ prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
724
+ Return only 'true' or 'false'.
725
+ Message: {user_message}"""
726
+ headers = {"Content-Type": "application/json"}
727
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
728
+ try:
729
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
730
+ data = resp.json()
731
+ text = (
732
+ data.get("candidates", [{}])[0]
733
+ .get("content", {})
734
+ .get("parts", [{}])[0]
735
+ .get("text", "")
736
+ )
737
+ return "true" in (text or "").strip().lower()
738
+ except Exception:
739
+ return False
740
+
741
+
742
+ def _set_incident_resolved(sys_id: str) -> bool:
743
+ try:
744
+ token = get_valid_token()
745
+ instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
746
+ if not instance_url:
747
+ print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
748
+ return False
749
+ headers = {
750
+ "Authorization": f"Bearer {token}",
751
+ "Accept": "application/json",
752
+ "Content-Type": "application/json",
753
+ }
754
+ url = f"{instance_url}/api/now/table/incident/{sys_id}"
755
+ close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
756
+ close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
757
+ caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
758
+ resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
759
+ assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
760
+ require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
761
+ if require_progress:
762
+ try:
763
+ resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
764
+ print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
765
+ except Exception as e:
766
+ print(f"[SN PATCH progress] exception={safe_str(e)}")
767
+
768
+ def clean(d: dict) -> dict:
769
+ return {k: v for k, v in d.items() if v is not None}
770
+
771
+ payload_A = clean({
772
+ "state": "6",
773
+ "close_code": close_code_val,
774
+ "close_notes": close_notes_val,
775
+ "caller_id": caller_sysid,
776
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
777
+ "work_notes": "Auto-resolve set by NOVA.",
778
+ "resolved_by": resolved_by_sysid,
779
+ "assignment_group": assign_group,
780
+ })
781
+ respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
782
+ if respA.status_code in (200, 204):
783
+ return True
784
+ print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
785
+
786
+ payload_B = clean({
787
+ "state": "Resolved",
788
+ "close_code": close_code_val,
789
+ "close_notes": close_notes_val,
790
+ "caller_id": caller_sysid,
791
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
792
+ "work_notes": "Auto-resolve set by NOVA.",
793
+ "resolved_by": resolved_by_sysid,
794
+ "assignment_group": assign_group,
795
+ })
796
+ respB = requests.patch(url, headers=headers, json=payload_B, verify=VERIFY_SSL, timeout=25)
797
+ if respB.status_code in (200, 204):
798
+ return True
799
+ print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
800
+
801
+ code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
802
+ notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
803
+ payload_C = clean({
804
+ "state": "6",
805
+ code_field: close_notes_val, # (if you have custom fields, adjust here)
806
+ notes_field: close_notes_val,
807
+ "caller_id": caller_sysid,
808
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
809
+ "work_notes": "Auto-resolve set by NOVA.",
810
+ "resolved_by": resolved_by_sysid,
811
+ "assignment_group": assign_group,
812
+ })
813
+ respC = requests.patch(url, headers=headers, json=payload_C, verify=VERIFY_SSL, timeout=25)
814
+ if respC.status_code in (200, 204):
815
+ return True
816
+ print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
817
+ return False
818
+ except Exception as e:
819
+ print(f"[SN PATCH resolve] exception={safe_str(e)}")
820
+ return False
821
+
822
+
823
+ # ------------------------------ Prereq helper ------------------------------
824
+ def _find_prereq_section_text(best_doc: str) -> str:
825
+ """
826
+ Return the prerequisites section text, trying common heading variants.
827
+ Generic for future SOPs—no document-specific keywords.
828
+ """
829
+ variants = [
830
+ "Pre-Requisites",
831
+ "Prerequisites",
832
+ "Pre Requisites",
833
+ "Pre-Requirements",
834
+ "Requirements",
835
+ ]
836
+ for title in variants:
837
+ txt = get_section_text(best_doc, title)
838
+ if txt and txt.strip():
839
+ return txt.strip()
840
+ return ""
841
+
842
+
843
  # ------------------------------ Health ------------------------------
844
  @app.get("/")
845
  async def health_check():
846
  return {"status": "ok"}
847
 
848
+
849
  # ------------------------------ Chat ------------------------------
850
  @app.post("/chat")
851
  async def chat_with_ai(input_data: ChatInput):
 
873
  }
874
 
875
  # Resolution ack (auto incident + mark Resolved)
876
+ is_llm_resolved = _classify_resolution_llm(input_data.user_message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
877
  if _has_negation_resolved(msg_norm):
878
  is_llm_resolved = False
879
+ if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
 
 
 
 
880
  try:
881
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
882
  result = create_incident(short_desc, long_desc)
 
885
  sys_id = result.get("sys_id")
886
  resolved_note = ""
887
  if sys_id:
888
+ ok = _set_incident_resolved(sys_id)
889
+ resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
890
  return {
891
  "bot_response": f"✅ Incident created: {inc_number}{resolved_note}",
892
  "status": "OK",
 
946
  "debug": {"intent": "create_ticket"},
947
  }
948
 
949
+ # Status intent (ticket/incident) — disambiguated
950
  status_intent = _parse_ticket_status_intent(msg_norm)
951
  if status_intent:
952
  if status_intent.get("ask_number"):
 
1006
  metadatas = kb_results.get("metadatas", [])
1007
  distances = kb_results.get("distances", [])
1008
  combined = kb_results.get("combined_scores", [])
 
1009
  items: List[Dict[str, Any]] = []
1010
  for i, doc in enumerate(documents):
1011
  text = doc.strip() if isinstance(doc, str) else ""
 
1024
  selected = items[:max(1, 2)]
1025
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
1026
 
1027
+ # Compute filter info for gating only; do NOT use the filtered text for steps
1028
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
1029
  filtered_context = filtered_text
1030
 
1031
+ context = context_raw # keep raw; we'll decide below
1032
  context_found = bool(context.strip())
1033
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
1034
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
 
1037
  top_meta = (metadatas or [{}])[0] if metadatas else {}
1038
  msg_low = (input_data.user_message or "").lower()
1039
 
 
 
 
 
1040
  GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
1041
  generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
1042
 
1043
+ # Query-based prereq nudge
1044
+ PREREQ_TERMS = (
1045
+ "pre req", "pre-requisite", "pre-requisites", "prerequisite",
1046
+ "prerequisites", "pre requirement", "pre-requirements", "requirements"
1047
+ )
1048
  if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
1049
  detected_intent = "prereqs"
1050
 
1051
+ # Force errors intent for permissions
1052
  PERM_QUERY_TERMS = [
1053
  "permission", "permissions", "access", "access right", "authorization", "authorisation",
1054
  "role", "role access", "security", "security profile", "privilege", "not allowed", "not authorized", "denied",
 
1057
  if is_perm_query:
1058
  detected_intent = "errors"
1059
 
1060
+ # Heading-aware prereq nudge
1061
  sec_title = ((top_meta or {}).get("section") or "").strip().lower()
1062
+ PREREQ_HEADINGS = (
1063
+ "pre-requisites", "prerequisites", "pre requisites",
1064
+ "pre-requirements", "requirements"
1065
+ )
1066
  if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
1067
  detected_intent = "prereqs"
1068
 
1069
+ # --- Module-aware steps nudge (appointments, picking, shipping, etc.) ---
1070
  STEPS_TERMS = ("how to", "procedure", "perform", "steps", "do", "navigate")
1071
  ACTIONS_PRESENT = any(s in msg_low for syns in ACTION_SYNONYMS_EXT.values() for s in syns)
1072
  mod_tags = ((top_meta or {}).get("module_tags") or "").lower()
 
1082
  if detected_intent in ("neutral", "prereqs") and looks_like_steps_query and looks_like_module:
1083
  detected_intent = "steps"
1084
 
1085
+ # --- Meaning-aware SOP gating (uses filter info) ---
1086
  def _contains_any(s: str, keywords: tuple) -> bool:
1087
  low = (s or "").lower()
1088
  return any(k in low for k in keywords)
 
1150
  return act
1151
  return None
1152
 
1153
+ def _strip_boilerplate(raw_context: str) -> str:
1154
+ """Remove document title/date/author/change-history noise from steps."""
1155
+ MONTH_TERMS = ("january", "february", "march", "april", "may", "june",
1156
+ "july", "august", "september", "october", "november", "december")
1157
+ lines = _normalize_lines(raw_context)
1158
+ cleaned: List[str] = []
1159
+ for ln in lines:
1160
+ low = ln.lower()
1161
+ is_change_hist = ("change history" in low) or ("initial draft" in low) or ("review" in low) or ("version" in low)
1162
+ has_month_year = any(m in low for m in MONTH_TERMS) and bool(re.search(r"\b20\d{2}\b", low))
1163
+ is_title_line = ("sop document" in low) or ("contents" in low)
1164
+ if is_change_hist or has_month_year or is_title_line:
1165
+ continue
1166
+ cleaned.append(ln)
1167
+ return "\n".join(cleaned).strip()
1168
+
1169
+ def _extract_action_block(raw_context: str, target_act: Optional[str]) -> str:
1170
+ """
1171
+ Extract the contiguous block of lines for the target action (create/update/delete).
1172
+ Start when a line mentions the target action OR looks procedural for 'create',
1173
+ and stop ONLY when a line is a clear boundary:
1174
+ - inline heading for another topic (e.g., 'Appointment schedule updation', 'Appointment deletion'), OR
1175
+ - a line that strongly signals a different action (update/delete) via extended synonyms.
1176
+ """
1177
+ if not raw_context.strip():
1178
+ return raw_context
1179
+
1180
+ lines = _normalize_lines(raw_context)
1181
+ if not lines or not target_act:
1182
+ return raw_context
1183
+
1184
+ INLINE_BOUNDARIES = (
1185
+ "appointment schedule updation",
1186
+ "schedule updation",
1187
+ "appointment deletion",
1188
+ "deletion",
1189
+ )
1190
+
1191
+ other_terms: List[str] = []
1192
+ for act, syns in ACTION_SYNONYMS_EXT.items():
1193
+ if act != target_act:
1194
+ other_terms.extend(syns)
1195
+ other_terms_low = set(t.lower() for t in other_terms)
1196
+
1197
+ def is_boundary_line(low: str) -> bool:
1198
+ if any(h in low for h in INLINE_BOUNDARIES):
1199
+ return True
1200
+ if any(t in low for t in other_terms_low):
1201
+ return True
1202
+ return False
1203
+
1204
+ PROCEDURAL_VERBS = ("select", "choose", "click", "open", "add", "assign", "save",
1205
+ "navigate", "tag", "displayed", "triggered")
1206
+ def is_procedural(low: str) -> bool:
1207
+ return any(v in low for v in PROCEDURAL_VERBS)
1208
+
1209
+ target_terms_low = set(t.lower() for t in ACTION_SYNONYMS_EXT.get(target_act, []))
1210
+
1211
+ started = False
1212
+ block: List[str] = []
1213
+
1214
+ for ln in lines:
1215
+ low = ln.lower()
1216
+
1217
+ contains_target = any(t in low for t in target_terms_low)
1218
+ if not started:
1219
+ if contains_target or (target_act == "create" and is_procedural(low)):
1220
+ started = True
1221
+ block.append(ln)
1222
+ continue
1223
+
1224
+ if is_boundary_line(low):
1225
+ break
1226
+
1227
+ block.append(ln)
1228
+
1229
+ return "\n".join(block).strip() if block else raw_context
1230
+
1231
+ def _filter_steps_by_action(raw_context: str, asked_act: Optional[str]) -> str:
1232
+ cleaned = _strip_boilerplate(raw_context)
1233
+ block = _extract_action_block(cleaned, asked_act)
1234
+ if asked_act:
1235
+ other_terms: List[str] = []
1236
+ for act, syns in ACTION_SYNONYMS_EXT.items():
1237
+ if act != asked_act:
1238
+ other_terms.extend(syns)
1239
+ lines = _normalize_lines(block)
1240
+ lines = [ln for ln in lines if not any(t in ln.lower() for t in other_terms)]
1241
+ block = "\n".join(lines).strip() if lines else block
1242
+ return block
1243
+
1244
  escalation_line = None
1245
  full_errors = None
1246
  next_step_applied = False
 
1250
  context_preformatted = False
1251
  full_steps = None
1252
 
1253
+ # 1) Try by KB action tags
 
 
 
1254
  action_steps = _get_steps_for_action(best_doc, kb_results.get("actions", []))
1255
  if action_steps:
1256
  full_steps = action_steps
1257
  else:
 
1258
  if kb_results.get("actions"):
1259
+ alt_doc, alt_steps = _get_steps_for_action_global(kb_results["actions"], prefer_doc=best_doc)
1260
+ if alt_steps:
1261
+ best_doc = alt_doc # switch to the doc that actually has the section
1262
+ full_steps = alt_steps
1263
+
1264
+ asked_action = _detect_action_from_query(input_data.user_message)
1265
+ if not kb_results.get("actions") and asked_action:
1266
+ alt_doc, alt_steps = _get_steps_for_action_global([asked_action], prefer_doc=best_doc)
1267
+ if alt_steps:
1268
+ best_doc = alt_doc # switch to the doc that actually has the section
1269
+ full_steps = alt_steps
 
 
 
 
 
1270
 
 
1271
  if not full_steps:
1272
+ default_sec = _pick_default_action_section(best_doc)
1273
+ if default_sec:
1274
+ full_steps = get_section_text(best_doc, default_sec)
1275
  if not full_steps:
1276
+ full_steps = get_best_steps_section_text(best_doc)
1277
+ if not full_steps:
1278
+ sec = (top_meta or {}).get("section")
1279
+ if sec:
1280
+ full_steps = get_section_text(best_doc, sec)
1281
+
1282
  if full_steps:
1283
+ # Always add Save lines if present anywhere in the doc (independent of query wording)
1284
+ save_lines = _find_save_lines_in_doc(best_doc, max_lines=2)
1285
+ if save_lines:
 
 
 
1286
  low_steps = (full_steps or "").lower()
1287
  if not any(s in low_steps for s in SAVE_SYNS):
1288
+ full_steps = (full_steps or "").rstrip() + "\n" + save_lines
1289
+
1290
+ asked_action = _detect_action_from_query(input_data.user_message)
1291
+ full_steps = _filter_steps_by_action(full_steps, asked_action)
1292
 
 
1293
  numbered_full = _ensure_numbering(full_steps)
1294
+ next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
1295
+
1296
  if next_only is not None:
1297
  if len(next_only) == 0:
1298
  context = "You are at the final step of this SOP. No further steps."
 
1308
  context = full_steps
1309
  context_preformatted = False
1310
 
1311
+ # Clear filter info for debug clarity
1312
  filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
1313
+ context_found = True
1314
 
1315
  elif best_doc and detected_intent == "errors":
1316
  full_errors = get_best_errors_section_text(best_doc)
 
1334
  else:
1335
  full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
1336
  if full_steps:
1337
+ asked_action = _detect_action_from_query(input_data.user_message)
1338
+ full_steps = _filter_steps_by_action(full_steps, asked_action)
1339
  context = full_steps
1340
  detected_intent = "steps"
1341
  context_preformatted = False
1342
 
1343
  elif best_doc and detected_intent == "prereqs":
1344
+ full_prereqs = _find_prereq_section_text(best_doc)
1345
  if full_prereqs:
1346
  context = full_prereqs.strip()
1347
  else:
1348
  full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
1349
  if full_steps:
1350
+ asked_action = _detect_action_from_query(input_data.user_message)
1351
+ full_steps = _filter_steps_by_action(full_steps, asked_action)
1352
  context = full_steps
1353
  detected_intent = "steps"
1354
  context_preformatted = False
1355
+
1356
  else:
1357
+ # Neutral or other intents: use filtered context
1358
  context = filtered_context
1359
 
1360
  language_hint = _detect_language_hint(input_data.user_message)
 
1389
  except Exception:
1390
  bot_text, http_code = "", 0
1391
 
1392
+ # Deterministic local formatting
1393
  if detected_intent == "steps":
1394
  if ('context_preformatted' in locals()) and context_preformatted:
1395
  bot_text = context
1396
  else:
1397
  bot_text = _ensure_numbering(context)
1398
+
1399
  elif detected_intent == "errors":
1400
  if not bot_text.strip() or http_code == 429:
1401
  bot_text = context.strip()
1402
  if escalation_line:
1403
  bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
1404
+
1405
  else:
1406
  bot_text = context
1407
 
1408
+ # If the user explicitly asks to escalate, append escalation even in 'steps' intent
1409
  needs_escalation = (" escalate" in msg_norm) or ("escalation" in msg_norm)
1410
  if needs_escalation and best_doc:
1411
  esc_text = get_escalation_text(best_doc)
 
1415
  if line:
1416
  bot_text = (bot_text or "").rstrip() + "\n\n" + line
1417
 
1418
+ # Guarantee non-empty bot response
1419
  if not (bot_text or "").strip():
1420
  if context.strip():
1421
  bot_text = context.strip()
 
1425
  "Share a bit more detail (module/screen/error), or say ‘create ticket’."
1426
  )
1427
 
1428
+ # Status: mark OK when we served steps successfully
1429
  if detected_intent == "steps" and bot_text.strip():
1430
  status = "OK"
1431
  else:
 
1468
  except Exception as e:
1469
  raise HTTPException(status_code=500, detail=safe_str(e))
1470
 
1471
+
1472
  # ------------------------------ Ticket description generation ------------------------------
1473
  @app.post("/generate_ticket_desc")
1474
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
 
1507
  except Exception as e:
1508
  raise HTTPException(status_code=500, detail=safe_str(e))
1509
 
1510
+
1511
  # ------------------------------ Incident status ------------------------------
1512
  @app.post("/incident_status")
1513
  async def incident_status(input_data: TicketStatusInput):
 
1548
  except Exception as e:
1549
  raise HTTPException(status_code=500, detail=safe_str(e))
1550
 
1551
+
1552
  # ------------------------------ Incident ------------------------------
1553
  @app.post("/incident")
1554
  async def raise_incident(input_data: IncidentInput):
 
1559
  sys_id = result.get("sys_id")
1560
  resolved_note = ""
1561
  if bool(input_data.mark_resolved) and sys_id not in ("<unknown>", None):
1562
+ ok = _set_incident_resolved(sys_id)
1563
+ resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1564
  ticket_text = f"Incident created: {inc_number}{resolved_note}" if inc_number else "Incident created."
1565
  return {
1566
  "bot_response": f"✅ {ticket_text}",