srilakshu012456 commited on
Commit
d52111a
·
verified ·
1 Parent(s): 4c0a264

Update main.py

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