Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -216,6 +216,61 @@ def _get_steps_by_title_keywords_global(keywords: List[str], prefer_doc: Optiona
|
|
| 216 |
return best_doc_g, best_sec_g, best_text_g
|
| 217 |
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
# --- Action-targeted steps selector (uses existing KB metadata) ---
|
| 220 |
def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
|
| 221 |
"""
|
|
@@ -227,7 +282,6 @@ def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
|
|
| 227 |
if not best_doc or not actions:
|
| 228 |
return None
|
| 229 |
act_set = set(a.strip().lower() for a in actions if a)
|
| 230 |
-
# Collect candidate sections in this doc that are 'steps' and have an action_tag we need
|
| 231 |
candidates = []
|
| 232 |
for d in bm25_docs:
|
| 233 |
m = d.get("meta", {})
|
|
@@ -235,7 +289,6 @@ def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
|
|
| 235 |
tag = (m.get("action_tag") or "").strip().lower()
|
| 236 |
if tag and tag in act_set:
|
| 237 |
candidates.append(m.get("section"))
|
| 238 |
-
# Prefer the first matched section with non-empty text
|
| 239 |
for title in candidates:
|
| 240 |
txt = get_section_text(best_doc, title)
|
| 241 |
if txt and txt.strip():
|
|
@@ -402,11 +455,6 @@ def _norm_text(s: str) -> str:
|
|
| 402 |
|
| 403 |
|
| 404 |
def _split_sop_into_steps(numbered_text: str) -> list:
|
| 405 |
-
"""
|
| 406 |
-
Split a numbered/bulleted SOP block (already passed through _ensure_numbering)
|
| 407 |
-
into atomic steps. Returns a list of raw step strings (order preserved).
|
| 408 |
-
Safe for circled digits, '1.' styles, and bullets.
|
| 409 |
-
"""
|
| 410 |
lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
|
| 411 |
steps = []
|
| 412 |
for ln in lines:
|
|
@@ -417,7 +465,6 @@ def _split_sop_into_steps(numbered_text: str) -> list:
|
|
| 417 |
|
| 418 |
|
| 419 |
def _soft_match_score(a: str, b: str) -> float:
|
| 420 |
-
# Simple Jaccard-like score on tokens for fuzzy matching
|
| 421 |
ta = set(_norm_text(a).split())
|
| 422 |
tb = set(_norm_text(b).split())
|
| 423 |
if not ta or not tb:
|
|
@@ -437,38 +484,27 @@ def _detect_next_intent(user_query: str) -> bool:
|
|
| 437 |
|
| 438 |
|
| 439 |
def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
|
| 440 |
-
"""
|
| 441 |
-
If 'what's next' intent is detected and we can reliably match the user's
|
| 442 |
-
referenced line to a SOP step, return ONLY the subsequent steps.
|
| 443 |
-
Else return None to preserve current behavior.
|
| 444 |
-
"""
|
| 445 |
if not _detect_next_intent(user_query):
|
| 446 |
return None
|
| 447 |
-
|
| 448 |
steps = _split_sop_into_steps(numbered_text)
|
| 449 |
if not steps:
|
| 450 |
return None
|
| 451 |
-
|
| 452 |
q = user_query or ""
|
| 453 |
best_idx, best_score = -1, -1.0
|
| 454 |
for idx, step in enumerate(steps):
|
| 455 |
score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
|
| 456 |
if score > best_score:
|
| 457 |
best_score, best_idx = score, idx
|
| 458 |
-
|
| 459 |
if best_idx < 0 or best_score < min_score:
|
| 460 |
-
return None
|
| 461 |
-
|
| 462 |
start = best_idx + 1
|
| 463 |
if start >= len(steps):
|
| 464 |
-
return []
|
| 465 |
-
|
| 466 |
end = min(start + max_next, len(steps))
|
| 467 |
return steps[start:end]
|
| 468 |
|
| 469 |
|
| 470 |
def _format_steps_as_numbered(steps: list) -> str:
|
| 471 |
-
"""Render a small list of steps with circled numbers for visual continuity."""
|
| 472 |
circled = {
|
| 473 |
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 474 |
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
|
@@ -482,17 +518,6 @@ def _format_steps_as_numbered(steps: list) -> str:
|
|
| 482 |
|
| 483 |
|
| 484 |
def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
|
| 485 |
-
"""
|
| 486 |
-
Pick the most relevant 'Common Errors & Resolution' bullet(s) for the user's message.
|
| 487 |
-
Generic (SOP-agnostic) scoring:
|
| 488 |
-
1) error-family match (NOT_FOUND/MISMATCH/LOCKED/PERMISSION/TIMEOUT/SYNC),
|
| 489 |
-
2) anchored starts (line begins with error heading),
|
| 490 |
-
3) multi-word overlap (bigrams/trigrams),
|
| 491 |
-
4) token overlap,
|
| 492 |
-
5) formatting bonus for bullets/headings.
|
| 493 |
-
|
| 494 |
-
Returns exactly `max_lines` best-scoring lines (defaults to 1).
|
| 495 |
-
"""
|
| 496 |
def _norm(s: str) -> str:
|
| 497 |
s = (s or "").lower()
|
| 498 |
s = re.sub(r"[^\w\s]", " ", s)
|
|
@@ -546,7 +571,7 @@ def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> s
|
|
| 546 |
0.30 * token_overlap
|
| 547 |
)
|
| 548 |
|
| 549 |
-
if re.match(r"^\s*[-*\u2022]\s*", ln):
|
| 550 |
score += 0.10
|
| 551 |
heading = ln_norm.split(":")[0].strip()
|
| 552 |
if heading and (heading in q or (first2 and first2 in heading)):
|
|
@@ -617,7 +642,6 @@ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
|
|
| 617 |
any(w in msg_norm for w in ("ticket", "incident", "servicenow", "snow")) or
|
| 618 |
bool(re.search(r"\binc\d{5,}\b", msg_norm, flags=re.IGNORECASE))
|
| 619 |
)
|
| 620 |
-
# Disambiguation: if it's a domain status query and not clearly ticket/incident, do NOT route to ticket-status.
|
| 621 |
if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
|
| 622 |
return {}
|
| 623 |
patterns = [
|
|
@@ -693,11 +717,6 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
|
|
| 693 |
|
| 694 |
|
| 695 |
def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
| 696 |
-
"""
|
| 697 |
-
Collect error bullets/heading-style lines from the SOP errors section.
|
| 698 |
-
Generic: keeps bullet points (•, -, *), and lines that look like "Heading: details".
|
| 699 |
-
This ensures items like 'ASN not found', 'Trailer status locked', etc., are preserved.
|
| 700 |
-
"""
|
| 701 |
kept: List[str] = []
|
| 702 |
for ln in _normalize_lines(text):
|
| 703 |
if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
|
|
@@ -768,7 +787,7 @@ def _classify_resolution_llm(user_message: str) -> bool:
|
|
| 768 |
Return only 'true' or 'false'.
|
| 769 |
Message: {user_message}"""
|
| 770 |
headers = {"Content-Type": "application/json"}
|
| 771 |
-
payload = {"contents": [{"parts": [{"text": prompt}]}]}
|
| 772 |
try:
|
| 773 |
resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
|
| 774 |
data = resp.json()
|
|
@@ -846,7 +865,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 846 |
notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
|
| 847 |
payload_C = clean({
|
| 848 |
"state": "6",
|
| 849 |
-
code_field: close_notes_val,
|
| 850 |
notes_field: close_notes_val,
|
| 851 |
"caller_id": caller_sysid,
|
| 852 |
"resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
|
@@ -866,10 +885,6 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 866 |
|
| 867 |
# ------------------------------ Prereq helper ------------------------------
|
| 868 |
def _find_prereq_section_text(best_doc: str) -> str:
|
| 869 |
-
"""
|
| 870 |
-
Return the prerequisites section text, trying common heading variants.
|
| 871 |
-
Generic for future SOPs—no document-specific keywords.
|
| 872 |
-
"""
|
| 873 |
variants = [
|
| 874 |
"Pre-Requisites",
|
| 875 |
"Prerequisites",
|
|
@@ -1044,7 +1059,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1044 |
except Exception as e:
|
| 1045 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1046 |
|
| 1047 |
-
# Hybrid KB search
|
| 1048 |
kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
|
| 1049 |
documents = kb_results.get("documents", [])
|
| 1050 |
metadatas = kb_results.get("metadatas", [])
|
|
@@ -1068,11 +1083,10 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1068 |
selected = items[:max(1, 2)]
|
| 1069 |
context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
|
| 1070 |
|
| 1071 |
-
# Compute filter info for gating only; do NOT use the filtered text for steps
|
| 1072 |
filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
|
| 1073 |
filtered_context = filtered_text
|
| 1074 |
|
| 1075 |
-
context = context_raw
|
| 1076 |
context_found = bool(context.strip())
|
| 1077 |
best_distance = min([d for d in distances if d is not None], default=None) if distances else None
|
| 1078 |
best_combined = max([c for c in combined if c is not None], default=None) if combined else None
|
|
@@ -1110,7 +1124,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1110 |
if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
|
| 1111 |
detected_intent = "prereqs"
|
| 1112 |
|
| 1113 |
-
# --- Module-aware steps nudge
|
| 1114 |
STEPS_TERMS = ("how to", "procedure", "perform", "steps", "do", "navigate")
|
| 1115 |
ACTIONS_PRESENT = any(s in msg_low for syns in ACTION_SYNONYMS_EXT.values() for s in syns)
|
| 1116 |
mod_tags = ((top_meta or {}).get("module_tags") or "").lower()
|
|
@@ -1194,97 +1208,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1194 |
return act
|
| 1195 |
return None
|
| 1196 |
|
| 1197 |
-
def _strip_boilerplate(raw_context: str) -> str:
|
| 1198 |
-
"""Remove document title/date/author/change-history noise from steps."""
|
| 1199 |
-
MONTH_TERMS = ("january", "february", "march", "april", "may", "june",
|
| 1200 |
-
"july", "august", "september", "october", "november", "december")
|
| 1201 |
-
lines = _normalize_lines(raw_context)
|
| 1202 |
-
cleaned: List[str] = []
|
| 1203 |
-
for ln in lines:
|
| 1204 |
-
low = ln.lower()
|
| 1205 |
-
is_change_hist = ("change history" in low) or ("initial draft" in low) or ("review" in low) or ("version" in low)
|
| 1206 |
-
has_month_year = any(m in low for m in MONTH_TERMS) and bool(re.search(r"\b20\d{2}\b", low))
|
| 1207 |
-
is_title_line = ("sop document" in low) or ("contents" in low)
|
| 1208 |
-
if is_change_hist or has_month_year or is_title_line:
|
| 1209 |
-
continue
|
| 1210 |
-
cleaned.append(ln)
|
| 1211 |
-
return "\n".join(cleaned).strip()
|
| 1212 |
-
|
| 1213 |
-
def _extract_action_block(raw_context: str, target_act: Optional[str]) -> str:
|
| 1214 |
-
"""
|
| 1215 |
-
Extract the contiguous block of lines for the target action (create/update/delete).
|
| 1216 |
-
Start when a line mentions the target action OR looks procedural for 'create',
|
| 1217 |
-
and stop ONLY when a line is a clear boundary:
|
| 1218 |
-
- inline heading for another topic (e.g., 'Appointment schedule updation', 'Appointment deletion'), OR
|
| 1219 |
-
- a line that strongly signals a different action (update/delete) via extended synonyms.
|
| 1220 |
-
"""
|
| 1221 |
-
if not raw_context.strip():
|
| 1222 |
-
return raw_context
|
| 1223 |
-
|
| 1224 |
-
lines = _normalize_lines(raw_context)
|
| 1225 |
-
if not lines or not target_act:
|
| 1226 |
-
return raw_context
|
| 1227 |
-
|
| 1228 |
-
INLINE_BOUNDARIES = (
|
| 1229 |
-
"appointment schedule updation",
|
| 1230 |
-
"schedule updation",
|
| 1231 |
-
"appointment deletion",
|
| 1232 |
-
"deletion",
|
| 1233 |
-
)
|
| 1234 |
-
|
| 1235 |
-
other_terms: List[str] = []
|
| 1236 |
-
for act, syns in ACTION_SYNONYMS_EXT.items():
|
| 1237 |
-
if act != target_act:
|
| 1238 |
-
other_terms.extend(syns)
|
| 1239 |
-
other_terms_low = set(t.lower() for t in other_terms)
|
| 1240 |
-
|
| 1241 |
-
def is_boundary_line(low: str) -> bool:
|
| 1242 |
-
if any(h in low for h in INLINE_BOUNDARIES):
|
| 1243 |
-
return True
|
| 1244 |
-
if any(t in low for t in other_terms_low):
|
| 1245 |
-
return True
|
| 1246 |
-
return False
|
| 1247 |
-
|
| 1248 |
-
PROCEDURAL_VERBS = ("select", "choose", "click", "open", "add", "assign", "save",
|
| 1249 |
-
"navigate", "tag", "displayed", "triggered")
|
| 1250 |
-
def is_procedural(low: str) -> bool:
|
| 1251 |
-
return any(v in low for v in PROCEDURAL_VERBS)
|
| 1252 |
-
|
| 1253 |
-
target_terms_low = set(t.lower() for t in ACTION_SYNONYMS_EXT.get(target_act, []))
|
| 1254 |
-
|
| 1255 |
-
started = False
|
| 1256 |
-
block: List[str] = []
|
| 1257 |
-
|
| 1258 |
-
for ln in lines:
|
| 1259 |
-
low = ln.lower()
|
| 1260 |
-
|
| 1261 |
-
contains_target = any(t in low for t in target_terms_low)
|
| 1262 |
-
if not started:
|
| 1263 |
-
if contains_target or (target_act == "create" and is_procedural(low)):
|
| 1264 |
-
started = True
|
| 1265 |
-
block.append(ln)
|
| 1266 |
-
continue
|
| 1267 |
-
|
| 1268 |
-
if is_boundary_line(low):
|
| 1269 |
-
break
|
| 1270 |
-
|
| 1271 |
-
block.append(ln)
|
| 1272 |
-
|
| 1273 |
-
return "\n".join(block).strip() if block else raw_context
|
| 1274 |
-
|
| 1275 |
-
def _filter_steps_by_action(raw_context: str, asked_act: Optional[str]) -> str:
|
| 1276 |
-
cleaned = _strip_boilerplate(raw_context)
|
| 1277 |
-
block = _extract_action_block(cleaned, asked_act)
|
| 1278 |
-
if asked_act:
|
| 1279 |
-
other_terms: List[str] = []
|
| 1280 |
-
for act, syns in ACTION_SYNONYMS_EXT.items():
|
| 1281 |
-
if act != asked_act:
|
| 1282 |
-
other_terms.extend(syns)
|
| 1283 |
-
lines = _normalize_lines(block)
|
| 1284 |
-
lines = [ln for ln in lines if not any(t in ln.lower() for t in other_terms)]
|
| 1285 |
-
block = "\n".join(lines).strip() if lines else block
|
| 1286 |
-
return block
|
| 1287 |
-
|
| 1288 |
escalation_line = None
|
| 1289 |
full_errors = None
|
| 1290 |
next_step_applied = False
|
|
@@ -1315,7 +1238,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1315 |
best_doc = alt_doc2
|
| 1316 |
full_steps = alt_steps2
|
| 1317 |
|
| 1318 |
-
# 1c) Title
|
| 1319 |
if not full_steps:
|
| 1320 |
if asked_action == "update":
|
| 1321 |
kd = ["updation", "update", "reschedule"]
|
|
@@ -1330,7 +1253,16 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1330 |
best_doc = alt_doc3
|
| 1331 |
full_steps = alt_steps3
|
| 1332 |
|
| 1333 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1334 |
if not full_steps:
|
| 1335 |
default_sec = _pick_default_action_section(best_doc)
|
| 1336 |
if default_sec:
|
|
@@ -1350,11 +1282,8 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1350 |
if not any(s in low_steps for s in SAVE_SYNS):
|
| 1351 |
full_steps = (full_steps or "").rstrip() + "\n" + save_lines
|
| 1352 |
|
| 1353 |
-
#
|
| 1354 |
-
|
| 1355 |
-
full_steps = _filter_steps_by_action(full_steps, asked_action)
|
| 1356 |
-
|
| 1357 |
-
# Number steps and apply next-step logic
|
| 1358 |
numbered_full = _ensure_numbering(full_steps)
|
| 1359 |
next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
|
| 1360 |
|
|
@@ -1373,7 +1302,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1373 |
context = full_steps
|
| 1374 |
context_preformatted = False
|
| 1375 |
|
| 1376 |
-
# Clear filter info for debug clarity
|
| 1377 |
filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
|
| 1378 |
context_found = True
|
| 1379 |
|
|
@@ -1399,8 +1327,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1399 |
else:
|
| 1400 |
full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
|
| 1401 |
if full_steps:
|
| 1402 |
-
asked_action = _detect_action_from_query(input_data.user_message)
|
| 1403 |
-
full_steps = _filter_steps_by_action(full_steps, asked_action)
|
| 1404 |
context = full_steps
|
| 1405 |
detected_intent = "steps"
|
| 1406 |
context_preformatted = False
|
|
@@ -1412,14 +1338,11 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1412 |
else:
|
| 1413 |
full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
|
| 1414 |
if full_steps:
|
| 1415 |
-
asked_action = _detect_action_from_query(input_data.user_message)
|
| 1416 |
-
full_steps = _filter_steps_by_action(full_steps, asked_action)
|
| 1417 |
context = full_steps
|
| 1418 |
detected_intent = "steps"
|
| 1419 |
context_preformatted = False
|
| 1420 |
|
| 1421 |
else:
|
| 1422 |
-
# Neutral or other intents: use filtered context
|
| 1423 |
context = filtered_context
|
| 1424 |
|
| 1425 |
language_hint = _detect_language_hint(input_data.user_message)
|
|
@@ -1436,7 +1359,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1436 |
### Output
|
| 1437 |
Return ONLY the rewritten guidance."""
|
| 1438 |
headers = {"Content-Type": "application/json"}
|
| 1439 |
-
payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
|
| 1440 |
bot_text = ""
|
| 1441 |
http_code = 0
|
| 1442 |
if use_gemini and GEMINI_API_KEY:
|
|
@@ -1548,7 +1471,7 @@ async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
|
| 1548 |
"Do not include any extra text, comments, or explanations outside the JSON."
|
| 1549 |
)
|
| 1550 |
headers = {"Content-Type": "application/json"}
|
| 1551 |
-
payload = {"contents": [{"parts": [{"text": prompt}]}]}
|
| 1552 |
resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
|
| 1553 |
try:
|
| 1554 |
data = resp.json()
|
|
|
|
| 216 |
return best_doc_g, best_sec_g, best_text_g
|
| 217 |
|
| 218 |
|
| 219 |
+
# --- Title or text global selector (ultimate fallback) ---
|
| 220 |
+
def _get_steps_by_title_or_text_global(keywords: List[str], prefer_doc: Optional[str] = None) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
| 221 |
+
"""
|
| 222 |
+
Search ALL SOP docs for a 'steps' section where either the section title OR any step text
|
| 223 |
+
contains any of `keywords`. Returns (doc_name, section_title, steps_text).
|
| 224 |
+
"""
|
| 225 |
+
if not keywords:
|
| 226 |
+
return None, None, None
|
| 227 |
+
keys_low = [k.strip().lower() for k in keywords if k and k.strip()]
|
| 228 |
+
|
| 229 |
+
# (score, doc, sec, text)
|
| 230 |
+
candidates: List[Tuple[float, str, str, str]] = []
|
| 231 |
+
seen = set()
|
| 232 |
+
|
| 233 |
+
for d in bm25_docs:
|
| 234 |
+
m = d.get("meta", {}) or {}
|
| 235 |
+
if m.get("intent_tag") != "steps":
|
| 236 |
+
continue
|
| 237 |
+
doc = m.get("filename")
|
| 238 |
+
sec = (m.get("section") or "").strip()
|
| 239 |
+
txt = (d.get("text") or "").strip()
|
| 240 |
+
if not doc or not sec or not txt:
|
| 241 |
+
continue
|
| 242 |
+
key_hit = False
|
| 243 |
+
sec_low = sec.lower()
|
| 244 |
+
txt_low = txt.lower()
|
| 245 |
+
if any(k in sec_low for k in keys_low) or any(k in txt_low for k in keys_low):
|
| 246 |
+
key_hit = True
|
| 247 |
+
if not key_hit:
|
| 248 |
+
continue
|
| 249 |
+
|
| 250 |
+
# aggregate once per (doc, sec)
|
| 251 |
+
if (doc, sec) in seen:
|
| 252 |
+
continue
|
| 253 |
+
full = get_section_text(doc, sec)
|
| 254 |
+
if not full or not full.strip():
|
| 255 |
+
continue
|
| 256 |
+
|
| 257 |
+
score = 1.0
|
| 258 |
+
if prefer_doc and doc == prefer_doc:
|
| 259 |
+
score += 0.4
|
| 260 |
+
mtags = (m.get("module_tags") or "").lower()
|
| 261 |
+
if "appointments" in mtags:
|
| 262 |
+
score += 0.3
|
| 263 |
+
candidates.append((score, doc, sec, full.strip()))
|
| 264 |
+
seen.add((doc, sec))
|
| 265 |
+
|
| 266 |
+
if not candidates:
|
| 267 |
+
return None, None, None
|
| 268 |
+
|
| 269 |
+
candidates.sort(key=lambda x: x[0], reverse=True)
|
| 270 |
+
_, bdoc, bsec, btext = candidates[0]
|
| 271 |
+
return bdoc, bsec, btext
|
| 272 |
+
|
| 273 |
+
|
| 274 |
# --- Action-targeted steps selector (uses existing KB metadata) ---
|
| 275 |
def _get_steps_for_action(best_doc: str, actions: list) -> Optional[str]:
|
| 276 |
"""
|
|
|
|
| 282 |
if not best_doc or not actions:
|
| 283 |
return None
|
| 284 |
act_set = set(a.strip().lower() for a in actions if a)
|
|
|
|
| 285 |
candidates = []
|
| 286 |
for d in bm25_docs:
|
| 287 |
m = d.get("meta", {})
|
|
|
|
| 289 |
tag = (m.get("action_tag") or "").strip().lower()
|
| 290 |
if tag and tag in act_set:
|
| 291 |
candidates.append(m.get("section"))
|
|
|
|
| 292 |
for title in candidates:
|
| 293 |
txt = get_section_text(best_doc, title)
|
| 294 |
if txt and txt.strip():
|
|
|
|
| 455 |
|
| 456 |
|
| 457 |
def _split_sop_into_steps(numbered_text: str) -> list:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
lines = [ln.strip() for ln in (numbered_text or "").splitlines() if ln.strip()]
|
| 459 |
steps = []
|
| 460 |
for ln in lines:
|
|
|
|
| 465 |
|
| 466 |
|
| 467 |
def _soft_match_score(a: str, b: str) -> float:
|
|
|
|
| 468 |
ta = set(_norm_text(a).split())
|
| 469 |
tb = set(_norm_text(b).split())
|
| 470 |
if not ta or not tb:
|
|
|
|
| 484 |
|
| 485 |
|
| 486 |
def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 6, min_score: float = 0.35):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
if not _detect_next_intent(user_query):
|
| 488 |
return None
|
|
|
|
| 489 |
steps = _split_sop_into_steps(numbered_text)
|
| 490 |
if not steps:
|
| 491 |
return None
|
|
|
|
| 492 |
q = user_query or ""
|
| 493 |
best_idx, best_score = -1, -1.0
|
| 494 |
for idx, step in enumerate(steps):
|
| 495 |
score = 1.0 if _norm_text(step) in _norm_text(q) else _soft_match_score(q, step)
|
| 496 |
if score > best_score:
|
| 497 |
best_score, best_idx = score, idx
|
|
|
|
| 498 |
if best_idx < 0 or best_score < min_score:
|
| 499 |
+
return None
|
|
|
|
| 500 |
start = best_idx + 1
|
| 501 |
if start >= len(steps):
|
| 502 |
+
return []
|
|
|
|
| 503 |
end = min(start + max_next, len(steps))
|
| 504 |
return steps[start:end]
|
| 505 |
|
| 506 |
|
| 507 |
def _format_steps_as_numbered(steps: list) -> str:
|
|
|
|
| 508 |
circled = {
|
| 509 |
1: "\u2460", 2: "\u2461", 3: "\u2462", 4: "\u2463", 5: "\u2464",
|
| 510 |
6: "\u2465", 7: "\u2466", 8: "\u2467", 9: "\u2468", 10: "\u2469",
|
|
|
|
| 518 |
|
| 519 |
|
| 520 |
def _filter_error_lines_by_query(text: str, query: str, max_lines: int = 1) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
def _norm(s: str) -> str:
|
| 522 |
s = (s or "").lower()
|
| 523 |
s = re.sub(r"[^\w\s]", " ", s)
|
|
|
|
| 571 |
0.30 * token_overlap
|
| 572 |
)
|
| 573 |
|
| 574 |
+
if re.match(r"^\s*[-*\u2022]\s*", ln):
|
| 575 |
score += 0.10
|
| 576 |
heading = ln_norm.split(":")[0].strip()
|
| 577 |
if heading and (heading in q or (first2 and first2 in heading)):
|
|
|
|
| 642 |
any(w in msg_norm for w in ("ticket", "incident", "servicenow", "snow")) or
|
| 643 |
bool(re.search(r"\binc\d{5,}\b", msg_norm, flags=re.IGNORECASE))
|
| 644 |
)
|
|
|
|
| 645 |
if (not base_has_status) or (base_has_status and not has_ticket_marker and _is_domain_status_context(msg_norm)):
|
| 646 |
return {}
|
| 647 |
patterns = [
|
|
|
|
| 717 |
|
| 718 |
|
| 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*[-*\u2022]\s*", ln) or (":" in ln):
|
|
|
|
| 787 |
Return only 'true' or 'false'.
|
| 788 |
Message: {user_message}"""
|
| 789 |
headers = {"Content-Type": "application/json"}
|
| 790 |
+
payload = {"contents": [{"parts": [{"text": prompt}]}]]}
|
| 791 |
try:
|
| 792 |
resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
|
| 793 |
data = resp.json()
|
|
|
|
| 865 |
notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
|
| 866 |
payload_C = clean({
|
| 867 |
"state": "6",
|
| 868 |
+
code_field: close_notes_val,
|
| 869 |
notes_field: close_notes_val,
|
| 870 |
"caller_id": caller_sysid,
|
| 871 |
"resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
| 885 |
|
| 886 |
# ------------------------------ Prereq helper ------------------------------
|
| 887 |
def _find_prereq_section_text(best_doc: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
variants = [
|
| 889 |
"Pre-Requisites",
|
| 890 |
"Prerequisites",
|
|
|
|
| 1059 |
except Exception as e:
|
| 1060 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1061 |
|
| 1062 |
+
# Hybrid KB search (keep raw; filter only used for gating)
|
| 1063 |
kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
|
| 1064 |
documents = kb_results.get("documents", [])
|
| 1065 |
metadatas = kb_results.get("metadatas", [])
|
|
|
|
| 1083 |
selected = items[:max(1, 2)]
|
| 1084 |
context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
|
| 1085 |
|
|
|
|
| 1086 |
filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
|
| 1087 |
filtered_context = filtered_text
|
| 1088 |
|
| 1089 |
+
context = context_raw
|
| 1090 |
context_found = bool(context.strip())
|
| 1091 |
best_distance = min([d for d in distances if d is not None], default=None) if distances else None
|
| 1092 |
best_combined = max([c for c in combined if c is not None], default=None) if combined else None
|
|
|
|
| 1124 |
if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
|
| 1125 |
detected_intent = "prereqs"
|
| 1126 |
|
| 1127 |
+
# --- Module-aware steps nudge ---
|
| 1128 |
STEPS_TERMS = ("how to", "procedure", "perform", "steps", "do", "navigate")
|
| 1129 |
ACTIONS_PRESENT = any(s in msg_low for syns in ACTION_SYNONYMS_EXT.values() for s in syns)
|
| 1130 |
mod_tags = ((top_meta or {}).get("module_tags") or "").lower()
|
|
|
|
| 1208 |
return act
|
| 1209 |
return None
|
| 1210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
escalation_line = None
|
| 1212 |
full_errors = None
|
| 1213 |
next_step_applied = False
|
|
|
|
| 1238 |
best_doc = alt_doc2
|
| 1239 |
full_steps = alt_steps2
|
| 1240 |
|
| 1241 |
+
# 1c) Title keywords global fallback
|
| 1242 |
if not full_steps:
|
| 1243 |
if asked_action == "update":
|
| 1244 |
kd = ["updation", "update", "reschedule"]
|
|
|
|
| 1253 |
best_doc = alt_doc3
|
| 1254 |
full_steps = alt_steps3
|
| 1255 |
|
| 1256 |
+
# 1d) Title OR text global fallback (ultimate)
|
| 1257 |
+
if not full_steps:
|
| 1258 |
+
alt_doc4, alt_sec4, alt_steps4 = _get_steps_by_title_or_text_global(
|
| 1259 |
+
["updation", "update", "deletion", "delete", "cancel", "creation"], prefer_doc=best_doc
|
| 1260 |
+
)
|
| 1261 |
+
if alt_steps4:
|
| 1262 |
+
best_doc = alt_doc4
|
| 1263 |
+
full_steps = alt_steps4
|
| 1264 |
+
|
| 1265 |
+
# Existing local fallbacks (doc-scoped)
|
| 1266 |
if not full_steps:
|
| 1267 |
default_sec = _pick_default_action_section(best_doc)
|
| 1268 |
if default_sec:
|
|
|
|
| 1282 |
if not any(s in low_steps for s in SAVE_SYNS):
|
| 1283 |
full_steps = (full_steps or "").rstrip() + "\n" + save_lines
|
| 1284 |
|
| 1285 |
+
# IMPORTANT: Do NOT trim steps by action — return full section
|
| 1286 |
+
# (We keep "what's next" behavior only.)
|
|
|
|
|
|
|
|
|
|
| 1287 |
numbered_full = _ensure_numbering(full_steps)
|
| 1288 |
next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
|
| 1289 |
|
|
|
|
| 1302 |
context = full_steps
|
| 1303 |
context_preformatted = False
|
| 1304 |
|
|
|
|
| 1305 |
filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
|
| 1306 |
context_found = True
|
| 1307 |
|
|
|
|
| 1327 |
else:
|
| 1328 |
full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
|
| 1329 |
if full_steps:
|
|
|
|
|
|
|
| 1330 |
context = full_steps
|
| 1331 |
detected_intent = "steps"
|
| 1332 |
context_preformatted = False
|
|
|
|
| 1338 |
else:
|
| 1339 |
full_steps = get_best_steps_section_text(best_doc) or get_section_text(best_doc, sec_title or "")
|
| 1340 |
if full_steps:
|
|
|
|
|
|
|
| 1341 |
context = full_steps
|
| 1342 |
detected_intent = "steps"
|
| 1343 |
context_preformatted = False
|
| 1344 |
|
| 1345 |
else:
|
|
|
|
| 1346 |
context = filtered_context
|
| 1347 |
|
| 1348 |
language_hint = _detect_language_hint(input_data.user_message)
|
|
|
|
| 1359 |
### Output
|
| 1360 |
Return ONLY the rewritten guidance."""
|
| 1361 |
headers = {"Content-Type": "application/json"}
|
| 1362 |
+
payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]]}
|
| 1363 |
bot_text = ""
|
| 1364 |
http_code = 0
|
| 1365 |
if use_gemini and GEMINI_API_KEY:
|
|
|
|
| 1471 |
"Do not include any extra text, comments, or explanations outside the JSON."
|
| 1472 |
)
|
| 1473 |
headers = {"Content-Type": "application/json"}
|
| 1474 |
+
payload = {"contents": [{"parts": [{"text": prompt}]}]]}
|
| 1475 |
resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
|
| 1476 |
try:
|
| 1477 |
data = resp.json()
|