Spaces:
Sleeping
Sleeping
Update main.py
Browse files
main.py
CHANGED
|
@@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 10 |
from pydantic import BaseModel
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
from datetime import datetime
|
| 13 |
-
|
| 14 |
# KB services (Chroma + sentence-transformers + BM25 hybrid)
|
| 15 |
from services.kb_creation import (
|
| 16 |
collection,
|
|
@@ -290,6 +290,20 @@ def _classify_resolution_llm(user_message: str) -> bool:
|
|
| 290 |
except Exception:
|
| 291 |
return False
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
# ---------------------- Health ----------------------
|
| 294 |
@app.get("/")
|
| 295 |
async def health_check():
|
|
@@ -303,10 +317,10 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 303 |
A) If 'resolved/working' detected → auto-create tracking incident, auto-mark Resolved, and ask if further help needed.
|
| 304 |
B) If user intent is 'create incident/ticket' → SHOW INCIDENT FORM (ask Short & Long description). No Yes/No confirmation here.
|
| 305 |
C) Ticket status intent → if number missing, ask for it; else return status from ServiceNow.
|
| 306 |
-
D)
|
|
|
|
| 307 |
If still no context after clarification → suggest incident ONCE (Yes/No).
|
| 308 |
E) Otherwise, use HYBRID search; rewrite answer from KB only; ask resolved if 'OK', or refine if 'PARTIAL'.
|
| 309 |
-
|
| 310 |
"""
|
| 311 |
try:
|
| 312 |
msg_norm = (input_data.user_message or "").lower().strip()
|
|
@@ -392,7 +406,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 392 |
# --- (B) Incident intent
|
| 393 |
if _is_incident_intent(msg_norm):
|
| 394 |
return {
|
| 395 |
-
"bot_response":(
|
| 396 |
"Okay, let’s create a ServiceNow incident.\n\n"
|
| 397 |
"Please provide:\n• Short Description (one line)\n• Detailed Description (steps, error text, IDs, site, environment)"
|
| 398 |
),
|
|
@@ -400,13 +414,34 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 400 |
"context_found": False,
|
| 401 |
"ask_resolved": False,
|
| 402 |
"suggest_incident": False,
|
| 403 |
-
"show_incident_form": True,
|
| 404 |
"followup": None,
|
| 405 |
"top_hits": [],
|
| 406 |
"sources": [],
|
| 407 |
"debug": {"intent": "create_ticket"},
|
| 408 |
}
|
| 409 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
# --- (C) Ticket status intent
|
| 411 |
status_intent = _parse_ticket_status_intent(msg_norm)
|
| 412 |
if status_intent:
|
|
@@ -418,6 +453,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 418 |
"ask_resolved": False,
|
| 419 |
"suggest_incident": False,
|
| 420 |
"followup": "Provide the Incident ID and I’ll fetch the status.",
|
|
|
|
| 421 |
"top_hits": [],
|
| 422 |
"sources": [],
|
| 423 |
"debug": {"intent": "status_request_missing_id"},
|
|
@@ -441,7 +477,6 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 441 |
short = result.get("short_description", "")
|
| 442 |
num = result.get("number", number or "unknown")
|
| 443 |
|
| 444 |
-
# ✅ Use bold labels + hard line breaks so lines appear separately in Markdown
|
| 445 |
return {
|
| 446 |
"bot_response": (
|
| 447 |
f"**Ticket:** {num} \n"
|
|
@@ -449,7 +484,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 449 |
f"**Issue description:** {short}"
|
| 450 |
),
|
| 451 |
"status": "OK",
|
| 452 |
-
"show_assist_card": True,
|
| 453 |
"context_found": False,
|
| 454 |
"ask_resolved": False,
|
| 455 |
"suggest_incident": False,
|
|
@@ -477,7 +512,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 477 |
"status": "NO_KB_MATCH",
|
| 478 |
"context_found": False,
|
| 479 |
"ask_resolved": False,
|
| 480 |
-
"suggest_incident": bool(second_try),
|
| 481 |
"followup": ("Please reply with the above details." if not second_try else "Shall I create a ticket now?"),
|
| 482 |
"top_hits": [],
|
| 483 |
"sources": [],
|
|
@@ -492,11 +527,11 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 492 |
best_distance = kb_ctx.get("best_score") # lower = better
|
| 493 |
best_combined = kb_ctx.get("best_combined") # higher = better
|
| 494 |
|
| 495 |
-
# Dynamic gating
|
| 496 |
short_query = len((input_data.user_message or "").split()) <= 4
|
| 497 |
gate_combined_no_kb = 0.22 if short_query else 0.28 # below this → NO_KB
|
| 498 |
gate_combined_ok = 0.60 if short_query else 0.55 # above this → OK
|
| 499 |
-
gate_distance_no_kb = 2.0
|
| 500 |
|
| 501 |
if (not context_found or not context.strip()) or (
|
| 502 |
(best_combined is None or best_combined < gate_combined_no_kb)
|
|
@@ -521,7 +556,7 @@ async def chat_with_ai(input_data: ChatInput):
|
|
| 521 |
}
|
| 522 |
|
| 523 |
# We have KB context → LLM rewrite (KB‑only, no Source lines)
|
| 524 |
-
threshold_ok = gate_combined_ok
|
| 525 |
enhanced_prompt = (
|
| 526 |
"Rewrite the following knowledge base context into clear, actionable steps for the user's question. "
|
| 527 |
"Use ONLY the provided context; do NOT add information that is not present in it. "
|
|
@@ -601,9 +636,7 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 601 |
Logs a short diagnostic line on failure so we can see the exact reason.
|
| 602 |
"""
|
| 603 |
try:
|
| 604 |
-
# 1) Get an OAuth token the same way you already do for other ServiceNow calls
|
| 605 |
token = get_valid_token()
|
| 606 |
-
# 2) Read your instance URL from env
|
| 607 |
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
| 608 |
if not instance_url:
|
| 609 |
print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
|
|
@@ -623,36 +656,33 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 623 |
assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID") # optional
|
| 624 |
require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
|
| 625 |
|
| 626 |
-
# Optional pre-step: set state=2 (In Progress) if your org enforces transitions
|
| 627 |
if require_progress:
|
| 628 |
try:
|
| 629 |
-
resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
|
| 630 |
print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
|
| 631 |
except Exception as e:
|
| 632 |
print(f"[SN PATCH progress] exception={str(e)}")
|
| 633 |
|
| 634 |
-
# Helper to clean payload
|
| 635 |
def clean(d: dict) -> dict:
|
| 636 |
return {k: v for k, v in d.items() if v is not None}
|
| 637 |
|
| 638 |
payload_A = clean({
|
| 639 |
-
"state": "6",
|
| 640 |
-
"close_code": close_code_val,
|
| 641 |
-
"close_notes": close_notes_val,
|
| 642 |
-
"caller_id": caller_sysid,
|
| 643 |
"resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
| 644 |
"work_notes": "Auto-resolve set by NOVA.",
|
| 645 |
-
"resolved_by": resolved_by_sysid,
|
| 646 |
-
"assignment_group": assign_group,
|
| 647 |
})
|
| 648 |
respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
|
| 649 |
if respA.status_code in (200, 204):
|
| 650 |
return True
|
| 651 |
print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
|
| 652 |
|
| 653 |
-
# ---- B) PATCH with state label (some orgs map differently)
|
| 654 |
payload_B = clean({
|
| 655 |
-
"state": "Resolved",
|
| 656 |
"close_code": close_code_val,
|
| 657 |
"close_notes": close_notes_val,
|
| 658 |
"caller_id": caller_sysid,
|
|
@@ -666,8 +696,6 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 666 |
return True
|
| 667 |
print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
|
| 668 |
|
| 669 |
-
# ---- C) PATCH using custom field names from env
|
| 670 |
-
# If your dictionary uses, for example, u_resolution_code/u_resolution_notes
|
| 671 |
code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
|
| 672 |
notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
|
| 673 |
payload_C = clean({
|
|
@@ -685,7 +713,6 @@ def _set_incident_resolved(sys_id: str) -> bool:
|
|
| 685 |
return True
|
| 686 |
print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
|
| 687 |
|
| 688 |
-
# If all attempts failed, return False so the chat shows the soft message
|
| 689 |
return False
|
| 690 |
except Exception as e:
|
| 691 |
print(f"[SN PATCH resolve] exception={str(e)}")
|
|
@@ -707,8 +734,7 @@ async def raise_incident(input_data: IncidentInput):
|
|
| 707 |
else:
|
| 708 |
ticket_text = "Incident created."
|
| 709 |
|
| 710 |
-
#
|
| 711 |
-
# The front-end will show a Yes/No assist card after this.
|
| 712 |
return {
|
| 713 |
"bot_response": f"✅ {ticket_text}",
|
| 714 |
"debug": "Incident created via ServiceNow",
|
|
@@ -793,15 +819,14 @@ async def incident_status(input_data: TicketStatusInput):
|
|
| 793 |
short = result.get("short_description", "")
|
| 794 |
number = result.get("number", input_data.number or "unknown")
|
| 795 |
|
| 796 |
-
# ✅ Bold labels + hard line breaks so ReactMarkdown renders on separate lines
|
| 797 |
return {
|
| 798 |
"bot_response": (
|
| 799 |
f"**Ticket:** {number} \n"
|
| 800 |
f"**Status:** {state_label} \n"
|
| 801 |
f"**Issue description:** {short}"
|
| 802 |
-
).replace("\n", " \n"), #
|
| 803 |
"followup": "Is there anything else I can assist you with?",
|
| 804 |
-
"show_assist_card": True, #
|
| 805 |
"persist": True,
|
| 806 |
"debug": "Incident status fetched",
|
| 807 |
}
|
|
|
|
| 10 |
from pydantic import BaseModel
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
from datetime import datetime
|
| 13 |
+
#updated
|
| 14 |
# KB services (Chroma + sentence-transformers + BM25 hybrid)
|
| 15 |
from services.kb_creation import (
|
| 16 |
collection,
|
|
|
|
| 290 |
except Exception:
|
| 291 |
return False
|
| 292 |
|
| 293 |
+
|
| 294 |
+
def _is_generic_issue(msg_norm: str) -> bool:
|
| 295 |
+
"""
|
| 296 |
+
Returns True for very generic/open-ended issue statements that
|
| 297 |
+
shouldn’t trigger a KB search yet.
|
| 298 |
+
"""
|
| 299 |
+
generic_phrases = [
|
| 300 |
+
"issue", "have an issue", "having an issue", "got an issue",
|
| 301 |
+
"problem", "have a problem", "help", "need help", "support",
|
| 302 |
+
"need support", "please help", "need assistance", "assist me",
|
| 303 |
+
"facing issue", "facing a problem", "got a problem"
|
| 304 |
+
]
|
| 305 |
+
return any(p == msg_norm or p in msg_norm for p in generic_phrases) or len(msg_norm.split()) <= 2
|
| 306 |
+
|
| 307 |
# ---------------------- Health ----------------------
|
| 308 |
@app.get("/")
|
| 309 |
async def health_check():
|
|
|
|
| 317 |
A) If 'resolved/working' detected → auto-create tracking incident, auto-mark Resolved, and ask if further help needed.
|
| 318 |
B) If user intent is 'create incident/ticket' → SHOW INCIDENT FORM (ask Short & Long description). No Yes/No confirmation here.
|
| 319 |
C) Ticket status intent → if number missing, ask for it; else return status from ServiceNow.
|
| 320 |
+
D) Generic issue/openers → ask clarifying details first (no KB yet).
|
| 321 |
+
Feedback-only → first ask clarifying details; after user shares details, try KB again.
|
| 322 |
If still no context after clarification → suggest incident ONCE (Yes/No).
|
| 323 |
E) Otherwise, use HYBRID search; rewrite answer from KB only; ask resolved if 'OK', or refine if 'PARTIAL'.
|
|
|
|
| 324 |
"""
|
| 325 |
try:
|
| 326 |
msg_norm = (input_data.user_message or "").lower().strip()
|
|
|
|
| 406 |
# --- (B) Incident intent
|
| 407 |
if _is_incident_intent(msg_norm):
|
| 408 |
return {
|
| 409 |
+
"bot_response": (
|
| 410 |
"Okay, let’s create a ServiceNow incident.\n\n"
|
| 411 |
"Please provide:\n• Short Description (one line)\n• Detailed Description (steps, error text, IDs, site, environment)"
|
| 412 |
),
|
|
|
|
| 414 |
"context_found": False,
|
| 415 |
"ask_resolved": False,
|
| 416 |
"suggest_incident": False,
|
| 417 |
+
"show_incident_form": True,
|
| 418 |
"followup": None,
|
| 419 |
"top_hits": [],
|
| 420 |
"sources": [],
|
| 421 |
"debug": {"intent": "create_ticket"},
|
| 422 |
}
|
| 423 |
|
| 424 |
+
# --- (B.1) Generic issue/open-ended messages → ask for details first
|
| 425 |
+
if _is_generic_issue(msg_norm):
|
| 426 |
+
return {
|
| 427 |
+
"bot_response": (
|
| 428 |
+
"Sure, I can help. Please describe your issue:\n"
|
| 429 |
+
"• Module/area (e.g., Picking, Receiving, Trailer Close)\n"
|
| 430 |
+
"• Exact error message text/code (copy-paste)\n"
|
| 431 |
+
"• IDs involved (Order#, Load ID, Shipment#)\n"
|
| 432 |
+
"• Warehouse/site & environment (prod/test)\n"
|
| 433 |
+
"• When it started and how many users are impacted"
|
| 434 |
+
),
|
| 435 |
+
"status": "NO_KB_MATCH",
|
| 436 |
+
"context_found": False,
|
| 437 |
+
"ask_resolved": False,
|
| 438 |
+
"suggest_incident": False,
|
| 439 |
+
"followup": "Please reply with the above details.",
|
| 440 |
+
"top_hits": [],
|
| 441 |
+
"sources": [],
|
| 442 |
+
"debug": {"intent": "generic_issue"},
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
# --- (C) Ticket status intent
|
| 446 |
status_intent = _parse_ticket_status_intent(msg_norm)
|
| 447 |
if status_intent:
|
|
|
|
| 453 |
"ask_resolved": False,
|
| 454 |
"suggest_incident": False,
|
| 455 |
"followup": "Provide the Incident ID and I’ll fetch the status.",
|
| 456 |
+
"show_status_form": True, # helps front-end show the status input card
|
| 457 |
"top_hits": [],
|
| 458 |
"sources": [],
|
| 459 |
"debug": {"intent": "status_request_missing_id"},
|
|
|
|
| 477 |
short = result.get("short_description", "")
|
| 478 |
num = result.get("number", number or "unknown")
|
| 479 |
|
|
|
|
| 480 |
return {
|
| 481 |
"bot_response": (
|
| 482 |
f"**Ticket:** {num} \n"
|
|
|
|
| 484 |
f"**Issue description:** {short}"
|
| 485 |
),
|
| 486 |
"status": "OK",
|
| 487 |
+
"show_assist_card": True, # show Yes/No card so user can continue
|
| 488 |
"context_found": False,
|
| 489 |
"ask_resolved": False,
|
| 490 |
"suggest_incident": False,
|
|
|
|
| 512 |
"status": "NO_KB_MATCH",
|
| 513 |
"context_found": False,
|
| 514 |
"ask_resolved": False,
|
| 515 |
+
"suggest_incident": bool(second_try), # on second try → show Yes/No card
|
| 516 |
"followup": ("Please reply with the above details." if not second_try else "Shall I create a ticket now?"),
|
| 517 |
"top_hits": [],
|
| 518 |
"sources": [],
|
|
|
|
| 527 |
best_distance = kb_ctx.get("best_score") # lower = better
|
| 528 |
best_combined = kb_ctx.get("best_combined") # higher = better
|
| 529 |
|
| 530 |
+
# Dynamic gating
|
| 531 |
short_query = len((input_data.user_message or "").split()) <= 4
|
| 532 |
gate_combined_no_kb = 0.22 if short_query else 0.28 # below this → NO_KB
|
| 533 |
gate_combined_ok = 0.60 if short_query else 0.55 # above this → OK
|
| 534 |
+
gate_distance_no_kb = 2.0
|
| 535 |
|
| 536 |
if (not context_found or not context.strip()) or (
|
| 537 |
(best_combined is None or best_combined < gate_combined_no_kb)
|
|
|
|
| 556 |
}
|
| 557 |
|
| 558 |
# We have KB context → LLM rewrite (KB‑only, no Source lines)
|
| 559 |
+
threshold_ok = gate_combined_ok
|
| 560 |
enhanced_prompt = (
|
| 561 |
"Rewrite the following knowledge base context into clear, actionable steps for the user's question. "
|
| 562 |
"Use ONLY the provided context; do NOT add information that is not present in it. "
|
|
|
|
| 636 |
Logs a short diagnostic line on failure so we can see the exact reason.
|
| 637 |
"""
|
| 638 |
try:
|
|
|
|
| 639 |
token = get_valid_token()
|
|
|
|
| 640 |
instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
|
| 641 |
if not instance_url:
|
| 642 |
print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
|
|
|
|
| 656 |
assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID") # optional
|
| 657 |
require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
|
| 658 |
|
|
|
|
| 659 |
if require_progress:
|
| 660 |
try:
|
| 661 |
+
resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
|
| 662 |
print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
|
| 663 |
except Exception as e:
|
| 664 |
print(f"[SN PATCH progress] exception={str(e)}")
|
| 665 |
|
|
|
|
| 666 |
def clean(d: dict) -> dict:
|
| 667 |
return {k: v for k, v in d.items() if v is not None}
|
| 668 |
|
| 669 |
payload_A = clean({
|
| 670 |
+
"state": "6",
|
| 671 |
+
"close_code": close_code_val,
|
| 672 |
+
"close_notes": close_notes_val,
|
| 673 |
+
"caller_id": caller_sysid,
|
| 674 |
"resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
| 675 |
"work_notes": "Auto-resolve set by NOVA.",
|
| 676 |
+
"resolved_by": resolved_by_sysid,
|
| 677 |
+
"assignment_group": assign_group,
|
| 678 |
})
|
| 679 |
respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
|
| 680 |
if respA.status_code in (200, 204):
|
| 681 |
return True
|
| 682 |
print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
|
| 683 |
|
|
|
|
| 684 |
payload_B = clean({
|
| 685 |
+
"state": "Resolved",
|
| 686 |
"close_code": close_code_val,
|
| 687 |
"close_notes": close_notes_val,
|
| 688 |
"caller_id": caller_sysid,
|
|
|
|
| 696 |
return True
|
| 697 |
print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
|
| 698 |
|
|
|
|
|
|
|
| 699 |
code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
|
| 700 |
notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
|
| 701 |
payload_C = clean({
|
|
|
|
| 713 |
return True
|
| 714 |
print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
|
| 715 |
|
|
|
|
| 716 |
return False
|
| 717 |
except Exception as e:
|
| 718 |
print(f"[SN PATCH resolve] exception={str(e)}")
|
|
|
|
| 734 |
else:
|
| 735 |
ticket_text = "Incident created."
|
| 736 |
|
| 737 |
+
# Do NOT include follow-up question inside bot_response to avoid duplication in UI.
|
|
|
|
| 738 |
return {
|
| 739 |
"bot_response": f"✅ {ticket_text}",
|
| 740 |
"debug": "Incident created via ServiceNow",
|
|
|
|
| 819 |
short = result.get("short_description", "")
|
| 820 |
number = result.get("number", input_data.number or "unknown")
|
| 821 |
|
|
|
|
| 822 |
return {
|
| 823 |
"bot_response": (
|
| 824 |
f"**Ticket:** {number} \n"
|
| 825 |
f"**Status:** {state_label} \n"
|
| 826 |
f"**Issue description:** {short}"
|
| 827 |
+
).replace("\n", " \n"), # hard breaks for ReactMarkdown
|
| 828 |
"followup": "Is there anything else I can assist you with?",
|
| 829 |
+
"show_assist_card": True, # Yes/No card so user can continue
|
| 830 |
"persist": True,
|
| 831 |
"debug": "Incident status fetched",
|
| 832 |
}
|