srilakshu012456 commited on
Commit
3fc36d2
·
verified ·
1 Parent(s): 62e7962

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +363 -85
main.py CHANGED
@@ -1,6 +1,4 @@
1
 
2
- # main_hugging_phase_recent.py
3
-
4
  import os
5
  import json
6
  import re
@@ -14,6 +12,8 @@ from pydantic import BaseModel
14
  from dotenv import load_dotenv
15
  from datetime import datetime
16
 
 
 
17
  from services.kb_creation import (
18
  collection,
19
  ingest_documents,
@@ -144,6 +144,20 @@ ERROR_FAMILY_SYNS = {
144
  ),
145
  }
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  def _detect_error_families(msg: str) -> list:
149
  """Return matching error family names found in the message (generic across SOPs)."""
@@ -157,6 +171,116 @@ def _detect_error_families(msg: str) -> list:
157
  return fams
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  def _is_domain_status_context(msg_norm: str) -> bool:
161
  if "status locked" in msg_norm or "locked status" in msg_norm:
162
  return True
@@ -678,7 +802,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
678
  notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
679
  payload_C = clean({
680
  "state": "6",
681
- code_field: close_code_val,
682
  notes_field: close_notes_val,
683
  "caller_id": caller_sysid,
684
  "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
@@ -899,8 +1023,12 @@ async def chat_with_ai(input_data: ChatInput):
899
 
900
  selected = items[:max(1, 2)]
901
  context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
 
 
902
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
903
- context = filtered_text
 
 
904
  context_found = bool(context.strip())
905
  best_distance = min([d for d in distances if d is not None], default=None) if distances else None
906
  best_combined = max([c for c in combined if c is not None], default=None) if combined else None
@@ -938,24 +1066,23 @@ async def chat_with_ai(input_data: ChatInput):
938
  if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
939
  detected_intent = "prereqs"
940
 
941
- # --- Steps nudge: "how to / perform" + receiving/inbound => steps intent
942
  STEPS_TERMS = ("how to", "procedure", "perform", "steps", "do", "navigate")
943
- RECEIVING_TERMS = ("inbound", "receiving", "goods receipt", "grn")
944
-
945
  mod_tags = ((top_meta or {}).get("module_tags") or "").lower()
946
 
947
- looks_like_steps_query = any(t in msg_low for t in STEPS_TERMS)
948
- looks_like_receiving = (
949
- any(t in msg_low for t in RECEIVING_TERMS)
950
- or "receiving" in mod_tags
951
- or "inbound" in sec_title
952
- or "receiving" in sec_title
953
  )
 
954
 
955
- if detected_intent in ("neutral", "prereqs") and looks_like_steps_query and looks_like_receiving:
956
  detected_intent = "steps"
957
 
958
- # --- Meaning-aware SOP gating ---
959
  def _contains_any(s: str, keywords: tuple) -> bool:
960
  low = (s or "").lower()
961
  return any(k in low for k in keywords)
@@ -967,9 +1094,9 @@ async def chat_with_ai(input_data: ChatInput):
967
  "asn", "grn", "pick", "picking"
968
  )
969
  ACTION_OR_ERROR_TERMS = (
970
- "how to", "procedure", "perform", # added
971
  "close", "closing", "open", "navigate", "scan", "confirm", "generate", "update",
972
- "receive", "receiving", # added
973
  "error", "issue", "fail", "failed", "not working", "locked", "mismatch",
974
  "access", "permission", "status"
975
  )
@@ -986,8 +1113,7 @@ async def chat_with_ai(input_data: ChatInput):
986
  weak_domain_only = (mentions_domain and not has_any_action_or_error)
987
  low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
988
 
989
- # Bypass gate when strong steps signals are present for Receiving module
990
- strong_steps_bypass = looks_like_steps_query and looks_like_receiving
991
  strong_error_signal = len(_detect_error_families(msg_low)) > 0
992
  if (weak_domain_only or (low_context_hit and not combined_ok)) \
993
  and not strong_steps_bypass \
@@ -1016,71 +1142,220 @@ async def chat_with_ai(input_data: ChatInput):
1016
  },
1017
  }
1018
 
1019
- # Build SOP context if allowed
1020
- if is_perm_query:
1021
- detected_intent = "errors"
1022
-
1023
- escalation_line = None # SOP escalation candidate
1024
- full_errors = None # keep for possible escalation extraction
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1025
  next_step_applied = False
1026
  next_step_info: Dict[str, Any] = {}
1027
 
1028
- if best_doc:
1029
- if detected_intent == "steps":
1030
- full_steps = get_best_steps_section_text(best_doc)
1031
- if not full_steps:
1032
- sec = (top_meta or {}).get("section")
1033
- if sec:
1034
- full_steps = get_section_text(best_doc, sec)
1035
 
1036
- if full_steps:
1037
- # Use numbered form only for matching; keep raw for full output
1038
- numbered_full = _ensure_numbering(full_steps)
1039
- next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
1040
-
1041
- if next_only is not None:
1042
- # "what's next" mode
1043
- if len(next_only) == 0:
1044
- context = "You are at the final step of this SOP. No further steps."
1045
- next_step_applied = True
1046
- next_step_info = {"count": 0}
1047
- context_preformatted = True
1048
- else:
1049
- context = _format_steps_as_numbered(next_only)
1050
- next_step_applied = True
1051
- next_step_info = {"count": len(next_only)}
1052
- context_preformatted = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1053
  else:
1054
- # Normal mode: return the full SOP section (raw),
1055
- # and we'll number it below once.
1056
- context = full_steps
1057
- context_preformatted = False
1058
-
1059
- elif detected_intent == "errors":
1060
- full_errors = get_best_errors_section_text(best_doc)
1061
- if full_errors:
1062
- ctx_err = _extract_errors_only(full_errors, max_lines=30)
1063
- if is_perm_query:
1064
- context = _filter_permission_lines(ctx_err, max_lines=6)
 
 
 
 
 
 
 
 
 
 
 
1065
  else:
1066
- # Decide specific vs generic:
1067
- is_specific_error = len(_detect_error_families(msg_low)) > 0
1068
- if is_specific_error:
1069
- context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=1)
1070
- else:
1071
- all_lines: List[str] = _normalize_lines(ctx_err)
1072
- error_bullets = [ln for ln in all_lines if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln)]
1073
- context = "\n".join(error_bullets[:8]).strip()
1074
- assist_followup = (
1075
- "Please tell me which error above matches your screen (paste the exact text), "
1076
- "or share a screenshot. I can guide you further or raise a ServiceNow ticket."
1077
- )
1078
- escalation_line = _extract_escalation_line(full_errors)
1079
-
1080
- elif detected_intent == "prereqs":
1081
- full_prereqs = _find_prereq_section_text(best_doc)
1082
- if full_prereqs:
1083
- context = full_prereqs.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1084
 
1085
  language_hint = _detect_language_hint(input_data.user_message)
1086
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
@@ -1116,8 +1391,6 @@ Return ONLY the rewritten guidance."""
1116
 
1117
  # Deterministic local formatting
1118
  if detected_intent == "steps":
1119
- # If we trimmed to next steps, 'context' is already formatted (or a sentence).
1120
- # Only number when returning full SOP raw text.
1121
  if ('context_preformatted' in locals()) and context_preformatted:
1122
  bot_text = context
1123
  else:
@@ -1152,9 +1425,14 @@ Return ONLY the rewritten guidance."""
1152
  "Share a bit more detail (module/screen/error), or say ‘create ticket’."
1153
  )
1154
 
1155
- short_query = len((input_data.user_message or "").split()) <= 4
1156
- gate_combined_ok = 0.60 if short_query else 0.55
1157
- status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
 
 
 
 
 
1158
  lower = (bot_text or "").lower()
1159
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
1160
  status = "PARTIAL"
@@ -1175,8 +1453,8 @@ Return ONLY the rewritten guidance."""
1175
  "best_distance": best_distance,
1176
  "best_combined": best_combined,
1177
  "http_status": http_code,
1178
- "filter_mode": filt_info.get("mode"),
1179
- "matched_count": filt_info.get("matched_count"),
1180
  "user_intent": detected_intent,
1181
  "best_doc": best_doc,
1182
  "next_step": {
 
1
 
 
 
2
  import os
3
  import json
4
  import re
 
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 (
18
  collection,
19
  ingest_documents,
 
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",
157
+ "update time", "update date", "update slot", "update appointment", "edit appointment"
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)."""
 
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", {})
191
+ if m.get("filename") == best_doc and (m.get("intent_tag") == "steps"):
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":
216
+ continue
217
+ tag = ((m.get("action_tag") or "").strip().lower())
218
+ if tag and tag in act_set:
219
+ doc = m.get("filename")
220
+ sec = (m.get("section") or "").strip()
221
+ txt = get_section_text(doc, sec)
222
+ if not txt or not txt.strip():
223
+ continue
224
+ score = 1.0
225
+ if prefer_doc and doc == prefer_doc:
226
+ score += 0.5
227
+ mtags = (m.get("module_tags") or "").lower()
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:
253
+ sections.append(title)
254
+ for key in order:
255
+ for t in sections:
256
+ if key in t:
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
 
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"),
 
1023
 
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
 
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()
1073
 
1074
+ MODULE_TOKENS = tuple({term for syns in MODULE_VOCAB.values() for term in syns})
1075
+ looks_like_module = (
1076
+ any(t in msg_low for t in MODULE_TOKENS)
1077
+ or any(m in mod_tags for m in ["appointments", "receiving", "picking", "putaway", "shipping", "inventory", "replenishment"])
1078
+ or any(m in sec_title for m in ["appointment", "appointments", "schedule", "dock", "door", "picking", "putaway", "shipping", "inventory"])
 
1079
  )
1080
+ looks_like_steps_query = any(t in msg_low for t in STEPS_TERMS) or ACTIONS_PRESENT
1081
 
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)
 
1094
  "asn", "grn", "pick", "picking"
1095
  )
1096
  ACTION_OR_ERROR_TERMS = (
1097
+ "how to", "procedure", "perform",
1098
  "close", "closing", "open", "navigate", "scan", "confirm", "generate", "update",
1099
+ "receive", "receiving",
1100
  "error", "issue", "fail", "failed", "not working", "locked", "mismatch",
1101
  "access", "permission", "status"
1102
  )
 
1113
  weak_domain_only = (mentions_domain and not has_any_action_or_error)
1114
  low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
1115
 
1116
+ strong_steps_bypass = looks_like_steps_query and looks_like_module
 
1117
  strong_error_signal = len(_detect_error_families(msg_low)) > 0
1118
  if (weak_domain_only or (low_context_hit and not combined_ok)) \
1119
  and not strong_steps_bypass \
 
1142
  },
1143
  }
1144
 
1145
+ # ---------- Build SOP context ----------
1146
+ def _detect_action_from_query(q: str) -> Optional[str]:
1147
+ qlow = (q or "").lower()
1148
+ for act, syns in ACTION_SYNONYMS_EXT.items():
1149
+ if any(s in qlow for s in syns):
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
1247
  next_step_info: Dict[str, Any] = {}
1248
 
1249
+ if best_doc and detected_intent == "steps":
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."
1299
+ next_step_applied = True
1300
+ next_step_info = {"count": 0}
1301
+ context_preformatted = True
1302
  else:
1303
+ context = _format_steps_as_numbered(next_only)
1304
+ next_step_applied = True
1305
+ next_step_info = {"count": len(next_only)}
1306
+ context_preformatted = True
1307
+ else:
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)
1317
+ if full_errors:
1318
+ ctx_err = _extract_errors_only(full_errors, max_lines=30)
1319
+ if is_perm_query:
1320
+ context = _filter_permission_lines(ctx_err, max_lines=6)
1321
+ else:
1322
+ is_specific_error = len(_detect_error_families(msg_low)) > 0
1323
+ if is_specific_error:
1324
+ context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=1)
1325
  else:
1326
+ all_lines: List[str] = _normalize_lines(ctx_err)
1327
+ error_bullets = [ln for ln in all_lines if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln)]
1328
+ context = "\n".join(error_bullets[:8]).strip()
1329
+ assist_followup = (
1330
+ "Please tell me which error above matches your screen (paste the exact text), "
1331
+ "or share a screenshot. I can guide you further or raise a ServiceNow ticket."
1332
+ )
1333
+ escalation_line = _extract_escalation_line(full_errors)
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)
1361
  lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
 
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:
 
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:
1432
+ short_query = len((input_data.user_message or "").split()) <= 4
1433
+ gate_combined_ok = 0.60 if short_query else 0.55
1434
+ status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
1435
+
1436
  lower = (bot_text or "").lower()
1437
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
1438
  status = "PARTIAL"
 
1453
  "best_distance": best_distance,
1454
  "best_combined": best_combined,
1455
  "http_status": http_code,
1456
+ "filter_mode": (filt_info.get("mode") if filt_info else None),
1457
+ "matched_count": (filt_info.get("matched_count") if filt_info else None),
1458
  "user_intent": detected_intent,
1459
  "best_doc": best_doc,
1460
  "next_step": {