srilakshu012456 commited on
Commit
192969d
·
verified ·
1 Parent(s): 82c195c

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +82 -243
main.py CHANGED
@@ -11,49 +11,38 @@ 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,
17
  ingest_documents,
18
- hybrid_search_knowledge_base, # intent-aware hybrid
19
  )
20
 
21
- # Optional routers/utilities you already have
22
- from services.login import router as login_router # login API router
23
- from services.generate_ticket import get_valid_token, create_incident # ServiceNow helpers
24
 
25
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
26
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
27
 
28
- # ---------- Env & App bootstrap ----------
29
  load_dotenv()
30
- os.environ["POSTHOG_DISABLED"] = "true" # Disable telemetry if present
31
-
32
 
33
  @asynccontextmanager
34
  async def lifespan(app: FastAPI):
35
- """
36
- On startup: populate KB if empty.
37
- """
38
  try:
39
  folder_path = os.path.join(os.getcwd(), "documents")
40
  if collection.count() == 0:
41
  print("🔍 KB empty. Running ingestion...")
42
- ingest_documents(folder_path) # walks /documents & ingests .docx
43
  else:
44
  print(f"✅ KB already populated with {collection.count()} entries. Skipping ingestion.")
45
  except Exception as e:
46
  print(f"⚠️ KB ingestion failed: {e}")
47
  yield
48
 
49
-
50
  app = FastAPI(lifespan=lifespan)
51
  app.include_router(login_router)
52
 
53
- # CORS (adjust origins as needed)
54
- origins = [
55
- "https://chatbotnova-chatbot-frontend.hf.space",
56
- ]
57
  app.add_middleware(
58
  CORSMiddleware,
59
  allow_origins=origins,
@@ -62,10 +51,9 @@ app.add_middleware(
62
  allow_headers=["*"],
63
  )
64
 
65
- # ---------- Models ----------
66
  class ChatInput(BaseModel):
67
  user_message: str
68
- prev_status: Optional[str] = None # "NO_KB_MATCH" | "PARTIAL" | "OK" | None
69
  last_issue: Optional[str] = None
70
 
71
  class IncidentInput(BaseModel):
@@ -78,9 +66,8 @@ class TicketDescInput(BaseModel):
78
 
79
  class TicketStatusInput(BaseModel):
80
  sys_id: Optional[str] = None
81
- number: Optional[str] = None # IncidentID (incident number)
82
 
83
- # ✅ Human‑readable mapping for ServiceNow incident state codes
84
  STATE_MAP = {
85
  "1": "New",
86
  "2": "In Progress",
@@ -90,38 +77,20 @@ STATE_MAP = {
90
  "8": "Canceled",
91
  }
92
 
93
- # ---------- Gemini setup ----------
94
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
95
  GEMINI_URL = (
96
  f"https://generativelanguage.googleapis.com/v1beta/models/"
97
  f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
98
  )
99
 
100
- # ---------- Helpers: KB context merge + sanitation ----------
101
  def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2) -> Dict[str, Any]:
102
- """
103
- Merge documents + metadatas + distances.
104
- If documents are missing but ids exist, fetch via collection.get (rare).
105
- Supports hybrid fields: 'combined_scores' in results.
106
- """
107
  if not kb_results or not isinstance(kb_results, dict):
108
  return {"context": "", "sources": [], "top_hits": [], "context_found": False, "best_score": None, "best_combined": None}
109
-
110
  documents = kb_results.get("documents") or []
111
  metadatas = kb_results.get("metadatas") or []
112
  distances = kb_results.get("distances") or []
113
- ids = kb_results.get("ids") or []
114
  combined = kb_results.get("combined_scores") or []
115
 
116
- # Fallback fetch by ids if needed
117
- if (not documents) and ids:
118
- try:
119
- fetched = collection.get(ids=ids, include=['documents', 'metadatas'])
120
- documents = fetched.get('documents', []) or []
121
- metadatas = fetched.get('metadatas', []) or metadatas
122
- except Exception:
123
- pass
124
-
125
  items = []
126
  for i, doc in enumerate(documents):
127
  text = doc.strip() if isinstance(doc, str) else ""
@@ -141,14 +110,12 @@ def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2
141
  context = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
142
  sources = [s["meta"] for s in selected]
143
 
144
- # best (lower distance is better, higher combined is better)
145
  best_distance = None
146
  if distances:
147
  try:
148
  best_distance = min([d for d in distances if d is not None])
149
  except Exception:
150
  best_distance = None
151
-
152
  best_combined = None
153
  if combined:
154
  try:
@@ -159,7 +126,7 @@ def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2
159
  return {
160
  "context": context,
161
  "sources": sources,
162
- "top_hits": [], # hidden in UI
163
  "context_found": bool(selected),
164
  "best_score": best_distance,
165
  "best_combined": best_combined,
@@ -179,13 +146,12 @@ def _build_clarifying_message() -> str:
179
  "I couldn’t find matching content in the KB yet. To help me narrow it down, please share:\n\n"
180
  "• Module/area (e.g., Picking, Receiving, Trailer Close)\n"
181
  "• Exact error message text/code (copy-paste)\n"
182
- "• IDs involved (Order#, Load ID, Shipment#, Item#)\n"
183
  "• Warehouse/site & environment (prod/test)\n"
184
  "• When it started and how many users are impacted\n\n"
185
  "Reply with these details and I’ll search again."
186
  )
187
 
188
- # ---------- Intent helpers ----------
189
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> tuple[str, str]:
190
  issue = (issue_text or "").strip()
191
  resolved = (resolved_text or "").strip()
@@ -215,16 +181,10 @@ def _is_feedback_message(msg_norm: str) -> bool:
215
  return any(p in msg_norm for p in feedback_phrases)
216
 
217
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
218
- status_keywords = [
219
- "status", "ticket status", "incident status",
220
- "check status", "check ticket status", "check incident status",
221
- ]
222
  if not any(k in msg_norm for k in status_keywords):
223
- return {} # not a status intent
224
- patterns = [
225
- r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
226
- r"(inc\d+)",
227
- ]
228
  for pat in patterns:
229
  m = re.search(pat, msg_norm, flags=re.IGNORECASE)
230
  if m:
@@ -262,13 +222,8 @@ def _classify_resolution_llm(user_message: str) -> bool:
262
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
263
  data = resp.json()
264
  text = (
265
- data.get("candidates", [{}])[0]
266
- .get("content", {})
267
- .get("parts", [{}])[0]
268
- .get("text", "")
269
- .strip()
270
- .lower()
271
- )
272
  return "true" in text
273
  except Exception:
274
  return False
@@ -282,82 +237,52 @@ def _is_generic_issue(msg_norm: str) -> bool:
282
  ]
283
  return any(p == msg_norm or p in msg_norm for p in generic_phrases) or len(msg_norm.split()) <= 2
284
 
285
- # ---------- NEW: Query-normalized, order-preserving filter ----------
286
- STRICT_OVERLAP = 3 # ≥3 shared terms → treat as exact match
287
- MAX_SENTENCES_STRICT = 4 # limit for exact-mode
288
- MAX_SENTENCES_CONCISE = 3 # limit for partial-mode
289
 
290
  def _normalize_for_match(text: str) -> str:
291
  t = (text or "").lower()
292
- t = re.sub(r"[^\w\s]", " ", t) # remove punctuation
293
- t = re.sub(r"\s+", " ", t).strip() # collapse spaces
294
  return t
295
 
296
  def _split_sentences(ctx: str) -> list[str]:
297
- # crude sentence split: punctuation/newlines/bullets/dashes
298
  raw_sents = re.split(r"(?<=[.!?])\s+|\n+|•\s*|-\s*", ctx or "")
299
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
300
 
301
  def _filter_context_for_query(context: str, query: str) -> tuple[str, dict]:
302
- """
303
- Returns (filtered_text, info) where filtered_text is:
304
- - Exact-mode: ONLY sentences with strong overlap, preserving doc order.
305
- - Concise-mode: First few sentences with some overlap, preserving order.
306
- info: { 'mode': 'exact'|'concise', 'matched_count': int, 'all_sentences': int }
307
- """
308
  ctx = (context or "").strip()
309
  if not ctx or not query:
310
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
311
-
312
  q_norm = _normalize_for_match(query)
313
- q_terms = [t for t in q_norm.split() if len(t) > 2] # ignore short tokens
314
  if not q_terms:
315
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
316
-
317
  sentences = _split_sentences(ctx)
318
- matched_exact = []
319
- matched_any = []
320
-
321
  for s in sentences:
322
  s_norm = _normalize_for_match(s)
323
- # small boost if sentence looks like a bullet
324
  is_bullet = bool(re.match(r"^[•\-\*]\s*", s))
325
  overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
326
  if overlap >= STRICT_OVERLAP:
327
  matched_exact.append(s)
328
  elif overlap > 0:
329
  matched_any.append(s)
330
-
331
  if matched_exact:
332
  kept = matched_exact[:MAX_SENTENCES_STRICT]
333
- return "\n".join(kept).strip(), {
334
- 'mode': 'exact',
335
- 'matched_count': len(kept),
336
- 'all_sentences': len(sentences)
337
- }
338
-
339
  if matched_any:
340
  kept = matched_any[:MAX_SENTENCES_CONCISE]
341
- return "\n".join(kept).strip(), {
342
- 'mode': 'concise',
343
- 'matched_count': len(kept),
344
- 'all_sentences': len(sentences)
345
- }
346
-
347
- # No overlap → keep the first few sentences (still concise, preserve order)
348
  kept = sentences[:MAX_SENTENCES_CONCISE]
349
- return "\n".join(kept).strip(), {
350
- 'mode': 'concise',
351
- 'matched_count': 0,
352
- 'all_sentences': len(sentences)
353
- }
354
-
355
- # ---------- NEW: intent-specific line extractors (steps/navigation/errors) ----------
356
 
 
357
  STEP_LINE_REGEX = re.compile(r"^\s*(?:\d+[\.\)]\s+|[•\-]\s+)", re.IGNORECASE)
358
  NAV_LINE_REGEX = re.compile(r"(navigate\s+to|>\s*)", re.IGNORECASE)
359
 
360
- # Common imperative verbs across SOPs (add more if you want, optional)
361
  PROCEDURE_VERBS = [
362
  "log in", "select", "scan", "verify", "confirm", "print",
363
  "move", "complete", "click", "open", "navigate", "choose",
@@ -365,56 +290,55 @@ PROCEDURE_VERBS = [
365
  ]
366
  VERB_START_REGEX = re.compile(r"^\s*(?:" + "|".join([re.escape(v) for v in PROCEDURE_VERBS]) + r")\b", re.IGNORECASE)
367
 
368
- # Lines that clearly are NOT steps when user intent is 'steps'
369
- NON_PROCEDURAL_STARTS = [
370
- "to ensure", "as per", "purpose", "pre-requisites", "prerequisites", "overview", "introduction"
371
  ]
372
- NON_PROC_REGEX = re.compile(r"^\s*(?:" + "|".join([re.escape(v) for v in NON_PROCEDURAL_STARTS]) + r")\b", re.IGNORECASE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
 
374
  def _is_procedural_line(ln: str) -> bool:
375
- """
376
- A line is procedural if:
377
- - it starts with a number/bullet, OR
378
- - it starts with an imperative verb (Log in, Select, Scan, etc.)
379
- and it does not look like Purpose/Pre-Requisites/Overview.
380
- Bullets are kept only if they contain an action verb (to avoid prereq bullets).
381
- """
382
  s = (ln or "").strip()
383
  if not s:
384
  return False
385
- # Exclude clearly non-procedural lines
386
- if NON_PROC_REGEX.match(s):
387
  return False
388
-
389
- # Numbered/bulleted lines
390
  if STEP_LINE_REGEX.match(s):
391
- # Keep bullet only if an action verb appears somewhere in the line
392
  if s.lstrip().startswith(("•", "-")):
393
- return bool(VERB_START_REGEX.search(s))
394
  return True
395
-
396
- # Imperative verb lines (covers Word lists where 1. isn't part of the text)
397
  if VERB_START_REGEX.match(s):
398
  return True
399
-
400
- # Allow navigation lines (even if not numbered)
401
  if NAV_LINE_REGEX.search(s):
402
  return True
403
-
404
  return False
405
 
406
- def _extract_steps_only(text: str, max_lines: int = 12) -> str:
407
- """
408
- Keep only procedural lines (numbered/bulleted or imperative verb starts) in original order.
409
- """
410
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
411
  kept = []
412
  for ln in lines:
413
  if _is_procedural_line(ln):
 
 
 
 
414
  kept.append(ln)
415
  if len(kept) >= max_lines:
416
  break
417
- # If nothing matched (rare), return the original “concise” filtered text
418
  return "\n".join(kept).strip() if kept else (text or "").strip()
419
 
420
  def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
@@ -431,41 +355,24 @@ def _extract_errors_only(text: str, max_lines: int = 10) -> str:
431
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
432
  kept = []
433
  for ln in lines:
434
- # Keep error/resolution bullets or imperative fixes (verify, check, etc.)
435
  if STEP_LINE_REGEX.match(ln) or ln.lower().startswith(("error", "resolution", "fix", "verify", "check")):
436
  kept.append(ln)
437
  if len(kept) >= max_lines:
438
  break
439
  return "\n".join(kept).strip() if kept else (text or "").strip()
440
 
441
- # ---------- Health ----------
442
  @app.get("/")
443
  async def health_check():
444
  return {"status": "ok"}
445
 
446
- # ---------- Chat endpoint ----------
447
  @app.post("/chat")
448
  async def chat_with_ai(input_data: ChatInput):
449
- """
450
- Policy:
451
- A) If 'resolved/working' detected → auto-create tracking incident, auto-mark Resolved, and ask if further help needed.
452
- B) If user intent is 'create incident/ticket' → SHOW INCIDENT FORM (ask Short & Long description). No Yes/No confirmation here.
453
- C) Ticket status intent → if number missing, ask for it; else return status from ServiceNow.
454
- D) Generic issue/openers → ask clarifying details first (no KB yet).
455
- Feedback-only → first ask clarifying details; after user shares details, try KB again.
456
- If still no context after clarification → suggest incident ONCE (Yes/No).
457
- E) Otherwise, use HYBRID search; rewrite answer from KB only; ask resolved if 'OK', or refine if 'PARTIAL'.
458
- """
459
  try:
460
  msg_norm = (input_data.user_message or "").lower().strip()
461
 
462
- # -- Handle Yes/No replies from the UI --
463
  if msg_norm in ("yes", "y", "sure", "ok", "okay"):
464
  return {
465
- "bot_response": (
466
- "Great! Tell me what you’d like to do next — check another ticket, "
467
- "create an incident, or describe your issue."
468
- ),
469
  "status": "OK",
470
  "followup": "You can say: 'create ticket', 'incident status INC0012345', or describe your problem.",
471
  "options": [],
@@ -481,19 +388,16 @@ async def chat_with_ai(input_data: ChatInput):
481
  "debug": {"intent": "end_conversation"},
482
  }
483
 
484
- # -- (A) Resolution acknowledgement --
485
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
486
  if _has_negation_resolved(msg_norm):
487
  is_llm_resolved = False
488
-
489
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
490
  try:
491
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
492
- result = create_incident(short_desc, long_desc) # ServiceNow helper
493
  if isinstance(result, dict) and not result.get("error"):
494
  inc_number = result.get("number", "<unknown>")
495
  sys_id = result.get("sys_id")
496
- # Auto-mark resolved (state=6) if we have sys_id
497
  resolved_note = ""
498
  if sys_id:
499
  ok = _set_incident_resolved(sys_id)
@@ -536,7 +440,6 @@ async def chat_with_ai(input_data: ChatInput):
536
  "debug": {"intent": "resolved_ack", "exception": True},
537
  }
538
 
539
- # -- (B) Incident intent --
540
  if _is_incident_intent(msg_norm):
541
  return {
542
  "bot_response": (
@@ -555,7 +458,6 @@ async def chat_with_ai(input_data: ChatInput):
555
  "debug": {"intent": "create_ticket"},
556
  }
557
 
558
- # -- (B.1) Generic issue/open-ended messages → ask details first
559
  if _is_generic_issue(msg_norm):
560
  return {
561
  "bot_response": (
@@ -576,7 +478,6 @@ async def chat_with_ai(input_data: ChatInput):
576
  "debug": {"intent": "generic_issue"},
577
  }
578
 
579
- # -- (C) Ticket status intent --
580
  status_intent = _parse_ticket_status_intent(msg_norm)
581
  if status_intent:
582
  if status_intent.get("ask_number"):
@@ -587,7 +488,7 @@ async def chat_with_ai(input_data: ChatInput):
587
  "ask_resolved": False,
588
  "suggest_incident": False,
589
  "followup": "Provide the Incident ID and I’ll fetch the status.",
590
- "show_status_form": True, # helps front-end show the status input card
591
  "top_hits": [],
592
  "sources": [],
593
  "debug": {"intent": "status_request_missing_id"},
@@ -609,13 +510,9 @@ async def chat_with_ai(input_data: ChatInput):
609
  short = result.get("short_description", "")
610
  num = result.get("number", number or "unknown")
611
  return {
612
- "bot_response": (
613
- f"**Ticket:** {num} \n"
614
- f"**Status:** {state_label} \n"
615
- f"**Issue description:** {short}"
616
- ),
617
  "status": "OK",
618
- "show_assist_card": True, # show Yes/No card so user can continue
619
  "context_found": False,
620
  "ask_resolved": False,
621
  "suggest_incident": False,
@@ -627,56 +524,32 @@ async def chat_with_ai(input_data: ChatInput):
627
  except Exception as e:
628
  raise HTTPException(status_code=500, detail=str(e))
629
 
630
- # -- (D) Feedback-only messages --
631
- if _is_feedback_message(msg_norm):
632
- second_try = (input_data.prev_status or "").upper() == "NO_KB_MATCH"
633
- return {
634
- "bot_response": (
635
- "Understood. To refine the steps, please share:\n"
636
- "• Exact error text/code\n"
637
- "• IDs (Order#, Load ID, Shipment#)\n"
638
- "• Site & environment (prod/test)\n"
639
- "• When it started and how many users are impacted"
640
- if not second_try else
641
- "It still looks unresolved after clarification."
642
- ),
643
- "status": "NO_KB_MATCH",
644
- "context_found": False,
645
- "ask_resolved": False,
646
- "suggest_incident": bool(second_try), # on second try → show Yes/No card
647
- "followup": ("Please reply with the above details." if not second_try else "Shall I create a ticket now?"),
648
- "top_hits": [],
649
- "sources": [],
650
- "debug": {"feedback_only": True, "second_try": second_try},
651
- }
652
-
653
- # -- (E) HYBRID KB search & rewrite --
654
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
655
  kb_ctx = extract_kb_context(kb_results, top_chunks=2)
656
  context_raw = kb_ctx.get("context", "") or ""
657
 
658
- # Filter to exact/concise and always preserve original order of matched sentences
659
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
660
  context = filtered_text
661
  context_found = bool(kb_ctx.get("context_found", False)) and bool(context.strip())
662
- best_distance = kb_ctx.get("best_score") # lower = better
663
- best_combined = kb_ctx.get("best_combined") # higher = better
664
  detected_intent = kb_results.get("user_intent", "neutral")
 
665
 
666
- # Intent-shaped extraction (steps/navigation/errors)
667
  q = (input_data.user_message or "").lower()
668
  if detected_intent == "steps" or any(k in q for k in ["steps", "procedure", "perform", "do", "process"]):
669
- context = _extract_steps_only(context, max_lines=12)
670
  elif detected_intent == "errors" or any(k in q for k in ["error", "issue", "fail", "not working", "resolution", "fix"]):
671
  context = _extract_errors_only(context, max_lines=10)
672
  elif any(k in q for k in ["navigate", "navigation", "menu", "screen"]):
673
  context = _extract_navigation_only(context, max_lines=6)
674
- # else: leave context as-is (concise filter already applied)
675
 
676
- # Dynamic gating
677
  short_query = len((input_data.user_message or "").split()) <= 4
678
- gate_combined_no_kb = 0.22 if short_query else 0.28 # below this → NO_KB
679
- gate_combined_ok = 0.60 if short_query else 0.55 # above this → OK
680
  gate_distance_no_kb = 2.0
681
 
682
  if (not context_found or not context.strip()) or (
@@ -701,26 +574,18 @@ async def chat_with_ai(input_data: ChatInput):
701
  "debug": {"used_chunks": 0, "second_try": second_try, "best_distance": best_distance, "best_combined": best_combined},
702
  }
703
 
704
- # We have KB context → LLM rewrite (KB‑only, no Source lines)
705
- threshold_ok = gate_combined_ok
706
- mode_note = (
707
- "Return ONLY the matched lines from the context in the same order."
708
- if filt_info.get("mode") == "exact" else
709
- "Return a short, meaningful snippet strictly based on the context."
710
- )
711
-
712
  enhanced_prompt = (
713
  "From the provided context, output only the actionable steps/procedure relevant to the user's question. "
714
  "Use ONLY the provided context; do NOT add information that is not present. "
715
- f"{mode_note} "
716
- "Do NOT include any document names, section titles, or 'Source:' lines.\n\n"
717
  f"### Context\n{context}\n\n"
718
  f"### Question\n{input_data.user_message}\n\n"
719
  "### Output\n"
720
  "- Return numbered/bulleted steps only, in the same order.\n"
721
  "- If context is insufficient, add: 'This may be partial based on available KB.'\n"
722
  )
723
-
724
  headers = {"Content-Type": "application/json"}
725
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
726
  try:
@@ -734,20 +599,16 @@ async def chat_with_ai(input_data: ChatInput):
734
  result = {}
735
 
736
  try:
737
- bot_text = (
738
- result["candidates"][0]["content"]["parts"][0]["text"]
739
- if isinstance(result, dict) else ""
740
- )
741
  except Exception:
742
  bot_text = ""
743
 
744
  if not bot_text.strip():
745
- # Fallback to the filtered/intent-shaped context (never the full SOP chunk)
746
- bot_text = context
747
  bot_text = _strip_any_source_lines(bot_text).strip()
748
 
749
  status = "OK" if (
750
- (best_combined is not None and best_combined >= threshold_ok)
751
  or (filt_info.get('mode') == 'exact' and filt_info.get('matched_count', 0) > 0)
752
  ) else "PARTIAL"
753
 
@@ -755,17 +616,13 @@ async def chat_with_ai(input_data: ChatInput):
755
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
756
  status = "PARTIAL"
757
 
758
- ask_resolved = (status == "OK")
759
- suggest_incident = False
760
- followup = ("Does this match your scenario? I can refine the steps." if status == "PARTIAL" else None)
761
-
762
  return {
763
  "bot_response": bot_text,
764
  "status": status,
765
  "context_found": True,
766
- "ask_resolved": ask_resolved,
767
- "suggest_incident": suggest_incident,
768
- "followup": followup,
769
  "top_hits": [],
770
  "sources": [],
771
  "debug": {
@@ -776,6 +633,7 @@ async def chat_with_ai(input_data: ChatInput):
776
  "filter_mode": filt_info.get("mode"),
777
  "matched_count": filt_info.get("matched_count"),
778
  "user_intent": detected_intent,
 
779
  },
780
  }
781
 
@@ -784,16 +642,7 @@ async def chat_with_ai(input_data: ChatInput):
784
  except Exception as e:
785
  raise HTTPException(status_code=500, detail=str(e))
786
 
787
- # ---------- Incident endpoints ----------
788
  def _set_incident_resolved(sys_id: str) -> bool:
789
- """
790
- Robust resolver:
791
- A) Try default fields (close_code/close_notes/caller_id) with state=6
792
- B) If fails, try state="Resolved"
793
- C) If still fails, try custom field names from env (e.g., u_resolution_code/u_resolution_notes)
794
- Optional: pre-step to In Progress if SERVICENOW_REQUIRE_IN_PROGRESS_FIRST=true
795
- Logs a short diagnostic line on failure so we can see the exact reason.
796
- """
797
  try:
798
  token = get_valid_token()
799
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
@@ -810,8 +659,8 @@ def _set_incident_resolved(sys_id: str) -> bool:
810
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
811
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
812
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
813
- resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID") # optional
814
- assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID") # optional
815
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
816
 
817
  if require_progress:
@@ -889,8 +738,6 @@ async def raise_incident(input_data: IncidentInput):
889
  ticket_text = f"Incident created: {inc_number}{resolved_note}"
890
  else:
891
  ticket_text = "Incident created."
892
-
893
- # Do NOT include follow-up question inside bot_response to avoid duplication in UI.
894
  return {
895
  "bot_response": f"✅ {ticket_text}",
896
  "debug": "Incident created via ServiceNow",
@@ -945,10 +792,7 @@ async def incident_status(input_data: TicketStatusInput):
945
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
946
  if not instance_url:
947
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
948
- headers = {
949
- "Authorization": f"Bearer {token}",
950
- "Accept": "application/json",
951
- }
952
  if input_data.sys_id:
953
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
954
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
@@ -962,19 +806,14 @@ async def incident_status(input_data: TicketStatusInput):
962
  result = (lst or [{}])[0] if response.status_code == 200 else {}
963
  else:
964
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
965
-
966
  state_code = str(result.get("state", "unknown"))
967
  state_label = STATE_MAP.get(state_code, state_code)
968
  short = result.get("short_description", "")
969
  number = result.get("number", input_data.number or "unknown")
970
  return {
971
- "bot_response": (
972
- f"**Ticket:** {number} \n"
973
- f"**Status:** {state_label} \n"
974
- f"**Issue description:** {short}"
975
- ).replace("\n", " \n"), # hard breaks for ReactMarkdown
976
  "followup": "Is there anything else I can assist you with?",
977
- "show_assist_card": True, # Yes/No card so user can continue
978
  "persist": True,
979
  "debug": "Incident status fetched",
980
  }
 
11
  from dotenv import load_dotenv
12
  from datetime import datetime
13
 
 
14
  from services.kb_creation import (
15
  collection,
16
  ingest_documents,
17
+ hybrid_search_knowledge_base,
18
  )
19
 
20
+ from services.login import router as login_router
21
+ from services.generate_ticket import get_valid_token, create_incident
 
22
 
23
  VERIFY_SSL = os.getenv("SERVICENOW_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
24
  GEMINI_SSL_VERIFY = os.getenv("GEMINI_SSL_VERIFY", "true").lower() in ("1", "true", "yes")
25
 
 
26
  load_dotenv()
27
+ os.environ["POSTHOG_DISABLED"] = "true"
 
28
 
29
  @asynccontextmanager
30
  async def lifespan(app: FastAPI):
 
 
 
31
  try:
32
  folder_path = os.path.join(os.getcwd(), "documents")
33
  if collection.count() == 0:
34
  print("🔍 KB empty. Running ingestion...")
35
+ ingest_documents(folder_path)
36
  else:
37
  print(f"✅ KB already populated with {collection.count()} entries. Skipping ingestion.")
38
  except Exception as e:
39
  print(f"⚠️ KB ingestion failed: {e}")
40
  yield
41
 
 
42
  app = FastAPI(lifespan=lifespan)
43
  app.include_router(login_router)
44
 
45
+ origins = ["https://chatbotnova-chatbot-frontend.hf.space"]
 
 
 
46
  app.add_middleware(
47
  CORSMiddleware,
48
  allow_origins=origins,
 
51
  allow_headers=["*"],
52
  )
53
 
 
54
  class ChatInput(BaseModel):
55
  user_message: str
56
+ prev_status: Optional[str] = None
57
  last_issue: Optional[str] = None
58
 
59
  class IncidentInput(BaseModel):
 
66
 
67
  class TicketStatusInput(BaseModel):
68
  sys_id: Optional[str] = None
69
+ number: Optional[str] = None
70
 
 
71
  STATE_MAP = {
72
  "1": "New",
73
  "2": "In Progress",
 
77
  "8": "Canceled",
78
  }
79
 
 
80
  GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
81
  GEMINI_URL = (
82
  f"https://generativelanguage.googleapis.com/v1beta/models/"
83
  f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
84
  )
85
 
 
86
  def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2) -> Dict[str, Any]:
 
 
 
 
 
87
  if not kb_results or not isinstance(kb_results, dict):
88
  return {"context": "", "sources": [], "top_hits": [], "context_found": False, "best_score": None, "best_combined": None}
 
89
  documents = kb_results.get("documents") or []
90
  metadatas = kb_results.get("metadatas") or []
91
  distances = kb_results.get("distances") or []
 
92
  combined = kb_results.get("combined_scores") or []
93
 
 
 
 
 
 
 
 
 
 
94
  items = []
95
  for i, doc in enumerate(documents):
96
  text = doc.strip() if isinstance(doc, str) else ""
 
110
  context = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
111
  sources = [s["meta"] for s in selected]
112
 
 
113
  best_distance = None
114
  if distances:
115
  try:
116
  best_distance = min([d for d in distances if d is not None])
117
  except Exception:
118
  best_distance = None
 
119
  best_combined = None
120
  if combined:
121
  try:
 
126
  return {
127
  "context": context,
128
  "sources": sources,
129
+ "top_hits": [],
130
  "context_found": bool(selected),
131
  "best_score": best_distance,
132
  "best_combined": best_combined,
 
146
  "I couldn’t find matching content in the KB yet. To help me narrow it down, please share:\n\n"
147
  "• Module/area (e.g., Picking, Receiving, Trailer Close)\n"
148
  "• Exact error message text/code (copy-paste)\n"
149
+ "• IDs involved (Order#, Load ID, Shipment#)\n"
150
  "• Warehouse/site & environment (prod/test)\n"
151
  "• When it started and how many users are impacted\n\n"
152
  "Reply with these details and I’ll search again."
153
  )
154
 
 
155
  def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> tuple[str, str]:
156
  issue = (issue_text or "").strip()
157
  resolved = (resolved_text or "").strip()
 
181
  return any(p in msg_norm for p in feedback_phrases)
182
 
183
  def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
184
+ status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
 
 
 
185
  if not any(k in msg_norm for k in status_keywords):
186
+ return {}
187
+ patterns = [r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)", r"(inc\d+)"]
 
 
 
188
  for pat in patterns:
189
  m = re.search(pat, msg_norm, flags=re.IGNORECASE)
190
  if m:
 
222
  resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
223
  data = resp.json()
224
  text = (
225
+ data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
226
+ ).strip().lower()
 
 
 
 
 
227
  return "true" in text
228
  except Exception:
229
  return False
 
237
  ]
238
  return any(p == msg_norm or p in msg_norm for p in generic_phrases) or len(msg_norm.split()) <= 2
239
 
240
+ # ---------- Query-normalized, order-preserving filter ----------
241
+ STRICT_OVERLAP = 3
242
+ MAX_SENTENCES_STRICT = 4
243
+ MAX_SENTENCES_CONCISE = 3
244
 
245
  def _normalize_for_match(text: str) -> str:
246
  t = (text or "").lower()
247
+ t = re.sub(r"[^\w\s]", " ", t)
248
+ t = re.sub(r"\s+", " ", t).strip()
249
  return t
250
 
251
  def _split_sentences(ctx: str) -> list[str]:
 
252
  raw_sents = re.split(r"(?<=[.!?])\s+|\n+|•\s*|-\s*", ctx or "")
253
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
254
 
255
  def _filter_context_for_query(context: str, query: str) -> tuple[str, dict]:
 
 
 
 
 
 
256
  ctx = (context or "").strip()
257
  if not ctx or not query:
258
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
 
259
  q_norm = _normalize_for_match(query)
260
+ q_terms = [t for t in q_norm.split() if len(t) > 2]
261
  if not q_terms:
262
  return ctx, {'mode': 'concise', 'matched_count': 0, 'all_sentences': 0}
 
263
  sentences = _split_sentences(ctx)
264
+ matched_exact, matched_any = [], []
 
 
265
  for s in sentences:
266
  s_norm = _normalize_for_match(s)
 
267
  is_bullet = bool(re.match(r"^[•\-\*]\s*", s))
268
  overlap = sum(1 for t in q_terms if t in s_norm) + (1 if is_bullet else 0)
269
  if overlap >= STRICT_OVERLAP:
270
  matched_exact.append(s)
271
  elif overlap > 0:
272
  matched_any.append(s)
 
273
  if matched_exact:
274
  kept = matched_exact[:MAX_SENTENCES_STRICT]
275
+ return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
 
 
 
 
 
276
  if matched_any:
277
  kept = matched_any[:MAX_SENTENCES_CONCISE]
278
+ return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': len(kept), 'all_sentences': len(sentences)}
 
 
 
 
 
 
279
  kept = sentences[:MAX_SENTENCES_CONCISE]
280
+ return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
 
 
 
 
 
 
281
 
282
+ # ---------- intent & action specific extractors ----------
283
  STEP_LINE_REGEX = re.compile(r"^\s*(?:\d+[\.\)]\s+|[•\-]\s+)", re.IGNORECASE)
284
  NAV_LINE_REGEX = re.compile(r"(navigate\s+to|>\s*)", re.IGNORECASE)
285
 
 
286
  PROCEDURE_VERBS = [
287
  "log in", "select", "scan", "verify", "confirm", "print",
288
  "move", "complete", "click", "open", "navigate", "choose",
 
290
  ]
291
  VERB_START_REGEX = re.compile(r"^\s*(?:" + "|".join([re.escape(v) for v in PROCEDURE_VERBS]) + r")\b", re.IGNORECASE)
292
 
293
+ NON_PROC_PHRASES = [
294
+ "to ensure", "as per", "purpose", "pre-requisites", "prerequisites", "overview", "introduction",
295
+ "organized manner", "structured", "help users", "objective"
296
  ]
297
+ NON_PROC_ANY_REGEX = re.compile("|".join([re.escape(v) for v in NON_PROC_PHRASES]), re.IGNORECASE)
298
+
299
+ ACTION_SYNS_FLAT = {
300
+ "create": ["create", "creation", "add", "new", "generate"],
301
+ "update": ["update", "modify", "change", "edit"],
302
+ "delete": ["delete", "remove"],
303
+ "navigate": ["navigate", "go to", "open"],
304
+ }
305
+
306
+ def _action_in_line(ln: str, target_actions: list[str]) -> bool:
307
+ s = (ln or "").lower()
308
+ for act in target_actions:
309
+ for syn in ACTION_SYNS_FLAT.get(act, [act]):
310
+ if syn in s:
311
+ return True
312
+ return False
313
 
314
  def _is_procedural_line(ln: str) -> bool:
 
 
 
 
 
 
 
315
  s = (ln or "").strip()
316
  if not s:
317
  return False
318
+ if NON_PROC_ANY_REGEX.search(s):
 
319
  return False
 
 
320
  if STEP_LINE_REGEX.match(s):
 
321
  if s.lstrip().startswith(("•", "-")):
322
+ return bool(VERB_START_REGEX.search(s) or NAV_LINE_REGEX.search(s))
323
  return True
 
 
324
  if VERB_START_REGEX.match(s):
325
  return True
 
 
326
  if NAV_LINE_REGEX.search(s):
327
  return True
 
328
  return False
329
 
330
+ def _extract_steps_only(text: str, max_lines: int = 12, target_actions: list[str] | None = None) -> str:
 
 
 
331
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
332
  kept = []
333
  for ln in lines:
334
  if _is_procedural_line(ln):
335
+ # If specific action requested (e.g., create), keep only lines containing that action
336
+ if target_actions and len(target_actions) > 0:
337
+ if not _action_in_line(ln, target_actions):
338
+ continue
339
  kept.append(ln)
340
  if len(kept) >= max_lines:
341
  break
 
342
  return "\n".join(kept).strip() if kept else (text or "").strip()
343
 
344
  def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
 
355
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
356
  kept = []
357
  for ln in lines:
 
358
  if STEP_LINE_REGEX.match(ln) or ln.lower().startswith(("error", "resolution", "fix", "verify", "check")):
359
  kept.append(ln)
360
  if len(kept) >= max_lines:
361
  break
362
  return "\n".join(kept).strip() if kept else (text or "").strip()
363
 
 
364
  @app.get("/")
365
  async def health_check():
366
  return {"status": "ok"}
367
 
 
368
  @app.post("/chat")
369
  async def chat_with_ai(input_data: ChatInput):
 
 
 
 
 
 
 
 
 
 
370
  try:
371
  msg_norm = (input_data.user_message or "").lower().strip()
372
 
 
373
  if msg_norm in ("yes", "y", "sure", "ok", "okay"):
374
  return {
375
+ "bot_response": ("Great! Tell me what you’d like to do next — check another ticket, create an incident, or describe your issue."),
 
 
 
376
  "status": "OK",
377
  "followup": "You can say: 'create ticket', 'incident status INC0012345', or describe your problem.",
378
  "options": [],
 
388
  "debug": {"intent": "end_conversation"},
389
  }
390
 
 
391
  is_llm_resolved = _classify_resolution_llm(input_data.user_message)
392
  if _has_negation_resolved(msg_norm):
393
  is_llm_resolved = False
 
394
  if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
395
  try:
396
  short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
397
+ result = create_incident(short_desc, long_desc)
398
  if isinstance(result, dict) and not result.get("error"):
399
  inc_number = result.get("number", "<unknown>")
400
  sys_id = result.get("sys_id")
 
401
  resolved_note = ""
402
  if sys_id:
403
  ok = _set_incident_resolved(sys_id)
 
440
  "debug": {"intent": "resolved_ack", "exception": True},
441
  }
442
 
 
443
  if _is_incident_intent(msg_norm):
444
  return {
445
  "bot_response": (
 
458
  "debug": {"intent": "create_ticket"},
459
  }
460
 
 
461
  if _is_generic_issue(msg_norm):
462
  return {
463
  "bot_response": (
 
478
  "debug": {"intent": "generic_issue"},
479
  }
480
 
 
481
  status_intent = _parse_ticket_status_intent(msg_norm)
482
  if status_intent:
483
  if status_intent.get("ask_number"):
 
488
  "ask_resolved": False,
489
  "suggest_incident": False,
490
  "followup": "Provide the Incident ID and I’ll fetch the status.",
491
+ "show_status_form": True,
492
  "top_hits": [],
493
  "sources": [],
494
  "debug": {"intent": "status_request_missing_id"},
 
510
  short = result.get("short_description", "")
511
  num = result.get("number", number or "unknown")
512
  return {
513
+ "bot_response": (f"**Ticket:** {num} \n" f"**Status:** {state_label} \n" f"**Issue description:** {short}"),
 
 
 
 
514
  "status": "OK",
515
+ "show_assist_card": True,
516
  "context_found": False,
517
  "ask_resolved": False,
518
  "suggest_incident": False,
 
524
  except Exception as e:
525
  raise HTTPException(status_code=500, detail=str(e))
526
 
527
+ # ---- Hybrid KB search ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
529
  kb_ctx = extract_kb_context(kb_results, top_chunks=2)
530
  context_raw = kb_ctx.get("context", "") or ""
531
 
 
532
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
533
  context = filtered_text
534
  context_found = bool(kb_ctx.get("context_found", False)) and bool(context.strip())
535
+ best_distance = kb_ctx.get("best_score")
536
+ best_combined = kb_ctx.get("best_combined")
537
  detected_intent = kb_results.get("user_intent", "neutral")
538
+ actions = kb_results.get("actions", [])
539
 
540
+ # Shape context by intent + action
541
  q = (input_data.user_message or "").lower()
542
  if detected_intent == "steps" or any(k in q for k in ["steps", "procedure", "perform", "do", "process"]):
543
+ context = _extract_steps_only(context, max_lines=12, target_actions=actions)
544
  elif detected_intent == "errors" or any(k in q for k in ["error", "issue", "fail", "not working", "resolution", "fix"]):
545
  context = _extract_errors_only(context, max_lines=10)
546
  elif any(k in q for k in ["navigate", "navigation", "menu", "screen"]):
547
  context = _extract_navigation_only(context, max_lines=6)
 
548
 
549
+ # Gating
550
  short_query = len((input_data.user_message or "").split()) <= 4
551
+ gate_combined_no_kb = 0.22 if short_query else 0.28
552
+ gate_combined_ok = 0.60 if short_query else 0.55
553
  gate_distance_no_kb = 2.0
554
 
555
  if (not context_found or not context.strip()) or (
 
574
  "debug": {"used_chunks": 0, "second_try": second_try, "best_distance": best_distance, "best_combined": best_combined},
575
  }
576
 
577
+ # LLM rewrite (optional, may rate-limit)
 
 
 
 
 
 
 
578
  enhanced_prompt = (
579
  "From the provided context, output only the actionable steps/procedure relevant to the user's question. "
580
  "Use ONLY the provided context; do NOT add information that is not present. "
581
+ ("Return ONLY lines containing the requested action verbs." if actions else "")
582
+ + " Do NOT include document names, section titles, or 'Source:' lines.\n\n"
583
  f"### Context\n{context}\n\n"
584
  f"### Question\n{input_data.user_message}\n\n"
585
  "### Output\n"
586
  "- Return numbered/bulleted steps only, in the same order.\n"
587
  "- If context is insufficient, add: 'This may be partial based on available KB.'\n"
588
  )
 
589
  headers = {"Content-Type": "application/json"}
590
  payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
591
  try:
 
599
  result = {}
600
 
601
  try:
602
+ bot_text = (result["candidates"][0]["content"]["parts"][0]["text"] if isinstance(result, dict) else "")
 
 
 
603
  except Exception:
604
  bot_text = ""
605
 
606
  if not bot_text.strip():
607
+ bot_text = context # strict steps-only fallback
 
608
  bot_text = _strip_any_source_lines(bot_text).strip()
609
 
610
  status = "OK" if (
611
+ (best_combined is not None and best_combined >= gate_combined_ok)
612
  or (filt_info.get('mode') == 'exact' and filt_info.get('matched_count', 0) > 0)
613
  ) else "PARTIAL"
614
 
 
616
  if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
617
  status = "PARTIAL"
618
 
 
 
 
 
619
  return {
620
  "bot_response": bot_text,
621
  "status": status,
622
  "context_found": True,
623
+ "ask_resolved": (status == "OK"),
624
+ "suggest_incident": False,
625
+ "followup": ("Does this match your scenario? I can refine the steps." if status == "PARTIAL" else None),
626
  "top_hits": [],
627
  "sources": [],
628
  "debug": {
 
633
  "filter_mode": filt_info.get("mode"),
634
  "matched_count": filt_info.get("matched_count"),
635
  "user_intent": detected_intent,
636
+ "actions": actions,
637
  },
638
  }
639
 
 
642
  except Exception as e:
643
  raise HTTPException(status_code=500, detail=str(e))
644
 
 
645
  def _set_incident_resolved(sys_id: str) -> bool:
 
 
 
 
 
 
 
 
646
  try:
647
  token = get_valid_token()
648
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
 
659
  close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
660
  close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
661
  caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
662
+ resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
663
+ assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
664
  require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
665
 
666
  if require_progress:
 
738
  ticket_text = f"Incident created: {inc_number}{resolved_note}"
739
  else:
740
  ticket_text = "Incident created."
 
 
741
  return {
742
  "bot_response": f"✅ {ticket_text}",
743
  "debug": "Incident created via ServiceNow",
 
792
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
793
  if not instance_url:
794
  raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
795
+ headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
 
 
 
796
  if input_data.sys_id:
797
  url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
798
  response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
 
806
  result = (lst or [{}])[0] if response.status_code == 200 else {}
807
  else:
808
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
 
809
  state_code = str(result.get("state", "unknown"))
810
  state_label = STATE_MAP.get(state_code, state_code)
811
  short = result.get("short_description", "")
812
  number = result.get("number", input_data.number or "unknown")
813
  return {
814
+ "bot_response": (f"**Ticket:** {number} \n" f"**Status:** {state_label} \n" f"**Issue description:** {short}").replace("\n", " \n"),
 
 
 
 
815
  "followup": "Is there anything else I can assist you with?",
816
+ "show_assist_card": True,
817
  "persist": True,
818
  "debug": "Incident status fetched",
819
  }