Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -21,7 +21,10 @@ from services.login import router as login_router
|
|
| 21 |
from services.generate_ticket import get_valid_token, create_incident
|
| 22 |
|
| 23 |
VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def safe_str(e: Any) -> str:
|
| 27 |
try:
|
|
@@ -53,7 +56,9 @@ app.add_middleware(
|
|
| 53 |
allow_headers=["*"],
|
| 54 |
)
|
| 55 |
|
| 56 |
-
# ---
|
|
|
|
|
|
|
| 57 |
class ChatInput(BaseModel):
|
| 58 |
user_message: str
|
| 59 |
prev_status: Optional[str] = None
|
|
@@ -71,7 +76,9 @@ class TicketStatusInput(BaseModel):
|
|
| 71 |
sys_id: Optional[str] = None
|
| 72 |
number: Optional[str] = None
|
| 73 |
|
| 74 |
-
# ---
|
|
|
|
|
|
|
| 75 |
STRICT_OVERLAP = 3
|
| 76 |
MAX_SENTENCES_CONCISE = 6
|
| 77 |
|
|
@@ -177,7 +184,7 @@ def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
|
| 177 |
break
|
| 178 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 179 |
|
| 180 |
-
# ---- Escalation extractor
|
| 181 |
ESCALATION_REGEX = re.compile(r"^\s*Escalation Path|\s+→\s+", re.IGNORECASE)
|
| 182 |
|
| 183 |
def _extract_escalation_only(text: str, max_lines: int = 3) -> str:
|
|
@@ -216,7 +223,9 @@ def _merge_number_only_lines(lines: List[str]) -> List[str]:
|
|
| 216 |
def _strip_leading_mark(s: str) -> str:
|
| 217 |
return re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", (s or "")).strip()
|
| 218 |
|
| 219 |
-
# ---
|
|
|
|
|
|
|
| 220 |
def _is_incident_intent(msg_norm: str) -> bool:
|
| 221 |
intents = [
|
| 222 |
"create ticket","create a ticket","raise ticket","raise a ticket","open ticket","open a ticket",
|
|
@@ -232,79 +241,122 @@ def _is_generic_issue(msg_norm: str) -> bool:
|
|
| 232 |
"need support","please help","need assistance","assist me","facing issue","facing a problem","got a problem"
|
| 233 |
]
|
| 234 |
return any(p == msg_norm or p in msg_norm for p in generic) or len(msg_norm.split()) <= 2
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
def _compose_errors_response(error_lines: List[str], escalation_lines: List[str]) -> str:
|
| 237 |
-
"""
|
| 238 |
-
Turn SOP 'Common Errors & Resolution' + 'Escalation Path' into a friendly,
|
| 239 |
-
user-understandable reply.
|
| 240 |
-
"""
|
| 241 |
err = [ln.strip() for ln in (error_lines or []) if ln and ln.strip()]
|
| 242 |
esc = [ln.strip() for ln in (escalation_lines or []) if ln and ln.strip()]
|
| 243 |
|
| 244 |
-
# 1) A one-line summary when permission/auth appears in the error lines
|
| 245 |
summary = None
|
| 246 |
joined = " ".join(err).lower()
|
| 247 |
-
if any(k in joined for k in ["permission",
|
| 248 |
summary = (
|
| 249 |
"It looks like your role doesn’t allow Inventory Adjustment right now. "
|
| 250 |
"Please verify you have the required authorization/role enabled. "
|
| 251 |
"If you still cannot proceed, follow the escalation path below."
|
| 252 |
)
|
| 253 |
|
| 254 |
-
|
| 255 |
-
actions = []
|
| 256 |
for ln in err:
|
| 257 |
s = re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", ln).strip()
|
| 258 |
-
# Light rewrite for common phrases
|
| 259 |
s = s.replace("Verify role access", "Verify your role/access in WMS")
|
| 260 |
s = s.replace("Check if cycle count or task is active", "Check if any cycle count/task is locking the location")
|
| 261 |
s = s.replace("Confirm unit of measure setup", "Confirm the item’s UOM setup in master data")
|
| 262 |
-
s = s.replace("Adjustment not allowed:", "Adjustment not allowed:")
|
| 263 |
if s:
|
| 264 |
actions.append(f"• {s}")
|
| 265 |
|
| 266 |
-
# 3) Escalation line (if present)
|
| 267 |
esc_text = ""
|
| 268 |
if esc:
|
| 269 |
-
# pick the first escalation line and strip decorative arrows
|
| 270 |
raw = esc[0]
|
| 271 |
-
clean = re.sub(r"^\s*Escalation Path\s*:\s*", "", raw, flags=re.IGNORECASE)
|
| 272 |
-
clean = clean.replace("→", "→").strip()
|
| 273 |
esc_text = f"\n\n**Escalation Path:** {clean}"
|
| 274 |
|
| 275 |
-
# Compose
|
| 276 |
top = summary or "Here’s how to resolve this:"
|
| 277 |
bullet_block = ("\n" + "\n".join(actions)) if actions else ""
|
| 278 |
return f"{top}{bullet_block}{esc_text}".strip()
|
| 279 |
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
Return (error_lines, escalation_lines) from the already-filtered SOP context.
|
| 284 |
-
"""
|
| 285 |
-
lines = [ln.strip() for ln in (context or "").splitlines() if ln.strip()]
|
| 286 |
-
error_lines, escalation_lines = [], []
|
| 287 |
-
for ln in lines:
|
| 288 |
-
# Collect typical error/resolution bullets
|
| 289 |
-
if STEP_LINE_REGEX.match(ln) or ln.lower().startswith(("error", "resolution", "fix", "verify", "check", "not allowed")):
|
| 290 |
-
error_lines.append(ln)
|
| 291 |
-
# Collect escalation
|
| 292 |
-
if re.search(r"Escalation Path", ln, flags=re.IGNORECASE) or ("→" in ln):
|
| 293 |
-
escalation_lines.append(ln)
|
| 294 |
-
return error_lines, escalation_lines
|
| 295 |
-
|
| 296 |
-
|
| 297 |
@app.get("/")
|
| 298 |
async def health_check():
|
| 299 |
return {"status": "ok"}
|
| 300 |
|
|
|
|
|
|
|
|
|
|
| 301 |
@app.post("/chat")
|
| 302 |
async def chat_with_ai(input_data: ChatInput):
|
| 303 |
try:
|
| 304 |
msg_norm = (input_data.user_message or "").lower().strip()
|
| 305 |
|
| 306 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
if _is_incident_intent(msg_norm):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
return {
|
| 309 |
"bot_response": (
|
| 310 |
"Okay, let’s create a ServiceNow incident.\n\n"
|
|
@@ -312,10 +364,12 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 312 |
"• Detailed Description (steps, error text, IDs, site, environment)"
|
| 313 |
),
|
| 314 |
"status": (input_data.prev_status or "PARTIAL"),
|
| 315 |
-
"context_found": False, "ask_resolved": False, "suggest_incident":
|
| 316 |
"show_incident_form": True, "followup": None, "top_hits": [], "sources": [],
|
| 317 |
"debug": {"intent": "create_ticket"},
|
| 318 |
}
|
|
|
|
|
|
|
| 319 |
if _is_generic_issue(msg_norm):
|
| 320 |
return {
|
| 321 |
"bot_response": (
|
|
@@ -332,7 +386,58 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 332 |
"debug": {"intent": "generic_issue"},
|
| 333 |
}
|
| 334 |
|
| 335 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
|
| 337 |
|
| 338 |
# Build a small context window
|
|
@@ -395,7 +500,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 395 |
high_conf = (best_combined is not None and best_combined >= gate_combined_ok)
|
| 396 |
exact = bool(exact_by_filter or high_conf)
|
| 397 |
|
| 398 |
-
# --- STEPS intent: full SOP steps (numbered)
|
| 399 |
if detected_intent == "steps" and best_doc:
|
| 400 |
full_steps = get_best_steps_section_text(best_doc)
|
| 401 |
if not full_steps:
|
|
@@ -433,43 +538,39 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 433 |
},
|
| 434 |
}
|
| 435 |
|
| 436 |
-
# --- Non-steps intents
|
| 437 |
context = filtered_text
|
| 438 |
if detected_intent == "errors":
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
status = "OK" if exact else "PARTIAL"
|
| 447 |
-
return {
|
| 448 |
"bot_response": bot_text,
|
| 449 |
"status": status,
|
| 450 |
"context_found": True,
|
| 451 |
"ask_resolved": (status == "OK"),
|
| 452 |
"suggest_incident": False,
|
| 453 |
"followup": ("Does this help? I can refine." if status == "PARTIAL" else None),
|
| 454 |
-
"top_hits": [],
|
| 455 |
-
"sources": [],
|
| 456 |
"debug": {
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
elif "navigate" in msg_norm or "menu" in msg_norm or "screen" in msg_norm:
|
| 470 |
context = _extract_steps_only(context, max_lines=MAX_SENTENCES_CONCISE)
|
| 471 |
-
|
| 472 |
-
# No-KB gate
|
| 473 |
if (not context.strip()) or (
|
| 474 |
(best_combined is None or best_combined < gate_combined_no_kb) and (best_distance is None or best_distance >= gate_distance_no_kb)
|
| 475 |
):
|
|
@@ -493,7 +594,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 493 |
"debug": {"used_chunks": 0, "second_try": second_try, "best_distance": best_distance, "best_combined": best_combined},
|
| 494 |
}
|
| 495 |
|
| 496 |
-
# Default response for non-steps
|
| 497 |
bot_text = context.strip()
|
| 498 |
status = "OK" if exact else "PARTIAL"
|
| 499 |
return {
|
|
@@ -517,3 +618,83 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 517 |
raise
|
| 518 |
except Exception as e:
|
| 519 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
from services.generate_ticket import get_valid_token, create_incident
|
| 22 |
|
| 23 |
VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 24 |
+
|
| 25 |
+
# --------------------------------------------------------
|
| 26 |
+
# Utilities
|
| 27 |
+
# --------------------------------------------------------
|
| 28 |
|
| 29 |
def safe_str(e: Any) -> str:
|
| 30 |
try:
|
|
|
|
| 56 |
allow_headers=["*"],
|
| 57 |
)
|
| 58 |
|
| 59 |
+
# --------------------------------------------------------
|
| 60 |
+
# Models
|
| 61 |
+
# --------------------------------------------------------
|
| 62 |
class ChatInput(BaseModel):
|
| 63 |
user_message: str
|
| 64 |
prev_status: Optional[str] = None
|
|
|
|
| 76 |
sys_id: Optional[str] = None
|
| 77 |
number: Optional[str] = None
|
| 78 |
|
| 79 |
+
# --------------------------------------------------------
|
| 80 |
+
# KB filters & extractors (no LLM)
|
| 81 |
+
# --------------------------------------------------------
|
| 82 |
STRICT_OVERLAP = 3
|
| 83 |
MAX_SENTENCES_CONCISE = 6
|
| 84 |
|
|
|
|
| 184 |
break
|
| 185 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 186 |
|
| 187 |
+
# ---- Escalation extractor ----
|
| 188 |
ESCALATION_REGEX = re.compile(r"^\s*Escalation Path|\s+→\s+", re.IGNORECASE)
|
| 189 |
|
| 190 |
def _extract_escalation_only(text: str, max_lines: int = 3) -> str:
|
|
|
|
| 223 |
def _strip_leading_mark(s: str) -> str:
|
| 224 |
return re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", (s or "")).strip()
|
| 225 |
|
| 226 |
+
# --------------------------------------------------------
|
| 227 |
+
# Conversation intents & helpers
|
| 228 |
+
# --------------------------------------------------------
|
| 229 |
def _is_incident_intent(msg_norm: str) -> bool:
|
| 230 |
intents = [
|
| 231 |
"create ticket","create a ticket","raise ticket","raise a ticket","open ticket","open a ticket",
|
|
|
|
| 241 |
"need support","please help","need assistance","assist me","facing issue","facing a problem","got a problem"
|
| 242 |
]
|
| 243 |
return any(p == msg_norm or p in msg_norm for p in generic) or len(msg_norm.split()) <= 2
|
| 244 |
+
|
| 245 |
+
def _is_negative_feedback(msg_norm: str) -> bool:
|
| 246 |
+
phrases = [
|
| 247 |
+
"not resolved","issue not resolved","still not working","not working",
|
| 248 |
+
"didn't work","doesn't work","no change","not fixed","still failing",
|
| 249 |
+
"failed again","broken","fail","not solved","same issue"
|
| 250 |
+
]
|
| 251 |
+
return any(p in msg_norm for p in phrases)
|
| 252 |
+
|
| 253 |
+
# Parse status intent inside chat (e.g., "status INC0012345")
|
| 254 |
+
def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
| 255 |
+
keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
|
| 256 |
+
if not any(k in msg_norm for k in keywords):
|
| 257 |
+
return {}
|
| 258 |
+
# Try to capture INC number
|
| 259 |
+
m = re.search(r"(inc\d+)", msg_norm, flags=re.IGNORECASE)
|
| 260 |
+
if m:
|
| 261 |
+
val = m.group(1).strip().upper()
|
| 262 |
+
return {"number": val}
|
| 263 |
+
# Ask the user for number if not present
|
| 264 |
+
return {"ask_number": True}
|
| 265 |
+
|
| 266 |
+
# --------------------------------------------------------
|
| 267 |
+
# Error/escalation friendly composition
|
| 268 |
+
# --------------------------------------------------------
|
| 269 |
def _compose_errors_response(error_lines: List[str], escalation_lines: List[str]) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
err = [ln.strip() for ln in (error_lines or []) if ln and ln.strip()]
|
| 271 |
esc = [ln.strip() for ln in (escalation_lines or []) if ln and ln.strip()]
|
| 272 |
|
|
|
|
| 273 |
summary = None
|
| 274 |
joined = " ".join(err).lower()
|
| 275 |
+
if any(k in joined for k in ["permission","authorized","authorization","role access","not allowed","insufficient"]):
|
| 276 |
summary = (
|
| 277 |
"It looks like your role doesn’t allow Inventory Adjustment right now. "
|
| 278 |
"Please verify you have the required authorization/role enabled. "
|
| 279 |
"If you still cannot proceed, follow the escalation path below."
|
| 280 |
)
|
| 281 |
|
| 282 |
+
actions: List[str] = []
|
|
|
|
| 283 |
for ln in err:
|
| 284 |
s = re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", ln).strip()
|
|
|
|
| 285 |
s = s.replace("Verify role access", "Verify your role/access in WMS")
|
| 286 |
s = s.replace("Check if cycle count or task is active", "Check if any cycle count/task is locking the location")
|
| 287 |
s = s.replace("Confirm unit of measure setup", "Confirm the item’s UOM setup in master data")
|
|
|
|
| 288 |
if s:
|
| 289 |
actions.append(f"• {s}")
|
| 290 |
|
|
|
|
| 291 |
esc_text = ""
|
| 292 |
if esc:
|
|
|
|
| 293 |
raw = esc[0]
|
| 294 |
+
clean = re.sub(r"^\s*Escalation Path\s*:?\s*", "", raw, flags=re.IGNORECASE).strip()
|
|
|
|
| 295 |
esc_text = f"\n\n**Escalation Path:** {clean}"
|
| 296 |
|
|
|
|
| 297 |
top = summary or "Here’s how to resolve this:"
|
| 298 |
bullet_block = ("\n" + "\n".join(actions)) if actions else ""
|
| 299 |
return f"{top}{bullet_block}{esc_text}".strip()
|
| 300 |
|
| 301 |
+
# --------------------------------------------------------
|
| 302 |
+
# Health
|
| 303 |
+
# --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
@app.get("/")
|
| 305 |
async def health_check():
|
| 306 |
return {"status": "ok"}
|
| 307 |
|
| 308 |
+
# --------------------------------------------------------
|
| 309 |
+
# Chat endpoint
|
| 310 |
+
# --------------------------------------------------------
|
| 311 |
@app.post("/chat")
|
| 312 |
async def chat_with_ai(input_data: ChatInput):
|
| 313 |
try:
|
| 314 |
msg_norm = (input_data.user_message or "").lower().strip()
|
| 315 |
|
| 316 |
+
# --- Negative feedback: proactively offer incident creation
|
| 317 |
+
if _is_negative_feedback(msg_norm):
|
| 318 |
+
return {
|
| 319 |
+
"bot_response": (
|
| 320 |
+
"Sorry it’s still not resolved. I can open a ServiceNow incident to track and escalate.\n\n"
|
| 321 |
+
"Please provide:\n• Short Description (one line)\n"
|
| 322 |
+
"• Detailed Description (what you tried, exact error text, IDs, site, environment)"
|
| 323 |
+
),
|
| 324 |
+
"status": "PARTIAL",
|
| 325 |
+
"context_found": False,
|
| 326 |
+
"ask_resolved": False,
|
| 327 |
+
"suggest_incident": True,
|
| 328 |
+
"show_incident_form": True,
|
| 329 |
+
"followup": None,
|
| 330 |
+
"top_hits": [], "sources": [],
|
| 331 |
+
"debug": {"intent": "negative_feedback"},
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
# --- Explicit incident intent: try direct creation if enough details; else show form
|
| 335 |
if _is_incident_intent(msg_norm):
|
| 336 |
+
# If user message has more than the keywords, attempt immediate creation
|
| 337 |
+
minimal_phrases = ["create ticket", "create incident", "open ticket", "open incident", "raise ticket", "raise incident"]
|
| 338 |
+
has_only_minimal = any(msg_norm.strip() == p for p in minimal_phrases)
|
| 339 |
+
if not has_only_minimal and len(msg_norm.split()) > 3:
|
| 340 |
+
short_desc = input_data.user_message.strip()[:100]
|
| 341 |
+
long_desc = input_data.user_message.strip()
|
| 342 |
+
try:
|
| 343 |
+
result = create_incident(short_desc, long_desc)
|
| 344 |
+
if isinstance(result, dict) and not result.get("error"):
|
| 345 |
+
inc_number = result.get("number", "<unknown>")
|
| 346 |
+
return {
|
| 347 |
+
"bot_response": f"✅ Incident created: {inc_number}",
|
| 348 |
+
"status": "OK",
|
| 349 |
+
"context_found": False,
|
| 350 |
+
"ask_resolved": False,
|
| 351 |
+
"suggest_incident": False,
|
| 352 |
+
"followup": "Is there anything else I can assist you with?",
|
| 353 |
+
"top_hits": [], "sources": [],
|
| 354 |
+
"debug": {"intent": "create_ticket_direct"},
|
| 355 |
+
}
|
| 356 |
+
except Exception as e:
|
| 357 |
+
# Fall back to form if direct creation fails
|
| 358 |
+
pass
|
| 359 |
+
# Prompt for details & show form
|
| 360 |
return {
|
| 361 |
"bot_response": (
|
| 362 |
"Okay, let’s create a ServiceNow incident.\n\n"
|
|
|
|
| 364 |
"• Detailed Description (steps, error text, IDs, site, environment)"
|
| 365 |
),
|
| 366 |
"status": (input_data.prev_status or "PARTIAL"),
|
| 367 |
+
"context_found": False, "ask_resolved": False, "suggest_incident": True,
|
| 368 |
"show_incident_form": True, "followup": None, "top_hits": [], "sources": [],
|
| 369 |
"debug": {"intent": "create_ticket"},
|
| 370 |
}
|
| 371 |
+
|
| 372 |
+
# --- Generic openers
|
| 373 |
if _is_generic_issue(msg_norm):
|
| 374 |
return {
|
| 375 |
"bot_response": (
|
|
|
|
| 386 |
"debug": {"intent": "generic_issue"},
|
| 387 |
}
|
| 388 |
|
| 389 |
+
# --- Status intent inside chat
|
| 390 |
+
status_intent = _parse_ticket_status_intent(msg_norm)
|
| 391 |
+
if status_intent:
|
| 392 |
+
if status_intent.get("ask_number"):
|
| 393 |
+
return {
|
| 394 |
+
"bot_response": "Please share the Incident ID (e.g., INC0012345) to check the status.",
|
| 395 |
+
"status": "PARTIAL",
|
| 396 |
+
"context_found": False,
|
| 397 |
+
"ask_resolved": False,
|
| 398 |
+
"suggest_incident": False,
|
| 399 |
+
"show_status_form": True,
|
| 400 |
+
"followup": "Provide the Incident ID and I’ll fetch the status.",
|
| 401 |
+
"top_hits": [], "sources": [],
|
| 402 |
+
"debug": {"intent": "status_request_missing_id"},
|
| 403 |
+
}
|
| 404 |
+
# fetch
|
| 405 |
+
try:
|
| 406 |
+
token = get_valid_token()
|
| 407 |
+
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
| 408 |
+
if not instance_url:
|
| 409 |
+
raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
|
| 410 |
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
| 411 |
+
number = status_intent.get("number")
|
| 412 |
+
url = f"{instance_url}/api/now/table/incident?number={number}"
|
| 413 |
+
response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
|
| 414 |
+
data = response.json()
|
| 415 |
+
lst = data.get("result", [])
|
| 416 |
+
result = (lst or [{}])[0] if response.status_code == 200 else {}
|
| 417 |
+
state_code = builtins.str(result.get("state", "unknown"))
|
| 418 |
+
state_map = {"1":"New","2":"In Progress","3":"On Hold","6":"Resolved","7":"Closed","8":"Canceled"}
|
| 419 |
+
state_label = state_map.get(state_code, state_code)
|
| 420 |
+
short = result.get("short_description", "")
|
| 421 |
+
num = result.get("number", number or "unknown")
|
| 422 |
+
return {
|
| 423 |
+
"bot_response": (
|
| 424 |
+
f"**Ticket:** {num}\n"
|
| 425 |
+
f"**Status:** {state_label}\n"
|
| 426 |
+
f"**Issue description:** {short}"
|
| 427 |
+
),
|
| 428 |
+
"status": "OK",
|
| 429 |
+
"show_assist_card": True,
|
| 430 |
+
"context_found": False,
|
| 431 |
+
"ask_resolved": False,
|
| 432 |
+
"suggest_incident": False,
|
| 433 |
+
"followup": "Is there anything else I can assist you with?",
|
| 434 |
+
"top_hits": [], "sources": [],
|
| 435 |
+
"debug": {"intent": "status", "http_status": response.status_code},
|
| 436 |
+
}
|
| 437 |
+
except Exception as e:
|
| 438 |
+
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 439 |
+
|
| 440 |
+
# --- Hybrid KB search
|
| 441 |
kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
|
| 442 |
|
| 443 |
# Build a small context window
|
|
|
|
| 500 |
high_conf = (best_combined is not None and best_combined >= gate_combined_ok)
|
| 501 |
exact = bool(exact_by_filter or high_conf)
|
| 502 |
|
| 503 |
+
# --- STEPS intent: full SOP steps (numbered)
|
| 504 |
if detected_intent == "steps" and best_doc:
|
| 505 |
full_steps = get_best_steps_section_text(best_doc)
|
| 506 |
if not full_steps:
|
|
|
|
| 538 |
},
|
| 539 |
}
|
| 540 |
|
| 541 |
+
# --- Non-steps intents
|
| 542 |
context = filtered_text
|
| 543 |
if detected_intent == "errors":
|
| 544 |
+
errs = _extract_errors_only(context, max_lines=MAX_SENTENCES_CONCISE)
|
| 545 |
+
esc = _extract_escalation_only(context, max_lines=3)
|
| 546 |
+
err_lines, esc_lines = _extract_errors_and_escalation(errs + ("\n" + esc if esc else ""))
|
| 547 |
+
bot_text = _compose_errors_response(err_lines, esc_lines)
|
| 548 |
+
status = "OK" if exact else "PARTIAL"
|
| 549 |
+
return {
|
|
|
|
|
|
|
|
|
|
| 550 |
"bot_response": bot_text,
|
| 551 |
"status": status,
|
| 552 |
"context_found": True,
|
| 553 |
"ask_resolved": (status == "OK"),
|
| 554 |
"suggest_incident": False,
|
| 555 |
"followup": ("Does this help? I can refine." if status == "PARTIAL" else None),
|
| 556 |
+
"top_hits": [], "sources": [],
|
|
|
|
| 557 |
"debug": {
|
| 558 |
+
"used_chunks": len(context.split("\n\n---\n\n")) if context else 0,
|
| 559 |
+
"best_distance": best_distance,
|
| 560 |
+
"best_combined": best_combined,
|
| 561 |
+
"filter_mode": filt_info.get("mode"),
|
| 562 |
+
"matched_count": filt_info.get("matched_count"),
|
| 563 |
+
"user_intent": detected_intent,
|
| 564 |
+
"actions": actions,
|
| 565 |
+
"best_doc": best_doc,
|
| 566 |
+
"exact": exact,
|
| 567 |
+
"high_conf": high_conf,
|
| 568 |
+
},
|
| 569 |
+
}
|
| 570 |
elif "navigate" in msg_norm or "menu" in msg_norm or "screen" in msg_norm:
|
| 571 |
context = _extract_steps_only(context, max_lines=MAX_SENTENCES_CONCISE)
|
| 572 |
+
|
| 573 |
+
# --- No-KB gate
|
| 574 |
if (not context.strip()) or (
|
| 575 |
(best_combined is None or best_combined < gate_combined_no_kb) and (best_distance is None or best_distance >= gate_distance_no_kb)
|
| 576 |
):
|
|
|
|
| 594 |
"debug": {"used_chunks": 0, "second_try": second_try, "best_distance": best_distance, "best_combined": best_combined},
|
| 595 |
}
|
| 596 |
|
| 597 |
+
# --- Default response for non-steps
|
| 598 |
bot_text = context.strip()
|
| 599 |
status = "OK" if exact else "PARTIAL"
|
| 600 |
return {
|
|
|
|
| 618 |
raise
|
| 619 |
except Exception as e:
|
| 620 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 621 |
+
|
| 622 |
+
# --------------------------------------------------------
|
| 623 |
+
# Incident endpoints (direct create + status + description)
|
| 624 |
+
# --------------------------------------------------------
|
| 625 |
+
@app.post("/incident")
|
| 626 |
+
async def raise_incident(input_data: IncidentInput):
|
| 627 |
+
try:
|
| 628 |
+
result = create_incident(input_data.short_description, input_data.description)
|
| 629 |
+
if isinstance(result, dict) and not result.get("error"):
|
| 630 |
+
inc_number = result.get("number", "<unknown>")
|
| 631 |
+
return {
|
| 632 |
+
"bot_response": f"✅ Incident created: {inc_number}",
|
| 633 |
+
"debug": "Incident created via ServiceNow",
|
| 634 |
+
"persist": True,
|
| 635 |
+
"show_assist_card": True,
|
| 636 |
+
"followup": "Is there anything else I can assist you with?",
|
| 637 |
+
}
|
| 638 |
+
else:
|
| 639 |
+
err = (result or {}).get("error", "Unknown error")
|
| 640 |
+
return {
|
| 641 |
+
"bot_response": f"⚠️ Could not create incident ({err}).",
|
| 642 |
+
"status": "PARTIAL",
|
| 643 |
+
"suggest_incident": True,
|
| 644 |
+
}
|
| 645 |
+
except Exception as e:
|
| 646 |
+
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 647 |
+
|
| 648 |
+
@app.post("/incident_status")
|
| 649 |
+
async def incident_status(input_data: TicketStatusInput):
|
| 650 |
+
try:
|
| 651 |
+
token = get_valid_token()
|
| 652 |
+
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
| 653 |
+
if not instance_url:
|
| 654 |
+
raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
|
| 655 |
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
| 656 |
+
if input_data.sys_id:
|
| 657 |
+
url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
|
| 658 |
+
response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
|
| 659 |
+
data = response.json()
|
| 660 |
+
result = data.get("result", {}) if response.status_code == 200 else {}
|
| 661 |
+
elif input_data.number:
|
| 662 |
+
url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
|
| 663 |
+
response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
|
| 664 |
+
data = response.json()
|
| 665 |
+
lst = data.get("result", [])
|
| 666 |
+
result = (lst or [{}])[0] if response.status_code == 200 else {}
|
| 667 |
+
else:
|
| 668 |
+
raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
|
| 669 |
+
state_code = builtins.str(result.get("state", "unknown"))
|
| 670 |
+
state_map = {"1":"New","2":"In Progress","3":"On Hold","6":"Resolved","7":"Closed","8":"Canceled"}
|
| 671 |
+
state_label = state_map.get(state_code, state_code)
|
| 672 |
+
short = result.get("short_description", "")
|
| 673 |
+
number = result.get("number", input_data.number or "unknown")
|
| 674 |
+
return {
|
| 675 |
+
"bot_response": (
|
| 676 |
+
f"**Ticket:** {number}\n"
|
| 677 |
+
f"**Status:** {state_label}\n"
|
| 678 |
+
f"**Issue description:** {short}"
|
| 679 |
+
).replace("\n", " \n"),
|
| 680 |
+
"followup": "Is there anything else I can assist you with?",
|
| 681 |
+
"show_assist_card": True,
|
| 682 |
+
"persist": True,
|
| 683 |
+
"debug": "Incident status fetched",
|
| 684 |
+
}
|
| 685 |
+
except Exception as e:
|
| 686 |
+
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 687 |
+
|
| 688 |
+
@app.post("/generate_ticket_desc")
|
| 689 |
+
async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
| 690 |
+
try:
|
| 691 |
+
# Minimal: echo structured JSON (no external LLM)
|
| 692 |
+
issue = (input_data.issue or "").strip()
|
| 693 |
+
short = issue[:100]
|
| 694 |
+
detailed = issue
|
| 695 |
+
return {
|
| 696 |
+
"ShortDescription": short,
|
| 697 |
+
"DetailedDescription": detailed,
|
| 698 |
+
}
|
| 699 |
+
except Exception as e:
|
| 700 |
+
raise HTTPException(status_code=500, detail=safe_str(e))
|