srilakshu012456 commited on
Commit
7463f64
·
verified ·
1 Parent(s): 3fc36d2

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +85 -363
main.py CHANGED
@@ -1,4 +1,6 @@
1
 
 
 
2
  import os
3
  import json
4
  import re
@@ -12,8 +14,6 @@ from pydantic import BaseModel
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,20 +144,6 @@ ERROR_FAMILY_SYNS = {
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,116 +157,6 @@ def _detect_error_families(msg: str) -> list:
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,7 +678,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
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,12 +899,8 @@ async def chat_with_ai(input_data: ChatInput):
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,23 +938,24 @@ async def chat_with_ai(input_data: ChatInput):
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,9 +967,9 @@ async def chat_with_ai(input_data: ChatInput):
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,7 +986,8 @@ async def chat_with_ai(input_data: ChatInput):
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,220 +1016,71 @@ async def chat_with_ai(input_data: ChatInput):
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,6 +1116,8 @@ Return ONLY the rewritten guidance."""
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,14 +1152,9 @@ Return ONLY the rewritten guidance."""
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,8 +1175,8 @@ Return ONLY the rewritten guidance."""
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": {
 
1
 
2
+ # main_hugging_phase_recent.py
3
+
4
  import os
5
  import json
6
  import re
 
14
  from dotenv import load_dotenv
15
  from datetime import datetime
16
 
 
 
17
  from services.kb_creation import (
18
  collection,
19
  ingest_documents,
 
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
  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
  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
 
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
  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
  "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
  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
  },
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
 
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
  "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
  "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": {