Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
|
|
|
|
|
|
|
| 2 |
import os
|
| 3 |
import json
|
| 4 |
import re
|
|
@@ -27,15 +29,18 @@ from services.generate_ticket import get_valid_token, create_incident
|
|
| 27 |
VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 28 |
GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 29 |
|
|
|
|
| 30 |
def safe_str(e: Any) -> str:
|
| 31 |
try:
|
| 32 |
return builtins.str(e)
|
| 33 |
except Exception:
|
| 34 |
return "<error stringify failed>"
|
| 35 |
|
|
|
|
| 36 |
load_dotenv()
|
| 37 |
os.environ["POSTHOG_DISABLED"] = "true"
|
| 38 |
|
|
|
|
| 39 |
@asynccontextmanager
|
| 40 |
async def lifespan(app: FastAPI):
|
| 41 |
try:
|
|
@@ -49,6 +54,7 @@ async def lifespan(app: FastAPI):
|
|
| 49 |
print(f"[KB] ingestion failed: {safe_str(e)}")
|
| 50 |
yield
|
| 51 |
|
|
|
|
| 52 |
app = FastAPI(lifespan=lifespan)
|
| 53 |
app.include_router(login_router)
|
| 54 |
|
|
@@ -67,18 +73,22 @@ class ChatInput(BaseModel):
|
|
| 67 |
prev_status: Optional[str] = None
|
| 68 |
last_issue: Optional[str] = None
|
| 69 |
|
|
|
|
| 70 |
class IncidentInput(BaseModel):
|
| 71 |
short_description: str
|
| 72 |
description: str
|
| 73 |
mark_resolved: Optional[bool] = False
|
| 74 |
|
|
|
|
| 75 |
class TicketDescInput(BaseModel):
|
| 76 |
issue: str
|
| 77 |
|
|
|
|
| 78 |
class TicketStatusInput(BaseModel):
|
| 79 |
sys_id: Optional[str] = None
|
| 80 |
number: Optional[str] = None
|
| 81 |
|
|
|
|
| 82 |
STATE_MAP = {
|
| 83 |
"1": "New",
|
| 84 |
"2": "In Progress",
|
|
@@ -134,10 +144,10 @@ ERROR_FAMILY_SYNS = {
|
|
| 134 |
),
|
| 135 |
}
|
| 136 |
|
|
|
|
| 137 |
def _detect_error_families(msg: str) -> list:
|
| 138 |
"""Return matching error family names found in the message (generic across SOPs)."""
|
| 139 |
low = (msg or "").lower()
|
| 140 |
-
import re
|
| 141 |
low_norm = re.sub(r"[^\w\s]", " ", low)
|
| 142 |
low_norm = re.sub(r"\s+", " ", low_norm).strip()
|
| 143 |
fams = []
|
|
@@ -146,11 +156,13 @@ def _detect_error_families(msg: str) -> list:
|
|
| 146 |
fams.append(fam)
|
| 147 |
return fams
|
| 148 |
|
|
|
|
| 149 |
def _is_domain_status_context(msg_norm: str) -> bool:
|
| 150 |
if "status locked" in msg_norm or "locked status" in msg_norm:
|
| 151 |
return True
|
| 152 |
return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
|
| 153 |
|
|
|
|
| 154 |
def _normalize_lines(text: str) -> List[str]:
|
| 155 |
raw = (text or "")
|
| 156 |
try:
|
|
@@ -158,59 +170,69 @@ def _normalize_lines(text: str) -> List[str]:
|
|
| 158 |
except Exception:
|
| 159 |
return [raw.strip()] if raw.strip() else []
|
| 160 |
|
|
|
|
| 161 |
def _ensure_numbering(text: str) -> str:
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
| 163 |
text = re.sub(r"[\u2060\u200B]", "", text or "")
|
| 164 |
-
def strip_prefix_any(s: str) -> str:
|
| 165 |
-
return re.sub(
|
| 166 |
-
r"^\s*(?:[\u2060\u200B]*"
|
| 167 |
-
r"(?:\d+\s*[.)])"
|
| 168 |
-
r"|(?:step\s*\d+:?)"
|
| 169 |
-
r"|(?:[\-\*\u2022])"
|
| 170 |
-
r"|(?:[\u2460-\u2473]))\s*",
|
| 171 |
-
"", (s or "").strip(), flags=re.IGNORECASE
|
| 172 |
-
)
|
| 173 |
lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
|
| 174 |
if not lines:
|
| 175 |
return text or ""
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
ln = lines[i]
|
| 180 |
-
if re.fullmatch(r"\d+[.)]?|[\u2460-\u2473]", ln) and (i + 1) < len(lines):
|
| 181 |
-
merged.append(lines[i + 1].strip())
|
| 182 |
-
i += 2
|
| 183 |
-
else:
|
| 184 |
-
merged.append(ln)
|
| 185 |
-
i += 1
|
| 186 |
-
para = " ".join(merged).strip()
|
| 187 |
if not para:
|
| 188 |
return ""
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
para_clean = re.sub(r"(?
|
|
|
|
|
|
|
| 192 |
segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
|
|
|
|
|
|
|
| 193 |
if len(segments) < 2:
|
| 194 |
tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
|
| 195 |
segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|\s+;\s+", para) if seg.strip()]
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
marker = circled.get(idx, f"{idx})")
|
| 202 |
out.append(f"{marker} {seg}")
|
| 203 |
return "\n".join(out)
|
| 204 |
|
| 205 |
-
# --- Next-step helpers (generic; SOP-agnostic) ---
|
| 206 |
|
|
|
|
| 207 |
def _norm_text(s: str) -> str:
|
| 208 |
-
import re
|
| 209 |
s = (s or "").lower()
|
| 210 |
s = re.sub(r"[^\w\s]", " ", s)
|
| 211 |
s = re.sub(r"\s+", " ", s).strip()
|
| 212 |
return s
|
| 213 |
|
|
|
|
| 214 |
def _split_sop_into_steps(numbered_text: str) -> list:
|
| 215 |
"""
|
| 216 |
Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
|
|
@@ -220,13 +242,12 @@ def _split_sop_into_steps(numbered_text: str) -> list:
|
|
| 220 |
lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
|
| 221 |
steps = []
|
| 222 |
for ln in lines:
|
| 223 |
-
|
| 224 |
-
cleaned = ln
|
| 225 |
-
cleaned = re.sub(r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*", "", cleaned)
|
| 226 |
if cleaned:
|
| 227 |
steps.append(cleaned)
|
| 228 |
return steps
|
| 229 |
|
|
|
|
| 230 |
def _soft_match_score(a: str, b: str) -> float:
|
| 231 |
# Simple Jaccard-like score on tokens for fuzzy matching
|
| 232 |
ta = set(_norm_text(a).split())
|
|
@@ -237,15 +258,16 @@ def _soft_match_score(a: str, b: str) -> float:
|
|
| 237 |
union = len(ta | tb)
|
| 238 |
return inter / union if union else 0.0
|
| 239 |
|
|
|
|
| 240 |
def _detect_next_intent(user_query: str) -> bool:
|
| 241 |
q = _norm_text(user_query)
|
| 242 |
-
# Conservative rules to avoid false triggers
|
| 243 |
keys = [
|
| 244 |
"after", "after this", "what next", "whats next", "next step",
|
| 245 |
"then what", "following step", "continue", "subsequent", "proceed"
|
| 246 |
]
|
| 247 |
return any(k in q for k in keys)
|
| 248 |
|
|
|
|
| 249 |
def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
|
| 250 |
"""
|
| 251 |
If 'what's next' intent is detected and we can reliably match the user's
|
|
@@ -262,7 +284,6 @@ def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6,
|
|
| 262 |
q = user_query or ""
|
| 263 |
best_idx, best_score = -1, -1.0
|
| 264 |
for idx, step in enumerate(steps):
|
| 265 |
-
# Exact substring match gets max score; else use soft match
|
| 266 |
score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
|
| 267 |
if score > best_score:
|
| 268 |
best_score, best_idx = score, idx
|
|
@@ -277,17 +298,21 @@ def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6,
|
|
| 277 |
end = min(start + max_next, len(steps))
|
| 278 |
return steps[start:end]
|
| 279 |
|
|
|
|
| 280 |
def _format_steps_as_numbered(steps: list) -> str:
|
| 281 |
-
"""
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
| 286 |
out = []
|
| 287 |
for i, s in enumerate(steps, start=1):
|
| 288 |
out.append(f"{circled.get(i, str(i))} {s}")
|
| 289 |
return "\n".join(out)
|
| 290 |
|
|
|
|
| 291 |
def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
|
| 292 |
"""
|
| 293 |
Pick the most relevant 'Common Errors & Resolution' bullet(s) for the user's message.
|
|
@@ -300,10 +325,6 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
|
|
| 300 |
|
| 301 |
Returns exactly `max_lines` best-scoring lines (defaults to 1).
|
| 302 |
"""
|
| 303 |
-
|
| 304 |
-
import re
|
| 305 |
-
from typing import List, Tuple
|
| 306 |
-
|
| 307 |
def _norm(s: str) -> str:
|
| 308 |
s = (s or "").lower()
|
| 309 |
s = re.sub(r"[^\w\s]", " ", s)
|
|
@@ -311,7 +332,7 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
|
|
| 311 |
return s
|
| 312 |
|
| 313 |
def _ngrams(tokens: List[str], n: int) -> List[str]:
|
| 314 |
-
return [" ".join(tokens[i:i+n]) for i in range(len(tokens) - n + 1)]
|
| 315 |
|
| 316 |
def _families_for(s: str) -> set:
|
| 317 |
low = _norm(s)
|
|
@@ -336,7 +357,7 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
|
|
| 336 |
ln_norm = _norm(ln)
|
| 337 |
ln_fams = _families_for(ln)
|
| 338 |
|
| 339 |
-
fam_overlap = len(q_fams & ln_fams)
|
| 340 |
anchored = 0.0
|
| 341 |
first2 = " ".join(q_tokens[:2]) if len(q_tokens) >= 2 else ""
|
| 342 |
first3 = " ".join(q_tokens[:3]) if len(q_tokens) >= 3 else ""
|
|
@@ -348,7 +369,6 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
|
|
| 348 |
token_overlap = sum(1 for t in q_tokens if t and t in ln_norm)
|
| 349 |
exact_phrase = 1.0 if (q and q in ln_norm) else 0.0
|
| 350 |
|
| 351 |
-
# Composite score (tuned generically)
|
| 352 |
score = (
|
| 353 |
1.70 * fam_overlap +
|
| 354 |
1.00 * anchored +
|
|
@@ -358,7 +378,7 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
|
|
| 358 |
0.30 * token_overlap
|
| 359 |
)
|
| 360 |
|
| 361 |
-
if re.match(r"^\s*[
|
| 362 |
score += 0.10
|
| 363 |
heading = ln_norm.split(":")[0].strip()
|
| 364 |
if heading and (heading in q or (first2 and first2 in heading)):
|
|
@@ -368,15 +388,14 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
|
|
| 368 |
|
| 369 |
scored.sort(key=lambda x: x[0], reverse=True)
|
| 370 |
top = [ln for s, ln in scored[:max_lines] if s > 0.0]
|
| 371 |
-
|
| 372 |
if not top:
|
| 373 |
top = lines[:max_lines]
|
| 374 |
-
|
| 375 |
return "\n".join(top).strip()
|
| 376 |
|
|
|
|
| 377 |
def _friendly_permission_reply(raw: str) -> str:
|
| 378 |
line = (raw or "").strip()
|
| 379 |
-
line = re.sub(r"^\s*[
|
| 380 |
if not line:
|
| 381 |
return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
|
| 382 |
if "verify role access" in line.lower():
|
|
@@ -385,6 +404,7 @@ def _friendly_permission_reply(raw: str) -> str:
|
|
| 385 |
return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
|
| 386 |
return line
|
| 387 |
|
|
|
|
| 388 |
def _detect_language_hint(msg: str) -> Optional[str]:
|
| 389 |
if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
|
| 390 |
return "Tamil"
|
|
@@ -392,9 +412,13 @@ def _detect_language_hint(msg: str) -> Optional[str]:
|
|
| 392 |
return "Hindi"
|
| 393 |
return None
|
| 394 |
|
|
|
|
| 395 |
def _build_clarifying_message() -> str:
|
| 396 |
-
return (
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
| 398 |
|
| 399 |
def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
|
| 400 |
issue = (issue_text or "").strip()
|
|
@@ -407,6 +431,7 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
|
|
| 407 |
).strip()
|
| 408 |
return short_desc, long_desc
|
| 409 |
|
|
|
|
| 410 |
def _is_incident_intent(msg_norm: str) -> bool:
|
| 411 |
intent_phrases = [
|
| 412 |
"create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
|
|
@@ -416,6 +441,7 @@ def _is_incident_intent(msg_norm: str) -> bool:
|
|
| 416 |
]
|
| 417 |
return any(p in msg_norm for p in intent_phrases)
|
| 418 |
|
|
|
|
| 419 |
def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
| 420 |
status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
|
| 421 |
base_has_status = any(k in msg_norm for k in status_keywords)
|
|
@@ -438,6 +464,7 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
|
| 438 |
return {"number": val.upper() if val.lower().startswith("inc") else val}
|
| 439 |
return {"number": None, "ask_number": True}
|
| 440 |
|
|
|
|
| 441 |
def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
|
| 442 |
phrases = [
|
| 443 |
"it is resolved", "resolved", "issue resolved", "problem resolved",
|
|
@@ -446,6 +473,7 @@ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
|
|
| 446 |
]
|
| 447 |
return any(p in msg_norm for p in phrases)
|
| 448 |
|
|
|
|
| 449 |
def _has_negation_resolved(msg_norm: str) -> bool:
|
| 450 |
neg_phrases = [
|
| 451 |
"not resolved", "issue not resolved", "still not working", "not working",
|
|
@@ -453,8 +481,8 @@ def _has_negation_resolved(msg_norm: str) -> bool:
|
|
| 453 |
]
|
| 454 |
return any(p in msg_norm for p in neg_phrases)
|
| 455 |
|
|
|
|
| 456 |
def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
|
| 457 |
-
import re
|
| 458 |
STRICT_OVERLAP = 3
|
| 459 |
MAX_SENTENCES_STRICT = 4
|
| 460 |
MAX_SENTENCES_CONCISE = 3
|
|
@@ -466,7 +494,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
|
|
| 466 |
return t
|
| 467 |
|
| 468 |
def _split_sentences(ctx: str) -> List[str]:
|
| 469 |
-
raw_sents = re.split(r"(?<=[.!?])\s+|\n+|
|
| 470 |
return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
|
| 471 |
|
| 472 |
ctx = (context or "").strip()
|
|
@@ -495,23 +523,22 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
|
|
| 495 |
kept = sentences[:MAX_SENTENCES_CONCISE]
|
| 496 |
return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
|
| 497 |
|
|
|
|
| 498 |
def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
| 499 |
"""
|
| 500 |
Collect error bullets/heading-style lines from the SOP errors section.
|
| 501 |
Generic: keeps bullet points (•, -, *), and lines that look like "Heading: details".
|
| 502 |
This ensures items like 'ASN not found', 'Trailer status locked', etc., are preserved.
|
| 503 |
"""
|
| 504 |
-
import re
|
| 505 |
kept: List[str] = []
|
| 506 |
for ln in _normalize_lines(text):
|
| 507 |
-
|
| 508 |
-
if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln):
|
| 509 |
kept.append(ln)
|
| 510 |
if len(kept) >= max_lines:
|
| 511 |
break
|
| 512 |
-
# Fallback: if nothing matched, return original text
|
| 513 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 514 |
|
|
|
|
| 515 |
def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
|
| 516 |
PERM_SYNONYMS = (
|
| 517 |
"permission", "permissions", "access", "authorization", "authorisation",
|
|
@@ -526,6 +553,7 @@ def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
|
|
| 526 |
break
|
| 527 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 528 |
|
|
|
|
| 529 |
def _extract_escalation_line(text: str) -> Optional[str]:
|
| 530 |
if not text:
|
| 531 |
return None
|
|
@@ -564,6 +592,7 @@ def _extract_escalation_line(text: str) -> Optional[str]:
|
|
| 564 |
path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
|
| 565 |
return f"If you want to escalate the issue, follow: {path}"
|
| 566 |
|
|
|
|
| 567 |
def _classify_resolution_llm(user_message: str) -> bool:
|
| 568 |
if not GEMINI_API_KEY:
|
| 569 |
return False
|
|
@@ -585,6 +614,7 @@ Message: {user_message}"""
|
|
| 585 |
except Exception:
|
| 586 |
return False
|
| 587 |
|
|
|
|
| 588 |
def _set_incident_resolved(sys_id: str) -> bool:
|
| 589 |
try:
|
| 590 |
token = get_valid_token()
|
|
@@ -610,8 +640,10 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 610 |
print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
|
| 611 |
except Exception as e:
|
| 612 |
print(f"[SN PATCH progress] exception={safe_str(e)}")
|
|
|
|
| 613 |
def clean(d: dict) -> dict:
|
| 614 |
return {k: v for k, v in d.items() if v is not None}
|
|
|
|
| 615 |
payload_A = clean({
|
| 616 |
"state": "6",
|
| 617 |
"close_code": close_code_val,
|
|
@@ -626,6 +658,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 626 |
if respA.status_code in (200, 204):
|
| 627 |
return True
|
| 628 |
print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
|
|
|
|
| 629 |
payload_B = clean({
|
| 630 |
"state": "Resolved",
|
| 631 |
"close_code": close_code_val,
|
|
@@ -640,6 +673,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 640 |
if respB.status_code in (200, 204):
|
| 641 |
return True
|
| 642 |
print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
|
|
|
|
| 643 |
code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
|
| 644 |
notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
|
| 645 |
payload_C = clean({
|
|
@@ -661,6 +695,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 661 |
print(f"[SN PATCH resolve] exception={safe_str(e)}")
|
| 662 |
return False
|
| 663 |
|
|
|
|
| 664 |
# ------------------------------ Prereq helper ------------------------------
|
| 665 |
def _find_prereq_section_text(best_doc: str) -> str:
|
| 666 |
"""
|
|
@@ -680,11 +715,13 @@ def _find_prereq_section_text(best_doc: str) -> str:
|
|
| 680 |
return txt.strip()
|
| 681 |
return ""
|
| 682 |
|
|
|
|
| 683 |
# ------------------------------ Health ------------------------------
|
| 684 |
@app.get("/")
|
| 685 |
async def health_check():
|
| 686 |
return {"status": "ok"}
|
| 687 |
|
|
|
|
| 688 |
# ------------------------------ Chat ------------------------------
|
| 689 |
@app.post("/chat")
|
| 690 |
async def chat_with_ai(input_data: ChatInput):
|
|
@@ -747,7 +784,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 747 |
"ask_resolved": False,
|
| 748 |
"suggest_incident": True,
|
| 749 |
"followup": "Shall I create a ticket now?",
|
| 750 |
-
"options": [{"type":"yesno","title":"Create ticket now?"}],
|
| 751 |
"top_hits": [],
|
| 752 |
"sources": [],
|
| 753 |
"debug": {"intent": "resolved_ack", "auto_created": False, "error": err},
|
|
@@ -760,7 +797,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 760 |
"ask_resolved": False,
|
| 761 |
"suggest_incident": True,
|
| 762 |
"followup": "Shall I create a ticket now?",
|
| 763 |
-
"options": [{"type":"yesno","title":"Create ticket now?"}],
|
| 764 |
"top_hits": [],
|
| 765 |
"sources": [],
|
| 766 |
"debug": {"intent": "resolved_ack", "exception": True},
|
|
@@ -854,8 +891,10 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 854 |
score = distances[i] if i < len(distances) else None
|
| 855 |
comb = combined[i] if i < len(combined) else None
|
| 856 |
m = dict(meta)
|
| 857 |
-
if score is not None:
|
| 858 |
-
|
|
|
|
|
|
|
| 859 |
items.append({"text": text, "meta": m})
|
| 860 |
|
| 861 |
selected = items[:max(1, 2)]
|
|
@@ -961,7 +1000,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 961 |
"ask_resolved": False,
|
| 962 |
"suggest_incident": True,
|
| 963 |
"followup": "Share more details (module/screen/error), or say 'create ticket'.",
|
| 964 |
-
"options": [{"type":"yesno","title":"Share details or raise a ticket?"}],
|
| 965 |
"top_hits": [],
|
| 966 |
"sources": [],
|
| 967 |
"debug": {
|
|
@@ -974,7 +1013,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 974 |
"strong_steps_bypass": strong_steps_bypass,
|
| 975 |
"strong_error_signal": strong_error_signal,
|
| 976 |
"generic_error_signal": generic_error_signal
|
| 977 |
-
|
| 978 |
},
|
| 979 |
}
|
| 980 |
|
|
@@ -984,51 +1022,59 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 984 |
|
| 985 |
escalation_line = None # SOP escalation candidate
|
| 986 |
full_errors = None # keep for possible escalation extraction
|
|
|
|
|
|
|
| 987 |
|
| 988 |
if best_doc:
|
| 989 |
if detected_intent == "steps":
|
| 990 |
full_steps = get_best_steps_section_text(best_doc)
|
| 991 |
if not full_steps:
|
| 992 |
sec = (top_meta or {}).get("section")
|
| 993 |
-
if sec:
|
|
|
|
|
|
|
| 994 |
if full_steps:
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1007 |
elif detected_intent == "errors":
|
| 1008 |
full_errors = get_best_errors_section_text(best_doc)
|
| 1009 |
-
#assist_followup = None # collect a helpful follow-up for generic cases
|
| 1010 |
-
|
| 1011 |
if full_errors:
|
| 1012 |
ctx_err = _extract_errors_only(full_errors, max_lines=30)
|
| 1013 |
if is_perm_query:
|
| 1014 |
context = _filter_permission_lines(ctx_err, max_lines=6)
|
| 1015 |
else:
|
| 1016 |
# Decide specific vs generic:
|
| 1017 |
-
# - specific: user named a family (NOT_FOUND / LOCKED / MISMATCH / ...)
|
| 1018 |
-
# - generic: user only said 'issue'/'error' without naming family
|
| 1019 |
is_specific_error = len(_detect_error_families(msg_low)) > 0
|
| 1020 |
if is_specific_error:
|
| 1021 |
-
|
| 1022 |
else:
|
| 1023 |
-
|
| 1024 |
all_lines: List[str] = _normalize_lines(ctx_err)
|
| 1025 |
-
error_bullets = [ln for ln in all_lines if re.match(r"^\s*[
|
| 1026 |
-
# limit to reasonable count (e.g., 8)
|
| 1027 |
context = "\n".join(error_bullets[:8]).strip()
|
| 1028 |
assist_followup = (
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
escalation_line = _extract_escalation_line(full_errors)
|
| 1033 |
|
| 1034 |
elif detected_intent == "prereqs":
|
|
@@ -1070,12 +1116,19 @@ Return ONLY the rewritten guidance."""
|
|
| 1070 |
|
| 1071 |
# Deterministic local formatting
|
| 1072 |
if detected_intent == "steps":
|
| 1073 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1074 |
elif detected_intent == "errors":
|
| 1075 |
if not bot_text.strip() or http_code == 429:
|
| 1076 |
bot_text = context.strip()
|
| 1077 |
if escalation_line:
|
| 1078 |
bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
|
|
|
|
| 1079 |
else:
|
| 1080 |
bot_text = context
|
| 1081 |
|
|
@@ -1105,7 +1158,8 @@ Return ONLY the rewritten guidance."""
|
|
| 1105 |
lower = (bot_text or "").lower()
|
| 1106 |
if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
|
| 1107 |
status = "PARTIAL"
|
| 1108 |
-
options = [{"type":"yesno","title":"Share details or raise a ticket?"}] if status == "PARTIAL" else []
|
|
|
|
| 1109 |
return {
|
| 1110 |
"bot_response": bot_text,
|
| 1111 |
"status": status,
|
|
@@ -1125,6 +1179,10 @@ Return ONLY the rewritten guidance."""
|
|
| 1125 |
"matched_count": filt_info.get("matched_count"),
|
| 1126 |
"user_intent": detected_intent,
|
| 1127 |
"best_doc": best_doc,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1128 |
},
|
| 1129 |
}
|
| 1130 |
except HTTPException:
|
|
@@ -1132,6 +1190,7 @@ Return ONLY the rewritten guidance."""
|
|
| 1132 |
except Exception as e:
|
| 1133 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1134 |
|
|
|
|
| 1135 |
# ------------------------------ Ticket description generation ------------------------------
|
| 1136 |
@app.post("/generate_ticket_desc")
|
| 1137 |
async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
|
@@ -1170,6 +1229,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
|
| 1170 |
except Exception as e:
|
| 1171 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1172 |
|
|
|
|
| 1173 |
# ------------------------------ Incident status ------------------------------
|
| 1174 |
@app.post("/incident_status")
|
| 1175 |
async def incident_status(input_data: TicketStatusInput):
|
|
@@ -1210,6 +1270,7 @@ async def incident_status(input_data: TicketStatusInput):
|
|
| 1210 |
except Exception as e:
|
| 1211 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1212 |
|
|
|
|
| 1213 |
# ------------------------------ Incident ------------------------------
|
| 1214 |
@app.post("/incident")
|
| 1215 |
async def raise_incident(input_data: IncidentInput):
|
|
|
|
| 1 |
|
| 2 |
+
# main_hugging_phase_recent.py
|
| 3 |
+
|
| 4 |
import os
|
| 5 |
import json
|
| 6 |
import re
|
|
|
|
| 29 |
VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 30 |
GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
|
| 31 |
|
| 32 |
+
|
| 33 |
def safe_str(e: Any) -> str:
|
| 34 |
try:
|
| 35 |
return builtins.str(e)
|
| 36 |
except Exception:
|
| 37 |
return "<error stringify failed>"
|
| 38 |
|
| 39 |
+
|
| 40 |
load_dotenv()
|
| 41 |
os.environ["POSTHOG_DISABLED"] = "true"
|
| 42 |
|
| 43 |
+
|
| 44 |
@asynccontextmanager
|
| 45 |
async def lifespan(app: FastAPI):
|
| 46 |
try:
|
|
|
|
| 54 |
print(f"[KB] ingestion failed: {safe_str(e)}")
|
| 55 |
yield
|
| 56 |
|
| 57 |
+
|
| 58 |
app = FastAPI(lifespan=lifespan)
|
| 59 |
app.include_router(login_router)
|
| 60 |
|
|
|
|
| 73 |
prev_status: Optional[str] = None
|
| 74 |
last_issue: Optional[str] = None
|
| 75 |
|
| 76 |
+
|
| 77 |
class IncidentInput(BaseModel):
|
| 78 |
short_description: str
|
| 79 |
description: str
|
| 80 |
mark_resolved: Optional[bool] = False
|
| 81 |
|
| 82 |
+
|
| 83 |
class TicketDescInput(BaseModel):
|
| 84 |
issue: str
|
| 85 |
|
| 86 |
+
|
| 87 |
class TicketStatusInput(BaseModel):
|
| 88 |
sys_id: Optional[str] = None
|
| 89 |
number: Optional[str] = None
|
| 90 |
|
| 91 |
+
|
| 92 |
STATE_MAP = {
|
| 93 |
"1": "New",
|
| 94 |
"2": "In Progress",
|
|
|
|
| 144 |
),
|
| 145 |
}
|
| 146 |
|
| 147 |
+
|
| 148 |
def _detect_error_families(msg: str) -> list:
|
| 149 |
"""Return matching error family names found in the message (generic across SOPs)."""
|
| 150 |
low = (msg or "").lower()
|
|
|
|
| 151 |
low_norm = re.sub(r"[^\w\s]", " ", low)
|
| 152 |
low_norm = re.sub(r"\s+", " ", low_norm).strip()
|
| 153 |
fams = []
|
|
|
|
| 156 |
fams.append(fam)
|
| 157 |
return fams
|
| 158 |
|
| 159 |
+
|
| 160 |
def _is_domain_status_context(msg_norm: str) -> bool:
|
| 161 |
if "status locked" in msg_norm or "locked status" in msg_norm:
|
| 162 |
return True
|
| 163 |
return any(term in msg_norm for term in DOMAIN_STATUS_TERMS)
|
| 164 |
|
| 165 |
+
|
| 166 |
def _normalize_lines(text: str) -> List[str]:
|
| 167 |
raw = (text or "")
|
| 168 |
try:
|
|
|
|
| 170 |
except Exception:
|
| 171 |
return [raw.strip()] if raw.strip() else []
|
| 172 |
|
| 173 |
+
|
| 174 |
def _ensure_numbering(text: str) -> str:
|
| 175 |
+
"""
|
| 176 |
+
Normalize raw SOP steps into a clean numbered list using circled digits.
|
| 177 |
+
Robust against '1.', '1)', 'Step 1:', bullets ('-', '*', '•'), and circled digits.
|
| 178 |
+
"""
|
| 179 |
text = re.sub(r"[\u2060\u200B]", "", text or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
lines = [ln.strip() for ln in (text or "").splitlines() if ln and ln.strip()]
|
| 181 |
if not lines:
|
| 182 |
return text or ""
|
| 183 |
+
|
| 184 |
+
# Collapse lines into a block and then split on common step markers
|
| 185 |
+
para = " ".join(lines).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
if not para:
|
| 187 |
return ""
|
| 188 |
+
|
| 189 |
+
# Create hard breaks at typical step boundaries
|
| 190 |
+
para_clean = re.sub(r"(?:\b\d+[.)]\s+)", "\n\n\n", para) # 1. / 1)
|
| 191 |
+
para_clean = re.sub(r"(?:[\u2460-\u2473]\s+)", "\n\n\n", para_clean) # circled digits
|
| 192 |
+
para_clean = re.sub(r"(?i)\bstep\s*\d+\s*:\s*", "\n\n\n", para_clean) # Step 1:
|
| 193 |
segments = [seg.strip() for seg in para_clean.split("\n\n\n") if seg.strip()]
|
| 194 |
+
|
| 195 |
+
# Fallback splitting if we didn't detect separators
|
| 196 |
if len(segments) < 2:
|
| 197 |
tmp = [ln.strip() for ln in para.splitlines() if ln.strip()]
|
| 198 |
segments = tmp if len(tmp) > 1 else [seg.strip() for seg in re.split(r"(?<=[.!?])\s+|\s+;\s+", para) if seg.strip()]
|
| 199 |
+
|
| 200 |
+
# Strip any step prefixes
|
| 201 |
+
def strip_prefix_any(s: str) -> str:
|
| 202 |
+
return re.sub(
|
| 203 |
+
r"^\s*(?:"
|
| 204 |
+
r"(?:\d+\s*[.)])" # leading numbers 1., 2)
|
| 205 |
+
r"|(?:step\s*\d+:?)" # Step 1:
|
| 206 |
+
r"|(?:[-*\u2022])" # bullets
|
| 207 |
+
r"|(?:[\u2460-\u2473])" # circled digits
|
| 208 |
+
r")\s*",
|
| 209 |
+
"",
|
| 210 |
+
(s or "").strip(),
|
| 211 |
+
flags=re.IGNORECASE
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
clean_segments = [strip_prefix_any(seg) for seg in segments if seg.strip()]
|
| 215 |
+
circled = {
|
| 216 |
+
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 217 |
+
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
| 218 |
+
11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
|
| 219 |
+
16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
|
| 220 |
+
}
|
| 221 |
+
out = []
|
| 222 |
+
for idx, seg in enumerate(clean_segments, start=1):
|
| 223 |
marker = circled.get(idx, f"{idx})")
|
| 224 |
out.append(f"{marker} {seg}")
|
| 225 |
return "\n".join(out)
|
| 226 |
|
|
|
|
| 227 |
|
| 228 |
+
# --- Next-step helpers (generic; SOP-agnostic) ---
|
| 229 |
def _norm_text(s: str) -> str:
|
|
|
|
| 230 |
s = (s or "").lower()
|
| 231 |
s = re.sub(r"[^\w\s]", " ", s)
|
| 232 |
s = re.sub(r"\s+", " ", s).strip()
|
| 233 |
return s
|
| 234 |
|
| 235 |
+
|
| 236 |
def _split_sop_into_steps(numbered_text: str) -> list:
|
| 237 |
"""
|
| 238 |
Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
|
|
|
|
| 242 |
lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
|
| 243 |
steps = []
|
| 244 |
for ln in lines:
|
| 245 |
+
cleaned = re.sub(r"^\s*(?:[\u2460-\u2473]|\d+[.)]|[-*•])\s*", "", ln)
|
|
|
|
|
|
|
| 246 |
if cleaned:
|
| 247 |
steps.append(cleaned)
|
| 248 |
return steps
|
| 249 |
|
| 250 |
+
|
| 251 |
def _soft_match_score(a: str, b: str) -> float:
|
| 252 |
# Simple Jaccard-like score on tokens for fuzzy matching
|
| 253 |
ta = set(_norm_text(a).split())
|
|
|
|
| 258 |
union = len(ta | tb)
|
| 259 |
return inter / union if union else 0.0
|
| 260 |
|
| 261 |
+
|
| 262 |
def _detect_next_intent(user_query: str) -> bool:
|
| 263 |
q = _norm_text(user_query)
|
|
|
|
| 264 |
keys = [
|
| 265 |
"after", "after this", "what next", "whats next", "next step",
|
| 266 |
"then what", "following step", "continue", "subsequent", "proceed"
|
| 267 |
]
|
| 268 |
return any(k in q for k in keys)
|
| 269 |
|
| 270 |
+
|
| 271 |
def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
|
| 272 |
"""
|
| 273 |
If 'what's next' intent is detected and we can reliably match the user's
|
|
|
|
| 284 |
q = user_query or ""
|
| 285 |
best_idx, best_score = -1, -1.0
|
| 286 |
for idx, step in enumerate(steps):
|
|
|
|
| 287 |
score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
|
| 288 |
if score > best_score:
|
| 289 |
best_score, best_idx = score, idx
|
|
|
|
| 298 |
end = min(start + max_next, len(steps))
|
| 299 |
return steps[start:end]
|
| 300 |
|
| 301 |
+
|
| 302 |
def _format_steps_as_numbered(steps: list) -> str:
|
| 303 |
+
"""Render a small list of steps with circled numbers for visual continuity."""
|
| 304 |
+
circled = {
|
| 305 |
+
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 306 |
+
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
| 307 |
+
11: "\u246a", 12: "\u246b", 13: "\u246c", 14: "\u246d", 15: "\u246e",
|
| 308 |
+
16: "\u246f", 17: "\u2470", 18: "\u2471", 19: "\u2472", 20: "\u2473"
|
| 309 |
+
}
|
| 310 |
out = []
|
| 311 |
for i, s in enumerate(steps, start=1):
|
| 312 |
out.append(f"{circled.get(i, str(i))} {s}")
|
| 313 |
return "\n".join(out)
|
| 314 |
|
| 315 |
+
|
| 316 |
def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
|
| 317 |
"""
|
| 318 |
Pick the most relevant 'Common Errors & Resolution' bullet(s) for the user's message.
|
|
|
|
| 325 |
|
| 326 |
Returns exactly `max_lines` best-scoring lines (defaults to 1).
|
| 327 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
def _norm(s: str) -> str:
|
| 329 |
s = (s or "").lower()
|
| 330 |
s = re.sub(r"[^\w\s]", " ", s)
|
|
|
|
| 332 |
return s
|
| 333 |
|
| 334 |
def _ngrams(tokens: List[str], n: int) -> List[str]:
|
| 335 |
+
return [" ".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]
|
| 336 |
|
| 337 |
def _families_for(s: str) -> set:
|
| 338 |
low = _norm(s)
|
|
|
|
| 357 |
ln_norm = _norm(ln)
|
| 358 |
ln_fams = _families_for(ln)
|
| 359 |
|
| 360 |
+
fam_overlap = len(q_fams & ln_fams)
|
| 361 |
anchored = 0.0
|
| 362 |
first2 = " ".join(q_tokens[:2]) if len(q_tokens) >= 2 else ""
|
| 363 |
first3 = " ".join(q_tokens[:3]) if len(q_tokens) >= 3 else ""
|
|
|
|
| 369 |
token_overlap = sum(1 for t in q_tokens if t and t in ln_norm)
|
| 370 |
exact_phrase = 1.0 if (q and q in ln_norm) else 0.0
|
| 371 |
|
|
|
|
| 372 |
score = (
|
| 373 |
1.70 * fam_overlap +
|
| 374 |
1.00 * anchored +
|
|
|
|
| 378 |
0.30 * token_overlap
|
| 379 |
)
|
| 380 |
|
| 381 |
+
if re.match(r"^\s*[-*\u2022]\s*", ln): # bullet
|
| 382 |
score += 0.10
|
| 383 |
heading = ln_norm.split(":")[0].strip()
|
| 384 |
if heading and (heading in q or (first2 and first2 in heading)):
|
|
|
|
| 388 |
|
| 389 |
scored.sort(key=lambda x: x[0], reverse=True)
|
| 390 |
top = [ln for s, ln in scored[:max_lines] if s > 0.0]
|
|
|
|
| 391 |
if not top:
|
| 392 |
top = lines[:max_lines]
|
|
|
|
| 393 |
return "\n".join(top).strip()
|
| 394 |
|
| 395 |
+
|
| 396 |
def _friendly_permission_reply(raw: str) -> str:
|
| 397 |
line = (raw or "").strip()
|
| 398 |
+
line = re.sub(r"^\s*[-*\u2022]\s*", "", line)
|
| 399 |
if not line:
|
| 400 |
return "It looks like you may not have access for this action. Please verify your WMS role/permission with your supervisor or IT."
|
| 401 |
if "verify role access" in line.lower():
|
|
|
|
| 404 |
return f"It seems to be an access issue: {line}. Please check your role mapping or request access."
|
| 405 |
return line
|
| 406 |
|
| 407 |
+
|
| 408 |
def _detect_language_hint(msg: str) -> Optional[str]:
|
| 409 |
if re.search(r"[\u0B80-\u0BFF]", msg or ""): # Tamil
|
| 410 |
return "Tamil"
|
|
|
|
| 412 |
return "Hindi"
|
| 413 |
return None
|
| 414 |
|
| 415 |
+
|
| 416 |
def _build_clarifying_message() -> str:
|
| 417 |
+
return (
|
| 418 |
+
"It seems the issue isn’t resolved yet. Would you like to share a few details so I can check further, "
|
| 419 |
+
"or should I raise a ServiceNow ticket for you?"
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
|
| 423 |
def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
|
| 424 |
issue = (issue_text or "").strip()
|
|
|
|
| 431 |
).strip()
|
| 432 |
return short_desc, long_desc
|
| 433 |
|
| 434 |
+
|
| 435 |
def _is_incident_intent(msg_norm: str) -> bool:
|
| 436 |
intent_phrases = [
|
| 437 |
"create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
|
|
|
|
| 441 |
]
|
| 442 |
return any(p in msg_norm for p in intent_phrases)
|
| 443 |
|
| 444 |
+
|
| 445 |
def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
| 446 |
status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
|
| 447 |
base_has_status = any(k in msg_norm for k in status_keywords)
|
|
|
|
| 464 |
return {"number": val.upper() if val.lower().startswith("inc") else val}
|
| 465 |
return {"number": None, "ask_number": True}
|
| 466 |
|
| 467 |
+
|
| 468 |
def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
|
| 469 |
phrases = [
|
| 470 |
"it is resolved", "resolved", "issue resolved", "problem resolved",
|
|
|
|
| 473 |
]
|
| 474 |
return any(p in msg_norm for p in phrases)
|
| 475 |
|
| 476 |
+
|
| 477 |
def _has_negation_resolved(msg_norm: str) -> bool:
|
| 478 |
neg_phrases = [
|
| 479 |
"not resolved", "issue not resolved", "still not working", "not working",
|
|
|
|
| 481 |
]
|
| 482 |
return any(p in msg_norm for p in neg_phrases)
|
| 483 |
|
| 484 |
+
|
| 485 |
def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
|
|
|
|
| 486 |
STRICT_OVERLAP = 3
|
| 487 |
MAX_SENTENCES_STRICT = 4
|
| 488 |
MAX_SENTENCES_CONCISE = 3
|
|
|
|
| 494 |
return t
|
| 495 |
|
| 496 |
def _split_sentences(ctx: str) -> List[str]:
|
| 497 |
+
raw_sents = re.split(r"(?<=[.!?])\s+|\n+|-\s*|\*\s*", ctx or "")
|
| 498 |
return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
|
| 499 |
|
| 500 |
ctx = (context or "").strip()
|
|
|
|
| 523 |
kept = sentences[:MAX_SENTENCES_CONCISE]
|
| 524 |
return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
|
| 525 |
|
| 526 |
+
|
| 527 |
def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
| 528 |
"""
|
| 529 |
Collect error bullets/heading-style lines from the SOP errors section.
|
| 530 |
Generic: keeps bullet points (•, -, *), and lines that look like "Heading: details".
|
| 531 |
This ensures items like 'ASN not found', 'Trailer status locked', etc., are preserved.
|
| 532 |
"""
|
|
|
|
| 533 |
kept: List[str] = []
|
| 534 |
for ln in _normalize_lines(text):
|
| 535 |
+
if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
|
|
|
|
| 536 |
kept.append(ln)
|
| 537 |
if len(kept) >= max_lines:
|
| 538 |
break
|
|
|
|
| 539 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 540 |
|
| 541 |
+
|
| 542 |
def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
|
| 543 |
PERM_SYNONYMS = (
|
| 544 |
"permission", "permissions", "access", "authorization", "authorisation",
|
|
|
|
| 553 |
break
|
| 554 |
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 555 |
|
| 556 |
+
|
| 557 |
def _extract_escalation_line(text: str) -> Optional[str]:
|
| 558 |
if not text:
|
| 559 |
return None
|
|
|
|
| 592 |
path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
|
| 593 |
return f"If you want to escalate the issue, follow: {path}"
|
| 594 |
|
| 595 |
+
|
| 596 |
def _classify_resolution_llm(user_message: str) -> bool:
|
| 597 |
if not GEMINI_API_KEY:
|
| 598 |
return False
|
|
|
|
| 614 |
except Exception:
|
| 615 |
return False
|
| 616 |
|
| 617 |
+
|
| 618 |
def _set_incident_resolved(sys_id: str) -> bool:
|
| 619 |
try:
|
| 620 |
token = get_valid_token()
|
|
|
|
| 640 |
print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
|
| 641 |
except Exception as e:
|
| 642 |
print(f"[SN PATCH progress] exception={safe_str(e)}")
|
| 643 |
+
|
| 644 |
def clean(d: dict) -> dict:
|
| 645 |
return {k: v for k, v in d.items() if v is not None}
|
| 646 |
+
|
| 647 |
payload_A = clean({
|
| 648 |
"state": "6",
|
| 649 |
"close_code": close_code_val,
|
|
|
|
| 658 |
if respA.status_code in (200, 204):
|
| 659 |
return True
|
| 660 |
print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
|
| 661 |
+
|
| 662 |
payload_B = clean({
|
| 663 |
"state": "Resolved",
|
| 664 |
"close_code": close_code_val,
|
|
|
|
| 673 |
if respB.status_code in (200, 204):
|
| 674 |
return True
|
| 675 |
print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
|
| 676 |
+
|
| 677 |
code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
|
| 678 |
notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
|
| 679 |
payload_C = clean({
|
|
|
|
| 695 |
print(f"[SN PATCH resolve] exception={safe_str(e)}")
|
| 696 |
return False
|
| 697 |
|
| 698 |
+
|
| 699 |
# ------------------------------ Prereq helper ------------------------------
|
| 700 |
def _find_prereq_section_text(best_doc: str) -> str:
|
| 701 |
"""
|
|
|
|
| 715 |
return txt.strip()
|
| 716 |
return ""
|
| 717 |
|
| 718 |
+
|
| 719 |
# ------------------------------ Health ------------------------------
|
| 720 |
@app.get("/")
|
| 721 |
async def health_check():
|
| 722 |
return {"status": "ok"}
|
| 723 |
|
| 724 |
+
|
| 725 |
# ------------------------------ Chat ------------------------------
|
| 726 |
@app.post("/chat")
|
| 727 |
async def chat_with_ai(input_data: ChatInput):
|
|
|
|
| 784 |
"ask_resolved": False,
|
| 785 |
"suggest_incident": True,
|
| 786 |
"followup": "Shall I create a ticket now?",
|
| 787 |
+
"options": [{"type": "yesno", "title": "Create ticket now?"}],
|
| 788 |
"top_hits": [],
|
| 789 |
"sources": [],
|
| 790 |
"debug": {"intent": "resolved_ack", "auto_created": False, "error": err},
|
|
|
|
| 797 |
"ask_resolved": False,
|
| 798 |
"suggest_incident": True,
|
| 799 |
"followup": "Shall I create a ticket now?",
|
| 800 |
+
"options": [{"type": "yesno", "title": "Create ticket now?"}],
|
| 801 |
"top_hits": [],
|
| 802 |
"sources": [],
|
| 803 |
"debug": {"intent": "resolved_ack", "exception": True},
|
|
|
|
| 891 |
score = distances[i] if i < len(distances) else None
|
| 892 |
comb = combined[i] if i < len(combined) else None
|
| 893 |
m = dict(meta)
|
| 894 |
+
if score is not None:
|
| 895 |
+
m["distance"] = score
|
| 896 |
+
if comb is not None:
|
| 897 |
+
m["combined"] = comb
|
| 898 |
items.append({"text": text, "meta": m})
|
| 899 |
|
| 900 |
selected = items[:max(1, 2)]
|
|
|
|
| 1000 |
"ask_resolved": False,
|
| 1001 |
"suggest_incident": True,
|
| 1002 |
"followup": "Share more details (module/screen/error), or say 'create ticket'.",
|
| 1003 |
+
"options": [{"type": "yesno", "title": "Share details or raise a ticket?"}],
|
| 1004 |
"top_hits": [],
|
| 1005 |
"sources": [],
|
| 1006 |
"debug": {
|
|
|
|
| 1013 |
"strong_steps_bypass": strong_steps_bypass,
|
| 1014 |
"strong_error_signal": strong_error_signal,
|
| 1015 |
"generic_error_signal": generic_error_signal
|
|
|
|
| 1016 |
},
|
| 1017 |
}
|
| 1018 |
|
|
|
|
| 1022 |
|
| 1023 |
escalation_line = None # SOP escalation candidate
|
| 1024 |
full_errors = None # keep for possible escalation extraction
|
| 1025 |
+
next_step_applied = False
|
| 1026 |
+
next_step_info: Dict[str, Any] = {}
|
| 1027 |
|
| 1028 |
if best_doc:
|
| 1029 |
if detected_intent == "steps":
|
| 1030 |
full_steps = get_best_steps_section_text(best_doc)
|
| 1031 |
if not full_steps:
|
| 1032 |
sec = (top_meta or {}).get("section")
|
| 1033 |
+
if sec:
|
| 1034 |
+
full_steps = get_section_text(best_doc, sec)
|
| 1035 |
+
|
| 1036 |
if full_steps:
|
| 1037 |
+
# Use numbered form only for matching; keep raw for full output
|
| 1038 |
+
numbered_full = _ensure_numbering(full_steps)
|
| 1039 |
+
next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
|
| 1040 |
+
|
| 1041 |
+
if next_only is not None:
|
| 1042 |
+
# "what's next" mode
|
| 1043 |
+
if len(next_only) == 0:
|
| 1044 |
+
context = "You are at the final step of this SOP. No further steps."
|
| 1045 |
+
next_step_applied = True
|
| 1046 |
+
next_step_info = {"count": 0}
|
| 1047 |
+
context_preformatted = True
|
| 1048 |
+
else:
|
| 1049 |
+
context = _format_steps_as_numbered(next_only)
|
| 1050 |
+
next_step_applied = True
|
| 1051 |
+
next_step_info = {"count": len(next_only)}
|
| 1052 |
+
context_preformatted = True
|
| 1053 |
+
else:
|
| 1054 |
+
# Normal mode: return the full SOP section (raw),
|
| 1055 |
+
# and we'll number it below once.
|
| 1056 |
+
context = full_steps
|
| 1057 |
+
context_preformatted = False
|
| 1058 |
+
|
| 1059 |
elif detected_intent == "errors":
|
| 1060 |
full_errors = get_best_errors_section_text(best_doc)
|
|
|
|
|
|
|
| 1061 |
if full_errors:
|
| 1062 |
ctx_err = _extract_errors_only(full_errors, max_lines=30)
|
| 1063 |
if is_perm_query:
|
| 1064 |
context = _filter_permission_lines(ctx_err, max_lines=6)
|
| 1065 |
else:
|
| 1066 |
# Decide specific vs generic:
|
|
|
|
|
|
|
| 1067 |
is_specific_error = len(_detect_error_families(msg_low)) > 0
|
| 1068 |
if is_specific_error:
|
| 1069 |
+
context = _filter_error_lines_by_query(ctx_err, input_data.user_message, max_lines=1)
|
| 1070 |
else:
|
|
|
|
| 1071 |
all_lines: List[str] = _normalize_lines(ctx_err)
|
| 1072 |
+
error_bullets = [ln for ln in all_lines if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln)]
|
|
|
|
| 1073 |
context = "\n".join(error_bullets[:8]).strip()
|
| 1074 |
assist_followup = (
|
| 1075 |
+
"Please tell me which error above matches your screen (paste the exact text), "
|
| 1076 |
+
"or share a screenshot. I can guide you further or raise a ServiceNow ticket."
|
| 1077 |
+
)
|
| 1078 |
escalation_line = _extract_escalation_line(full_errors)
|
| 1079 |
|
| 1080 |
elif detected_intent == "prereqs":
|
|
|
|
| 1116 |
|
| 1117 |
# Deterministic local formatting
|
| 1118 |
if detected_intent == "steps":
|
| 1119 |
+
# If we trimmed to next steps, 'context' is already formatted (or a sentence).
|
| 1120 |
+
# Only number when returning full SOP raw text.
|
| 1121 |
+
if ('context_preformatted' in locals()) and context_preformatted:
|
| 1122 |
+
bot_text = context
|
| 1123 |
+
else:
|
| 1124 |
+
bot_text = _ensure_numbering(context)
|
| 1125 |
+
|
| 1126 |
elif detected_intent == "errors":
|
| 1127 |
if not bot_text.strip() or http_code == 429:
|
| 1128 |
bot_text = context.strip()
|
| 1129 |
if escalation_line:
|
| 1130 |
bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
|
| 1131 |
+
|
| 1132 |
else:
|
| 1133 |
bot_text = context
|
| 1134 |
|
|
|
|
| 1158 |
lower = (bot_text or "").lower()
|
| 1159 |
if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
|
| 1160 |
status = "PARTIAL"
|
| 1161 |
+
options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
|
| 1162 |
+
|
| 1163 |
return {
|
| 1164 |
"bot_response": bot_text,
|
| 1165 |
"status": status,
|
|
|
|
| 1179 |
"matched_count": filt_info.get("matched_count"),
|
| 1180 |
"user_intent": detected_intent,
|
| 1181 |
"best_doc": best_doc,
|
| 1182 |
+
"next_step": {
|
| 1183 |
+
"applied": next_step_applied,
|
| 1184 |
+
"info": next_step_info,
|
| 1185 |
+
},
|
| 1186 |
},
|
| 1187 |
}
|
| 1188 |
except HTTPException:
|
|
|
|
| 1190 |
except Exception as e:
|
| 1191 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1192 |
|
| 1193 |
+
|
| 1194 |
# ------------------------------ Ticket description generation ------------------------------
|
| 1195 |
@app.post("/generate_ticket_desc")
|
| 1196 |
async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
|
|
|
| 1229 |
except Exception as e:
|
| 1230 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1231 |
|
| 1232 |
+
|
| 1233 |
# ------------------------------ Incident status ------------------------------
|
| 1234 |
@app.post("/incident_status")
|
| 1235 |
async def incident_status(input_data: TicketStatusInput):
|
|
|
|
| 1270 |
except Exception as e:
|
| 1271 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1272 |
|
| 1273 |
+
|
| 1274 |
# ------------------------------ Incident ------------------------------
|
| 1275 |
@app.post("/incident")
|
| 1276 |
async def raise_incident(input_data: IncidentInput):
|