Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -315,9 +315,8 @@ def _soft_match_score(a: str, b: str) -> float:
|
|
| 315 |
def _detect_next_intent(user_query: str) -> bool:
|
| 316 |
q = _norm_text(user_query)
|
| 317 |
keys = [
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
'what to do','what should i do','how to proceed','how do i continue','proceed further','next?'
|
| 321 |
]
|
| 322 |
return any(k in q for k in keys)
|
| 323 |
|
|
@@ -327,41 +326,39 @@ def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 8,
|
|
| 327 |
1) Detect 'what next' intent.
|
| 328 |
2) Stem & match query against each step using tokens + bigrams + synonyms.
|
| 329 |
3) If a good anchor is found, return ONLY subsequent steps (window=max_next).
|
| 330 |
-
|
| 331 |
"""
|
| 332 |
if not _detect_next_intent(user_query):
|
| 333 |
return None
|
|
|
|
| 334 |
steps = _split_sop_into_steps(numbered_text)
|
| 335 |
if not steps:
|
| 336 |
return None
|
|
|
|
| 337 |
q_norm = _norm_text(user_query)
|
| 338 |
q_tokens = [t for t in q_norm.split() if len(t) > 1]
|
|
|
|
| 339 |
best_idx, best_score = -1, -1.0
|
| 340 |
for idx, step in enumerate(steps):
|
|
|
|
| 341 |
s1 = _soft_match_score(user_query, step)
|
|
|
|
| 342 |
syn = _syn_hits(q_tokens, step)
|
|
|
|
| 343 |
score = s1 + 0.12 * syn
|
| 344 |
if score > best_score:
|
| 345 |
best_score, best_idx = score, idx
|
|
|
|
|
|
|
| 346 |
if best_idx < 0 or best_score < min_score:
|
| 347 |
-
return None
|
|
|
|
| 348 |
start = best_idx + 1
|
| 349 |
if start >= len(steps):
|
| 350 |
-
return []
|
|
|
|
| 351 |
end = min(start + max_next, len(steps))
|
| 352 |
-
|
| 353 |
-
def _unique(seq):
|
| 354 |
-
seen = set()
|
| 355 |
-
out = []
|
| 356 |
-
for s in seq:
|
| 357 |
-
k = _norm_text(s)
|
| 358 |
-
if k == anchor_norm:
|
| 359 |
-
continue
|
| 360 |
-
if k not in seen:
|
| 361 |
-
seen.add(k)
|
| 362 |
-
out.append(s)
|
| 363 |
-
return out
|
| 364 |
-
return _unique(steps[start:end])
|
| 365 |
|
| 366 |
def _syn_hits(q_tokens: List[str], step_line: str) -> int:
|
| 367 |
"""
|
|
@@ -631,12 +628,153 @@ async def health_check():
|
|
| 631 |
# ------------------------------------------------------------------------------
|
| 632 |
# Chat
|
| 633 |
# ------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
@app.post("/chat")
|
| 635 |
async def chat_with_ai(input_data: ChatInput):
|
| 636 |
assist_followup: Optional[str] = None
|
| 637 |
try:
|
| 638 |
msg_norm = (input_data.user_message or "").lower().strip()
|
| 639 |
-
|
| 640 |
# Yes/No handlers
|
| 641 |
if msg_norm in ("yes", "y", "sure", "ok", "okay"):
|
| 642 |
return {
|
|
@@ -660,7 +798,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 660 |
is_llm_resolved = _classify_resolution_llm(input_data.user_message)
|
| 661 |
if _has_negation_resolved(msg_norm):
|
| 662 |
is_llm_resolved = False
|
| 663 |
-
|
| 664 |
if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
|
| 665 |
try:
|
| 666 |
short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
|
|
@@ -731,7 +868,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 731 |
"debug": {"intent": "create_ticket"},
|
| 732 |
}
|
| 733 |
|
| 734 |
-
# Status intent (ticket/incident)
|
| 735 |
status_intent = _parse_ticket_status_intent(msg_norm)
|
| 736 |
if status_intent:
|
| 737 |
if status_intent.get("ask_number"):
|
|
@@ -755,7 +892,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 755 |
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
| 756 |
if not instance_url:
|
| 757 |
raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
|
| 758 |
-
|
| 759 |
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
| 760 |
number = status_intent.get("number")
|
| 761 |
url = f"{instance_url}/api/now/table/incident?number={number}"
|
|
@@ -763,12 +899,10 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 763 |
data = response.json()
|
| 764 |
lst = data.get("result", [])
|
| 765 |
result = (lst or [{}])[0] if response.status_code == 200 else {}
|
| 766 |
-
|
| 767 |
state_code = builtins.str(result.get("state", "unknown"))
|
| 768 |
state_label = STATE_MAP.get(state_code, state_code)
|
| 769 |
short = result.get("short_description", "")
|
| 770 |
num = result.get("number", number or "unknown")
|
| 771 |
-
|
| 772 |
return {
|
| 773 |
"bot_response": (
|
| 774 |
f"**Ticket:** {num}\n"
|
|
@@ -788,9 +922,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 788 |
except Exception as e:
|
| 789 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 790 |
|
| 791 |
-
# -----------------------------
|
| 792 |
# Hybrid KB search
|
| 793 |
-
# -----------------------------
|
| 794 |
kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
|
| 795 |
documents = kb_results.get("documents", [])
|
| 796 |
metadatas = kb_results.get("metadatas", [])
|
|
@@ -814,141 +946,44 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 814 |
|
| 815 |
selected = items[:max(1, 2)]
|
| 816 |
context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
|
| 817 |
-
|
| 818 |
filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
|
| 819 |
context = filtered_text
|
| 820 |
context_found = bool(context.strip())
|
| 821 |
|
| 822 |
-
|
| 823 |
-
min([d for d in distances if d is not None], default=None) if distances else None
|
| 824 |
-
)
|
| 825 |
-
best_combined = (
|
| 826 |
-
max([c for c in combined if c is not None], default=None) if combined else None
|
| 827 |
-
)
|
| 828 |
-
|
| 829 |
-
detected_intent = kb_results.get("user_intent", "neutral")
|
| 830 |
best_doc = kb_results.get("best_doc")
|
| 831 |
top_meta = (metadatas or [{}])[0] if metadatas else {}
|
| 832 |
-
msg_low = (input_data.user_message or "").lower()
|
| 833 |
|
|
|
|
| 834 |
GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
|
| 835 |
generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
|
| 836 |
|
| 837 |
-
#
|
| 838 |
-
PREREQ_TERMS = (
|
| 839 |
-
"pre req", "pre-requisite", "pre-requisites", "prerequisite",
|
| 840 |
-
"prerequisites", "pre requirement", "pre-requirements", "requirements"
|
| 841 |
-
)
|
| 842 |
-
if detected_intent == "neutral" and any(t in msg_low for t in PREREQ_TERMS):
|
| 843 |
-
detected_intent = "prereqs"
|
| 844 |
-
|
| 845 |
-
# Permissions force
|
| 846 |
-
PERM_QUERY_TERMS = [
|
| 847 |
-
"permission", "permissions", "access", "access right", "authorization", "authorisation",
|
| 848 |
-
"role", "role access", "security", "security profile", "privilege",
|
| 849 |
-
"not allowed", "not authorized", "denied",
|
| 850 |
-
]
|
| 851 |
-
is_perm_query = any(t in msg_norm for t in PERM_QUERY_TERMS)
|
| 852 |
-
if is_perm_query:
|
| 853 |
-
detected_intent = "errors"
|
| 854 |
-
|
| 855 |
-
# Heading-aware prereq nudge
|
| 856 |
-
sec_title = ((top_meta or {}).get("section") or "").strip().lower()
|
| 857 |
-
PREREQ_HEADINGS = (
|
| 858 |
-
"pre-requisites", "prerequisites", "pre requisites",
|
| 859 |
-
"pre-requirements", "requirements"
|
| 860 |
-
)
|
| 861 |
-
if detected_intent == "neutral" and any(h in sec_title for h in PREREQ_HEADINGS):
|
| 862 |
-
detected_intent = "prereqs"
|
| 863 |
-
|
| 864 |
-
# ---- FORCE STEPS for "what's next" / "next step" queries ----
|
| 865 |
try:
|
| 866 |
if _detect_next_intent(input_data.user_message):
|
| 867 |
detected_intent = "steps"
|
|
|
|
|
|
|
| 868 |
except Exception:
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
# Gating
|
| 872 |
-
def _contains_any(s: str, keywords: tuple) -> bool:
|
| 873 |
-
low = (s or "").lower()
|
| 874 |
-
return any(k in low for k in keywords)
|
| 875 |
-
|
| 876 |
-
DOMAIN_TERMS = (
|
| 877 |
-
"trailer", "shipment", "order", "load", "wave",
|
| 878 |
-
"inventory", "putaway", "receiving", "appointment",
|
| 879 |
-
"dock", "door", "manifest", "pallet", "container",
|
| 880 |
-
"asn", "grn", "pick", "picking"
|
| 881 |
-
)
|
| 882 |
-
ACTION_OR_ERROR_TERMS = (
|
| 883 |
-
"how to", "procedure", "perform",
|
| 884 |
-
"close", "closing", "open", "navigate", "scan", "confirm", "generate", "update",
|
| 885 |
-
"receive", "receiving",
|
| 886 |
-
"error", "issue", "fail", "failed", "not working", "locked", "mismatch",
|
| 887 |
-
"access", "permission", "status"
|
| 888 |
-
)
|
| 889 |
|
| 890 |
-
matched_count = int(filt_info.get("matched_count") or 0)
|
| 891 |
-
filter_mode = (filt_info.get("mode") or "").lower()
|
| 892 |
-
has_any_action_or_error = _contains_any(msg_low, ACTION_OR_ERROR_TERMS)
|
| 893 |
-
mentions_domain = _contains_any(msg_low, DOMAIN_TERMS)
|
| 894 |
-
|
| 895 |
-
short_query = len((input_data.user_message or "").split()) <= 4
|
| 896 |
-
gate_combined_ok = 0.60 if short_query else 0.55
|
| 897 |
-
combined_ok = (best_combined is not None and best_combined >= gate_combined_ok)
|
| 898 |
-
weak_domain_only = (mentions_domain and not has_any_action_or_error)
|
| 899 |
-
low_context_hit = (matched_count < 2 and filter_mode in ("concise", "exact"))
|
| 900 |
-
|
| 901 |
-
strong_steps_bypass = True # next-step override already set steps; allow
|
| 902 |
-
strong_error_signal = len(_detect_error_families(msg_low)) > 0
|
| 903 |
-
|
| 904 |
-
if (weak_domain_only or (low_context_hit and not combined_ok)) \
|
| 905 |
-
and not strong_steps_bypass \
|
| 906 |
-
and not (strong_error_signal or generic_error_signal):
|
| 907 |
-
return {
|
| 908 |
-
"bot_response": _build_clarifying_message(),
|
| 909 |
-
"status": "NO_KB_MATCH",
|
| 910 |
-
"context_found": False,
|
| 911 |
-
"ask_resolved": False,
|
| 912 |
-
"suggest_incident": True,
|
| 913 |
-
"followup": "Share more details (module/screen/error), or say 'create ticket'.",
|
| 914 |
-
"options": [{"type": "yesno", "title": "Share details or raise a ticket?"}],
|
| 915 |
-
"top_hits": [],
|
| 916 |
-
"sources": [],
|
| 917 |
-
"debug": {
|
| 918 |
-
"intent": "sop_rejected_weak_match",
|
| 919 |
-
"matched_count": matched_count,
|
| 920 |
-
"filter_mode": filter_mode,
|
| 921 |
-
"best_combined": best_combined,
|
| 922 |
-
"mentions_domain": mentions_domain,
|
| 923 |
-
"has_any_action_or_error": has_any_action_or_error,
|
| 924 |
-
"strong_steps_bypass": strong_steps_bypass,
|
| 925 |
-
"strong_error_signal": strong_error_signal,
|
| 926 |
-
"generic_error_signal": generic_error_signal,
|
| 927 |
-
},
|
| 928 |
-
}
|
| 929 |
-
|
| 930 |
-
# Build SOP context if allowed
|
| 931 |
escalation_line: Optional[str] = None
|
| 932 |
-
full_errors: Optional[str] = None
|
| 933 |
next_step_applied = False
|
| 934 |
next_step_info: Dict[str, Any] = {}
|
| 935 |
context_preformatted = False
|
| 936 |
|
| 937 |
if best_doc and detected_intent == "steps":
|
| 938 |
-
|
| 939 |
-
if
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
if full_steps:
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
min_score=0.35
|
| 951 |
-
)
|
| 952 |
if next_only is not None:
|
| 953 |
if len(next_only) == 0:
|
| 954 |
context = "You are at the final step of this SOP. No further steps."
|
|
@@ -961,158 +996,42 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 961 |
next_step_info = {"count": len(next_only)}
|
| 962 |
context_preformatted = True
|
| 963 |
else:
|
| 964 |
-
context =
|
| 965 |
context_preformatted = False
|
| 966 |
-
|
| 967 |
-
# clear filter info for debug clarity
|
| 968 |
-
filt_info = {'mode': None, 'matched_count': None, 'all_sentences': None}
|
| 969 |
-
context_found = True
|
| 970 |
-
|
| 971 |
-
elif best_doc and detected_intent == "errors":
|
| 972 |
-
full_errors = get_best_errors_section_text(best_doc)
|
| 973 |
-
if full_errors:
|
| 974 |
-
ctx_err = _extract_errors_only(full_errors, max_lines=30)
|
| 975 |
-
if is_perm_query:
|
| 976 |
-
context = _filter_permission_lines(ctx_err, max_lines=6)
|
| 977 |
-
else:
|
| 978 |
-
is_specific_error = len(_detect_error_families(msg_low)) > 0
|
| 979 |
-
if is_specific_error:
|
| 980 |
-
context = _filter_context_for_query(ctx_err, input_data.user_message)[0]
|
| 981 |
-
else:
|
| 982 |
-
all_lines: List[str] = _normalize_lines(ctx_err)
|
| 983 |
-
error_bullets = [
|
| 984 |
-
ln for ln in all_lines
|
| 985 |
-
if re.match(r"^\s*[\-\*\u2022]\s*", ln) or (":" in ln)
|
| 986 |
-
]
|
| 987 |
-
context = "\n".join(error_bullets[:8]).strip()
|
| 988 |
-
assist_followup = (
|
| 989 |
-
"Please tell me which error above matches your screen (paste the exact text), "
|
| 990 |
-
"or share a screenshot. I can guide you further or raise a ServiceNow ticket."
|
| 991 |
-
)
|
| 992 |
-
escalation_line = _extract_escalation_line(full_errors)
|
| 993 |
-
|
| 994 |
-
elif best_doc and detected_intent == "prereqs":
|
| 995 |
-
full_prereqs = _find_prereq_section_text(best_doc)
|
| 996 |
-
if full_prereqs:
|
| 997 |
-
context = full_prereqs.strip()
|
| 998 |
context_found = True
|
| 999 |
|
| 1000 |
else:
|
| 1001 |
-
#
|
| 1002 |
context = filtered_text
|
| 1003 |
|
| 1004 |
-
#
|
| 1005 |
-
language_hint = _detect_language_hint(input_data.user_message)
|
| 1006 |
-
lang_line = f"Respond in {language_hint}." if language_hint else "Respond in a clear, polite tone."
|
| 1007 |
-
use_gemini = (detected_intent == "errors")
|
| 1008 |
-
|
| 1009 |
-
enhanced_prompt = f"""You are a helpful support assistant. Rewrite the provided context ONLY into clear, user-friendly guidance.
|
| 1010 |
-
- Do not add any information that is not present in the context.
|
| 1011 |
-
- If the content is an error/access/permission note, paraphrase it into a helpful sentence users can understand.
|
| 1012 |
-
- {lang_line}
|
| 1013 |
-
### Context
|
| 1014 |
-
{context}
|
| 1015 |
-
### Question
|
| 1016 |
-
{input_data.user_message}
|
| 1017 |
-
### Output
|
| 1018 |
-
Return ONLY the rewritten guidance."""
|
| 1019 |
-
|
| 1020 |
-
headers = {"Content-Type": "application/json"}
|
| 1021 |
-
payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
|
| 1022 |
-
bot_text = ""
|
| 1023 |
-
http_code = 0
|
| 1024 |
-
|
| 1025 |
-
if use_gemini and GEMINI_API_KEY:
|
| 1026 |
-
try:
|
| 1027 |
-
resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
|
| 1028 |
-
http_code = getattr(resp, "status_code", 0)
|
| 1029 |
-
try:
|
| 1030 |
-
result = resp.json()
|
| 1031 |
-
except Exception:
|
| 1032 |
-
result = {}
|
| 1033 |
-
try:
|
| 1034 |
-
bot_text = result.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
|
| 1035 |
-
except Exception:
|
| 1036 |
-
bot_text = ""
|
| 1037 |
-
except Exception:
|
| 1038 |
-
bot_text, http_code = "", 0
|
| 1039 |
-
|
| 1040 |
-
# Deterministic local formatting
|
| 1041 |
if detected_intent == "steps":
|
| 1042 |
-
if context_preformatted
|
| 1043 |
-
bot_text = context
|
| 1044 |
-
else:
|
| 1045 |
-
bot_text = _ensure_numbering(context)
|
| 1046 |
-
elif detected_intent == "errors":
|
| 1047 |
-
if not (bot_text or "").strip() or http_code == 429:
|
| 1048 |
-
bot_text = context.strip()
|
| 1049 |
-
if escalation_line:
|
| 1050 |
-
bot_text = (bot_text or "").rstrip() + "\n\n" + escalation_line
|
| 1051 |
else:
|
| 1052 |
bot_text = context
|
| 1053 |
|
| 1054 |
-
# Append escalation if explicitly requested even in steps mode
|
| 1055 |
-
needs_escalation = (" escalate" in msg_norm) or ("escalation" in msg_norm)
|
| 1056 |
-
if needs_escalation and best_doc:
|
| 1057 |
-
esc_text = get_escalation_text(best_doc)
|
| 1058 |
-
if not esc_text and full_errors:
|
| 1059 |
-
esc_text = full_errors
|
| 1060 |
-
line = _extract_escalation_line(esc_text or "")
|
| 1061 |
-
if line:
|
| 1062 |
-
bot_text = (bot_text or "").rstrip() + "\n\n" + line
|
| 1063 |
-
|
| 1064 |
-
# Non-empty guarantee
|
| 1065 |
-
if not (bot_text or "").strip():
|
| 1066 |
-
if context.strip():
|
| 1067 |
-
bot_text = context.strip()
|
| 1068 |
-
else:
|
| 1069 |
-
bot_text = (
|
| 1070 |
-
"I found some related guidance but couldn’t assemble a reply. "
|
| 1071 |
-
"Share a bit more detail (module/screen/error), or say ‘create ticket’."
|
| 1072 |
-
)
|
| 1073 |
-
|
| 1074 |
-
short_query = len((input_data.user_message or "").split()) <= 4
|
| 1075 |
-
gate_combined_ok = 0.60 if short_query else 0.55
|
| 1076 |
-
status = "OK" if (best_combined is not None and best_combined >= gate_combined_ok) else "PARTIAL"
|
| 1077 |
-
|
| 1078 |
-
lower = (bot_text or "").lower()
|
| 1079 |
-
if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
|
| 1080 |
-
status = "PARTIAL"
|
| 1081 |
-
|
| 1082 |
-
options = [{"type": "yesno", "title": "Share details or raise a ticket?"}] if status == "PARTIAL" else []
|
| 1083 |
-
|
| 1084 |
return {
|
| 1085 |
"bot_response": bot_text,
|
| 1086 |
-
"status":
|
| 1087 |
"context_found": context_found,
|
| 1088 |
-
"ask_resolved":
|
| 1089 |
-
"suggest_incident":
|
| 1090 |
-
"followup":
|
| 1091 |
-
"options":
|
| 1092 |
"top_hits": [],
|
| 1093 |
"sources": [],
|
| 1094 |
"debug": {
|
| 1095 |
-
"used_chunks": len((context or "").split("\n\n---\n\n")) if context else 0,
|
| 1096 |
-
"best_distance": best_distance,
|
| 1097 |
-
"best_combined": best_combined,
|
| 1098 |
-
"http_status": http_code,
|
| 1099 |
-
"filter_mode": filt_info.get("mode") if isinstance(filt_info, dict) else None,
|
| 1100 |
-
"matched_count": filt_info.get("matched_count") if isinstance(filt_info, dict) else None,
|
| 1101 |
"user_intent": detected_intent,
|
| 1102 |
"best_doc": best_doc,
|
| 1103 |
"next_step": {"applied": next_step_applied, "info": next_step_info},
|
| 1104 |
},
|
| 1105 |
}
|
| 1106 |
-
|
| 1107 |
except HTTPException:
|
| 1108 |
raise
|
| 1109 |
except Exception as e:
|
| 1110 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1111 |
|
| 1112 |
|
| 1113 |
-
# ------------------------------------------------------------------------------
|
| 1114 |
-
# Ticket description generation
|
| 1115 |
-
# ------------------------------------------------------------------------------
|
| 1116 |
@app.post("/generate_ticket_desc")
|
| 1117 |
async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
| 1118 |
try:
|
|
|
|
| 315 |
def _detect_next_intent(user_query: str) -> bool:
|
| 316 |
q = _norm_text(user_query)
|
| 317 |
keys = [
|
| 318 |
+
"after", "after this", "what next", "whats next", "next step",
|
| 319 |
+
"then what", "following step", "continue", "subsequent", "proceed"
|
|
|
|
| 320 |
]
|
| 321 |
return any(k in q for k in keys)
|
| 322 |
|
|
|
|
| 326 |
1) Detect 'what next' intent.
|
| 327 |
2) Stem & match query against each step using tokens + bigrams + synonyms.
|
| 328 |
3) If a good anchor is found, return ONLY subsequent steps (window=max_next).
|
| 329 |
+
Else return None (fallback to full SOP rendering).
|
| 330 |
"""
|
| 331 |
if not _detect_next_intent(user_query):
|
| 332 |
return None
|
| 333 |
+
|
| 334 |
steps = _split_sop_into_steps(numbered_text)
|
| 335 |
if not steps:
|
| 336 |
return None
|
| 337 |
+
|
| 338 |
q_norm = _norm_text(user_query)
|
| 339 |
q_tokens = [t for t in q_norm.split() if len(t) > 1]
|
| 340 |
+
|
| 341 |
best_idx, best_score = -1, -1.0
|
| 342 |
for idx, step in enumerate(steps):
|
| 343 |
+
# base fuzzy score
|
| 344 |
s1 = _soft_match_score(user_query, step)
|
| 345 |
+
# synonym hits
|
| 346 |
syn = _syn_hits(q_tokens, step)
|
| 347 |
+
# combined score (synonyms are discrete)
|
| 348 |
score = s1 + 0.12 * syn
|
| 349 |
if score > best_score:
|
| 350 |
best_score, best_idx = score, idx
|
| 351 |
+
|
| 352 |
+
# Looser threshold to accept anchors with synonyms / tense differences
|
| 353 |
if best_idx < 0 or best_score < min_score:
|
| 354 |
+
return None # let caller fall back to the full SOP
|
| 355 |
+
|
| 356 |
start = best_idx + 1
|
| 357 |
if start >= len(steps):
|
| 358 |
+
return [] # already at final step
|
| 359 |
+
|
| 360 |
end = min(start + max_next, len(steps))
|
| 361 |
+
return steps[start:end]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
def _syn_hits(q_tokens: List[str], step_line: str) -> int:
|
| 364 |
"""
|
|
|
|
| 628 |
# ------------------------------------------------------------------------------
|
| 629 |
# Chat
|
| 630 |
# ------------------------------------------------------------------------------
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
# --- Helpers for section slicing and action-filter ---
|
| 634 |
+
def _slice_steps_by_known_headings(text: str, actions: List[str]) -> str:
|
| 635 |
+
"""
|
| 636 |
+
If the SOP mixes creation/update/deletion under one 'Document' section,
|
| 637 |
+
carve out the subsection by looking for heading markers in the text.
|
| 638 |
+
"""
|
| 639 |
+
if not text:
|
| 640 |
+
return text
|
| 641 |
+
act = (actions or [None])[0]
|
| 642 |
+
lines = [ln for ln in (text or '').splitlines() if ln.strip()]
|
| 643 |
+
if not lines or not act:
|
| 644 |
+
return text
|
| 645 |
+
act = act.lower()
|
| 646 |
+
idx_create = idx_update = idx_delete = None
|
| 647 |
+
for i, ln in enumerate(lines):
|
| 648 |
+
low = ln.lower()
|
| 649 |
+
if idx_create is None and ('appointment' in low and 'creat' in low and 'step' in low):
|
| 650 |
+
idx_create = i
|
| 651 |
+
if idx_update is None and ('appointment' in low and 'updat' in low and 'step' in low):
|
| 652 |
+
idx_update = i
|
| 653 |
+
if idx_delete is None and (('deletion' in low) or ('delete' in low)) and ('appointment' in low and 'step' in low):
|
| 654 |
+
idx_delete = i
|
| 655 |
+
start = None
|
| 656 |
+
if act == 'create':
|
| 657 |
+
start = idx_create
|
| 658 |
+
elif act == 'update':
|
| 659 |
+
start = idx_update
|
| 660 |
+
elif act == 'delete':
|
| 661 |
+
start = idx_delete
|
| 662 |
+
if start is None:
|
| 663 |
+
return text
|
| 664 |
+
next_idxs = sorted([x for x in (idx_create, idx_update, idx_delete) if x is not None and x > start])
|
| 665 |
+
end = next_idxs[0] if next_idxs else len(lines)
|
| 666 |
+
sub = '\n'.join(lines[start:end]).strip()
|
| 667 |
+
return sub or text
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def _filter_steps_for_action(text: str, actions: List[str]) -> str:
|
| 671 |
+
"""Remove lines that conflict with requested action (create/update/delete)."""
|
| 672 |
+
if not text:
|
| 673 |
+
return text
|
| 674 |
+
lines = [ln for ln in (text or '').splitlines() if ln.strip()]
|
| 675 |
+
if not lines or not actions:
|
| 676 |
+
return text
|
| 677 |
+
act = (actions or [None])[0]
|
| 678 |
+
if not act:
|
| 679 |
+
return text
|
| 680 |
+
act = act.lower()
|
| 681 |
+
conflicts = {
|
| 682 |
+
'create': ('delete','remove','update','modify','change','edit'),
|
| 683 |
+
'update': ('delete','remove','create','add','new','generate'),
|
| 684 |
+
'delete': ('create','add','new','generate','update','modify','change','edit'),
|
| 685 |
+
}
|
| 686 |
+
conf = conflicts.get(act, ())
|
| 687 |
+
kept = [ln for ln in lines if not any(c in ln.lower() for c in conf)]
|
| 688 |
+
return '\n'.join(kept).strip() if kept else text
|
| 689 |
+
|
| 690 |
+
# --- Overrides with safer logic ---
|
| 691 |
+
def _detect_next_intent(user_query: str) -> bool:
|
| 692 |
+
q = _norm_text(user_query)
|
| 693 |
+
keys = [
|
| 694 |
+
'after','after this','what next','whats next','next step',
|
| 695 |
+
'then what','following step','continue','subsequent','proceed',
|
| 696 |
+
'what to do','what should i do','how to proceed','how do i continue','proceed further','next?'
|
| 697 |
+
]
|
| 698 |
+
return any(k in q for k in keys)
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def _resolve_next_steps(user_query: str, numbered_text: str, max_next: int = 8, min_score: float = 0.25):
|
| 702 |
+
"""
|
| 703 |
+
Detect 'what next' intent and return only subsequent steps.
|
| 704 |
+
Excludes the anchor step and de-duplicates the result.
|
| 705 |
+
"""
|
| 706 |
+
if not _detect_next_intent(user_query):
|
| 707 |
+
return None
|
| 708 |
+
steps = _split_sop_into_steps(numbered_text)
|
| 709 |
+
if not steps:
|
| 710 |
+
return None
|
| 711 |
+
q_norm = _norm_text(user_query)
|
| 712 |
+
q_tokens = [t for t in q_norm.split() if len(t) > 1]
|
| 713 |
+
best_idx, best_score = -1, -1.0
|
| 714 |
+
for idx, step in enumerate(steps):
|
| 715 |
+
s1 = _soft_match_score(user_query, step)
|
| 716 |
+
syn = _syn_hits(q_tokens, step)
|
| 717 |
+
score = s1 + 0.12 * syn
|
| 718 |
+
if score > best_score:
|
| 719 |
+
best_score, best_idx = score, idx
|
| 720 |
+
if best_idx < 0 or best_score < min_score:
|
| 721 |
+
return None
|
| 722 |
+
start = best_idx + 1
|
| 723 |
+
if start >= len(steps):
|
| 724 |
+
return []
|
| 725 |
+
end = min(start + max_next, len(steps))
|
| 726 |
+
anchor_norm = _norm_text(steps[best_idx])
|
| 727 |
+
out, seen = [], set()
|
| 728 |
+
for s in steps[start:end]:
|
| 729 |
+
k = _norm_text(s)
|
| 730 |
+
if k == anchor_norm:
|
| 731 |
+
continue
|
| 732 |
+
if k not in seen:
|
| 733 |
+
seen.add(k)
|
| 734 |
+
out.append(s)
|
| 735 |
+
return out
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
def _syn_hits(q_tokens: List[str], step_line: str) -> int:
|
| 739 |
+
"""Synonym/alias hits tailored for WMS picking + appointments wording."""
|
| 740 |
+
step = _norm_text(step_line)
|
| 741 |
+
SYNS = {
|
| 742 |
+
'scan': {'scan', 'prompt', 'display', 'show'},
|
| 743 |
+
'confirm': {'confirm', 'verify', 'check'},
|
| 744 |
+
'item': {'item', 'sku', 'product'},
|
| 745 |
+
'qty': {'qty', 'quantity', 'count'},
|
| 746 |
+
'place': {'place', 'put', 'stow'},
|
| 747 |
+
'complete': {'complete', 'finish', 'done'},
|
| 748 |
+
'staging': {'staging', 'stage', 'dock', 'door'},
|
| 749 |
+
'location': {'location', 'loc'},
|
| 750 |
+
'pallet': {'pallet', 'container'},
|
| 751 |
+
# Appointment vocabulary
|
| 752 |
+
'appointment': {'appointment', 'slot', 'schedule'},
|
| 753 |
+
'assign': {'assign', 'tag'},
|
| 754 |
+
'save': {'save', 'saved'},
|
| 755 |
+
'delete': {'delete', 'remove', 'removed'},
|
| 756 |
+
'update': {'update', 'modify', 'change', 'edit', 'updated'},
|
| 757 |
+
'vendor': {'vendor', 'supplier'},
|
| 758 |
+
'pro': {'pro number', 'pro'},
|
| 759 |
+
}
|
| 760 |
+
hits = 0
|
| 761 |
+
for qt in q_tokens:
|
| 762 |
+
for fam, words in SYNS.items():
|
| 763 |
+
if qt == fam or qt in words:
|
| 764 |
+
if any(w in step for w in words):
|
| 765 |
+
hits += 1
|
| 766 |
+
PHRASES = ('scan location', 'confirm item', 'pick quantity', 'place picked', 'move pallet', 'staging area')
|
| 767 |
+
for ph in PHRASES:
|
| 768 |
+
if ph in step:
|
| 769 |
+
hits += 1
|
| 770 |
+
return hits
|
| 771 |
+
|
| 772 |
+
|
| 773 |
@app.post("/chat")
|
| 774 |
async def chat_with_ai(input_data: ChatInput):
|
| 775 |
assist_followup: Optional[str] = None
|
| 776 |
try:
|
| 777 |
msg_norm = (input_data.user_message or "").lower().strip()
|
|
|
|
| 778 |
# Yes/No handlers
|
| 779 |
if msg_norm in ("yes", "y", "sure", "ok", "okay"):
|
| 780 |
return {
|
|
|
|
| 798 |
is_llm_resolved = _classify_resolution_llm(input_data.user_message)
|
| 799 |
if _has_negation_resolved(msg_norm):
|
| 800 |
is_llm_resolved = False
|
|
|
|
| 801 |
if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
|
| 802 |
try:
|
| 803 |
short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
|
|
|
|
| 868 |
"debug": {"intent": "create_ticket"},
|
| 869 |
}
|
| 870 |
|
| 871 |
+
# Status intent (ticket/incident)
|
| 872 |
status_intent = _parse_ticket_status_intent(msg_norm)
|
| 873 |
if status_intent:
|
| 874 |
if status_intent.get("ask_number"):
|
|
|
|
| 892 |
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
| 893 |
if not instance_url:
|
| 894 |
raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
|
|
|
|
| 895 |
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
| 896 |
number = status_intent.get("number")
|
| 897 |
url = f"{instance_url}/api/now/table/incident?number={number}"
|
|
|
|
| 899 |
data = response.json()
|
| 900 |
lst = data.get("result", [])
|
| 901 |
result = (lst or [{}])[0] if response.status_code == 200 else {}
|
|
|
|
| 902 |
state_code = builtins.str(result.get("state", "unknown"))
|
| 903 |
state_label = STATE_MAP.get(state_code, state_code)
|
| 904 |
short = result.get("short_description", "")
|
| 905 |
num = result.get("number", number or "unknown")
|
|
|
|
| 906 |
return {
|
| 907 |
"bot_response": (
|
| 908 |
f"**Ticket:** {num}\n"
|
|
|
|
| 922 |
except Exception as e:
|
| 923 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 924 |
|
|
|
|
| 925 |
# Hybrid KB search
|
|
|
|
| 926 |
kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
|
| 927 |
documents = kb_results.get("documents", [])
|
| 928 |
metadatas = kb_results.get("metadatas", [])
|
|
|
|
| 946 |
|
| 947 |
selected = items[:max(1, 2)]
|
| 948 |
context_raw = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
|
|
|
|
| 949 |
filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
|
| 950 |
context = filtered_text
|
| 951 |
context_found = bool(context.strip())
|
| 952 |
|
| 953 |
+
best_combined = (max([c for c in combined if c is not None], default=None) if combined else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
best_doc = kb_results.get("best_doc")
|
| 955 |
top_meta = (metadatas or [{}])[0] if metadatas else {}
|
|
|
|
| 956 |
|
| 957 |
+
msg_low = (input_data.user_message or "").lower()
|
| 958 |
GENERIC_ERROR_TERMS = ("error", "issue", "problem", "not working", "failed", "failure")
|
| 959 |
generic_error_signal = any(t in msg_low for t in GENERIC_ERROR_TERMS)
|
| 960 |
|
| 961 |
+
# Force steps on next-intent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
try:
|
| 963 |
if _detect_next_intent(input_data.user_message):
|
| 964 |
detected_intent = "steps"
|
| 965 |
+
else:
|
| 966 |
+
detected_intent = kb_results.get("user_intent", "neutral")
|
| 967 |
except Exception:
|
| 968 |
+
detected_intent = kb_results.get("user_intent", "neutral")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 969 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
escalation_line: Optional[str] = None
|
|
|
|
| 971 |
next_step_applied = False
|
| 972 |
next_step_info: Dict[str, Any] = {}
|
| 973 |
context_preformatted = False
|
| 974 |
|
| 975 |
if best_doc and detected_intent == "steps":
|
| 976 |
+
sec = (top_meta or {}).get('section')
|
| 977 |
+
if sec:
|
| 978 |
+
full_steps = get_section_text(best_doc, sec)
|
| 979 |
+
else:
|
| 980 |
+
full_steps = get_best_steps_section_text(best_doc)
|
|
|
|
| 981 |
if full_steps:
|
| 982 |
+
actions = kb_results.get('actions', [])
|
| 983 |
+
sliced = _slice_steps_by_known_headings(full_steps, actions)
|
| 984 |
+
filtered = _filter_steps_for_action(sliced, actions) if actions else sliced
|
| 985 |
+
numbered_full = _ensure_numbering(filtered)
|
| 986 |
+
next_only = _resolve_next_steps(input_data.user_message, numbered_full, max_next=6, min_score=0.35)
|
|
|
|
|
|
|
| 987 |
if next_only is not None:
|
| 988 |
if len(next_only) == 0:
|
| 989 |
context = "You are at the final step of this SOP. No further steps."
|
|
|
|
| 996 |
next_step_info = {"count": len(next_only)}
|
| 997 |
context_preformatted = True
|
| 998 |
else:
|
| 999 |
+
context = filtered
|
| 1000 |
context_preformatted = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1001 |
context_found = True
|
| 1002 |
|
| 1003 |
else:
|
| 1004 |
+
# Non-steps intents fall back to filtered context
|
| 1005 |
context = filtered_text
|
| 1006 |
|
| 1007 |
+
# Final formatting
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1008 |
if detected_intent == "steps":
|
| 1009 |
+
bot_text = context if context_preformatted else _ensure_numbering(context)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1010 |
else:
|
| 1011 |
bot_text = context
|
| 1012 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1013 |
return {
|
| 1014 |
"bot_response": bot_text,
|
| 1015 |
+
"status": "OK" if (best_combined is not None and best_combined >= 0.55) else "PARTIAL",
|
| 1016 |
"context_found": context_found,
|
| 1017 |
+
"ask_resolved": False,
|
| 1018 |
+
"suggest_incident": False,
|
| 1019 |
+
"followup": None,
|
| 1020 |
+
"options": [],
|
| 1021 |
"top_hits": [],
|
| 1022 |
"sources": [],
|
| 1023 |
"debug": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
"user_intent": detected_intent,
|
| 1025 |
"best_doc": best_doc,
|
| 1026 |
"next_step": {"applied": next_step_applied, "info": next_step_info},
|
| 1027 |
},
|
| 1028 |
}
|
|
|
|
| 1029 |
except HTTPException:
|
| 1030 |
raise
|
| 1031 |
except Exception as e:
|
| 1032 |
raise HTTPException(status_code=500, detail=safe_str(e))
|
| 1033 |
|
| 1034 |
|
|
|
|
|
|
|
|
|
|
| 1035 |
@app.post("/generate_ticket_desc")
|
| 1036 |
async def generate_ticket_desc_ep(input_data: TicketDescInput):
|
| 1037 |
try:
|