Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -43,12 +43,14 @@ GEMINI_URL = (
|
|
| 43 |
)
|
| 44 |
os.environ["POSTHOG_DISABLED"] = "true"
|
| 45 |
|
|
|
|
| 46 |
def safe_str(e: Any) -> str:
|
| 47 |
try:
|
| 48 |
return builtins.str(e)
|
| 49 |
except Exception:
|
| 50 |
return "<error stringify failed>"
|
| 51 |
|
|
|
|
| 52 |
# ---------------------------------------------------------------------
|
| 53 |
# App / Lifespan
|
| 54 |
# ---------------------------------------------------------------------
|
|
@@ -65,6 +67,7 @@ async def lifespan(app: FastAPI):
|
|
| 65 |
print(f"[KB] ingestion failed: {safe_str(e)}")
|
| 66 |
yield
|
| 67 |
|
|
|
|
| 68 |
app = FastAPI(lifespan=lifespan)
|
| 69 |
app.include_router(login_router)
|
| 70 |
|
|
@@ -89,18 +92,22 @@ class ChatInput(BaseModel):
|
|
| 89 |
prev_status: Optional[str] = None
|
| 90 |
last_issue: Optional[str] = None
|
| 91 |
|
|
|
|
| 92 |
class IncidentInput(BaseModel):
|
| 93 |
short_description: str
|
| 94 |
description: str
|
| 95 |
mark_resolved: Optional[bool] = False
|
| 96 |
|
|
|
|
| 97 |
class TicketDescInput(BaseModel):
|
| 98 |
issue: str
|
| 99 |
|
|
|
|
| 100 |
class TicketStatusInput(BaseModel):
|
| 101 |
sys_id: Optional[str] = None
|
| 102 |
number: Optional[str] = None
|
| 103 |
|
|
|
|
| 104 |
STATE_MAP = {
|
| 105 |
"1": "New",
|
| 106 |
"2": "In Progress",
|
|
@@ -118,37 +125,38 @@ DOMAIN_STATUS_TERMS = (
|
|
| 118 |
"shipment", "order", "load", "trailer", "wave",
|
| 119 |
"inventory", "putaway", "receiving", "appointment",
|
| 120 |
"dock", "door", "manifest", "pallet", "container",
|
| 121 |
-
"asn", "grn", "pick", "picking"
|
| 122 |
)
|
| 123 |
ERROR_FAMILY_SYNS = {
|
| 124 |
"NOT_FOUND": (
|
| 125 |
"not found", "missing", "does not exist", "doesn't exist",
|
| 126 |
"unavailable", "not available", "cannot find", "no such",
|
| 127 |
-
"not present", "absent"
|
| 128 |
),
|
| 129 |
"MISMATCH": (
|
| 130 |
"mismatch", "doesn't match", "does not match", "variance",
|
| 131 |
-
"difference", "discrepancy", "not equal"
|
| 132 |
),
|
| 133 |
"LOCKED": (
|
| 134 |
"locked", "status locked", "blocked", "read only", "read-only",
|
| 135 |
-
"frozen", "freeze"
|
| 136 |
),
|
| 137 |
"PERMISSION": (
|
| 138 |
"permission", "permissions", "access denied", "not authorized",
|
| 139 |
"not authorised", "insufficient privileges", "no access",
|
| 140 |
-
"authorization", "authorisation"
|
| 141 |
),
|
| 142 |
"TIMEOUT": (
|
| 143 |
"timeout", "timed out", "network", "connection",
|
| 144 |
-
"unable to connect", "disconnected", "no network"
|
| 145 |
),
|
| 146 |
"SYNC": (
|
| 147 |
"sync", "synchronization", "synchronisation", "replication",
|
| 148 |
-
"refresh", "out of sync", "stale", "delay", "lag"
|
| 149 |
),
|
| 150 |
}
|
| 151 |
|
|
|
|
| 152 |
def _detect_error_families(msg: str) -> list:
|
| 153 |
low = (msg or "").lower()
|
| 154 |
low_norm = re.sub(r"[^\w\s]", " ", low)
|
|
@@ -159,11 +167,13 @@ def _detect_error_families(msg: str) -> list:
|
|
| 159 |
fams.append(fam)
|
| 160 |
return fams
|
| 161 |
|
|
|
|
| 162 |
def _is_domain_status_context(msg_norm: str) -> bool:
|
| 163 |
if "status locked" in msg_norm or "locked status" in msg_norm:
|
| 164 |
return True
|
| 165 |
return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
|
| 166 |
|
|
|
|
| 167 |
def _normalize_lines(text: str) -> List[str]:
|
| 168 |
raw = (text or "")
|
| 169 |
try:
|
|
@@ -171,6 +181,7 @@ def _normalize_lines(text: str) -> List[str]:
|
|
| 171 |
except Exception:
|
| 172 |
return [raw.strip()] if raw.strip() else []
|
| 173 |
|
|
|
|
| 174 |
# ---------------- Action filters for steps (create/update/delete) ----------------
|
| 175 |
def _filter_numbered_steps_by_actions(numbered_text: str, wanted: set[str], exclude: set[str]) -> str:
|
| 176 |
ACTION_SYNONYMS = {
|
|
@@ -179,6 +190,7 @@ def _filter_numbered_steps_by_actions(numbered_text: str, wanted: set[str], excl
|
|
| 179 |
"delete": ("delete", "remove"),
|
| 180 |
"navigate": ("navigate", "go to", "open"),
|
| 181 |
}
|
|
|
|
| 182 |
def _has_any(line: str, keys: set[str]) -> bool:
|
| 183 |
low = (line or "").lower()
|
| 184 |
for k in keys:
|
|
@@ -198,6 +210,7 @@ def _filter_numbered_steps_by_actions(numbered_text: str, wanted: set[str], excl
|
|
| 198 |
out_lines.append(ln)
|
| 199 |
return "\n".join(out_lines).strip() or (numbered_text or "").strip()
|
| 200 |
|
|
|
|
| 201 |
# ---------------- Small utilities used by next-step & filtering ----------------
|
| 202 |
def _dedupe_lines(text: str) -> str:
|
| 203 |
seen, out = set(), []
|
|
@@ -208,10 +221,12 @@ def _dedupe_lines(text: str) -> str:
|
|
| 208 |
seen.add(key)
|
| 209 |
return "\n".join(out).strip()
|
| 210 |
|
|
|
|
| 211 |
def _split_sentences(block: str) -> list:
|
| 212 |
parts = [t.strip() for t in re.split(r"(?<=[.!?])\s+", block or "") if t.strip()]
|
| 213 |
return parts if parts else ([block.strip()] if (block or "").strip() else [])
|
| 214 |
|
|
|
|
| 215 |
# ------------- Numbering + text normalization used elsewhere ----------
|
| 216 |
def _ensure_numbering(text: str) -> str:
|
| 217 |
text = re.sub(r"[\u2060\u200B]", "", text or "")
|
|
@@ -221,9 +236,10 @@ def _ensure_numbering(text: str) -> str:
|
|
| 221 |
para = " ".join(lines).strip()
|
| 222 |
if not para:
|
| 223 |
return ""
|
| 224 |
-
|
| 225 |
-
para_clean = re.sub(r"(?:
|
| 226 |
-
para_clean = re.sub(r"(?
|
|
|
|
| 227 |
segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
|
| 228 |
if len(segments) < 2:
|
| 229 |
tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
|
|
@@ -232,18 +248,21 @@ def _ensure_numbering(text: str) -> str:
|
|
| 232 |
def strip_prefix_any(s: str) -> str:
|
| 233 |
return re.sub(
|
| 234 |
r"^\s*(?:"
|
| 235 |
-
r"(?:\d+\s*[.\)])|"
|
| 236 |
-
r"(?i:step\s*\d+:?)|"
|
| 237 |
-
r"(?:[-*\u2022])|"
|
| 238 |
-
r"(?:[\u2460-\u2473])"
|
| 239 |
-
r")\s*",
|
|
|
|
|
|
|
| 240 |
)
|
|
|
|
| 241 |
clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
|
| 242 |
circled = {
|
| 243 |
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 244 |
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
| 245 |
11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
|
| 246 |
-
16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
|
| 247 |
}
|
| 248 |
out = []
|
| 249 |
for idx, seg in enumerate(clean_segments, start=1):
|
|
@@ -251,6 +270,7 @@ def _ensure_numbering(text: str) -> str:
|
|
| 251 |
out.append(f"{marker} {seg}")
|
| 252 |
return "\n".join(out)
|
| 253 |
|
|
|
|
| 254 |
def _norm_text(s: str) -> str:
|
| 255 |
s = (s or "").lower()
|
| 256 |
s = re.sub(r"[^\w\s]", " ", s)
|
|
@@ -269,32 +289,31 @@ def _norm_text(s: str) -> str:
|
|
| 269 |
stemmed.append(t)
|
| 270 |
return " ".join(stemmed).strip()
|
| 271 |
|
|
|
|
| 272 |
def _split_sop_into_steps(numbered_text: str) -> list:
|
| 273 |
lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
|
| 274 |
steps = []
|
| 275 |
for ln in lines:
|
| 276 |
-
cleaned = re.sub(
|
| 277 |
-
r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*",
|
| 278 |
-
"",
|
| 279 |
-
ln
|
| 280 |
-
)
|
| 281 |
if cleaned:
|
| 282 |
steps.append(cleaned)
|
| 283 |
return steps
|
| 284 |
|
|
|
|
| 285 |
def _format_steps_as_numbered(steps: list) -> str:
|
| 286 |
"""Render a list of steps with circled numbers for visual continuity."""
|
| 287 |
circled = {
|
| 288 |
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 289 |
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
| 290 |
11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
|
| 291 |
-
16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
|
| 292 |
}
|
| 293 |
out = []
|
| 294 |
for i, s in enumerate(steps, start=1):
|
| 295 |
out.append(f"{circled.get(i, str(i))} {s}")
|
| 296 |
return "\n".join(out)
|
| 297 |
|
|
|
|
| 298 |
# ---------------- Similarity for anchor-based next steps ----------------
|
| 299 |
def _similarity(a: str, b: str) -> float:
|
| 300 |
a_norm, b_norm = _norm_text(a), _norm_text(b)
|
|
@@ -302,35 +321,50 @@ def _similarity(a: str, b: str) -> float:
|
|
| 302 |
inter = len(ta & tb)
|
| 303 |
union = len(ta | tb) or 1
|
| 304 |
jacc = inter / union
|
|
|
|
| 305 |
def _bigrams(tokens: list) -> set:
|
| 306 |
-
return set([" ".join(tokens[i:i+2]) for i in range(len(tokens)-1)]) if len(tokens) > 1 else set()
|
|
|
|
| 307 |
ab, bb = _bigrams(a_norm.split()), _bigrams(b_norm.split())
|
| 308 |
big_inter = len(ab & bb)
|
| 309 |
big_union = len(ab | bb) or 1
|
| 310 |
big = big_inter / big_union
|
| 311 |
char = SequenceMatcher(None, a_norm, b_norm).ratio()
|
| 312 |
-
return min(1.0, 0.45*jacc + 0.30*big + 0.35*char)
|
|
|
|
| 313 |
|
| 314 |
def _extract_anchor_from_query(msg: str) -> dict:
|
|
|
|
|
|
|
|
|
|
| 315 |
raw = (msg or "").strip()
|
| 316 |
low = _norm_text(raw)
|
| 317 |
FOLLOWUP_CUES = ("what next", "what is next", "what to do", "then", "after that", "next")
|
| 318 |
has_followup = any(cue in low for cue in FOLLOWUP_CUES)
|
|
|
|
| 319 |
parts = [p.strip() for p in re.split(r"[?.,;:\-\n]+", raw) if p.strip()]
|
| 320 |
if not parts:
|
| 321 |
return {"anchor": raw, "has_followup": has_followup}
|
|
|
|
| 322 |
last = parts[-1]
|
| 323 |
last_low = _norm_text(last)
|
| 324 |
if any(cue in last_low for cue in FOLLOWUP_CUES) and len(parts) >= 2:
|
| 325 |
anchor = parts[-2]
|
| 326 |
else:
|
| 327 |
anchor = parts[-1] if len(parts) > 1 else parts[0]
|
|
|
|
| 328 |
return {"anchor": anchor.strip(), "has_followup": has_followup}
|
| 329 |
|
|
|
|
| 330 |
def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8) -> list | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
steps = _split_sop_into_steps(numbered_text)
|
| 332 |
if not steps:
|
| 333 |
return None
|
|
|
|
| 334 |
info = _extract_anchor_from_query(user_message)
|
| 335 |
anchor = info.get("anchor", "").strip()
|
| 336 |
if not anchor:
|
|
@@ -360,8 +394,8 @@ def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8)
|
|
| 360 |
accept = True
|
| 361 |
else:
|
| 362 |
base_ok = best_score >= (0.55 if not has_followup else 0.50)
|
| 363 |
-
len_ok
|
| 364 |
-
accept
|
| 365 |
if not accept:
|
| 366 |
return None
|
| 367 |
|
|
@@ -372,6 +406,7 @@ def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8)
|
|
| 372 |
next_steps = steps[start:end]
|
| 373 |
return [ln for ln in _dedupe_lines("\n".join(next_steps)).splitlines() if ln.strip()]
|
| 374 |
|
|
|
|
| 375 |
# ---------------- Context filtering (neutral/errors rendering) ----------------
|
| 376 |
def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
|
| 377 |
STRICT_OVERLAP = 3
|
|
@@ -410,18 +445,19 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
|
|
| 410 |
if matched_exact:
|
| 411 |
kept = matched_exact[:MAX_SENTENCES_STRICT]
|
| 412 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 413 |
-
'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)
|
| 414 |
}
|
| 415 |
if matched_any:
|
| 416 |
kept = matched_any[:MAX_SENTENCES_CONCISE]
|
| 417 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 418 |
-
'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)
|
| 419 |
}
|
| 420 |
kept = sentences[:MAX_SENTENCES_CONCISE]
|
| 421 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 422 |
-
'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)
|
| 423 |
}
|
| 424 |
|
|
|
|
| 425 |
def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
| 426 |
kept: List[str] = []
|
| 427 |
for ln in _normalize_lines(text):
|
|
@@ -431,10 +467,11 @@ def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
|
| 431 |
break
|
| 432 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 433 |
|
|
|
|
| 434 |
def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
|
| 435 |
PERM_SYNONYMS = (
|
| 436 |
"permission", "permissions", "access", "authorization", "authorisation",
|
| 437 |
-
"role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
|
| 438 |
)
|
| 439 |
kept: List[str] = []
|
| 440 |
for ln in _normalize_lines(text):
|
|
@@ -445,6 +482,7 @@ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
|
|
| 445 |
break
|
| 446 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 447 |
|
|
|
|
| 448 |
def _extract_escalation_line(text: str) -> Optional[str]:
|
| 449 |
if not text:
|
| 450 |
return None
|
|
@@ -483,6 +521,7 @@ def _extract_escalation_line(text: str) -> Optional[str]:
|
|
| 483 |
path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
|
| 484 |
return f"If you want to escalate the issue, follow: {path}"
|
| 485 |
|
|
|
|
| 486 |
def _detect_language_hint(msg: str) -> Optional[str]:
|
| 487 |
if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
|
| 488 |
return "Tamil"
|
|
@@ -490,10 +529,12 @@ def _detect_language_hint(msg: str) -> Optional[str]:
|
|
| 490 |
return "Hindi"
|
| 491 |
return None
|
| 492 |
|
|
|
|
| 493 |
def _build_clarifying_message() -> str:
|
| 494 |
return ("It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
|
| 495 |
"or should I raise a ServiceNow ticket for you?")
|
| 496 |
|
|
|
|
| 497 |
def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
|
| 498 |
issue = (issue_text or "").strip()
|
| 499 |
resolved = (resolved_text or "").strip()
|
|
@@ -505,6 +546,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
|
|
| 505 |
).strip()
|
| 506 |
return short_desc, long_desc
|
| 507 |
|
|
|
|
| 508 |
def _is_incident_intent(msg_norm: str) -> bool:
|
| 509 |
intent_phrases = [
|
| 510 |
"create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
|
|
@@ -514,6 +556,7 @@ def _is_incident_intent(msg_norm: str) -> bool:
|
|
| 514 |
]
|
| 515 |
return any(p in msg_norm for p in intent_phrases)
|
| 516 |
|
|
|
|
| 517 |
def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
| 518 |
status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
|
| 519 |
base_has_status = any(k in msg_norm for k in status_keywords)
|
|
@@ -525,7 +568,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
|
| 525 |
return {}
|
| 526 |
patterns = [
|
| 527 |
r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
|
| 528 |
-
r"(inc\d+)"
|
| 529 |
]
|
| 530 |
for pat in patterns:
|
| 531 |
m = re.search(pat, msg_norm, flags=re.IGNORECASE)
|
|
@@ -535,6 +578,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
|
| 535 |
return {"number": val.upper() if val.lower().startswith("inc") else val}
|
| 536 |
return {"number": None, "ask_number": True}
|
| 537 |
|
|
|
|
| 538 |
def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
|
| 539 |
phrases = [
|
| 540 |
"it is resolved", "resolved", "issue resolved", "problem resolved",
|
|
@@ -543,6 +587,7 @@ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
|
|
| 543 |
]
|
| 544 |
return any(p in msg_norm for p in phrases)
|
| 545 |
|
|
|
|
| 546 |
def _has_negation_resolved(msg_norm: str) -> bool:
|
| 547 |
neg_phrases = [
|
| 548 |
"not resolved", "issue not resolved", "still not working", "not working",
|
|
@@ -550,6 +595,7 @@ def _has_negation_resolved(msg_norm: str) -> bool:
|
|
| 550 |
]
|
| 551 |
return any(p in msg_norm for p in neg_phrases)
|
| 552 |
|
|
|
|
| 553 |
def _find_prereq_section_text(best_doc: str) -> str:
|
| 554 |
variants = [
|
| 555 |
"Pre-Requisites", "Prerequisites", "Pre Requisites", "Pre-Requirements", "Requirements",
|
|
@@ -560,6 +606,7 @@ def _find_prereq_section_text(best_doc: str) -> str:
|
|
| 560 |
return txt.strip()
|
| 561 |
return ""
|
| 562 |
|
|
|
|
| 563 |
# ---------------------------------------------------------------------
|
| 564 |
# Health
|
| 565 |
# ---------------------------------------------------------------------
|
|
@@ -567,6 +614,7 @@ def _find_prereq_section_text(best_doc: str) -> str:
|
|
| 567 |
async def health_check():
|
| 568 |
return {"status": "ok"}
|
| 569 |
|
|
|
|
| 570 |
# ---------------------------------------------------------------------
|
| 571 |
# Chat
|
| 572 |
# ---------------------------------------------------------------------
|
|
@@ -758,8 +806,10 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 758 |
generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
|
| 759 |
|
| 760 |
# intent nudge for prereqs
|
| 761 |
-
PREREQ_TERMS = (
|
| 762 |
-
|
|
|
|
|
|
|
| 763 |
if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
|
| 764 |
detected_intent = "prereqs"
|
| 765 |
|
|
@@ -788,14 +838,12 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 788 |
"trailer", "shipment", "order", "load", "wave",
|
| 789 |
"inventory", "putaway", "receiving", "appointment",
|
| 790 |
"dock", "door", "manifest", "pallet", "container",
|
| 791 |
-
"asn", "grn", "pick", "picking"
|
| 792 |
)
|
| 793 |
ACTION_OR_ERROR_TERMS = (
|
| 794 |
-
"how to", "procedure", "perform",
|
| 795 |
-
"
|
| 796 |
-
"
|
| 797 |
-
"error", "issue", "fail", "failed", "not working", "locked", "mismatch",
|
| 798 |
-
"access", "permission", "status"
|
| 799 |
)
|
| 800 |
matched_count = int(filt_info.get("matched_count") or 0)
|
| 801 |
filter_mode = (filt_info.get("mode") or "").lower()
|
|
@@ -810,8 +858,8 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 810 |
strong_error_signal = len(_detect_error_families(msg_low)) > 0
|
| 811 |
|
| 812 |
if (weak_domain_only or (low_context_hit and not combined_ok)) \
|
| 813 |
-
|
| 814 |
-
|
| 815 |
return {
|
| 816 |
"bot_response": _build_clarifying_message(),
|
| 817 |
"status": "NO_KB_MATCH",
|
|
@@ -853,36 +901,46 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 853 |
if full_steps:
|
| 854 |
numbered_full = _ensure_numbering(full_steps)
|
| 855 |
|
| 856 |
-
# action filtering (
|
| 857 |
raw_actions = set((kb_results.get("actions") or []))
|
| 858 |
msg_low2 = (input_data.user_message or "").lower()
|
|
|
|
|
|
|
| 859 |
if not raw_actions and ("creation" in msg_low2 or "create" in msg_low2 or "set up" in msg_low2 or "setup" in msg_low2):
|
| 860 |
-
|
| 861 |
elif not raw_actions and ("update" in msg_low2 or "modify" in msg_low2 or "edit" in msg_low2 or "change" in msg_low2):
|
| 862 |
-
|
| 863 |
elif not raw_actions and ("delete" in msg_low2 or "remove" in msg_low2 or "cancel" in msg_low2 or "void" in msg_low2):
|
| 864 |
-
|
|
|
|
| 865 |
sec_title_low = ((top_meta or {}).get("section") or "").strip().lower()
|
| 866 |
section_is_create = any(k in sec_title_low for k in ("create", "creation"))
|
| 867 |
section_is_update = any(k in sec_title_low for k in ("update", "updation"))
|
| 868 |
section_is_delete = any(k in sec_title_low for k in ("delete", "removal", "cancellation"))
|
|
|
|
| 869 |
skip_action_filter = (
|
| 870 |
("create" in raw_actions and section_is_create) or
|
| 871 |
("update" in raw_actions and section_is_update) or
|
| 872 |
("delete" in raw_actions and section_is_delete)
|
| 873 |
-
)
|
|
|
|
| 874 |
wanted, exclude = set(), set()
|
| 875 |
if not skip_action_filter:
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
|
|
|
| 882 |
if (wanted or exclude) and not skip_action_filter:
|
| 883 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
|
| 885 |
-
|
| 886 |
next_only = _anchor_next_steps(input_data.user_message, numbered_full, max_next=6)
|
| 887 |
|
| 888 |
if next_only is not None:
|
|
@@ -933,9 +991,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 933 |
if full_prereqs:
|
| 934 |
context = full_prereqs.strip()
|
| 935 |
context_found = True
|
| 936 |
-
else:
|
| 937 |
-
# neutral or other intents: keep filtered context (already set as 'context')
|
| 938 |
-
pass
|
| 939 |
|
| 940 |
# language hint & paraphrase (errors only)
|
| 941 |
language_hint = _detect_language_hint(input_data.user_message)
|
|
@@ -997,7 +1052,7 @@ Return ONLY the rewritten guidance."""
|
|
| 997 |
|
| 998 |
# non-empty guarantee
|
| 999 |
if not (bot_text or "").strip():
|
| 1000 |
-
if context.strip():
|
| 1001 |
bot_text = context.strip()
|
| 1002 |
else:
|
| 1003 |
bot_text = (
|
|
@@ -1042,6 +1097,7 @@ Return ONLY the rewritten guidance."""
|
|
| 1042 |
except Exception as e:
|
| 1043 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1044 |
|
|
|
|
| 1045 |
# ---------------------------------------------------------------------
|
| 1046 |
# Ticket description generation
|
| 1047 |
# ---------------------------------------------------------------------
|
|
@@ -1082,6 +1138,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
|
| 1082 |
except Exception as e:
|
| 1083 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1084 |
|
|
|
|
| 1085 |
# ---------------------------------------------------------------------
|
| 1086 |
# Incident status
|
| 1087 |
# ---------------------------------------------------------------------
|
|
@@ -1124,6 +1181,7 @@ async def incident_status(input_data: TicketStatusInput):
|
|
| 1124 |
except Exception as e:
|
| 1125 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1126 |
|
|
|
|
| 1127 |
# ---------------------------------------------------------------------
|
| 1128 |
# Incident creation
|
| 1129 |
# ---------------------------------------------------------------------
|
|
@@ -1148,6 +1206,7 @@ Message: {user_message}"""
|
|
| 1148 |
except Exception:
|
| 1149 |
return False
|
| 1150 |
|
|
|
|
| 1151 |
def _set_incident_resolved(sys_id: str) -> bool:
|
| 1152 |
try:
|
| 1153 |
token = get_valid_token()
|
|
@@ -1228,6 +1287,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 1228 |
print(f"[SN PATCH resolve] exception={safe_str(e)}")
|
| 1229 |
return False
|
| 1230 |
|
|
|
|
| 1231 |
@app.post("/incident")
|
| 1232 |
async def raise_incident(input_data: IncidentInput):
|
| 1233 |
try:
|
|
|
|
| 43 |
)
|
| 44 |
os.environ["POSTHOG_DISABLED"] = "true"
|
| 45 |
|
| 46 |
+
|
| 47 |
def safe_str(e: Any) -> str:
|
| 48 |
try:
|
| 49 |
return builtins.str(e)
|
| 50 |
except Exception:
|
| 51 |
return "<error stringify failed>"
|
| 52 |
|
| 53 |
+
|
| 54 |
# ---------------------------------------------------------------------
|
| 55 |
# App / Lifespan
|
| 56 |
# ---------------------------------------------------------------------
|
|
|
|
| 67 |
print(f"[KB] ingestion failed: {safe_str(e)}")
|
| 68 |
yield
|
| 69 |
|
| 70 |
+
|
| 71 |
app = FastAPI(lifespan=lifespan)
|
| 72 |
app.include_router(login_router)
|
| 73 |
|
|
|
|
| 92 |
prev_status: Optional[str] = None
|
| 93 |
last_issue: Optional[str] = None
|
| 94 |
|
| 95 |
+
|
| 96 |
class IncidentInput(BaseModel):
|
| 97 |
short_description: str
|
| 98 |
description: str
|
| 99 |
mark_resolved: Optional[bool] = False
|
| 100 |
|
| 101 |
+
|
| 102 |
class TicketDescInput(BaseModel):
|
| 103 |
issue: str
|
| 104 |
|
| 105 |
+
|
| 106 |
class TicketStatusInput(BaseModel):
|
| 107 |
sys_id: Optional[str] = None
|
| 108 |
number: Optional[str] = None
|
| 109 |
|
| 110 |
+
|
| 111 |
STATE_MAP = {
|
| 112 |
"1": "New",
|
| 113 |
"2": "In Progress",
|
|
|
|
| 125 |
"shipment", "order", "load", "trailer", "wave",
|
| 126 |
"inventory", "putaway", "receiving", "appointment",
|
| 127 |
"dock", "door", "manifest", "pallet", "container",
|
| 128 |
+
"asn", "grn", "pick", "picking",
|
| 129 |
)
|
| 130 |
ERROR_FAMILY_SYNS = {
|
| 131 |
"NOT_FOUND": (
|
| 132 |
"not found", "missing", "does not exist", "doesn't exist",
|
| 133 |
"unavailable", "not available", "cannot find", "no such",
|
| 134 |
+
"not present", "absent",
|
| 135 |
),
|
| 136 |
"MISMATCH": (
|
| 137 |
"mismatch", "doesn't match", "does not match", "variance",
|
| 138 |
+
"difference", "discrepancy", "not equal",
|
| 139 |
),
|
| 140 |
"LOCKED": (
|
| 141 |
"locked", "status locked", "blocked", "read only", "read-only",
|
| 142 |
+
"frozen", "freeze",
|
| 143 |
),
|
| 144 |
"PERMISSION": (
|
| 145 |
"permission", "permissions", "access denied", "not authorized",
|
| 146 |
"not authorised", "insufficient privileges", "no access",
|
| 147 |
+
"authorization", "authorisation",
|
| 148 |
),
|
| 149 |
"TIMEOUT": (
|
| 150 |
"timeout", "timed out", "network", "connection",
|
| 151 |
+
"unable to connect", "disconnected", "no network",
|
| 152 |
),
|
| 153 |
"SYNC": (
|
| 154 |
"sync", "synchronization", "synchronisation", "replication",
|
| 155 |
+
"refresh", "out of sync", "stale", "delay", "lag",
|
| 156 |
),
|
| 157 |
}
|
| 158 |
|
| 159 |
+
|
| 160 |
def _detect_error_families(msg: str) -> list:
|
| 161 |
low = (msg or "").lower()
|
| 162 |
low_norm = re.sub(r"[^\w\s]", " ", low)
|
|
|
|
| 167 |
fams.append(fam)
|
| 168 |
return fams
|
| 169 |
|
| 170 |
+
|
| 171 |
def _is_domain_status_context(msg_norm: str) -> bool:
|
| 172 |
if "status locked" in msg_norm or "locked status" in msg_norm:
|
| 173 |
return True
|
| 174 |
return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
|
| 175 |
|
| 176 |
+
|
| 177 |
def _normalize_lines(text: str) -> List[str]:
|
| 178 |
raw = (text or "")
|
| 179 |
try:
|
|
|
|
| 181 |
except Exception:
|
| 182 |
return [raw.strip()] if raw.strip() else []
|
| 183 |
|
| 184 |
+
|
| 185 |
# ---------------- Action filters for steps (create/update/delete) ----------------
|
| 186 |
def _filter_numbered_steps_by_actions(numbered_text: str, wanted: set[str], exclude: set[str]) -> str:
|
| 187 |
ACTION_SYNONYMS = {
|
|
|
|
| 190 |
"delete": ("delete", "remove"),
|
| 191 |
"navigate": ("navigate", "go to", "open"),
|
| 192 |
}
|
| 193 |
+
|
| 194 |
def _has_any(line: str, keys: set[str]) -> bool:
|
| 195 |
low = (line or "").lower()
|
| 196 |
for k in keys:
|
|
|
|
| 210 |
out_lines.append(ln)
|
| 211 |
return "\n".join(out_lines).strip() or (numbered_text or "").strip()
|
| 212 |
|
| 213 |
+
|
| 214 |
# ---------------- Small utilities used by next-step & filtering ----------------
|
| 215 |
def _dedupe_lines(text: str) -> str:
|
| 216 |
seen, out = set(), []
|
|
|
|
| 221 |
seen.add(key)
|
| 222 |
return "\n".join(out).strip()
|
| 223 |
|
| 224 |
+
|
| 225 |
def _split_sentences(block: str) -> list:
|
| 226 |
parts = [t.strip() for t in re.split(r"(?<=[.!?])\s+", block or "") if t.strip()]
|
| 227 |
return parts if parts else ([block.strip()] if (block or "").strip() else [])
|
| 228 |
|
| 229 |
+
|
| 230 |
# ------------- Numbering + text normalization used elsewhere ----------
|
| 231 |
def _ensure_numbering(text: str) -> str:
|
| 232 |
text = re.sub(r"[\u2060\u200B]", "", text or "")
|
|
|
|
| 236 |
para = " ".join(lines).strip()
|
| 237 |
if not para:
|
| 238 |
return ""
|
| 239 |
+
# Hard breaks at step boundaries
|
| 240 |
+
para_clean = re.sub(r"(?:\b\d+\s*[.\)])\s+", "\n\n\n", para) # 1. / 1)
|
| 241 |
+
para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
|
| 242 |
+
para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean) # Step 1:
|
| 243 |
segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
|
| 244 |
if len(segments) < 2:
|
| 245 |
tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
|
|
|
|
| 248 |
def strip_prefix_any(s: str) -> str:
|
| 249 |
return re.sub(
|
| 250 |
r"^\s*(?:"
|
| 251 |
+
r"(?:\d+\s*[.\)])|"
|
| 252 |
+
r"(?i:step\s*\d+:?)|"
|
| 253 |
+
r"(?:[-*\u2022])|"
|
| 254 |
+
r"(?:[\u2460-\u2473])"
|
| 255 |
+
r")\s*",
|
| 256 |
+
"",
|
| 257 |
+
(s or "").strip(),
|
| 258 |
)
|
| 259 |
+
|
| 260 |
clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
|
| 261 |
circled = {
|
| 262 |
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 263 |
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
| 264 |
11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
|
| 265 |
+
16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473",
|
| 266 |
}
|
| 267 |
out = []
|
| 268 |
for idx, seg in enumerate(clean_segments, start=1):
|
|
|
|
| 270 |
out.append(f"{marker} {seg}")
|
| 271 |
return "\n".join(out)
|
| 272 |
|
| 273 |
+
|
| 274 |
def _norm_text(s: str) -> str:
|
| 275 |
s = (s or "").lower()
|
| 276 |
s = re.sub(r"[^\w\s]", " ", s)
|
|
|
|
| 289 |
stemmed.append(t)
|
| 290 |
return " ".join(stemmed).strip()
|
| 291 |
|
| 292 |
+
|
| 293 |
def _split_sop_into_steps(numbered_text: str) -> list:
|
| 294 |
lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
|
| 295 |
steps = []
|
| 296 |
for ln in lines:
|
| 297 |
+
cleaned = re.sub(r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*", "", ln)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
if cleaned:
|
| 299 |
steps.append(cleaned)
|
| 300 |
return steps
|
| 301 |
|
| 302 |
+
|
| 303 |
def _format_steps_as_numbered(steps: list) -> str:
|
| 304 |
"""Render a list of steps with circled numbers for visual continuity."""
|
| 305 |
circled = {
|
| 306 |
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 307 |
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
| 308 |
11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
|
| 309 |
+
16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473",
|
| 310 |
}
|
| 311 |
out = []
|
| 312 |
for i, s in enumerate(steps, start=1):
|
| 313 |
out.append(f"{circled.get(i, str(i))} {s}")
|
| 314 |
return "\n".join(out)
|
| 315 |
|
| 316 |
+
|
| 317 |
# ---------------- Similarity for anchor-based next steps ----------------
|
| 318 |
def _similarity(a: str, b: str) -> float:
|
| 319 |
a_norm, b_norm = _norm_text(a), _norm_text(b)
|
|
|
|
| 321 |
inter = len(ta & tb)
|
| 322 |
union = len(ta | tb) or 1
|
| 323 |
jacc = inter / union
|
| 324 |
+
|
| 325 |
def _bigrams(tokens: list) -> set:
|
| 326 |
+
return set([" ".join(tokens[i:i + 2]) for i in range(len(tokens) - 1)]) if len(tokens) > 1 else set()
|
| 327 |
+
|
| 328 |
ab, bb = _bigrams(a_norm.split()), _bigrams(b_norm.split())
|
| 329 |
big_inter = len(ab & bb)
|
| 330 |
big_union = len(ab | bb) or 1
|
| 331 |
big = big_inter / big_union
|
| 332 |
char = SequenceMatcher(None, a_norm, b_norm).ratio()
|
| 333 |
+
return min(1.0, 0.45 * jacc + 0.30 * big + 0.35 * char)
|
| 334 |
+
|
| 335 |
|
| 336 |
def _extract_anchor_from_query(msg: str) -> dict:
|
| 337 |
+
"""
|
| 338 |
+
Pull the anchor clause out of the user's sentence and note if a follow-up cue exists.
|
| 339 |
+
"""
|
| 340 |
raw = (msg or "").strip()
|
| 341 |
low = _norm_text(raw)
|
| 342 |
FOLLOWUP_CUES = ("what next", "what is next", "what to do", "then", "after that", "next")
|
| 343 |
has_followup = any(cue in low for cue in FOLLOWUP_CUES)
|
| 344 |
+
|
| 345 |
parts = [p.strip() for p in re.split(r"[?.,;:\-\n]+", raw) if p.strip()]
|
| 346 |
if not parts:
|
| 347 |
return {"anchor": raw, "has_followup": has_followup}
|
| 348 |
+
|
| 349 |
last = parts[-1]
|
| 350 |
last_low = _norm_text(last)
|
| 351 |
if any(cue in last_low for cue in FOLLOWUP_CUES) and len(parts) >= 2:
|
| 352 |
anchor = parts[-2]
|
| 353 |
else:
|
| 354 |
anchor = parts[-1] if len(parts) > 1 else parts[0]
|
| 355 |
+
|
| 356 |
return {"anchor": anchor.strip(), "has_followup": has_followup}
|
| 357 |
|
| 358 |
+
|
| 359 |
def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8) -> list | None:
|
| 360 |
+
"""
|
| 361 |
+
Locate the best-matching line (or sentence inside it) for the user's anchor,
|
| 362 |
+
then return ONLY subsequent steps. Returns None if no strong anchor is found.
|
| 363 |
+
"""
|
| 364 |
steps = _split_sop_into_steps(numbered_text)
|
| 365 |
if not steps:
|
| 366 |
return None
|
| 367 |
+
|
| 368 |
info = _extract_anchor_from_query(user_message)
|
| 369 |
anchor = info.get("anchor", "").strip()
|
| 370 |
if not anchor:
|
|
|
|
| 394 |
accept = True
|
| 395 |
else:
|
| 396 |
base_ok = best_score >= (0.55 if not has_followup else 0.50)
|
| 397 |
+
len_ok = (best_score >= 0.40) and (tok_count >= 3)
|
| 398 |
+
accept = base_ok or len_ok
|
| 399 |
if not accept:
|
| 400 |
return None
|
| 401 |
|
|
|
|
| 406 |
next_steps = steps[start:end]
|
| 407 |
return [ln for ln in _dedupe_lines("\n".join(next_steps)).splitlines() if ln.strip()]
|
| 408 |
|
| 409 |
+
|
| 410 |
# ---------------- Context filtering (neutral/errors rendering) ----------------
|
| 411 |
def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
|
| 412 |
STRICT_OVERLAP = 3
|
|
|
|
| 445 |
if matched_exact:
|
| 446 |
kept = matched_exact[:MAX_SENTENCES_STRICT]
|
| 447 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 448 |
+
'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences),
|
| 449 |
}
|
| 450 |
if matched_any:
|
| 451 |
kept = matched_any[:MAX_SENTENCES_CONCISE]
|
| 452 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 453 |
+
'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences),
|
| 454 |
}
|
| 455 |
kept = sentences[:MAX_SENTENCES_CONCISE]
|
| 456 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 457 |
+
'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences),
|
| 458 |
}
|
| 459 |
|
| 460 |
+
|
| 461 |
def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
| 462 |
kept: List[str] = []
|
| 463 |
for ln in _normalize_lines(text):
|
|
|
|
| 467 |
break
|
| 468 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 469 |
|
| 470 |
+
|
| 471 |
def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
|
| 472 |
PERM_SYNONYMS = (
|
| 473 |
"permission", "permissions", "access", "authorization", "authorisation",
|
| 474 |
+
"role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient",
|
| 475 |
)
|
| 476 |
kept: List[str] = []
|
| 477 |
for ln in _normalize_lines(text):
|
|
|
|
| 482 |
break
|
| 483 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 484 |
|
| 485 |
+
|
| 486 |
def _extract_escalation_line(text: str) -> Optional[str]:
|
| 487 |
if not text:
|
| 488 |
return None
|
|
|
|
| 521 |
path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
|
| 522 |
return f"If you want to escalate the issue, follow: {path}"
|
| 523 |
|
| 524 |
+
|
| 525 |
def _detect_language_hint(msg: str) -> Optional[str]:
|
| 526 |
if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
|
| 527 |
return "Tamil"
|
|
|
|
| 529 |
return "Hindi"
|
| 530 |
return None
|
| 531 |
|
| 532 |
+
|
| 533 |
def _build_clarifying_message() -> str:
|
| 534 |
return ("It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
|
| 535 |
"or should I raise a ServiceNow ticket for you?")
|
| 536 |
|
| 537 |
+
|
| 538 |
def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
|
| 539 |
issue = (issue_text or "").strip()
|
| 540 |
resolved = (resolved_text or "").strip()
|
|
|
|
| 546 |
).strip()
|
| 547 |
return short_desc, long_desc
|
| 548 |
|
| 549 |
+
|
| 550 |
def _is_incident_intent(msg_norm: str) -> bool:
|
| 551 |
intent_phrases = [
|
| 552 |
"create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
|
|
|
|
| 556 |
]
|
| 557 |
return any(p in msg_norm for p in intent_phrases)
|
| 558 |
|
| 559 |
+
|
| 560 |
def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
| 561 |
status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
|
| 562 |
base_has_status = any(k in msg_norm for k in status_keywords)
|
|
|
|
| 568 |
return {}
|
| 569 |
patterns = [
|
| 570 |
r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
|
| 571 |
+
r"(inc\d+)",
|
| 572 |
]
|
| 573 |
for pat in patterns:
|
| 574 |
m = re.search(pat, msg_norm, flags=re.IGNORECASE)
|
|
|
|
| 578 |
return {"number": val.upper() if val.lower().startswith("inc") else val}
|
| 579 |
return {"number": None, "ask_number": True}
|
| 580 |
|
| 581 |
+
|
| 582 |
def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
|
| 583 |
phrases = [
|
| 584 |
"it is resolved", "resolved", "issue resolved", "problem resolved",
|
|
|
|
| 587 |
]
|
| 588 |
return any(p in msg_norm for p in phrases)
|
| 589 |
|
| 590 |
+
|
| 591 |
def _has_negation_resolved(msg_norm: str) -> bool:
|
| 592 |
neg_phrases = [
|
| 593 |
"not resolved", "issue not resolved", "still not working", "not working",
|
|
|
|
| 595 |
]
|
| 596 |
return any(p in msg_norm for p in neg_phrases)
|
| 597 |
|
| 598 |
+
|
| 599 |
def _find_prereq_section_text(best_doc: str) -> str:
|
| 600 |
variants = [
|
| 601 |
"Pre-Requisites", "Prerequisites", "Pre Requisites", "Pre-Requirements", "Requirements",
|
|
|
|
| 606 |
return txt.strip()
|
| 607 |
return ""
|
| 608 |
|
| 609 |
+
|
| 610 |
# ---------------------------------------------------------------------
|
| 611 |
# Health
|
| 612 |
# ---------------------------------------------------------------------
|
|
|
|
| 614 |
async def health_check():
|
| 615 |
return {"status": "ok"}
|
| 616 |
|
| 617 |
+
|
| 618 |
# ---------------------------------------------------------------------
|
| 619 |
# Chat
|
| 620 |
# ---------------------------------------------------------------------
|
|
|
|
| 806 |
generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
|
| 807 |
|
| 808 |
# intent nudge for prereqs
|
| 809 |
+
PREREQ_TERMS = (
|
| 810 |
+
"pre req", "pre-requisite", "pre-requisites", "prerequisite",
|
| 811 |
+
"prerequisites", "pre requirement", "pre-requirements", "requirements",
|
| 812 |
+
)
|
| 813 |
if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
|
| 814 |
detected_intent = "prereqs"
|
| 815 |
|
|
|
|
| 838 |
"trailer", "shipment", "order", "load", "wave",
|
| 839 |
"inventory", "putaway", "receiving", "appointment",
|
| 840 |
"dock", "door", "manifest", "pallet", "container",
|
| 841 |
+
"asn", "grn", "pick", "picking",
|
| 842 |
)
|
| 843 |
ACTION_OR_ERROR_TERMS = (
|
| 844 |
+
"how to", "procedure", "perform", "close", "closing", "open", "navigate", "scan",
|
| 845 |
+
"confirm", "generate", "update", "receive", "receiving", "error", "issue", "fail", "failed",
|
| 846 |
+
"not working", "locked", "mismatch", "access", "permission", "status",
|
|
|
|
|
|
|
| 847 |
)
|
| 848 |
matched_count = int(filt_info.get("matched_count") or 0)
|
| 849 |
filter_mode = (filt_info.get("mode") or "").lower()
|
|
|
|
| 858 |
strong_error_signal = len(_detect_error_families(msg_low)) > 0
|
| 859 |
|
| 860 |
if (weak_domain_only or (low_context_hit and not combined_ok)) \
|
| 861 |
+
and not strong_steps_bypass \
|
| 862 |
+
and not (strong_error_signal or generic_error_signal):
|
| 863 |
return {
|
| 864 |
"bot_response": _build_clarifying_message(),
|
| 865 |
"status": "NO_KB_MATCH",
|
|
|
|
| 901 |
if full_steps:
|
| 902 |
numbered_full = _ensure_numbering(full_steps)
|
| 903 |
|
| 904 |
+
# --- Section-aware action filtering (avoid over-trimming "update" sections) ---
|
| 905 |
raw_actions = set((kb_results.get("actions") or []))
|
| 906 |
msg_low2 = (input_data.user_message or "").lower()
|
| 907 |
+
|
| 908 |
+
# infer action from user text if extractor missed it
|
| 909 |
if not raw_actions and ("creation" in msg_low2 or "create" in msg_low2 or "set up" in msg_low2 or "setup" in msg_low2):
|
| 910 |
+
raw_actions = {"create"}
|
| 911 |
elif not raw_actions and ("update" in msg_low2 or "modify" in msg_low2 or "edit" in msg_low2 or "change" in msg_low2):
|
| 912 |
+
raw_actions = {"update"}
|
| 913 |
elif not raw_actions and ("delete" in msg_low2 or "remove" in msg_low2 or "cancel" in msg_low2 or "void" in msg_low2):
|
| 914 |
+
raw_actions = {"delete"}
|
| 915 |
+
|
| 916 |
sec_title_low = ((top_meta or {}).get("section") or "").strip().lower()
|
| 917 |
section_is_create = any(k in sec_title_low for k in ("create", "creation"))
|
| 918 |
section_is_update = any(k in sec_title_low for k in ("update", "updation"))
|
| 919 |
section_is_delete = any(k in sec_title_low for k in ("delete", "removal", "cancellation"))
|
| 920 |
+
|
| 921 |
skip_action_filter = (
|
| 922 |
("create" in raw_actions and section_is_create) or
|
| 923 |
("update" in raw_actions and section_is_update) or
|
| 924 |
("delete" in raw_actions and section_is_delete)
|
| 925 |
+
)
|
| 926 |
+
|
| 927 |
wanted, exclude = set(), set()
|
| 928 |
if not skip_action_filter:
|
| 929 |
+
if "create" in raw_actions and not ({"update", "delete"} & raw_actions):
|
| 930 |
+
wanted, exclude = {"create"}, {"update", "delete"}
|
| 931 |
+
elif "update" in raw_actions and not ({"create", "delete"} & raw_actions):
|
| 932 |
+
wanted, exclude = {"update"}, {"create", "delete"}
|
| 933 |
+
elif "delete" in raw_actions and not ({"create", "update"} & raw_actions):
|
| 934 |
+
wanted, exclude = {"delete"}, {"create", "update"}
|
| 935 |
+
|
| 936 |
if (wanted or exclude) and not skip_action_filter:
|
| 937 |
+
before = numbered_full
|
| 938 |
+
numbered_full = _filter_numbered_steps_by_actions(numbered_full, wanted=wanted, exclude=exclude)
|
| 939 |
+
# safety: if over-trimmed to <=1 line, revert
|
| 940 |
+
if len([ln for ln in numbered_full.splitlines() if ln.strip()]) <= 1:
|
| 941 |
+
numbered_full = before
|
| 942 |
|
| 943 |
+
# --- Keyword-free anchor-based next-step resolver ---
|
| 944 |
next_only = _anchor_next_steps(input_data.user_message, numbered_full, max_next=6)
|
| 945 |
|
| 946 |
if next_only is not None:
|
|
|
|
| 991 |
if full_prereqs:
|
| 992 |
context = full_prereqs.strip()
|
| 993 |
context_found = True
|
|
|
|
|
|
|
|
|
|
| 994 |
|
| 995 |
# language hint & paraphrase (errors only)
|
| 996 |
language_hint = _detect_language_hint(input_data.user_message)
|
|
|
|
| 1052 |
|
| 1053 |
# non-empty guarantee
|
| 1054 |
if not (bot_text or "").strip():
|
| 1055 |
+
if (context or "").strip():
|
| 1056 |
bot_text = context.strip()
|
| 1057 |
else:
|
| 1058 |
bot_text = (
|
|
|
|
| 1097 |
except Exception as e:
|
| 1098 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1099 |
|
| 1100 |
+
|
| 1101 |
# ---------------------------------------------------------------------
|
| 1102 |
# Ticket description generation
|
| 1103 |
# ---------------------------------------------------------------------
|
|
|
|
| 1138 |
except Exception as e:
|
| 1139 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1140 |
|
| 1141 |
+
|
| 1142 |
# ---------------------------------------------------------------------
|
| 1143 |
# Incident status
|
| 1144 |
# ---------------------------------------------------------------------
|
|
|
|
| 1181 |
except Exception as e:
|
| 1182 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1183 |
|
| 1184 |
+
|
| 1185 |
# ---------------------------------------------------------------------
|
| 1186 |
# Incident creation
|
| 1187 |
# ---------------------------------------------------------------------
|
|
|
|
| 1206 |
except Exception:
|
| 1207 |
return False
|
| 1208 |
|
| 1209 |
+
|
| 1210 |
def _set_incident_resolved(sys_id: str) -> bool:
|
| 1211 |
try:
|
| 1212 |
token = get_valid_token()
|
|
|
|
| 1287 |
print(f"[SN PATCH resolve] exception={safe_str(e)}")
|
| 1288 |
return False
|
| 1289 |
|
| 1290 |
+
|
| 1291 |
@app.post("/incident")
|
| 1292 |
async def raise_incident(input_data: IncidentInput):
|
| 1293 |
try:
|