srilakshu012456 commited on
Commit
e4601ba
·
verified ·
1 Parent(s): 99a6eaa

Upload 8 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ SOP_7_Appointment_Creation.docx filter=lfs diff=lfs merge=lfs -text
SOP_1_Inbound_Order_Receiving.docx ADDED
Binary file (47.3 kB). View file
 
SOP_2_Inventory_Adjustment.docx ADDED
Binary file (34 kB). View file
 
SOP_3_Updating_Part_Footprint.docx ADDED
Binary file (34 kB). View file
 
SOP_4_Picking_Inventory.docx ADDED
Binary file (34 kB). View file
 
SOP_5_Shipping.docx ADDED
Binary file (47.1 kB). View file
 
SOP_6_Closing_Trailer.docx ADDED
Binary file (34 kB). View file
 
SOP_7_Appointment_Creation.docx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:96ffe2e9ec319c3b9575a814b4bade078d1769bbae9de7c16d5810fbf4c5bff6
3
+ size 2129745
main.py ADDED
@@ -0,0 +1,806 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import json
4
+ import re
5
+ import requests
6
+ from typing import Optional, Any, Dict
7
+ from contextlib import asynccontextmanager
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from pydantic import BaseModel
11
+ from dotenv import load_dotenv
12
+ from datetime import datetime
13
+
14
+ # KB services (Chroma + sentence-transformers + BM25 hybrid)
15
+ from services.kb_creation import (
16
+ collection,
17
+ ingest_documents,
18
+ hybrid_search_knowledge_base, # new
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
+ "http://localhost:5173",
56
+ "http://localhost:5174",
57
+ "http://localhost:3000",
58
+ ]
59
+ app.add_middleware(
60
+ CORSMiddleware,
61
+ allow_origins=origins,
62
+ allow_credentials=True,
63
+ allow_methods=["*"],
64
+ allow_headers=["*"],
65
+ )
66
+
67
+ # ---------------------- Models ----------------------
68
+ class ChatInput(BaseModel):
69
+ user_message: str
70
+ prev_status: Optional[str] = None # "NO_KB_MATCH" | "PARTIAL" | "OK" | None
71
+ last_issue: Optional[str] = None
72
+
73
+
74
+ class IncidentInput(BaseModel):
75
+ short_description: str
76
+ description: str
77
+ mark_resolved: Optional[bool] = False
78
+
79
+
80
+ class TicketDescInput(BaseModel):
81
+ issue: str
82
+
83
+
84
+ class TicketStatusInput(BaseModel):
85
+ sys_id: Optional[str] = None
86
+ number: Optional[str] = None # IncidentID (incident number)
87
+
88
+
89
+ # ✅ Human‑readable mapping for ServiceNow incident state codes
90
+ STATE_MAP = {
91
+ "1": "New",
92
+ "2": "In Progress",
93
+ "3": "On Hold",
94
+ "6": "Resolved",
95
+ "7": "Closed",
96
+ "8": "Canceled",
97
+ }
98
+
99
+ # ---------------------- Gemini setup ----------------------
100
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
101
+ GEMINI_URL = (
102
+ f"https://generativelanguage.googleapis.com/v1beta/models/"
103
+ f"gemini-2.5-flash-lite:generateContent?key={GEMINI_API_KEY}"
104
+ )
105
+
106
+ # ---------------------- Helpers: KB context merge + sanitation ----------------------
107
+ def extract_kb_context(kb_results: Optional[Dict[str, Any]], top_chunks: int = 2) -> Dict[str, Any]:
108
+ """
109
+ Merge documents + metadatas + distances.
110
+ If documents are missing but ids exist, fetch via collection.get (rare).
111
+ Supports hybrid fields: 'combined_scores' in results.
112
+ """
113
+ if not kb_results or not isinstance(kb_results, dict):
114
+ return {"context": "", "sources": [], "top_hits": [], "context_found": False, "best_score": None, "best_combined": None}
115
+
116
+ documents = kb_results.get("documents") or []
117
+ metadatas = kb_results.get("metadatas") or []
118
+ distances = kb_results.get("distances") or []
119
+ ids = kb_results.get("ids") or []
120
+ combined = kb_results.get("combined_scores") or []
121
+
122
+ # Fallback fetch by ids if needed
123
+ if (not documents) and ids:
124
+ try:
125
+ fetched = collection.get(ids=ids, include=['documents', 'metadatas'])
126
+ documents = fetched.get('documents', []) or []
127
+ metadatas = fetched.get('metadatas', []) or metadatas
128
+ except Exception:
129
+ pass
130
+
131
+ items = []
132
+ for i, doc in enumerate(documents):
133
+ text = doc.strip() if isinstance(doc, str) else ""
134
+ if not text:
135
+ continue
136
+ meta = metadatas[i] if i < len(metadatas) and isinstance(metadatas[i], dict) else {}
137
+ score = distances[i] if i < len(distances) else None
138
+ comb = combined[i] if i < len(combined) else None
139
+ m = dict(meta)
140
+ if score is not None:
141
+ m["distance"] = score
142
+ if comb is not None:
143
+ m["combined"] = comb
144
+ items.append({"text": text, "meta": m})
145
+
146
+ selected = items[:max(1, top_chunks)]
147
+ context = "\n\n---\n\n".join([s["text"] for s in selected]) if selected else ""
148
+ sources = [s["meta"] for s in selected]
149
+
150
+ # best (lower distance is better, higher combined is better)
151
+ best_distance = None
152
+ if distances:
153
+ try:
154
+ best_distance = min([d for d in distances if d is not None])
155
+ except Exception:
156
+ best_distance = None
157
+
158
+ best_combined = None
159
+ if combined:
160
+ try:
161
+ best_combined = max([c for c in combined if c is not None])
162
+ except Exception:
163
+ best_combined = None
164
+
165
+ return {
166
+ "context": context,
167
+ "sources": sources,
168
+ "top_hits": [], # hidden in UI
169
+ "context_found": bool(selected),
170
+ "best_score": best_distance,
171
+ "best_combined": best_combined,
172
+ }
173
+
174
+
175
+ def _strip_any_source_lines(text: str) -> str:
176
+ lines = text.splitlines()
177
+ kept = []
178
+ for ln in lines:
179
+ if re.match(r"^\s*source\s*:", ln, flags=re.IGNORECASE):
180
+ continue
181
+ kept.append(ln)
182
+ return "\n".join(kept).strip()
183
+
184
+
185
+ def _build_clarifying_message() -> str:
186
+ return (
187
+ "I couldn’t find matching content in the KB yet. To help me narrow it down, please share:\n\n"
188
+ "• Module/area (e.g., Picking, Receiving, Trailer Close)\n"
189
+ "• Exact error message text/code (copy-paste)\n"
190
+ "• IDs involved (Order#, Load ID, Shipment#, Item#)\n"
191
+ "• Warehouse/site & environment (prod/test)\n"
192
+ "• When it started and how many users are impacted\n\n"
193
+ "Reply with these details and I’ll search again."
194
+ )
195
+
196
+ # ---------------------- Intent helpers ----------------------
197
+ def _build_tracking_descriptions(issue_text: str, resolved_text: str) -> tuple[str, str]:
198
+ """
199
+ Short: first 100 chars of the ORIGINAL issue text (preferred).
200
+ Long: clear sentence that includes both original issue and resolved ack.
201
+ """
202
+ issue = (issue_text or "").strip()
203
+ resolved = (resolved_text or "").strip()
204
+ short_desc = issue[:100] if issue else (resolved[:100] or "Issue resolved (user confirmation)")
205
+ long_desc = (
206
+ f'User reported: "{issue}". '
207
+ f'User confirmation: "{resolved}". '
208
+ f'Tracking record created automatically by NOVA.'
209
+ ).strip()
210
+ return short_desc, long_desc
211
+
212
+
213
+ def _is_incident_intent(msg_norm: str) -> bool:
214
+ intent_phrases = [
215
+ "create ticket", "create a ticket", "raise ticket", "raise a ticket", "open ticket", "open a ticket",
216
+ "create incident", "create an incident", "raise incident", "raise an incident", "open incident", "open an incident",
217
+ "log ticket", "log an incident", "generate ticket", "create snow ticket", "raise snow ticket",
218
+ "raise service now ticket", "create service now ticket", "raise sr", "open sr",
219
+ ]
220
+ return any(p in msg_norm for p in intent_phrases)
221
+
222
+
223
+ def _is_feedback_message(msg_norm: str) -> bool:
224
+ feedback_phrases = [
225
+ "issue not resolved", "not resolved", "still not working",
226
+ "same issue", "no change", "didn't work", "doesn't work",
227
+ "not fixed", "still failing", "failed again",
228
+ ]
229
+ return any(p in msg_norm for p in feedback_phrases)
230
+
231
+
232
+ def _parse_ticket_status_intent(msg_norm: str) -> Dict[str, Optional[str]]:
233
+ status_keywords = [
234
+ "status", "ticket status", "incident status",
235
+ "check status", "check ticket status", "check incident status",
236
+ ]
237
+ if not any(k in msg_norm for k in status_keywords):
238
+ return {} # not a status intent
239
+
240
+ patterns = [
241
+ r"(?:incident\s*id|incidentid|ticket\s*number|number)\s*[:=]?\s*(inc\d+)",
242
+ r"(inc\d+)",
243
+ ]
244
+ for pat in patterns:
245
+ m = re.search(pat, msg_norm, flags=re.IGNORECASE)
246
+ if m:
247
+ val = m.group(1).strip()
248
+ if val:
249
+ return {"number": val.upper() if val.lower().startswith("inc") else val}
250
+ return {"number": None, "ask_number": True}
251
+
252
+
253
+ def _is_resolution_ack_heuristic(msg_norm: str) -> bool:
254
+ phrases = [
255
+ "it is resolved", "resolved", "issue resolved", "problem resolved",
256
+ "it's working", "working now", "works now", "fixed", "sorted",
257
+ "ok now", "fine now", "all good", "all set", "thanks works", "thank you it works", "back to normal",
258
+ ]
259
+ return any(p in msg_norm for p in phrases)
260
+
261
+
262
+ def _has_negation_resolved(msg_norm: str) -> bool:
263
+ neg_phrases = [
264
+ "not resolved", "issue not resolved", "still not working", "not working",
265
+ "didn't work", "doesn't work", "no change", "not fixed", "still failing", "failed again", "broken", "fail",
266
+ ]
267
+ return any(p in msg_norm for p in neg_phrases)
268
+
269
+
270
+ def _classify_resolution_llm(user_message: str) -> bool:
271
+ if not GEMINI_API_KEY:
272
+ return False
273
+ prompt = (
274
+ "Classify if the following user message indicates that the issue is resolved or working now.\n"
275
+ "Return only 'true' or 'false'.\n\n"
276
+ f"Message: {user_message}"
277
+ )
278
+ headers = {"Content-Type": "application/json"}
279
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
280
+ try:
281
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=12, verify=GEMINI_SSL_VERIFY)
282
+ data = resp.json()
283
+ text = (
284
+ data.get("candidates", [{}])[0]
285
+ .get("content", {})
286
+ .get("parts", [{}])[0]
287
+ .get("text", "")
288
+ .strip()
289
+ .lower()
290
+ )
291
+ return "true" in text
292
+ except Exception:
293
+ return False
294
+
295
+ # ---------------------- Health ----------------------
296
+ @app.get("/")
297
+ async def health_check():
298
+ return {"status": "ok"}
299
+
300
+ # ---------------------- Chat endpoint ----------------------
301
+ @app.post("/chat")
302
+ async def chat_with_ai(input_data: ChatInput):
303
+ """
304
+ Policy:
305
+ A) If 'resolved/working' detected → auto-create tracking incident, auto-mark Resolved, and ask if further help needed.
306
+ B) If user intent is 'create ticket' → offer incident form (Yes/No).
307
+ C) Ticket status intent → if number missing, ask for it; else return status from ServiceNow.
308
+ D) Feedback-only → ask details (or offer incident on second try) without KB search.
309
+ E) Otherwise, use HYBRID search; rewrite answer from KB only; ask resolved if 'OK', or refine if 'PARTIAL'.
310
+ """
311
+ try:
312
+ msg_norm = (input_data.user_message or "").lower().strip()
313
+
314
+ # --- Handle Yes/No replies from the UI ---
315
+ if msg_norm in ("yes", "y", "sure", "ok", "okay"):
316
+ return {
317
+ "bot_response": (
318
+ "Great! Tell me what you’d like to do next — check another ticket, "
319
+ "create an incident, or describe your issue."
320
+ ),
321
+ "status": "OK",
322
+ "followup": "You can say: 'create ticket', 'incident status INC0012345', or describe your problem.",
323
+ "options": [],
324
+ "debug": {"intent": "continue_conversation"},
325
+ }
326
+
327
+ if msg_norm in ("no", "no thanks", "nope"):
328
+ return {
329
+ "bot_response": "Glad I could help! 👋 If you need anything else later, just let me know.",
330
+ "status": "OK",
331
+ "end_chat": True,
332
+ "followup": None,
333
+ "options": [],
334
+ "debug": {"intent": "end_conversation"},
335
+ }
336
+
337
+ # --- (A) Resolution acknowledgement
338
+ is_llm_resolved = _classify_resolution_llm(input_data.user_message)
339
+ if _has_negation_resolved(msg_norm):
340
+ is_llm_resolved = False
341
+
342
+ if (not _has_negation_resolved(msg_norm)) and (_is_resolution_ack_heuristic(msg_norm) or is_llm_resolved):
343
+ try:
344
+ short_desc, long_desc = _build_tracking_descriptions(input_data.last_issue, input_data.user_message)
345
+ result = create_incident(short_desc, long_desc) # ServiceNow helper
346
+ if isinstance(result, dict) and not result.get("error"):
347
+ inc_number = result.get("number", "<unknown>")
348
+ sys_id = result.get("sys_id")
349
+ # Auto-mark resolved (state=6) if we have sys_id
350
+ resolved_note = ""
351
+ if sys_id:
352
+ ok = _set_incident_resolved(sys_id)
353
+ resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
354
+ return {
355
+ "bot_response": f"✅ Incident created: {inc_number}{resolved_note}",
356
+ "status": "OK",
357
+ "context_found": False,
358
+ "ask_resolved": False,
359
+ "suggest_incident": False,
360
+ "followup": None,
361
+ "top_hits": [],
362
+ "sources": [],
363
+ "auto_incident": True,
364
+ "debug": {"intent": "resolved_ack", "auto_created": True},
365
+ }
366
+ else:
367
+ err = (result or {}).get("error", "Unknown error")
368
+ return {
369
+ "bot_response": f"⚠️ I couldn’t create the tracking incident automatically ({err}). Would you like me to try again?",
370
+ "status": "PARTIAL",
371
+ "context_found": False,
372
+ "ask_resolved": False,
373
+ "suggest_incident": True,
374
+ "followup": "Shall I create a ticket now?",
375
+ "top_hits": [],
376
+ "sources": [],
377
+ "debug": {"intent": "resolved_ack", "auto_created": False, "error": err},
378
+ }
379
+ except Exception as e:
380
+ return {
381
+ "bot_response": f"⚠️ Something went wrong while creating the tracking incident: {str(e)}",
382
+ "status": "PARTIAL",
383
+ "context_found": False,
384
+ "ask_resolved": False,
385
+ "suggest_incident": True,
386
+ "followup": "Shall I create a ticket now?",
387
+ "top_hits": [],
388
+ "sources": [],
389
+ "debug": {"intent": "resolved_ack", "exception": True},
390
+ }
391
+
392
+ # --- (B) Incident intent
393
+ if _is_incident_intent(msg_norm):
394
+ return {
395
+ "bot_response": "Okay. Shall I create a ServiceNow incident now?",
396
+ "status": (input_data.prev_status or "PARTIAL"),
397
+ "context_found": False,
398
+ "ask_resolved": False,
399
+ "suggest_incident": True,
400
+ "followup": "Shall I create a ticket now?",
401
+ "top_hits": [],
402
+ "sources": [],
403
+ "debug": {"intent": "create_ticket"},
404
+ }
405
+
406
+ # --- (C) Ticket status intent
407
+ status_intent = _parse_ticket_status_intent(msg_norm)
408
+ if status_intent:
409
+ if status_intent.get("ask_number"):
410
+ return {
411
+ "bot_response": "Please share the Incident ID (e.g., INC0012345) to check the status.",
412
+ "status": "PARTIAL",
413
+ "context_found": False,
414
+ "ask_resolved": False,
415
+ "suggest_incident": False,
416
+ "followup": "Provide the Incident ID and I’ll fetch the status.",
417
+ "top_hits": [],
418
+ "sources": [],
419
+ "debug": {"intent": "status_request_missing_id"},
420
+ }
421
+ try:
422
+ token = get_valid_token()
423
+ instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
424
+ if not instance_url:
425
+ raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
426
+
427
+ headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
428
+ number = status_intent.get("number")
429
+ url = f"{instance_url}/api/now/table/incident?number={number}"
430
+ response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
431
+ data = response.json()
432
+ lst = data.get("result", [])
433
+ result = (lst or [{}])[0] if response.status_code == 200 else {}
434
+
435
+ state_code = str(result.get("state", "unknown"))
436
+ state_label = STATE_MAP.get(state_code, state_code)
437
+ short = result.get("short_description", "")
438
+ num = result.get("number", number or "unknown")
439
+
440
+ # ✅ Use bold labels + hard line breaks so lines appear separately in Markdown
441
+ return {
442
+ "bot_response": (
443
+ f"**Ticket:** {num} \n"
444
+ f"**Status:** {state_label} \n"
445
+ f"**Issue description:** {short}"
446
+ ),
447
+ "status": "OK",
448
+ "show_assist_card": True, # <— triggers Yes/No card
449
+ "context_found": False,
450
+ "ask_resolved": False,
451
+ "suggest_incident": False,
452
+ "followup": "Is there anything else I can assist you with?",
453
+ "top_hits": [],
454
+ "sources": [],
455
+ "debug": {"intent": "status", "http_status": response.status_code},
456
+ }
457
+ except Exception as e:
458
+ raise HTTPException(status_code=500, detail=str(e))
459
+
460
+ # --- (D) Feedback-only messages
461
+ if _is_feedback_message(msg_norm):
462
+ second_try = (input_data.prev_status or "").upper() == "NO_KB_MATCH"
463
+ return {
464
+ "bot_response": (
465
+ "Understood. To refine the steps, please share:\n"
466
+ "• Exact error text/code\n"
467
+ "• IDs (Order#, Load ID, Shipment#)\n"
468
+ "• Site & environment (prod/test)\n"
469
+ "• When it started and how many users are impacted"
470
+ if not second_try else
471
+ "It still looks unresolved after clarification.\n\nWould you like me to raise a ServiceNow incident?"
472
+ ),
473
+ "status": "NO_KB_MATCH",
474
+ "context_found": False,
475
+ "ask_resolved": False,
476
+ "suggest_incident": bool(second_try),
477
+ "followup": ("Please reply with the above details." if not second_try else "Shall I create a ticket now?"),
478
+ "top_hits": [],
479
+ "sources": [],
480
+ "debug": {"feedback_only": True, "second_try": second_try},
481
+ }
482
+
483
+ # --- (E) HYBRID KB search & rewrite ---
484
+ kb_results = hybrid_search_knowledge_base(input_data.user_message, top_k=10, alpha=0.6, beta=0.4)
485
+ kb_ctx = extract_kb_context(kb_results, top_chunks=2)
486
+ context = kb_ctx.get("context", "") or ""
487
+ context_found = bool(kb_ctx.get("context_found", False))
488
+ best_distance = kb_ctx.get("best_score") # lower = better
489
+ best_combined = kb_ctx.get("best_combined") # higher = better
490
+
491
+ # Dynamic gating based on combined score primarily, distance secondarily
492
+ short_query = len((input_data.user_message or "").split()) <= 4
493
+ gate_combined_no_kb = 0.22 if short_query else 0.28 # below this → NO_KB
494
+ gate_combined_ok = 0.60 if short_query else 0.55 # above this → OK
495
+ gate_distance_no_kb = 2.0 # if semantic distance is very high, treat carefully
496
+
497
+ if (not context_found or not context.strip()) or (
498
+ (best_combined is None or best_combined < gate_combined_no_kb)
499
+ and (best_distance is None or best_distance >= gate_distance_no_kb)
500
+ ):
501
+ second_try = (input_data.prev_status or "").upper() == "NO_KB_MATCH"
502
+ clarify_text = (
503
+ _build_clarifying_message()
504
+ if not second_try
505
+ else "I still don’t find a relevant KB match for this scenario even after clarification.\n\n"
506
+ "Would you like me to raise a ServiceNow incident?"
507
+ )
508
+ return {
509
+ "bot_response": clarify_text,
510
+ "status": "NO_KB_MATCH",
511
+ "context_found": False,
512
+ "ask_resolved": False,
513
+ "suggest_incident": bool(second_try),
514
+ "followup": ("Please reply with the above details." if not second_try else "Shall I create a ticket now?"),
515
+ "top_hits": [],
516
+ "sources": [],
517
+ "debug": {"used_chunks": 0, "second_try": second_try, "best_distance": best_distance, "best_combined": best_combined},
518
+ }
519
+
520
+ # We have KB context → LLM rewrite (KB‑only, no Source lines)
521
+ threshold_ok = gate_combined_ok # use combined score as the primary signal
522
+ enhanced_prompt = (
523
+ "Rewrite the following knowledge base context into clear, actionable steps for the user's question. "
524
+ "Use ONLY the provided context; do NOT add information that is not present in it. "
525
+ "If the context is incomplete, say that the answer may be partial. "
526
+ "Do NOT include any 'Source:' or citation lines in your output.\n\n"
527
+ f"### Context\n{context}\n\n"
528
+ f"### Question\n{input_data.user_message}\n\n"
529
+ "### Output\n"
530
+ "- Provide numbered steps or concise bullets.\n"
531
+ "- Mention any prerequisites or validations only if present in the context.\n"
532
+ "- If context is insufficient, add a one-line note: 'This may be partial based on available KB.'\n"
533
+ )
534
+ headers = {"Content-Type": "application/json"}
535
+ payload = {"contents": [{"parts": [{"text": enhanced_prompt}]}]}
536
+ try:
537
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=VERIFY_SSL)
538
+ try:
539
+ result = resp.json()
540
+ except Exception:
541
+ result = {}
542
+ except Exception:
543
+ resp = type("RespStub", (), {"status_code": 0})()
544
+ result = {}
545
+
546
+ try:
547
+ bot_text = (
548
+ result["candidates"][0]["content"]["parts"][0]["text"]
549
+ if isinstance(result, dict) else ""
550
+ )
551
+ except Exception:
552
+ bot_text = ""
553
+
554
+ if not bot_text.strip():
555
+ bot_text = context
556
+
557
+ bot_text = _strip_any_source_lines(bot_text)
558
+ status = "OK" if (best_combined is not None and best_combined >= threshold_ok) else "PARTIAL"
559
+ lower = bot_text.lower()
560
+ if ("partial" in lower) or ("may be partial" in lower) or ("closest" in lower) or ("may not fully" in lower):
561
+ status = "PARTIAL"
562
+
563
+ ask_resolved = (status == "OK")
564
+ suggest_incident = False
565
+ followup = ("Does this match your scenario? I can refine the steps." if status == "PARTIAL" else None)
566
+
567
+ return {
568
+ "bot_response": bot_text,
569
+ "status": status,
570
+ "context_found": True,
571
+ "ask_resolved": ask_resolved,
572
+ "suggest_incident": suggest_incident,
573
+ "followup": followup,
574
+ "top_hits": [],
575
+ "sources": [],
576
+ "debug": {
577
+ "used_chunks": len(context.split("\n\n---\n\n")) if context else 0,
578
+ "best_distance": best_distance,
579
+ "best_combined": best_combined,
580
+ "http_status": getattr(resp, "status_code", 0),
581
+ },
582
+ }
583
+
584
+ except HTTPException:
585
+ raise
586
+ except Exception as e:
587
+ raise HTTPException(status_code=500, detail=str(e))
588
+
589
+
590
+ # ---------------------- Incident endpoints ----------------------
591
+ def _set_incident_resolved(sys_id: str) -> bool:
592
+ """
593
+ Robust resolver:
594
+ A) Try default fields (close_code/close_notes/caller_id) with state=6
595
+ B) If fails, try state="Resolved"
596
+ C) If still fails, try custom field names from env (e.g., u_resolution_code/u_resolution_notes)
597
+ Optional: pre-step to In Progress if SERVICENOW_REQUIRE_IN_PROGRESS_FIRST=true
598
+ Logs a short diagnostic line on failure so we can see the exact reason.
599
+ """
600
+ try:
601
+ # 1) Get an OAuth token the same way you already do for other ServiceNow calls
602
+ token = get_valid_token()
603
+ # 2) Read your instance URL from env
604
+ instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
605
+ if not instance_url:
606
+ print("[SN PATCH resolve] missing SERVICENOW_INSTANCE_URL")
607
+ return False
608
+
609
+ headers = {
610
+ "Authorization": f"Bearer {token}",
611
+ "Accept": "application/json",
612
+ "Content-Type": "application/json",
613
+ }
614
+ url = f"{instance_url}/api/now/table/incident/{sys_id}"
615
+
616
+ close_code_val = os.getenv("SERVICENOW_CLOSE_CODE", "Solution provided")
617
+ close_notes_val = os.getenv("SERVICENOW_RESOLUTION_NOTES", "Issue resolved, user confirmed")
618
+ caller_sysid = os.getenv("SERVICENOW_CALLER_SYSID")
619
+ resolved_by_sysid = os.getenv("SERVICENOW_RESOLVED_BY_SYSID") # optional
620
+ assign_group = os.getenv("SERVICENOW_ASSIGNMENT_GROUP_SYSID") # optional
621
+ require_progress = os.getenv("SERVICENOW_REQUIRE_IN_PROGRESS_FIRST", "false").lower() in ("1", "true", "yes")
622
+
623
+ # Optional pre-step: set state=2 (In Progress) if your org enforces transitions
624
+ if require_progress:
625
+ try:
626
+ resp1 = requests.patch(url, headers=headers, json={"state": "2"}, verify=VERIFY_SSL, timeout=25) # <-- use VERIFY_SSL
627
+ print(f"[SN PATCH progress] status={resp1.status_code} body={resp1.text[:500]}")
628
+ except Exception as e:
629
+ print(f"[SN PATCH progress] exception={str(e)}")
630
+
631
+ # Helper to clean payload
632
+ def clean(d: dict) -> dict:
633
+ return {k: v for k, v in d.items() if v is not None}
634
+
635
+ payload_A = clean({
636
+ "state": "6", # numeric Resolved
637
+ "close_code": close_code_val, # Resolution code (default field name)
638
+ "close_notes": close_notes_val, # Resolution notes (default field name)
639
+ "caller_id": caller_sysid, # mandatory in your org
640
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
641
+ "work_notes": "Auto-resolve set by NOVA.",
642
+ "resolved_by": resolved_by_sysid, # optional if BR requires who resolved
643
+ "assignment_group": assign_group, # optional if BR requires a group
644
+ })
645
+ respA = requests.patch(url, headers=headers, json=payload_A, verify=VERIFY_SSL, timeout=25)
646
+ if respA.status_code in (200, 204):
647
+ return True
648
+ print(f"[SN PATCH resolve A] status={respA.status_code} body={respA.text[:500]}")
649
+
650
+ # ---- B) PATCH with state label (some orgs map differently)
651
+ payload_B = clean({
652
+ "state": "Resolved", # label instead of numeric
653
+ "close_code": close_code_val,
654
+ "close_notes": close_notes_val,
655
+ "caller_id": caller_sysid,
656
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
657
+ "work_notes": "Auto-resolve set by NOVA.",
658
+ "resolved_by": resolved_by_sysid,
659
+ "assignment_group": assign_group,
660
+ })
661
+ respB = requests.patch(url, headers=headers, json=payload_B, verify=VERIFY_SSL, timeout=25)
662
+ if respB.status_code in (200, 204):
663
+ return True
664
+ print(f"[SN PATCH resolve B] status={respB.status_code} body={respB.text[:500]}")
665
+
666
+ # ---- C) PATCH using custom field names from env
667
+ # If your dictionary uses, for example, u_resolution_code/u_resolution_notes
668
+ code_field = os.getenv("SERVICENOW_RESOLUTION_CODE_FIELD", "close_code")
669
+ notes_field = os.getenv("SERVICENOW_RESOLUTION_NOTES_FIELD", "close_notes")
670
+ payload_C = clean({
671
+ "state": "6",
672
+ code_field: close_code_val,
673
+ notes_field: close_notes_val,
674
+ "caller_id": caller_sysid,
675
+ "resolved_at": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
676
+ "work_notes": "Auto-resolve set by NOVA.",
677
+ "resolved_by": resolved_by_sysid,
678
+ "assignment_group": assign_group,
679
+ })
680
+ respC = requests.patch(url, headers=headers, json=payload_C, verify=VERIFY_SSL, timeout=25)
681
+ if respC.status_code in (200, 204):
682
+ return True
683
+ print(f"[SN PATCH resolve C] status={respC.status_code} body={respC.text[:500]}")
684
+
685
+ # If all attempts failed, return False so the chat shows the soft message
686
+ return False
687
+ except Exception as e:
688
+ print(f"[SN PATCH resolve] exception={str(e)}")
689
+ return False
690
+
691
+
692
+ @app.post("/incident")
693
+ async def raise_incident(input_data: IncidentInput):
694
+ try:
695
+ result = create_incident(input_data.short_description, input_data.description)
696
+ if isinstance(result, dict) and not result.get("error"):
697
+ inc_number = result.get("number", "<unknown>")
698
+ sys_id = result.get("sys_id")
699
+ resolved_note = ""
700
+ if bool(input_data.mark_resolved) and sys_id not in ("<unknown>", None):
701
+ ok = _set_incident_resolved(sys_id)
702
+ resolved_note = " (marked Resolved)" if ok else " (could not mark Resolved; please update manually)"
703
+ ticket_text = f"Incident created: {inc_number}{resolved_note}"
704
+ else:
705
+ ticket_text = "Incident created."
706
+
707
+ # ✅ Do NOT include follow-up question inside bot_response to avoid duplication in UI.
708
+ # The front-end will show a Yes/No assist card after this.
709
+ return {
710
+ "bot_response": f"✅ {ticket_text}",
711
+ "debug": "Incident created via ServiceNow",
712
+ "persist": True,
713
+ "show_assist_card": True,
714
+ "followup": "Is there anything else I can assist you with?",
715
+ }
716
+ except Exception as e:
717
+ raise HTTPException(status_code=500, detail=str(e))
718
+
719
+
720
+ @app.post("/generate_ticket_desc")
721
+ async def generate_ticket_desc_ep(input_data: TicketDescInput):
722
+ try:
723
+ prompt = (
724
+ f"You are helping generate ServiceNow ticket descriptions based on the issue: {input_data.issue}.\n"
725
+ "Please return the output strictly in JSON format with the following keys:\n"
726
+ "{\n"
727
+ ' "ShortDescription": "A concise summary of the issue (max 100 characters)",\n'
728
+ ' "DetailedDescription": "A detailed explanation of the issue"\n'
729
+ "}\n"
730
+ "Do not include any extra text, comments, or explanations outside the JSON."
731
+ )
732
+ headers = {"Content-Type": "application/json"}
733
+ payload = {"contents": [{"parts": [{"text": prompt}]}]}
734
+ resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=25, verify=GEMINI_SSL_VERIFY)
735
+ try:
736
+ data = resp.json()
737
+ except Exception:
738
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini returned non-JSON"}
739
+
740
+ try:
741
+ text = data["candidates"][0]["content"]["parts"][0]["text"].strip()
742
+ except Exception:
743
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Gemini parsing failed"}
744
+
745
+ if text.startswith("```"):
746
+ lines = [ln for ln in text.splitlines() if not ln.strip().startswith("```")]
747
+ text = "\n".join(lines).strip()
748
+
749
+ try:
750
+ ticket_json = json.loads(text)
751
+ return {
752
+ "ShortDescription": ticket_json.get("ShortDescription", "").strip(),
753
+ "DetailedDescription": ticket_json.get("DetailedDescription", "").strip(),
754
+ }
755
+ except Exception:
756
+ return {"ShortDescription": "", "DetailedDescription": "", "error": "Invalid JSON returned"}
757
+ except Exception as e:
758
+ raise HTTPException(status_code=500, detail=str(e))
759
+
760
+
761
+ @app.post("/incident_status")
762
+ async def incident_status(input_data: TicketStatusInput):
763
+ try:
764
+ token = get_valid_token()
765
+ instance_url = os.getenv("SERVICENOW_INSTANCE_URL")
766
+ if not instance_url:
767
+ raise HTTPException(status_code=500, detail="SERVICENOW_INSTANCE_URL missing")
768
+
769
+ headers = {
770
+ "Authorization": f"Bearer {token}",
771
+ "Accept": "application/json",
772
+ }
773
+
774
+ if input_data.sys_id:
775
+ url = f"{instance_url}/api/now/table/incident/{input_data.sys_id}"
776
+ response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
777
+ data = response.json()
778
+ result = data.get("result", {}) if response.status_code == 200 else {}
779
+ elif input_data.number:
780
+ url = f"{instance_url}/api/now/table/incident?number={input_data.number}"
781
+ response = requests.get(url, headers=headers, verify=VERIFY_SSL, timeout=25)
782
+ data = response.json()
783
+ lst = data.get("result", [])
784
+ result = (lst or [{}])[0] if response.status_code == 200 else {}
785
+ else:
786
+ raise HTTPException(status_code=400, detail="Provide IncidentID (number) or sys_id")
787
+
788
+ state_code = str(result.get("state", "unknown"))
789
+ state_label = STATE_MAP.get(state_code, state_code)
790
+ short = result.get("short_description", "")
791
+ number = result.get("number", input_data.number or "unknown")
792
+
793
+ # �� Bold labels + hard line breaks so ReactMarkdown renders on separate lines
794
+ return {
795
+ "bot_response": (
796
+ f"**Ticket:** {number} \n"
797
+ f"**Status:** {state_label} \n"
798
+ f"**Issue description:** {short}"
799
+ ).replace("\n", " \n"), # ensure hard-breaks
800
+ "followup": "Is there anything else I can assist you with?",
801
+ "show_assist_card": True, # <— show Yes/No card from UI after status
802
+ "persist": True,
803
+ "debug": "Incident status fetched",
804
+ }
805
+ except Exception as e:
806
+ raise HTTPException(status_code=500, detail=str(e))