srilakshu012456 commited on
Commit
61d7528
·
verified ·
1 Parent(s): 518f6a3

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +124 -127
main.py CHANGED
@@ -1,13 +1,5 @@
1
-
2
  #!/usr/bin/env python3
3
  # -*- coding: utf-8 -*-
4
- """
5
- main.py
6
-
7
- - For 'steps' intent: skip LLM rewriter; render full numbered steps directly.
8
- - Exact = high overlap OR high combined score. If exact → status=OK; else PARTIAL.
9
- - No 'partial' word override for steps (prevents false PARTIAL).
10
- """
11
 
12
  import os, json, re, requests, builtins
13
  from typing import Optional, Any, Dict, List, Tuple
@@ -32,8 +24,10 @@ VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true",
32
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
33
 
34
  def safe_str(e: Any) -> str:
35
- try: return builtins.str(e)
36
- except Exception: return "<error stringify failed>"
 
 
37
 
38
  load_dotenv()
39
  os.environ["POSTHOG_DISABLED"] = "true"
@@ -77,7 +71,7 @@ class TicketStatusInput(BaseModel):
77
  sys_id: Optional[str] = None
78
  number: Optional[str] = None
79
 
80
- # --- filters & extractors (same behavior as your file, but without LLM) ---
81
  STRICT_OVERLAP = 3
82
  MAX_SENTENCES_CONCISE = 6
83
 
@@ -119,29 +113,41 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
119
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
120
 
121
  STEP_LINE_REGEX = re.compile(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", re.IGNORECASE)
122
- NAV_LINE_REGEX = re.compile(r"(navigate\s+to|>\s*)", re.IGNORECASE)
123
- PROCEDURE_VERBS = ["log in","select","scan","verify","confirm","print","move","complete","click","open","navigate","choose","enter","update","save","delete","create","attach","assign"]
124
- VERB_START_REGEX = re.compile(r"^\s*(?:" + "|".join([re.escape(v) for v in PROCEDURE_VERBS]) + r")\b", re.IGNORECASE)
125
- NON_PROC_ANY_REGEX = re.compile("|".join([re.escape(v) for v in ["to ensure","as per","purpose","pre-requisites","prerequisites","overview","introduction","organized manner","structured","help users","objective"]]), re.IGNORECASE)
126
- ACTION_SYNS_FLAT = {"create":["create","creation","add","new","generate"],"update":["update","modify","change","edit"],"delete":["delete","remove"],"navigate":["navigate","go to","open"]}
 
 
 
 
 
 
 
127
 
128
  def _action_in_line(ln: str, target_actions: List[str]) -> bool:
129
  s = (ln or "").lower()
130
  for act in target_actions:
131
  for syn in ACTION_SYNS_FLAT.get(act, [act]):
132
- if syn in s: return True
 
133
  return False
134
 
135
  def _is_procedural_line(ln: str) -> bool:
136
  s = (ln or "").strip()
137
- if not s: return False
138
- if NON_PROC_ANY_REGEX.search(s): return False
 
 
139
  if STEP_LINE_REGEX.match(s):
140
  if s.lstrip().startswith(("•","-")):
141
  return bool(VERB_START_REGEX.search(s) or NAV_LINE_REGEX.search(s))
142
  return True
143
- if VERB_START_REGEX.match(s): return True
144
- if NAV_LINE_REGEX.search(s): return True
 
 
145
  return False
146
 
147
  def _extract_steps_only(text: str, max_lines: Optional[int] = None, target_actions: Optional[List[str]] = None) -> str:
@@ -150,25 +156,29 @@ def _extract_steps_only(text: str, max_lines: Optional[int] = None, target_actio
150
  for ln in lines:
151
  if _is_procedural_line(ln):
152
  normalized = re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", ln).strip().lower()
153
- if normalized in seen: continue
154
- if target_actions and not _action_in_line(ln, target_actions): continue
155
- kept.append(ln); seen.add(normalized)
156
- if max_lines is not None and len(kept) >= max_lines: break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  return "\n".join(kept).strip() if kept else (text or "").strip()
158
 
159
- def _format_steps_markdown(lines: List[str]) -> str:
160
- items, seen_norm = [], set()
161
- for i, ln in enumerate(lines, start=1):
162
- s = (ln or "").strip()
163
- if not s: continue
164
- s = re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", s).strip()
165
- norm = s.lower()
166
- if norm in seen_norm: continue
167
- seen_norm.add(norm)
168
- items.append(f"{i}. {s}")
169
- return "\n".join(items).strip()
170
-
171
- ESCALATION_REGEX = re.compile(r"^\\s*Escalation Path|^\\s*\\→\\s*|\\s+→\\s+", re.IGNORECASE)
172
 
173
  def _extract_escalation_only(text: str, max_lines: int = 3) -> str:
174
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
@@ -180,48 +190,32 @@ def _extract_escalation_only(text: str, max_lines: int = 3) -> str:
180
  break
181
  return "\n".join(kept).strip()
182
 
183
- # --- conversation intents (unchanged) ---
184
- def _is_incident_intent(msg_norm: str) -> bool:
185
- intents = ["create ticket","create a ticket","raise ticket","raise a ticket","open ticket","open a ticket","create incident","create an incident","raise incident","raise an incident","open incident","open an incident","log ticket","log an incident","generate ticket","create snow ticket","raise snow ticket","raise service now ticket","create service now ticket","raise sr","open sr"]
186
- return any(p in msg_norm for p in intents)
187
-
188
- def _is_generic_issue(msg_norm: str) -> bool:
189
- generic = ["issue","have an issue","having an issue","got an issue","problem","have a problem","help","need help","support","need support","please help","need assistance","assist me","facing issue","facing a problem","got a problem"]
190
- return any(p == msg_norm or p in msg_norm for p in generic) or len(msg_norm.split()) <= 2
191
-
192
- def _merge_number_only_lines(lines: list[str]) -> list[str]:
193
- """
194
- Merge lines that are just '1', '2', '3', ... with the next non-empty,
195
- non-number-only line so the final output becomes '1. <text>'.
196
- Also skips consecutive number-only lines safely.
197
- """
198
- merged: list[str] = []
199
- i = 0
200
- n = len(lines)
201
-
202
  def is_number_only(s: str) -> bool:
203
  return bool(re.fullmatch(r"\s*\d+\s*", s or ""))
204
-
205
  while i < n:
206
  curr = (lines[i] or "").strip()
207
- # Case A: this line is only a number (e.g., "3")
208
  if is_number_only(curr):
209
- # Find the next line that actually has text content (not just another number)
210
  j = i + 1
211
  while j < n and is_number_only(lines[j]):
212
  j += 1
213
  if j < n:
214
  merged.append(f"{curr.strip()}. {lines[j].strip()}")
215
- i = j + 1 # skip the content line we just merged
216
  else:
217
- # Dangling number at end → skip it
218
  i += 1
219
  else:
220
  merged.append(curr)
221
  i += 1
222
-
223
  return merged
224
 
 
 
 
 
225
  @app.get("/")
226
  async def health_check():
227
  return {"status": "ok"}
@@ -231,12 +225,14 @@ async def chat_with_ai(input_data: ChatInput):
231
  try:
232
  msg_norm = (input_data.user_message or "").lower().strip()
233
 
234
- # incident & generic handlers (unchanged)
235
  if _is_incident_intent(msg_norm):
236
  return {
237
- "bot_response": ("Okay, let’s create a ServiceNow incident.\n\n"
238
- "Please provide:\n• Short Description (one line)\n"
239
- "• Detailed Description (steps, error text, IDs, site, environment)"),
 
 
240
  "status": (input_data.prev_status or "PARTIAL"),
241
  "context_found": False, "ask_resolved": False, "suggest_incident": False,
242
  "show_incident_form": True, "followup": None, "top_hits": [], "sources": [],
@@ -244,12 +240,14 @@ async def chat_with_ai(input_data: ChatInput):
244
  }
245
  if _is_generic_issue(msg_norm):
246
  return {
247
- "bot_response": ("Sure, I can help. Please describe your issue:\n"
248
- " Module/area (e.g., Picking, Receiving, Trailer Close)\n"
249
- "• Exact error message text/code (copy-paste)\n"
250
- "• IDs involved (Order#, Load ID, Shipment#)\n"
251
- "• Warehouse/site & environment (prod/test)\n"
252
- "• When it started and how many users are impacted"),
 
 
253
  "status": "NO_KB_MATCH",
254
  "context_found": False, "ask_resolved": False, "suggest_incident": False,
255
  "followup": "Please reply with the above details.", "top_hits": [], "sources": [],
@@ -258,60 +256,68 @@ async def chat_with_ai(input_data: ChatInput):
258
 
259
  # Hybrid KB search
260
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
261
- # Build a small context window from top chunks
 
262
  def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2) -> Dict[str, Any]:
263
  if not kb_results or not isinstance(kb_results, dict):
264
  return {"context": "", "sources": [], "top_hits": [], "context_found": False, "best_score": None, "best_combined": None}
265
  documents = kb_results.get("documents") or []
266
  metadatas = kb_results.get("metadatas") or []
267
  distances = kb_results.get("distances") or []
268
- combined = kb_results.get("combined_scores") or []
269
- items = []
270
  for i, doc in enumerate(documents):
271
  text = doc.strip() if isinstance(doc, str) else ""
272
- if not text: continue
273
- meta = metadatas[i] if i < len(metadatas) and isinstance(metadatas[i], dict) else {}
 
274
  score = distances[i] if i < len(distances) else None
275
- comb = combined[i] if i < len(combined) else None
276
  m = dict(meta)
277
  if score is not None: m["distance"] = score
278
- if comb is not None: m["combined"] = comb
279
  items.append({"text": text, "meta": m})
280
  selected = items[:max(1, top_chunks)]
281
- context = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
282
- sources = [s["meta"] for s in selected]
283
  best_distance = None
284
  if distances:
285
- try: best_distance = min([d for d in distances if d is not None])
286
- except Exception: best_distance = None
 
 
287
  best_combined = None
288
  if combined:
289
- try: best_combined = max([c for c in combined if c is not None])
290
- except Exception: best_combined = None
291
- return {"context": context, "sources": sources, "top_hits": [], "context_found": bool(selected),
292
- "best_score": best_distance, "best_combined": best_combined}
 
 
 
 
293
 
294
- kb_ctx = extract_kb_context(kb_results, top_chunks=2)
295
- context_raw = kb_ctx.get("context", "") or ""
296
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
297
 
298
  detected_intent = kb_results.get("user_intent", "neutral")
299
- actions = kb_results.get("actions", [])
300
- best_doc = kb_results.get("best_doc")
301
- best_distance = kb_ctx.get("best_score")
302
- best_combined = kb_ctx.get("best_combined")
303
- top_meta = (kb_results.get("metadatas") or [{}])[0] if (kb_results.get("metadatas") or []) else {}
304
 
305
- short_query = len((input_data.user_message or "").split()) <= 4
306
- gate_combined_ok = 0.58 if short_query else 0.55
307
- gate_combined_no_kb = 0.22 if short_query else 0.28
308
- gate_distance_no_kb = 2.0
309
 
310
  exact_by_filter = (filt_info.get('mode') == 'exact' and filt_info.get('matched_count', 0) > 0)
311
- high_conf = (best_combined is not None and best_combined >= gate_combined_ok)
312
- exact = bool(exact_by_filter or high_conf)
313
 
314
- # --- STEPS intent: full SOP steps first (no LLM rewrite) ---
315
  if detected_intent == "steps" and best_doc:
316
  full_steps = get_best_steps_section_text(best_doc)
317
  if not full_steps:
@@ -323,26 +329,17 @@ async def chat_with_ai(input_data: ChatInput):
323
  else:
324
  context = _extract_steps_only(filtered_text, max_lines=MAX_SENTENCES_CONCISE, target_actions=actions)
325
 
326
- # Render numbered list
327
-
328
  raw_lines = [ln.strip() for ln in context.splitlines() if ln.strip()]
329
-
330
- # If everything is a single paragraph, defensively split on ". "
331
  if len(raw_lines) == 1:
332
- parts = [p.strip() for p in re.split(r"\.\s+(?=[A-Z0-9])", raw_lines[0]) if p.strip()]
333
- raw_lines = parts if len(parts) > 1 else raw_lines
334
-
335
- # ✅ NEW: merge number-only lines with their following text lines
336
  raw_lines = _merge_number_only_lines(raw_lines)
 
337
 
338
- # Render as a clean Markdown numbered list
339
- #bot_text = _format_steps_markdown(raw_lines)
340
- bot_text = "\n".join(
341
- [f"{i+1}. {re.sub(r'^\\s*(?:\\d+[\\.)]\\s+|[•\\-]\\s+)', '', ln).strip()}" for i, ln in enumerate(raw_lines)])
342
 
343
- # Status ONLY from exact/high confidence gates
344
  status = "OK" if exact else "PARTIAL"
345
-
346
  return {
347
  "bot_response": bot_text,
348
  "status": status,
@@ -360,12 +357,11 @@ async def chat_with_ai(input_data: ChatInput):
360
  },
361
  }
362
 
363
- # --- Non-steps: keep concise behavior ---
364
  context = filtered_text
365
  if detected_intent == "errors":
366
  errs = _extract_errors_only(context, max_lines=MAX_SENTENCES_CONCISE)
367
  esc = _extract_escalation_only(context, max_lines=3)
368
- # Join errors + escalation neatly (no extra LLM, no steps)
369
  context = (errs + ("\n" + esc if esc else "")).strip()
370
  elif "navigate" in msg_norm or "menu" in msg_norm or "screen" in msg_norm:
371
  context = _extract_steps_only(context, max_lines=MAX_SENTENCES_CONCISE)
@@ -375,14 +371,15 @@ async def chat_with_ai(input_data: ChatInput):
375
  (best_combined is None or best_combined < gate_combined_no_kb) and (best_distance is None or best_distance >= gate_distance_no_kb)
376
  ):
377
  second_try = (input_data.prev_status or "").upper() == "NO_KB_MATCH"
378
- clarify = ("I couldn’t find matching content in the KB yet. To help me narrow it down, please share:\n\n"
379
- " Module/area (e.g., Picking, Receiving, Trailer Close)\n"
380
- "• Exact error message text/code (copy-paste)\n"
381
- "• IDs involved (Order#, Load ID, Shipment#)\n"
382
- "• Warehouse/site & environment (prod/test)\n"
383
- "• When it started and how many users are impacted\n\n"
384
- "Reply with these details and I’ll search again.") if not second_try else \
385
- "I still don’t find a relevant KB match for this scenario even after clarification."
 
386
  return {
387
  "bot_response": clarify,
388
  "status": "NO_KB_MATCH",
@@ -395,7 +392,7 @@ async def chat_with_ai(input_data: ChatInput):
395
 
396
  # Default response for non-steps
397
  bot_text = context.strip()
398
- status = "OK" if exact else "PARTIAL"
399
  return {
400
  "bot_response": bot_text,
401
  "status": status,
 
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
 
 
 
 
 
 
 
3
 
4
  import os, json, re, requests, builtins
5
  from typing import Optional, Any, Dict, List, Tuple
 
24
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
25
 
26
  def safe_str(e: Any) -> str:
27
+ try:
28
+ return builtins.str(e)
29
+ except Exception:
30
+ return "<error stringify failed>"
31
 
32
  load_dotenv()
33
  os.environ["POSTHOG_DISABLED"] = "true"
 
71
  sys_id: Optional[str] = None
72
  number: Optional[str] = None
73
 
74
+ # --- filters & extractors (no LLM) ---
75
  STRICT_OVERLAP = 3
76
  MAX_SENTENCES_CONCISE = 6
77
 
 
113
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
114
 
115
  STEP_LINE_REGEX = re.compile(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", re.IGNORECASE)
116
+ NAV_LINE_REGEX = re.compile(r"(navigate\s+to|>\s*)", re.IGNORECASE)
117
+ PROCEDURE_VERBS = [
118
+ "log in","select","scan","verify","confirm","print",
119
+ "move","complete","click","open","navigate","choose",
120
+ "enter","update","save","delete","create","attach","assign"
121
+ ]
122
+ VERB_START_REGEX = re.compile(r"^\s*(?:" + "|".join([re.escape(v) for v in PROCEDURE_VERBS]) + r")\b", re.IGNORECASE)
123
+ NON_PROC_ANY_REGEX = re.compile("|".join([re.escape(v) for v in [
124
+ "to ensure","as per","purpose","pre-requisites","prerequisites","overview","introduction",
125
+ "organized manner","structured","help users","objective"
126
+ ]]), re.IGNORECASE)
127
+ ACTION_SYNS_FLAT = {"create":["create","creation","add","new","generate"],"update":["update","modify","change","edit"],"delete":["delete","remove"],"navigate":["navigate","go to","open"]}
128
 
129
  def _action_in_line(ln: str, target_actions: List[str]) -> bool:
130
  s = (ln or "").lower()
131
  for act in target_actions:
132
  for syn in ACTION_SYNS_FLAT.get(act, [act]):
133
+ if syn in s:
134
+ return True
135
  return False
136
 
137
  def _is_procedural_line(ln: str) -> bool:
138
  s = (ln or "").strip()
139
+ if not s:
140
+ return False
141
+ if NON_PROC_ANY_REGEX.search(s):
142
+ return False
143
  if STEP_LINE_REGEX.match(s):
144
  if s.lstrip().startswith(("•","-")):
145
  return bool(VERB_START_REGEX.search(s) or NAV_LINE_REGEX.search(s))
146
  return True
147
+ if VERB_START_REGEX.match(s):
148
+ return True
149
+ if NAV_LINE_REGEX.search(s):
150
+ return True
151
  return False
152
 
153
  def _extract_steps_only(text: str, max_lines: Optional[int] = None, target_actions: Optional[List[str]] = None) -> str:
 
156
  for ln in lines:
157
  if _is_procedural_line(ln):
158
  normalized = re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", ln).strip().lower()
159
+ if normalized in seen:
160
+ continue
161
+ if target_actions and not _action_in_line(ln, target_actions):
162
+ continue
163
+ kept.append(ln)
164
+ seen.add(normalized)
165
+ if max_lines is not None and len(kept) >= max_lines:
166
+ break
167
+ return "\n".join(kept).strip() if kept else (text or "").strip()
168
+
169
+ # ---- Errors extractor ----
170
+ def _extract_errors_only(text: str, max_lines: int = 12) -> str:
171
+ lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
172
+ kept: List[str] = []
173
+ for ln in lines:
174
+ if STEP_LINE_REGEX.match(ln) or ln.lower().startswith(("error", "resolution", "fix", "verify", "check")):
175
+ kept.append(ln)
176
+ if len(kept) >= max_lines:
177
+ break
178
  return "\n".join(kept).strip() if kept else (text or "").strip()
179
 
180
+ # ---- Escalation extractor (optional) ----
181
+ ESCALATION_REGEX = re.compile(r"^\s*Escalation Path|\s+→\s+", re.IGNORECASE)
 
 
 
 
 
 
 
 
 
 
 
182
 
183
  def _extract_escalation_only(text: str, max_lines: int = 3) -> str:
184
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
 
190
  break
191
  return "\n".join(kept).strip()
192
 
193
+ # ---- merge number-only lines helper ----
194
+ def _merge_number_only_lines(lines: List[str]) -> List[str]:
195
+ merged: List[str] = []
196
+ i, n = 0, len(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  def is_number_only(s: str) -> bool:
198
  return bool(re.fullmatch(r"\s*\d+\s*", s or ""))
 
199
  while i < n:
200
  curr = (lines[i] or "").strip()
 
201
  if is_number_only(curr):
 
202
  j = i + 1
203
  while j < n and is_number_only(lines[j]):
204
  j += 1
205
  if j < n:
206
  merged.append(f"{curr.strip()}. {lines[j].strip()}")
207
+ i = j + 1
208
  else:
 
209
  i += 1
210
  else:
211
  merged.append(curr)
212
  i += 1
 
213
  return merged
214
 
215
+ # ---- strip any existing leading numbering/bullets ----
216
+ def _strip_leading_mark(s: str) -> str:
217
+ return re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", (s or "")).strip()
218
+
219
  @app.get("/")
220
  async def health_check():
221
  return {"status": "ok"}
 
225
  try:
226
  msg_norm = (input_data.user_message or "").lower().strip()
227
 
228
+ # incident & generic handlers
229
  if _is_incident_intent(msg_norm):
230
  return {
231
+ "bot_response": (
232
+ "Okay, let’s create a ServiceNow incident.\n\n"
233
+ "Please provide:\nShort Description (one line)\n"
234
+ "• Detailed Description (steps, error text, IDs, site, environment)"
235
+ ),
236
  "status": (input_data.prev_status or "PARTIAL"),
237
  "context_found": False, "ask_resolved": False, "suggest_incident": False,
238
  "show_incident_form": True, "followup": None, "top_hits": [], "sources": [],
 
240
  }
241
  if _is_generic_issue(msg_norm):
242
  return {
243
+ "bot_response": (
244
+ "Sure, I can help. Please describe your issue:\n"
245
+ "• Module/area (e.g., Picking, Receiving, Trailer Close)\n"
246
+ "• Exact error message text/code (copy-paste)\n"
247
+ "• IDs involved (Order#, Load ID, Shipment#)\n"
248
+ "• Warehouse/site & environment (prod/test)\n"
249
+ "• When it started and how many users are impacted"
250
+ ),
251
  "status": "NO_KB_MATCH",
252
  "context_found": False, "ask_resolved": False, "suggest_incident": False,
253
  "followup": "Please reply with the above details.", "top_hits": [], "sources": [],
 
256
 
257
  # Hybrid KB search
258
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
259
+
260
+ # Build a small context window
261
  def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2) -> Dict[str, Any]:
262
  if not kb_results or not isinstance(kb_results, dict):
263
  return {"context": "", "sources": [], "top_hits": [], "context_found": False, "best_score": None, "best_combined": None}
264
  documents = kb_results.get("documents") or []
265
  metadatas = kb_results.get("metadatas") or []
266
  distances = kb_results.get("distances") or []
267
+ combined = kb_results.get("combined_scores") or []
268
+ items: List[Dict[str, Any]] = []
269
  for i, doc in enumerate(documents):
270
  text = doc.strip() if isinstance(doc, str) else ""
271
+ if not text:
272
+ continue
273
+ meta = metadatas[i] if i < len(metadatas) and isinstance(metadatas[i], dict) else {}
274
  score = distances[i] if i < len(distances) else None
275
+ comb = combined[i] if i < len(combined) else None
276
  m = dict(meta)
277
  if score is not None: m["distance"] = score
278
+ if comb is not None: m["combined"] = comb
279
  items.append({"text": text, "meta": m})
280
  selected = items[:max(1, top_chunks)]
281
+ context = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
282
+ sources = [s["meta"] for s in selected]
283
  best_distance = None
284
  if distances:
285
+ try:
286
+ best_distance = min([d for d in distances if d is not None])
287
+ except Exception:
288
+ best_distance = None
289
  best_combined = None
290
  if combined:
291
+ try:
292
+ best_combined = max([c for c in combined if c is not None])
293
+ except Exception:
294
+ best_combined = None
295
+ return {
296
+ "context": context, "sources": sources, "top_hits": [], "context_found": bool(selected),
297
+ "best_score": best_distance, "best_combined": best_combined
298
+ }
299
 
300
+ kb_ctx = extract_kb_context(kb_results, top_chunks=2)
301
+ context_raw = kb_ctx.get("context", "") or ""
302
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
303
 
304
  detected_intent = kb_results.get("user_intent", "neutral")
305
+ actions = kb_results.get("actions", [])
306
+ best_doc = kb_results.get("best_doc")
307
+ best_distance = kb_ctx.get("best_score")
308
+ best_combined = kb_ctx.get("best_combined")
309
+ top_meta = (kb_results.get("metadatas") or [{}])[0] if (kb_results.get("metadatas") or []) else {}
310
 
311
+ short_query = len((input_data.user_message or "").split()) <= 4
312
+ gate_combined_ok = 0.58 if short_query else 0.55
313
+ gate_combined_no_kb = 0.22 if short_query else 0.28
314
+ gate_distance_no_kb = 2.0
315
 
316
  exact_by_filter = (filt_info.get('mode') == 'exact' and filt_info.get('matched_count', 0) > 0)
317
+ high_conf = (best_combined is not None and best_combined >= gate_combined_ok)
318
+ exact = bool(exact_by_filter or high_conf)
319
 
320
+ # --- STEPS intent: full SOP steps (numbered) ---
321
  if detected_intent == "steps" and best_doc:
322
  full_steps = get_best_steps_section_text(best_doc)
323
  if not full_steps:
 
329
  else:
330
  context = _extract_steps_only(filtered_text, max_lines=MAX_SENTENCES_CONCISE, target_actions=actions)
331
 
 
 
332
  raw_lines = [ln.strip() for ln in context.splitlines() if ln.strip()]
 
 
333
  if len(raw_lines) == 1:
334
+ parts = [p.strip() for p in re.split(r"\.\s+(?=[A-Z0-9])", raw_lines[0]) if p.strip()]
335
+ raw_lines = parts if len(parts) > 1 else raw_lines
 
 
336
  raw_lines = _merge_number_only_lines(raw_lines)
337
+ cleaned_lines = [_strip_leading_mark(ln) for ln in raw_lines]
338
 
339
+ # Final numbering without regex inside f-string
340
+ bot_text = "\n".join([f"{i+1}. {ln}" for i, ln in enumerate(cleaned_lines)])
 
 
341
 
 
342
  status = "OK" if exact else "PARTIAL"
 
343
  return {
344
  "bot_response": bot_text,
345
  "status": status,
 
357
  },
358
  }
359
 
360
+ # --- Non-steps intents ---
361
  context = filtered_text
362
  if detected_intent == "errors":
363
  errs = _extract_errors_only(context, max_lines=MAX_SENTENCES_CONCISE)
364
  esc = _extract_escalation_only(context, max_lines=3)
 
365
  context = (errs + ("\n" + esc if esc else "")).strip()
366
  elif "navigate" in msg_norm or "menu" in msg_norm or "screen" in msg_norm:
367
  context = _extract_steps_only(context, max_lines=MAX_SENTENCES_CONCISE)
 
371
  (best_combined is None or best_combined < gate_combined_no_kb) and (best_distance is None or best_distance >= gate_distance_no_kb)
372
  ):
373
  second_try = (input_data.prev_status or "").upper() == "NO_KB_MATCH"
374
+ clarify = (
375
+ "I couldn’t find matching content in the KB yet. To help me narrow it down, please share:\n\n"
376
+ "• Module/area (e.g., Picking, Receiving, Trailer Close)\n"
377
+ "• Exact error message text/code (copy-paste)\n"
378
+ "• IDs involved (Order#, Load ID, Shipment#)\n"
379
+ "• Warehouse/site & environment (prod/test)\n"
380
+ " When it started and how many users are impacted\n\n"
381
+ "Reply with these details and I’ll search again."
382
+ ) if not second_try else "I still don’t find a relevant KB match for this scenario even after clarification."
383
  return {
384
  "bot_response": clarify,
385
  "status": "NO_KB_MATCH",
 
392
 
393
  # Default response for non-steps
394
  bot_text = context.strip()
395
+ status = "OK" if exact else "PARTIAL"
396
  return {
397
  "bot_response": bot_text,
398
  "status": status,