Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -23,7 +23,6 @@ from public_api.public_api_location import get_location_status
|
|
| 23 |
from public_api.utils import flatten_json, extract_fields
|
| 24 |
from public_api.field_mapping import TOOL_REGISTRY, ALL_TOOLS
|
| 25 |
|
| 26 |
-
|
| 27 |
# KB services
|
| 28 |
from services.kb_creation import (
|
| 29 |
collection,
|
|
@@ -39,23 +38,51 @@ from services.kb_creation import (
|
|
| 39 |
from services.login import router as login_router
|
| 40 |
from services.generate_ticket import get_valid_token, create_incident
|
| 41 |
|
| 42 |
-
|
| 43 |
-
#
|
| 44 |
-
#
|
|
|
|
| 45 |
load_dotenv()
|
|
|
|
| 46 |
VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 47 |
GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 48 |
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
GEMINI_URL = (
|
| 50 |
"https://generativelanguage.googleapis.com/v1beta/models/"
|
| 51 |
-
|
| 52 |
)
|
|
|
|
| 53 |
os.environ["POSTHOG_DISABLED"] = "true"
|
| 54 |
|
| 55 |
# --- API WMS session cache ---
|
| 56 |
_WMS_SESSION_DATA = None
|
| 57 |
_WMS_WAREHOUSE_ID = None
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def _ensure_wms_session() -> bool:
|
| 60 |
"""Login once to WMS and cache session_data + default warehouse."""
|
| 61 |
global _WMS_SESSION_DATA, _WMS_WAREHOUSE_ID
|
|
@@ -71,9 +98,10 @@ def _ensure_wms_session() -> bool:
|
|
| 71 |
print("[WMS] login failed:", e)
|
| 72 |
return False
|
| 73 |
|
| 74 |
-
|
| 75 |
-
#
|
| 76 |
-
# -
|
|
|
|
| 77 |
LAST_ISSUE_HINT: str = ""
|
| 78 |
|
| 79 |
|
|
@@ -84,9 +112,9 @@ def safe_str(e: Any) -> str:
|
|
| 84 |
return "<error stringify failed>"
|
| 85 |
|
| 86 |
|
| 87 |
-
#
|
| 88 |
# App / Lifespan
|
| 89 |
-
#
|
| 90 |
@asynccontextmanager
|
| 91 |
async def lifespan(app: FastAPI):
|
| 92 |
try:
|
|
@@ -106,7 +134,7 @@ app.include_router(login_router)
|
|
| 106 |
|
| 107 |
# CORS
|
| 108 |
origins = [
|
| 109 |
-
"https://chatbotnova-chatbot-frontend.hf.space",
|
| 110 |
# "http://localhost:5173", # local dev if needed
|
| 111 |
]
|
| 112 |
app.add_middleware(
|
|
@@ -117,9 +145,9 @@ app.add_middleware(
|
|
| 117 |
allow_headers=["*"],
|
| 118 |
)
|
| 119 |
|
| 120 |
-
#
|
| 121 |
# Models
|
| 122 |
-
#
|
| 123 |
class ChatInput(BaseModel):
|
| 124 |
user_message: str
|
| 125 |
prev_status: Optional[str] = None
|
|
@@ -150,9 +178,9 @@ STATE_MAP = {
|
|
| 150 |
"8": "Canceled",
|
| 151 |
}
|
| 152 |
|
| 153 |
-
#
|
| 154 |
# Generic helpers (shared)
|
| 155 |
-
#
|
| 156 |
NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
|
| 157 |
DOMAIN_STATUS_TERMS = (
|
| 158 |
"shipment", "order", "load", "trailer", "wave",
|
|
@@ -160,6 +188,7 @@ DOMAIN_STATUS_TERMS = (
|
|
| 160 |
"dock", "door", "manifest", "pallet", "container",
|
| 161 |
"asn", "grn", "pick", "picking",
|
| 162 |
)
|
|
|
|
| 163 |
ERROR_FAMILY_SYNS = {
|
| 164 |
"NOT_FOUND": (
|
| 165 |
"not found", "missing", "does not exist", "doesn't exist",
|
|
@@ -190,38 +219,29 @@ ERROR_FAMILY_SYNS = {
|
|
| 190 |
}
|
| 191 |
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
def _try_wms_tool(user_text: str) -> dict | None:
|
| 196 |
"""
|
| 197 |
Uses Gemini function calling + TOOL_REGISTRY to decide if a WMS tool should be called.
|
| 198 |
Returns YOUR existing response dict shape. If no tool match or any error -> returns None.
|
| 199 |
"""
|
| 200 |
-
|
| 201 |
# Guards
|
| 202 |
if not GEMINI_API_KEY:
|
| 203 |
return None
|
| 204 |
if not _ensure_wms_session():
|
| 205 |
return None
|
| 206 |
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
| 208 |
payload = {
|
| 209 |
-
"contents": [{"role": "user","parts": [{"text": user_text}]}],
|
| 210 |
-
"tools": [{"functionDeclarations": ALL_TOOLS}],
|
| 211 |
-
"toolConfig": {
|
| 212 |
-
"functionCallingConfig": { "mode": "ANY" }
|
| 213 |
-
}
|
| 214 |
}
|
| 215 |
|
| 216 |
try:
|
| 217 |
-
resp =
|
| 218 |
-
GEMINI_URL,
|
| 219 |
-
headers=headers,
|
| 220 |
-
json=payload,
|
| 221 |
-
timeout=25,
|
| 222 |
-
verify=GEMINI_SSL_VERIFY
|
| 223 |
-
)
|
| 224 |
-
# Raise if HTTP not 2xx so we handle cleanly
|
| 225 |
resp.raise_for_status()
|
| 226 |
data = resp.json()
|
| 227 |
|
|
@@ -229,12 +249,9 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 229 |
if not candidates:
|
| 230 |
return None
|
| 231 |
|
| 232 |
-
# Defensive extraction of the first part
|
| 233 |
content = candidates[0].get("content", {})
|
| 234 |
parts = content.get("parts", [])
|
| 235 |
part = parts[0] if parts else {}
|
| 236 |
-
|
| 237 |
-
# Gemini returns a function call in part["functionCall"] when a tool is selected
|
| 238 |
fn_call = part.get("functionCall")
|
| 239 |
if not fn_call or "name" not in fn_call:
|
| 240 |
return None
|
|
@@ -244,21 +261,19 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 244 |
|
| 245 |
cfg = TOOL_REGISTRY.get(tool_name)
|
| 246 |
if not cfg:
|
| 247 |
-
# Tool not registered → bail
|
| 248 |
return None
|
| 249 |
|
| 250 |
-
#
|
| 251 |
wh = (
|
| 252 |
args.pop("warehouse_id", None)
|
| 253 |
or args.pop("warehouseId", None)
|
| 254 |
or args.pop("wh_id", None)
|
| 255 |
-
or
|
| 256 |
)
|
| 257 |
|
| 258 |
-
#
|
| 259 |
tool_fn = cfg.get("function")
|
| 260 |
if not callable(tool_fn):
|
| 261 |
-
# Misconfigured registry
|
| 262 |
return {
|
| 263 |
"bot_response": f"⚠️ Tool '{tool_name}' is not callable. Check TOOL_REGISTRY['{tool_name}']['function'].",
|
| 264 |
"status": "PARTIAL",
|
|
@@ -272,15 +287,20 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 272 |
"source": "ERROR",
|
| 273 |
}
|
| 274 |
|
| 275 |
-
#
|
| 276 |
-
#
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
call_kwargs
|
| 281 |
-
raw = tool_fn(**call_kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
else:
|
| 283 |
-
id_param = cfg.get("id_param")
|
| 284 |
target = args.get(id_param)
|
| 285 |
if id_param and target is None:
|
| 286 |
return {
|
|
@@ -295,19 +315,10 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 295 |
"debug": {"intent": "wms_missing_id_param", "tool": tool_name},
|
| 296 |
"source": "CLIENT",
|
| 297 |
}
|
|
|
|
|
|
|
| 298 |
|
| 299 |
-
|
| 300 |
-
# Include the ID param, plus any other Gemini-parsed args
|
| 301 |
-
if id_param:
|
| 302 |
-
call_kwargs[id_param] = target
|
| 303 |
-
# Keep other non-ID args (filters, etc.)
|
| 304 |
-
for k, v in args.items():
|
| 305 |
-
if k != id_param:
|
| 306 |
-
call_kwargs[k] = v
|
| 307 |
-
|
| 308 |
-
raw = tool_fn(**call_kwargs)
|
| 309 |
-
|
| 310 |
-
# -------- Error handling from tool layer --------
|
| 311 |
if isinstance(raw, dict) and raw.get("error"):
|
| 312 |
return {
|
| 313 |
"bot_response": f"⚠️ {raw['error']}",
|
|
@@ -322,7 +333,7 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 322 |
"source": "ERROR",
|
| 323 |
}
|
| 324 |
|
| 325 |
-
# ----
|
| 326 |
response_key = cfg.get("response_key", "data")
|
| 327 |
data_list = (raw.get(response_key, []) if isinstance(raw, dict) else []) or []
|
| 328 |
if not data_list:
|
|
@@ -339,24 +350,20 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 339 |
"source": "LIVE_API",
|
| 340 |
}
|
| 341 |
|
| 342 |
-
# ----
|
| 343 |
if tool_name in ("get_inventory_holds", "get_location_status"):
|
| 344 |
-
rows_for_text = []
|
| 345 |
-
total_qty = 0
|
| 346 |
-
|
| 347 |
for item in data_list:
|
| 348 |
flat = flatten_json(item)
|
| 349 |
row = extract_fields(flat, cfg["mapping"], cfg.get("formatters", {}))
|
| 350 |
rows_for_text.append("• " + "; ".join(f"{k}: {v}" for k, v in row.items()))
|
| 351 |
|
| 352 |
if tool_name == "get_inventory_holds":
|
| 353 |
-
# Only holds have quantity in most schemas
|
| 354 |
def _to_int(x):
|
| 355 |
try:
|
| 356 |
return int(x or 0)
|
| 357 |
except Exception:
|
| 358 |
return 0
|
| 359 |
-
|
| 360 |
total_qty = sum(_to_int(it.get("untqty", 0)) for it in data_list)
|
| 361 |
msg = f"Found {len(rows_for_text)} hold records (Total Qty: {total_qty}).\n" + "\n".join(rows_for_text[:10])
|
| 362 |
else:
|
|
@@ -379,9 +386,8 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 379 |
"show_export": True if tool_name == "get_inventory_holds" else False,
|
| 380 |
}
|
| 381 |
|
| 382 |
-
# ----
|
| 383 |
flat = flatten_json(data_list[0])
|
| 384 |
-
|
| 385 |
requested = args.get("fields", [])
|
| 386 |
if requested:
|
| 387 |
filtered_map = {k: v for k, v in cfg["mapping"].items() if k in requested or k == "Item"}
|
|
@@ -412,7 +418,6 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 412 |
}
|
| 413 |
|
| 414 |
except requests.HTTPError as e:
|
| 415 |
-
# Surface Gemini API issues more clearly
|
| 416 |
return {
|
| 417 |
"bot_response": f"Gemini API error: {e}",
|
| 418 |
"status": "PARTIAL",
|
|
@@ -429,6 +434,7 @@ def _try_wms_tool(user_text: str) -> dict | None:
|
|
| 429 |
print("[WMS] tool call error:", e)
|
| 430 |
return None
|
| 431 |
|
|
|
|
| 432 |
def _detect_error_families(msg: str) -> list:
|
| 433 |
low = (msg or "").lower()
|
| 434 |
low_norm = re.sub(r"[^\w\s]", " ", low)
|
|
@@ -493,7 +499,7 @@ def _dedupe_lines(text: str) -> str:
|
|
| 493 |
|
| 494 |
|
| 495 |
def _split_sentences(block: str) -> list:
|
| 496 |
-
parts = [t.strip() for t in re.split(r"(?<=[.!?])\s+", block or "") if t.strip()]
|
| 497 |
return parts if parts else ([block.strip()] if (block or "").strip() else [])
|
| 498 |
|
| 499 |
|
|
@@ -505,33 +511,31 @@ def _ensure_numbering(text: str) -> str:
|
|
| 505 |
para = " ".join(lines).strip()
|
| 506 |
if not para:
|
| 507 |
return ""
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
para_clean = re.sub(r"(?
|
|
|
|
|
|
|
|
|
|
| 511 |
segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
|
| 512 |
if len(segments) < 2:
|
| 513 |
tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
|
| 514 |
-
segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+", para) if seg.strip()]
|
| 515 |
|
| 516 |
def strip_prefix_any(s: str) -> str:
|
| 517 |
return re.sub(
|
| 518 |
r"^\s*(?:"
|
| 519 |
-
r"(?:\d+\s*[.\)])|"
|
| 520 |
-
r"(?i:step\s*\d+:?)|"
|
| 521 |
-
r"(?:[-*\u2022])|"
|
| 522 |
-
r"(?:[\u2460-\u2473])"
|
| 523 |
r")\s*",
|
| 524 |
"",
|
| 525 |
(s or "").strip(),
|
| 526 |
)
|
| 527 |
|
| 528 |
clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
|
| 529 |
-
circled = {
|
| 530 |
-
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 531 |
-
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
| 532 |
-
11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
|
| 533 |
-
16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473",
|
| 534 |
-
}
|
| 535 |
out = []
|
| 536 |
for idx, seg in enumerate(clean_segments, start=1):
|
| 537 |
marker = circled.get(idx, f"{idx})")
|
|
@@ -539,49 +543,13 @@ def _ensure_numbering(text: str) -> str:
|
|
| 539 |
return "\n".join(out)
|
| 540 |
|
| 541 |
|
| 542 |
-
def
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
return s
|
| 548 |
-
toks = s.split()
|
| 549 |
-
stemmed = []
|
| 550 |
-
for t in toks:
|
| 551 |
-
if len(t) > 3 and t.endswith("s"):
|
| 552 |
-
t = t[:-1]
|
| 553 |
-
if len(t) > 4 and t.endswith("ed"):
|
| 554 |
-
t = t[:-2]
|
| 555 |
-
if len(t) > 5 and t.endswith("ing"):
|
| 556 |
-
t = t[:-3]
|
| 557 |
-
stemmed.append(t)
|
| 558 |
-
return " ".join(stemmed).strip()
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
def _split_sop_into_steps(numbered_text: str) -> list:
|
| 562 |
-
lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
|
| 563 |
-
steps = []
|
| 564 |
-
for ln in lines:
|
| 565 |
-
cleaned = re.sub(r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*", "", ln)
|
| 566 |
-
if cleaned:
|
| 567 |
-
steps.append(cleaned)
|
| 568 |
-
return steps
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
def _format_steps_as_numbered(steps: list) -> str:
|
| 572 |
-
circled = {
|
| 573 |
-
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 574 |
-
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
| 575 |
-
11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
|
| 576 |
-
16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473",
|
| 577 |
-
}
|
| 578 |
-
out = []
|
| 579 |
-
for i, s in enumerate(steps, start=1):
|
| 580 |
-
out.append(f"{circled.get(i, str(i))} {s}")
|
| 581 |
-
return "\n".join(out)
|
| 582 |
-
|
| 583 |
|
| 584 |
-
def _similarity(a: str, b: str) -> float:
|
| 585 |
a_norm, b_norm = _norm_text(a), _norm_text(b)
|
| 586 |
ta, tb = set(a_norm.split()), set(b_norm.split())
|
| 587 |
inter = len(ta & tb)
|
|
@@ -595,13 +563,15 @@ def _similarity(a: str, b: str) -> float:
|
|
| 595 |
big_inter = len(ab & bb)
|
| 596 |
big_union = len(ab | bb) or 1
|
| 597 |
big = big_inter / big_union
|
|
|
|
| 598 |
char = SequenceMatcher(None, a_norm, b_norm).ratio()
|
| 599 |
return min(1.0, 0.45 * jacc + 0.30 * big + 0.35 * char)
|
| 600 |
|
| 601 |
|
| 602 |
def _extract_anchor_from_query(msg: str) -> dict:
|
| 603 |
raw = (msg or "").strip()
|
| 604 |
-
low =
|
|
|
|
| 605 |
FOLLOWUP_CUES = ("what next", "what is next", "what to do", "then", "after that", "next")
|
| 606 |
has_followup = any(cue in low for cue in FOLLOWUP_CUES)
|
| 607 |
|
|
@@ -610,17 +580,16 @@ def _extract_anchor_from_query(msg: str) -> dict:
|
|
| 610 |
return {"anchor": raw, "has_followup": has_followup}
|
| 611 |
|
| 612 |
last = parts[-1]
|
| 613 |
-
last_low =
|
| 614 |
if any(cue in last_low for cue in FOLLOWUP_CUES) and len(parts) >= 2:
|
| 615 |
anchor = parts[-2]
|
| 616 |
else:
|
| 617 |
anchor = parts[-1] if len(parts) > 1 else parts[0]
|
| 618 |
-
|
| 619 |
return {"anchor": anchor.strip(), "has_followup": has_followup}
|
| 620 |
|
| 621 |
|
| 622 |
def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8) -> list | None:
|
| 623 |
-
steps =
|
| 624 |
if not steps:
|
| 625 |
return None
|
| 626 |
|
|
@@ -628,9 +597,8 @@ def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8)
|
|
| 628 |
anchor = info.get("anchor", "").strip()
|
| 629 |
if not anchor:
|
| 630 |
return None
|
| 631 |
-
anchor_norm = _norm_text(anchor)
|
| 632 |
-
has_followup = bool(info.get("has_followup"))
|
| 633 |
|
|
|
|
| 634 |
candidates = []
|
| 635 |
for idx, step_line in enumerate(steps):
|
| 636 |
s_full = _similarity(anchor, step_line)
|
|
@@ -638,23 +606,24 @@ def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8)
|
|
| 638 |
scores = [s_full]
|
| 639 |
for s in _split_sentences(step_line):
|
| 640 |
scores.append(_similarity(anchor, s))
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
score = max(scores)
|
| 646 |
candidates.append((idx, score, literal_hit))
|
| 647 |
|
| 648 |
candidates.sort(key=lambda t: (t[1], t[0]), reverse=True)
|
| 649 |
best_idx, best_score, best_literal = candidates[0]
|
| 650 |
|
| 651 |
-
tok_count = len([t for t in
|
| 652 |
if best_literal:
|
| 653 |
accept = True
|
| 654 |
else:
|
| 655 |
base_ok = best_score >= (0.55 if not has_followup else 0.50)
|
| 656 |
len_ok = (best_score >= 0.40) and (tok_count >= 3)
|
| 657 |
accept = base_ok or len_ok
|
|
|
|
| 658 |
if not accept:
|
| 659 |
return None
|
| 660 |
|
|
@@ -678,12 +647,13 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
|
|
| 678 |
return t
|
| 679 |
|
| 680 |
def _split_sents(ctx: str) -> List[str]:
|
| 681 |
-
raw_sents = re.split(r"(?<=[.!?])\s+", ctx or "")
|
| 682 |
return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
|
| 683 |
|
| 684 |
ctx = (context or "").strip()
|
| 685 |
if not ctx or not query:
|
| 686 |
return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
|
|
|
|
| 687 |
q_norm = _norm(query)
|
| 688 |
q_terms = [t for t in q_norm.split() if len(t) > 2]
|
| 689 |
if not q_terms:
|
|
@@ -693,7 +663,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
|
|
| 693 |
matched_exact, matched_any = [], []
|
| 694 |
for s in sentences:
|
| 695 |
s_norm = _norm(s)
|
| 696 |
-
is_bullet = bool(re.match(r"^[
|
| 697 |
overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
|
| 698 |
if overlap >= STRICT_OVERLAP:
|
| 699 |
matched_exact.append(s)
|
|
@@ -705,11 +675,13 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
|
|
| 705 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 706 |
'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences),
|
| 707 |
}
|
|
|
|
| 708 |
if matched_any:
|
| 709 |
kept = matched_any[:MAX_SENTENCES_CONCISE]
|
| 710 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 711 |
'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences),
|
| 712 |
}
|
|
|
|
| 713 |
kept = sentences[:MAX_SENTENCES_CONCISE]
|
| 714 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 715 |
'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences),
|
|
@@ -719,7 +691,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
|
|
| 719 |
def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
| 720 |
kept: List[str] = []
|
| 721 |
for ln in _normalize_lines(text):
|
| 722 |
-
if re.match(r"^\s*[
|
| 723 |
kept.append(ln)
|
| 724 |
if len(kept) >= max_lines:
|
| 725 |
break
|
|
@@ -747,12 +719,14 @@ def _extract_escalation_line(text: str) -> Optional[str]:
|
|
| 747 |
lines = _normalize_lines(text)
|
| 748 |
if not lines:
|
| 749 |
return None
|
|
|
|
| 750 |
start_idx = None
|
| 751 |
for i, ln in enumerate(lines):
|
| 752 |
low = ln.lower()
|
| 753 |
if "escalation" in low or "escalation path" in low or "escalate" in low:
|
| 754 |
start_idx = i
|
| 755 |
break
|
|
|
|
| 756 |
block: List[str] = []
|
| 757 |
if start_idx is not None:
|
| 758 |
for j in range(start_idx, min(len(lines), start_idx + 6)):
|
|
@@ -763,9 +737,11 @@ def _extract_escalation_line(text: str) -> Optional[str]:
|
|
| 763 |
block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
|
| 764 |
if not block:
|
| 765 |
return None
|
|
|
|
| 766 |
text_block = " ".join(block)
|
| 767 |
m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
|
| 768 |
path = m.group(1).strip() if m else None
|
|
|
|
| 769 |
if not path:
|
| 770 |
arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
|
| 771 |
if arrow_lines:
|
|
@@ -775,6 +751,7 @@ def _extract_escalation_line(text: str) -> Optional[str]:
|
|
| 775 |
path = m2.group(1).strip() if m2 else None
|
| 776 |
if not path:
|
| 777 |
return None
|
|
|
|
| 778 |
path = path.replace("->", "→").strip()
|
| 779 |
path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
|
| 780 |
return f"If you want to escalate the issue, follow: {path}"
|
|
@@ -789,9 +766,10 @@ def _detect_language_hint(msg: str) -> Optional[str]:
|
|
| 789 |
|
| 790 |
|
| 791 |
def _build_clarifying_message() -> str:
|
| 792 |
-
return (
|
| 793 |
-
|
| 794 |
-
|
|
|
|
| 795 |
|
| 796 |
|
| 797 |
def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
|
|
@@ -804,16 +782,14 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
|
|
| 804 |
issue = (issue_text or "").strip()
|
| 805 |
resolved = (resolved_text or "").strip()
|
| 806 |
|
| 807 |
-
# Detect
|
| 808 |
is_process_query = bool(re.search(r"\b(how to|steps|procedure|process)\b", issue.lower()))
|
| 809 |
|
| 810 |
-
# Avoid "Process Steps – Process Steps" duplication
|
| 811 |
if is_process_query:
|
| 812 |
-
#
|
| 813 |
cleaned_issue = re.sub(r"^\s*(process\s*steps\s*[-–:]?\s*)", "", issue, flags=re.IGNORECASE).strip()
|
| 814 |
short_desc = f"Process Steps – {cleaned_issue or issue}"[:100]
|
| 815 |
else:
|
| 816 |
-
# Error or other query: use the issue as-is
|
| 817 |
short_desc = issue[:100]
|
| 818 |
|
| 819 |
long_desc = (
|
|
@@ -824,6 +800,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
|
|
| 824 |
|
| 825 |
return short_desc, long_desc
|
| 826 |
|
|
|
|
| 827 |
def _is_incident_intent(msg_norm: str) -> bool:
|
| 828 |
intent_phrases = [
|
| 829 |
"create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
|
|
@@ -843,6 +820,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
|
| 843 |
)
|
| 844 |
if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
|
| 845 |
return {}
|
|
|
|
| 846 |
patterns = [
|
| 847 |
r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
|
| 848 |
r"(inc\d+)",
|
|
@@ -884,21 +862,22 @@ def _find_prereq_section_text(best_doc: str) -> str:
|
|
| 884 |
return ""
|
| 885 |
|
| 886 |
|
| 887 |
-
#
|
| 888 |
# Health
|
| 889 |
-
#
|
| 890 |
@app.get("/")
|
| 891 |
async def health_check():
|
| 892 |
return {"status": "ok"}
|
| 893 |
|
| 894 |
|
| 895 |
-
#
|
| 896 |
# Chat
|
| 897 |
-
#
|
| 898 |
@app.post("/chat")
|
| 899 |
async def chat_with_ai(input_data: ChatInput):
|
| 900 |
-
global LAST_ISSUE_HINT #
|
| 901 |
assist_followup: Optional[str] = None
|
|
|
|
| 902 |
try:
|
| 903 |
msg_norm = (input_data.user_message or "").lower().strip()
|
| 904 |
|
|
@@ -911,6 +890,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 911 |
"options": [],
|
| 912 |
"debug": {"intent": "continue_conversation"},
|
| 913 |
}
|
|
|
|
| 914 |
if msg_norm in ("no", "no thanks", "nope"):
|
| 915 |
return {
|
| 916 |
"bot_response": "No problem. Do you need assistance with any other issue?",
|
|
@@ -925,6 +905,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 925 |
is_llm_resolved = _classify_resolution_llm(input_data.user_message)
|
| 926 |
if _has_negation_resolved(msg_norm):
|
| 927 |
is_llm_resolved = False
|
|
|
|
| 928 |
if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
|
| 929 |
try:
|
| 930 |
# Prefer cached issue hint if frontend didn't pass last_issue
|
|
@@ -1015,6 +996,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1015 |
"sources": [],
|
| 1016 |
"debug": {"intent": "status_request_missing_id"},
|
| 1017 |
}
|
|
|
|
| 1018 |
try:
|
| 1019 |
token = get_valid_token()
|
| 1020 |
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
|
@@ -1049,14 +1031,13 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1049 |
}
|
| 1050 |
except Exception as e:
|
| 1051 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1052 |
-
|
| 1053 |
-
# --- Try WMS API tools
|
| 1054 |
res = _try_wms_tool(input_data.user_message)
|
| 1055 |
if res is not None:
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
|
| 1059 |
-
# ----------------
|
| 1060 |
kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
|
| 1061 |
documents = kb_results.get("documents", [])
|
| 1062 |
metadatas = kb_results.get("metadatas", [])
|
|
@@ -1080,16 +1061,17 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1080 |
|
| 1081 |
selected = items[:max(1, 2)]
|
| 1082 |
context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
|
|
|
|
| 1083 |
filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
|
| 1084 |
context = filtered_text
|
| 1085 |
context_found = bool(context.strip())
|
| 1086 |
|
| 1087 |
best_distance = (min([d for d in distances if d is not None], default=None) if distances else None)
|
| 1088 |
best_combined = (max([c for c in combined if c is not None], default=None) if combined else None)
|
| 1089 |
-
|
| 1090 |
detected_intent = kb_results.get("user_intent", "neutral")
|
| 1091 |
best_doc = kb_results.get("best_doc")
|
| 1092 |
top_meta = (metadatas or [{}])[0] if metadatas else {}
|
|
|
|
| 1093 |
msg_low = (input_data.user_message or "").lower()
|
| 1094 |
GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
|
| 1095 |
generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
|
|
@@ -1113,12 +1095,12 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1113 |
detected_intent = "errors"
|
| 1114 |
|
| 1115 |
# heading-aware prereq nudge
|
| 1116 |
-
sec_title = (
|
|
|
|
| 1117 |
PREREQ_HEADINGS = ("pre-requisites", "prerequisites", "pre requisites", "pre-requirements", "requirements")
|
| 1118 |
-
if detected_intent == "neutral" and any(h in
|
| 1119 |
detected_intent = "prereqs"
|
| 1120 |
|
| 1121 |
-
# gating
|
| 1122 |
def _contains_any(s: str, keywords: tuple) -> bool:
|
| 1123 |
low = (s or "").lower()
|
| 1124 |
return any(k in low for k in keywords)
|
|
@@ -1134,13 +1116,16 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1134 |
"confirm", "generate", "update", "receive", "receiving", "error", "issue", "fail", "failed",
|
| 1135 |
"not working", "locked", "mismatch", "access", "permission", "status",
|
| 1136 |
)
|
|
|
|
| 1137 |
matched_count = int(filt_info.get("matched_count") or 0)
|
| 1138 |
filter_mode = (filt_info.get("mode") or "").lower()
|
| 1139 |
has_any_action_or_error = _contains_any(msg_low, ACTION_OR_ERROR_TERMS)
|
| 1140 |
mentions_domain = _contains_any(msg_low, DOMAIN_TERMS)
|
|
|
|
| 1141 |
short_query = len((input_data.user_message or "").split()) <= 4
|
| 1142 |
gate_combined_ok = 0.60 if short_query else 0.55
|
| 1143 |
combined_ok = (best_combined is not None and best_combined >= gate_combined_ok)
|
|
|
|
| 1144 |
weak_domain_only = (mentions_domain and not has_any_action_or_error)
|
| 1145 |
low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
|
| 1146 |
strong_steps_bypass = True
|
|
@@ -1172,7 +1157,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1172 |
},
|
| 1173 |
}
|
| 1174 |
|
| 1175 |
-
#
|
| 1176 |
escalation_line: Optional[str] = None
|
| 1177 |
full_errors: Optional[str] = None
|
| 1178 |
next_step_applied = False
|
|
@@ -1186,13 +1171,11 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1186 |
full_steps = get_section_text(best_doc, sec)
|
| 1187 |
else:
|
| 1188 |
full_steps = get_best_steps_section_text(best_doc)
|
| 1189 |
-
|
| 1190 |
if full_steps:
|
| 1191 |
numbered_full = _ensure_numbering(full_steps)
|
| 1192 |
|
| 1193 |
raw_actions = set((kb_results.get("actions") or []))
|
| 1194 |
msg_low2 = (input_data.user_message or "").lower()
|
| 1195 |
-
|
| 1196 |
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):
|
| 1197 |
raw_actions = {"create"}
|
| 1198 |
elif not raw_actions and ("update" in msg_low2 or "modify" in msg_low2 or "edit" in msg_low2 or "change" in msg_low2):
|
|
@@ -1227,7 +1210,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1227 |
numbered_full = before
|
| 1228 |
|
| 1229 |
next_only = _anchor_next_steps(input_data.user_message, numbered_full, max_next=6)
|
| 1230 |
-
|
| 1231 |
if next_only is not None:
|
| 1232 |
if len(next_only) == 0:
|
| 1233 |
context = "You are at the final step of this SOP. No further steps."
|
|
@@ -1235,8 +1217,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1235 |
next_step_info = {"count": 0}
|
| 1236 |
context_preformatted = True
|
| 1237 |
else:
|
| 1238 |
-
context =
|
| 1239 |
-
context = _dedupe_lines(context)
|
| 1240 |
next_step_applied = True
|
| 1241 |
next_step_info = {"count": len(next_only)}
|
| 1242 |
context_preformatted = True
|
|
@@ -1256,10 +1237,9 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1256 |
flags=re.IGNORECASE,
|
| 1257 |
))
|
| 1258 |
)
|
| 1259 |
-
|
| 1260 |
if said_not_resolved:
|
| 1261 |
return {
|
| 1262 |
-
"bot_response": "Select an option below.",
|
| 1263 |
"status": "OK",
|
| 1264 |
"context_found": False,
|
| 1265 |
"ask_resolved": False,
|
|
@@ -1270,26 +1250,18 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1270 |
"top_hits": [],
|
| 1271 |
"sources": [],
|
| 1272 |
"debug": {
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
},
|
| 1276 |
}
|
| 1277 |
|
| 1278 |
full_errors = get_best_errors_section_text(best_doc)
|
| 1279 |
if full_errors:
|
| 1280 |
ctx_err = _extract_errors_only(full_errors, max_lines=30)
|
| 1281 |
-
|
| 1282 |
if is_perm_query:
|
| 1283 |
context = _filter_permission_lines(ctx_err, max_lines=6)
|
| 1284 |
else:
|
| 1285 |
-
DOMAIN_TERMS = (
|
| 1286 |
-
"trailer", "shipment", "order", "load", "wave",
|
| 1287 |
-
"inventory", "putaway", "receiving", "appointment",
|
| 1288 |
-
"dock", "door", "manifest", "pallet", "container",
|
| 1289 |
-
"asn", "grn", "pick", "picking",
|
| 1290 |
-
)
|
| 1291 |
mentions_domain_local = any(t in msg_low for t in DOMAIN_TERMS)
|
| 1292 |
-
|
| 1293 |
is_specific_error = (len(_detect_error_families(msg_low)) > 0) or mentions_domain_local
|
| 1294 |
if is_specific_error:
|
| 1295 |
context = _filter_context_for_query(ctx_err, input_data.user_message)[0]
|
|
@@ -1297,14 +1269,14 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1297 |
all_lines = _normalize_lines(ctx_err)
|
| 1298 |
error_bullets = [
|
| 1299 |
ln for ln in all_lines
|
| 1300 |
-
if re.match(r"^\s*[
|
| 1301 |
]
|
| 1302 |
context = "\n".join(error_bullets[:6]).strip()
|
| 1303 |
else:
|
| 1304 |
all_lines = _normalize_lines(ctx_err)
|
| 1305 |
error_bullets = [
|
| 1306 |
ln for ln in all_lines
|
| 1307 |
-
if re.match(r"^\s*[
|
| 1308 |
]
|
| 1309 |
context = "\n".join(error_bullets[:6]).strip()
|
| 1310 |
|
|
@@ -1321,8 +1293,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1321 |
numbered_steps = _ensure_numbering(steps_src)
|
| 1322 |
next_only = _anchor_next_steps(input_data.user_message, numbered_steps, max_next=6)
|
| 1323 |
if next_only is not None and len(next_only) > 0:
|
| 1324 |
-
context =
|
| 1325 |
-
context = _dedupe_lines(context)
|
| 1326 |
context_preformatted = True
|
| 1327 |
next_step_applied = True
|
| 1328 |
next_step_info = {"count": len(next_only), "source": "errors_domain_override"}
|
|
@@ -1336,10 +1307,11 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1336 |
context = full_prereqs.strip()
|
| 1337 |
context_found = True
|
| 1338 |
|
| 1339 |
-
# language hint & paraphrase (errors only)
|
| 1340 |
language_hint = _detect_language_hint(input_data.user_message)
|
| 1341 |
lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
|
| 1342 |
use_gemini = (detected_intent == "errors") and not steps_override_applied
|
|
|
|
| 1343 |
enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
|
| 1344 |
- Do not add any information that is not present in the context.
|
| 1345 |
- If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
|
|
@@ -1350,14 +1322,13 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1350 |
{input_data.user_message}
|
| 1351 |
### Output
|
| 1352 |
Return ONLY the rewritten guidance."""
|
| 1353 |
-
headers = {"Content-Type": "application/json"}
|
| 1354 |
-
payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
|
| 1355 |
bot_text = ""
|
| 1356 |
http_code = 0
|
| 1357 |
|
| 1358 |
if use_gemini and GEMINI_API_KEY:
|
| 1359 |
try:
|
| 1360 |
-
|
|
|
|
| 1361 |
http_code = getattr(resp, "status_code", 0)
|
| 1362 |
try:
|
| 1363 |
result = resp.json()
|
|
@@ -1372,10 +1343,7 @@ Return ONLY the rewritten guidance."""
|
|
| 1372 |
|
| 1373 |
# deterministic local formatting
|
| 1374 |
if detected_intent == "steps":
|
| 1375 |
-
if context_preformatted
|
| 1376 |
-
bot_text = context
|
| 1377 |
-
else:
|
| 1378 |
-
bot_text = _ensure_numbering(context)
|
| 1379 |
elif detected_intent == "errors":
|
| 1380 |
if not (bot_text or "").strip() or http_code == 429:
|
| 1381 |
bot_text = context.strip()
|
|
@@ -1407,41 +1375,35 @@ Return ONLY the rewritten guidance."""
|
|
| 1407 |
short_query = len((input_data.user_message or "").split()) <= 4
|
| 1408 |
gate_combined_ok = 0.60 if short_query else 0.55
|
| 1409 |
status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
|
|
|
|
| 1410 |
lower = (bot_text or "").lower()
|
| 1411 |
if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
|
| 1412 |
status = "PARTIAL"
|
| 1413 |
|
| 1414 |
options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
|
| 1415 |
|
| 1416 |
-
# ---
|
| 1417 |
-
# ----- Cache last issue hint (used if user later says "issue resolved thanks")
|
| 1418 |
try:
|
| 1419 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
|
| 1428 |
-
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
-
|
| 1432 |
-
|
| 1433 |
-
|
| 1434 |
-
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
-
|
| 1439 |
-
top_error_line = re.split(r"\bif you want to escalate\b", top_error_line, flags=re.IGNORECASE)[0].strip()
|
| 1440 |
-
if top_error_line:
|
| 1441 |
-
LAST_ISSUE_HINT = f"{base_query} — {top_error_line}"
|
| 1442 |
-
else:
|
| 1443 |
-
LAST_ISSUE_HINT = base_query
|
| 1444 |
-
LAST_ISSUE_HINT = LAST_ISSUE_HINT[:100]
|
| 1445 |
except Exception:
|
| 1446 |
pass
|
| 1447 |
|
|
@@ -1474,9 +1436,9 @@ Return ONLY the rewritten guidance."""
|
|
| 1474 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1475 |
|
| 1476 |
|
| 1477 |
-
#
|
| 1478 |
# Ticket description generation
|
| 1479 |
-
#
|
| 1480 |
@app.post("/generate_ticket_desc")
|
| 1481 |
async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
| 1482 |
try:
|
|
@@ -1489,20 +1451,23 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
|
| 1489 |
"}\n"
|
| 1490 |
"Do not include any extra text, comments, or explanations outside the JSON."
|
| 1491 |
)
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
try:
|
| 1496 |
data = resp.json()
|
| 1497 |
except Exception:
|
| 1498 |
return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
|
|
|
|
| 1499 |
try:
|
| 1500 |
text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
|
| 1501 |
except Exception:
|
| 1502 |
return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
|
|
|
|
| 1503 |
if text.startswith("```"):
|
| 1504 |
lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
|
| 1505 |
text = "\n".join(lines).strip()
|
|
|
|
| 1506 |
try:
|
| 1507 |
ticket_json = json.loads(text)
|
| 1508 |
return {
|
|
@@ -1511,13 +1476,14 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
|
| 1511 |
}
|
| 1512 |
except Exception:
|
| 1513 |
return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
|
|
|
|
| 1514 |
except Exception as e:
|
| 1515 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1516 |
|
| 1517 |
|
| 1518 |
-
#
|
| 1519 |
# Incident status
|
| 1520 |
-
#
|
| 1521 |
@app.post("/incident_status")
|
| 1522 |
async def incident_status(input_data: TicketStatusInput):
|
| 1523 |
try:
|
|
@@ -1525,24 +1491,30 @@ async def incident_status(input_data: TicketStatusInput):
|
|
| 1525 |
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
| 1526 |
if not instance_url:
|
| 1527 |
raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
|
|
|
|
| 1528 |
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
|
|
|
| 1529 |
if input_data.sys_id:
|
| 1530 |
url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
|
| 1531 |
response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
|
| 1532 |
data = response.json()
|
| 1533 |
result = data.get("result", {}) if response.status_code == 200 else {}
|
|
|
|
| 1534 |
elif input_data.number:
|
| 1535 |
url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
|
| 1536 |
response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
|
| 1537 |
data = response.json()
|
| 1538 |
lst = data.get("result", [])
|
| 1539 |
result = (lst or [{}])[0] if response.status_code == 200 else {}
|
|
|
|
| 1540 |
else:
|
| 1541 |
raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
|
|
|
|
| 1542 |
state_code = builtins.str(result.get("state", "unknown"))
|
| 1543 |
state_label = STATE_MAP.get(state_code, state_code)
|
| 1544 |
short = result.get("short_description", "")
|
| 1545 |
number = result.get("number", input_data.number or "unknown")
|
|
|
|
| 1546 |
return {
|
| 1547 |
"bot_response": (
|
| 1548 |
f"**Ticket:** {number} \n"
|
|
@@ -1554,23 +1526,23 @@ async def incident_status(input_data: TicketStatusInput):
|
|
| 1554 |
"persist": True,
|
| 1555 |
"debug": "Incident status fetched",
|
| 1556 |
}
|
|
|
|
| 1557 |
except Exception as e:
|
| 1558 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1559 |
|
| 1560 |
|
| 1561 |
-
#
|
| 1562 |
# Incident creation
|
| 1563 |
-
#
|
| 1564 |
def _classify_resolution_llm(user_message: str) -> bool:
|
| 1565 |
if not GEMINI_API_KEY:
|
| 1566 |
return False
|
| 1567 |
prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
|
| 1568 |
Return only 'true' or 'false'.
|
| 1569 |
Message: {user_message}"""
|
| 1570 |
-
|
| 1571 |
-
payload = {"contents": [{"parts": [{"text": prompt}]}]}
|
| 1572 |
try:
|
| 1573 |
-
resp =
|
| 1574 |
data = resp.json()
|
| 1575 |
text = (
|
| 1576 |
data.get("candidates", [{}])[0]
|
|
@@ -1590,18 +1562,21 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 1590 |
if not instance_url:
|
| 1591 |
print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
|
| 1592 |
return False
|
|
|
|
| 1593 |
headers = {
|
| 1594 |
"Authorization": f"Bearer {token}",
|
| 1595 |
"Accept": "application/json",
|
| 1596 |
"Content-Type": "application/json",
|
| 1597 |
}
|
| 1598 |
url = f"{instance_url}/api/now/table/incident/{sys_id}"
|
|
|
|
| 1599 |
close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
|
| 1600 |
close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
|
| 1601 |
caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
|
| 1602 |
resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
|
| 1603 |
assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
|
| 1604 |
require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
|
|
|
|
| 1605 |
if require_progress:
|
| 1606 |
try:
|
| 1607 |
resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
|
|
@@ -1612,6 +1587,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 1612 |
def clean(d: dict) -> dict:
|
| 1613 |
return {k: v for k, v in d.items() if v is not None}
|
| 1614 |
|
|
|
|
| 1615 |
payload_A = clean({
|
| 1616 |
"state": "6",
|
| 1617 |
"close_code": close_code_val,
|
|
@@ -1627,6 +1603,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 1627 |
return True
|
| 1628 |
print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
|
| 1629 |
|
|
|
|
| 1630 |
payload_B = clean({
|
| 1631 |
"state": "Resolved",
|
| 1632 |
"close_code": close_code_val,
|
|
@@ -1642,6 +1619,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 1642 |
return True
|
| 1643 |
print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
|
| 1644 |
|
|
|
|
| 1645 |
code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
|
| 1646 |
notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
|
| 1647 |
payload_C = clean({
|
|
@@ -1658,7 +1636,9 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 1658 |
if respC.status_code in (200, 204):
|
| 1659 |
return True
|
| 1660 |
print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
|
|
|
|
| 1661 |
return False
|
|
|
|
| 1662 |
except Exception as e:
|
| 1663 |
print(f"[SN PATCH resolve] exception={safe_str(e)}")
|
| 1664 |
return False
|
|
|
|
| 23 |
from public_api.utils import flatten_json, extract_fields
|
| 24 |
from public_api.field_mapping import TOOL_REGISTRY, ALL_TOOLS
|
| 25 |
|
|
|
|
| 26 |
# KB services
|
| 27 |
from services.kb_creation import (
|
| 28 |
collection,
|
|
|
|
| 38 |
from services.login import router as login_router
|
| 39 |
from services.generate_ticket import get_valid_token, create_incident
|
| 40 |
|
| 41 |
+
|
| 42 |
+
# =============================================================================
|
| 43 |
+
# Environment / Gemini config
|
| 44 |
+
# =============================================================================
|
| 45 |
load_dotenv()
|
| 46 |
+
|
| 47 |
VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 48 |
GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 49 |
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
| 50 |
+
|
| 51 |
+
# ✔ Use header-based auth (safer than embedding the key in the URL)
|
| 52 |
+
# You can switch model id if needed (e.g., "gemini-2.5-flash-lite"), but
|
| 53 |
+
# standard "flash" is generally stronger with function-calling.
|
| 54 |
GEMINI_URL = (
|
| 55 |
"https://generativelanguage.googleapis.com/v1beta/models/"
|
| 56 |
+
"gemini-2.5-flash:generateContent"
|
| 57 |
)
|
| 58 |
+
|
| 59 |
os.environ["POSTHOG_DISABLED"] = "true"
|
| 60 |
|
| 61 |
# --- API WMS session cache ---
|
| 62 |
_WMS_SESSION_DATA = None
|
| 63 |
_WMS_WAREHOUSE_ID = None
|
| 64 |
|
| 65 |
+
|
| 66 |
+
def _gemini_post(payload: dict, timeout: int = 25):
|
| 67 |
+
"""
|
| 68 |
+
Central helper to call Gemini with proper headers and SSL behavior.
|
| 69 |
+
"""
|
| 70 |
+
if not GEMINI_API_KEY:
|
| 71 |
+
# Return an object mimicking requests.Response for graceful handling
|
| 72 |
+
return type(
|
| 73 |
+
"Resp",
|
| 74 |
+
(),
|
| 75 |
+
{
|
| 76 |
+
"status_code": 401,
|
| 77 |
+
"json": lambda: {"error": {"message": "Missing GEMINI_API_KEY"}},
|
| 78 |
+
"text": "Missing GEMINI_API_KEY",
|
| 79 |
+
"raise_for_status": lambda: None,
|
| 80 |
+
},
|
| 81 |
+
)()
|
| 82 |
+
headers = {"Content-Type": "application/json", "x-goog-api-key": GEMINI_API_KEY}
|
| 83 |
+
return requests.post(GEMINI_URL, headers=headers, json=payload, timeout=timeout, verify=GEMINI_SSL_VERIFY)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
def _ensure_wms_session() -> bool:
|
| 87 |
"""Login once to WMS and cache session_data + default warehouse."""
|
| 88 |
global _WMS_SESSION_DATA, _WMS_WAREHOUSE_ID
|
|
|
|
| 98 |
print("[WMS] login failed:", e)
|
| 99 |
return False
|
| 100 |
|
| 101 |
+
|
| 102 |
+
# =============================================================================
|
| 103 |
+
# Minimal server-side cache (used to populate short description later)
|
| 104 |
+
# =============================================================================
|
| 105 |
LAST_ISSUE_HINT: str = ""
|
| 106 |
|
| 107 |
|
|
|
|
| 112 |
return "<error stringify failed>"
|
| 113 |
|
| 114 |
|
| 115 |
+
# =============================================================================
|
| 116 |
# App / Lifespan
|
| 117 |
+
# =============================================================================
|
| 118 |
@asynccontextmanager
|
| 119 |
async def lifespan(app: FastAPI):
|
| 120 |
try:
|
|
|
|
| 134 |
|
| 135 |
# CORS
|
| 136 |
origins = [
|
| 137 |
+
"https://chatbotnova-chatbot-frontend.hf.space", # HF frontend Space URL
|
| 138 |
# "http://localhost:5173", # local dev if needed
|
| 139 |
]
|
| 140 |
app.add_middleware(
|
|
|
|
| 145 |
allow_headers=["*"],
|
| 146 |
)
|
| 147 |
|
| 148 |
+
# =============================================================================
|
| 149 |
# Models
|
| 150 |
+
# =============================================================================
|
| 151 |
class ChatInput(BaseModel):
|
| 152 |
user_message: str
|
| 153 |
prev_status: Optional[str] = None
|
|
|
|
| 178 |
"8": "Canceled",
|
| 179 |
}
|
| 180 |
|
| 181 |
+
# =============================================================================
|
| 182 |
# Generic helpers (shared)
|
| 183 |
+
# =============================================================================
|
| 184 |
NUMBERING_STYLE = os.getenv("NUMBERING_STYLE", "digit").lower() # 'digit' or 'step'
|
| 185 |
DOMAIN_STATUS_TERMS = (
|
| 186 |
"shipment", "order", "load", "trailer", "wave",
|
|
|
|
| 188 |
"dock", "door", "manifest", "pallet", "container",
|
| 189 |
"asn", "grn", "pick", "picking",
|
| 190 |
)
|
| 191 |
+
|
| 192 |
ERROR_FAMILY_SYNS = {
|
| 193 |
"NOT_FOUND": (
|
| 194 |
"not found", "missing", "does not exist", "doesn't exist",
|
|
|
|
| 219 |
}
|
| 220 |
|
| 221 |
|
|
|
|
|
|
|
| 222 |
def _try_wms_tool(user_text: str) -> dict | None:
|
| 223 |
"""
|
| 224 |
Uses Gemini function calling + TOOL_REGISTRY to decide if a WMS tool should be called.
|
| 225 |
Returns YOUR existing response dict shape. If no tool match or any error -> returns None.
|
| 226 |
"""
|
|
|
|
| 227 |
# Guards
|
| 228 |
if not GEMINI_API_KEY:
|
| 229 |
return None
|
| 230 |
if not _ensure_wms_session():
|
| 231 |
return None
|
| 232 |
|
| 233 |
+
# Prepare default warehouse from cached session
|
| 234 |
+
session_data = _WMS_SESSION_DATA
|
| 235 |
+
default_wh = session_data.get("user_warehouse_id") if session_data else None
|
| 236 |
+
|
| 237 |
payload = {
|
| 238 |
+
"contents": [{"role": "user", "parts": [{"text": user_text}]}],
|
| 239 |
+
"tools": [{"functionDeclarations": ALL_TOOLS}], # from public_api.field_mapping
|
| 240 |
+
"toolConfig": {"functionCallingConfig": {"mode": "ANY"}}, # nudge function calling
|
|
|
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
try:
|
| 244 |
+
resp = _gemini_post(payload, timeout=25)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
resp.raise_for_status()
|
| 246 |
data = resp.json()
|
| 247 |
|
|
|
|
| 249 |
if not candidates:
|
| 250 |
return None
|
| 251 |
|
|
|
|
| 252 |
content = candidates[0].get("content", {})
|
| 253 |
parts = content.get("parts", [])
|
| 254 |
part = parts[0] if parts else {}
|
|
|
|
|
|
|
| 255 |
fn_call = part.get("functionCall")
|
| 256 |
if not fn_call or "name" not in fn_call:
|
| 257 |
return None
|
|
|
|
| 261 |
|
| 262 |
cfg = TOOL_REGISTRY.get(tool_name)
|
| 263 |
if not cfg:
|
|
|
|
| 264 |
return None
|
| 265 |
|
| 266 |
+
# Resolve warehouse override, else use session default
|
| 267 |
wh = (
|
| 268 |
args.pop("warehouse_id", None)
|
| 269 |
or args.pop("warehouseId", None)
|
| 270 |
or args.pop("wh_id", None)
|
| 271 |
+
or default_wh
|
| 272 |
)
|
| 273 |
|
| 274 |
+
# Get callable
|
| 275 |
tool_fn = cfg.get("function")
|
| 276 |
if not callable(tool_fn):
|
|
|
|
| 277 |
return {
|
| 278 |
"bot_response": f"⚠️ Tool '{tool_name}' is not callable. Check TOOL_REGISTRY['{tool_name}']['function'].",
|
| 279 |
"status": "PARTIAL",
|
|
|
|
| 287 |
"source": "ERROR",
|
| 288 |
}
|
| 289 |
|
| 290 |
+
# ---- Correct invocation according to your real function signatures ----
|
| 291 |
+
# get_inventory_holds(session_data, warehouse_id, **kwargs)
|
| 292 |
+
# get_location_status(session_data, warehouse_id, stoloc)
|
| 293 |
+
# get_item(session_data, warehouse_id, item_number, **kwargs)
|
| 294 |
+
if tool_name == "get_inventory_holds":
|
| 295 |
+
call_kwargs = {**args} # lodnum / subnum / dtlnum
|
| 296 |
+
raw = tool_fn(session_data, wh, **call_kwargs)
|
| 297 |
+
|
| 298 |
+
elif tool_name == "get_location_status":
|
| 299 |
+
stoloc = args.get("stoloc")
|
| 300 |
+
raw = tool_fn(session_data, wh, stoloc)
|
| 301 |
+
|
| 302 |
else:
|
| 303 |
+
id_param = cfg.get("id_param") # "item_number"
|
| 304 |
target = args.get(id_param)
|
| 305 |
if id_param and target is None:
|
| 306 |
return {
|
|
|
|
| 315 |
"debug": {"intent": "wms_missing_id_param", "tool": tool_name},
|
| 316 |
"source": "CLIENT",
|
| 317 |
}
|
| 318 |
+
extra_kwargs = {k: v for k, v in args.items() if k != id_param}
|
| 319 |
+
raw = tool_fn(session_data, wh, target, **extra_kwargs)
|
| 320 |
|
| 321 |
+
# ---- Tool-layer error handling ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
if isinstance(raw, dict) and raw.get("error"):
|
| 323 |
return {
|
| 324 |
"bot_response": f"⚠️ {raw['error']}",
|
|
|
|
| 333 |
"source": "ERROR",
|
| 334 |
}
|
| 335 |
|
| 336 |
+
# ---- Extract rows from response ----
|
| 337 |
response_key = cfg.get("response_key", "data")
|
| 338 |
data_list = (raw.get(response_key, []) if isinstance(raw, dict) else []) or []
|
| 339 |
if not data_list:
|
|
|
|
| 350 |
"source": "LIVE_API",
|
| 351 |
}
|
| 352 |
|
| 353 |
+
# ---- Table rendering (holds + location) ----
|
| 354 |
if tool_name in ("get_inventory_holds", "get_location_status"):
|
| 355 |
+
rows_for_text, total_qty = [], 0
|
|
|
|
|
|
|
| 356 |
for item in data_list:
|
| 357 |
flat = flatten_json(item)
|
| 358 |
row = extract_fields(flat, cfg["mapping"], cfg.get("formatters", {}))
|
| 359 |
rows_for_text.append("• " + "; ".join(f"{k}: {v}" for k, v in row.items()))
|
| 360 |
|
| 361 |
if tool_name == "get_inventory_holds":
|
|
|
|
| 362 |
def _to_int(x):
|
| 363 |
try:
|
| 364 |
return int(x or 0)
|
| 365 |
except Exception:
|
| 366 |
return 0
|
|
|
|
| 367 |
total_qty = sum(_to_int(it.get("untqty", 0)) for it in data_list)
|
| 368 |
msg = f"Found {len(rows_for_text)} hold records (Total Qty: {total_qty}).\n" + "\n".join(rows_for_text[:10])
|
| 369 |
else:
|
|
|
|
| 386 |
"show_export": True if tool_name == "get_inventory_holds" else False,
|
| 387 |
}
|
| 388 |
|
| 389 |
+
# ---- Item summary + one-row table ----
|
| 390 |
flat = flatten_json(data_list[0])
|
|
|
|
| 391 |
requested = args.get("fields", [])
|
| 392 |
if requested:
|
| 393 |
filtered_map = {k: v for k, v in cfg["mapping"].items() if k in requested or k == "Item"}
|
|
|
|
| 418 |
}
|
| 419 |
|
| 420 |
except requests.HTTPError as e:
|
|
|
|
| 421 |
return {
|
| 422 |
"bot_response": f"Gemini API error: {e}",
|
| 423 |
"status": "PARTIAL",
|
|
|
|
| 434 |
print("[WMS] tool call error:", e)
|
| 435 |
return None
|
| 436 |
|
| 437 |
+
|
| 438 |
def _detect_error_families(msg: str) -> list:
|
| 439 |
low = (msg or "").lower()
|
| 440 |
low_norm = re.sub(r"[^\w\s]", " ", low)
|
|
|
|
| 499 |
|
| 500 |
|
| 501 |
def _split_sentences(block: str) -> list:
|
| 502 |
+
parts = [t.strip() for t in re.split(r"(?<=\w[.!?])\s+", block or "") if t.strip()]
|
| 503 |
return parts if parts else ([block.strip()] if (block or "").strip() else [])
|
| 504 |
|
| 505 |
|
|
|
|
| 511 |
para = " ".join(lines).strip()
|
| 512 |
if not para:
|
| 513 |
return ""
|
| 514 |
+
|
| 515 |
+
# Remove existing numbering/bullets to rebuild consistently
|
| 516 |
+
para_clean = re.sub(r"(?:\b\d+\s*[\.\)])\s+", "\n\n\n", para) # 1. / 1)
|
| 517 |
+
para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
|
| 518 |
+
para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:?\s*", "\n\n\n", para_clean) # Step 1:
|
| 519 |
+
|
| 520 |
segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
|
| 521 |
if len(segments) < 2:
|
| 522 |
tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
|
| 523 |
+
segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=\w[.!?])\s+", para) if seg.strip()]
|
| 524 |
|
| 525 |
def strip_prefix_any(s: str) -> str:
|
| 526 |
return re.sub(
|
| 527 |
r"^\s*(?:"
|
| 528 |
+
r"(?:\d+\s*[\.\)])|" # 1. / 1)
|
| 529 |
+
r"(?i:step\s*\d+:?)|" # Step 1:
|
| 530 |
+
r"(?:[-*\u2022])|" # bullets
|
| 531 |
+
r"(?:[\u2460-\u2473])" # circled digits
|
| 532 |
r")\s*",
|
| 533 |
"",
|
| 534 |
(s or "").strip(),
|
| 535 |
)
|
| 536 |
|
| 537 |
clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
|
| 538 |
+
circled = {i: chr(9311 + i) for i in range(1, 21)} # \u2460.. \u2473
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
out = []
|
| 540 |
for idx, seg in enumerate(clean_segments, start=1):
|
| 541 |
marker = circled.get(idx, f"{idx})")
|
|
|
|
| 543 |
return "\n".join(out)
|
| 544 |
|
| 545 |
|
| 546 |
+
def _similarity(a: str, b: str) -> float:
|
| 547 |
+
def _norm_text(s: str) -> str:
|
| 548 |
+
s = (s or "").lower()
|
| 549 |
+
s = re.sub(r"[^\w\s]", " ", s)
|
| 550 |
+
s = re.sub(r"\s+", " ", s).strip()
|
| 551 |
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
|
|
|
|
| 553 |
a_norm, b_norm = _norm_text(a), _norm_text(b)
|
| 554 |
ta, tb = set(a_norm.split()), set(b_norm.split())
|
| 555 |
inter = len(ta & tb)
|
|
|
|
| 563 |
big_inter = len(ab & bb)
|
| 564 |
big_union = len(ab | bb) or 1
|
| 565 |
big = big_inter / big_union
|
| 566 |
+
|
| 567 |
char = SequenceMatcher(None, a_norm, b_norm).ratio()
|
| 568 |
return min(1.0, 0.45 * jacc + 0.30 * big + 0.35 * char)
|
| 569 |
|
| 570 |
|
| 571 |
def _extract_anchor_from_query(msg: str) -> dict:
|
| 572 |
raw = (msg or "").strip()
|
| 573 |
+
low = re.sub(r"[^\w\s]", " ", raw.lower()).strip()
|
| 574 |
+
|
| 575 |
FOLLOWUP_CUES = ("what next", "what is next", "what to do", "then", "after that", "next")
|
| 576 |
has_followup = any(cue in low for cue in FOLLOWUP_CUES)
|
| 577 |
|
|
|
|
| 580 |
return {"anchor": raw, "has_followup": has_followup}
|
| 581 |
|
| 582 |
last = parts[-1]
|
| 583 |
+
last_low = re.sub(r"[^\w\s]", " ", last.lower()).strip()
|
| 584 |
if any(cue in last_low for cue in FOLLOWUP_CUES) and len(parts) >= 2:
|
| 585 |
anchor = parts[-2]
|
| 586 |
else:
|
| 587 |
anchor = parts[-1] if len(parts) > 1 else parts[0]
|
|
|
|
| 588 |
return {"anchor": anchor.strip(), "has_followup": has_followup}
|
| 589 |
|
| 590 |
|
| 591 |
def _anchor_next_steps(user_message: str, numbered_text: str, max_next: int = 8) -> list | None:
|
| 592 |
+
steps = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
|
| 593 |
if not steps:
|
| 594 |
return None
|
| 595 |
|
|
|
|
| 597 |
anchor = info.get("anchor", "").strip()
|
| 598 |
if not anchor:
|
| 599 |
return None
|
|
|
|
|
|
|
| 600 |
|
| 601 |
+
has_followup = bool(info.get("has_followup"))
|
| 602 |
candidates = []
|
| 603 |
for idx, step_line in enumerate(steps):
|
| 604 |
s_full = _similarity(anchor, step_line)
|
|
|
|
| 606 |
scores = [s_full]
|
| 607 |
for s in _split_sentences(step_line):
|
| 608 |
scores.append(_similarity(anchor, s))
|
| 609 |
+
a_flat = re.sub(r"\W+", "", re.sub(r"[^\w\s]", " ", anchor.lower()))
|
| 610 |
+
s_flat = re.sub(r"\W+", "", re.sub(r"[^\w\s]", " ", s.lower()))
|
| 611 |
+
if a_flat and (a_flat in s_flat or s_flat in a_flat):
|
| 612 |
+
literal_hit = True
|
| 613 |
score = max(scores)
|
| 614 |
candidates.append((idx, score, literal_hit))
|
| 615 |
|
| 616 |
candidates.sort(key=lambda t: (t[1], t[0]), reverse=True)
|
| 617 |
best_idx, best_score, best_literal = candidates[0]
|
| 618 |
|
| 619 |
+
tok_count = len([t for t in re.sub(r"[^\w\s]", " ", anchor.lower()).split() if len(t) > 1])
|
| 620 |
if best_literal:
|
| 621 |
accept = True
|
| 622 |
else:
|
| 623 |
base_ok = best_score >= (0.55 if not has_followup else 0.50)
|
| 624 |
len_ok = (best_score >= 0.40) and (tok_count >= 3)
|
| 625 |
accept = base_ok or len_ok
|
| 626 |
+
|
| 627 |
if not accept:
|
| 628 |
return None
|
| 629 |
|
|
|
|
| 647 |
return t
|
| 648 |
|
| 649 |
def _split_sents(ctx: str) -> List[str]:
|
| 650 |
+
raw_sents = re.split(r"(?<=\w[.!?])\s+", ctx or "")
|
| 651 |
return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
|
| 652 |
|
| 653 |
ctx = (context or "").strip()
|
| 654 |
if not ctx or not query:
|
| 655 |
return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
|
| 656 |
+
|
| 657 |
q_norm = _norm(query)
|
| 658 |
q_terms = [t for t in q_norm.split() if len(t) > 2]
|
| 659 |
if not q_terms:
|
|
|
|
| 663 |
matched_exact, matched_any = [], []
|
| 664 |
for s in sentences:
|
| 665 |
s_norm = _norm(s)
|
| 666 |
+
is_bullet = bool(re.match(r"^[-*\u2022]\s*", s))
|
| 667 |
overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
|
| 668 |
if overlap >= STRICT_OVERLAP:
|
| 669 |
matched_exact.append(s)
|
|
|
|
| 675 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 676 |
'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences),
|
| 677 |
}
|
| 678 |
+
|
| 679 |
if matched_any:
|
| 680 |
kept = matched_any[:MAX_SENTENCES_CONCISE]
|
| 681 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 682 |
'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences),
|
| 683 |
}
|
| 684 |
+
|
| 685 |
kept = sentences[:MAX_SENTENCES_CONCISE]
|
| 686 |
return _dedupe_lines("\n".join(kept).strip()), {
|
| 687 |
'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences),
|
|
|
|
| 691 |
def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
| 692 |
kept: List[str] = []
|
| 693 |
for ln in _normalize_lines(text):
|
| 694 |
+
if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
|
| 695 |
kept.append(ln)
|
| 696 |
if len(kept) >= max_lines:
|
| 697 |
break
|
|
|
|
| 719 |
lines = _normalize_lines(text)
|
| 720 |
if not lines:
|
| 721 |
return None
|
| 722 |
+
|
| 723 |
start_idx = None
|
| 724 |
for i, ln in enumerate(lines):
|
| 725 |
low = ln.lower()
|
| 726 |
if "escalation" in low or "escalation path" in low or "escalate" in low:
|
| 727 |
start_idx = i
|
| 728 |
break
|
| 729 |
+
|
| 730 |
block: List[str] = []
|
| 731 |
if start_idx is not None:
|
| 732 |
for j in range(start_idx, min(len(lines), start_idx + 6)):
|
|
|
|
| 737 |
block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
|
| 738 |
if not block:
|
| 739 |
return None
|
| 740 |
+
|
| 741 |
text_block = " ".join(block)
|
| 742 |
m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
|
| 743 |
path = m.group(1).strip() if m else None
|
| 744 |
+
|
| 745 |
if not path:
|
| 746 |
arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
|
| 747 |
if arrow_lines:
|
|
|
|
| 751 |
path = m2.group(1).strip() if m2 else None
|
| 752 |
if not path:
|
| 753 |
return None
|
| 754 |
+
|
| 755 |
path = path.replace("->", "→").strip()
|
| 756 |
path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
|
| 757 |
return f"If you want to escalate the issue, follow: {path}"
|
|
|
|
| 766 |
|
| 767 |
|
| 768 |
def _build_clarifying_message() -> str:
|
| 769 |
+
return (
|
| 770 |
+
"It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
|
| 771 |
+
"or should I raise a ServiceNow ticket for you?"
|
| 772 |
+
)
|
| 773 |
|
| 774 |
|
| 775 |
def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
|
|
|
|
| 782 |
issue = (issue_text or "").strip()
|
| 783 |
resolved = (resolved_text or "").strip()
|
| 784 |
|
| 785 |
+
# Detect process/SOP query
|
| 786 |
is_process_query = bool(re.search(r"\b(how to|steps|procedure|process)\b", issue.lower()))
|
| 787 |
|
|
|
|
| 788 |
if is_process_query:
|
| 789 |
+
# Avoid "Process Steps – Process Steps" duplication
|
| 790 |
cleaned_issue = re.sub(r"^\s*(process\s*steps\s*[-–:]?\s*)", "", issue, flags=re.IGNORECASE).strip()
|
| 791 |
short_desc = f"Process Steps – {cleaned_issue or issue}"[:100]
|
| 792 |
else:
|
|
|
|
| 793 |
short_desc = issue[:100]
|
| 794 |
|
| 795 |
long_desc = (
|
|
|
|
| 800 |
|
| 801 |
return short_desc, long_desc
|
| 802 |
|
| 803 |
+
|
| 804 |
def _is_incident_intent(msg_norm: str) -> bool:
|
| 805 |
intent_phrases = [
|
| 806 |
"create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
|
|
|
|
| 820 |
)
|
| 821 |
if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
|
| 822 |
return {}
|
| 823 |
+
|
| 824 |
patterns = [
|
| 825 |
r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
|
| 826 |
r"(inc\d+)",
|
|
|
|
| 862 |
return ""
|
| 863 |
|
| 864 |
|
| 865 |
+
# =============================================================================
|
| 866 |
# Health
|
| 867 |
+
# =============================================================================
|
| 868 |
@app.get("/")
|
| 869 |
async def health_check():
|
| 870 |
return {"status": "ok"}
|
| 871 |
|
| 872 |
|
| 873 |
+
# =============================================================================
|
| 874 |
# Chat
|
| 875 |
+
# =============================================================================
|
| 876 |
@app.post("/chat")
|
| 877 |
async def chat_with_ai(input_data: ChatInput):
|
| 878 |
+
global LAST_ISSUE_HINT # ensure global declared before any assignment/use
|
| 879 |
assist_followup: Optional[str] = None
|
| 880 |
+
|
| 881 |
try:
|
| 882 |
msg_norm = (input_data.user_message or "").lower().strip()
|
| 883 |
|
|
|
|
| 890 |
"options": [],
|
| 891 |
"debug": {"intent": "continue_conversation"},
|
| 892 |
}
|
| 893 |
+
|
| 894 |
if msg_norm in ("no", "no thanks", "nope"):
|
| 895 |
return {
|
| 896 |
"bot_response": "No problem. Do you need assistance with any other issue?",
|
|
|
|
| 905 |
is_llm_resolved = _classify_resolution_llm(input_data.user_message)
|
| 906 |
if _has_negation_resolved(msg_norm):
|
| 907 |
is_llm_resolved = False
|
| 908 |
+
|
| 909 |
if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
|
| 910 |
try:
|
| 911 |
# Prefer cached issue hint if frontend didn't pass last_issue
|
|
|
|
| 996 |
"sources": [],
|
| 997 |
"debug": {"intent": "status_request_missing_id"},
|
| 998 |
}
|
| 999 |
+
|
| 1000 |
try:
|
| 1001 |
token = get_valid_token()
|
| 1002 |
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
|
|
|
| 1031 |
}
|
| 1032 |
except Exception as e:
|
| 1033 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1034 |
+
|
| 1035 |
+
# --- Try WMS API tools BEFORE KB search ---
|
| 1036 |
res = _try_wms_tool(input_data.user_message)
|
| 1037 |
if res is not None:
|
| 1038 |
+
return res
|
|
|
|
| 1039 |
|
| 1040 |
+
# ---------------- Hybrid KB search ----------------
|
| 1041 |
kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
|
| 1042 |
documents = kb_results.get("documents", [])
|
| 1043 |
metadatas = kb_results.get("metadatas", [])
|
|
|
|
| 1061 |
|
| 1062 |
selected = items[:max(1, 2)]
|
| 1063 |
context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
|
| 1064 |
+
|
| 1065 |
filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
|
| 1066 |
context = filtered_text
|
| 1067 |
context_found = bool(context.strip())
|
| 1068 |
|
| 1069 |
best_distance = (min([d for d in distances if d is not None], default=None) if distances else None)
|
| 1070 |
best_combined = (max([c for c in combined if c is not None], default=None) if combined else None)
|
|
|
|
| 1071 |
detected_intent = kb_results.get("user_intent", "neutral")
|
| 1072 |
best_doc = kb_results.get("best_doc")
|
| 1073 |
top_meta = (metadatas or [{}])[0] if metadatas else {}
|
| 1074 |
+
|
| 1075 |
msg_low = (input_data.user_message or "").lower()
|
| 1076 |
GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
|
| 1077 |
generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
|
|
|
|
| 1095 |
detected_intent = "errors"
|
| 1096 |
|
| 1097 |
# heading-aware prereq nudge
|
| 1098 |
+
sec_title = (top_meta or {}).get("section", "") or ""
|
| 1099 |
+
sec_title_low = sec_title.strip().lower()
|
| 1100 |
PREREQ_HEADINGS = ("pre-requisites", "prerequisites", "pre requisites", "pre-requirements", "requirements")
|
| 1101 |
+
if detected_intent == "neutral" and any(h in sec_title_low for h in PREREQ_HEADINGS):
|
| 1102 |
detected_intent = "prereqs"
|
| 1103 |
|
|
|
|
| 1104 |
def _contains_any(s: str, keywords: tuple) -> bool:
|
| 1105 |
low = (s or "").lower()
|
| 1106 |
return any(k in low for k in keywords)
|
|
|
|
| 1116 |
"confirm", "generate", "update", "receive", "receiving", "error", "issue", "fail", "failed",
|
| 1117 |
"not working", "locked", "mismatch", "access", "permission", "status",
|
| 1118 |
)
|
| 1119 |
+
|
| 1120 |
matched_count = int(filt_info.get("matched_count") or 0)
|
| 1121 |
filter_mode = (filt_info.get("mode") or "").lower()
|
| 1122 |
has_any_action_or_error = _contains_any(msg_low, ACTION_OR_ERROR_TERMS)
|
| 1123 |
mentions_domain = _contains_any(msg_low, DOMAIN_TERMS)
|
| 1124 |
+
|
| 1125 |
short_query = len((input_data.user_message or "").split()) <= 4
|
| 1126 |
gate_combined_ok = 0.60 if short_query else 0.55
|
| 1127 |
combined_ok = (best_combined is not None and best_combined >= gate_combined_ok)
|
| 1128 |
+
|
| 1129 |
weak_domain_only = (mentions_domain and not has_any_action_or_error)
|
| 1130 |
low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
|
| 1131 |
strong_steps_bypass = True
|
|
|
|
| 1157 |
},
|
| 1158 |
}
|
| 1159 |
|
| 1160 |
+
# Build SOP/Errors/Prereqs context
|
| 1161 |
escalation_line: Optional[str] = None
|
| 1162 |
full_errors: Optional[str] = None
|
| 1163 |
next_step_applied = False
|
|
|
|
| 1171 |
full_steps = get_section_text(best_doc, sec)
|
| 1172 |
else:
|
| 1173 |
full_steps = get_best_steps_section_text(best_doc)
|
|
|
|
| 1174 |
if full_steps:
|
| 1175 |
numbered_full = _ensure_numbering(full_steps)
|
| 1176 |
|
| 1177 |
raw_actions = set((kb_results.get("actions") or []))
|
| 1178 |
msg_low2 = (input_data.user_message or "").lower()
|
|
|
|
| 1179 |
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):
|
| 1180 |
raw_actions = {"create"}
|
| 1181 |
elif not raw_actions and ("update" in msg_low2 or "modify" in msg_low2 or "edit" in msg_low2 or "change" in msg_low2):
|
|
|
|
| 1210 |
numbered_full = before
|
| 1211 |
|
| 1212 |
next_only = _anchor_next_steps(input_data.user_message, numbered_full, max_next=6)
|
|
|
|
| 1213 |
if next_only is not None:
|
| 1214 |
if len(next_only) == 0:
|
| 1215 |
context = "You are at the final step of this SOP. No further steps."
|
|
|
|
| 1217 |
next_step_info = {"count": 0}
|
| 1218 |
context_preformatted = True
|
| 1219 |
else:
|
| 1220 |
+
context = _dedupe_lines(_ensure_numbering("\n".join(next_only)))
|
|
|
|
| 1221 |
next_step_applied = True
|
| 1222 |
next_step_info = {"count": len(next_only)}
|
| 1223 |
context_preformatted = True
|
|
|
|
| 1237 |
flags=re.IGNORECASE,
|
| 1238 |
))
|
| 1239 |
)
|
|
|
|
| 1240 |
if said_not_resolved:
|
| 1241 |
return {
|
| 1242 |
+
"bot_response": "Select an option below.",
|
| 1243 |
"status": "OK",
|
| 1244 |
"context_found": False,
|
| 1245 |
"ask_resolved": False,
|
|
|
|
| 1250 |
"top_hits": [],
|
| 1251 |
"sources": [],
|
| 1252 |
"debug": {
|
| 1253 |
+
"intent": "errors_not_resolved",
|
| 1254 |
+
"best_doc": best_doc,
|
| 1255 |
},
|
| 1256 |
}
|
| 1257 |
|
| 1258 |
full_errors = get_best_errors_section_text(best_doc)
|
| 1259 |
if full_errors:
|
| 1260 |
ctx_err = _extract_errors_only(full_errors, max_lines=30)
|
|
|
|
| 1261 |
if is_perm_query:
|
| 1262 |
context = _filter_permission_lines(ctx_err, max_lines=6)
|
| 1263 |
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1264 |
mentions_domain_local = any(t in msg_low for t in DOMAIN_TERMS)
|
|
|
|
| 1265 |
is_specific_error = (len(_detect_error_families(msg_low)) > 0) or mentions_domain_local
|
| 1266 |
if is_specific_error:
|
| 1267 |
context = _filter_context_for_query(ctx_err, input_data.user_message)[0]
|
|
|
|
| 1269 |
all_lines = _normalize_lines(ctx_err)
|
| 1270 |
error_bullets = [
|
| 1271 |
ln for ln in all_lines
|
| 1272 |
+
if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln)
|
| 1273 |
]
|
| 1274 |
context = "\n".join(error_bullets[:6]).strip()
|
| 1275 |
else:
|
| 1276 |
all_lines = _normalize_lines(ctx_err)
|
| 1277 |
error_bullets = [
|
| 1278 |
ln for ln in all_lines
|
| 1279 |
+
if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln)
|
| 1280 |
]
|
| 1281 |
context = "\n".join(error_bullets[:6]).strip()
|
| 1282 |
|
|
|
|
| 1293 |
numbered_steps = _ensure_numbering(steps_src)
|
| 1294 |
next_only = _anchor_next_steps(input_data.user_message, numbered_steps, max_next=6)
|
| 1295 |
if next_only is not None and len(next_only) > 0:
|
| 1296 |
+
context = _dedupe_lines(_ensure_numbering("\n".join(next_only)))
|
|
|
|
| 1297 |
context_preformatted = True
|
| 1298 |
next_step_applied = True
|
| 1299 |
next_step_info = {"count": len(next_only), "source": "errors_domain_override"}
|
|
|
|
| 1307 |
context = full_prereqs.strip()
|
| 1308 |
context_found = True
|
| 1309 |
|
| 1310 |
+
# language hint & paraphrase (errors only when not overridden)
|
| 1311 |
language_hint = _detect_language_hint(input_data.user_message)
|
| 1312 |
lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
|
| 1313 |
use_gemini = (detected_intent == "errors") and not steps_override_applied
|
| 1314 |
+
|
| 1315 |
enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
|
| 1316 |
- Do not add any information that is not present in the context.
|
| 1317 |
- If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
|
|
|
|
| 1322 |
{input_data.user_message}
|
| 1323 |
### Output
|
| 1324 |
Return ONLY the rewritten guidance."""
|
|
|
|
|
|
|
| 1325 |
bot_text = ""
|
| 1326 |
http_code = 0
|
| 1327 |
|
| 1328 |
if use_gemini and GEMINI_API_KEY:
|
| 1329 |
try:
|
| 1330 |
+
payload = {"contents": [{"role": "user", "parts": [{"text": enhanced_prompt}]}]}
|
| 1331 |
+
resp = _gemini_post(payload, timeout=25)
|
| 1332 |
http_code = getattr(resp, "status_code", 0)
|
| 1333 |
try:
|
| 1334 |
result = resp.json()
|
|
|
|
| 1343 |
|
| 1344 |
# deterministic local formatting
|
| 1345 |
if detected_intent == "steps":
|
| 1346 |
+
bot_text = context if context_preformatted else _ensure_numbering(context)
|
|
|
|
|
|
|
|
|
|
| 1347 |
elif detected_intent == "errors":
|
| 1348 |
if not (bot_text or "").strip() or http_code == 429:
|
| 1349 |
bot_text = context.strip()
|
|
|
|
| 1375 |
short_query = len((input_data.user_message or "").split()) <= 4
|
| 1376 |
gate_combined_ok = 0.60 if short_query else 0.55
|
| 1377 |
status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
|
| 1378 |
+
|
| 1379 |
lower = (bot_text or "").lower()
|
| 1380 |
if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
|
| 1381 |
status = "PARTIAL"
|
| 1382 |
|
| 1383 |
options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
|
| 1384 |
|
| 1385 |
+
# --- Cache last issue hint (used if user later says "issue resolved thanks")
|
|
|
|
| 1386 |
try:
|
| 1387 |
+
base_query = (input_data.user_message or "").strip()
|
| 1388 |
+
if detected_intent == "steps":
|
| 1389 |
+
LAST_ISSUE_HINT = base_query[:100]
|
| 1390 |
+
elif detected_intent == "errors":
|
| 1391 |
+
shown_lines = [ln.strip() for ln in (bot_text or "").splitlines() if ln.strip()]
|
| 1392 |
+
top_error_line = ""
|
| 1393 |
+
for ln in shown_lines:
|
| 1394 |
+
if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
|
| 1395 |
+
low = ln.lower()
|
| 1396 |
+
if "escalation" in low:
|
| 1397 |
+
continue # skip escalation sentences
|
| 1398 |
+
top_error_line = ln
|
| 1399 |
+
break
|
| 1400 |
+
if top_error_line:
|
| 1401 |
+
top_error_line = re.split(r"\bif you want to escalate\b", top_error_line, flags=re.IGNORECASE)[0].strip()
|
| 1402 |
+
if top_error_line:
|
| 1403 |
+
LAST_ISSUE_HINT = f"{base_query} — {top_error_line}"
|
| 1404 |
+
else:
|
| 1405 |
+
LAST_ISSUE_HINT = base_query
|
| 1406 |
+
LAST_ISSUE_HINT = LAST_ISSUE_HINT[:100]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1407 |
except Exception:
|
| 1408 |
pass
|
| 1409 |
|
|
|
|
| 1436 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1437 |
|
| 1438 |
|
| 1439 |
+
# =============================================================================
|
| 1440 |
# Ticket description generation
|
| 1441 |
+
# =============================================================================
|
| 1442 |
@app.post("/generate_ticket_desc")
|
| 1443 |
async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
| 1444 |
try:
|
|
|
|
| 1451 |
"}\n"
|
| 1452 |
"Do not include any extra text, comments, or explanations outside the JSON."
|
| 1453 |
)
|
| 1454 |
+
payload = {"contents": [{"role": "user", "parts": [{"text": prompt}]}]}
|
| 1455 |
+
resp = _gemini_post(payload, timeout=25)
|
| 1456 |
+
|
| 1457 |
try:
|
| 1458 |
data = resp.json()
|
| 1459 |
except Exception:
|
| 1460 |
return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
|
| 1461 |
+
|
| 1462 |
try:
|
| 1463 |
text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "").strip()
|
| 1464 |
except Exception:
|
| 1465 |
return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
|
| 1466 |
+
|
| 1467 |
if text.startswith("```"):
|
| 1468 |
lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
|
| 1469 |
text = "\n".join(lines).strip()
|
| 1470 |
+
|
| 1471 |
try:
|
| 1472 |
ticket_json = json.loads(text)
|
| 1473 |
return {
|
|
|
|
| 1476 |
}
|
| 1477 |
except Exception:
|
| 1478 |
return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
|
| 1479 |
+
|
| 1480 |
except Exception as e:
|
| 1481 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1482 |
|
| 1483 |
|
| 1484 |
+
# =============================================================================
|
| 1485 |
# Incident status
|
| 1486 |
+
# =============================================================================
|
| 1487 |
@app.post("/incident_status")
|
| 1488 |
async def incident_status(input_data: TicketStatusInput):
|
| 1489 |
try:
|
|
|
|
| 1491 |
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
| 1492 |
if not instance_url:
|
| 1493 |
raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
|
| 1494 |
+
|
| 1495 |
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
| 1496 |
+
|
| 1497 |
if input_data.sys_id:
|
| 1498 |
url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
|
| 1499 |
response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
|
| 1500 |
data = response.json()
|
| 1501 |
result = data.get("result", {}) if response.status_code == 200 else {}
|
| 1502 |
+
|
| 1503 |
elif input_data.number:
|
| 1504 |
url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
|
| 1505 |
response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
|
| 1506 |
data = response.json()
|
| 1507 |
lst = data.get("result", [])
|
| 1508 |
result = (lst or [{}])[0] if response.status_code == 200 else {}
|
| 1509 |
+
|
| 1510 |
else:
|
| 1511 |
raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
|
| 1512 |
+
|
| 1513 |
state_code = builtins.str(result.get("state", "unknown"))
|
| 1514 |
state_label = STATE_MAP.get(state_code, state_code)
|
| 1515 |
short = result.get("short_description", "")
|
| 1516 |
number = result.get("number", input_data.number or "unknown")
|
| 1517 |
+
|
| 1518 |
return {
|
| 1519 |
"bot_response": (
|
| 1520 |
f"**Ticket:** {number} \n"
|
|
|
|
| 1526 |
"persist": True,
|
| 1527 |
"debug": "Incident status fetched",
|
| 1528 |
}
|
| 1529 |
+
|
| 1530 |
except Exception as e:
|
| 1531 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1532 |
|
| 1533 |
|
| 1534 |
+
# =============================================================================
|
| 1535 |
# Incident creation
|
| 1536 |
+
# =============================================================================
|
| 1537 |
def _classify_resolution_llm(user_message: str) -> bool:
|
| 1538 |
if not GEMINI_API_KEY:
|
| 1539 |
return False
|
| 1540 |
prompt = f"""Classify if the following user message indicates that the issue is resolved or working now.
|
| 1541 |
Return only 'true' or 'false'.
|
| 1542 |
Message: {user_message}"""
|
| 1543 |
+
payload = {"contents": [{"role": "user", "parts": [{"text": prompt}]}]}
|
|
|
|
| 1544 |
try:
|
| 1545 |
+
resp = _gemini_post(payload, timeout=12)
|
| 1546 |
data = resp.json()
|
| 1547 |
text = (
|
| 1548 |
data.get("candidates", [{}])[0]
|
|
|
|
| 1562 |
if not instance_url:
|
| 1563 |
print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
|
| 1564 |
return False
|
| 1565 |
+
|
| 1566 |
headers = {
|
| 1567 |
"Authorization": f"Bearer {token}",
|
| 1568 |
"Accept": "application/json",
|
| 1569 |
"Content-Type": "application/json",
|
| 1570 |
}
|
| 1571 |
url = f"{instance_url}/api/now/table/incident/{sys_id}"
|
| 1572 |
+
|
| 1573 |
close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
|
| 1574 |
close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
|
| 1575 |
caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
|
| 1576 |
resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
|
| 1577 |
assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
|
| 1578 |
require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
|
| 1579 |
+
|
| 1580 |
if require_progress:
|
| 1581 |
try:
|
| 1582 |
resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
|
|
|
|
| 1587 |
def clean(d: dict) -> dict:
|
| 1588 |
return {k: v for k, v in d.items() if v is not None}
|
| 1589 |
|
| 1590 |
+
# Attempt A: numeric state
|
| 1591 |
payload_A = clean({
|
| 1592 |
"state": "6",
|
| 1593 |
"close_code": close_code_val,
|
|
|
|
| 1603 |
return True
|
| 1604 |
print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
|
| 1605 |
|
| 1606 |
+
# Attempt B: string state (some instances prefer labels)
|
| 1607 |
payload_B = clean({
|
| 1608 |
"state": "Resolved",
|
| 1609 |
"close_code": close_code_val,
|
|
|
|
| 1619 |
return True
|
| 1620 |
print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
|
| 1621 |
|
| 1622 |
+
# Attempt C: configurable field names
|
| 1623 |
code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
|
| 1624 |
notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
|
| 1625 |
payload_C = clean({
|
|
|
|
| 1636 |
if respC.status_code in (200, 204):
|
| 1637 |
return True
|
| 1638 |
print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
|
| 1639 |
+
|
| 1640 |
return False
|
| 1641 |
+
|
| 1642 |
except Exception as e:
|
| 1643 |
print(f"[SN PATCH resolve] exception={safe_str(e)}")
|
| 1644 |
return False
|