srilakshu012456 commited on
Commit
76caf03
·
verified ·
1 Parent(s): c6f40fa

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +603 -386
main.py CHANGED
@@ -1,7 +1,8 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
-
4
- import os, json, re, requests, builtins
 
5
  from typing import Optional, Any, Dict, List, Tuple
6
  from contextlib import asynccontextmanager
7
  from fastapi import FastAPI, HTTPException
@@ -14,17 +15,15 @@ from services.kb_creation import (
14
  collection,
15
  ingest_documents,
16
  hybrid_search_knowledge_base,
17
- get_section_text,
18
- get_best_steps_section_text,
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
-
25
- # --------------------------------------------------------
26
- # Utilities
27
- # --------------------------------------------------------
28
 
29
  def safe_str(e: Any) -> str:
30
  try:
@@ -40,13 +39,17 @@ async def lifespan(app: FastAPI):
40
  try:
41
  folder_path = os.path.join(os.getcwd(), "documents")
42
  if collection.count() == 0:
 
43
  ingest_documents(folder_path)
 
 
44
  except Exception as e:
45
- print(f"[lifespan] KB ingestion failed: {safe_str(e)}")
46
  yield
47
 
48
  app = FastAPI(lifespan=lifespan)
49
  app.include_router(login_router)
 
50
  origins = ["https://chatbotnova-chatbot-frontend.hf.space"]
51
  app.add_middleware(
52
  CORSMiddleware,
@@ -56,9 +59,6 @@ app.add_middleware(
56
  allow_headers=["*"],
57
  )
58
 
59
- # --------------------------------------------------------
60
- # Models
61
- # --------------------------------------------------------
62
  class ChatInput(BaseModel):
63
  user_message: str
64
  prev_status: Optional[str] = None
@@ -76,11 +76,183 @@ class TicketStatusInput(BaseModel):
76
  sys_id: Optional[str] = None
77
  number: Optional[str] = None
78
 
79
- # --------------------------------------------------------
80
- # KB filters & extractors (no LLM)
81
- # --------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  STRICT_OVERLAP = 3
83
- MAX_SENTENCES_CONCISE = 6
 
84
 
85
  def _normalize_for_match(text: str) -> str:
86
  t = (text or "").lower()
@@ -89,7 +261,7 @@ def _normalize_for_match(text: str) -> str:
89
  return t
90
 
91
  def _split_sentences(ctx: str) -> List[str]:
92
- raw_sents = re.split(r"(?<=[.!?])\s+|\n+|•\s*|\-\s*", ctx or "")
93
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
94
 
95
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
@@ -111,7 +283,7 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
111
  elif overlap > 0:
112
  matched_any.append(s)
113
  if matched_exact:
114
- kept = matched_exact # keep many lines; gate later
115
  return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
116
  if matched_any:
117
  kept = matched_any[:MAX_SENTENCES_CONCISE]
@@ -119,19 +291,29 @@ def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str,
119
  kept = sentences[:MAX_SENTENCES_CONCISE]
120
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
121
 
122
- STEP_LINE_REGEX = re.compile(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", re.IGNORECASE)
 
123
  NAV_LINE_REGEX = re.compile(r"(navigate\s+to|>\s*)", re.IGNORECASE)
 
124
  PROCEDURE_VERBS = [
125
- "log in","select","scan","verify","confirm","print",
126
- "move","complete","click","open","navigate","choose",
127
- "enter","update","save","delete","create","attach","assign"
128
  ]
129
- VERB_START_REGEX = re.compile(r"^\s*(?:" + "|".join([re.escape(v) for v in PROCEDURE_VERBS]) + r")\b", re.IGNORECASE)
130
- NON_PROC_ANY_REGEX = re.compile("|".join([re.escape(v) for v in [
131
- "to ensure","as per","purpose","pre-requisites","prerequisites","overview","introduction",
132
- "organized manner","structured","help users","objective"
133
- ]]), re.IGNORECASE)
134
- ACTION_SYNS_FLAT = {"create":["create","creation","add","new","generate"],"update":["update","modify","change","edit"],"delete":["delete","remove"],"navigate":["navigate","go to","open"]}
 
 
 
 
 
 
 
 
135
 
136
  def _action_in_line(ln: str, target_actions: List[str]) -> bool:
137
  s = (ln or "").lower()
@@ -148,7 +330,7 @@ def _is_procedural_line(ln: str) -> bool:
148
  if NON_PROC_ANY_REGEX.search(s):
149
  return False
150
  if STEP_LINE_REGEX.match(s):
151
- if s.lstrip().startswith(("•","-")):
152
  return bool(VERB_START_REGEX.search(s) or NAV_LINE_REGEX.search(s))
153
  return True
154
  if VERB_START_REGEX.match(s):
@@ -157,206 +339,137 @@ def _is_procedural_line(ln: str) -> bool:
157
  return True
158
  return False
159
 
160
- def _extract_steps_only(text: str, max_lines: Optional[int] = None, target_actions: Optional[List[str]] = None) -> str:
161
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
162
- kept, seen = [], set()
163
  for ln in lines:
164
  if _is_procedural_line(ln):
165
- normalized = re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", ln).strip().lower()
166
- if normalized in seen:
167
- continue
168
- if target_actions and not _action_in_line(ln, target_actions):
169
- continue
170
  kept.append(ln)
171
- seen.add(normalized)
172
  if max_lines is not None and len(kept) >= max_lines:
173
  break
174
  return "\n".join(kept).strip() if kept else (text or "").strip()
175
 
176
- # ---- Errors extractor ----
177
- def _extract_errors_only(text: str, max_lines: int = 12) -> str:
178
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
179
- kept: List[str] = []
180
  for ln in lines:
181
- if STEP_LINE_REGEX.match(ln) or ln.lower().startswith(("error", "resolution", "fix", "verify", "check")):
182
  kept.append(ln)
183
  if len(kept) >= max_lines:
184
  break
185
  return "\n".join(kept).strip() if kept else (text or "").strip()
186
 
187
- # ---- Escalation extractor ----
188
- ESCALATION_REGEX = re.compile(r"^\s*Escalation Path|\s+→\s+", re.IGNORECASE)
189
-
190
- def _extract_escalation_only(text: str, max_lines: int = 3) -> str:
191
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
192
  kept = []
193
  for ln in lines:
194
- if ESCALATION_REGEX.search(ln):
195
  kept.append(ln)
196
  if len(kept) >= max_lines:
197
  break
198
- return "\n".join(kept).strip()
199
-
200
- # ---- merge number-only lines helper ----
201
- def _merge_number_only_lines(lines: List[str]) -> List[str]:
202
- merged: List[str] = []
203
- i, n = 0, len(lines)
204
- def is_number_only(s: str) -> bool:
205
- return bool(re.fullmatch(r"\s*\d+\s*", s or ""))
206
- while i < n:
207
- curr = (lines[i] or "").strip()
208
- if is_number_only(curr):
209
- j = i + 1
210
- while j < n and is_number_only(lines[j]):
211
- j += 1
212
- if j < n:
213
- merged.append(f"{curr.strip()}. {lines[j].strip()}")
214
- i = j + 1
215
- else:
216
- i += 1
217
- else:
218
- merged.append(curr)
219
- i += 1
220
- return merged
221
-
222
- # ---- strip any existing leading numbering/bullets ----
223
- def _strip_leading_mark(s: str) -> str:
224
- return re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", (s or "")).strip()
225
-
226
- # --------------------------------------------------------
227
- # Conversation intents & helpers
228
- # --------------------------------------------------------
229
- def _is_incident_intent(msg_norm: str) -> bool:
230
- intents = [
231
- "create ticket","create a ticket","raise ticket","raise a ticket","open ticket","open a ticket",
232
- "create incident","create an incident","raise incident","raise an incident","open incident","open an incident",
233
- "log ticket","log an incident","generate ticket","create snow ticket","raise snow ticket",
234
- "raise service now ticket","create service now ticket","raise sr","open sr"
235
- ]
236
- return any(p in msg_norm for p in intents)
237
-
238
- def _is_generic_issue(msg_norm: str) -> bool:
239
- generic = [
240
- "issue","have an issue","having an issue","got an issue","problem","have a problem","help","need help","support",
241
- "need support","please help","need assistance","assist me","facing issue","facing a problem","got a problem"
242
- ]
243
- return any(p == msg_norm or p in msg_norm for p in generic) or len(msg_norm.split()) <= 2
244
-
245
- def _is_negative_feedback(msg_norm: str) -> bool:
246
- phrases = [
247
- "not resolved","issue not resolved","still not working","not working",
248
- "didn't work","doesn't work","no change","not fixed","still failing",
249
- "failed again","broken","fail","not solved","same issue"
250
- ]
251
- return any(p in msg_norm for p in phrases)
252
-
253
- # Parse status intent inside chat (e.g., "status INC0012345")
254
- def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
255
- keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
256
- if not any(k in msg_norm for k in keywords):
257
- return {}
258
- # Try to capture INC number
259
- m = re.search(r"(inc\d+)", msg_norm, flags=re.IGNORECASE)
260
- if m:
261
- val = m.group(1).strip().upper()
262
- return {"number": val}
263
- # Ask the user for number if not present
264
- return {"ask_number": True}
265
-
266
- # --------------------------------------------------------
267
- # Error/escalation friendly composition
268
- # --------------------------------------------------------
269
- def _compose_errors_response(error_lines: List[str], escalation_lines: List[str]) -> str:
270
- err = [ln.strip() for ln in (error_lines or []) if ln and ln.strip()]
271
- esc = [ln.strip() for ln in (escalation_lines or []) if ln and ln.strip()]
272
-
273
- summary = None
274
- joined = " ".join(err).lower()
275
- if any(k in joined for k in ["permission","authorized","authorization","role access","not allowed","insufficient"]):
276
- summary = (
277
- "It looks like your role doesn’t allow Inventory Adjustment right now. "
278
- "Please verify you have the required authorization/role enabled. "
279
- "If you still cannot proceed, follow the escalation path below."
280
- )
281
 
282
- actions: List[str] = []
283
- for ln in err:
284
- s = re.sub(r"^\s*(?:\d+[\.)]\s+|[•\-]\s+)", "", ln).strip()
285
- s = s.replace("Verify role access", "Verify your role/access in WMS")
286
- s = s.replace("Check if cycle count or task is active", "Check if any cycle count/task is locking the location")
287
- s = s.replace("Confirm unit of measure setup", "Confirm the item’s UOM setup in master data")
288
- if s:
289
- actions.append(f"• {s}")
290
-
291
- esc_text = ""
292
- if esc:
293
- raw = esc[0]
294
- clean = re.sub(r"^\s*Escalation Path\s*:?\s*", "", raw, flags=re.IGNORECASE).strip()
295
- esc_text = f"\n\n**Escalation Path:** {clean}"
296
-
297
- top = summary or "Here’s how to resolve this:"
298
- bullet_block = ("\n" + "\n".join(actions)) if actions else ""
299
- return f"{top}{bullet_block}{esc_text}".strip()
300
-
301
- # --------------------------------------------------------
302
- # Health
303
- # --------------------------------------------------------
304
  @app.get("/")
305
  async def health_check():
306
  return {"status": "ok"}
307
 
308
- # --------------------------------------------------------
309
- # Chat endpoint
310
- # --------------------------------------------------------
311
  @app.post("/chat")
312
  async def chat_with_ai(input_data: ChatInput):
313
  try:
314
  msg_norm = (input_data.user_message or "").lower().strip()
315
 
316
- # --- Negative feedback: proactively offer incident creation
317
- if _is_negative_feedback(msg_norm):
318
  return {
319
- "bot_response": (
320
- "Sorry it’s still not resolved. I can open a ServiceNow incident to track and escalate.\n\n"
321
- "Please provide:\n• Short Description (one line)\n"
322
- " Detailed Description (what you tried, exact error text, IDs, site, environment)"
323
- ),
324
- "status": "PARTIAL",
325
- "context_found": False,
326
- "ask_resolved": False,
327
- "suggest_incident": True,
328
- "show_incident_form": True,
 
329
  "followup": None,
330
- "top_hits": [], "sources": [],
331
- "debug": {"intent": "negative_feedback"},
332
  }
333
 
334
- # --- Explicit incident intent: try direct creation if enough details; else show form
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  if _is_incident_intent(msg_norm):
336
- # If user message has more than the keywords, attempt immediate creation
337
- minimal_phrases = ["create ticket", "create incident", "open ticket", "open incident", "raise ticket", "raise incident"]
338
- has_only_minimal = any(msg_norm.strip() == p for p in minimal_phrases)
339
- if not has_only_minimal and len(msg_norm.split()) > 3:
340
- short_desc = input_data.user_message.strip()[:100]
341
- long_desc = input_data.user_message.strip()
342
- try:
343
- result = create_incident(short_desc, long_desc)
344
- if isinstance(result, dict) and not result.get("error"):
345
- inc_number = result.get("number", "<unknown>")
346
- return {
347
- "bot_response": f"✅ Incident created: {inc_number}",
348
- "status": "OK",
349
- "context_found": False,
350
- "ask_resolved": False,
351
- "suggest_incident": False,
352
- "followup": "Is there anything else I can assist you with?",
353
- "top_hits": [], "sources": [],
354
- "debug": {"intent": "create_ticket_direct"},
355
- }
356
- except Exception as e:
357
- # Fall back to form if direct creation fails
358
- pass
359
- # Prompt for details & show form
360
  return {
361
  "bot_response": (
362
  "Okay, let’s create a ServiceNow incident.\n\n"
@@ -364,12 +477,17 @@ async def chat_with_ai(input_data: ChatInput):
364
  "• Detailed Description (steps, error text, IDs, site, environment)"
365
  ),
366
  "status": (input_data.prev_status or "PARTIAL"),
367
- "context_found": False, "ask_resolved": False, "suggest_incident": True,
368
- "show_incident_form": True, "followup": None, "top_hits": [], "sources": [],
 
 
 
 
 
369
  "debug": {"intent": "create_ticket"},
370
  }
371
 
372
- # --- Generic openers
373
  if _is_generic_issue(msg_norm):
374
  return {
375
  "bot_response": (
@@ -381,12 +499,16 @@ async def chat_with_ai(input_data: ChatInput):
381
  "• When it started and how many users are impacted"
382
  ),
383
  "status": "NO_KB_MATCH",
384
- "context_found": False, "ask_resolved": False, "suggest_incident": False,
385
- "followup": "Please reply with the above details.", "top_hits": [], "sources": [],
 
 
 
 
386
  "debug": {"intent": "generic_issue"},
387
  }
388
 
389
- # --- Status intent inside chat
390
  status_intent = _parse_ticket_status_intent(msg_norm)
391
  if status_intent:
392
  if status_intent.get("ask_number"):
@@ -396,12 +518,12 @@ async def chat_with_ai(input_data: ChatInput):
396
  "context_found": False,
397
  "ask_resolved": False,
398
  "suggest_incident": False,
399
- "show_status_form": True,
400
  "followup": "Provide the Incident ID and I’ll fetch the status.",
401
- "top_hits": [], "sources": [],
 
 
402
  "debug": {"intent": "status_request_missing_id"},
403
  }
404
- # fetch
405
  try:
406
  token = get_valid_token()
407
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
@@ -415,14 +537,13 @@ async def chat_with_ai(input_data: ChatInput):
415
  lst = data.get("result", [])
416
  result = (lst or [{}])[0] if response.status_code == 200 else {}
417
  state_code = builtins.str(result.get("state", "unknown"))
418
- state_map = {"1":"New","2":"In Progress","3":"On Hold","6":"Resolved","7":"Closed","8":"Canceled"}
419
- state_label = state_map.get(state_code, state_code)
420
  short = result.get("short_description", "")
421
  num = result.get("number", number or "unknown")
422
  return {
423
  "bot_response": (
424
- f"**Ticket:** {num}\n"
425
- f"**Status:** {state_label}\n"
426
  f"**Issue description:** {short}"
427
  ),
428
  "status": "OK",
@@ -431,186 +552,167 @@ async def chat_with_ai(input_data: ChatInput):
431
  "ask_resolved": False,
432
  "suggest_incident": False,
433
  "followup": "Is there anything else I can assist you with?",
434
- "top_hits": [], "sources": [],
 
435
  "debug": {"intent": "status", "http_status": response.status_code},
436
  }
437
  except Exception as e:
438
  raise HTTPException(status_code=500, detail=safe_str(e))
439
 
440
- # --- Hybrid KB search
441
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
 
 
442
 
443
- # Build a small context window
444
- def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2) -> Dict[str, Any]:
445
- if not kb_results or not isinstance(kb_results, dict):
446
- return {"context": "", "sources": [], "top_hits": [], "context_found": False, "best_score": None, "best_combined": None}
447
- documents = kb_results.get("documents") or []
448
- metadatas = kb_results.get("metadatas") or []
449
- distances = kb_results.get("distances") or []
450
- combined = kb_results.get("combined_scores") or []
451
- items: List[Dict[str, Any]] = []
452
- for i, doc in enumerate(documents):
453
- text = doc.strip() if isinstance(doc, str) else ""
454
- if not text:
455
- continue
456
- meta = metadatas[i] if i < len(metadatas) and isinstance(metadatas[i], dict) else {}
457
- score = distances[i] if i < len(distances) else None
458
- comb = combined[i] if i < len(combined) else None
459
- m = dict(meta)
460
- if score is not None: m["distance"] = score
461
- if comb is not None: m["combined"] = comb
462
- items.append({"text": text, "meta": m})
463
- selected = items[:max(1, top_chunks)]
464
- context = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
465
- sources = [s["meta"] for s in selected]
466
- best_distance = None
467
- if distances:
468
- try:
469
- best_distance = min([d for d in distances if d is not None])
470
- except Exception:
471
- best_distance = None
472
- best_combined = None
473
- if combined:
474
- try:
475
- best_combined = max([c for c in combined if c is not None])
476
- except Exception:
477
- best_combined = None
478
- return {
479
- "context": context, "sources": sources, "top_hits": [], "context_found": bool(selected),
480
- "best_score": best_distance, "best_combined": best_combined
481
- }
482
-
483
- kb_ctx = extract_kb_context(kb_results, top_chunks=2)
484
- context_raw = kb_ctx.get("context", "") or ""
485
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
486
-
 
 
 
487
  detected_intent = kb_results.get("user_intent", "neutral")
488
- actions = kb_results.get("actions", [])
489
- best_doc = kb_results.get("best_doc")
490
- best_distance = kb_ctx.get("best_score")
491
- best_combined = kb_ctx.get("best_combined")
492
- top_meta = (kb_results.get("metadatas") or [{}])[0] if (kb_results.get("metadatas") or []) else {}
493
-
494
- short_query = len((input_data.user_message or "").split()) <= 4
495
- gate_combined_ok = 0.58 if short_query else 0.55
496
- gate_combined_no_kb = 0.22 if short_query else 0.28
497
- gate_distance_no_kb = 2.0
498
-
499
- exact_by_filter = (filt_info.get('mode') == 'exact' and filt_info.get('matched_count', 0) > 0)
500
- high_conf = (best_combined is not None and best_combined >= gate_combined_ok)
501
- exact = bool(exact_by_filter or high_conf)
502
-
503
- # --- STEPS intent: full SOP steps (numbered)
504
  if detected_intent == "steps" and best_doc:
 
505
  full_steps = get_best_steps_section_text(best_doc)
506
  if not full_steps:
 
507
  sec = (top_meta or {}).get("section")
508
  if sec:
509
  full_steps = get_section_text(best_doc, sec)
510
  if full_steps:
511
- context = _extract_steps_only(full_steps, max_lines=None if exact else MAX_SENTENCES_CONCISE, target_actions=actions)
512
- else:
513
- context = _extract_steps_only(filtered_text, max_lines=MAX_SENTENCES_CONCISE, target_actions=actions)
514
-
515
- raw_lines = [ln.strip() for ln in context.splitlines() if ln.strip()]
516
- if len(raw_lines) == 1:
517
- parts = [p.strip() for p in re.split(r"\.\s+(?=[A-Z0-9])", raw_lines[0]) if p.strip()]
518
- raw_lines = parts if len(parts) > 1 else raw_lines
519
- raw_lines = _merge_number_only_lines(raw_lines)
520
- cleaned_lines = [_strip_leading_mark(ln) for ln in raw_lines]
521
- bot_text = "\n".join([f"{i+1}. {ln}" for i, ln in enumerate(cleaned_lines)])
522
-
523
- status = "OK" if exact else "PARTIAL"
524
- return {
525
- "bot_response": bot_text,
526
- "status": status,
527
- "context_found": True,
528
- "ask_resolved": (status == "OK"),
529
- "suggest_incident": False,
530
- "followup": ("Does this match your scenario? I can refine the steps." if status == "PARTIAL" else None),
531
- "top_hits": [], "sources": [],
532
- "debug": {
533
- "used_chunks": len(context.split("\n\n---\n\n")) if context else 0,
534
- "best_distance": best_distance, "best_combined": best_combined,
535
- "filter_mode": filt_info.get("mode"), "matched_count": filt_info.get("matched_count"),
536
- "user_intent": detected_intent, "actions": actions, "best_doc": best_doc,
537
- "exact": exact, "high_conf": high_conf,
538
- },
539
- }
540
-
541
- # --- Non-steps intents
542
- context = filtered_text
543
- if detected_intent == "errors":
544
- errs = _extract_errors_only(context, max_lines=MAX_SENTENCES_CONCISE)
545
- esc = _extract_escalation_only(context, max_lines=3)
546
- err_lines, esc_lines = _extract_errors_and_escalation(errs + ("\n" + esc if esc else ""))
547
- bot_text = _compose_errors_response(err_lines, esc_lines)
548
- status = "OK" if exact else "PARTIAL"
549
- return {
550
- "bot_response": bot_text,
551
- "status": status,
552
- "context_found": True,
553
- "ask_resolved": (status == "OK"),
554
- "suggest_incident": False,
555
- "followup": ("Does this help? I can refine." if status == "PARTIAL" else None),
556
- "top_hits": [], "sources": [],
557
- "debug": {
558
- "used_chunks": len(context.split("\n\n---\n\n")) if context else 0,
559
- "best_distance": best_distance,
560
- "best_combined": best_combined,
561
- "filter_mode": filt_info.get("mode"),
562
- "matched_count": filt_info.get("matched_count"),
563
- "user_intent": detected_intent,
564
- "actions": actions,
565
- "best_doc": best_doc,
566
- "exact": exact,
567
- "high_conf": high_conf,
568
- },
569
- }
570
- elif "navigate" in msg_norm or "menu" in msg_norm or "screen" in msg_norm:
571
- context = _extract_steps_only(context, max_lines=MAX_SENTENCES_CONCISE)
572
-
573
- # --- No-KB gate
574
- if (not context.strip()) or (
575
- (best_combined is None or best_combined < gate_combined_no_kb) and (best_distance is None or best_distance >= gate_distance_no_kb)
576
  ):
577
  second_try = (input_data.prev_status or "").upper() == "NO_KB_MATCH"
578
- clarify = (
579
- "I couldn’t find matching content in the KB yet. To help me narrow it down, please share:\n\n"
580
- "• Module/area (e.g., Picking, Receiving, Trailer Close)\n"
581
- " Exact error message text/code (copy-paste)\n"
582
- "• IDs involved (Order#, Load ID, Shipment#)\n"
583
- "• Warehouse/site & environment (prod/test)\n"
584
- "• When it started and how many users are impacted\n\n"
585
- "Reply with these details and I’ll search again."
586
- ) if not second_try else "I still don’t find a relevant KB match for this scenario even after clarification."
587
  return {
588
- "bot_response": clarify,
589
  "status": "NO_KB_MATCH",
590
- "context_found": False, "ask_resolved": False,
 
591
  "suggest_incident": bool(second_try),
592
  "followup": ("Please reply with the above details." if not second_try else "Shall I create a ticket now?"),
593
- "top_hits": [], "sources": [],
 
594
  "debug": {"used_chunks": 0, "second_try": second_try, "best_distance": best_distance, "best_combined": best_combined},
595
  }
596
 
597
- # --- Default response for non-steps
598
- bot_text = context.strip()
599
- status = "OK" if exact else "PARTIAL"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  return {
601
  "bot_response": bot_text,
602
  "status": status,
603
  "context_found": True,
604
  "ask_resolved": (status == "OK"),
605
  "suggest_incident": False,
606
- "followup": ("Does this match your scenario? I can refine." if status == "PARTIAL" else None),
607
- "top_hits": [], "sources": [],
 
608
  "debug": {
609
  "used_chunks": len(context.split("\n\n---\n\n")) if context else 0,
610
- "best_distance": best_distance, "best_combined": best_combined,
611
- "filter_mode": filt_info.get("mode"), "matched_count": filt_info.get("matched_count"),
612
- "user_intent": detected_intent, "actions": actions, "best_doc": best_doc,
613
- "exact": exact, "high_conf": high_conf,
 
 
 
 
614
  },
615
  }
616
 
@@ -619,29 +721,146 @@ async def chat_with_ai(input_data: ChatInput):
619
  except Exception as e:
620
  raise HTTPException(status_code=500, detail=safe_str(e))
621
 
622
- # --------------------------------------------------------
623
- # Incident endpoints (direct create + status + description)
624
- # --------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  @app.post("/incident")
626
  async def raise_incident(input_data: IncidentInput):
627
  try:
628
  result = create_incident(input_data.short_description, input_data.description)
629
  if isinstance(result, dict) and not result.get("error"):
630
  inc_number = result.get("number", "<unknown>")
631
- return {
632
- "bot_response": f"✅ Incident created: {inc_number}",
633
- "debug": "Incident created via ServiceNow",
634
- "persist": True,
635
- "show_assist_card": True,
636
- "followup": "Is there anything else I can assist you with?",
637
- }
638
  else:
639
- err = (result or {}).get("error", "Unknown error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  return {
641
- "bot_response": f"⚠️ Could not create incident ({err}).",
642
- "status": "PARTIAL",
643
- "suggest_incident": True,
644
  }
 
 
645
  except Exception as e:
646
  raise HTTPException(status_code=500, detail=safe_str(e))
647
 
@@ -667,14 +886,13 @@ async def incident_status(input_data: TicketStatusInput):
667
  else:
668
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
669
  state_code = builtins.str(result.get("state", "unknown"))
670
- state_map = {"1":"New","2":"In Progress","3":"On Hold","6":"Resolved","7":"Closed","8":"Canceled"}
671
- state_label = state_map.get(state_code, state_code)
672
  short = result.get("short_description", "")
673
  number = result.get("number", input_data.number or "unknown")
674
  return {
675
  "bot_response": (
676
- f"**Ticket:** {number}\n"
677
- f"**Status:** {state_label}\n"
678
  f"**Issue description:** {short}"
679
  ).replace("\n", " \n"),
680
  "followup": "Is there anything else I can assist you with?",
@@ -685,16 +903,15 @@ async def incident_status(input_data: TicketStatusInput):
685
  except Exception as e:
686
  raise HTTPException(status_code=500, detail=safe_str(e))
687
 
688
- @app.post("/generate_ticket_desc")
689
- async def generate_ticket_desc_ep(input_data: TicketDescInput):
690
- try:
691
- # Minimal: echo structured JSON (no external LLM)
692
- issue = (input_data.issue or "").strip()
693
- short = issue[:100]
694
- detailed = issue
695
- return {
696
- "ShortDescription": short,
697
- "DetailedDescription": detailed,
698
- }
699
- except Exception as e:
700
- raise HTTPException(status_code=500, detail=safe_str(e))
 
1
+ import os
2
+ import json
3
+ import re
4
+ import requests
5
+ import builtins
6
  from typing import Optional, Any, Dict, List, Tuple
7
  from contextlib import asynccontextmanager
8
  from fastapi import FastAPI, HTTPException
 
15
  collection,
16
  ingest_documents,
17
  hybrid_search_knowledge_base,
18
+ get_section_text, # NEW
19
+ get_best_steps_section_text, # NEW
20
  )
21
+
22
  from services.login import router as login_router
23
  from services.generate_ticket import get_valid_token, create_incident
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
  def safe_str(e: Any) -> str:
29
  try:
 
39
  try:
40
  folder_path = os.path.join(os.getcwd(), "documents")
41
  if collection.count() == 0:
42
+ print("🔍 KB empty. Running ingestion...")
43
  ingest_documents(folder_path)
44
+ else:
45
+ print(f"✅ KB already populated with {collection.count()} entries. Skipping ingestion.")
46
  except Exception as e:
47
+ print(f"⚠️ KB ingestion failed: {safe_str(e)}")
48
  yield
49
 
50
  app = FastAPI(lifespan=lifespan)
51
  app.include_router(login_router)
52
+
53
  origins = ["https://chatbotnova-chatbot-frontend.hf.space"]
54
  app.add_middleware(
55
  CORSMiddleware,
 
59
  allow_headers=["*"],
60
  )
61
 
 
 
 
62
  class ChatInput(BaseModel):
63
  user_message: str
64
  prev_status: Optional[str] = None
 
76
  sys_id: Optional[str] = None
77
  number: Optional[str] = None
78
 
79
+ STATE_MAP = {
80
+ "1": "New",
81
+ "2": "In Progress",
82
+ "3": "On Hold",
83
+ "6": "Resolved",
84
+ "7": "Closed",
85
+ "8": "Canceled",
86
+ }
87
+
88
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
89
+ GEMINI_URL = (
90
+ f"https://generativelanguage.googleapis.com/v1beta/models/"
91
+ f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
92
+ )
93
+
94
+ def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2) -> Dict[str, Any]:
95
+ if not kb_results or not isinstance(kb_results, dict):
96
+ return {"context": "", "sources": [], "top_hits": [], "context_found": False, "best_score": None, "best_combined": None}
97
+
98
+ documents = kb_results.get("documents") or []
99
+ metadatas = kb_results.get("metadatas") or []
100
+ distances = kb_results.get("distances") or []
101
+ combined = kb_results.get("combined_scores") or []
102
+
103
+ items = []
104
+ for i, doc in enumerate(documents):
105
+ text = doc.strip() if isinstance(doc, str) else ""
106
+ if not text:
107
+ continue
108
+ meta = metadatas[i] if i < len(metadatas) and isinstance(metadatas[i], dict) else {}
109
+ score = distances[i] if i < len(distances) else None
110
+ comb = combined[i] if i < len(combined) else None
111
+ m = dict(meta)
112
+ if score is not None:
113
+ m["distance"] = score
114
+ if comb is not None:
115
+ m["combined"] = comb
116
+ items.append({"text": text, "meta": m})
117
+
118
+ selected = items[:max(1, top_chunks)]
119
+ context = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
120
+ sources = [s["meta"] for s in selected]
121
+
122
+ best_distance = None
123
+ if distances:
124
+ try:
125
+ best_distance = min([d for d in distances if d is not None])
126
+ except Exception:
127
+ best_distance = None
128
+ best_combined = None
129
+ if combined:
130
+ try:
131
+ best_combined = max([c for c in combined if c is not None])
132
+ except Exception:
133
+ best_combined = None
134
+
135
+ return {
136
+ "context": context,
137
+ "sources": sources,
138
+ "top_hits": [],
139
+ "context_found": bool(selected),
140
+ "best_score": best_distance,
141
+ "best_combined": best_combined,
142
+ }
143
+
144
+ def _strip_any_source_lines(text: str) -> str:
145
+ lines = text.splitlines()
146
+ kept = []
147
+ for ln in lines:
148
+ if re.match(r"^\s*source\s*:", ln, flags=re.IGNORECASE):
149
+ continue
150
+ kept.append(ln)
151
+ return "\n".join(kept).strip()
152
+
153
+ def _build_clarifying_message() -> str:
154
+ return (
155
+ "I couldn’t find matching content in the KB yet. To help me narrow it down, please share:\n\n"
156
+ "• Module/area (e.g., Picking, Receiving, Trailer Close)\n"
157
+ "• Exact error message text/code (copy-paste)\n"
158
+ "• IDs involved (Order#, Load ID, Shipment#)\n"
159
+ "• Warehouse/site & environment (prod/test)\n"
160
+ "• When it started and how many users are impacted\n\n"
161
+ "Reply with these details and I’ll search again."
162
+ )
163
+
164
+ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> Tuple[str, str]:
165
+ issue = (issue_text or "").strip()
166
+ resolved = (resolved_text or "").strip()
167
+ short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
168
+ long_desc = (
169
+ f'User reported: "{issue}". '
170
+ f'User confirmation: "{resolved}". '
171
+ f'Tracking record created automatically by NOVA.'
172
+ ).strip()
173
+ return short_desc, long_desc
174
+
175
+ def _is_incident_intent(msg_norm: str) -> bool:
176
+ intent_phrases = [
177
+ "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
178
+ "create incident", "create an incident", "raise incident", "raise an incident", "open incident", "open an incident",
179
+ "log ticket", "log an incident", "generate ticket", "create snow ticket", "raise snow ticket",
180
+ "raise service now ticket", "create service now ticket", "raise sr", "open sr",
181
+ ]
182
+ return any(p in msg_norm for p in intent_phrases)
183
+
184
+ def _is_feedback_message(msg_norm: str) -> bool:
185
+ feedback_phrases = [
186
+ "issue not resolved", "not resolved", "still not working",
187
+ "same issue", "no change", "didn't work", "doesn't work",
188
+ "not fixed", "still failing", "failed again",
189
+ ]
190
+ return any(p in msg_norm for p in feedback_phrases)
191
+
192
+ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
193
+ status_keywords = ["status", "ticket status", "incident status", "check status", "check ticket status", "check incident status"]
194
+ if not any(k in msg_norm for k in status_keywords):
195
+ return {}
196
+ patterns = [r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)", r"(inc\d+)"]
197
+ for pat in patterns:
198
+ m = re.search(pat, msg_norm, flags=re.IGNORECASE)
199
+ if m:
200
+ val = m.group(1).strip()
201
+ if val:
202
+ return {"number": val.upper() if val.lower().startswith("inc") else val}
203
+ return {"number": None, "ask_number": True}
204
+
205
+ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
206
+ phrases = [
207
+ "it is resolved", "resolved", "issue resolved", "problem resolved",
208
+ "it's working", "working now", "works now", "fixed", "sorted",
209
+ "ok now", "fine now", "all good", "all set", "thanks works", "thank you it works", "back to normal",
210
+ ]
211
+ return any(p in msg_norm for p in phrases)
212
+
213
+ def _has_negation_resolved(msg_norm: str) -> bool:
214
+ neg_phrases = [
215
+ "not resolved", "issue not resolved", "still not working", "not working",
216
+ "didn't work", "doesn't work", "no change", "not fixed", "still failing", "failed again", "broken", "fail",
217
+ ]
218
+ return any(p in msg_norm for p in neg_phrases)
219
+
220
+ def _classify_resolution_llm(user_message: str) -> bool:
221
+ if not GEMINI_API_KEY:
222
+ return False
223
+ prompt = (
224
+ "Classify if the following user message indicates that the issue is resolved or working now.\n"
225
+ "Return only 'true' or 'false'.\n\n"
226
+ f"Message: {user_message}"
227
+ )
228
+ headers = {"Content-Type": "application/json"}
229
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
230
+ try:
231
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
232
+ data = resp.json()
233
+ text = (
234
+ data.get("candidates", [{}])[0]
235
+ .get("content", {})
236
+ .get("parts", [{}])[0]
237
+ .get("text", "")
238
+ )
239
+ return "true" in (text or "").strip().lower()
240
+ except Exception:
241
+ return False
242
+
243
+ def _is_generic_issue(msg_norm: str) -> bool:
244
+ generic_phrases = [
245
+ "issue", "have an issue", "having an issue", "got an issue",
246
+ "problem", "have a problem", "help", "need help", "support",
247
+ "need support", "please help", "need assistance", "assist me",
248
+ "facing issue", "facing a problem", "got a problem"
249
+ ]
250
+ return any(p == msg_norm or p in msg_norm for p in generic_phrases) or len(msg_norm.split()) <= 2
251
+
252
+ # ---------- Query-normalized, order-preserving filter ----------
253
  STRICT_OVERLAP = 3
254
+ MAX_SENTENCES_STRICT = 4
255
+ MAX_SENTENCES_CONCISE = 3
256
 
257
  def _normalize_for_match(text: str) -> str:
258
  t = (text or "").lower()
 
261
  return t
262
 
263
  def _split_sentences(ctx: str) -> List[str]:
264
+ raw_sents = re.split(r"(?<=[.!?])\s+|\n+|•\s*|-\s*", ctx or "")
265
  return [s.strip() for s in raw_sents if s and len(s.strip()) > 2]
266
 
267
  def _filter_context_for_query(context: str, query: str) -> Tuple[str, Dict[str, Any]]:
 
283
  elif overlap > 0:
284
  matched_any.append(s)
285
  if matched_exact:
286
+ kept = matched_exact[:MAX_SENTENCES_STRICT]
287
  return "\n".join(kept).strip(), {'mode': 'exact', 'matched_count': len(kept), 'all_sentences': len(sentences)}
288
  if matched_any:
289
  kept = matched_any[:MAX_SENTENCES_CONCISE]
 
291
  kept = sentences[:MAX_SENTENCES_CONCISE]
292
  return "\n".join(kept).strip(), {'mode': 'concise', 'matched_count': 0, 'all_sentences': len(sentences)}
293
 
294
+ # ---------- intent & action specific extractors ----------
295
+ STEP_LINE_REGEX = re.compile(r"^\s*(?:\d+[\.\)]\s+|[•\-]\s+)", re.IGNORECASE)
296
  NAV_LINE_REGEX = re.compile(r"(navigate\s+to|>\s*)", re.IGNORECASE)
297
+
298
  PROCEDURE_VERBS = [
299
+ "log in", "select", "scan", "verify", "confirm", "print",
300
+ "move", "complete", "click", "open", "navigate", "choose",
301
+ "enter", "update", "save", "delete", "create", "attach", "assign"
302
  ]
303
+ VERB_START_REGEX = re.compile(r"^\s*(?:" + "|".join([re.escape(v) for v in PROCEDURE_VERBS]) + r")\b", re.IGNORECASE)
304
+
305
+ NON_PROC_PHRASES = [
306
+ "to ensure", "as per", "purpose", "pre-requisites", "prerequisites", "overview", "introduction",
307
+ "organized manner", "structured", "help users", "objective"
308
+ ]
309
+ NON_PROC_ANY_REGEX = re.compile("|".join([re.escape(v) for v in NON_PROC_PHRASES]), re.IGNORECASE)
310
+
311
+ ACTION_SYNS_FLAT = {
312
+ "create": ["create", "creation", "add", "new", "generate"],
313
+ "update": ["update", "modify", "change", "edit"],
314
+ "delete": ["delete", "remove"],
315
+ "navigate": ["navigate", "go to", "open"],
316
+ }
317
 
318
  def _action_in_line(ln: str, target_actions: List[str]) -> bool:
319
  s = (ln or "").lower()
 
330
  if NON_PROC_ANY_REGEX.search(s):
331
  return False
332
  if STEP_LINE_REGEX.match(s):
333
+ if s.lstrip().startswith(("•", "-")):
334
  return bool(VERB_START_REGEX.search(s) or NAV_LINE_REGEX.search(s))
335
  return True
336
  if VERB_START_REGEX.match(s):
 
339
  return True
340
  return False
341
 
342
+ def _extract_steps_only(text: str, max_lines: Optional[int] = 12, target_actions: Optional[List[str]] = None) -> str:
343
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
344
+ kept = []
345
  for ln in lines:
346
  if _is_procedural_line(ln):
347
+ if target_actions:
348
+ if not _action_in_line(ln, target_actions):
349
+ continue
 
 
350
  kept.append(ln)
 
351
  if max_lines is not None and len(kept) >= max_lines:
352
  break
353
  return "\n".join(kept).strip() if kept else (text or "").strip()
354
 
355
+ def _extract_navigation_only(text: str, max_lines: int = 6) -> str:
 
356
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
357
+ kept = []
358
  for ln in lines:
359
+ if NAV_LINE_REGEX.search(ln) or ln.lower().startswith("log in"):
360
  kept.append(ln)
361
  if len(kept) >= max_lines:
362
  break
363
  return "\n".join(kept).strip() if kept else (text or "").strip()
364
 
365
+ def _extract_errors_only(text: str, max_lines: int = 10) -> str:
 
 
 
366
  lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
367
  kept = []
368
  for ln in lines:
369
+ if STEP_LINE_REGEX.match(ln) or ln.lower().startswith(("error", "resolution", "fix", "verify", "check")):
370
  kept.append(ln)
371
  if len(kept) >= max_lines:
372
  break
373
+ return "\n".join(kept).strip() if kept else (text or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
+ def _format_steps_markdown(lines: List[str]) -> str:
376
+ """
377
+ Convert a list of step lines into a clean Markdown numbered list.
378
+ Keeps original order, trims whitespace, skips empty lines.
379
+ """
380
+ items = []
381
+ for i, ln in enumerate(lines, start=1):
382
+ s = (ln or "").strip()
383
+ if not s:
384
+ continue
385
+ # If the line already has leading "1. " or "• ", strip it so numbering is consistent
386
+ s = re.sub(r"^\s*(?:\d+[\.\)]\s+|[•\-]\s+)", "", s).strip()
387
+ items.append(f"{i}. {s}")
388
+ return "\n".join(items).strip()
389
+
 
 
 
 
 
 
 
390
  @app.get("/")
391
  async def health_check():
392
  return {"status": "ok"}
393
 
 
 
 
394
  @app.post("/chat")
395
  async def chat_with_ai(input_data: ChatInput):
396
  try:
397
  msg_norm = (input_data.user_message or "").lower().strip()
398
 
399
+ # --- Yes/No handlers ---
400
+ if msg_norm in ("yes", "y", "sure", "ok", "okay"):
401
  return {
402
+ "bot_response": ("Great! Tell me what you’d like to do next — check another ticket, create an incident, or describe your issue."),
403
+ "status": "OK",
404
+ "followup": "You can say: 'create ticket', 'incident status INC0012345', or describe your problem.",
405
+ "options": [],
406
+ "debug": {"intent": "continue_conversation"},
407
+ }
408
+ if msg_norm in ("no", "no thanks", "nope"):
409
+ return {
410
+ "bot_response": "Glad I could help! 👋 If you need anything else later, just let me know.",
411
+ "status": "OK",
412
+ "end_chat": True,
413
  "followup": None,
414
+ "options": [],
415
+ "debug": {"intent": "end_conversation"},
416
  }
417
 
418
+ # --- Resolution ack ---
419
+ is_llm_resolved = _classify_resolution_llm(input_data.user_message)
420
+ if _has_negation_resolved(msg_norm):
421
+ is_llm_resolved = False
422
+ if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
423
+ try:
424
+ short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
425
+ result = create_incident(short_desc, long_desc)
426
+ if isinstance(result, dict) and not result.get("error"):
427
+ inc_number = result.get("number", "<unknown>")
428
+ sys_id = result.get("sys_id")
429
+ resolved_note = ""
430
+ if sys_id:
431
+ ok = _set_incident_resolved(sys_id)
432
+ resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
433
+ return {
434
+ "bot_response": f"✅ Incident created: {inc_number}{resolved_note}",
435
+ "status": "OK",
436
+ "context_found": False,
437
+ "ask_resolved": False,
438
+ "suggest_incident": False,
439
+ "followup": None,
440
+ "top_hits": [],
441
+ "sources": [],
442
+ "auto_incident": True,
443
+ "debug": {"intent": "resolved_ack", "auto_created": True},
444
+ }
445
+ else:
446
+ err = (result or {}).get("error", "Unknown error")
447
+ return {
448
+ "bot_response": f"⚠️ I couldn’t create the tracking incident automatically ({err}).",
449
+ "status": "PARTIAL",
450
+ "context_found": False,
451
+ "ask_resolved": False,
452
+ "suggest_incident": True,
453
+ "followup": "Shall I create a ticket now?",
454
+ "top_hits": [],
455
+ "sources": [],
456
+ "debug": {"intent": "resolved_ack", "auto_created": False, "error": err},
457
+ }
458
+ except Exception as e:
459
+ return {
460
+ "bot_response": f"⚠️ Something went wrong while creating the tracking incident: {safe_str(e)}",
461
+ "status": "PARTIAL",
462
+ "context_found": False,
463
+ "ask_resolved": False,
464
+ "suggest_incident": True,
465
+ "followup": "Shall I create a ticket now?",
466
+ "top_hits": [],
467
+ "sources": [],
468
+ "debug": {"intent": "resolved_ack", "exception": True},
469
+ }
470
+
471
+ # --- Incident intent ---
472
  if _is_incident_intent(msg_norm):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  return {
474
  "bot_response": (
475
  "Okay, let’s create a ServiceNow incident.\n\n"
 
477
  "• Detailed Description (steps, error text, IDs, site, environment)"
478
  ),
479
  "status": (input_data.prev_status or "PARTIAL"),
480
+ "context_found": False,
481
+ "ask_resolved": False,
482
+ "suggest_incident": False,
483
+ "show_incident_form": True,
484
+ "followup": None,
485
+ "top_hits": [],
486
+ "sources": [],
487
  "debug": {"intent": "create_ticket"},
488
  }
489
 
490
+ # --- Generic opener ---
491
  if _is_generic_issue(msg_norm):
492
  return {
493
  "bot_response": (
 
499
  "• When it started and how many users are impacted"
500
  ),
501
  "status": "NO_KB_MATCH",
502
+ "context_found": False,
503
+ "ask_resolved": False,
504
+ "suggest_incident": False,
505
+ "followup": "Please reply with the above details.",
506
+ "top_hits": [],
507
+ "sources": [],
508
  "debug": {"intent": "generic_issue"},
509
  }
510
 
511
+ # --- Status intent ---
512
  status_intent = _parse_ticket_status_intent(msg_norm)
513
  if status_intent:
514
  if status_intent.get("ask_number"):
 
518
  "context_found": False,
519
  "ask_resolved": False,
520
  "suggest_incident": False,
 
521
  "followup": "Provide the Incident ID and I’ll fetch the status.",
522
+ "show_status_form": True,
523
+ "top_hits": [],
524
+ "sources": [],
525
  "debug": {"intent": "status_request_missing_id"},
526
  }
 
527
  try:
528
  token = get_valid_token()
529
  instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
 
537
  lst = data.get("result", [])
538
  result = (lst or [{}])[0] if response.status_code == 200 else {}
539
  state_code = builtins.str(result.get("state", "unknown"))
540
+ state_label = STATE_MAP.get(state_code, state_code)
 
541
  short = result.get("short_description", "")
542
  num = result.get("number", number or "unknown")
543
  return {
544
  "bot_response": (
545
+ f"**Ticket:** {num} \n"
546
+ f"**Status:** {state_label} \n"
547
  f"**Issue description:** {short}"
548
  ),
549
  "status": "OK",
 
552
  "ask_resolved": False,
553
  "suggest_incident": False,
554
  "followup": "Is there anything else I can assist you with?",
555
+ "top_hits": [],
556
+ "sources": [],
557
  "debug": {"intent": "status", "http_status": response.status_code},
558
  }
559
  except Exception as e:
560
  raise HTTPException(status_code=500, detail=safe_str(e))
561
 
562
+ # --- Hybrid KB search ---
563
  kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
564
+ kb_ctx = extract_kb_context(kb_results, top_chunks=2)
565
+ context_raw = kb_ctx.get("context", "") or ""
566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  filtered_text, filt_info = _filter_context_for_query(context_raw, input_data.user_message)
568
+ context = filtered_text
569
+ context_found = bool(kb_ctx.get("context_found", False)) and bool(context.strip())
570
+ best_distance = kb_ctx.get("best_score")
571
+ best_combined = kb_ctx.get("best_combined")
572
  detected_intent = kb_results.get("user_intent", "neutral")
573
+ actions = kb_results.get("actions", [])
574
+ best_doc = kb_results.get("best_doc")
575
+ top_meta = (kb_results.get("metadatas") or [{}])[0] if (kb_results.get("metadatas") or []) else {}
576
+
577
+ # --- FULL SECTION when strongly found & steps intent ---
 
 
 
 
 
 
 
 
 
 
 
578
  if detected_intent == "steps" and best_doc:
579
+ # prefer full 'Process Steps' section from the best SOP
580
  full_steps = get_best_steps_section_text(best_doc)
581
  if not full_steps:
582
+ # fallback: full text of the top section
583
  sec = (top_meta or {}).get("section")
584
  if sec:
585
  full_steps = get_section_text(best_doc, sec)
586
  if full_steps:
587
+ # If confidence is high, return ALL procedural lines (no truncation)
588
+ high_conf = (best_combined is not None and best_combined >= 0.75)
589
+ context = _extract_steps_only(full_steps, max_lines=None if high_conf else 20, target_actions=actions)
590
+
591
+ # Intent-shaping (secondary; if not already handled above)
592
+ q = (input_data.user_message or "").lower()
593
+ if detected_intent == "steps" or any(k in q for k in ["steps", "procedure", "perform", "do", "process"]):
594
+ context = _extract_steps_only(context, max_lines=None if (best_combined and best_combined >= 0.75) else 12, target_actions=actions)
595
+ elif detected_intent == "errors" or any(k in q for k in ["error", "issue", "fail", "not working", "resolution", "fix"]):
596
+ context = _extract_errors_only(context, max_lines=10)
597
+ elif any(k in q for k in ["navigate", "navigation", "menu", "screen"]):
598
+ context = _extract_navigation_only(context, max_lines=6)
599
+
600
+ # Gating
601
+ short_query = len((input_data.user_message or "").split()) <= 4
602
+ gate_combined_no_kb = 0.22 if short_query else 0.28
603
+ gate_combined_ok = 0.60 if short_query else 0.55
604
+ gate_distance_no_kb = 2.0
605
+
606
+ if (not context_found or not context.strip()) or (
607
+ (best_combined is None or best_combined < gate_combined_no_kb)
608
+ and (best_distance is None or best_distance >= gate_distance_no_kb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  ):
610
  second_try = (input_data.prev_status or "").upper() == "NO_KB_MATCH"
611
+ clarify_text = (
612
+ _build_clarifying_message()
613
+ if not second_try
614
+ else "I still don’t find a relevant KB match for this scenario even after clarification."
615
+ )
 
 
 
 
616
  return {
617
+ "bot_response": clarify_text,
618
  "status": "NO_KB_MATCH",
619
+ "context_found": False,
620
+ "ask_resolved": False,
621
  "suggest_incident": bool(second_try),
622
  "followup": ("Please reply with the above details." if not second_try else "Shall I create a ticket now?"),
623
+ "top_hits": [],
624
+ "sources": [],
625
  "debug": {"used_chunks": 0, "second_try": second_try, "best_distance": best_distance, "best_combined": best_combined},
626
  }
627
 
628
+ # LLM rewrite (kept, but we still fallback cleanly)
629
+ enhanced_prompt = (
630
+ "From the provided context, output only the actionable steps/procedure relevant to the user's question. "
631
+ "Use ONLY the provided context; do NOT add information that is not present. "
632
+ + ("Return ONLY lines containing the requested action verbs. " if actions else "")
633
+ + "Do NOT include document names, section titles, or 'Source:' lines.\n\n"
634
+ f"### Context\n{context}\n\n"
635
+ f"### Question\n{input_data.user_message}\n\n"
636
+ "### Output\n"
637
+ "- Return numbered/bulleted steps in the same order.\n"
638
+ "- If context is insufficient, add: 'This may be partial based on available KB.'\n"
639
+ )
640
+ headers = {"Content-Type": "application/json"}
641
+ payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
642
+ try:
643
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=VERIFY_SSL)
644
+ try:
645
+ result = resp.json()
646
+ except Exception:
647
+ result = {}
648
+ except Exception:
649
+ resp = type("RespStub", (), {"status_code": 0})()
650
+ result = {}
651
+
652
+ try:
653
+ bot_text = (result["candidates"][0]["content"]["parts"][0]["text"] if isinstance(result, dict) else "")
654
+ except Exception:
655
+ bot_text = ""
656
+
657
+ if not bot_text.strip():
658
+ bot_text = context
659
+ bot_text = _strip_any_source_lines(bot_text).strip()
660
+
661
+ # If the intent is steps, render lines as a numbered Markdown list
662
+ if kb_results.get("user_intent", "neutral") == "steps":
663
+ raw_lines = [ln.strip() for ln in bot_text.splitlines() if ln.strip()]
664
+
665
+ # If everything is on a single line, split defensively on ". "
666
+ if len(raw_lines) == 1:
667
+ parts = [p.strip() for p in re.split(r"\.\s+(?=[A-Z0-9])", raw_lines[0]) if p.strip()]
668
+ raw_lines = parts if len(parts) > 1 else raw_lines
669
+
670
+ # 🔴 NEW: merge number-only lines with the next line
671
+ merged: list[str] = []
672
+ i = 0
673
+ while i < len(raw_lines):
674
+ curr = raw_lines[i]
675
+ # A number-only line (e.g., "1", "2", "3")
676
+ if re.fullmatch(r"\d+", curr) and (i + 1) < len(raw_lines):
677
+ nxt = raw_lines[i + 1].strip()
678
+ # Combine into one line: "1. <next line text>"
679
+ merged.append(f"{curr}. {nxt}")
680
+ i += 2 # skip the next line; already merged
681
+ else:
682
+ merged.append(curr)
683
+ i += 1
684
+
685
+ # Finally: normalize and render as Markdown numbered list
686
+ bot_text = _format_steps_markdown(merged)
687
+
688
+ status = "OK" if (
689
+ (best_combined is not None and best_combined >= gate_combined_ok)
690
+ or (filt_info.get('mode') == 'exact' and filt_info.get('matched_count', 0) > 0)
691
+ ) else "PARTIAL"
692
+
693
+ lower = bot_text.lower()
694
+ if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
695
+ status = "PARTIAL"
696
+
697
  return {
698
  "bot_response": bot_text,
699
  "status": status,
700
  "context_found": True,
701
  "ask_resolved": (status == "OK"),
702
  "suggest_incident": False,
703
+ "followup": ("Does this match your scenario? I can refine the steps." if status == "PARTIAL" else None),
704
+ "top_hits": [],
705
+ "sources": [],
706
  "debug": {
707
  "used_chunks": len(context.split("\n\n---\n\n")) if context else 0,
708
+ "best_distance": best_distance,
709
+ "best_combined": best_combined,
710
+ "http_status": getattr(resp, "status_code", 0),
711
+ "filter_mode": filt_info.get("mode"),
712
+ "matched_count": filt_info.get("matched_count"),
713
+ "user_intent": detected_intent,
714
+ "actions": actions,
715
+ "best_doc": best_doc,
716
  },
717
  }
718
 
 
721
  except Exception as e:
722
  raise HTTPException(status_code=500, detail=safe_str(e))
723
 
724
+ def _set_incident_resolved(sys_id: str) -> bool:
725
+ try:
726
+ token = get_valid_token()
727
+ instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
728
+ if not instance_url:
729
+ print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
730
+ return False
731
+ headers = {
732
+ "Authorization": f"Bearer {token}",
733
+ "Accept": "application/json",
734
+ "Content-Type": "application/json",
735
+ }
736
+ url = f"{instance_url}/api/now/table/incident/{sys_id}"
737
+
738
+ close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
739
+ close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
740
+ caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
741
+ resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID")
742
+ assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID")
743
+ require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
744
+
745
+ if require_progress:
746
+ try:
747
+ resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25)
748
+ print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
749
+ except Exception as e:
750
+ print(f"[SN PATCH progress] exception={safe_str(e)}")
751
+
752
+ def clean(d: dict) -> dict:
753
+ return {k: v for k, v in d.items() if v is not None}
754
+
755
+ payload_A = clean({
756
+ "state": "6",
757
+ "close_code": close_code_val,
758
+ "close_notes": close_notes_val,
759
+ "caller_id": caller_sysid,
760
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
761
+ "work_notes": "Auto-resolve set by NOVA.",
762
+ "resolved_by": resolved_by_sysid,
763
+ "assignment_group": assign_group,
764
+ })
765
+ respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
766
+ if respA.status_code in (200, 204):
767
+ return True
768
+ print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
769
+
770
+ payload_B = clean({
771
+ "state": "Resolved",
772
+ "close_code": close_code_val,
773
+ "close_notes": close_notes_val,
774
+ "caller_id": caller_sysid,
775
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
776
+ "work_notes": "Auto-resolve set by NOVA.",
777
+ "resolved_by": resolved_by_sysid,
778
+ "assignment_group": assign_group,
779
+ })
780
+ respB = requests.patch(url, headers=headers, json=payload_B, verify=VERIFY_SSL, timeout=25)
781
+ if respB.status_code in (200, 204):
782
+ return True
783
+ print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
784
+
785
+ code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
786
+ notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
787
+ payload_C = clean({
788
+ "state": "6",
789
+ code_field: close_code_val,
790
+ notes_field: close_notes_val,
791
+ "caller_id": caller_sysid,
792
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
793
+ "work_notes": "Auto-resolve set by NOVA.",
794
+ "resolved_by": resolved_by_sysid,
795
+ "assignment_group": assign_group,
796
+ })
797
+ respC = requests.patch(url, headers=headers, json=payload_C, verify=VERIFY_SSL, timeout=25)
798
+ if respC.status_code in (200, 204):
799
+ return True
800
+ print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
801
+ return False
802
+ except Exception as e:
803
+ print(f"[SN PATCH resolve] exception={safe_str(e)}")
804
+ return False
805
+
806
  @app.post("/incident")
807
  async def raise_incident(input_data: IncidentInput):
808
  try:
809
  result = create_incident(input_data.short_description, input_data.description)
810
  if isinstance(result, dict) and not result.get("error"):
811
  inc_number = result.get("number", "<unknown>")
812
+ sys_id = result.get("sys_id")
813
+ resolved_note = ""
814
+ if bool(input_data.mark_resolved) and sys_id not in ("<unknown>", None):
815
+ ok = _set_incident_resolved(sys_id)
816
+ resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
817
+ ticket_text = f"Incident created: {inc_number}{resolved_note}"
 
818
  else:
819
+ ticket_text = "Incident created."
820
+ return {
821
+ "bot_response": f"✅ {ticket_text}",
822
+ "debug": "Incident created via ServiceNow",
823
+ "persist": True,
824
+ "show_assist_card": True,
825
+ "followup": "Is there anything else I can assist you with?",
826
+ }
827
+ except Exception as e:
828
+ raise HTTPException(status_code=500, detail=safe_str(e))
829
+
830
+ @app.post("/generate_ticket_desc")
831
+ async def generate_ticket_desc_ep(input_data: TicketDescInput):
832
+ try:
833
+ prompt = (
834
+ f"You are helping generate ServiceNow ticket descriptions based on the issue: {input_data.issue}.\n"
835
+ "Please return the output strictly in JSON format with the following keys:\n"
836
+ "{\n"
837
+ ' "ShortDescription": "A concise summary of the issue (max 100 characters)",\n'
838
+ ' "DetailedDescription": "A detailed explanation of the issue"\n'
839
+ "}\n"
840
+ "Do not include any extra text, comments, or explanations outside the JSON."
841
+ )
842
+ headers = {"Content-Type": "application/json"}
843
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
844
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_GSL_VERIFY if 'GEMINI_GSL_VERIFY' in globals() else GEMINI_SSL_VERIFY)
845
+ try:
846
+ data = resp.json()
847
+ except Exception:
848
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
849
+ try:
850
+ text = data["candidates"][0]["content"]["parts"][0]["text"].strip()
851
+ except Exception:
852
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
853
+ if text.startswith("```"):
854
+ lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
855
+ text = "\n".join(lines).strip()
856
+ try:
857
+ ticket_json = json.loads(text)
858
  return {
859
+ "ShortDescription": ticket_json.get("ShortDescription", "").strip(),
860
+ "DetailedDescription": ticket_json.get("DetailedDescription", "").strip(),
 
861
  }
862
+ except Exception:
863
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
864
  except Exception as e:
865
  raise HTTPException(status_code=500, detail=safe_str(e))
866
 
 
886
  else:
887
  raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
888
  state_code = builtins.str(result.get("state", "unknown"))
889
+ state_label = STATE_MAP.get(state_code, state_code)
 
890
  short = result.get("short_description", "")
891
  number = result.get("number", input_data.number or "unknown")
892
  return {
893
  "bot_response": (
894
+ f"**Ticket:** {number} \n"
895
+ f"**Status:** {state_label} \n"
896
  f"**Issue description:** {short}"
897
  ).replace("\n", " \n"),
898
  "followup": "Is there anything else I can assist you with?",
 
903
  except Exception as e:
904
  raise HTTPException(status_code=500, detail=safe_str(e))
905
 
906
+ # ---- Admin endpoints (optional) ----
907
+ @app.get("/kb/info")
908
+ async def kb_info():
909
+ from services.kb_creation import get_kb_runtime_info
910
+ return get_kb_runtime_info()
911
+
912
+ @app.post("/kb/reset")
913
+ async def kb_reset():
914
+ from services.kb_creation import reset_kb
915
+ folder_path = os.path.join(os.getcwd(), "documents")
916
+ return reset_kb(folder_path)
917
+