Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -227,14 +227,13 @@ def _get_steps_for_action_global(actions: list, prefer_doc: Optional[str] = None
|
|
| 227 |
candidates.sort(key=lambda x: x[0], reverse=True)
|
| 228 |
_, best_doc_global, best_text = candidates[0]
|
| 229 |
return best_doc_global, best_text
|
| 230 |
-
|
| 231 |
def _pick_default_action_section(best_doc: str) -> Optional[str]:
|
| 232 |
"""
|
| 233 |
Backward-compatible wrapper: prefer-action version with no preference.
|
| 234 |
"""
|
| 235 |
return _pick_default_action_section_with_preference(best_doc, None)
|
| 236 |
|
| 237 |
-
|
| 238 |
def _pick_default_action_section_with_preference(best_doc: str, prefer_action: Optional[str]) -> Optional[str]:
|
| 239 |
"""
|
| 240 |
Generic: if there are sections tagged with the requested action (via action_tag),
|
|
@@ -265,8 +264,7 @@ def _pick_default_action_section_with_preference(best_doc: str, prefer_action: O
|
|
| 265 |
return t
|
| 266 |
return sections[0] if sections else None
|
| 267 |
|
| 268 |
-
# ------------------------------ Action -> section selector (
|
| 269 |
-
# Maps user action to likely section name tokens (lowercase)
|
| 270 |
ACTION_SECTION_KEYS = {
|
| 271 |
"create": ("create", "creation", "appointment creation", "new appointment", "book", "schedule"),
|
| 272 |
"update": ("update", "updation", "reschedule", "change", "modify", "edit"),
|
|
@@ -274,17 +272,11 @@ ACTION_SECTION_KEYS = {
|
|
| 274 |
}
|
| 275 |
|
| 276 |
def _pick_action_section(best_doc: str, action: Optional[str]) -> Optional[str]:
|
| 277 |
-
"""
|
| 278 |
-
Return the exact section title (original casing) inside `best_doc` whose name
|
| 279 |
-
contains any token for the given action. Falls back to None.
|
| 280 |
-
"""
|
| 281 |
if not best_doc or not action:
|
| 282 |
return None
|
| 283 |
keys = ACTION_SECTION_KEYS.get(action.lower(), ())
|
| 284 |
if not keys:
|
| 285 |
return None
|
| 286 |
-
|
| 287 |
-
# scan available sections for this doc
|
| 288 |
candidates = []
|
| 289 |
for d in bm25_docs:
|
| 290 |
m = d.get("meta", {}) or {}
|
|
@@ -294,32 +286,24 @@ def _pick_action_section(best_doc: str, action: Optional[str]) -> Optional[str]:
|
|
| 294 |
continue
|
| 295 |
low = sec.lower()
|
| 296 |
if any(k in low for k in keys):
|
| 297 |
-
# prefer exact action match over generic words
|
| 298 |
score = 1.0
|
| 299 |
if action.lower() in low:
|
| 300 |
score += 0.5
|
| 301 |
-
# appointments module slight boost
|
| 302 |
mtags = (m.get("module_tags") or "").lower()
|
| 303 |
if "appointments" in mtags:
|
| 304 |
score += 0.2
|
| 305 |
candidates.append((score, sec))
|
| 306 |
-
|
| 307 |
if not candidates:
|
| 308 |
return None
|
| 309 |
-
|
| 310 |
-
# pick best scored section title
|
| 311 |
candidates.sort(key=lambda x: x[0], reverse=True)
|
| 312 |
-
return candidates[0][1]
|
| 313 |
|
| 314 |
def _get_steps_text_for_action(best_doc: str, action: Optional[str]) -> Optional[str]:
|
| 315 |
-
"""
|
| 316 |
-
Use the section title returned by `_pick_action_section` to fetch its text.
|
| 317 |
-
"""
|
| 318 |
sec = _pick_action_section(best_doc, action)
|
| 319 |
if not sec:
|
| 320 |
return None
|
| 321 |
txt = get_section_text(best_doc, sec)
|
| 322 |
-
return (txt or "").strip() or None
|
| 323 |
|
| 324 |
# ------------------------------ Save lines helpers ------------------------------
|
| 325 |
SAVE_SYNS = ("save", "saving", "save changes", "click save", "press save", "select save")
|
|
@@ -334,30 +318,52 @@ def _find_save_lines_in_section(section_text: str, max_lines: int = 2) -> str:
|
|
| 334 |
break
|
| 335 |
return "\n".join(lines)
|
| 336 |
|
| 337 |
-
# ------------------------------
|
| 338 |
-
|
| 339 |
-
"
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
"
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
if not (section_text or "").strip():
|
| 347 |
return section_text
|
|
|
|
|
|
|
|
|
|
| 348 |
lines = [ln for ln in (section_text or "").splitlines() if ln.strip()]
|
| 349 |
out: List[str] = []
|
|
|
|
| 350 |
for ln in lines:
|
| 351 |
low = ln.lower().strip()
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
break
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
| 359 |
break
|
|
|
|
| 360 |
out.append(ln)
|
|
|
|
| 361 |
return "\n".join(out).strip()
|
| 362 |
|
| 363 |
# ------------------------------ Text normalization / numbering ------------------------------
|
|
@@ -602,58 +608,6 @@ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[s
|
|
| 602 |
f"Tracking record created automatically by NOVA."
|
| 603 |
).strip()
|
| 604 |
return short_desc, long_desc
|
| 605 |
-
|
| 606 |
-
def _build_doc_section_index(best_doc: str) -> Dict[str, Optional[str]]:
|
| 607 |
-
"""
|
| 608 |
-
Build a dictionary for the given doc:
|
| 609 |
-
{ lower(section_title): lower(action_tag or None) }
|
| 610 |
-
"""
|
| 611 |
-
index: Dict[str, Optional[str]] = {}
|
| 612 |
-
for d in bm25_docs:
|
| 613 |
-
m = d.get("meta", {}) or {}
|
| 614 |
-
if m.get("filename") == best_doc and m.get("intent_tag") == "steps":
|
| 615 |
-
sec = (m.get("section") or "").strip()
|
| 616 |
-
tag = (m.get("action_tag") or "").strip().lower() or None
|
| 617 |
-
if sec:
|
| 618 |
-
index[sec.lower()] = tag
|
| 619 |
-
return index
|
| 620 |
-
|
| 621 |
-
def _cut_at_next_boundary_generic(section_text: str, best_doc: str, current_action: Optional[str]) -> str:
|
| 622 |
-
"""
|
| 623 |
-
Stop when we hit any known section heading in the same doc.
|
| 624 |
-
If current_action is set, stop only when the next heading belongs to a different action_tag.
|
| 625 |
-
Fully generic: no hard-coded SOP words.
|
| 626 |
-
"""
|
| 627 |
-
if not (section_text or "").strip():
|
| 628 |
-
return section_text
|
| 629 |
-
|
| 630 |
-
index = _build_doc_section_index(best_doc)
|
| 631 |
-
known_headings = set(index.keys())
|
| 632 |
-
lines = [ln for ln in (section_text or "").splitlines() if ln.strip()]
|
| 633 |
-
out: List[str] = []
|
| 634 |
-
|
| 635 |
-
for ln in lines:
|
| 636 |
-
low = ln.lower().strip()
|
| 637 |
-
|
| 638 |
-
# If any known heading appears in the line, consider it a boundary.
|
| 639 |
-
# We use substring match so headings like "Update: ..." or "Delete – ..." work.
|
| 640 |
-
matched_heading = None
|
| 641 |
-
for h in known_headings:
|
| 642 |
-
if h in low:
|
| 643 |
-
matched_heading = h
|
| 644 |
-
break
|
| 645 |
-
|
| 646 |
-
if matched_heading:
|
| 647 |
-
next_action = index.get(matched_heading)
|
| 648 |
-
# If we know the current action, break only when the upcoming heading is a different action section.
|
| 649 |
-
# If current_action is None, break at the first heading we encounter.
|
| 650 |
-
if (current_action and next_action and next_action != current_action) or (current_action is None):
|
| 651 |
-
break
|
| 652 |
-
|
| 653 |
-
out.append(ln)
|
| 654 |
-
|
| 655 |
-
return "\n".join(out).strip()
|
| 656 |
-
|
| 657 |
|
| 658 |
# ------------------------------ Incident helpers ------------------------------
|
| 659 |
def _is_incident_intent(msg_norm: str) -> bool:
|
|
@@ -701,108 +655,6 @@ def _has_negation_resolved(msg_norm: str) -> bool:
|
|
| 701 |
]
|
| 702 |
return any(p in msg_norm for p in neg_phrases)
|
| 703 |
|
| 704 |
-
def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
|
| 705 |
-
STRICT_OVERLAP = 3
|
| 706 |
-
MAX_SENTENCES_STRICT = 4
|
| 707 |
-
MAX_SENTENCES_CONCISE = 3
|
| 708 |
-
|
| 709 |
-
def _norm(text: str) -> str:
|
| 710 |
-
t = (text or "").lower()
|
| 711 |
-
t = re.sub(r"[^\w\s]", " ", t)
|
| 712 |
-
t = re.sub(r"\s+", " ", t).strip()
|
| 713 |
-
return t
|
| 714 |
-
|
| 715 |
-
def _split_sentences(ctx: str) -> List[str]:
|
| 716 |
-
raw_sents = re.split(r"(?<=[.!?])\s+|\n+|-\s*|\*\s*", ctx or "")
|
| 717 |
-
return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
|
| 718 |
-
|
| 719 |
-
ctx = (context or "").strip()
|
| 720 |
-
if not ctx or not query:
|
| 721 |
-
return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
|
| 722 |
-
q_norm = _norm(query)
|
| 723 |
-
q_terms = [t for t in q_norm.split() if len(t) > 2]
|
| 724 |
-
if not q_terms:
|
| 725 |
-
return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
|
| 726 |
-
sentences = _split_sentences(ctx)
|
| 727 |
-
matched_exact, matched_any = [], []
|
| 728 |
-
for s in sentences:
|
| 729 |
-
s_norm = _norm(s)
|
| 730 |
-
is_bullet = bool(re.match(r"^[\-\*]\s*", s))
|
| 731 |
-
overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
|
| 732 |
-
if overlap >= STRICT_OVERLAP:
|
| 733 |
-
matched_exact.append(s)
|
| 734 |
-
elif overlap > 0:
|
| 735 |
-
matched_any.append(s)
|
| 736 |
-
if matched_exact:
|
| 737 |
-
kept = matched_exact[:MAX_SENTENCES_STRICT]
|
| 738 |
-
return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
|
| 739 |
-
if matched_any:
|
| 740 |
-
kept = matched_any[:MAX_SENTENCES_CONCISE]
|
| 741 |
-
return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)}
|
| 742 |
-
kept = sentences[:MAX_SENTENCES_CONCISE]
|
| 743 |
-
return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
|
| 744 |
-
|
| 745 |
-
def _extract_errors_only(text: str, max_lines: int = 12) -> str:
|
| 746 |
-
kept: List[str] = []
|
| 747 |
-
for ln in _normalize_lines(text):
|
| 748 |
-
if re.match(r"^\s*[-*\u2022]\s*", ln) or (":" in ln):
|
| 749 |
-
kept.append(ln)
|
| 750 |
-
if len(kept) >= max_lines:
|
| 751 |
-
break
|
| 752 |
-
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 753 |
-
|
| 754 |
-
def _filter_permission_lines(text: str, max_lines: int = 6) -> str:
|
| 755 |
-
PERM_SYNONYMS = (
|
| 756 |
-
"permission", "permissions", "access", "authorization", "authorisation",
|
| 757 |
-
"role", "role mapping", "security profile", "not allowed", "not authorized", "denied", "insufficient"
|
| 758 |
-
)
|
| 759 |
-
kept: List[str] = []
|
| 760 |
-
for ln in _normalize_lines(text):
|
| 761 |
-
low = ln.lower()
|
| 762 |
-
if any(k in low for k in PERM_SYNONYMS):
|
| 763 |
-
kept.append(ln)
|
| 764 |
-
if len(kept) >= max_lines:
|
| 765 |
-
break
|
| 766 |
-
return "\n".join(kept).strip() if kept else (text or "").strip()
|
| 767 |
-
|
| 768 |
-
def _extract_escalation_line(text: str) -> Optional[str]:
|
| 769 |
-
if not text:
|
| 770 |
-
return None
|
| 771 |
-
lines = _normalize_lines(text)
|
| 772 |
-
if not lines:
|
| 773 |
-
return None
|
| 774 |
-
start_idx = None
|
| 775 |
-
for i, ln in enumerate(lines):
|
| 776 |
-
low = ln.lower()
|
| 777 |
-
if "escalation" in low or "escalation path" in low or "escalate" in low:
|
| 778 |
-
start_idx = i
|
| 779 |
-
break
|
| 780 |
-
block = []
|
| 781 |
-
if start_idx is not None:
|
| 782 |
-
for j in range(start_idx, min(len(lines), start_idx + 6)):
|
| 783 |
-
if not lines[j].strip():
|
| 784 |
-
break
|
| 785 |
-
block.append(lines[j].strip())
|
| 786 |
-
else:
|
| 787 |
-
block = [ln.strip() for ln in lines if ("->" in ln or "→" in ln)]
|
| 788 |
-
if not block:
|
| 789 |
-
return None
|
| 790 |
-
text_block = " ".join(block)
|
| 791 |
-
m = re.search(r"escalation[^:]*:\s*(.+)", text_block, flags=re.IGNORECASE)
|
| 792 |
-
path = m.group(1).strip() if m else None
|
| 793 |
-
if not path:
|
| 794 |
-
arrow_lines = [ln for ln in block if ("->" in ln or "→" in ln)]
|
| 795 |
-
if arrow_lines:
|
| 796 |
-
path = arrow_lines[0]
|
| 797 |
-
if not path:
|
| 798 |
-
m2 = re.search(r"(operator.*?administrator|operator.*)", text_block, flags=re.IGNORECASE)
|
| 799 |
-
path = m2.group(1).strip() if m2 else None
|
| 800 |
-
if not path:
|
| 801 |
-
return None
|
| 802 |
-
path = path.replace("->", "→").strip()
|
| 803 |
-
path = re.sub(r"^(?i:escalation\s*path)\s*:\s*", "", path).strip()
|
| 804 |
-
return f"If you want to escalate the issue, follow: {path}"
|
| 805 |
-
|
| 806 |
# ------------------------------ Health ------------------------------
|
| 807 |
@app.get("/")
|
| 808 |
async def health_check():
|
|
@@ -853,12 +705,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 853 |
except Exception:
|
| 854 |
is_llm_resolved = False
|
| 855 |
|
| 856 |
-
def _has_negation_resolved(msg_norm: str) -> bool:
|
| 857 |
-
return any(p in msg_norm for p in [
|
| 858 |
-
"not resolved", "issue not resolved", "still not working", "not working",
|
| 859 |
-
"didn't work", "doesn't work", "no change", "not fixed", "still failing", "failed again", "broken", "fail",
|
| 860 |
-
])
|
| 861 |
-
|
| 862 |
if _has_negation_resolved(msg_norm):
|
| 863 |
is_llm_resolved = False
|
| 864 |
if (not _has_negation_resolved(msg_norm)) and (any(p in msg_norm for p in [
|
|
@@ -1162,42 +1008,34 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1162 |
context_preformatted = False
|
| 1163 |
full_steps = None
|
| 1164 |
|
| 1165 |
-
# Detect asked action generically using ACTION_SYNONYMS_EXT (already defined)
|
| 1166 |
-
asked_action = _detect_action_from_query(input_data.user_message) #
|
| 1167 |
|
|
|
|
| 1168 |
action_steps = _get_steps_for_action(best_doc, kb_results.get("actions", []))
|
| 1169 |
if action_steps:
|
| 1170 |
full_steps = action_steps
|
| 1171 |
else:
|
|
|
|
| 1172 |
if kb_results.get("actions"):
|
| 1173 |
alt_doc, alt_steps = _get_steps_for_action_global(kb_results["actions"], prefer_doc=best_doc)
|
| 1174 |
if alt_steps:
|
| 1175 |
best_doc = alt_doc
|
| 1176 |
full_steps = alt_steps
|
| 1177 |
-
|
| 1178 |
-
if not full_steps and asked_action:
|
| 1179 |
-
via_tag_steps = _get_steps_by_action_tag(best_doc, asked_action)
|
| 1180 |
-
if via_tag_steps:
|
| 1181 |
-
full_steps = via_tag_steps
|
| 1182 |
-
if not full_steps:
|
| 1183 |
-
default_sec = _pick_default_action_section_with_preference(best_doc, asked_action)
|
| 1184 |
-
if default_sec:
|
| 1185 |
-
full_steps = get_section_text(best_doc, default_sec)
|
| 1186 |
|
|
|
|
| 1187 |
if not full_steps and asked_action:
|
| 1188 |
-
|
| 1189 |
-
if
|
| 1190 |
-
full_steps =
|
| 1191 |
|
| 1192 |
-
|
| 1193 |
-
alt_doc2, alt_steps2 = _get_steps_for_action_global([asked_action], prefer_doc=best_doc)
|
| 1194 |
-
if alt_steps2:
|
| 1195 |
-
best_doc = alt_doc2
|
| 1196 |
-
full_steps = alt_steps2
|
| 1197 |
if not full_steps:
|
| 1198 |
-
default_sec =
|
| 1199 |
if default_sec:
|
| 1200 |
full_steps = get_section_text(best_doc, default_sec)
|
|
|
|
|
|
|
| 1201 |
if not full_steps:
|
| 1202 |
full_steps = get_best_steps_section_text(best_doc)
|
| 1203 |
if not full_steps:
|
|
@@ -1206,10 +1044,10 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1206 |
full_steps = get_section_text(best_doc, sec)
|
| 1207 |
|
| 1208 |
if full_steps:
|
| 1209 |
-
#
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
#
|
| 1213 |
save_local = _find_save_lines_in_section(full_steps, max_lines=2)
|
| 1214 |
if save_local:
|
| 1215 |
low_steps = (full_steps or "").lower()
|
|
@@ -1218,7 +1056,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1218 |
|
| 1219 |
# Number + Next steps
|
| 1220 |
numbered_full = _ensure_numbering(full_steps)
|
| 1221 |
-
next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.
|
| 1222 |
if next_only is not None:
|
| 1223 |
if len(next_only) == 0:
|
| 1224 |
context = "You are at the final step of this SOP. No further steps."
|
|
@@ -1233,9 +1071,10 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 1233 |
else:
|
| 1234 |
context = full_steps
|
| 1235 |
context_preformatted = False
|
|
|
|
| 1236 |
context_found = True
|
| 1237 |
filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
|
| 1238 |
-
|
| 1239 |
elif best_doc and detected_intent == "errors":
|
| 1240 |
full_errors = get_best_errors_section_text(best_doc)
|
| 1241 |
if full_errors:
|
|
|
|
| 227 |
candidates.sort(key=lambda x: x[0], reverse=True)
|
| 228 |
_, best_doc_global, best_text = candidates[0]
|
| 229 |
return best_doc_global, best_text
|
| 230 |
+
|
| 231 |
def _pick_default_action_section(best_doc: str) -> Optional[str]:
|
| 232 |
"""
|
| 233 |
Backward-compatible wrapper: prefer-action version with no preference.
|
| 234 |
"""
|
| 235 |
return _pick_default_action_section_with_preference(best_doc, None)
|
| 236 |
|
|
|
|
| 237 |
def _pick_default_action_section_with_preference(best_doc: str, prefer_action: Optional[str]) -> Optional[str]:
|
| 238 |
"""
|
| 239 |
Generic: if there are sections tagged with the requested action (via action_tag),
|
|
|
|
| 264 |
return t
|
| 265 |
return sections[0] if sections else None
|
| 266 |
|
| 267 |
+
# ------------------------------ Action -> section selector (optional fallback by title) ------------------------------
|
|
|
|
| 268 |
ACTION_SECTION_KEYS = {
|
| 269 |
"create": ("create", "creation", "appointment creation", "new appointment", "book", "schedule"),
|
| 270 |
"update": ("update", "updation", "reschedule", "change", "modify", "edit"),
|
|
|
|
| 272 |
}
|
| 273 |
|
| 274 |
def _pick_action_section(best_doc: str, action: Optional[str]) -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
if not best_doc or not action:
|
| 276 |
return None
|
| 277 |
keys = ACTION_SECTION_KEYS.get(action.lower(), ())
|
| 278 |
if not keys:
|
| 279 |
return None
|
|
|
|
|
|
|
| 280 |
candidates = []
|
| 281 |
for d in bm25_docs:
|
| 282 |
m = d.get("meta", {}) or {}
|
|
|
|
| 286 |
continue
|
| 287 |
low = sec.lower()
|
| 288 |
if any(k in low for k in keys):
|
|
|
|
| 289 |
score = 1.0
|
| 290 |
if action.lower() in low:
|
| 291 |
score += 0.5
|
|
|
|
| 292 |
mtags = (m.get("module_tags") or "").lower()
|
| 293 |
if "appointments" in mtags:
|
| 294 |
score += 0.2
|
| 295 |
candidates.append((score, sec))
|
|
|
|
| 296 |
if not candidates:
|
| 297 |
return None
|
|
|
|
|
|
|
| 298 |
candidates.sort(key=lambda x: x[0], reverse=True)
|
| 299 |
+
return candidates[0][1]
|
| 300 |
|
| 301 |
def _get_steps_text_for_action(best_doc: str, action: Optional[str]) -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
| 302 |
sec = _pick_action_section(best_doc, action)
|
| 303 |
if not sec:
|
| 304 |
return None
|
| 305 |
txt = get_section_text(best_doc, sec)
|
| 306 |
+
return (txt or "").strip() or None
|
| 307 |
|
| 308 |
# ------------------------------ Save lines helpers ------------------------------
|
| 309 |
SAVE_SYNS = ("save", "saving", "save changes", "click save", "press save", "select save")
|
|
|
|
| 318 |
break
|
| 319 |
return "\n".join(lines)
|
| 320 |
|
| 321 |
+
# ------------------------------ Generic boundary cutter (metadata-driven) ------------------------------
|
| 322 |
+
def _build_doc_section_index(best_doc: str) -> Dict[str, Optional[str]]:
|
| 323 |
+
"""
|
| 324 |
+
Build a dictionary for the given doc:
|
| 325 |
+
{ lower(section_title): lower(action_tag or None) }
|
| 326 |
+
"""
|
| 327 |
+
index: Dict[str, Optional[str]] = {}
|
| 328 |
+
for d in bm25_docs:
|
| 329 |
+
m = d.get("meta", {}) or {}
|
| 330 |
+
if m.get("filename") == best_doc and m.get("intent_tag") == "steps":
|
| 331 |
+
sec = (m.get("section") or "").strip()
|
| 332 |
+
tag = (m.get("action_tag") or "").strip().lower() or None
|
| 333 |
+
if sec:
|
| 334 |
+
index[sec.lower()] = tag
|
| 335 |
+
return index
|
| 336 |
|
| 337 |
+
def _cut_at_next_boundary_generic(section_text: str, best_doc: str, current_action: Optional[str]) -> str:
|
| 338 |
+
"""
|
| 339 |
+
Stop when we hit any known section heading in the same doc.
|
| 340 |
+
If current_action is set, stop only when the next heading belongs to a different action_tag.
|
| 341 |
+
Fully generic: no hard-coded SOP words.
|
| 342 |
+
"""
|
| 343 |
if not (section_text or "").strip():
|
| 344 |
return section_text
|
| 345 |
+
|
| 346 |
+
index = _build_doc_section_index(best_doc)
|
| 347 |
+
known_headings = set(index.keys())
|
| 348 |
lines = [ln for ln in (section_text or "").splitlines() if ln.strip()]
|
| 349 |
out: List[str] = []
|
| 350 |
+
|
| 351 |
for ln in lines:
|
| 352 |
low = ln.lower().strip()
|
| 353 |
+
|
| 354 |
+
matched_heading = None
|
| 355 |
+
for h in known_headings:
|
| 356 |
+
if h in low:
|
| 357 |
+
matched_heading = h
|
| 358 |
break
|
| 359 |
+
|
| 360 |
+
if matched_heading:
|
| 361 |
+
next_action = index.get(matched_heading)
|
| 362 |
+
if (current_action and next_action and next_action != current_action) or (current_action is None):
|
| 363 |
break
|
| 364 |
+
|
| 365 |
out.append(ln)
|
| 366 |
+
|
| 367 |
return "\n".join(out).strip()
|
| 368 |
|
| 369 |
# ------------------------------ Text normalization / numbering ------------------------------
|
|
|
|
| 608 |
f"Tracking record created automatically by NOVA."
|
| 609 |
).strip()
|
| 610 |
return short_desc, long_desc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
|
| 612 |
# ------------------------------ Incident helpers ------------------------------
|
| 613 |
def _is_incident_intent(msg_norm: str) -> bool:
|
|
|
|
| 655 |
]
|
| 656 |
return any(p in msg_norm for p in neg_phrases)
|
| 657 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
# ------------------------------ Health ------------------------------
|
| 659 |
@app.get("/")
|
| 660 |
async def health_check():
|
|
|
|
| 705 |
except Exception:
|
| 706 |
is_llm_resolved = False
|
| 707 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
if _has_negation_resolved(msg_norm):
|
| 709 |
is_llm_resolved = False
|
| 710 |
if (not _has_negation_resolved(msg_norm)) and (any(p in msg_norm for p in [
|
|
|
|
| 1008 |
context_preformatted = False
|
| 1009 |
full_steps = None
|
| 1010 |
|
| 1011 |
+
# Detect asked action generically using ACTION_SYNONYMS_EXT (already defined)
|
| 1012 |
+
asked_action = _detect_action_from_query(input_data.user_message) # 'create'/'update'/'delete'/None
|
| 1013 |
|
| 1014 |
+
# 1) Try KB-tagged action section (existing)
|
| 1015 |
action_steps = _get_steps_for_action(best_doc, kb_results.get("actions", []))
|
| 1016 |
if action_steps:
|
| 1017 |
full_steps = action_steps
|
| 1018 |
else:
|
| 1019 |
+
# 2) Global action lookup (existing)
|
| 1020 |
if kb_results.get("actions"):
|
| 1021 |
alt_doc, alt_steps = _get_steps_for_action_global(kb_results["actions"], prefer_doc=best_doc)
|
| 1022 |
if alt_steps:
|
| 1023 |
best_doc = alt_doc
|
| 1024 |
full_steps = alt_steps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1025 |
|
| 1026 |
+
# 3) Generic: exact section by meta.action_tag in SAME best_doc
|
| 1027 |
if not full_steps and asked_action:
|
| 1028 |
+
via_tag_steps = _get_steps_by_action_tag(best_doc, asked_action)
|
| 1029 |
+
if via_tag_steps:
|
| 1030 |
+
full_steps = via_tag_steps
|
| 1031 |
|
| 1032 |
+
# 4) Prefer requested action when defaulting
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1033 |
if not full_steps:
|
| 1034 |
+
default_sec = _pick_default_action_section_with_preference(best_doc, asked_action)
|
| 1035 |
if default_sec:
|
| 1036 |
full_steps = get_section_text(best_doc, default_sec)
|
| 1037 |
+
|
| 1038 |
+
# 5) Existing fallbacks
|
| 1039 |
if not full_steps:
|
| 1040 |
full_steps = get_best_steps_section_text(best_doc)
|
| 1041 |
if not full_steps:
|
|
|
|
| 1044 |
full_steps = get_section_text(best_doc, sec)
|
| 1045 |
|
| 1046 |
if full_steps:
|
| 1047 |
+
# Generic boundary cutter (prevents create/update/delete bleed)
|
| 1048 |
+
full_steps = _cut_at_next_boundary_generic(full_steps, best_doc, asked_action)
|
| 1049 |
+
|
| 1050 |
+
# Scope 'Save' lines to current section
|
| 1051 |
save_local = _find_save_lines_in_section(full_steps, max_lines=2)
|
| 1052 |
if save_local:
|
| 1053 |
low_steps = (full_steps or "").lower()
|
|
|
|
| 1056 |
|
| 1057 |
# Number + Next steps
|
| 1058 |
numbered_full = _ensure_numbering(full_steps)
|
| 1059 |
+
next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.30)
|
| 1060 |
if next_only is not None:
|
| 1061 |
if len(next_only) == 0:
|
| 1062 |
context = "You are at the final step of this SOP. No further steps."
|
|
|
|
| 1071 |
else:
|
| 1072 |
context = full_steps
|
| 1073 |
context_preformatted = False
|
| 1074 |
+
|
| 1075 |
context_found = True
|
| 1076 |
filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
|
| 1077 |
+
|
| 1078 |
elif best_doc and detected_intent == "errors":
|
| 1079 |
full_errors = get_best_errors_section_text(best_doc)
|
| 1080 |
if full_errors:
|