srilakshu012456 commited on
Commit
82fc00a
·
verified ·
1 Parent(s): 225b5f1

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +184 -265
main.py CHANGED
@@ -315,9 +315,8 @@ def _soft_match_score(a: str, b: str) -> float:
315
  def _detect_next_intent(user_query: str) -> bool:
316
  q = _norm_text(user_query)
317
  keys = [
318
- 'after','after this','what next','whats next','next step',
319
- 'then what','following step','continue','subsequent','proceed',
320
- 'what to do','what should i do','how to proceed','how do i continue','proceed further','next?'
321
  ]
322
  return any(k in q for k in keys)
323
 
@@ -327,41 +326,39 @@ def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 8,
327
  1) Detect 'what next' intent.
328
  2) Stem & match query against each step using tokens + bigrams + synonyms.
329
  3) If a good anchor is found, return ONLY subsequent steps (window=max_next).
330
- Else return None (fallback to full SOP rendering).
331
  """
332
  if not _detect_next_intent(user_query):
333
  return None
 
334
  steps = _split_sop_into_steps(numbered_text)
335
  if not steps:
336
  return None
 
337
  q_norm = _norm_text(user_query)
338
  q_tokens = [t for t in q_norm.split() if len(t) > 1]
 
339
  best_idx, best_score = -1, -1.0
340
  for idx, step in enumerate(steps):
 
341
  s1 = _soft_match_score(user_query, step)
 
342
  syn = _syn_hits(q_tokens, step)
 
343
  score = s1 + 0.12 * syn
344
  if score > best_score:
345
  best_score, best_idx = score, idx
 
 
346
  if best_idx < 0 or best_score < min_score:
347
- return None
 
348
  start = best_idx + 1
349
  if start >= len(steps):
350
- return []
 
351
  end = min(start + max_next, len(steps))
352
- anchor_norm = _norm_text(steps[best_idx])
353
- def _unique(seq):
354
- seen = set()
355
- out = []
356
- for s in seq:
357
- k = _norm_text(s)
358
- if k == anchor_norm:
359
- continue
360
- if k not in seen:
361
- seen.add(k)
362
- out.append(s)
363
- return out
364
- return _unique(steps[start:end])
365
 
366
  def _syn_hits(q_tokens: List[str], step_line: str) -> int:
367
  """
@@ -631,12 +628,153 @@ async def health_check():
631
  # ------------------------------------------------------------------------------
632
  # Chat
633
  # ------------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  @app.post("/chat")
635
  async def chat_with_ai(input_data: ChatInput):
636
  assist_followup: Optional[str] = None
637
  try:
638
  msg_norm = (input_data.user_message or "").lower().strip()
639
-
640
  # Yes/No handlers
641
  if msg_norm in ("yes", "y", "sure", "ok", "okay"):
642
  return {
@@ -660,7 +798,6 @@ async def chat_with_ai(input_data: ChatInput):
660
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
661
  if _has_negation_resolved(msg_norm):
662
  is_llm_resolved = False
663
-
664
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
665
  try:
666
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
@@ -731,7 +868,7 @@ async def chat_with_ai(input_data: ChatInput):
731
  "debug": {"intent": "create_ticket"},
732
  }
733
 
734
- # Status intent (ticket/incident) — disambiguated
735
  status_intent = _parse_ticket_status_intent(msg_norm)
736
  if status_intent:
737
  if status_intent.get("ask_number"):
@@ -755,7 +892,6 @@ async def chat_with_ai(input_data: ChatInput):
755
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
756
  if not instance_url:
757
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
758
-
759
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
760
  number = status_intent.get("number")
761
  url = f"{instance_url}/api/now/table/incident?number={number}"
@@ -763,12 +899,10 @@ async def chat_with_ai(input_data: ChatInput):
763
  data = response.json()
764
  lst = data.get("result", [])
765
  result = (lst or [{}])[0] if response.status_code == 200 else {}
766
-
767
  state_code = builtins.str(result.get("state", "unknown"))
768
  state_label = STATE_MAP.get(state_code, state_code)
769
  short = result.get("short_description", "")
770
  num = result.get("number", number or "unknown")
771
-
772
  return {
773
  "bot_response": (
774
  f"**Ticket:** {num}\n"
@@ -788,9 +922,7 @@ async def chat_with_ai(input_data: ChatInput):
788
  except Exception as e:
789
  raise HTTPException(status_code=500, detail=safe_str(e))
790
 
791
- # -----------------------------
792
  # Hybrid KB search
793
- # -----------------------------
794
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
795
  documents = kb_results.get("documents", [])
796
  metadatas = kb_results.get("metadatas", [])
@@ -814,141 +946,44 @@ async def chat_with_ai(input_data: ChatInput):
814
 
815
  selected = items[:max(1, 2)]
816
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
817
-
818
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
819
  context = filtered_text
820
  context_found = bool(context.strip())
821
 
822
- best_distance = (
823
- min([d for d in distances if d is not None], default=None) if distances else None
824
- )
825
- best_combined = (
826
- max([c for c in combined if c is not None], default=None) if combined else None
827
- )
828
-
829
- detected_intent = kb_results.get("user_intent", "neutral")
830
  best_doc = kb_results.get("best_doc")
831
  top_meta = (metadatas or [{}])[0] if metadatas else {}
832
- msg_low = (input_data.user_message or "").lower()
833
 
 
834
  GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
835
  generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
836
 
837
- # Query-based prereq nudge
838
- PREREQ_TERMS = (
839
- "pre req", "pre-requisite", "pre-requisites", "prerequisite",
840
- "prerequisites", "pre requirement", "pre-requirements", "requirements"
841
- )
842
- if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
843
- detected_intent = "prereqs"
844
-
845
- # Permissions force
846
- PERM_QUERY_TERMS = [
847
- "permission", "permissions", "access", "access right", "authorization", "authorisation",
848
- "role", "role access", "security", "security profile", "privilege",
849
- "not allowed", "not authorized", "denied",
850
- ]
851
- is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
852
- if is_perm_query:
853
- detected_intent = "errors"
854
-
855
- # Heading-aware prereq nudge
856
- sec_title = ((top_meta or {}).get("section") or "").strip().lower()
857
- PREREQ_HEADINGS = (
858
- "pre-requisites", "prerequisites", "pre requisites",
859
- "pre-requirements", "requirements"
860
- )
861
- if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
862
- detected_intent = "prereqs"
863
-
864
- # ---- FORCE STEPS for "what's next" / "next step" queries ----
865
  try:
866
  if _detect_next_intent(input_data.user_message):
867
  detected_intent = "steps"
 
 
868
  except Exception:
869
- pass
870
-
871
- # Gating
872
- def _contains_any(s: str, keywords: tuple) -> bool:
873
- low = (s or "").lower()
874
- return any(k in low for k in keywords)
875
-
876
- DOMAIN_TERMS = (
877
- "trailer", "shipment", "order", "load", "wave",
878
- "inventory", "putaway", "receiving", "appointment",
879
- "dock", "door", "manifest", "pallet", "container",
880
- "asn", "grn", "pick", "picking"
881
- )
882
- ACTION_OR_ERROR_TERMS = (
883
- "how to", "procedure", "perform",
884
- "close", "closing", "open", "navigate", "scan", "confirm", "generate", "update",
885
- "receive", "receiving",
886
- "error", "issue", "fail", "failed", "not working", "locked", "mismatch",
887
- "access", "permission", "status"
888
- )
889
 
890
- matched_count = int(filt_info.get("matched_count") or 0)
891
- filter_mode = (filt_info.get("mode") or "").lower()
892
- has_any_action_or_error = _contains_any(msg_low, ACTION_OR_ERROR_TERMS)
893
- mentions_domain = _contains_any(msg_low, DOMAIN_TERMS)
894
-
895
- short_query = len((input_data.user_message or "").split()) <= 4
896
- gate_combined_ok = 0.60 if short_query else 0.55
897
- combined_ok = (best_combined is not None and best_combined >= gate_combined_ok)
898
- weak_domain_only = (mentions_domain and not has_any_action_or_error)
899
- low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
900
-
901
- strong_steps_bypass = True # next-step override already set steps; allow
902
- strong_error_signal = len(_detect_error_families(msg_low)) > 0
903
-
904
- if (weak_domain_only or (low_context_hit and not combined_ok)) \
905
- and not strong_steps_bypass \
906
- and not (strong_error_signal or generic_error_signal):
907
- return {
908
- "bot_response": _build_clarifying_message(),
909
- "status": "NO_KB_MATCH",
910
- "context_found": False,
911
- "ask_resolved": False,
912
- "suggest_incident": True,
913
- "followup": "Share more details (module/screen/error), or say 'create ticket'.",
914
- "options": [{"type": "yesno", "title": "Share details or raise a ticket?"}],
915
- "top_hits": [],
916
- "sources": [],
917
- "debug": {
918
- "intent": "sop_rejected_weak_match",
919
- "matched_count": matched_count,
920
- "filter_mode": filter_mode,
921
- "best_combined": best_combined,
922
- "mentions_domain": mentions_domain,
923
- "has_any_action_or_error": has_any_action_or_error,
924
- "strong_steps_bypass": strong_steps_bypass,
925
- "strong_error_signal": strong_error_signal,
926
- "generic_error_signal": generic_error_signal,
927
- },
928
- }
929
-
930
- # Build SOP context if allowed
931
  escalation_line: Optional[str] = None
932
- full_errors: Optional[str] = None
933
  next_step_applied = False
934
  next_step_info: Dict[str, Any] = {}
935
  context_preformatted = False
936
 
937
  if best_doc and detected_intent == "steps":
938
- full_steps = get_best_steps_section_text(best_doc)
939
- if not full_steps:
940
- sec = (top_meta or {}).get("section")
941
- if sec:
942
- full_steps = get_section_text(best_doc, sec)
943
-
944
  if full_steps:
945
- numbered_full = _ensure_numbering(full_steps)
946
- next_only = _resolve_next_steps(
947
- input_data.user_message,
948
- numbered_full,
949
- max_next=6,
950
- min_score=0.35
951
- )
952
  if next_only is not None:
953
  if len(next_only) == 0:
954
  context = "You are at the final step of this SOP. No further steps."
@@ -961,158 +996,42 @@ async def chat_with_ai(input_data: ChatInput):
961
  next_step_info = {"count": len(next_only)}
962
  context_preformatted = True
963
  else:
964
- context = full_steps
965
  context_preformatted = False
966
-
967
- # clear filter info for debug clarity
968
- filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
969
- context_found = True
970
-
971
- elif best_doc and detected_intent == "errors":
972
- full_errors = get_best_errors_section_text(best_doc)
973
- if full_errors:
974
- ctx_err = _extract_errors_only(full_errors, max_lines=30)
975
- if is_perm_query:
976
- context = _filter_permission_lines(ctx_err, max_lines=6)
977
- else:
978
- is_specific_error = len(_detect_error_families(msg_low)) > 0
979
- if is_specific_error:
980
- context = _filter_context_for_query(ctx_err, input_data.user_message)[0]
981
- else:
982
- all_lines: List[str] = _normalize_lines(ctx_err)
983
- error_bullets = [
984
- ln for ln in all_lines
985
- if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln)
986
- ]
987
- context = "\n".join(error_bullets[:8]).strip()
988
- assist_followup = (
989
- "Please tell me which error above matches your screen (paste the exact text), "
990
- "or share a screenshot. I can guide you further or raise a ServiceNow ticket."
991
- )
992
- escalation_line = _extract_escalation_line(full_errors)
993
-
994
- elif best_doc and detected_intent == "prereqs":
995
- full_prereqs = _find_prereq_section_text(best_doc)
996
- if full_prereqs:
997
- context = full_prereqs.strip()
998
  context_found = True
999
 
1000
  else:
1001
- # Neutral or other intents: use filtered context
1002
  context = filtered_text
1003
 
1004
- # Language hint & paraphrase (for errors only)
1005
- language_hint = _detect_language_hint(input_data.user_message)
1006
- lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
1007
- use_gemini = (detected_intent == "errors")
1008
-
1009
- enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
1010
- - Do not add any information that is not present in the context.
1011
- - If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
1012
- - {lang_line}
1013
- ### Context
1014
- {context}
1015
- ### Question
1016
- {input_data.user_message}
1017
- ### Output
1018
- Return ONLY the rewritten guidance."""
1019
-
1020
- headers = {"Content-Type": "application/json"}
1021
- payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
1022
- bot_text = ""
1023
- http_code = 0
1024
-
1025
- if use_gemini and GEMINI_API_KEY:
1026
- try:
1027
- resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
1028
- http_code = getattr(resp, "status_code", 0)
1029
- try:
1030
- result = resp.json()
1031
- except Exception:
1032
- result = {}
1033
- try:
1034
- bot_text = result.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
1035
- except Exception:
1036
- bot_text = ""
1037
- except Exception:
1038
- bot_text, http_code = "", 0
1039
-
1040
- # Deterministic local formatting
1041
  if detected_intent == "steps":
1042
- if context_preformatted:
1043
- bot_text = context
1044
- else:
1045
- bot_text = _ensure_numbering(context)
1046
- elif detected_intent == "errors":
1047
- if not (bot_text or "").strip() or http_code == 429:
1048
- bot_text = context.strip()
1049
- if escalation_line:
1050
- bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
1051
  else:
1052
  bot_text = context
1053
 
1054
- # Append escalation if explicitly requested even in steps mode
1055
- needs_escalation = (" escalate" in msg_norm) or ("escalation" in msg_norm)
1056
- if needs_escalation and best_doc:
1057
- esc_text = get_escalation_text(best_doc)
1058
- if not esc_text and full_errors:
1059
- esc_text = full_errors
1060
- line = _extract_escalation_line(esc_text or "")
1061
- if line:
1062
- bot_text = (bot_text or "").rstrip() + "\n\n" + line
1063
-
1064
- # Non-empty guarantee
1065
- if not (bot_text or "").strip():
1066
- if context.strip():
1067
- bot_text = context.strip()
1068
- else:
1069
- bot_text = (
1070
- "I found some related guidance but couldn’t assemble a reply. "
1071
- "Share a bit more detail (module/screen/error), or say ‘create ticket’."
1072
- )
1073
-
1074
- short_query = len((input_data.user_message or "").split()) <= 4
1075
- gate_combined_ok = 0.60 if short_query else 0.55
1076
- status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
1077
-
1078
- lower = (bot_text or "").lower()
1079
- if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
1080
- status = "PARTIAL"
1081
-
1082
- options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
1083
-
1084
  return {
1085
  "bot_response": bot_text,
1086
- "status": status,
1087
  "context_found": context_found,
1088
- "ask_resolved": (status == "OK" and detected_intent != "errors"),
1089
- "suggest_incident": (status == "PARTIAL"),
1090
- "followup": (assist_followup if assist_followup else ("Is this helpful, or should I raise a ticket?" if status == "PARTIAL" else None)),
1091
- "options": options,
1092
  "top_hits": [],
1093
  "sources": [],
1094
  "debug": {
1095
- "used_chunks": len((context or "").split("\n\n---\n\n")) if context else 0,
1096
- "best_distance": best_distance,
1097
- "best_combined": best_combined,
1098
- "http_status": http_code,
1099
- "filter_mode": filt_info.get("mode") if isinstance(filt_info, dict) else None,
1100
- "matched_count": filt_info.get("matched_count") if isinstance(filt_info, dict) else None,
1101
  "user_intent": detected_intent,
1102
  "best_doc": best_doc,
1103
  "next_step": {"applied": next_step_applied, "info": next_step_info},
1104
  },
1105
  }
1106
-
1107
  except HTTPException:
1108
  raise
1109
  except Exception as e:
1110
  raise HTTPException(status_code=500, detail=safe_str(e))
1111
 
1112
 
1113
- # ------------------------------------------------------------------------------
1114
- # Ticket description generation
1115
- # ------------------------------------------------------------------------------
1116
  @app.post("/generate_ticket_desc")
1117
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
1118
  try:
 
315
  def _detect_next_intent(user_query: str) -> bool:
316
  q = _norm_text(user_query)
317
  keys = [
318
+ "after", "after this", "what next", "whats next", "next step",
319
+ "then what", "following step", "continue", "subsequent", "proceed"
 
320
  ]
321
  return any(k in q for k in keys)
322
 
 
326
  1) Detect 'what next' intent.
327
  2) Stem & match query against each step using tokens + bigrams + synonyms.
328
  3) If a good anchor is found, return ONLY subsequent steps (window=max_next).
329
+ Else return None (fallback to full SOP rendering).
330
  """
331
  if not _detect_next_intent(user_query):
332
  return None
333
+
334
  steps = _split_sop_into_steps(numbered_text)
335
  if not steps:
336
  return None
337
+
338
  q_norm = _norm_text(user_query)
339
  q_tokens = [t for t in q_norm.split() if len(t) > 1]
340
+
341
  best_idx, best_score = -1, -1.0
342
  for idx, step in enumerate(steps):
343
+ # base fuzzy score
344
  s1 = _soft_match_score(user_query, step)
345
+ # synonym hits
346
  syn = _syn_hits(q_tokens, step)
347
+ # combined score (synonyms are discrete)
348
  score = s1 + 0.12 * syn
349
  if score > best_score:
350
  best_score, best_idx = score, idx
351
+
352
+ # Looser threshold to accept anchors with synonyms / tense differences
353
  if best_idx < 0 or best_score < min_score:
354
+ return None # let caller fall back to the full SOP
355
+
356
  start = best_idx + 1
357
  if start >= len(steps):
358
+ return [] # already at final step
359
+
360
  end = min(start + max_next, len(steps))
361
+ return steps[start:end]
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
  def _syn_hits(q_tokens: List[str], step_line: str) -> int:
364
  """
 
628
  # ------------------------------------------------------------------------------
629
  # Chat
630
  # ------------------------------------------------------------------------------
631
+
632
+
633
+ # --- Helpers for section slicing and action-filter ---
634
+ def _slice_steps_by_known_headings(text: str, actions: List[str]) -> str:
635
+ """
636
+ If the SOP mixes creation/update/deletion under one 'Document' section,
637
+ carve out the subsection by looking for heading markers in the text.
638
+ """
639
+ if not text:
640
+ return text
641
+ act = (actions or [None])[0]
642
+ lines = [ln for ln in (text or '').splitlines() if ln.strip()]
643
+ if not lines or not act:
644
+ return text
645
+ act = act.lower()
646
+ idx_create = idx_update = idx_delete = None
647
+ for i, ln in enumerate(lines):
648
+ low = ln.lower()
649
+ if idx_create is None and ('appointment' in low and 'creat' in low and 'step' in low):
650
+ idx_create = i
651
+ if idx_update is None and ('appointment' in low and 'updat' in low and 'step' in low):
652
+ idx_update = i
653
+ if idx_delete is None and (('deletion' in low) or ('delete' in low)) and ('appointment' in low and 'step' in low):
654
+ idx_delete = i
655
+ start = None
656
+ if act == 'create':
657
+ start = idx_create
658
+ elif act == 'update':
659
+ start = idx_update
660
+ elif act == 'delete':
661
+ start = idx_delete
662
+ if start is None:
663
+ return text
664
+ next_idxs = sorted([x for x in (idx_create, idx_update, idx_delete) if x is not None and x > start])
665
+ end = next_idxs[0] if next_idxs else len(lines)
666
+ sub = '\n'.join(lines[start:end]).strip()
667
+ return sub or text
668
+
669
+
670
+ def _filter_steps_for_action(text: str, actions: List[str]) -> str:
671
+ """Remove lines that conflict with requested action (create/update/delete)."""
672
+ if not text:
673
+ return text
674
+ lines = [ln for ln in (text or '').splitlines() if ln.strip()]
675
+ if not lines or not actions:
676
+ return text
677
+ act = (actions or [None])[0]
678
+ if not act:
679
+ return text
680
+ act = act.lower()
681
+ conflicts = {
682
+ 'create': ('delete','remove','update','modify','change','edit'),
683
+ 'update': ('delete','remove','create','add','new','generate'),
684
+ 'delete': ('create','add','new','generate','update','modify','change','edit'),
685
+ }
686
+ conf = conflicts.get(act, ())
687
+ kept = [ln for ln in lines if not any(c in ln.lower() for c in conf)]
688
+ return '\n'.join(kept).strip() if kept else text
689
+
690
+ # --- Overrides with safer logic ---
691
+ def _detect_next_intent(user_query: str) -> bool:
692
+ q = _norm_text(user_query)
693
+ keys = [
694
+ 'after','after this','what next','whats next','next step',
695
+ 'then what','following step','continue','subsequent','proceed',
696
+ 'what to do','what should i do','how to proceed','how do i continue','proceed further','next?'
697
+ ]
698
+ return any(k in q for k in keys)
699
+
700
+
701
+ def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 8, min_score: float = 0.25):
702
+ """
703
+ Detect 'what next' intent and return only subsequent steps.
704
+ Excludes the anchor step and de-duplicates the result.
705
+ """
706
+ if not _detect_next_intent(user_query):
707
+ return None
708
+ steps = _split_sop_into_steps(numbered_text)
709
+ if not steps:
710
+ return None
711
+ q_norm = _norm_text(user_query)
712
+ q_tokens = [t for t in q_norm.split() if len(t) > 1]
713
+ best_idx, best_score = -1, -1.0
714
+ for idx, step in enumerate(steps):
715
+ s1 = _soft_match_score(user_query, step)
716
+ syn = _syn_hits(q_tokens, step)
717
+ score = s1 + 0.12 * syn
718
+ if score > best_score:
719
+ best_score, best_idx = score, idx
720
+ if best_idx < 0 or best_score < min_score:
721
+ return None
722
+ start = best_idx + 1
723
+ if start >= len(steps):
724
+ return []
725
+ end = min(start + max_next, len(steps))
726
+ anchor_norm = _norm_text(steps[best_idx])
727
+ out, seen = [], set()
728
+ for s in steps[start:end]:
729
+ k = _norm_text(s)
730
+ if k == anchor_norm:
731
+ continue
732
+ if k not in seen:
733
+ seen.add(k)
734
+ out.append(s)
735
+ return out
736
+
737
+
738
+ def _syn_hits(q_tokens: List[str], step_line: str) -> int:
739
+ """Synonym/alias hits tailored for WMS picking + appointments wording."""
740
+ step = _norm_text(step_line)
741
+ SYNS = {
742
+ 'scan': {'scan', 'prompt', 'display', 'show'},
743
+ 'confirm': {'confirm', 'verify', 'check'},
744
+ 'item': {'item', 'sku', 'product'},
745
+ 'qty': {'qty', 'quantity', 'count'},
746
+ 'place': {'place', 'put', 'stow'},
747
+ 'complete': {'complete', 'finish', 'done'},
748
+ 'staging': {'staging', 'stage', 'dock', 'door'},
749
+ 'location': {'location', 'loc'},
750
+ 'pallet': {'pallet', 'container'},
751
+ # Appointment vocabulary
752
+ 'appointment': {'appointment', 'slot', 'schedule'},
753
+ 'assign': {'assign', 'tag'},
754
+ 'save': {'save', 'saved'},
755
+ 'delete': {'delete', 'remove', 'removed'},
756
+ 'update': {'update', 'modify', 'change', 'edit', 'updated'},
757
+ 'vendor': {'vendor', 'supplier'},
758
+ 'pro': {'pro number', 'pro'},
759
+ }
760
+ hits = 0
761
+ for qt in q_tokens:
762
+ for fam, words in SYNS.items():
763
+ if qt == fam or qt in words:
764
+ if any(w in step for w in words):
765
+ hits += 1
766
+ PHRASES = ('scan location', 'confirm item', 'pick quantity', 'place picked', 'move pallet', 'staging area')
767
+ for ph in PHRASES:
768
+ if ph in step:
769
+ hits += 1
770
+ return hits
771
+
772
+
773
  @app.post("/chat")
774
  async def chat_with_ai(input_data: ChatInput):
775
  assist_followup: Optional[str] = None
776
  try:
777
  msg_norm = (input_data.user_message or "").lower().strip()
 
778
  # Yes/No handlers
779
  if msg_norm in ("yes", "y", "sure", "ok", "okay"):
780
  return {
 
798
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
799
  if _has_negation_resolved(msg_norm):
800
  is_llm_resolved = False
 
801
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
802
  try:
803
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
 
868
  "debug": {"intent": "create_ticket"},
869
  }
870
 
871
+ # Status intent (ticket/incident)
872
  status_intent = _parse_ticket_status_intent(msg_norm)
873
  if status_intent:
874
  if status_intent.get("ask_number"):
 
892
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
893
  if not instance_url:
894
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
 
895
  headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
896
  number = status_intent.get("number")
897
  url = f"{instance_url}/api/now/table/incident?number={number}"
 
899
  data = response.json()
900
  lst = data.get("result", [])
901
  result = (lst or [{}])[0] if response.status_code == 200 else {}
 
902
  state_code = builtins.str(result.get("state", "unknown"))
903
  state_label = STATE_MAP.get(state_code, state_code)
904
  short = result.get("short_description", "")
905
  num = result.get("number", number or "unknown")
 
906
  return {
907
  "bot_response": (
908
  f"**Ticket:** {num}\n"
 
922
  except Exception as e:
923
  raise HTTPException(status_code=500, detail=safe_str(e))
924
 
 
925
  # Hybrid KB search
 
926
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
927
  documents = kb_results.get("documents", [])
928
  metadatas = kb_results.get("metadatas", [])
 
946
 
947
  selected = items[:max(1, 2)]
948
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
 
949
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
950
  context = filtered_text
951
  context_found = bool(context.strip())
952
 
953
+ best_combined = (max([c for c in combined if c is not None], default=None) if combined else None)
 
 
 
 
 
 
 
954
  best_doc = kb_results.get("best_doc")
955
  top_meta = (metadatas or [{}])[0] if metadatas else {}
 
956
 
957
+ msg_low = (input_data.user_message or "").lower()
958
  GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
959
  generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
960
 
961
+ # Force steps on next-intent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
962
  try:
963
  if _detect_next_intent(input_data.user_message):
964
  detected_intent = "steps"
965
+ else:
966
+ detected_intent = kb_results.get("user_intent", "neutral")
967
  except Exception:
968
+ detected_intent = kb_results.get("user_intent", "neutral")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970
  escalation_line: Optional[str] = None
 
971
  next_step_applied = False
972
  next_step_info: Dict[str, Any] = {}
973
  context_preformatted = False
974
 
975
  if best_doc and detected_intent == "steps":
976
+ sec = (top_meta or {}).get('section')
977
+ if sec:
978
+ full_steps = get_section_text(best_doc, sec)
979
+ else:
980
+ full_steps = get_best_steps_section_text(best_doc)
 
981
  if full_steps:
982
+ actions = kb_results.get('actions', [])
983
+ sliced = _slice_steps_by_known_headings(full_steps, actions)
984
+ filtered = _filter_steps_for_action(sliced, actions) if actions else sliced
985
+ numbered_full = _ensure_numbering(filtered)
986
+ next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
 
 
987
  if next_only is not None:
988
  if len(next_only) == 0:
989
  context = "You are at the final step of this SOP. No further steps."
 
996
  next_step_info = {"count": len(next_only)}
997
  context_preformatted = True
998
  else:
999
+ context = filtered
1000
  context_preformatted = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1001
  context_found = True
1002
 
1003
  else:
1004
+ # Non-steps intents fall back to filtered context
1005
  context = filtered_text
1006
 
1007
+ # Final formatting
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1008
  if detected_intent == "steps":
1009
+ bot_text = context if context_preformatted else _ensure_numbering(context)
 
 
 
 
 
 
 
 
1010
  else:
1011
  bot_text = context
1012
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1013
  return {
1014
  "bot_response": bot_text,
1015
+ "status": "OK" if (best_combined is not None and best_combined >= 0.55) else "PARTIAL",
1016
  "context_found": context_found,
1017
+ "ask_resolved": False,
1018
+ "suggest_incident": False,
1019
+ "followup": None,
1020
+ "options": [],
1021
  "top_hits": [],
1022
  "sources": [],
1023
  "debug": {
 
 
 
 
 
 
1024
  "user_intent": detected_intent,
1025
  "best_doc": best_doc,
1026
  "next_step": {"applied": next_step_applied, "info": next_step_info},
1027
  },
1028
  }
 
1029
  except HTTPException:
1030
  raise
1031
  except Exception as e:
1032
  raise HTTPException(status_code=500, detail=safe_str(e))
1033
 
1034
 
 
 
 
1035
  @app.post("/generate_ticket_desc")
1036
  async def generate_ticket_desc_ep(input_data: TicketDescInput):
1037
  try: